From 4ab5ced11e2c479e87ecb0c70b6be66437a58afe Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Mon, 19 Oct 2026 11:43:35 +0200 Subject: [PATCH 01/81] Track llama-cpp chat and completion calls in the gguf integration The gguf integration patched only Llama.__call__, but create_chat_completion() runs llama.create_completion() through the chat handler, so chat apps produced no inference events. Patch create_completion instead: __call__, create_completion and create_chat_completion all run it, so each call is tracked once. patch_instance_call_once takes a method name (default __call__). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++ tests/test_integrations.py | 70 ++++++++++++++++++++++++++++++++++- wildedge/integrations/base.py | 10 ++++- wildedge/integrations/gguf.py | 8 +++- 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bce4574..efbc6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Fixed + +- The gguf integration now tracks `Llama.create_chat_completion()` and + `Llama.create_completion()`, streaming or not. It used to patch only + `Llama.__call__`, so chat apps on llama-cpp-python reported model loads but + no inference events. It now patches `create_completion`, which every entry + point runs, so each call is tracked once. + ## 0.2.2 - 2026-09-30 ### Added diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 9f6f7e3..16cd62a 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -75,16 +75,37 @@ def run(self, output_names, input_feed, run_options=None): class Llama: # must match the name check in GgufExtractor.can_handle + """Mirrors llama-cpp-python's call graph: every entry point runs create_completion.""" + model_path = "/models/llama-3-Q4_K_M.gguf" n_gpu_layers = 0 metadata = {"general.architecture": "llama", "general.version": "3.0"} - def __call__(self, *args, **kwargs): + def create_completion(self, prompt="", **kwargs): + if kwargs.get("stream"): + return iter( + [ + {"choices": [{"text": "hi", "finish_reason": None}]}, + { + "choices": [{"text": "!", "finish_reason": "stop"}], + "usage": {"prompt_tokens": 10, "completion_tokens": 20}, + }, + ] + ) return {"usage": {"prompt_tokens": 10, "completion_tokens": 20}} + def __call__(self, prompt="", **kwargs): + return self.create_completion(prompt=prompt, **kwargs) + + def create_chat_completion(self, messages, **kwargs): + # The chat handler formats the messages, then calls llama.create_completion. + prompt = "".join(m["content"] for m in messages) + result = self.create_completion(prompt=prompt, **kwargs) + return {"choices": [{"message": {"content": "hi"}}], "usage": result["usage"]} + class _FailingLlama(Llama): - def __call__(self, *args, **kwargs): + def create_completion(self, prompt="", **kwargs): raise RuntimeError("cuda oom") @@ -264,6 +285,51 @@ def test_install_hooks_tracks_error_on_exception(self, publish_spy): llm() assert publish_spy.events[0]["event_type"] == "error" + def test_install_hooks_tracks_chat_completion(self, publish_spy): + llm = Llama() + handle = make_handle(publish_spy) + self.extractor.install_hooks(llm, handle) + llm.create_chat_completion(messages=[{"role": "user", "content": "hello"}]) + assert len(publish_spy.events) == 1 + event = publish_spy.events[0] + assert event["event_type"] == "inference" + assert event["inference"]["output_meta"]["tokens_out"] == 20 + + def test_install_hooks_tracks_create_completion(self, publish_spy): + llm = Llama() + handle = make_handle(publish_spy) + self.extractor.install_hooks(llm, handle) + llm.create_completion(prompt="hello") + assert len(publish_spy.events) == 1 + assert publish_spy.events[0]["inference"]["input_meta"]["char_count"] == 5 + + def test_install_hooks_tracks_streamed_chat_completion(self, publish_spy): + llm = Llama() + handle = make_handle(publish_spy) + self.extractor.install_hooks(llm, handle) + chunks = list(llm.create_completion(prompt="hello", stream=True)) + assert len(chunks) == 2 + assert len(publish_spy.events) == 1 + meta = publish_spy.events[0]["inference"]["output_meta"] + assert meta["tokens_out"] == 20 + assert meta["stop_reason"] == "stop" + + def test_install_hooks_tracks_chat_completion_error(self, publish_spy): + llm = _FailingLlama() + handle = make_handle(publish_spy) + self.extractor.install_hooks(llm, handle) + with pytest.raises(RuntimeError): + llm.create_chat_completion(messages=[{"role": "user", "content": "hi"}]) + assert [e["event_type"] for e in publish_spy.events] == ["error"] + + def test_install_hooks_twice_does_not_double_track(self, publish_spy): + llm = Llama() + handle = make_handle(publish_spy) + self.extractor.install_hooks(llm, handle) + self.extractor.install_hooks(llm, handle) + llm.create_chat_completion(messages=[{"role": "user", "content": "hello"}]) + assert len(publish_spy.events) == 1 + # --------------------------------------------------------------------------- # Keras diff --git a/wildedge/integrations/base.py b/wildedge/integrations/base.py index b6e2c0e..963852d 100644 --- a/wildedge/integrations/base.py +++ b/wildedge/integrations/base.py @@ -20,7 +20,13 @@ def patch_instance_call_once( *, patch_name: str, make_patched_call: Callable[[Callable[..., Any]], Callable[..., Any]], + method: str = "__call__", ) -> None: + """Swap obj's class for a cached subclass whose ``method`` is wrapped. + + ``method`` defaults to ``__call__``; pass another name when the runtime's + entry points all funnel through a different method. + """ current_cls = type(obj) if getattr(current_cls, "__wildedge_patch_name__", None) == patch_name: return @@ -37,14 +43,14 @@ def patch_instance_call_once( patched_cls = per_class.get(patch_name) if patched_cls is None: - original_call = current_cls.__call__ + original_call = getattr(current_cls, method) patched_call = make_patched_call(original_call) class_name = f"{current_cls.__name__}WildEdgePatched{patch_name.title()}" patched_cls = type( class_name, (current_cls,), { - "__call__": patched_call, + method: patched_call, "__wildedge_patch_name__": patch_name, "__wildedge_original_call__": original_call, }, diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index dc44360..a730493 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -34,7 +34,10 @@ _llama_patched = False _LLAMA_PATCH_LOCK = threading.Lock() -GGUF_CALL_PATCH_NAME = "gguf_call" +# llama-cpp-python's __call__ and create_chat_completion (via the chat handler) +# both run create_completion, so patching it tracks every entry point once. +GGUF_COMPLETION_PATCH_NAME = "gguf_create_completion" +GGUF_PATCHED_METHOD = "create_completion" GGUF_HANDLE_ATTR = "__wildedge_gguf_handle__" LLAMA_AUTO_LOAD_PATCH_NAME = "gguf_auto_load" @@ -269,8 +272,9 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: setattr(obj, GGUF_HANDLE_ATTR, handle) patch_instance_call_once( obj, - patch_name=GGUF_CALL_PATCH_NAME, + patch_name=GGUF_COMPLETION_PATCH_NAME, make_patched_call=build_patched_call, + method=GGUF_PATCHED_METHOD, ) @classmethod From 1e8e19c2d7a6ea3ab70d1d2a517f14aea84c13c6 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Mon, 19 Oct 2026 14:40:46 +0200 Subject: [PATCH 02/81] README: PyPI badge, links to the app, docs and blog, and where the DSN comes from Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d5c3f5..b45abcf 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,20 @@ [![Tested on Linux](https://img.shields.io/badge/tested%20on-linux-blue)](https://github.com/wild-edge/wildedge-python/actions/workflows/ci.yml) [![Tested on macOS](https://img.shields.io/badge/tested%20on-macOS-blue)](https://github.com/wild-edge/wildedge-python/actions/workflows/ci.yml) [![Tested on Windows](https://img.shields.io/badge/tested%20on-windows-blue)](https://github.com/wild-edge/wildedge-python/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/wildedge-sdk)](https://pypi.org/project/wildedge-sdk/) [![Coverage](https://codecov.io/gh/wild-edge/wildedge-python/branch/main/graph/badge.svg)](https://codecov.io/gh/wild-edge/wildedge-python) ML inference monitoring for Python: on-device and remote models. Tracks latency, tokens, errors, and model metadata with no code changes, plus traces for agent pipelines. +**[Get a DSN](https://app.wildedge.dev)** · [Website](https://wildedge.dev) · [Docs](https://docs.wildedge.dev) · [Blog](https://blog.wildedge.dev) + ## Install ```bash uv add wildedge-sdk ``` -Without a DSN everything is a silent no-op, so dev and CI need no configuration. +To send events, sign up at [app.wildedge.dev](https://app.wildedge.dev), create a project and copy its DSN. Without a DSN everything is a silent no-op, so dev and CI need no configuration. ## CLI From 63ba1690df2769f7dc1e350ad69e2b64d66cfabf Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Mon, 19 Oct 2026 15:16:41 +0200 Subject: [PATCH 03/81] Update compatibility pins to current releases and test anthropic mlx 0.22.0 is gone from PyPI, which broke the daily run. Current pins move to the latest releases; numpy 2.2.6 is the newest that covers 3.10 to 3.13, with 3.10 overrides for onnxruntime and keras. Anthropic had pins and a test but was missing from the CI matrix. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/compat.yml | 2 +- docs/compatibility.md | 26 ++++++++++++++------ scripts/compat_matrix.py | 46 +++++++++++++++++++++--------------- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 3e267e3..e4ab14b 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -30,7 +30,7 @@ jobs: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] - integration: ["onnx", "torch", "timm", "tensorflow", "gguf", "openai", "transformers", "ultralytics"] + integration: ["anthropic", "onnx", "torch", "timm", "tensorflow", "gguf", "openai", "transformers", "ultralytics"] version-set: ["min", "current"] exclude: - python-version: "3.13" diff --git a/docs/compatibility.md b/docs/compatibility.md index 9f417fa..4f65d22 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -5,19 +5,31 @@ The table below is generated from `scripts/compat_matrix.py`. | Integration | Version set | Dependencies | Supported Python | |---|---|---|---| | `anthropic` | `min` | `anthropic==0.25.0` | 3.10, 3.11, 3.12, 3.13 | -| `anthropic` | `current` | `anthropic==0.92.0` | 3.10, 3.11, 3.12, 3.13 | +| `anthropic` | `current` | `anthropic==1.15.0` | 3.10, 3.11, 3.12, 3.13 | | `onnx` | `min` | `onnxruntime==1.18.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13, 3.14 | | `onnx` | `min` (`py3.13` override) | `onnxruntime==1.20.1`, `numpy==2.1.3` | 3.13 | -| `onnx` | `current` | `onnxruntime==1.20.1`, `numpy==2.1.3` | 3.10, 3.11, 3.12, 3.13, 3.14 | +| `onnx` | `current` | `onnxruntime==1.31.0`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13, 3.14 | +| `onnx` | `current` (`py3.10` override) | `onnxruntime==1.23.2`, `numpy==2.2.6` | 3.10 | | `torch` | `min` | `torch==2.4.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13, 3.14 | | `torch` | `min` (`py3.13` override) | `torch==2.6.0`, `numpy==2.1.3` | 3.13 | | `torch` | `min` (`py3.14` override) | `torch==2.5.0`, `numpy==2.1.3` | 3.14 | -| `torch` | `current` | `torch==2.6.0`, `numpy==2.1.3` | 3.10, 3.11, 3.12, 3.13, 3.14 | -| `torch` | `current` (`py3.14` override) | `torch==2.10.0`, `numpy==2.1.3` | 3.14 | +| `torch` | `current` | `torch==2.14.1`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13, 3.14 | +| `torch` | `current` (`py3.14` override) | `torch==2.14.1`, `numpy==2.5.4` | 3.14 | | `timm` | `min` | `torch==2.4.1`, `torchvision==0.19.1`, `timm==1.0.11`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13, 3.14 | | `timm` | `min` (`py3.13` override) | `torch==2.6.0`, `torchvision==0.21.0`, `timm==1.0.11`, `numpy==2.1.3` | 3.13 | | `timm` | `min` (`py3.14` override) | `torch==2.5.0`, `torchvision==0.19.1`, `timm==1.0.11`, `numpy==2.1.3` | 3.14 | -| `timm` | `current` | `torch==2.6.0`, `torchvision==0.21.0`, `timm==1.0.15`, `numpy==2.1.3` | 3.10, 3.11, 3.12, 3.13, 3.14 | -| `timm` | `current` (`py3.14` override) | `torch==2.10.0`, `torchvision==0.25.0`, `timm==1.0.15`, `numpy==2.1.3` | 3.14 | +| `timm` | `current` | `torch==2.14.1`, `torchvision==0.29.1`, `timm==1.0.30`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13, 3.14 | +| `timm` | `current` (`py3.14` override) | `torch==2.14.1`, `torchvision==0.29.1`, `timm==1.0.30`, `numpy==2.5.4` | 3.14 | | `tensorflow` | `min` | `tensorflow==2.16.1`, `keras==3.3.3`, `numpy==1.26.4` | 3.10, 3.11, 3.12 | -| `tensorflow` | `current` | `tensorflow==2.18.0`, `keras==3.8.0`, `numpy==2.0.2` | 3.10, 3.11, 3.12 | +| `tensorflow` | `current` | `tensorflow==2.21.0`, `keras==3.15.1`, `numpy==2.2.6` | 3.10, 3.11, 3.12 | +| `tensorflow` | `current` (`py3.10` override) | `tensorflow==2.21.0`, `keras==3.12.4`, `numpy==2.2.6` | 3.10 | +| `gguf` | `min` | `llama-cpp-python==0.2.90`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | +| `gguf` | `current` | `llama-cpp-python==0.3.36`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | +| `openai` | `min` | `openai==1.30.0` | 3.10, 3.11, 3.12, 3.13 | +| `openai` | `current` | `openai==3.35.1` | 3.10, 3.11, 3.12, 3.13 | +| `transformers` | `min` | `transformers==4.40.0`, `torch==2.4.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | +| `transformers` | `min` (`py3.13` override) | `transformers==4.45.0`, `torch==2.5.0`, `numpy==2.1.3` | 3.13 | +| `transformers` | `current` | `transformers==5.20.1`, `torch==2.14.1`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | +| `ultralytics` | `min` | `ultralytics==8.0.0`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | +| `ultralytics` | `current` | `ultralytics==8.4.182`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | +| `mlx` | `current` | `mlx==0.32.4`, `mlx-lm==0.32.2` | 3.12, 3.13 | diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 7cb5d22..94ba51a 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -10,15 +10,15 @@ MATRIX = { "anthropic": { "min": ["anthropic==0.25.0"], - "current": ["anthropic==0.92.0"], + "current": ["anthropic==1.15.0"], }, "onnx": { "min": ["onnxruntime==1.18.1", "numpy==1.26.4"], - "current": ["onnxruntime==1.20.1", "numpy==2.1.3"], + "current": ["onnxruntime==1.31.0", "numpy==2.2.6"], }, "torch": { "min": ["torch==2.4.1", "numpy==1.26.4"], - "current": ["torch==2.6.0", "numpy==2.1.3"], + "current": ["torch==2.14.1", "numpy==2.2.6"], }, "timm": { "min": [ @@ -28,34 +28,34 @@ "numpy==1.26.4", ], "current": [ - "torch==2.6.0", - "torchvision==0.21.0", - "timm==1.0.15", - "numpy==2.1.3", + "torch==2.14.1", + "torchvision==0.29.1", + "timm==1.0.30", + "numpy==2.2.6", ], }, "tensorflow": { "min": ["tensorflow==2.16.1", "keras==3.3.3", "numpy==1.26.4"], - "current": ["tensorflow==2.18.0", "keras==3.8.0", "numpy==2.0.2"], + "current": ["tensorflow==2.21.0", "keras==3.15.1", "numpy==2.2.6"], }, "gguf": { "min": ["llama-cpp-python==0.2.90", "numpy==1.26.4"], - "current": ["llama-cpp-python==0.3.4", "numpy==2.1.3"], + "current": ["llama-cpp-python==0.3.36", "numpy==2.2.6"], }, "openai": { "min": ["openai==1.30.0"], - "current": ["openai==1.61.0"], + "current": ["openai==3.35.1"], }, "transformers": { "min": ["transformers==4.40.0", "torch==2.4.1", "numpy==1.26.4"], - "current": ["transformers==4.47.0", "torch==2.6.0", "numpy==2.1.3"], + "current": ["transformers==5.20.1", "torch==2.14.1", "numpy==2.2.6"], }, "ultralytics": { "min": ["ultralytics==8.0.0", "numpy==1.26.4"], - "current": ["ultralytics==8.3.4", "numpy==2.1.3"], + "current": ["ultralytics==8.4.182", "numpy==2.2.6"], }, "mlx": { - "current": ["mlx==0.22.0", "mlx-lm==0.21.0"], + "current": ["mlx==0.32.4", "mlx-lm==0.32.2"], }, } @@ -77,7 +77,10 @@ "onnx": { "min": { "3.13": ["onnxruntime==1.20.1", "numpy==2.1.3"], - } + }, + "current": { + "3.10": ["onnxruntime==1.23.2", "numpy==2.2.6"], + }, }, "torch": { "min": { @@ -85,7 +88,12 @@ "3.14": ["torch==2.5.0", "numpy==2.1.3"], }, "current": { - "3.14": ["torch==2.10.0", "numpy==2.1.3"], + "3.14": ["torch==2.14.1", "numpy==2.5.4"], + }, + }, + "tensorflow": { + "current": { + "3.10": ["tensorflow==2.21.0", "keras==3.12.4", "numpy==2.2.6"], }, }, "transformers": { @@ -110,10 +118,10 @@ }, "current": { "3.14": [ - "torch==2.10.0", - "torchvision==0.25.0", - "timm==1.0.15", - "numpy==2.1.3", + "torch==2.14.1", + "torchvision==0.29.1", + "timm==1.0.30", + "numpy==2.5.4", ], }, }, From 04319c705ba3eb85daaa1d58d1edf43533ce7fb3 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 14:36:27 +0200 Subject: [PATCH 04/81] Record chat context, generation config and stop reason for gguf calls create_chat_completion now exposes its messages to the create_completion it runs, so chat events get prompt_type="chat" and turn_index while tracking stays in one place. Non-streamed calls report finish_reason, every call reports its sampling arguments with the method's defaults filled in, and with attachments enabled the prompt or messages and the reply are attached. Closes #64 --- CHANGELOG.md | 6 + tests/test_integrations.py | 115 ++++++++++++++++++- wildedge/integrations/base.py | 25 +++-- wildedge/integrations/gguf.py | 202 +++++++++++++++++++++++++++++++--- 4 files changed, 323 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efbc6c6..50efd7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ under Unreleased and move into a version section at release time. `Llama.__call__`, so chat apps on llama-cpp-python reported model loads but no inference events. It now patches `create_completion`, which every entry point runs, so each call is tracked once. +- gguf inference events now include the stop reason for non-streamed calls, + the generation config (`temperature`, `top_p`, `top_k`, `max_tokens`, + `repeat_penalty`, penalties, `seed`, stop sequence count), and, for chat + calls, `prompt_type="chat"` and `turn_index` measured on the last user + message. With attachments enabled, the prompt or chat messages and the reply + are attached. ## 0.2.2 - 2026-09-30 diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 16cd62a..033b550 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from unittest.mock import MagicMock, patch import pytest @@ -92,7 +93,10 @@ def create_completion(self, prompt="", **kwargs): }, ] ) - return {"usage": {"prompt_tokens": 10, "completion_tokens": 20}} + return { + "choices": [{"text": "hi!", "finish_reason": "length"}], + "usage": {"prompt_tokens": 10, "completion_tokens": 20}, + } def __call__(self, prompt="", **kwargs): return self.create_completion(prompt=prompt, **kwargs) @@ -101,7 +105,17 @@ def create_chat_completion(self, messages, **kwargs): # The chat handler formats the messages, then calls llama.create_completion. prompt = "".join(m["content"] for m in messages) result = self.create_completion(prompt=prompt, **kwargs) - return {"choices": [{"message": {"content": "hi"}}], "usage": result["usage"]} + if kwargs.get("stream"): + return result + return { + "choices": [ + { + "message": {"role": "assistant", "content": "hi!"}, + "finish_reason": "stop", + } + ], + "usage": result["usage"], + } class _FailingLlama(Llama): @@ -330,6 +344,103 @@ def test_install_hooks_twice_does_not_double_track(self, publish_spy): llm.create_chat_completion(messages=[{"role": "user", "content": "hello"}]) assert len(publish_spy.events) == 1 + def test_non_streaming_stop_reason(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + llm.create_completion(prompt="hello") + meta = publish_spy.events[0]["inference"]["output_meta"] + assert meta["stop_reason"] == "length" + + def test_generation_config_from_kwargs(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + llm.create_chat_completion( + messages=[{"role": "user", "content": "hello"}], + temperature=0.7, + top_p=0.9, + max_tokens=64, + repeat_penalty=1.1, + stop=["", "User:"], + ) + config = publish_spy.events[0]["inference"]["generation_config"] + assert config == { + "temperature": 0.7, + "top_p": 0.9, + "max_tokens": 64, + "repetition_penalty": 1.1, + "stop_sequences_count": 2, + } + + def test_chat_input_meta(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + llm.create_chat_completion( + messages=[ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "what time is it"}, + ] + ) + meta = publish_spy.events[0]["inference"]["input_meta"] + assert meta["prompt_type"] == "chat" + assert meta["turn_index"] == 1 + assert meta["word_count"] == 4 + assert meta["token_count"] == 10 + + def test_streamed_chat_input_meta(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + stream = llm.create_chat_completion( + messages=[{"role": "user", "content": "hello"}], stream=True + ) + list(stream) + assert len(publish_spy.events) == 1 + inference = publish_spy.events[0]["inference"] + assert inference["input_meta"]["prompt_type"] == "chat" + assert inference["output_meta"]["stop_reason"] == "stop" + + def test_completion_input_meta_is_not_chat(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + llm.create_completion(prompt="hello") + assert "prompt_type" not in publish_spy.events[0]["inference"]["input_meta"] + + def test_no_attachments_when_disabled(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + llm.create_chat_completion(messages=[{"role": "user", "content": "hello"}]) + assert "attachments" not in publish_spy.events[0] + + def test_chat_attachments_when_enabled(self, publish_spy): + captured = [] + + def capture(attachments, inference_id, timestamp): + captured.extend(attachments) + return [{"attachment_id": str(i)} for i in range(len(attachments))] + + llm = Llama() + handle = make_handle(publish_spy) + handle.capture_attachments = capture + self.extractor.install_hooks(llm, handle) + messages = [{"role": "user", "content": "hello"}] + llm.create_chat_completion(messages=messages) + assert [(a.role, a.content_type) for a in captured] == [ + ("input", "application/json"), + ("output", "text/plain"), + ] + assert json.loads(captured[0].data) == messages + assert captured[1].data == b"hi!" + + def test_streamed_completion_attaches_joined_reply(self, publish_spy): + captured = [] + llm = Llama() + handle = make_handle(publish_spy) + handle.capture_attachments = lambda a, *_: captured.extend(a) or [] + self.extractor.install_hooks(llm, handle) + list(llm.create_completion(prompt="hello", stream=True)) + assert [a.data for a in captured] == [b"hello", b"hi!"] + # --------------------------------------------------------------------------- # Keras diff --git a/wildedge/integrations/base.py b/wildedge/integrations/base.py index 963852d..e525bf9 100644 --- a/wildedge/integrations/base.py +++ b/wildedge/integrations/base.py @@ -21,11 +21,15 @@ def patch_instance_call_once( patch_name: str, make_patched_call: Callable[[Callable[..., Any]], Callable[..., Any]], method: str = "__call__", + extra_methods: dict[str, Callable[[Callable[..., Any]], Callable[..., Any]]] + | None = None, ) -> None: """Swap obj's class for a cached subclass whose ``method`` is wrapped. ``method`` defaults to ``__call__``; pass another name when the runtime's - entry points all funnel through a different method. + entry points all funnel through a different method. ``extra_methods`` maps + more method names to wrapper factories, patched in the same subclass so a + repeat call with the same ``patch_name`` stays a no-op. """ current_cls = type(obj) if getattr(current_cls, "__wildedge_patch_name__", None) == patch_name: @@ -45,16 +49,17 @@ def patch_instance_call_once( if patched_cls is None: original_call = getattr(current_cls, method) patched_call = make_patched_call(original_call) + namespace: dict[str, Any] = { + method: patched_call, + "__wildedge_patch_name__": patch_name, + "__wildedge_original_call__": original_call, + } + for extra_name, make_extra in (extra_methods or {}).items(): + original_extra = getattr(current_cls, extra_name, None) + if original_extra is not None: + namespace[extra_name] = make_extra(original_extra) class_name = f"{current_cls.__name__}WildEdgePatched{patch_name.title()}" - patched_cls = type( - class_name, - (current_cls,), - { - method: patched_call, - "__wildedge_patch_name__": patch_name, - "__wildedge_original_call__": original_call, - }, - ) + patched_cls = type(class_name, (current_cls,), namespace) per_class[patch_name] = patched_cls obj.__class__ = patched_cls # type: ignore[assignment] diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index a730493..f024766 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -2,7 +2,10 @@ from __future__ import annotations +import contextvars import functools +import inspect +import json import re import threading import time @@ -10,9 +13,18 @@ from typing import TYPE_CHECKING from wildedge import constants -from wildedge.events.inference import GenerationOutputMeta, TextInputMeta +from wildedge.attachments import Attachment +from wildedge.events.inference import ( + GenerationConfig, + GenerationOutputMeta, + TextInputMeta, +) from wildedge.integrations.base import BaseExtractor, patch_instance_call_once -from wildedge.integrations.common import SyncStreamWrapper, debug_failure +from wildedge.integrations.common import ( + SyncStreamWrapper, + build_input_meta, + debug_failure, +) from wildedge.logging import logger from wildedge.model import ModelInfo from wildedge.platforms import CURRENT_PLATFORM @@ -38,6 +50,7 @@ # both run create_completion, so patching it tracks every entry point once. GGUF_COMPLETION_PATCH_NAME = "gguf_create_completion" GGUF_PATCHED_METHOD = "create_completion" +GGUF_CHAT_METHOD = "create_chat_completion" GGUF_HANDLE_ATTR = "__wildedge_gguf_handle__" LLAMA_AUTO_LOAD_PATCH_NAME = "gguf_auto_load" @@ -72,7 +85,24 @@ def parse_quantization(filename: str) -> str | None: return None -def make_gguf_input_meta(prompt: object, tokens_in: int | None) -> TextInputMeta | None: +# Messages of the create_chat_completion call in progress, so the +# create_completion it runs can record the event as a chat turn. +_chat_messages: contextvars.ContextVar[list | None] = contextvars.ContextVar( + "wildedge_gguf_chat_messages", default=None +) + + +def make_gguf_input_meta( + prompt: object, tokens_in: int | None, messages: list | None = None +) -> TextInputMeta | None: + if messages is not None: + meta = build_input_meta(messages, tokens_in) + if meta is not None: + user_turns = sum( + 1 for m in messages if isinstance(m, dict) and m.get("role") == "user" + ) + meta.turn_index = max(user_turns - 1, 0) + return meta if not isinstance(prompt, str) or not prompt: return None return TextInputMeta( @@ -89,7 +119,7 @@ def make_gguf_output_meta( ttft_ms: int | None, duration_ms: int, ) -> GenerationOutputMeta | None: - if tokens_out is None and ttft_ms is None: + if tokens_out is None and ttft_ms is None and stop_reason is None: return None tps = ( round(tokens_out / duration_ms * 1000, 1) @@ -106,7 +136,82 @@ def make_gguf_output_meta( ) -def make_gguf_stream_callbacks(handle: object, prompt: object) -> tuple: +def make_gguf_generation_config(kwargs: dict) -> GenerationConfig | None: + stop = kwargs.get("stop") + if isinstance(stop, str): + stop_count: int | None = 1 + elif isinstance(stop, (list, tuple)): + stop_count = len(stop) or None + else: + stop_count = None + config = GenerationConfig( + temperature=kwargs.get("temperature"), + top_p=kwargs.get("top_p"), + top_k=kwargs.get("top_k"), + max_tokens=kwargs.get("max_tokens"), + repetition_penalty=kwargs.get("repeat_penalty"), + frequency_penalty=kwargs.get("frequency_penalty"), + presence_penalty=kwargs.get("presence_penalty"), + seed=kwargs.get("seed"), + stop_sequences_count=stop_count, + ) + return config if config.to_dict() else None + + +def make_gguf_attachments( + handle: object, prompt: object, messages: list | None, reply: str | None +) -> list[Attachment] | None: + """Prompt (or chat messages) and reply, only when attachments are enabled.""" + if getattr(handle, "capture_attachments", None) is None: + return None + attachments: list[Attachment] = [] + try: + if messages is not None: + attachments.append( + Attachment( + content_type="application/json", + role="input", + data=json.dumps(messages, default=str).encode(), + name="messages.json", + ) + ) + elif isinstance(prompt, str) and prompt: + attachments.append( + Attachment( + content_type="text/plain", + role="input", + data=prompt.encode(), + name="prompt.txt", + ) + ) + if reply: + attachments.append( + Attachment( + content_type="text/plain", + role="output", + data=reply.encode(), + name="reply.txt", + ) + ) + except Exception as exc: + debug_gguf_failure("attachment build", exc) + return attachments or None + + +def _choice_text(choice: dict) -> str | None: + text = choice.get("text") + if text is None: + message = choice.get("message") or choice.get("delta") or {} + text = message.get("content") if isinstance(message, dict) else None + return text if isinstance(text, str) else None + + +def make_gguf_stream_callbacks( + handle: object, + prompt: object, + messages: list | None = None, + generation_config: GenerationConfig | None = None, +) -> tuple: """Return (on_chunk, on_done) callbacks for a llama-cpp-python streaming response. Chunks are dicts; usage appears in the final chunk when available. @@ -114,6 +219,7 @@ def make_gguf_stream_callbacks(handle: object, prompt: object) -> tuple: tokens_in: list[int | None] = [None] tokens_out: list[int | None] = [None] stop_reason: list[str | None] = [None] + reply_parts: list[str] = [] def on_chunk(chunk: object) -> None: if not isinstance(chunk, dict): @@ -123,10 +229,13 @@ def on_chunk(chunk: object) -> None: tokens_in[0] = usage.get("prompt_tokens") tokens_out[0] = usage.get("completion_tokens") choices = chunk.get("choices") or [] - if choices: + if choices and isinstance(choices[0], dict): reason = choices[0].get("finish_reason") if reason: stop_reason[0] = reason + text = _choice_text(choices[0]) + if text: + reply_parts.append(text) def on_done(duration_ms: int, ttft_ms: int | None) -> None: ti, to, sr = tokens_in[0], tokens_out[0], stop_reason[0] @@ -134,47 +243,94 @@ def on_done(duration_ms: int, ttft_ms: int | None) -> None: duration_ms=duration_ms, input_modality="text", output_modality="generation", - input_meta=make_gguf_input_meta(prompt, ti), + input_meta=make_gguf_input_meta(prompt, ti, messages), success=True, output_meta=make_gguf_output_meta(ti, to, sr, ttft_ms, duration_ms), + generation_config=generation_config, + attachments=make_gguf_attachments( + handle, prompt, messages, "".join(reply_parts) + ), ) return on_chunk, on_done +def call_arguments(signature, self_inner, args, kwargs) -> dict: + """Arguments of the call with the method's defaults filled in. + + The chat handler and __call__ pass every sampling argument explicitly; this + gives a direct create_completion call the same generation config. + """ + if signature is not None: + try: + bound = signature.bind(self_inner, *args, **kwargs) + bound.apply_defaults() + arguments = dict(bound.arguments) + arguments.update(arguments.pop("kwargs", None) or {}) + return arguments + except Exception as exc: + debug_gguf_failure("argument binding", exc) + arguments = dict(kwargs) + if args: + arguments.setdefault("prompt", args[0]) + return arguments + + def build_patched_call(original_call): + try: + signature = inspect.signature(original_call) + except (TypeError, ValueError): + signature = None + def patched_call(self_inner, *args, **kwargs): handle = getattr(self_inner, GGUF_HANDLE_ATTR, None) if handle is None: return original_call(self_inner, *args, **kwargs) - prompt = args[0] if args else kwargs.get("prompt", "") - is_streaming: bool = bool(kwargs.get("stream", False)) + arguments = call_arguments(signature, self_inner, args, kwargs) + prompt = arguments.get("prompt", "") + messages = _chat_messages.get() + is_streaming: bool = bool(arguments.get("stream", False)) + generation_config = None + try: + generation_config = make_gguf_generation_config(arguments) + except Exception as exc: + debug_gguf_failure("generation config extraction", exc) t0 = time.perf_counter() try: result = original_call(self_inner, *args, **kwargs) if is_streaming: - on_chunk, on_done = make_gguf_stream_callbacks(handle, prompt) + on_chunk, on_done = make_gguf_stream_callbacks( + handle, prompt, messages, generation_config + ) return SyncStreamWrapper(result, handle, t0, on_chunk, on_done) duration_ms = elapsed_ms(t0) tokens_in = None tokens_out = None + stop_reason = None + reply = None try: if isinstance(result, dict): - usage = result.get("usage", {}) + usage = result.get("usage") or {} tokens_in = usage.get("prompt_tokens") tokens_out = usage.get("completion_tokens") + choices = result.get("choices") or [] + if choices and isinstance(choices[0], dict): + stop_reason = choices[0].get("finish_reason") + reply = _choice_text(choices[0]) except Exception as exc: debug_gguf_failure("usage extraction", exc) handle.track_inference( duration_ms=duration_ms, input_modality="text", output_modality="generation", - input_meta=make_gguf_input_meta(prompt, tokens_in), + input_meta=make_gguf_input_meta(prompt, tokens_in, messages), success=True, output_meta=make_gguf_output_meta( - tokens_in, tokens_out, None, None, duration_ms + tokens_in, tokens_out, stop_reason, None, duration_ms ), + generation_config=generation_config, + attachments=make_gguf_attachments(handle, prompt, messages, reply), ) return result except Exception as exc: @@ -187,6 +343,25 @@ def patched_call(self_inner, *args, **kwargs): return patched_call +def build_patched_chat(original_chat): + """Expose the chat messages to the create_completion call the handler makes. + + Tracking itself stays in create_completion, so each call is counted once. + """ + + def patched_chat(self_inner, *args, **kwargs): + if getattr(self_inner, GGUF_HANDLE_ATTR, None) is None: + return original_chat(self_inner, *args, **kwargs) + messages = args[0] if args else kwargs.get("messages") + token = _chat_messages.set(list(messages) if messages else None) + try: + return original_chat(self_inner, *args, **kwargs) + finally: + _chat_messages.reset(token) + + return patched_chat + + class GgufExtractor(BaseExtractor): # llama-cpp-python's Llama class is the universal GGUF loader and covers Mistral, # Phi, Gemma, etc. Model family is read from general.architecture in GGUF metadata. @@ -275,6 +450,7 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: patch_name=GGUF_COMPLETION_PATCH_NAME, make_patched_call=build_patched_call, method=GGUF_PATCHED_METHOD, + extra_methods={GGUF_CHAT_METHOD: build_patched_chat}, ) @classmethod From f48992b4fd5b45beb6138d8d2078e8184a66cf74 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 14:44:11 +0200 Subject: [PATCH 05/81] Cover the gguf integration's fallback and error paths --- tests/test_integrations.py | 110 +++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 033b550..fbffefe 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -7,8 +7,14 @@ import pytest +from wildedge.integrations import gguf as gguf_integration from wildedge.integrations.gguf import ( GgufExtractor, + call_arguments, + make_gguf_attachments, + make_gguf_generation_config, + make_gguf_output_meta, + make_gguf_stream_callbacks, parse_quantization, ) from wildedge.integrations.gguf import ( @@ -432,6 +438,110 @@ def capture(attachments, inference_id, timestamp): assert json.loads(captured[0].data) == messages assert captured[1].data == b"hi!" + def test_stop_given_as_string_counts_one_sequence(self): + config = make_gguf_generation_config({"stop": ""}) + assert config.stop_sequences_count == 1 + + def test_generation_config_none_without_sampling_arguments(self): + assert make_gguf_generation_config({"stop": []}) is None + + def test_output_meta_none_without_tokens_ttft_or_stop_reason(self): + assert make_gguf_output_meta(None, None, None, None, 10) is None + + def test_call_arguments_fill_defaults_and_flatten_kwargs(self): + def create_completion(self, prompt, temperature=0.8, **kwargs): + pass + + import inspect + + args = call_arguments( + inspect.signature(create_completion), object(), ("hi",), {"seed": 1} + ) + assert args["prompt"] == "hi" + assert args["temperature"] == 0.8 + assert args["seed"] == 1 + assert "kwargs" not in args + + def test_call_arguments_fall_back_when_binding_fails(self): + import inspect + + def create_completion(self, prompt): + pass + + args = call_arguments( + inspect.signature(create_completion), object(), ("hi", "extra"), {} + ) + assert args == {"prompt": "hi"} + + def test_call_arguments_without_signature(self): + assert call_arguments(None, object(), (), {"prompt": "p", "top_p": 0.5}) == { + "prompt": "p", + "top_p": 0.5, + } + + def test_patched_call_survives_unreadable_signature(self, publish_spy): + with patch.object( + gguf_integration.inspect, "signature", side_effect=ValueError("no sig") + ): + patched = gguf_integration.build_patched_call(Llama.create_completion) + llm = Llama() + setattr(llm, gguf_integration.GGUF_HANDLE_ATTR, make_handle(publish_spy)) + patched(llm, "hello", temperature=0.1) + event = publish_spy.events[0]["inference"] + assert event["input_meta"]["char_count"] == 5 + assert event["generation_config"] == {"temperature": 0.1} + + def test_generation_config_error_does_not_block_tracking(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + with patch.object( + gguf_integration, + "make_gguf_generation_config", + side_effect=RuntimeError("boom"), + ): + llm.create_completion(prompt="hello") + assert len(publish_spy.events) == 1 + assert "generation_config" not in publish_spy.events[0]["inference"] + + def test_malformed_usage_still_tracks(self, publish_spy): + class OddLlama(Llama): + def create_completion(self, prompt="", **kwargs): + return {"usage": "n/a"} + + llm = OddLlama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + llm.create_completion(prompt="hello") + assert len(publish_spy.events) == 1 + + def test_chat_without_handle_is_untracked(self, publish_spy): + llm = Llama() + self.extractor.install_hooks(llm, make_handle(publish_spy)) + setattr(llm, gguf_integration.GGUF_HANDLE_ATTR, None) + result = llm.create_chat_completion(messages=[{"role": "user", "content": "x"}]) + assert result["choices"][0]["message"]["content"] == "hi!" + assert publish_spy.events == [] + + def test_stream_callbacks_read_chat_deltas_and_skip_non_dicts(self, publish_spy): + captured = [] + handle = make_handle(publish_spy) + handle.capture_attachments = lambda a, *_: captured.extend(a) or [] + on_chunk, on_done = make_gguf_stream_callbacks(handle, "p") + on_chunk("not a dict") + on_chunk({"choices": [{"delta": {"role": "assistant"}}]}) + on_chunk({"choices": [{"delta": {"content": "he"}}]}) + on_chunk({"choices": [{"delta": {"content": "y"}, "finish_reason": "stop"}]}) + on_done(10, 2) + assert captured[-1].data == b"hey" + meta = publish_spy.events[0]["inference"]["output_meta"] + assert meta["stop_reason"] == "stop" + + def test_attachment_build_error_is_swallowed(self, publish_spy): + handle = make_handle(publish_spy) + handle.capture_attachments = lambda *a: [] + circular: list = [] + circular.append(circular) + assert make_gguf_attachments(handle, "p", [circular], "reply") is None + def test_streamed_completion_attaches_joined_reply(self, publish_spy): captured = [] llm = Llama() From 2f073776ab2bf87cf8e7ba71ca342041ed4daef8 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 15:21:21 +0200 Subject: [PATCH 06/81] Make compat tests run each integration and fail instead of skipping Every compat test used importorskip, so a broken install skipped and the job passed (ultralytics min on 3.12/3.13 did), and half of them only called instrument(). Each test now loads a real model with the pinned library and checks the load and inference events, without network access: canned HTTP for openai and anthropic, small committed ONNX and GGUF files, and models built from config for the rest. A conftest hook fails any skip. Bugs found are marked xfail(strict=True) with their issue: #64, #70, #71, #72, #73. tests/compat is left out of the regular pytest run. --- pyproject.toml | 3 + tests/compat/conftest.py | 80 +++++++++++++- tests/compat/fixtures/README.md | 8 ++ tests/compat/fixtures/identity.onnx | Bin 0 -> 89 bytes tests/compat/fixtures/make_onnx_fixture.py | 26 +++++ tests/compat/test_anthropic_compat.py | 71 ++++++++++++- tests/compat/test_gguf_compat.py | 42 +++++++- tests/compat/test_mlx_compat.py | 61 +++++++++-- tests/compat/test_onnx_compat.py | 25 ++++- tests/compat/test_openai_compat.py | 117 ++++++++++++++++++++- tests/compat/test_tensorflow_compat.py | 14 ++- tests/compat/test_timm_compat.py | 29 +++-- tests/compat/test_torch_compat.py | 15 ++- tests/compat/test_transformers_compat.py | 63 ++++++++--- tests/compat/test_ultralytics_compat.py | 54 +++++++++- 15 files changed, 546 insertions(+), 62 deletions(-) create mode 100644 tests/compat/fixtures/README.md create mode 100644 tests/compat/fixtures/identity.onnx create mode 100644 tests/compat/fixtures/make_onnx_fixture.py diff --git a/pyproject.toml b/pyproject.toml index a378272..6c1ef75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,9 @@ ban-relative-imports = "all" [tool.pytest.ini_options] testpaths = ["tests"] +# tests/compat needs the pinned ML libraries; the Compatibility workflow runs +# it file by file (scripts/compat_matrix.py). +norecursedirs = [".*", "*.egg", "build", "dist", "node_modules", "venv", "compat"] log_level = "ERROR" asyncio_mode = "auto" markers = [ diff --git a/tests/compat/conftest.py b/tests/compat/conftest.py index a42f63a..628a6a2 100644 --- a/tests/compat/conftest.py +++ b/tests/compat/conftest.py @@ -1,5 +1,15 @@ +"""Shared fixtures for the compatibility suite. + +These tests run against real, pinned library versions (see +scripts/compat_matrix.py). Import the library directly: a missing or broken +install must fail the job, never skip it. +""" + from __future__ import annotations +import importlib +from pathlib import Path +from types import ModuleType from unittest.mock import patch import pytest @@ -7,6 +17,33 @@ import wildedge from wildedge.platforms.device_info import DeviceInfo +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Fail any skipped test: a skip here means a dependency didn't install. + + Expected failures (xfail) are still allowed; they name a known bug. + """ + outcome = yield + report = outcome.get_result() + if report.skipped and not hasattr(report, "wasxfail"): + report.outcome = "failed" + report.longrepr = f"compat tests must not skip: {report.longrepr}" + + +def sdk_http_module(package: str) -> ModuleType: + """The HTTP library an API SDK is built on: httpx, or httpx2 in newer releases. + + Tests pass a client with a MockTransport from that same library, so no + request leaves the machine. + """ + base_client = importlib.import_module(f"{package}._base_client") + module = getattr(base_client, "httpx2", None) or base_client.httpx + assert hasattr(module, "MockTransport"), module + return module + class _DummyConsumer: def __init__(self, *args, **kwargs): @@ -15,7 +52,7 @@ def __init__(self, *args, **kwargs): def flush(self, timeout: float = 5.0) -> None: pass - def close(self) -> None: + def close(self, timeout: float | None = None) -> None: pass def _pause(self) -> None: @@ -30,8 +67,33 @@ def __init__(self, *args, **kwargs): pass -@pytest.fixture -def compat_client(): +class CompatClient: + """A WildEdge client that records published events instead of sending them.""" + + def __init__(self, client: wildedge.WildEdge) -> None: + self.client = client + self.events: list[dict] = [] + # Handles keep a reference to client.publish, so swap it before any + # model is registered. + client.publish = self.events.append # type: ignore[method-assign] + + def __getattr__(self, name: str): + return getattr(self.client, name) + + def of_type(self, event_type: str) -> list[dict]: + return [e for e in self.events if e["event_type"] == event_type] + + def one(self, event_type: str) -> dict: + found = self.of_type(event_type) + types = [e["event_type"] for e in self.events] + assert len(found) == 1, f"expected one {event_type} event, got {types}" + return found[0] + + +@pytest.fixture(scope="session") +def _session_client(): + # One client per process, as in a real app: integration patches are + # process-wide and stay bound to the client that installed them (#74). with ( patch( "wildedge.client.detect_device", @@ -43,6 +105,16 @@ def compat_client(): client = wildedge.WildEdge( dsn="https://compat-secret@ingest.wildedge.dev/compat-project", app_version="compat", + enable_offline_persistence=False, + sampling_interval_s=0, ) - yield client + wrapped = CompatClient(client) + yield wrapped client.close() + + +@pytest.fixture +def compat_client(_session_client): + """The shared client, with the events of earlier tests cleared.""" + _session_client.events.clear() + return _session_client diff --git a/tests/compat/fixtures/README.md b/tests/compat/fixtures/README.md new file mode 100644 index 0000000..bf67dff --- /dev/null +++ b/tests/compat/fixtures/README.md @@ -0,0 +1,8 @@ +# Compatibility test fixtures + +- `identity.onnx`: a one-node ONNX graph, generated by `make_onnx_fixture.py`. +- `stories260K-f32.gguf`: the 260K-parameter TinyStories Llama from + [karpathy/llama2.c](https://github.com/karpathy/llama2.c) (MIT), converted + to GGUF by ggml-org (`tinyllamas/stories260K.gguf` on Hugging Face). + sha256 `270cba1bd5109f42d03350f60406024560464db173c0e387d91f0426d3bd256d`. + It is committed rather than downloaded so the gguf job needs no network. diff --git a/tests/compat/fixtures/identity.onnx b/tests/compat/fixtures/identity.onnx new file mode 100644 index 0000000000000000000000000000000000000000..01be959f4ef402af32b60fe9440a6d135badd3dc GIT binary patch literal 89 zcmdT)bUC@bzi$Q=F098Q`FaQ7m literal 0 HcmV?d00001 diff --git a/tests/compat/fixtures/make_onnx_fixture.py b/tests/compat/fixtures/make_onnx_fixture.py new file mode 100644 index 0000000..006525b --- /dev/null +++ b/tests/compat/fixtures/make_onnx_fixture.py @@ -0,0 +1,26 @@ +"""Regenerate identity.onnx, the model the onnx compat test loads. + + uv run --with onnx python tests/compat/fixtures/make_onnx_fixture.py + +IR version 8 and opset 13 keep it loadable by the oldest onnxruntime in the +compatibility matrix. +""" + +from __future__ import annotations + +from pathlib import Path + +import onnx +from onnx import TensorProto, helper + +graph = helper.make_graph( + [helper.make_node("Identity", ["x"], ["y"])], + "compat_identity", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, 4])], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, [1, 4])], +) +model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)]) +model.ir_version = 8 +model.model_version = 3 +onnx.checker.check_model(model) +onnx.save(model, Path(__file__).with_name("identity.onnx")) diff --git a/tests/compat/test_anthropic_compat.py b/tests/compat/test_anthropic_compat.py index 0b7fadc..93a7566 100644 --- a/tests/compat/test_anthropic_compat.py +++ b/tests/compat/test_anthropic_compat.py @@ -1,9 +1,72 @@ from __future__ import annotations -import pytest +import anthropic +from conftest import sdk_http_module +httpx = sdk_http_module("anthropic") -def test_anthropic_import_and_instrument(compat_client): - anthropic = pytest.importorskip("anthropic") - assert getattr(anthropic, "__version__", None) +MESSAGE = { + "id": "msg_compat", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "Hello there"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 3}, +} + + +def _client(handler) -> anthropic.Anthropic: + return anthropic.Anthropic( + api_key="sk-ant-compat", + max_retries=0, + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + +def test_message_is_tracked(compat_client): + compat_client.instrument("anthropic") + client = _client(lambda request: httpx.Response(200, json=MESSAGE)) + + result = client.messages.create( + model="claude-haiku-4-5", + max_tokens=32, + messages=[{"role": "user", "content": "Say hello"}], + ) + assert result.content[0].text == "Hello there" + + inference = compat_client.one("inference") + assert inference["model_id"] == "claude-haiku-4-5" + output = inference["inference"]["output_meta"] + assert output["tokens_in"] == 12 + assert output["tokens_out"] == 3 + assert output["stop_reason"] == "end_turn" + assert compat_client.registry.models["claude-haiku-4-5"].model_format == "api" + + +def test_api_error_is_tracked(compat_client): compat_client.instrument("anthropic") + client = _client( + lambda request: httpx.Response( + 529, + json={ + "type": "error", + "error": {"type": "overloaded_error", "message": "Overloaded"}, + }, + ) + ) + try: + client.messages.create( + model="claude-haiku-4-5", + max_tokens=8, + messages=[{"role": "user", "content": "x"}], + ) + except anthropic.APIStatusError: + pass + else: + raise AssertionError("expected APIStatusError") + + error = compat_client.one("error") + assert error["error"]["http_status"] == 529 + assert error["error"]["provider_error_code"] == "overloaded_error" diff --git a/tests/compat/test_gguf_compat.py b/tests/compat/test_gguf_compat.py index 9512db6..4ca0634 100644 --- a/tests/compat/test_gguf_compat.py +++ b/tests/compat/test_gguf_compat.py @@ -1,9 +1,45 @@ from __future__ import annotations +import llama_cpp import pytest +from conftest import FIXTURES +MODEL = FIXTURES / "stories260K-f32.gguf" -def test_gguf_import_and_instrument(compat_client): - llama_cpp = pytest.importorskip("llama_cpp") - assert hasattr(llama_cpp, "Llama") + +@pytest.fixture +def llm(compat_client): compat_client.instrument("gguf") + return llama_cpp.Llama(model_path=str(MODEL), n_ctx=128, seed=1, verbose=False) + + +def test_llama_load_is_tracked(compat_client, llm): + load = compat_client.one("model_load") + assert load["model_id"] == "stories260K-f32" + info = compat_client.registry.models["stories260K-f32"] + assert info.model_format == "gguf" + assert info.model_family == "llama" + assert info.quantization == "f32" + + +def test_llama_call_is_tracked(compat_client, llm): + result = llm("Once upon a time", max_tokens=4) + assert result["choices"][0]["text"] is not None + + inference = compat_client.one("inference") + assert inference["model_id"] == "stories260K-f32" + output = inference["inference"]["output_meta"] + assert output["tokens_in"] == result["usage"]["prompt_tokens"] + assert output["tokens_out"] == result["usage"]["completion_tokens"] + + +@pytest.mark.xfail( + strict=True, + reason="create_completion and create_chat_completion are not tracked (#64)", +) +def test_chat_and_completion_calls_are_tracked(compat_client, llm): + llm.create_completion("Once upon a time", max_tokens=4) + llm.create_chat_completion( + messages=[{"role": "user", "content": "Tell me a story"}], max_tokens=4 + ) + assert len(compat_client.of_type("inference")) == 2 diff --git a/tests/compat/test_mlx_compat.py b/tests/compat/test_mlx_compat.py index d66db56..96e6977 100644 --- a/tests/compat/test_mlx_compat.py +++ b/tests/compat/test_mlx_compat.py @@ -1,17 +1,60 @@ from __future__ import annotations +import json + +import mlx.core as mx +import mlx_lm import pytest +from mlx.utils import tree_flatten +from mlx_lm.models import llama +from tokenizers import Tokenizer, models, pre_tokenizers +from transformers import PreTrainedTokenizerFast + +VOCAB = ["", "", "", "once", "upon", "a", "time", "there", "was"] + + +@pytest.fixture(scope="module") +def tiny_model_dir(tmp_path_factory): + """A random-weight two-layer Llama with a word-level tokenizer, built locally.""" + path = tmp_path_factory.mktemp("tiny-llama") + config = { + "model_type": "llama", + "hidden_size": 16, + "num_hidden_layers": 2, + "intermediate_size": 32, + "num_attention_heads": 2, + "num_key_value_heads": 2, + "rms_norm_eps": 1e-5, + "vocab_size": len(VOCAB), + "tie_word_embeddings": True, + } + (path / "config.json").write_text(json.dumps(config)) + model = llama.Model(llama.ModelArgs.from_dict(config)) + mx.save_safetensors( + str(path / "model.safetensors"), dict(tree_flatten(model.parameters())) + ) + tokenizer = Tokenizer( + models.WordLevel({w: i for i, w in enumerate(VOCAB)}, unk_token="") + ) + tokenizer.pre_tokenizer = pre_tokenizers.Whitespace() + PreTrainedTokenizerFast( + tokenizer_object=tokenizer, + unk_token="", + bos_token="", + eos_token="", + ).save_pretrained(path) + return path -def test_mlx_import_and_instrumentation(compat_client): - pytest.importorskip("mlx_lm") - mx = pytest.importorskip("mlx.core") - nn = pytest.importorskip("mlx.nn") +def test_mlx_lm_load_and_generate_are_tracked(compat_client, tiny_model_dir): compat_client.instrument("mlx") + model, tokenizer = mlx_lm.load(str(tiny_model_dir)) + text = mlx_lm.generate(model, tokenizer, prompt="once upon a time", max_tokens=3) + assert isinstance(text, str) - model = nn.Linear(4, 2) - x = mx.ones((3, 4)) - y = model(x) - mx.eval(y) - assert y.shape == (3, 2) + load = compat_client.one("model_load") + inference = compat_client.one("inference") + assert inference["model_id"] == load["model_id"] + output = inference["inference"]["output_meta"] + assert output["tokens_out"] >= 1 diff --git a/tests/compat/test_onnx_compat.py b/tests/compat/test_onnx_compat.py index ea4fc66..541d7fc 100644 --- a/tests/compat/test_onnx_compat.py +++ b/tests/compat/test_onnx_compat.py @@ -1,9 +1,26 @@ from __future__ import annotations -import pytest +import numpy as np +import onnxruntime as ort +from conftest import FIXTURES -def test_onnxruntime_import_and_instrument(compat_client): - onnxruntime = pytest.importorskip("onnxruntime") - assert getattr(onnxruntime, "__version__", None) +def test_inference_session_load_and_run_are_tracked(compat_client): compat_client.instrument("onnx") + session = ort.InferenceSession( + str(FIXTURES / "identity.onnx"), providers=["CPUExecutionProvider"] + ) + x = np.arange(4, dtype=np.float32).reshape(1, 4) + (y,) = session.run(None, {"x": x}) + assert np.array_equal(x, y) + + # Auto-load takes the id from the file name, the rest from the session. + load = compat_client.one("model_load") + assert load["model_id"] == "identity" + info = compat_client.registry.models["identity"] + assert info.model_format == "onnx" + assert info.model_name == "compat_identity" + assert info.model_version == "3" + inference = compat_client.one("inference") + assert inference["model_id"] == "identity" + assert inference["inference"]["success"] is True diff --git a/tests/compat/test_openai_compat.py b/tests/compat/test_openai_compat.py index a704734..925686f 100644 --- a/tests/compat/test_openai_compat.py +++ b/tests/compat/test_openai_compat.py @@ -1,9 +1,118 @@ from __future__ import annotations -import pytest +import json +import openai +from conftest import sdk_http_module -def test_openai_import_and_instrument(compat_client): - openai = pytest.importorskip("openai") - assert getattr(openai, "__version__", None) +httpx = sdk_http_module("openai") + +COMPLETION = { + "id": "chatcmpl-compat", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello there"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11}, +} + + +def _client(handler) -> openai.OpenAI: + return openai.OpenAI( + api_key="sk-compat", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + +def test_chat_completion_is_tracked(compat_client): + compat_client.instrument("openai") + client = _client(lambda request: httpx.Response(200, json=COMPLETION)) + + result = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Say hello"}], + temperature=0.2, + ) + assert result.choices[0].message.content == "Hello there" + + inference = compat_client.one("inference") + assert inference["model_id"] == "gpt-4o-mini" + output = inference["inference"]["output_meta"] + assert output["tokens_in"] == 9 + assert output["tokens_out"] == 2 + assert output["stop_reason"] == "stop" + + +def test_streamed_chat_completion_is_tracked(compat_client): + compat_client.instrument("openai") + chunks = [ + {"choices": [{"index": 0, "delta": {"role": "assistant", "content": "Hi"}}]}, + {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}, + { + "choices": [], + "usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10}, + }, + ] + base = { + "id": "c", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + } + body = "".join(f"data: {json.dumps({**base, **c})}\n\n" for c in chunks) + body += "data: [DONE]\n\n" + client = _client( + lambda request: httpx.Response( + 200, text=body, headers={"content-type": "text/event-stream"} + ) + ) + + stream = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "Say hi"}], + stream=True, + stream_options={"include_usage": True}, + ) + assert ( + "".join(c.choices[0].delta.content or "" for c in stream if c.choices) == "Hi" + ) + + inference = compat_client.one("inference") + output = inference["inference"]["output_meta"] + assert output["tokens_out"] == 1 + assert output["stop_reason"] == "stop" + assert output["time_to_first_token_ms"] >= 0 + + +def test_api_error_is_tracked(compat_client): compat_client.instrument("openai") + client = _client( + lambda request: httpx.Response( + 429, + json={ + "error": { + "message": "slow down", + "type": "rate_limit", + "code": "rate_limit_exceeded", + } + }, + ) + ) + try: + client.with_options(max_retries=0).chat.completions.create( + model="gpt-4o-mini", messages=[{"role": "user", "content": "x"}] + ) + except openai.RateLimitError: + pass + else: + raise AssertionError("expected RateLimitError") + + error = compat_client.one("error") + assert error["error"]["http_status"] == 429 + assert error["error"]["provider_error_code"] == "rate_limit_exceeded" diff --git a/tests/compat/test_tensorflow_compat.py b/tests/compat/test_tensorflow_compat.py index 37a0299..82c0d1d 100644 --- a/tests/compat/test_tensorflow_compat.py +++ b/tests/compat/test_tensorflow_compat.py @@ -1,12 +1,10 @@ from __future__ import annotations -import pytest +import numpy as np +import tensorflow as tf -def test_tensorflow_import_and_instrumentation(compat_client): - np = pytest.importorskip("numpy") - tf = pytest.importorskip("tensorflow") - +def test_keras_model_load_and_call_are_tracked(compat_client): compat_client.instrument("tensorflow") model = compat_client.load( tf.keras.Sequential, @@ -18,3 +16,9 @@ def test_tensorflow_import_and_instrumentation(compat_client): ) out = model(np.random.randn(2, 8).astype(np.float32), training=False) assert tuple(out.shape) == (2, 2) + + load = compat_client.one("model_load") + assert load["model_id"] + inference = compat_client.one("inference") + assert inference["model_id"] == load["model_id"] + assert inference["inference"]["success"] is True diff --git a/tests/compat/test_timm_compat.py b/tests/compat/test_timm_compat.py index c5f75a6..2afdedc 100644 --- a/tests/compat/test_timm_compat.py +++ b/tests/compat/test_timm_compat.py @@ -1,14 +1,31 @@ from __future__ import annotations import pytest +import timm +import torch -def test_timm_import_and_instrumentation(compat_client): - torch = pytest.importorskip("torch") - timm = pytest.importorskip("timm") - +def test_timm_create_model_and_forward_are_tracked(compat_client): compat_client.instrument("timm") model = timm.create_model("resnet18", pretrained=False) - x = torch.randn(1, 3, 224, 224) - y = model(x) + y = model(torch.randn(1, 3, 64, 64)) assert y.shape[0] == 1 + + load = compat_client.one("model_load") + assert load["load"]["duration_ms"] >= 0 + inference = compat_client.one("inference") + assert inference["model_id"] == load["model_id"] + assert inference["inference"]["input_modality"] == "image" + + +@pytest.mark.xfail( + strict=True, + reason="timm models are registered by class name, so architectures collide (#73)", +) +def test_timm_architectures_get_their_own_ids(compat_client): + compat_client.instrument("timm") + # Neither is loaded by another test, so each should add one model_load. + timm.create_model("resnet34", pretrained=False) + timm.create_model("resnet50", pretrained=False) + ids = {e["model_id"] for e in compat_client.of_type("model_load")} + assert len(ids) == 2 diff --git a/tests/compat/test_torch_compat.py b/tests/compat/test_torch_compat.py index 2ea558e..f3f2a9b 100644 --- a/tests/compat/test_torch_compat.py +++ b/tests/compat/test_torch_compat.py @@ -1,11 +1,16 @@ from __future__ import annotations -import pytest +import torch -def test_torch_import_and_load_tracking(compat_client): - torch = pytest.importorskip("torch") +def test_torch_load_and_inference_are_tracked(compat_client): model = compat_client.load(torch.nn.Linear, 4, 2) - x = torch.randn(3, 4) - y = model(x) + y = model(torch.randn(3, 4)) assert tuple(y.shape) == (3, 2) + + load = compat_client.one("model_load") + assert load["load"]["duration_ms"] >= 0 + inference = compat_client.one("inference") + assert inference["model_id"] == load["model_id"] + assert inference["inference"]["success"] is True + assert inference["inference"]["batch_size"] == 3 diff --git a/tests/compat/test_transformers_compat.py b/tests/compat/test_transformers_compat.py index 01b22cf..9d4e8ae 100644 --- a/tests/compat/test_transformers_compat.py +++ b/tests/compat/test_transformers_compat.py @@ -1,25 +1,58 @@ from __future__ import annotations import pytest +import torch +import transformers -def test_transformers_import_and_instrumentation(compat_client): - torch = pytest.importorskip("torch") - transformers = pytest.importorskip("transformers") - - compat_client.instrument("transformers") - +def _save_tiny_bert(path) -> None: config = transformers.BertConfig( - hidden_size=32, - num_hidden_layers=2, + vocab_size=32, + hidden_size=16, + num_hidden_layers=1, num_attention_heads=2, - intermediate_size=64, + intermediate_size=32, num_labels=2, ) - model = transformers.BertForSequenceClassification(config) - inputs = { - "input_ids": torch.zeros((1, 4), dtype=torch.long), - "attention_mask": torch.ones((1, 4), dtype=torch.long), - } - out = model(**inputs) + transformers.BertForSequenceClassification(config).save_pretrained(path) + + +def test_from_pretrained_load_and_forward_are_tracked(compat_client, tmp_path): + _save_tiny_bert(tmp_path) + compat_client.instrument("transformers") + + model = transformers.AutoModelForSequenceClassification.from_pretrained(tmp_path) + out = model( + input_ids=torch.zeros((1, 4), dtype=torch.long), + attention_mask=torch.ones((1, 4), dtype=torch.long), + ) assert tuple(out.logits.shape) == (1, 2) + + load = compat_client.one("model_load") + inference = compat_client.one("inference") + assert inference["model_id"] == load["model_id"] + assert inference["inference"]["success"] is True + + +@pytest.mark.xfail( + strict=True, + reason="pipeline() calls are counted twice: the inner model keeps its " + "forward hooks after the pipeline supersedes it (#70)", +) +def test_pipeline_load_and_call_are_tracked(compat_client, tmp_path): + _save_tiny_bert(tmp_path) + vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]", "hello", "world"] + (tmp_path / "vocab.txt").write_text("\n".join(vocab) + "\n") + transformers.BertTokenizer(str(tmp_path / "vocab.txt")).save_pretrained(tmp_path) + compat_client.instrument("transformers") + + classifier = transformers.pipeline( + "text-classification", model=str(tmp_path), tokenizer=str(tmp_path) + ) + result = classifier("hello world") + assert result[0]["label"] in {"LABEL_0", "LABEL_1"} + + load = compat_client.one("model_load") + inference = compat_client.one("inference") + assert inference["model_id"] == load["model_id"] + assert inference["inference"]["input_modality"] == "text" diff --git a/tests/compat/test_ultralytics_compat.py b/tests/compat/test_ultralytics_compat.py index 6f6e5e5..ffd8e4f 100644 --- a/tests/compat/test_ultralytics_compat.py +++ b/tests/compat/test_ultralytics_compat.py @@ -1,9 +1,57 @@ from __future__ import annotations +import importlib.util + import pytest +import ultralytics +from PIL import Image + +# Early 8.0.x releases keep their helpers in ultralytics.yolo.utils. The +# integration imports ultralytics.utils, so on those releases it is off. +LEGACY_LAYOUT = importlib.util.find_spec("ultralytics.utils") is None +legacy_xfail = pytest.mark.xfail( + LEGACY_LAYOUT, + strict=True, + reason="integration is disabled on ultralytics without ultralytics.utils (#71)", +) -def test_ultralytics_import_and_instrument(compat_client): - ultralytics = pytest.importorskip("ultralytics") - assert getattr(ultralytics, "__version__", None) +@pytest.fixture +def yolo(compat_client): compat_client.instrument("ultralytics") + # Built from the architecture config, so no weights are downloaded. + return ultralytics.YOLO("yolov8n.yaml") + + +@pytest.fixture +def image(tmp_path) -> str: + # A file path: ultralytics 8.0.0 can't take a numpy array as the source. + path = tmp_path / "blank.png" + Image.new("RGB", (64, 64)).save(path) + return str(path) + + +@legacy_xfail +def test_yolo_load_is_tracked(compat_client, yolo): + compat_client.one("model_load") + assert compat_client.of_type("model_download") == [] + + +@legacy_xfail +def test_yolo_call_is_tracked(compat_client, yolo, image): + results = yolo(image, imgsz=64, verbose=False) + assert len(results) == 1 + + inference = compat_client.one("inference") + assert inference["model_id"] in compat_client.registry.handles + assert inference["inference"]["input_modality"] == "image" + assert inference["inference"]["success"] is True + + +@pytest.mark.xfail( + strict=True, + reason="model.predict() is not tracked, only model() (#72)", +) +def test_yolo_predict_is_tracked(compat_client, yolo, image): + yolo.predict(image, imgsz=64, verbose=False) + compat_client.one("inference") From 7fb9cc296f405da1ca030039b0a4a44b141e6b56 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 15:21:21 +0200 Subject: [PATCH 07/81] Generate the compat CI matrix from compat_matrix.py and run it on devel PRs The workflow's matrix was hand-written and drifted from the script (anthropic was missing), and pull requests into devel never ran compat. compat_matrix.py now builds the matrix: a pr profile with each integration's min and current pins, and the full nightly profile. A verify step checks the installed versions match the pins. tests/test_compat_matrix.py keeps the script, the compat tests and docs/compatibility.md in step. --- .github/workflows/compat.yml | 94 +++++++-------------- docs/compatibility.md | 34 +++++++- scripts/compat_matrix.py | 96 +++++++++++++++++++++- scripts/run_compat_local.py | 20 ++++- tests/test_compat_matrix.py | 154 +++++++++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 73 deletions(-) create mode 100644 tests/test_compat_matrix.py diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index e4ab14b..1ab875d 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -1,5 +1,13 @@ name: Compatibility +# Runs tests/compat against the pinned library versions in +# scripts/compat_matrix.py, which is also the source of the job matrix and of +# docs/compatibility.md. +# +# - Pull requests into devel or main: each integration's minimum pins on its +# oldest Python and current pins on its newest ("pr" profile). +# - Nightly, release branches and manual runs: every combination ("full"). + on: push: branches: [ main, 'release/**' ] @@ -10,7 +18,7 @@ on: - 'pyproject.toml' - '.github/workflows/compat.yml' pull_request: - branches: [ main ] + branches: [ main, devel ] types: [opened, synchronize, reopened] paths: - 'wildedge/**' @@ -23,46 +31,25 @@ on: workflow_dispatch: jobs: - compat: - if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'release/') + matrix: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - integration: ["anthropic", "onnx", "torch", "timm", "tensorflow", "gguf", "openai", "transformers", "ultralytics"] - version-set: ["min", "current"] - exclude: - - python-version: "3.13" - integration: "tensorflow" + outputs: + rows: ${{ steps.rows.outputs.rows }} steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install project - run: | - python -m pip install --upgrade pip - pip install -e . pytest pytest-asyncio pytest-mock - - name: Install compatibility dependencies - run: | - python scripts/compat_matrix.py deps \ - --integration "${{ matrix.integration }}" \ - --version-set "${{ matrix.version-set }}" \ - --python-version "${{ matrix.python-version }}" > compat-requirements.txt - pip install -r compat-requirements.txt - - name: Run compatibility test - run: python -m pytest "tests/compat/test_${{ matrix.integration }}_compat.py" -q + - id: rows + env: + PROFILE: ${{ (github.event_name == 'pull_request' && !startsWith(github.head_ref, 'release/')) && 'pr' || 'full' }} + run: echo "rows=$(python scripts/compat_matrix.py github-matrix --profile "$PROFILE")" >> "$GITHUB_OUTPUT" - compat-mlx: - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: macos-14 + compat: + needs: matrix + name: compat (${{ matrix.python-version }}, ${{ matrix.integration }}, ${{ matrix.version-set }}) + runs-on: ${{ matrix.runs-on }} + continue-on-error: ${{ matrix.canary }} strategy: fail-fast: false - matrix: - python-version: ["3.12", "3.13"] - version-set: ["current"] + matrix: ${{ fromJSON(needs.matrix.outputs.rows) }} steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -76,40 +63,15 @@ jobs: - name: Install compatibility dependencies run: | python scripts/compat_matrix.py deps \ - --integration "mlx" \ + --integration "${{ matrix.integration }}" \ --version-set "${{ matrix.version-set }}" \ --python-version "${{ matrix.python-version }}" > compat-requirements.txt pip install -r compat-requirements.txt - - name: Run compatibility test - run: python -m pytest tests/compat/test_mlx_compat.py -q - - compat-canary-314: - if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'release/') - runs-on: ubuntu-latest - continue-on-error: true - strategy: - fail-fast: false - matrix: - integration: ["torch", "timm", "tensorflow"] - version-set: ["current"] - exclude: - - integration: "tensorflow" - steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.14 - uses: actions/setup-python@v5 - with: - python-version: "3.14" - - name: Install project + - name: Check installed versions match the pins run: | - python -m pip install --upgrade pip - pip install -e . pytest pytest-asyncio pytest-mock - - name: Install compatibility dependencies - run: | - python scripts/compat_matrix.py deps \ + python scripts/compat_matrix.py verify \ --integration "${{ matrix.integration }}" \ --version-set "${{ matrix.version-set }}" \ - --python-version "3.14" > compat-requirements.txt - pip install -r compat-requirements.txt - - name: Run compatibility test - run: python -m pytest "tests/compat/test_${{ matrix.integration }}_compat.py" -q + --python-version "${{ matrix.python-version }}" + - name: Run compatibility tests + run: python -m pytest "tests/compat/test_${{ matrix.integration }}_compat.py" -q -rxX diff --git a/docs/compatibility.md b/docs/compatibility.md index 4f65d22..2cb11da 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,6 +1,38 @@ ## Compatibility Matrix -The table below is generated from `scripts/compat_matrix.py`. +Each integration is tested against the pinned library versions below: the +oldest supported release (`min`) and a recent one (`current`). The table is +generated from `scripts/compat_matrix.py`, which also defines the CI jobs. + +### What the tests check + +`tests/compat/test__compat.py` loads a real model with the real +library and checks the events the SDK records: a `model_load` event, then an +`inference` event for the same model with the expected fields (token counts +for LLMs, input modality for vision models). Nothing touches the network: + +- openai and anthropic get canned responses through the SDK's own HTTP + client (`MockTransport`). +- onnx and gguf load small files from `tests/compat/fixtures`. +- torch, timm, tensorflow, transformers, ultralytics and mlx build models from + a config, with random weights. + +A test never skips. A library that fails to install or import fails the job, +and a step checks that the installed versions match the pins. A known SDK +bug is marked `xfail(strict=True)` with its issue number, so the job fails +once the bug is fixed and the marker has to come off. + +### When they run + +- Pull requests into `devel` or `main` that touch the SDK: each integration's + `min` pins on its oldest Python and `current` pins on its newest. +- Nightly, on release branches and on manual runs: every combination below. + Python 3.14 rows are canaries and don't fail the run. mlx runs on macOS. + +To run the full matrix locally: `python scripts/run_compat_local.py`. +`python scripts/compat_matrix.py table` prints the table below. + +### Matrix | Integration | Version set | Dependencies | Supported Python | |---|---|---|---| diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 94ba51a..dd312ab 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -6,6 +6,7 @@ import argparse import json import sys +from importlib import metadata MATRIX = { "anthropic": { @@ -72,6 +73,19 @@ "mlx": ["3.12", "3.13"], } +# Integrations without a row of their own, and the row that exercises them. +COVERED_BY = { + "keras": "tensorflow", +} + +# Integrations that need a specific runner; everything else runs on Linux. +RUNNER = { + "mlx": "macos-14", +} + +# Python versions run as non-blocking canaries, and only with current pins. +CANARY_PYTHON = {"3.14"} + # Interpreter-specific overrides where upstream wheels are unavailable for older pins. PYTHON_DEP_OVERRIDES = { "onnx": { @@ -185,25 +199,99 @@ def print_table() -> int: return 0 +def _strip_local(version: str) -> str: + return version.split("+", 1)[0] + + +def verify(integration: str, version_set: str, python_version: str) -> int: + """Check that the installed distributions match the pins exactly. + + pip can satisfy a pin and then let another package upgrade it, or fall + back to a different release; this makes the job fail instead. + """ + mismatches = [] + for requirement in resolve_deps(integration, version_set, python_version): + name, _, wanted = requirement.partition("==") + try: + installed = metadata.version(name) + except metadata.PackageNotFoundError: + mismatches.append(f"{name}: not installed, pinned {wanted}") + continue + if _strip_local(installed) != wanted: + mismatches.append(f"{name}: installed {installed}, pinned {wanted}") + for line in mismatches: + print(line, file=sys.stderr) + return 1 if mismatches else 0 + + +def github_matrix(profile: str) -> list[dict]: + """Rows for the compat workflow. + + ``full`` runs every supported combination (nightly, release PRs, manual + runs). ``pr`` runs each integration's current pins on its newest stable + Python and its minimum pins on its oldest, skipping macOS-only rows. + """ + rows = [] + for integration, sets in MATRIX.items(): + runner = RUNNER.get(integration, "ubuntu-latest") + if profile == "pr" and runner != "ubuntu-latest": + continue + stable = [py for py in SUPPORTED_PYTHON[integration] if py not in CANARY_PYTHON] + for version_set in sets: + if profile == "pr": + pythons = [stable[-1] if version_set == "current" else stable[0]] + else: + pythons = list(stable) + if version_set == "current": + pythons += [ + py + for py in SUPPORTED_PYTHON[integration] + if py in CANARY_PYTHON + ] + for py in pythons: + rows.append( + { + "integration": integration, + "version-set": version_set, + "python-version": py, + "runs-on": runner, + "canary": py in CANARY_PYTHON, + } + ) + return rows + + +def print_github_matrix(profile: str) -> int: + print(json.dumps({"include": github_matrix(profile)})) + return 0 + + def main() -> int: parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="cmd", required=True) - deps = sub.add_parser("deps") - deps.add_argument("--integration", required=True) - deps.add_argument("--version-set", required=True, choices=["min", "current"]) - deps.add_argument("--python-version", required=True) + for name in ("deps", "verify"): + cmd = sub.add_parser(name) + cmd.add_argument("--integration", required=True) + cmd.add_argument("--version-set", required=True, choices=["min", "current"]) + cmd.add_argument("--python-version", required=True) sub.add_parser("rows") sub.add_parser("table") + gh = sub.add_parser("github-matrix") + gh.add_argument("--profile", required=True, choices=["full", "pr"]) args = parser.parse_args() if args.cmd == "deps": return print_deps(args.integration, args.version_set, args.python_version) + if args.cmd == "verify": + return verify(args.integration, args.version_set, args.python_version) if args.cmd == "rows": return print_rows() if args.cmd == "table": return print_table() + if args.cmd == "github-matrix": + return print_github_matrix(args.profile) return 2 diff --git a/scripts/run_compat_local.py b/scripts/run_compat_local.py index 44fe82f..dc6c1bb 100755 --- a/scripts/run_compat_local.py +++ b/scripts/run_compat_local.py @@ -24,14 +24,30 @@ class Row: def load_rows() -> list[Row]: + """The rows the nightly workflow runs; macOS-only rows only on macOS.""" result = subprocess.run( - [sys.executable, str(REPO_ROOT / "scripts" / "compat_matrix.py"), "rows"], + [ + sys.executable, + str(REPO_ROOT / "scripts" / "compat_matrix.py"), + "github-matrix", + "--profile", + "full", + ], check=True, capture_output=True, text=True, cwd=REPO_ROOT, ) - return [Row(**r) for r in json.loads(result.stdout)] + on_macos = sys.platform == "darwin" + return [ + Row( + python_version=r["python-version"], + integration=r["integration"], + version_set=r["version-set"], + ) + for r in json.loads(result.stdout)["include"] + if on_macos or not r["runs-on"].startswith("macos") + ] WORKFLOW_ROWS: list[Row] = load_rows() diff --git a/tests/test_compat_matrix.py b/tests/test_compat_matrix.py new file mode 100644 index 0000000..6bab7bb --- /dev/null +++ b/tests/test_compat_matrix.py @@ -0,0 +1,154 @@ +"""Keep scripts/compat_matrix.py, tests/compat and the docs in step.""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +import pytest + +from wildedge.integrations.registry import INTEGRATION_SPECS + +ROOT = Path(__file__).resolve().parents[1] +COMPAT_TESTS = ROOT / "tests" / "compat" + +_spec = importlib.util.spec_from_file_location( + "compat_matrix", ROOT / "scripts" / "compat_matrix.py" +) +compat_matrix = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(compat_matrix) + + +def test_every_integration_has_a_compat_row(): + for spec in INTEGRATION_SPECS: + row = compat_matrix.COVERED_BY.get(spec.name, spec.name) + assert row in compat_matrix.MATRIX, f"{spec.name} has no compat row" + + +def test_covered_by_points_at_real_rows(): + for integration, row in compat_matrix.COVERED_BY.items(): + assert integration not in compat_matrix.MATRIX + assert row in compat_matrix.MATRIX + + +def test_every_row_has_a_test_file_and_supported_pythons(): + assert set(compat_matrix.SUPPORTED_PYTHON) == set(compat_matrix.MATRIX) + for integration in compat_matrix.MATRIX: + assert (COMPAT_TESTS / f"test_{integration}_compat.py").exists() + + +def test_no_compat_test_file_without_a_row(): + for path in COMPAT_TESTS.glob("test_*_compat.py"): + integration = path.name[len("test_") : -len("_compat.py")] + assert integration in compat_matrix.MATRIX, path.name + + +def test_compat_tests_never_skip(): + # A skip would turn a broken install into a green job. + for path in COMPAT_TESTS.glob("*.py"): + source = path.read_text() + assert "importorskip" not in source, path.name + assert not re.search(r"pytest\.skip\(|mark\.skip", source), path.name + + +def test_pins_are_exact(): + pin = re.compile(r"^[A-Za-z0-9_.-]+==[0-9][0-9A-Za-z.]*$") + for integration, sets in compat_matrix.MATRIX.items(): + for deps in sets.values(): + assert all(pin.match(d) for d in deps), (integration, deps) + for integration, by_set in compat_matrix.PYTHON_DEP_OVERRIDES.items(): + for version_set, by_python in by_set.items(): + assert version_set in compat_matrix.MATRIX[integration] + for py, deps in by_python.items(): + assert py in compat_matrix.SUPPORTED_PYTHON[integration] + assert all(pin.match(d) for d in deps), (integration, py, deps) + + +def test_docs_table_matches_the_matrix(capsys): + compat_matrix.print_table() + table = capsys.readouterr().out.strip() + docs = (ROOT / "docs" / "compatibility.md").read_text() + assert table in docs, ( + "docs/compatibility.md is out of date; regenerate it with " + "`python scripts/compat_matrix.py table`" + ) + + +def test_pr_profile_runs_min_on_oldest_and_current_on_newest(): + rows = compat_matrix.github_matrix("pr") + seen = {(r["integration"], r["version-set"]): r for r in rows} + for integration, sets in compat_matrix.MATRIX.items(): + if compat_matrix.RUNNER.get(integration, "ubuntu-latest") != "ubuntu-latest": + assert not any(r["integration"] == integration for r in rows) + continue + stable = [ + py + for py in compat_matrix.SUPPORTED_PYTHON[integration] + if py not in compat_matrix.CANARY_PYTHON + ] + for version_set in sets: + row = seen[(integration, version_set)] + expected = stable[0] if version_set == "min" else stable[-1] + assert row["python-version"] == expected + assert len(rows) == len(seen) + assert not any(r["canary"] for r in rows) + + +def test_full_profile_covers_every_stable_combination(): + rows = compat_matrix.github_matrix("full") + keys = {(r["integration"], r["version-set"], r["python-version"]) for r in rows} + assert len(keys) == len(rows) + for integration, sets in compat_matrix.MATRIX.items(): + for version_set in sets: + for py in compat_matrix.SUPPORTED_PYTHON[integration]: + canary = py in compat_matrix.CANARY_PYTHON + if canary and version_set != "current": + assert (integration, version_set, py) not in keys + else: + assert (integration, version_set, py) in keys + for row in rows: + assert row["canary"] == (row["python-version"] in compat_matrix.CANARY_PYTHON) + expected_runner = compat_matrix.RUNNER.get(row["integration"], "ubuntu-latest") + assert row["runs-on"] == expected_runner + + +@pytest.fixture +def installed(monkeypatch): + versions: dict[str, str] = {} + + def version(name): + if name not in versions: + raise compat_matrix.metadata.PackageNotFoundError(name) + return versions[name] + + monkeypatch.setattr(compat_matrix.metadata, "version", version) + return versions + + +def test_verify_accepts_matching_versions(installed, capsys): + installed.update({"onnxruntime": "1.31.0", "numpy": "2.2.6"}) + assert compat_matrix.verify("onnx", "current", "3.12") == 0 + assert capsys.readouterr().err == "" + + +def test_verify_ignores_local_version_suffix(installed): + installed.update({"torch": "2.14.1+cpu", "numpy": "2.2.6"}) + assert compat_matrix.verify("torch", "current", "3.12") == 0 + + +def test_verify_reports_a_different_version(installed, capsys): + installed.update({"onnxruntime": "1.31.0", "numpy": "2.3.0"}) + assert compat_matrix.verify("onnx", "current", "3.12") == 1 + assert "numpy: installed 2.3.0, pinned 2.2.6" in capsys.readouterr().err + + +def test_verify_reports_a_missing_package(installed, capsys): + installed.update({"numpy": "2.2.6"}) + assert compat_matrix.verify("onnx", "current", "3.12") == 1 + assert "onnxruntime: not installed" in capsys.readouterr().err + + +def test_verify_uses_python_overrides(installed): + installed.update({"onnxruntime": "1.23.2", "numpy": "2.2.6"}) + assert compat_matrix.verify("onnx", "current", "3.10") == 0 From 14a7ce2afcacf8fb9bda5cb701556b9ae3a56e83 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 15:32:47 +0200 Subject: [PATCH 08/81] Pin setuptools for ultralytics 8.0.0 on Python 3.12 and 3.13 ultralytics 8.0.0 imports pkg_resources without declaring setuptools, and Python 3.12+ environments no longer include it. The old compat test skipped here; with skips now failing, the full run caught it. --- docs/compatibility.md | 2 ++ scripts/compat_matrix.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/compatibility.md b/docs/compatibility.md index 2cb11da..679db99 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -63,5 +63,7 @@ To run the full matrix locally: `python scripts/run_compat_local.py`. | `transformers` | `min` (`py3.13` override) | `transformers==4.45.0`, `torch==2.5.0`, `numpy==2.1.3` | 3.13 | | `transformers` | `current` | `transformers==5.20.1`, `torch==2.14.1`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | | `ultralytics` | `min` | `ultralytics==8.0.0`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | +| `ultralytics` | `min` (`py3.12` override) | `ultralytics==8.0.0`, `numpy==1.26.4`, `setuptools==75.8.0` | 3.12 | +| `ultralytics` | `min` (`py3.13` override) | `ultralytics==8.0.0`, `numpy==1.26.4`, `setuptools==75.8.0` | 3.13 | | `ultralytics` | `current` | `ultralytics==8.4.182`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | | `mlx` | `current` | `mlx==0.32.4`, `mlx-lm==0.32.2` | 3.12, 3.13 | diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index dd312ab..6ff2181 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -110,6 +110,14 @@ "3.10": ["tensorflow==2.21.0", "keras==3.12.4", "numpy==2.2.6"], }, }, + "ultralytics": { + # ultralytics 8.0.0 imports pkg_resources without declaring setuptools, + # which Python 3.12+ environments no longer include. + "min": { + "3.12": ["ultralytics==8.0.0", "numpy==1.26.4", "setuptools==75.8.0"], + "3.13": ["ultralytics==8.0.0", "numpy==1.26.4", "setuptools==75.8.0"], + }, + }, "transformers": { "min": { "3.13": ["transformers==4.45.0", "torch==2.5.0", "numpy==2.1.3"], From 0270e0cc14a14fdf7c4c05a60710db9be903c0f8 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 15:40:14 +0200 Subject: [PATCH 09/81] Run compat on pull requests into main and release branches only Every devel pull request running the matrix is more than needed; devel gets the nightly run. Pull requests into main or a release branch run the full matrix, so the separate pr profile is gone. --- .github/workflows/compat.yml | 11 ++++------- docs/compatibility.md | 8 ++++---- scripts/compat_matrix.py | 35 ++++++++++++----------------------- scripts/run_compat_local.py | 2 -- tests/test_compat_matrix.py | 24 ++---------------------- 5 files changed, 22 insertions(+), 58 deletions(-) diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 1ab875d..92b2e3f 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -4,9 +4,8 @@ name: Compatibility # scripts/compat_matrix.py, which is also the source of the job matrix and of # docs/compatibility.md. # -# - Pull requests into devel or main: each integration's minimum pins on its -# oldest Python and current pins on its newest ("pr" profile). -# - Nightly, release branches and manual runs: every combination ("full"). +# Every run uses the full matrix. It runs on pull requests into main or a +# release branch, on pushes to them, nightly against devel, and on demand. on: push: @@ -18,7 +17,7 @@ on: - 'pyproject.toml' - '.github/workflows/compat.yml' pull_request: - branches: [ main, devel ] + branches: [ main, 'release/**' ] types: [opened, synchronize, reopened] paths: - 'wildedge/**' @@ -38,9 +37,7 @@ jobs: steps: - uses: actions/checkout@v4 - id: rows - env: - PROFILE: ${{ (github.event_name == 'pull_request' && !startsWith(github.head_ref, 'release/')) && 'pr' || 'full' }} - run: echo "rows=$(python scripts/compat_matrix.py github-matrix --profile "$PROFILE")" >> "$GITHUB_OUTPUT" + run: echo "rows=$(python scripts/compat_matrix.py github-matrix)" >> "$GITHUB_OUTPUT" compat: needs: matrix diff --git a/docs/compatibility.md b/docs/compatibility.md index 679db99..f663983 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -24,10 +24,10 @@ once the bug is fixed and the marker has to come off. ### When they run -- Pull requests into `devel` or `main` that touch the SDK: each integration's - `min` pins on its oldest Python and `current` pins on its newest. -- Nightly, on release branches and on manual runs: every combination below. - Python 3.14 rows are canaries and don't fail the run. mlx runs on macOS. +On pull requests into `main` or a release branch, on pushes to them, nightly +against `devel`, and on manual runs. Every run covers all the combinations +below. Python 3.14 rows are canaries and don't fail the run. mlx runs on +macOS. To run the full matrix locally: `python scripts/run_compat_local.py`. `python scripts/compat_matrix.py table` prints the table below. diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 6ff2181..95caa85 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -232,30 +232,20 @@ def verify(integration: str, version_set: str, python_version: str) -> int: return 1 if mismatches else 0 -def github_matrix(profile: str) -> list[dict]: - """Rows for the compat workflow. +def github_matrix() -> list[dict]: + """Rows for the compat workflow: every supported combination. - ``full`` runs every supported combination (nightly, release PRs, manual - runs). ``pr`` runs each integration's current pins on its newest stable - Python and its minimum pins on its oldest, skipping macOS-only rows. + Canary Pythons run with current pins only and don't fail the run. """ rows = [] for integration, sets in MATRIX.items(): runner = RUNNER.get(integration, "ubuntu-latest") - if profile == "pr" and runner != "ubuntu-latest": - continue - stable = [py for py in SUPPORTED_PYTHON[integration] if py not in CANARY_PYTHON] for version_set in sets: - if profile == "pr": - pythons = [stable[-1] if version_set == "current" else stable[0]] - else: - pythons = list(stable) - if version_set == "current": - pythons += [ - py - for py in SUPPORTED_PYTHON[integration] - if py in CANARY_PYTHON - ] + pythons = [ + py + for py in SUPPORTED_PYTHON[integration] + if py not in CANARY_PYTHON or version_set == "current" + ] for py in pythons: rows.append( { @@ -269,8 +259,8 @@ def github_matrix(profile: str) -> list[dict]: return rows -def print_github_matrix(profile: str) -> int: - print(json.dumps({"include": github_matrix(profile)})) +def print_github_matrix() -> int: + print(json.dumps({"include": github_matrix()})) return 0 @@ -286,8 +276,7 @@ def main() -> int: sub.add_parser("rows") sub.add_parser("table") - gh = sub.add_parser("github-matrix") - gh.add_argument("--profile", required=True, choices=["full", "pr"]) + sub.add_parser("github-matrix") args = parser.parse_args() if args.cmd == "deps": @@ -299,7 +288,7 @@ def main() -> int: if args.cmd == "table": return print_table() if args.cmd == "github-matrix": - return print_github_matrix(args.profile) + return print_github_matrix() return 2 diff --git a/scripts/run_compat_local.py b/scripts/run_compat_local.py index dc6c1bb..4c2d5b3 100755 --- a/scripts/run_compat_local.py +++ b/scripts/run_compat_local.py @@ -30,8 +30,6 @@ def load_rows() -> list[Row]: sys.executable, str(REPO_ROOT / "scripts" / "compat_matrix.py"), "github-matrix", - "--profile", - "full", ], check=True, capture_output=True, diff --git a/tests/test_compat_matrix.py b/tests/test_compat_matrix.py index 6bab7bb..67f9b9a 100644 --- a/tests/test_compat_matrix.py +++ b/tests/test_compat_matrix.py @@ -75,28 +75,8 @@ def test_docs_table_matches_the_matrix(capsys): ) -def test_pr_profile_runs_min_on_oldest_and_current_on_newest(): - rows = compat_matrix.github_matrix("pr") - seen = {(r["integration"], r["version-set"]): r for r in rows} - for integration, sets in compat_matrix.MATRIX.items(): - if compat_matrix.RUNNER.get(integration, "ubuntu-latest") != "ubuntu-latest": - assert not any(r["integration"] == integration for r in rows) - continue - stable = [ - py - for py in compat_matrix.SUPPORTED_PYTHON[integration] - if py not in compat_matrix.CANARY_PYTHON - ] - for version_set in sets: - row = seen[(integration, version_set)] - expected = stable[0] if version_set == "min" else stable[-1] - assert row["python-version"] == expected - assert len(rows) == len(seen) - assert not any(r["canary"] for r in rows) - - -def test_full_profile_covers_every_stable_combination(): - rows = compat_matrix.github_matrix("full") +def test_github_matrix_covers_every_combination(): + rows = compat_matrix.github_matrix() keys = {(r["integration"], r["version-set"], r["python-version"]) for r in rows} assert len(keys) == len(rows) for integration, sets in compat_matrix.MATRIX.items(): From 7bba65c1dfde5f8a0df21a0508eeb817f9a12726 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 15:45:50 +0200 Subject: [PATCH 10/81] Download the GGUF test model instead of committing it The gguf compat test fetches the 1.2 MB TinyStories model from a pinned Hugging Face revision on first use and checks its sha256. A broken link or changed file fails the test with the URL to update. Models are cached in tests/compat/.cache, which the gguf jobs keep between runs. --- .github/workflows/compat.yml | 6 +++ .gitignore | 3 ++ docs/compatibility.md | 3 +- tests/compat/conftest.py | 38 +++++++++++++++ tests/compat/fixtures/README.md | 11 +++-- tests/compat/test_gguf_compat.py | 13 ++++-- tests/test_compat_fixtures.py | 79 ++++++++++++++++++++++++++++++++ 7 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 tests/test_compat_fixtures.py diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 92b2e3f..4921e49 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -70,5 +70,11 @@ jobs: --integration "${{ matrix.integration }}" \ --version-set "${{ matrix.version-set }}" \ --python-version "${{ matrix.python-version }}" + - name: Cache downloaded test models + if: matrix.integration == 'gguf' + uses: actions/cache@v4 + with: + path: tests/compat/.cache + key: compat-models-${{ matrix.integration }}-${{ hashFiles(format('tests/compat/test_{0}_compat.py', matrix.integration)) }} - name: Run compatibility tests run: python -m pytest "tests/compat/test_${{ matrix.integration }}_compat.py" -q -rxX diff --git a/.gitignore b/.gitignore index 4dcd2c5..9c6e1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -218,3 +218,6 @@ internal/ # macOS .DS_Store + +# Models downloaded by the compat tests +tests/compat/.cache/ diff --git a/docs/compatibility.md b/docs/compatibility.md index f663983..f51e044 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -13,7 +13,8 @@ for LLMs, input modality for vision models). Nothing touches the network: - openai and anthropic get canned responses through the SDK's own HTTP client (`MockTransport`). -- onnx and gguf load small files from `tests/compat/fixtures`. +- onnx loads a tiny model committed in `tests/compat/fixtures`. gguf + downloads a 1.2 MB model on first use, checked against a pinned sha256. - torch, timm, tensorflow, transformers, ultralytics and mlx build models from a config, with random weights. diff --git a/tests/compat/conftest.py b/tests/compat/conftest.py index 628a6a2..891cbb1 100644 --- a/tests/compat/conftest.py +++ b/tests/compat/conftest.py @@ -7,7 +7,10 @@ from __future__ import annotations +import hashlib import importlib +import os +import urllib.request from pathlib import Path from types import ModuleType from unittest.mock import patch @@ -18,6 +21,41 @@ from wildedge.platforms.device_info import DeviceInfo FIXTURES = Path(__file__).parent / "fixtures" +# Downloaded test models; CI caches this directory between runs. +DOWNLOAD_CACHE = Path( + os.environ.get("WILDEDGE_COMPAT_CACHE", Path(__file__).parent / ".cache") +) + + +def downloaded_fixture(name: str, url: str, sha256: str) -> Path: + """Download ``url`` once into the cache and check its sha256. + + A failed download or a different checksum fails the test: update the URL + (and the checksum if the file changed) where the fixture is declared. + """ + path = DOWNLOAD_CACHE / name + if path.exists() and _sha256(path) == sha256: + return path + DOWNLOAD_CACHE.mkdir(parents=True, exist_ok=True) + partial = path.with_suffix(path.suffix + ".part") + try: + urllib.request.urlretrieve(url, partial) + except OSError as exc: + pytest.fail(f"could not download test model {name} from {url}: {exc}") + actual = _sha256(partial) + if actual != sha256: + partial.unlink() + pytest.fail(f"{url} has sha256 {actual}, expected {sha256}") + partial.replace(path) + return path + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() @pytest.hookimpl(hookwrapper=True) diff --git a/tests/compat/fixtures/README.md b/tests/compat/fixtures/README.md index bf67dff..3b3c7e4 100644 --- a/tests/compat/fixtures/README.md +++ b/tests/compat/fixtures/README.md @@ -1,8 +1,9 @@ # Compatibility test fixtures - `identity.onnx`: a one-node ONNX graph, generated by `make_onnx_fixture.py`. -- `stories260K-f32.gguf`: the 260K-parameter TinyStories Llama from - [karpathy/llama2.c](https://github.com/karpathy/llama2.c) (MIT), converted - to GGUF by ggml-org (`tinyllamas/stories260K.gguf` on Hugging Face). - sha256 `270cba1bd5109f42d03350f60406024560464db173c0e387d91f0426d3bd256d`. - It is committed rather than downloaded so the gguf job needs no network. + +Larger models are downloaded on first use and checked against a sha256 (see +`downloaded_fixture` in `../conftest.py`). They are cached in +`tests/compat/.cache`, or in `$WILDEDGE_COMPAT_CACHE` when set, which CI +keeps between runs. The gguf test declares its model's URL and checksum at +the top of `test_gguf_compat.py`. diff --git a/tests/compat/test_gguf_compat.py b/tests/compat/test_gguf_compat.py index 4ca0634..d2ebf92 100644 --- a/tests/compat/test_gguf_compat.py +++ b/tests/compat/test_gguf_compat.py @@ -2,15 +2,22 @@ import llama_cpp import pytest -from conftest import FIXTURES +from conftest import downloaded_fixture -MODEL = FIXTURES / "stories260K-f32.gguf" +# The 260K-parameter TinyStories Llama from karpathy/llama2.c (MIT), converted +# to GGUF by ggml-org. 1.2 MB. If the link breaks, point it at another copy. +MODEL_URL = ( + "https://huggingface.co/ggml-org/models-moved/resolve/" + "499bc8821c6b12b4e53c5bffcb21ec206f212d81/tinyllamas/stories260K.gguf" +) +MODEL_SHA256 = "270cba1bd5109f42d03350f60406024560464db173c0e387d91f0426d3bd256d" @pytest.fixture def llm(compat_client): + model = downloaded_fixture("stories260K-f32.gguf", MODEL_URL, MODEL_SHA256) compat_client.instrument("gguf") - return llama_cpp.Llama(model_path=str(MODEL), n_ctx=128, seed=1, verbose=False) + return llama_cpp.Llama(model_path=str(model), n_ctx=128, seed=1, verbose=False) def test_llama_load_is_tracked(compat_client, llm): diff --git a/tests/test_compat_fixtures.py b/tests/test_compat_fixtures.py new file mode 100644 index 0000000..3effa8c --- /dev/null +++ b/tests/test_compat_fixtures.py @@ -0,0 +1,79 @@ +"""The download helper the compat tests use for test models.""" + +from __future__ import annotations + +import hashlib +import importlib.util +from pathlib import Path + +import pytest + +_spec = importlib.util.spec_from_file_location( + "compat_conftest", Path(__file__).parent / "compat" / "conftest.py" +) +compat_conftest = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(compat_conftest) + +URL = "https://example.invalid/model.bin" +DATA = b"tiny model" +SHA = hashlib.sha256(DATA).hexdigest() + + +@pytest.fixture +def cache(tmp_path, monkeypatch): + monkeypatch.setattr(compat_conftest, "DOWNLOAD_CACHE", tmp_path) + return tmp_path + + +@pytest.fixture +def downloads(monkeypatch): + calls: list[str] = [] + served = {"data": DATA, "error": None} + + def urlretrieve(url, filename): + calls.append(url) + if served["error"] is not None: + raise served["error"] + Path(filename).write_bytes(served["data"]) + + monkeypatch.setattr(compat_conftest.urllib.request, "urlretrieve", urlretrieve) + return calls, served + + +def test_downloads_and_verifies(cache, downloads): + calls, _ = downloads + path = compat_conftest.downloaded_fixture("model.bin", URL, SHA) + assert path == cache / "model.bin" + assert path.read_bytes() == DATA + assert calls == [URL] + assert not (cache / "model.bin.part").exists() + + +def test_reuses_a_cached_file(cache, downloads): + calls, _ = downloads + (cache / "model.bin").write_bytes(DATA) + compat_conftest.downloaded_fixture("model.bin", URL, SHA) + assert calls == [] + + +def test_replaces_a_corrupt_cached_file(cache, downloads): + calls, _ = downloads + (cache / "model.bin").write_bytes(b"truncated") + path = compat_conftest.downloaded_fixture("model.bin", URL, SHA) + assert path.read_bytes() == DATA + assert calls == [URL] + + +def test_wrong_checksum_fails_and_keeps_nothing(cache, downloads): + _, served = downloads + served["data"] = b"something else" + with pytest.raises(pytest.fail.Exception, match="expected " + SHA): + compat_conftest.downloaded_fixture("model.bin", URL, SHA) + assert list(cache.iterdir()) == [] + + +def test_download_error_fails_with_the_url(cache, downloads): + _, served = downloads + served["error"] = OSError("404 Not Found") + with pytest.raises(pytest.fail.Exception, match=URL): + compat_conftest.downloaded_fixture("model.bin", URL, SHA) From d74d1d31bd6b85cb185095decf14a4d56e2f13a7 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 15:46:33 +0200 Subject: [PATCH 11/81] Docs: the gguf compat model is downloaded --- docs/compatibility.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index f51e044..cae8d07 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -9,7 +9,8 @@ generated from `scripts/compat_matrix.py`, which also defines the CI jobs. `tests/compat/test__compat.py` loads a real model with the real library and checks the events the SDK records: a `model_load` event, then an `inference` event for the same model with the expected fields (token counts -for LLMs, input modality for vision models). Nothing touches the network: +for LLMs, input modality for vision models). Apart from one download for gguf, nothing +touches the network: - openai and anthropic get canned responses through the SDK's own HTTP client (`MockTransport`). From 4c22567002478578869e2e69bc4aefb6b5d6e8f3 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 15:59:48 +0200 Subject: [PATCH 12/81] Check gguf chat and completion tracking against real llama-cpp-python Replaces the #64 xfail with compat tests for create_completion (stop reason and generation config with defaults), create_chat_completion (prompt_type, turn_index) and streamed chat, each tracked once. They fail on devel and pass with this branch on llama-cpp-python 0.2.90 and 0.3.36. --- tests/compat/test_gguf_compat.py | 60 +++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/tests/compat/test_gguf_compat.py b/tests/compat/test_gguf_compat.py index d2ebf92..1b6b656 100644 --- a/tests/compat/test_gguf_compat.py +++ b/tests/compat/test_gguf_compat.py @@ -40,13 +40,55 @@ def test_llama_call_is_tracked(compat_client, llm): assert output["tokens_out"] == result["usage"]["completion_tokens"] -@pytest.mark.xfail( - strict=True, - reason="create_completion and create_chat_completion are not tracked (#64)", -) -def test_chat_and_completion_calls_are_tracked(compat_client, llm): - llm.create_completion("Once upon a time", max_tokens=4) - llm.create_chat_completion( - messages=[{"role": "user", "content": "Tell me a story"}], max_tokens=4 +def test_create_completion_is_tracked_with_its_config(compat_client, llm): + result = llm.create_completion( + "Once upon a time", max_tokens=4, temperature=0.3, stop=["\n"] + ) + + inference = compat_client.one("inference")["inference"] + output = inference["output_meta"] + assert output["tokens_in"] == result["usage"]["prompt_tokens"] + assert output["tokens_out"] == result["usage"]["completion_tokens"] + assert output["stop_reason"] == result["choices"][0]["finish_reason"] + config = inference["generation_config"] + assert config["temperature"] == 0.3 + assert config["max_tokens"] == 4 + assert config["stop_sequences_count"] == 1 + # Arguments not passed are reported with llama-cpp-python's defaults. + assert "top_p" in config and "top_k" in config + + +def test_chat_completion_is_tracked_once_as_chat(compat_client, llm): + result = llm.create_chat_completion( + messages=[ + {"role": "user", "content": "Tell me a story"}, + {"role": "assistant", "content": "Once upon a time"}, + {"role": "user", "content": "Go on"}, + ], + max_tokens=4, ) - assert len(compat_client.of_type("inference")) == 2 + + inference = compat_client.one("inference")["inference"] + assert inference["input_meta"]["prompt_type"] == "chat" + assert inference["input_meta"]["turn_index"] == 1 + assert inference["input_meta"]["word_count"] == 2 + output = inference["output_meta"] + assert output["tokens_in"] == result["usage"]["prompt_tokens"] + assert output["stop_reason"] == result["choices"][0]["finish_reason"] + assert inference["generation_config"]["max_tokens"] == 4 + + +def test_streamed_chat_completion_is_tracked_once(compat_client, llm): + stream = llm.create_chat_completion( + messages=[{"role": "user", "content": "Tell me a story"}], + max_tokens=4, + stream=True, + ) + chunks = list(stream) + assert chunks + + inference = compat_client.one("inference")["inference"] + assert inference["input_meta"]["prompt_type"] == "chat" + output = inference["output_meta"] + assert output["time_to_first_token_ms"] >= 0 + assert output["stop_reason"] in {"stop", "length"} From b88d0486f644d20bce8ae78c855513c4d1b9c6c7 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:18:10 +0200 Subject: [PATCH 13/81] Make the gguf example a multi-turn chat The example only called llm(...). It now runs three streamed create_chat_completion turns and one create_completion, so it exercises the chat tracking from this PR. --- examples/gguf_example.py | 42 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/examples/gguf_example.py b/examples/gguf_example.py index 861603d..ae8983e 100644 --- a/examples/gguf_example.py +++ b/examples/gguf_example.py @@ -5,7 +5,14 @@ # [tool.uv.sources] # wildedge-sdk = { path = "..", editable = true } # /// -"""GGUF / llama.cpp integration example. Run with: uv run gguf_example.py""" +"""GGUF / llama.cpp integration example. Run with: uv run gguf_example.py + +The gguf integration tracks every llama-cpp-python entry point, once per +call: create_chat_completion (streamed or not), create_completion and +llm(...). Each inference event carries token counts, tokens per second, +the stop reason and the sampling settings; chat calls also record +prompt_type="chat" and the turn index. +""" from huggingface_hub import hf_hub_download from llama_cpp import Llama @@ -24,16 +31,27 @@ ) llm = Llama(model_path, n_ctx=2048, n_gpu_layers=-1, verbose=False) -prompts = [ - "Explain what a transformer architecture is in one sentence.", - "What is the capital of France?", - "Write a haiku about on-device AI.", -] - -for prompt in prompts: - stream = llm(prompt, max_tokens=128, temperature=0.7, stream=True) - print(f"Q: {prompt}\nA: ", end="", flush=True) - for chunk in stream: - token = chunk["choices"][0].get("text", "") +# A short multi-turn chat, streamed. Each turn is one inference event. +messages = [{"role": "system", "content": "You are a concise assistant."}] +for question in [ + "What is a transformer architecture, in one sentence?", + "And why does it suit on-device AI?", + "Now say that as a haiku.", +]: + messages.append({"role": "user", "content": question}) + print(f"Q: {question}\nA: ", end="", flush=True) + reply = "" + for chunk in llm.create_chat_completion( + messages=messages, max_tokens=128, temperature=0.7, top_p=0.9, stream=True + ): + token = chunk["choices"][0]["delta"].get("content") or "" + reply += token print(token, end="", flush=True) print("\n") + messages.append({"role": "assistant", "content": reply}) + +# Plain text completion, not streamed. +result = llm.create_completion( + "The capital of France is", max_tokens=8, temperature=0.0, stop=["."] +) +print(f"Completion: {result['choices'][0]['text'].strip()}") From aea50b185c79fc3575435673b7a7fc7773bba764 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:21:59 +0200 Subject: [PATCH 14/81] Import inspect at module level in the gguf tests --- tests/test_integrations.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_integrations.py b/tests/test_integrations.py index fbffefe..90e14ce 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import json from unittest.mock import MagicMock, patch @@ -452,8 +453,6 @@ def test_call_arguments_fill_defaults_and_flatten_kwargs(self): def create_completion(self, prompt, temperature=0.8, **kwargs): pass - import inspect - args = call_arguments( inspect.signature(create_completion), object(), ("hi",), {"seed": 1} ) @@ -463,8 +462,6 @@ def create_completion(self, prompt, temperature=0.8, **kwargs): assert "kwargs" not in args def test_call_arguments_fall_back_when_binding_fails(self): - import inspect - def create_completion(self, prompt): pass From 156ad6d8bdb78632627fd3369d270fe00696fc11 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:28:49 +0200 Subject: [PATCH 15/81] Enable more ruff rules and check lint in CI Lint was only run by hand, so misplaced imports and similar slips reached review. CI gets a lint job running ruff check and ruff format --check, and the rule set adds bugbear (B), comprehensions (C4), PIE, import placement (PLC0415), pytest style (PT), RET, RUF and SIM. Test doubles may keep mutable class attributes (RUF012). --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ CONTRIBUTING.md | 3 +++ pyproject.toml | 13 ++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f942d3..b9f9123 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - 'wildedge/**' - 'tests/**' - 'scripts/**' + - 'examples/**' - 'pyproject.toml' - 'uv.lock' - '.github/workflows/ci.yml' @@ -16,11 +17,31 @@ on: - 'wildedge/**' - 'tests/**' - 'scripts/**' + - 'examples/**' - 'pyproject.toml' - 'uv.lock' - '.github/workflows/ci.yml' jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install uv + run: | + pip install uv + - name: Install dependencies + run: | + uv sync --group dev + - name: Ruff lint + run: uv run ruff check --output-format=github . + - name: Ruff format + run: uv run ruff format --check . + test: runs-on: ${{ matrix.os }} strategy: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index baa954d..9bd3aac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,9 @@ uv run ruff check . uv run ruff format . ``` + CI runs both (`ruff check` and `ruff format --check`) and fails on any finding. + The rule set is in `pyproject.toml`; prefer fixing over `# noqa`, and give a + reason when a `noqa` is needed. - Follow PEP 8 and PEP 484 (type hints). diff --git a/pyproject.toml b/pyproject.toml index 6c1ef75..71f5656 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,11 +52,22 @@ packages = ["wildedge"] line-length = 88 [tool.ruff.lint] -select = ["E", "F", "W", "I", "N", "UP", "TID252"] +select = [ + "E", "F", "W", "I", "N", "UP", "TID252", + "B", # likely bugs (bugbear) + "C4", # comprehensions + "PIE", # misc. simplifications + "PLC0415", # imports belong at the top of the module + "PT", # pytest style + "RET", # return statements + "RUF", # ruff-specific checks + "SIM", # simplifications +] ignore = ["E501"] # line-length is set above [tool.ruff.lint.per-file-ignores] "wildedge/decorators.py" = ["N801"] # track is a decorator class, not following CapWords +"tests/**" = ["RUF012"] # test doubles mimic library objects with class-level lists and dicts [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" From 616498340e4355c498e75d4c5415384e4c97fac5 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:28:49 +0200 Subject: [PATCH 16/81] Fix lint findings in the SDK and examples Mostly mechanical: sorted __all__, removed unused noqa directives, dropped else after return, dict literals, itertools.pairwise, ClassVar on the client's class-level tables, contextlib.suppress in the hub cache walk, and an explicit zip strict flag (strict where lengths must match, not where a list can fall back to empty). llm_api imports get_client at module level: the import cycle its noqa mentioned no longer exists. --- examples/agentic_example.py | 2 +- examples/llm_api_example.py | 12 +++--- wildedge/__init__.py | 62 ++++++++++++++-------------- wildedge/autoload/sitecustomize.py | 9 ++-- wildedge/client.py | 6 +-- wildedge/decorators.py | 20 ++++----- wildedge/events/__init__.py | 2 +- wildedge/hubs/__init__.py | 6 +-- wildedge/hubs/base.py | 5 +-- wildedge/hubs/huggingface.py | 2 +- wildedge/integrations/anthropic.py | 50 +++++++++++----------- wildedge/integrations/common.py | 3 +- wildedge/integrations/mlx.py | 2 +- wildedge/integrations/openai.py | 6 +-- wildedge/integrations/pytorch.py | 7 +--- wildedge/integrations/tensorflow.py | 2 +- wildedge/integrations/ultralytics.py | 10 ++--- wildedge/llm_api.py | 2 +- wildedge/platforms/macos.py | 2 +- wildedge/platforms/windows.py | 2 +- wildedge/transmitter.py | 23 +++++------ 21 files changed, 112 insertions(+), 123 deletions(-) diff --git a/examples/agentic_example.py b/examples/agentic_example.py index 6874184..e4d17a0 100644 --- a/examples/agentic_example.py +++ b/examples/agentic_example.py @@ -80,7 +80,7 @@ def calculator(expression: str) -> str: # ~60ms to simulate a remote computation call. time.sleep(0.06) try: - result = eval(expression, {"__builtins__": {}}) # noqa: S307 + result = eval(expression, {"__builtins__": {}}) return json.dumps({"expression": expression, "result": result}) except Exception as e: return json.dumps({"error": str(e)}) diff --git a/examples/llm_api_example.py b/examples/llm_api_example.py index 1afe2ff..19d92f0 100644 --- a/examples/llm_api_example.py +++ b/examples/llm_api_example.py @@ -51,12 +51,12 @@ def chat(prompt: str) -> dict: with wildedge.trace(agent_id="llm-api-example", run_id=str(uuid.uuid4())): for step, prompt in enumerate(PROMPTS): - with wildedge.span(kind="agent_step", name="ask", step_index=step): - with wildedge.llm_api( - model=MODEL, provider="openrouter", prompt=prompt - ) as call: - data = chat(prompt) - call.response(data) + with ( + wildedge.span(kind="agent_step", name="ask", step_index=step), + wildedge.llm_api(model=MODEL, provider="openrouter", prompt=prompt) as call, + ): + data = chat(prompt) + call.response(data) print(f"Q: {prompt}\nA: {data['choices'][0]['message']['content']}\n") wildedge.flush() diff --git a/wildedge/__init__.py b/wildedge/__init__.py index 83aa9ec..63deebc 100644 --- a/wildedge/__init__.py +++ b/wildedge/__init__.py @@ -44,44 +44,44 @@ ) __all__ = [ - "WildEdge", - "init", - "get_client", - "set_default_client", - "trace", - "span", - "track_span", - "register_model", - "flush", - "llm_api", - "LLMCall", + "AdapterDownload", + "AdapterLoad", "Attachment", - "capture_hardware", - "HardwareContext", - "ThermalContext", - "track", - "QueuePolicy", - "DeviceInfo", - "ImageInputMeta", "AudioInputMeta", - "TextInputMeta", - "DetectionOutputMeta", "ClassificationOutputMeta", - "GenerationOutputMeta", + "DetectionOutputMeta", + "DeviceInfo", "EmbeddingOutputMeta", + "ErrorCode", + "FeedbackType", "GenerationConfig", - "AdapterLoad", - "AdapterDownload", + "GenerationOutputMeta", + "HardwareContext", + "ImageInputMeta", + "LLMCall", + "QueuePolicy", + "SpanContext", + "SpanContextManager", "SpanEvent", - "FeedbackType", - "ErrorCode", + "SpanKind", + "SpanStatus", + "TextInputMeta", + "ThermalContext", "Timer", - "span_context", "TraceContext", - "SpanContext", - "get_trace_context", + "WildEdge", + "capture_hardware", + "flush", + "get_client", "get_span_context", - "SpanKind", - "SpanStatus", - "SpanContextManager", + "get_trace_context", + "init", + "llm_api", + "register_model", + "set_default_client", + "span", + "span_context", + "trace", + "track", + "track_span", ] diff --git a/wildedge/autoload/sitecustomize.py b/wildedge/autoload/sitecustomize.py index e3a9dbd..86fa03f 100644 --- a/wildedge/autoload/sitecustomize.py +++ b/wildedge/autoload/sitecustomize.py @@ -15,6 +15,7 @@ from __future__ import annotations +import importlib.util as _iutil import os import sys @@ -37,14 +38,14 @@ def _die(code: int) -> None: os._exit(code) -if _INSTALLED_MARKER not in sys.modules: +if _INSTALLED_MARKER not in sys.modules: # noqa: SIM102 - re-entry guard kept separate # Activate if the CLI launched this process, or if WILDEDGE_DSN is set # and the user has manually prepended wildedge/autoload/ to PYTHONPATH. if os.environ.get(_AUTOLOAD) or os.environ.get(_DSN): # Set the guard before importing wildedge to prevent re-entry. sys.modules[_INSTALLED_MARKER] = True # type: ignore[assignment] try: - from wildedge.runtime.bootstrap import ( # noqa: PLC0415 + from wildedge.runtime.bootstrap import ( EXIT_BOOTSTRAP_INTERNAL_ERROR, EXIT_CONFIG_ERROR, EXIT_STRICT_INTEGRATION_ERROR, @@ -54,7 +55,7 @@ def _die(code: int) -> None: format_startup_report, install_runtime, ) - from wildedge.settings import read_runner_env # noqa: PLC0415 + from wildedge.settings import read_runner_env _runner_env = read_runner_env() _context = None @@ -99,8 +100,6 @@ def _die(code: int) -> None: # Use importlib to find and exec it directly; avoids sys.modules manipulation # which can trigger CPython's module GC and clear globals mid-execution. def _load_existing_sitecustomize() -> None: - import importlib.util as _iutil - _autoload_dir = os.path.dirname(os.path.abspath(__file__)) _saved = sys.path[:] sys.path = [p for p in sys.path if p != _autoload_dir] diff --git a/wildedge/client.py b/wildedge/client.py index 736c467..902b906 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -6,7 +6,7 @@ import weakref from collections.abc import Callable from datetime import datetime, timezone -from typing import Any +from typing import Any, ClassVar from urllib.parse import urlparse from wildedge import constants @@ -248,7 +248,7 @@ class WildEdge: # Framework integrations: patch ML runtime constructors for inference tracking. SUPPORTED_INTEGRATIONS = supported_integrations() NOOP_INTEGRATIONS = noop_integrations() - PATCH_INSTALLERS = { + PATCH_INSTALLERS: ClassVar[dict[str, Callable[[object], None]]] = { "anthropic": AnthropicExtractor.install_auto_load_patch, "gguf": GgufExtractor.install_auto_load_patch, "mlx": MlxExtractor.install_auto_load_patch, @@ -262,7 +262,7 @@ class WildEdge: # Hub trackers: record download provenance (where models came from). SUPPORTED_HUBS = supported_hubs() - HUB_TRACKER_CLASSES: dict[str, type[BaseHubTracker]] = { + HUB_TRACKER_CLASSES: ClassVar[dict[str, type[BaseHubTracker]]] = { "huggingface": HuggingFaceHubTracker, "torchhub": TorchHubTracker, } diff --git a/wildedge/decorators.py b/wildedge/decorators.py index f953159..86cbd65 100644 --- a/wildedge/decorators.py +++ b/wildedge/decorators.py @@ -54,16 +54,16 @@ def __init__( self.input_meta = input_meta self.output_meta = output_meta self.generation_config = generation_config - self._correlation = dict( - trace_id=trace_id, - span_id=span_id, - parent_span_id=parent_span_id, - run_id=run_id, - agent_id=agent_id, - step_index=step_index, - conversation_id=conversation_id, - context=context, - ) + self._correlation = { + "trace_id": trace_id, + "span_id": span_id, + "parent_span_id": parent_span_id, + "run_id": run_id, + "agent_id": agent_id, + "step_index": step_index, + "conversation_id": conversation_id, + "context": context, + } self.start_time: float | None = None def __call__(self, func): diff --git a/wildedge/events/__init__.py b/wildedge/events/__init__.py index 6648c40..3a75e29 100644 --- a/wildedge/events/__init__.py +++ b/wildedge/events/__init__.py @@ -22,9 +22,9 @@ from wildedge.events.span import SpanEvent __all__ = [ - "ApiMeta", "AdapterDownload", "AdapterLoad", + "ApiMeta", "AudioInputMeta", "ClassificationOutputMeta", "DetectionOutputMeta", diff --git a/wildedge/hubs/__init__.py b/wildedge/hubs/__init__.py index 0de302a..29681ff 100644 --- a/wildedge/hubs/__init__.py +++ b/wildedge/hubs/__init__.py @@ -37,11 +37,11 @@ from wildedge.hubs.torchhub import TorchHubTracker __all__ = [ + "HUBS_BY_NAME", + "HUB_SPECS", "BaseHubTracker", + "HubSpec", "HuggingFaceHubTracker", "TorchHubTracker", - "HubSpec", - "HUB_SPECS", - "HUBS_BY_NAME", "supported_hubs", ] diff --git a/wildedge/hubs/base.py b/wildedge/hubs/base.py index c4a40e8..1e41461 100644 --- a/wildedge/hubs/base.py +++ b/wildedge/hubs/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import os from abc import ABC, abstractmethod @@ -77,10 +78,8 @@ def scan_cache(self) -> dict[str, int]: path = os.path.join(dirpath, filename) if os.path.islink(path): continue - try: + with contextlib.suppress(OSError): snapshot[path] = os.path.getsize(path) - except OSError: - pass except Exception: pass return snapshot diff --git a/wildedge/hubs/huggingface.py b/wildedge/hubs/huggingface.py index da91503..239ea69 100644 --- a/wildedge/hubs/huggingface.py +++ b/wildedge/hubs/huggingface.py @@ -242,7 +242,7 @@ def drain(self) -> list[dict]: records.clear() return result - def install_patch(self, client_ref: object) -> None: # noqa: ARG002 + def install_patch(self, client_ref: object) -> None: """ Patch ``hf_hub_download`` and ``snapshot_download`` for thread-local tracking. diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index d64bb60..4544802 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -221,19 +221,18 @@ def patched_create(*args, **kwargs): handle, messages ) return SyncStreamWrapper(result, handle, t0, on_chunk, on_done) - else: - duration = elapsed_ms(t0) - usage = getattr(result, "usage", None) - tokens_in = getattr(usage, "input_tokens", None) if usage else None - handle.track_inference( - duration_ms=duration, - input_modality="text", - output_modality="generation", - success=True, - input_meta=build_input_meta(messages, tokens_in), - output_meta=build_output_meta(result, duration), - api_meta=build_api_meta(result), - ) + duration = elapsed_ms(t0) + usage = getattr(result, "usage", None) + tokens_in = getattr(usage, "input_tokens", None) if usage else None + handle.track_inference( + duration_ms=duration, + input_modality="text", + output_modality="generation", + success=True, + input_meta=build_input_meta(messages, tokens_in), + output_meta=build_output_meta(result, duration), + api_meta=build_api_meta(result), + ) return result except Exception as exc: if handle is not None: @@ -264,19 +263,18 @@ async def patched_create(*args, **kwargs): handle, messages ) return AsyncStreamWrapper(result, handle, t0, on_chunk, on_done) - else: - duration = elapsed_ms(t0) - usage = getattr(result, "usage", None) - tokens_in = getattr(usage, "input_tokens", None) if usage else None - handle.track_inference( - duration_ms=duration, - input_modality="text", - output_modality="generation", - success=True, - input_meta=build_input_meta(messages, tokens_in), - output_meta=build_output_meta(result, duration), - api_meta=build_api_meta(result), - ) + duration = elapsed_ms(t0) + usage = getattr(result, "usage", None) + tokens_in = getattr(usage, "input_tokens", None) if usage else None + handle.track_inference( + duration_ms=duration, + input_modality="text", + output_modality="generation", + success=True, + input_meta=build_input_meta(messages, tokens_in), + output_meta=build_output_meta(result, duration), + api_meta=build_api_meta(result), + ) return result except Exception as exc: if handle is not None: diff --git a/wildedge/integrations/common.py b/wildedge/integrations/common.py index 7442d43..bd8f523 100644 --- a/wildedge/integrations/common.py +++ b/wildedge/integrations/common.py @@ -2,6 +2,7 @@ from __future__ import annotations +import itertools from collections.abc import Callable from typing import TYPE_CHECKING, Any from urllib.parse import urlparse @@ -191,7 +192,7 @@ def image_brightness_histogram(norm_flat: Any) -> tuple[float, float, list[int]] edges = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] buckets = [ int(((norm_flat >= lo) & (norm_flat < hi)).sum()) - for lo, hi in zip(edges, edges[1:]) + for lo, hi in itertools.pairwise(edges) ] buckets[-1] += int((norm_flat == 1.0).sum()) diff --git a/wildedge/integrations/mlx.py b/wildedge/integrations/mlx.py index 4613219..fbc243e 100644 --- a/wildedge/integrations/mlx.py +++ b/wildedge/integrations/mlx.py @@ -244,7 +244,7 @@ def patched_load(path_or_hf_repo, *args, **kwargs): # type: ignore[no-untyped-d _mlx_lm.load = patched_load @classmethod - def _patch_generate(cls, client_ref: object) -> None: # noqa: ARG003 + def _patch_generate(cls, client_ref: object) -> None: original_generate = _mlx_lm.generate if ( getattr(original_generate, "__wildedge_patch_name__", None) diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index 299f9b9..4ced640 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -208,8 +208,7 @@ def patched_create(*args, **kwargs): if is_streaming: on_chunk, on_done = make_openai_stream_callbacks(handle, messages) return SyncStreamWrapper(result, handle, t0, on_chunk, on_done) - else: - record_inference(handle, result, messages, elapsed_ms(t0)) + record_inference(handle, result, messages, elapsed_ms(t0)) return result except Exception as exc: if handle is not None: @@ -240,8 +239,7 @@ async def patched_create(*args, **kwargs): if is_streaming: on_chunk, on_done = make_openai_stream_callbacks(handle, messages) return AsyncStreamWrapper(result, handle, t0, on_chunk, on_done) - else: - record_inference(handle, result, messages, elapsed_ms(t0)) + record_inference(handle, result, messages, elapsed_ms(t0)) return result except Exception as exc: if handle is not None: diff --git a/wildedge/integrations/pytorch.py b/wildedge/integrations/pytorch.py index c9f414b..b81029b 100644 --- a/wildedge/integrations/pytorch.py +++ b/wildedge/integrations/pytorch.py @@ -102,10 +102,7 @@ def image_input_meta(tensor: object) -> ImageInputMeta | None: t_min = float(tensor.min().item()) # type: ignore[union-attr] t_max = float(tensor.max().item()) # type: ignore[union-attr] span = t_max - t_min - if span > 0: - norm = (tensor - t_min) / span # type: ignore[operator] - else: - norm = tensor # type: ignore[assignment] + norm = (tensor - t_min) / span if span > 0 else tensor # type: ignore[operator] brightness_mean, brightness_stddev, buckets = image_brightness_histogram( norm.flatten() @@ -157,7 +154,7 @@ def classification_output_meta( label=labels[int(idx.item())] if labels else str(int(idx.item())), confidence=round(float(conf.item()), 4), ) - for conf, idx in zip(top_vals, top_idx) + for conf, idx in zip(top_vals, top_idx, strict=True) ] return ClassificationOutputMeta( diff --git a/wildedge/integrations/tensorflow.py b/wildedge/integrations/tensorflow.py index 03d2416..d8b3c6f 100644 --- a/wildedge/integrations/tensorflow.py +++ b/wildedge/integrations/tensorflow.py @@ -168,7 +168,7 @@ def patched_predict(*args, **kwargs): ) raise - setattr(obj, "predict", patched_predict) + obj.predict = patched_predict setattr(obj, TENSORFLOW_PREDICT_PATCHED_ATTR, True) diff --git a/wildedge/integrations/ultralytics.py b/wildedge/integrations/ultralytics.py index ccd4531..e96a9e9 100644 --- a/wildedge/integrations/ultralytics.py +++ b/wildedge/integrations/ultralytics.py @@ -155,7 +155,7 @@ def detection_output_meta( total_preds += len(confs) all_confs.extend(confs) - for i, (conf, cls_id) in enumerate(zip(confs, classes)): + for i, (conf, cls_id) in enumerate(zip(confs, classes, strict=False)): if len(top_preds) >= 5: break label = (names or {}).get(int(cls_id), str(int(cls_id))) @@ -199,7 +199,7 @@ def classify_output_meta( top5conf = ( probs.top5conf.tolist() if hasattr(probs.top5conf, "tolist") else [] ) - for cls_id, conf in zip(top5, top5conf): + for cls_id, conf in zip(top5, top5conf, strict=False): label = (names or {}).get(int(cls_id), str(int(cls_id))) top_preds.append( TopKPrediction(label=label, confidence=round(float(conf), 4)) @@ -228,12 +228,10 @@ def weights_file_exists(model_arg: object) -> bool: if p.is_file(): return True # ultralytics resolves short names (e.g. "yolov8n.pt") against its assets dir - if ( + return ( _ULTRALYTICS_WEIGHTS_DIR is not None and (_ULTRALYTICS_WEIGHTS_DIR / p.name).is_file() - ): - return True - return False + ) def build_download_record(obj: object, load_ms: int) -> dict | None: diff --git a/wildedge/llm_api.py b/wildedge/llm_api.py index 47ff17f..7b0ca24 100644 --- a/wildedge/llm_api.py +++ b/wildedge/llm_api.py @@ -22,6 +22,7 @@ import time from typing import TYPE_CHECKING, Any +from wildedge.defaults import get_client from wildedge.events.inference import ApiMeta, GenerationOutputMeta, TextInputMeta from wildedge.integrations.common import ( build_input_meta, @@ -179,7 +180,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: return self.__exit__(exc_type, exc_val, exc_tb) def _handle(self) -> ModelHandle | None: - from wildedge.defaults import get_client # noqa: PLC0415 (import cycle) try: return get_client().register_model( diff --git a/wildedge/platforms/macos.py b/wildedge/platforms/macos.py index dc86852..99ef144 100644 --- a/wildedge/platforms/macos.py +++ b/wildedge/platforms/macos.py @@ -21,7 +21,7 @@ def _get_libc() -> ctypes.CDLL: CF_STRING_ENCODING_UTF8 = 0x08000100 CF_NUMBER_SINT32_TYPE = 3 -# NSProcessInfoThermalState values (0–3) +# NSProcessInfoThermalState values (0-3) MACOS_THERMAL_STATES: dict[int, tuple[str, str]] = { 0: ("nominal", "nominal"), 1: ("fair", "fair"), diff --git a/wildedge/platforms/windows.py b/wildedge/platforms/windows.py index 7748816..67dbd48 100644 --- a/wildedge/platforms/windows.py +++ b/wildedge/platforms/windows.py @@ -91,7 +91,7 @@ def meminfo(self) -> tuple[int | None, int | None]: def disk_bytes(self) -> int | None: try: - drive = os.environ.get("SystemDrive", "C:\\") + drive = os.environ.get("SYSTEMDRIVE", "C:\\") return shutil.disk_usage(drive).total except Exception as exc: debug_detection_failure("windows disk_bytes", exc) diff --git a/wildedge/transmitter.py b/wildedge/transmitter.py index f31c88d..de74bed 100644 --- a/wildedge/transmitter.py +++ b/wildedge/transmitter.py @@ -15,7 +15,7 @@ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): """Raise immediately on any redirect instead of following it.""" - def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: PLR0913 + def redirect_request(self, req, fp, code, msg, headers, newurl): raise urllib.error.HTTPError(newurl, code, msg, headers, fp) @@ -78,7 +78,7 @@ def send(self, batch: dict) -> IngestResponse: server_time=data.get("server_time"), rejected=data.get("rejected"), ) - elif status_code == 400: + if status_code == 400: logger.warning( "wildedge: batch rejected (400) - discarding: %s", raw[: constants.ERROR_MSG_MAX_LEN], @@ -89,7 +89,7 @@ def send(self, batch: dict) -> IngestResponse: events_accepted=0, events_rejected=len(batch.get("events", [])), ) - elif status_code == 401: + if status_code == 401: logger.error("wildedge: authentication failed (401) - check your API key") return IngestResponse( status="unauthorized", @@ -97,7 +97,7 @@ def send(self, batch: dict) -> IngestResponse: events_accepted=0, events_rejected=len(batch.get("events", [])), ) - elif 300 <= status_code < 400: + if 300 <= status_code < 400: # Redirects should never occur (we disable redirect following). # Treat as a permanent config error so we don't loop. logger.error( @@ -111,7 +111,7 @@ def send(self, batch: dict) -> IngestResponse: events_accepted=0, events_rejected=len(batch.get("events", [])), ) - elif status_code == 404: + if status_code == 404: logger.error( "wildedge: endpoint not found (404) at %s; check WILDEDGE_DSN", url ) @@ -121,11 +121,11 @@ def send(self, batch: dict) -> IngestResponse: events_accepted=0, events_rejected=len(batch.get("events", [])), ) - elif status_code == 429 or status_code >= 500: + if status_code == 429 or status_code >= 500: raise TransmitError( f"HTTP {status_code}: {raw[: constants.ERROR_MSG_MAX_LEN]!r}" ) - elif 400 <= status_code < 500: + if 400 <= status_code < 500: # Other 4xx (e.g. 422 Unprocessable) are permanent client errors; discard. logger.warning( "wildedge: batch rejected (%d) - discarding: %s", @@ -138,11 +138,10 @@ def send(self, batch: dict) -> IngestResponse: events_accepted=0, events_rejected=len(batch.get("events", [])), ) - else: - # Guard against unexpected status codes that would otherwise return None. - raise TransmitError( - f"Unexpected HTTP {status_code}: {raw[: constants.ERROR_MSG_MAX_LEN]!r}" - ) + # Guard against unexpected status codes that would otherwise return None. + raise TransmitError( + f"Unexpected HTTP {status_code}: {raw[: constants.ERROR_MSG_MAX_LEN]!r}" + ) def close(self) -> None: pass From 44ed3b76bb55342dbff590a20f4489ecdc96d632 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:28:49 +0200 Subject: [PATCH 17/81] Fix lint findings in the tests Imports move to module level, except numpy, which stays optional through pytest.importorskip; the sampler test reads platforms._sampler at call time since start_sampler replaces it. Also tuple parametrize names, split composite asserts, combined with statements, match= on pytest.raises, raise from None in fake async iterators, and a duplicate parametrize case removed. --- tests/conftest.py | 13 +++---- tests/test_attachments.py | 19 +++------ tests/test_autoload.py | 14 ++----- tests/test_cli.py | 13 ++----- tests/test_client.py | 40 ++----------------- tests/test_client_flows.py | 8 ++-- tests/test_consumer.py | 7 ++-- tests/test_decorators.py | 10 +++-- tests/test_defaults.py | 10 +++-- tests/test_hubs.py | 6 +-- tests/test_init.py | 9 +---- tests/test_integration_patching.py | 20 +++------- tests/test_integrations_anthropic.py | 2 +- tests/test_integrations_common.py | 20 +++++----- tests/test_integrations_openai.py | 7 ++-- tests/test_integrations_ultralytics.py | 5 +-- tests/test_llm_api.py | 18 ++++----- tests/test_model.py | 12 +++--- tests/test_platform_adapter.py | 18 ++++++--- tests/test_platform_adapters.py | 11 +----- tests/test_platform_linux.py | 11 ++++-- tests/test_platform_macos.py | 8 ++-- tests/test_platform_windows.py | 6 ++- tests/test_sampler.py | 11 ++---- tests/test_tensorflow_integration.py | 3 +- tests/test_tracing.py | 24 +++++++----- tests/test_transmitter.py | 54 +++++++++++++++----------- 27 files changed, 157 insertions(+), 222 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3c8890a..88f2f0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,23 +6,23 @@ import pytest +import wildedge.paths as paths from wildedge.client import WildEdge +from wildedge.defaults import set_default_client from wildedge.model import ModelInfo +from wildedge.platforms import stop_sampler from wildedge.platforms.device_info import DeviceInfo @pytest.fixture(autouse=True) def reset_hardware_sampler(): yield - from wildedge.platforms import stop_sampler stop_sampler() @pytest.fixture(autouse=True) def reset_default_client(): - from wildedge.defaults import set_default_client - set_default_client(None) yield set_default_client(None) @@ -37,7 +37,6 @@ def isolate_sdk_state(tmp_path, monkeypatch): between runs and machines (and once masking a real regression locally while CI failed). """ - import wildedge.paths as paths monkeypatch.setattr(paths, "default_sdk_state_dir", lambda: tmp_path / "state") monkeypatch.setattr(paths, "default_sdk_cache_dir", lambda: tmp_path / "cache") @@ -103,7 +102,7 @@ def publish(event): @pytest.fixture def dummy_handle(): - handle = SimpleNamespace( + return SimpleNamespace( model_id="model-1", track_download=MagicMock(), track_load=MagicMock(), @@ -111,7 +110,6 @@ def dummy_handle(): track_inference=MagicMock(), track_error=MagicMock(), ) - return handle @pytest.fixture @@ -121,8 +119,7 @@ def client_with_stubbed_runtime(): patch("wildedge.client.Transmitter"), patch("wildedge.client.Consumer"), ): - client = WildEdge( + return WildEdge( dsn="https://secret@ingest.wildedge.dev/key", sampling_interval_s=None, ) - return client diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 46ff061..d265359 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -6,6 +6,7 @@ import urllib.request from datetime import datetime, timezone +from wildedge import constants from wildedge.attachments import ( Attachment, AttachmentManager, @@ -15,7 +16,8 @@ PendingAttachment, UploadOutcome, ) -from wildedge.events.inference import InferenceEvent +from wildedge.client import WildEdge +from wildedge.events.inference import InferenceEvent, TextInputMeta from wildedge.model import ModelHandle, ModelInfo # --------------------------------------------------------------------------- # @@ -41,7 +43,8 @@ def test_store_file_strategy_roundtrip(tmp_path): assert len(pending) == 1 assert pending[0].attachment_id == "a1" assert pending[0].read_bytes() == b"hello" - assert pending[0].bin_path is not None and pending[0].bin_path.exists() + assert pending[0].bin_path is not None + assert pending[0].bin_path.exists() def test_store_inline_strategy_roundtrip(tmp_path): @@ -158,7 +161,7 @@ def test_manager_drops_oversized(tmp_path): def test_manager_filter_hook(tmp_path): - store, mgr = _manager( + _store, mgr = _manager( tmp_path, attachment_filter=lambda atts: [a for a in atts if a.role == "input"], ) @@ -199,8 +202,6 @@ def test_inference_event_omits_attachments_when_absent(): def test_text_input_meta_no_has_attachments(): - from wildedge.events.inference import TextInputMeta - assert not hasattr(TextInputMeta(char_count=1), "has_attachments") @@ -435,9 +436,6 @@ def test_uploader_evicts_stale(tmp_path): def test_client_attachments_disabled_by_default(monkeypatch): - from wildedge import constants - from wildedge.client import WildEdge - monkeypatch.delenv(constants.ENV_ATTACHMENTS_ENABLED, raising=False) client = WildEdge(dsn="https://test@test.com/key") try: @@ -448,8 +446,6 @@ def test_client_attachments_disabled_by_default(monkeypatch): def test_client_attachments_enabled_wires_capture(monkeypatch, tmp_path): - from wildedge.client import WildEdge - client = WildEdge( dsn="https://test@test.com/key", attachments_enabled=True, @@ -465,9 +461,6 @@ def test_client_attachments_enabled_wires_capture(monkeypatch, tmp_path): def test_client_attachments_enabled_via_env(monkeypatch, tmp_path): - from wildedge import constants - from wildedge.client import WildEdge - monkeypatch.setenv(constants.ENV_ATTACHMENTS_ENABLED, "1") client = WildEdge( dsn="https://test@test.com/key", diff --git a/tests/test_autoload.py b/tests/test_autoload.py index 578af6a..7e699ff 100644 --- a/tests/test_autoload.py +++ b/tests/test_autoload.py @@ -4,12 +4,15 @@ import importlib import importlib.util +import os +import pathlib import subprocess import sys import textwrap from unittest.mock import patch import wildedge.autoload.sitecustomize as _sc_mod +from wildedge.runtime.bootstrap import RuntimeConfigError, RuntimeStrictIntegrationError _MARKER = _sc_mod._INSTALLED_MARKER @@ -138,8 +141,6 @@ def test_uvicorn_reload_worker_bootstraps(tmp_path): across exec and blocked the worker's bootstrap. sys.modules is not inherited across exec, so the worker re-initialises correctly. """ - import os - import pathlib autoload_dir = str( pathlib.Path( @@ -179,8 +180,6 @@ def test_uvicorn_reload_worker_bootstraps(tmp_path): def test_strict_missing_dsn_exits_120(monkeypatch): - from wildedge.runtime.bootstrap import RuntimeConfigError - monkeypatch.delitem(sys.modules, _MARKER, raising=False) monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") monkeypatch.setenv("WILDEDGE_STRICT", "1") @@ -200,7 +199,6 @@ def test_strict_missing_dsn_exits_120(monkeypatch): def test_strict_integration_error_exits_121_without_strict_env(monkeypatch): """--strict-integrations is its own opt-in; it exits even without --strict.""" - from wildedge.runtime.bootstrap import RuntimeStrictIntegrationError monkeypatch.delitem(sys.modules, _MARKER, raising=False) monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") @@ -236,8 +234,6 @@ def test_strict_internal_error_exits_122(monkeypatch): def test_non_strict_config_error_warns_and_continues(monkeypatch, capsys): - from wildedge.runtime.bootstrap import RuntimeConfigError - monkeypatch.delitem(sys.modules, _MARKER, raising=False) monkeypatch.setenv("WILDEDGE_AUTOLOAD", "1") monkeypatch.delenv("WILDEDGE_STRICT", raising=False) @@ -277,8 +273,6 @@ class FakeContext: def test_no_propagate_clears_runtime_env(monkeypatch): - import os - class FakeContext: debug = False print_startup_report = False @@ -300,8 +294,6 @@ class FakeContext: def test_strict_missing_dsn_exit_code_in_real_interpreter(tmp_path): """sys.exit inside sitecustomize must terminate interpreter startup with our code.""" - import os - import pathlib autoload_dir = str( pathlib.Path( diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bb3cf3..882bf3b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,8 +7,11 @@ import pytest from wildedge import cli, constants +from wildedge.defaults import peek_default_client from wildedge.integrations.registry import IntegrationSpec +from wildedge.platforms.device_info import DeviceInfo from wildedge.runtime import bootstrap +from wildedge.transmitter import IngestResponse, TransmitError def _fake_execle(captured: dict): @@ -432,8 +435,6 @@ def test_parse_run_args_only_double_dash_raises(): def test_install_runtime_registers_default_client(monkeypatch): - from wildedge.defaults import peek_default_client - class FakeWildEdge: SUPPORTED_INTEGRATIONS = {"onnx"} @@ -467,14 +468,10 @@ def _doctor_env(monkeypatch): def _fake_device(api_key, app_version, overrides=None): - from wildedge.platforms.device_info import DeviceInfo - return DeviceInfo(device_id="d", device_type="linux") def test_doctor_send_test_event_accepted(monkeypatch, capsys): - from wildedge.transmitter import IngestResponse - _doctor_env(monkeypatch) monkeypatch.setattr(cli, "detect_device", _fake_device) sent_batches: list[dict] = [] @@ -504,8 +501,6 @@ def send(self, batch): def test_doctor_send_test_event_unauthorized_exits_2(monkeypatch, capsys): - from wildedge.transmitter import IngestResponse - _doctor_env(monkeypatch) monkeypatch.setattr(cli, "detect_device", _fake_device) @@ -530,8 +525,6 @@ def send(self, batch): def test_doctor_send_test_event_network_error_exits_2(monkeypatch, capsys): - from wildedge.transmitter import TransmitError - _doctor_env(monkeypatch) monkeypatch.setattr(cli, "detect_device", _fake_device) diff --git a/tests/test_client.py b/tests/test_client.py index 127dc29..4620aa5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,7 +4,9 @@ import pytest +import wildedge.client as client_module from wildedge import constants +from wildedge.client import WildEdge @pytest.fixture(autouse=True) @@ -22,8 +24,6 @@ def mock_dependencies(): def test_batch_size_too_low(): - from wildedge.client import WildEdge - with pytest.raises( ValueError, match=f"batch_size must be between {constants.BATCH_SIZE_MIN} and {constants.BATCH_SIZE_MAX}", @@ -32,9 +32,6 @@ def test_batch_size_too_low(): def test_no_dsn_is_noop(monkeypatch, caplog): - import wildedge.client as client_module - from wildedge.client import WildEdge - monkeypatch.delenv(constants.ENV_DSN, raising=False) monkeypatch.setattr(client_module, "_noop_notice_logged", False) with caplog.at_level("INFO"): @@ -52,8 +49,6 @@ def test_no_dsn_is_noop(monkeypatch, caplog): def test_no_dsn_instrument_does_not_raise(monkeypatch): - from wildedge.client import WildEdge - monkeypatch.delenv(constants.ENV_DSN, raising=False) client = WildEdge() @@ -62,8 +57,6 @@ def test_no_dsn_instrument_does_not_raise(monkeypatch): def test_no_dsn_publish_does_not_enqueue(monkeypatch): - from wildedge.client import WildEdge - monkeypatch.delenv(constants.ENV_DSN, raising=False) client = WildEdge() @@ -72,8 +65,6 @@ def test_no_dsn_publish_does_not_enqueue(monkeypatch): def test_batch_size_too_high(): - from wildedge.client import WildEdge - with pytest.raises( ValueError, match=f"batch_size must be between {constants.BATCH_SIZE_MIN} and {constants.BATCH_SIZE_MAX}", @@ -82,8 +73,6 @@ def test_batch_size_too_high(): def test_flush_interval_too_low(): - from wildedge.client import WildEdge - with pytest.raises( ValueError, match=f"flush_interval_sec must be between {constants.FLUSH_INTERVAL_MIN} and {constants.FLUSH_INTERVAL_MAX}", @@ -92,8 +81,6 @@ def test_flush_interval_too_low(): def test_flush_interval_too_high(): - from wildedge.client import WildEdge - with pytest.raises( ValueError, match=f"flush_interval_sec must be between {constants.FLUSH_INTERVAL_MIN} and {constants.FLUSH_INTERVAL_MAX}", @@ -102,8 +89,6 @@ def test_flush_interval_too_high(): def test_max_queue_size_too_low(): - from wildedge.client import WildEdge - with pytest.raises( ValueError, match=f"max_queue_size must be between {constants.MAX_QUEUE_SIZE_MIN} and {constants.MAX_QUEUE_SIZE_MAX}", @@ -112,8 +97,6 @@ def test_max_queue_size_too_low(): def test_max_queue_size_too_high(): - from wildedge.client import WildEdge - with pytest.raises( ValueError, match=f"max_queue_size must be between {constants.MAX_QUEUE_SIZE_MIN} and {constants.MAX_QUEUE_SIZE_MAX}", @@ -122,8 +105,6 @@ def test_max_queue_size_too_high(): def test_valid_values(): - from wildedge.client import WildEdge - # Should not raise client = WildEdge( dsn="https://test@test.com/key", @@ -135,22 +116,16 @@ def test_valid_values(): def test_max_event_age_must_be_positive(): - from wildedge.client import WildEdge - with pytest.raises(ValueError, match="max_event_age_sec must be greater than 0"): WildEdge(dsn="https://test@test.com/key", max_event_age_sec=0) def test_max_dead_letter_batches_must_be_non_negative(): - from wildedge.client import WildEdge - with pytest.raises(ValueError, match="max_dead_letter_batches must be >= 0"): WildEdge(dsn="https://test@test.com/key", max_dead_letter_batches=-1) def test_app_identity_defaults_to_project_key(): - from wildedge.client import WildEdge - with ( patch( "wildedge.client.default_pending_queue_dir", return_value="pending-dir" @@ -167,8 +142,6 @@ def test_app_identity_defaults_to_project_key(): def test_app_identity_override_used_for_paths(): - from wildedge.client import WildEdge - with ( patch( "wildedge.client.default_pending_queue_dir", return_value="pending-dir" @@ -185,8 +158,6 @@ def test_app_identity_override_used_for_paths(): def test_app_identity_env_override_used_for_paths(monkeypatch): - from wildedge.client import WildEdge - monkeypatch.setenv(constants.ENV_APP_IDENTITY, "env-app") with ( patch( @@ -204,8 +175,6 @@ def test_app_identity_env_override_used_for_paths(monkeypatch): def test_register_model_defaults_name_to_model_id_and_accepts_format(): - from wildedge.client import WildEdge - client = WildEdge(dsn="https://secret@ingest.wildedge.dev/key") client.registry.register.return_value = (object(), True) @@ -220,8 +189,6 @@ def test_register_model_defaults_name_to_model_id_and_accepts_format(): def test_register_model_format_defaults_to_unknown(): - from wildedge.client import WildEdge - client = WildEdge(dsn="https://secret@ingest.wildedge.dev/key") client.registry.register.return_value = (object(), True) @@ -232,12 +199,11 @@ def test_register_model_format_defaults_to_unknown(): @pytest.mark.parametrize( - "class_name, source", + ("class_name", "source"), [("Messages", "anthropic"), ("Completions", "openrouter")], ) def test_register_model_api_integrations_use_their_extractor(class_name, source): """The anthropic and openai integrations register the patched resource object.""" - from wildedge.client import WildEdge client = WildEdge(dsn="https://secret@ingest.wildedge.dev/key") client.registry.register.return_value = (object(), True) diff --git a/tests/test_client_flows.py b/tests/test_client_flows.py index e0ab7ae..ede625b 100644 --- a/tests/test_client_flows.py +++ b/tests/test_client_flows.py @@ -13,9 +13,11 @@ def test_register_model_fallback_requires_id_when_no_extractor( client_with_stubbed_runtime, ): client = client_with_stubbed_runtime - with patch.object(client, "_find_extractor", return_value=None): - with pytest.raises(ValueError, match="Could not auto-derive a stable model_id"): - client.register_model(object()) + with ( + patch.object(client, "_find_extractor", return_value=None), + pytest.raises(ValueError, match="Could not auto-derive a stable model_id"), + ): + client.register_model(object()) def test_on_model_auto_loaded_uses_hub_records_when_downloads_missing( diff --git a/tests/test_consumer.py b/tests/test_consumer.py index 0db0971..3d61885 100644 --- a/tests/test_consumer.py +++ b/tests/test_consumer.py @@ -6,6 +6,7 @@ import pytest from wildedge import constants +from wildedge.client import WildEdge from wildedge.consumer import Consumer from wildedge.platforms.device_info import DeviceInfo from wildedge.queue import EventQueue @@ -42,7 +43,7 @@ def _make_consumer(self, queue, transmitter, **kwargs) -> Consumer: queue=queue, transmitter=transmitter, device=make_device(), - get_models=lambda: {}, + get_models=dict, session_id="sess-1", **kwargs, ) @@ -359,7 +360,7 @@ def _make_consumer(self, queue, transmitter, **kwargs) -> Consumer: queue=queue, transmitter=transmitter, device=DeviceInfo(app_version="1.0", device_id="d", device_type="linux"), - get_models=lambda: {}, + get_models=dict, session_id="sess-fork", **kwargs, ) @@ -449,7 +450,6 @@ class TestForkRegistration: ) def test_register_at_fork_wires_pause_and_resume(self, monkeypatch): """WildEdge.__init__ registers _pause and _resume via os.register_at_fork.""" - from wildedge.client import WildEdge registered = {} @@ -475,7 +475,6 @@ def fake_register_at_fork(*, before, after_in_child, after_in_parent): def test_register_at_fork_noop_on_windows(self, monkeypatch): """When os.register_at_fork is absent (Windows), __init__ must not raise.""" - from wildedge.client import WildEdge monkeypatch.delattr(os, "register_at_fork", raising=False) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 24ba728..94d48b6 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -56,7 +56,7 @@ def test_decorator_without_error_capture_does_not_track_error(self, publish_spy) def failing_func(): raise ValueError("oops") - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="oops"): failing_func() assert len(publish_spy.events) == 0 @@ -95,9 +95,11 @@ def test_context_manager_tracks_successful_inference(self, publish_spy): def test_context_manager_tracks_error_and_does_not_suppress(self, publish_spy): handle = make_handle(publish_spy) - with pytest.raises(ValueError, match="boom"): - with track(handle, capture_errors=True): - raise ValueError("boom") + with ( + pytest.raises(ValueError, match="boom"), + track(handle, capture_errors=True), + ): + raise ValueError("boom") assert len(publish_spy.events) == 1 event = publish_spy.events[0] diff --git a/tests/test_defaults.py b/tests/test_defaults.py index a8cbe9e..ff9e1b9 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -117,8 +117,10 @@ def test_trace_is_pure_context_and_creates_no_client(monkeypatch): def test_module_api_works_end_to_end_without_dsn(monkeypatch): monkeypatch.delenv(constants.ENV_DSN, raising=False) monkeypatch.delenv(constants.ENV_INTEGRATIONS, raising=False) - with wildedge.trace(run_id="r"): - with wildedge.span(kind="eval", name="lint") as span: - span.set_attributes(lint_errors=3) - span.fail("3 lint errors") + with ( + wildedge.trace(run_id="r"), + wildedge.span(kind="eval", name="lint") as span, + ): + span.set_attributes(lint_errors=3) + span.fail("3 lint errors") assert wildedge.get_client().noop is True diff --git a/tests/test_hubs.py b/tests/test_hubs.py index bdb1108..fff52d9 100644 --- a/tests/test_hubs.py +++ b/tests/test_hubs.py @@ -8,6 +8,8 @@ import pytest +import wildedge.hubs.huggingface as hf_mod +import wildedge.hubs.torchhub as torchhub_mod from wildedge.hubs.huggingface import HuggingFaceHubTracker from wildedge.hubs.torchhub import TorchHubTracker @@ -145,8 +147,6 @@ def test_torch_hub_diff_to_records_empty_when_no_new_files(): def test_torch_hub_install_patch_is_idempotent(monkeypatch): - import wildedge.hubs.torchhub as torchhub_mod - original_load_calls = [] class FakeHub: @@ -179,8 +179,6 @@ def get_dir(): def test_hf_tracker_drain_returns_and_clears_buffer(monkeypatch): - import wildedge.hubs.huggingface as hf_mod - tracker = HuggingFaceHubTracker() # Directly inject a record into the thread-local buffer hf_mod._buffer().append({"repo_id": "test/model", "source_type": "huggingface"}) diff --git a/tests/test_init.py b/tests/test_init.py index c8d6a7a..f09e510 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -2,6 +2,7 @@ import wildedge import wildedge.convenience as convenience +from wildedge.defaults import peek_default_client, set_default_client def test_init_calls_instrument_for_integrations(monkeypatch): @@ -70,8 +71,6 @@ def instrument(self, integration, *, hubs=None): def test_init_reuses_existing_default_client(monkeypatch): - from wildedge.defaults import peek_default_client, set_default_client - calls: list[tuple[str | None, list[str] | None]] = [] class ExistingClient: @@ -89,8 +88,6 @@ def instrument(self, integration, *, hubs=None): def test_init_with_dsn_replaces_default_client(monkeypatch): - from wildedge.defaults import peek_default_client, set_default_client - class ExistingClient: def instrument(self, integration, *, hubs=None): raise AssertionError("existing client must not be touched") @@ -114,8 +111,6 @@ def instrument(self, integration, *, hubs=None): def test_init_reuse_warns_about_ignored_kwargs(monkeypatch): - from wildedge.defaults import set_default_client - class ExistingClient: def instrument(self, integration, *, hubs=None): pass @@ -134,8 +129,6 @@ def instrument(self, integration, *, hubs=None): def test_init_registers_new_client_as_default(monkeypatch): - from wildedge.defaults import peek_default_client - class DummyClient: def __init__(self, **kwargs): self.kwargs = kwargs diff --git a/tests/test_integration_patching.py b/tests/test_integration_patching.py index 7390d4c..479a5df 100644 --- a/tests/test_integration_patching.py +++ b/tests/test_integration_patching.py @@ -5,6 +5,11 @@ import pytest +import wildedge.hubs.huggingface as hf_mod +import wildedge.integrations.gguf as gguf_mod +import wildedge.integrations.onnx as onnx_mod +import wildedge.integrations.pytorch as torch_mod +import wildedge.integrations.transformers as tf_mod from wildedge.client import WildEdge from wildedge.hubs.huggingface import HuggingFaceHubTracker from wildedge.integrations.gguf import GgufExtractor @@ -15,8 +20,6 @@ def test_hf_install_patch_is_idempotent(monkeypatch): - import wildedge.hubs.huggingface as hf_mod - def orig_hf_hub_download(repo_id, filename, **kwargs): return f"/tmp/{repo_id}/{filename}" @@ -43,8 +46,6 @@ def orig_hf_hub_download(repo_id, filename, **kwargs): def test_hf_install_patch_retries_unpatched_part(monkeypatch): - import wildedge.hubs.huggingface as hf_mod - monkeypatch.setattr(hf_mod, "_hf", object()) monkeypatch.setattr(hf_mod, "_fd", object()) monkeypatch.setattr(hf_mod, "_hf_hub_download_patched", False) @@ -66,8 +67,6 @@ def fake_install_hf(): def test_onnx_install_auto_load_patch_is_idempotent(monkeypatch): - import wildedge.integrations.onnx as onnx_mod - class FakeSession: def __init__(self, *args, **kwargs): pass @@ -87,8 +86,6 @@ def client_ref(): def test_timm_install_patch_is_idempotent(monkeypatch): - import wildedge.integrations.pytorch as torch_mod - def create_model(*args, **kwargs): return object() @@ -107,8 +104,6 @@ def client_ref(): def test_transformers_install_patch_is_idempotent(monkeypatch): - import wildedge.integrations.transformers as tf_mod - class FakePipeline: def __init__(self, *args, **kwargs): pass @@ -141,7 +136,6 @@ def client_ref(): def test_transformers_from_import_style_intercepted(monkeypatch): """from transformers import pipeline; pipeline() must be intercepted.""" - import wildedge.integrations.transformers as tf_mod loaded = [] @@ -164,7 +158,7 @@ def from_pretrained(cls, *args, **kwargs): client = types.SimpleNamespace( closed=False, - _snapshot_hub_caches=lambda: {}, + _snapshot_hub_caches=dict, _diff_hub_caches=lambda before, ms: None, _on_model_auto_loaded=lambda obj, **kw: loaded.append(("tracked", obj)), ) @@ -186,8 +180,6 @@ def from_pretrained(cls, *args, **kwargs): def test_gguf_install_auto_load_patch_is_idempotent(monkeypatch): - import wildedge.integrations.gguf as gguf_mod - class FakeLlama: def __init__(self, *args, **kwargs): pass diff --git a/tests/test_integrations_anthropic.py b/tests/test_integrations_anthropic.py index 3652700..68964b1 100644 --- a/tests/test_integrations_anthropic.py +++ b/tests/test_integrations_anthropic.py @@ -128,7 +128,7 @@ async def __anext__(self): try: return next(self._iter) except StopIteration: - raise StopAsyncIteration + raise StopAsyncIteration from None # Named "Anthropic" / "AsyncAnthropic" so can_handle sees the right type name. diff --git a/tests/test_integrations_common.py b/tests/test_integrations_common.py index 787f6ff..eb75a16 100644 --- a/tests/test_integrations_common.py +++ b/tests/test_integrations_common.py @@ -41,7 +41,7 @@ def test_debug_failure_logs_correctly(caplog): @pytest.mark.parametrize( - "dtype_str, expected", + ("dtype_str", "expected"), [ # PyTorch repr strings ("torch.float16", "f16"), @@ -56,11 +56,9 @@ def test_debug_failure_logs_correctly(caplog): ("tensor(uint8)", "int8"), # Plain names ("float16", "f16"), - ("bfloat16", "bf16"), + ("bfloat16", "bf16"), # must not match float16 ("int8", "int8"), ("float32", "f32"), - # bfloat16 must not match float16 - ("bfloat16", "bf16"), # Unknown ("float64", None), ("complex64", None), @@ -101,7 +99,7 @@ def test_histogram_uniform_distribution(): np = pytest.importorskip("numpy") # 500 pixels evenly spread [0, 0.2, 0.4, 0.6, 0.8, 1.0) flat = np.linspace(0.0, 1.0, 500, dtype=np.float32) - mean, std, buckets = image_brightness_histogram(flat) + mean, _std, buckets = image_brightness_histogram(flat) assert abs(mean - 0.5) < 0.01 # Each bucket should have roughly equal counts for b in buckets: @@ -139,7 +137,7 @@ def test_histogram_mean_and_std_rounded_to_4dp(): @pytest.mark.parametrize( - "shape, expected", + ("shape", "expected"), [ ((4, 3, 224, 224), "image"), # NCHW image batch ((1, 3, 64, 64), "image"), # single image @@ -159,7 +157,7 @@ def test_infer_input_modality_from_shape(shape, expected): @pytest.mark.parametrize( - "names, expected", + ("names", "expected"), [ (["input_ids", "attention_mask"], "text"), (["input_ids"], "text"), @@ -185,7 +183,7 @@ def test_infer_input_modality_from_names(names, expected): @pytest.mark.parametrize( - "layer_types, expected", + ("layer_types", "expected"), [ (["InputLayer", "Conv2D", "Dense"], "image"), (["InputLayer", "DepthwiseConv2D", "GlobalAveragePooling2D", "Dense"], "image"), @@ -207,7 +205,7 @@ def test_infer_input_modality_from_layer_types(layer_types, expected): @pytest.mark.parametrize( - "shape, expected", + ("shape", "expected"), [ ((None, 10), 10), # standard classification head ((None, 1000), 1000), # ImageNet @@ -274,7 +272,7 @@ class APITimeoutError(APIConnectionError): @pytest.mark.parametrize( - "exc, expected", + ("exc", "expected"), [ (APITimeoutError(), ErrorCode.INFERENCE_TIMEOUT), (TimeoutError(), ErrorCode.INFERENCE_TIMEOUT), @@ -306,7 +304,7 @@ def test_provider_error_code_of(): @pytest.mark.parametrize( - "exc, expected", + ("exc", "expected"), [ # anthropic, at request time and mid-stream: type in the wrapped body. ( diff --git a/tests/test_integrations_openai.py b/tests/test_integrations_openai.py index 9902731..10fdf82 100644 --- a/tests/test_integrations_openai.py +++ b/tests/test_integrations_openai.py @@ -92,7 +92,7 @@ async def create(self, *args, **kwargs): def make_stream_chunk(content=None, finish_reason=None, usage=None): - chunk = SimpleNamespace( + return SimpleNamespace( choices=[ SimpleNamespace( delta=SimpleNamespace(content=content), @@ -104,7 +104,6 @@ def make_stream_chunk(content=None, finish_reason=None, usage=None): system_fingerprint=None, service_tier=None, ) - return chunk class FakeStreamingCompletions: @@ -138,7 +137,7 @@ async def __anext__(self): try: return next(self._iter) except StopIteration: - raise StopAsyncIteration + raise StopAsyncIteration from None # Named "OpenAI" / "AsyncOpenAI" so can_handle sees the right type name. @@ -184,7 +183,7 @@ def register_model(obj, *, model_id=None, source=None, **kwargs): @pytest.mark.parametrize( - "url,expected", + ("url", "expected"), [ (None, "openai"), ("", "openai"), diff --git a/tests/test_integrations_ultralytics.py b/tests/test_integrations_ultralytics.py index 1a05458..c740418 100644 --- a/tests/test_integrations_ultralytics.py +++ b/tests/test_integrations_ultralytics.py @@ -130,7 +130,7 @@ def test_is_yolo_false_for_string(): @pytest.mark.parametrize( - "name,expected", + ("name", "expected"), [ ("yolov8n", "yolov8"), ("yolov8s-seg", "yolov8"), @@ -208,8 +208,7 @@ def test_image_input_meta_returns_none_when_numpy_absent(): def test_image_input_meta_extracts_hwc_array(): - pytest.importorskip("numpy") - import numpy as np + np = pytest.importorskip("numpy") arr = np.zeros((480, 640, 3), dtype=np.uint8) meta = image_input_meta(arr) diff --git a/tests/test_llm_api.py b/tests/test_llm_api.py index d9a5863..3b214f2 100644 --- a/tests/test_llm_api.py +++ b/tests/test_llm_api.py @@ -136,9 +136,11 @@ def test_response_anthropic_shape(client): def test_exception_records_error_not_inference(client): - with pytest.raises(ValueError): - with wildedge.llm_api(model="m", provider="p"): - raise ValueError("bad payload") + with ( + pytest.raises(ValueError, match="bad payload"), + wildedge.llm_api(model="m", provider="p"), + ): + raise ValueError("bad payload") assert client.handle.inferences == [] assert client.handle.errors == [ @@ -150,9 +152,8 @@ def test_http_error_leaves_code_to_server(client): class HTTPStatusError(Exception): response = SimpleNamespace(status_code=401) - with pytest.raises(HTTPStatusError): - with wildedge.llm_api(model="m", provider="p"): - raise HTTPStatusError("Client error '401 Unauthorized'") + with pytest.raises(HTTPStatusError), wildedge.llm_api(model="m", provider="p"): + raise HTTPStatusError("Client error '401 Unauthorized'") assert client.handle.errors[0]["error_code"] == "UNKNOWN" @@ -161,9 +162,8 @@ def test_provider_error_without_status_leaves_code_to_server(client): class APIError(Exception): code = "server_error" - with pytest.raises(APIError): - with wildedge.llm_api(model="m", provider="p"): - raise APIError("An error occurred during streaming") + with pytest.raises(APIError), wildedge.llm_api(model="m", provider="p"): + raise APIError("An error occurred during streaming") assert client.handle.errors[0]["error_code"] == "UNKNOWN" diff --git a/tests/test_model.py b/tests/test_model.py index ae5683d..823d75c 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -4,12 +4,12 @@ def make_info(**kwargs) -> ModelInfo: - defaults = dict( - model_name="test", - model_version="1.0", - model_source="local", - model_format="onnx", - ) + defaults = { + "model_name": "test", + "model_version": "1.0", + "model_source": "local", + "model_format": "onnx", + } return ModelInfo(**{**defaults, **kwargs}) diff --git a/tests/test_platform_adapter.py b/tests/test_platform_adapter.py index 6c754cf..f54598b 100644 --- a/tests/test_platform_adapter.py +++ b/tests/test_platform_adapter.py @@ -18,26 +18,30 @@ def test_real_device_model(platform_cls): model = platform_cls().device_model() if model is not None: - assert isinstance(model, str) and model + assert isinstance(model, str) + assert model @pytest.mark.parametrize("platform_cls", PLATFORMS) def test_real_os_version(platform_cls): ver = platform_cls().os_version() if ver is not None: - assert isinstance(ver, str) and ver + assert isinstance(ver, str) + assert ver @pytest.mark.parametrize("platform_cls", PLATFORMS) def test_real_ram_bytes(platform_cls): total = platform_cls().ram_bytes() - assert isinstance(total, int) and total > 0 + assert isinstance(total, int) + assert total > 0 @pytest.mark.parametrize("platform_cls", PLATFORMS) def test_real_disk_bytes(platform_cls): total = platform_cls().disk_bytes() - assert isinstance(total, int) and total > 0 + assert isinstance(total, int) + assert total > 0 @pytest.mark.parametrize("platform_cls", PLATFORMS) @@ -46,13 +50,15 @@ def test_real_gpu_accelerators(platform_cls): assert isinstance(accs, list) assert all(isinstance(a, str) for a in accs) if gpu_name is not None: - assert isinstance(gpu_name, str) and gpu_name + assert isinstance(gpu_name, str) + assert gpu_name @pytest.mark.parametrize("platform_cls", PLATFORMS) def test_real_gpu_accelerator_for_offload(platform_cls): offload = platform_cls().gpu_accelerator_for_offload() - assert isinstance(offload, str) and offload + assert isinstance(offload, str) + assert offload @pytest.mark.parametrize("platform_cls", PLATFORMS) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 05a1de4..a9b3d85 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -1,5 +1,6 @@ from __future__ import annotations +import wildedge.platforms.macos as macos_mod from wildedge.platforms.base import cuda_device_count from wildedge.platforms.linux import LinuxPlatform from wildedge.platforms.macos import MACOS_THERMAL_STATES, MacOSPlatform @@ -67,7 +68,7 @@ def cuInit(self, _flags): # noqa: N802 return 0 def cuDeviceGetCount(self, ptr): # noqa: N802 - ptr._obj.value = 2 # noqa: SLF001 + ptr._obj.value = 2 return 0 monkeypatch.setattr("ctypes.CDLL", lambda _name: FakeCudaLib()) @@ -142,7 +143,6 @@ def test_macos_thermal_states_covers_all_levels(): def test_macos_cpu_freq_intel(monkeypatch): """On Intel, both hw.cpufrequency and hw.cpufrequency_max are available.""" - import wildedge.platforms.macos as macos_mod calls = [] @@ -161,7 +161,6 @@ def fake_sysctl(name): def test_macos_cpu_freq_apple_silicon(monkeypatch): """On Apple Silicon, hw.cpufrequency is absent; max falls back to hw.perflevel0.cpufrequency_max.""" - import wildedge.platforms.macos as macos_mod def fake_sysctl(name): return {b"hw.perflevel0.cpufrequency_max": 4_056_000_000}.get(name) @@ -173,8 +172,6 @@ def fake_sysctl(name): def test_macos_thermal_nominal(monkeypatch): - import wildedge.platforms.macos as macos_mod - monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc()) monkeypatch.setattr( macos_mod.ctypes, @@ -191,8 +188,6 @@ def test_macos_thermal_nominal(monkeypatch): def test_macos_thermal_serious(monkeypatch): - import wildedge.platforms.macos as macos_mod - monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc()) monkeypatch.setattr( macos_mod.ctypes, @@ -210,8 +205,6 @@ def test_macos_thermal_serious(monkeypatch): def test_macos_thermal_returns_none_on_error(monkeypatch): - import wildedge.platforms.macos as macos_mod - monkeypatch.setattr( macos_mod.ctypes.cdll, "LoadLibrary", diff --git a/tests/test_platform_linux.py b/tests/test_platform_linux.py index 5c7d431..0a01a6a 100644 --- a/tests/test_platform_linux.py +++ b/tests/test_platform_linux.py @@ -19,7 +19,7 @@ def test_meminfo_parses_total_and_available(monkeypatch): lambda p, **kw: ( __import__("io").StringIO(content) if p == "/proc/meminfo" - else open(p, **kw) + else open(p, **kw) # noqa: SIM115 - the caller closes it ), ) total, available = LinuxPlatform().meminfo() @@ -86,7 +86,8 @@ def test_battery_no_supply_returns_none(tmp_path, monkeypatch): lambda p: ps if p == "/sys/class/power_supply" else Path(p), ) level, charging = LinuxPlatform().battery() - assert level is None and charging is None + assert level is None + assert charging is None def test_thermal_nominal(tmp_path, monkeypatch): @@ -181,8 +182,10 @@ def test_trip_point_states_covers_all_severity_levels(): @pytest.mark.requires_linux def test_real_meminfo(): total, available = LinuxPlatform().meminfo() - assert isinstance(total, int) and total > 0 - assert isinstance(available, int) and 0 < available <= total + assert isinstance(total, int) + assert total > 0 + assert isinstance(available, int) + assert 0 < available <= total @pytest.mark.requires_linux diff --git a/tests/test_platform_macos.py b/tests/test_platform_macos.py index 096030c..281d79c 100644 --- a/tests/test_platform_macos.py +++ b/tests/test_platform_macos.py @@ -2,13 +2,12 @@ import pytest +from wildedge.platforms import macos as macos_mod from wildedge.platforms.hardware import ThermalContext from wildedge.platforms.macos import MacOSPlatform def test_meminfo_computes_available(monkeypatch): - from wildedge.platforms import macos as macos_mod - monkeypatch.setattr( macos_mod, "_sysctl_uint64", @@ -25,8 +24,6 @@ def test_meminfo_computes_available(monkeypatch): def test_meminfo_available_none_when_page_size_missing(monkeypatch): - from wildedge.platforms import macos as macos_mod - monkeypatch.setattr(macos_mod, "_sysctl_uint64", lambda name: 16_000_000_000) monkeypatch.setattr(macos_mod, "_sysctl_uint32", lambda name: None) _, available = MacOSPlatform().meminfo() @@ -62,7 +59,8 @@ def test_hardware_context_fields(monkeypatch): @pytest.mark.requires_macos def test_real_meminfo(): total, available = MacOSPlatform().meminfo() - assert isinstance(total, int) and total > 0 + assert isinstance(total, int) + assert total > 0 if available is not None: assert 0 < available <= total diff --git a/tests/test_platform_windows.py b/tests/test_platform_windows.py index 862d7eb..71d1a14 100644 --- a/tests/test_platform_windows.py +++ b/tests/test_platform_windows.py @@ -24,8 +24,10 @@ def test_hardware_context_fields(monkeypatch): @pytest.mark.requires_windows def test_real_meminfo(): total, available = WindowsPlatform().meminfo() - assert isinstance(total, int) and total > 0 - assert isinstance(available, int) and 0 < available <= total + assert isinstance(total, int) + assert total > 0 + assert isinstance(available, int) + assert 0 < available <= total @pytest.mark.requires_windows diff --git a/tests/test_sampler.py b/tests/test_sampler.py index df71696..1f305eb 100644 --- a/tests/test_sampler.py +++ b/tests/test_sampler.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch +from wildedge import platforms +from wildedge.model import ModelHandle from wildedge.platforms import ( capture_hardware, is_sampling, @@ -87,9 +89,8 @@ def test_capture_hardware_does_not_mutate_snapshot(self): platform = make_platform(HardwareContext(memory_available_bytes=1)) with patch("wildedge.platforms.CURRENT_PLATFORM", platform): start_sampler(interval_s=60) - from wildedge.platforms import _sampler - original = _sampler.snapshot() + original = platforms._sampler.snapshot() capture_hardware(accelerator_actual="cuda") assert original.accelerator_actual is None @@ -103,8 +104,6 @@ def test_capture_hardware_accelerator_override(self): class TestTrackInferenceHardware: def test_auto_attaches_when_sampling(self, monkeypatch): - from wildedge.model import ModelHandle - platform = make_platform(HardwareContext(memory_available_bytes=512)) with patch("wildedge.platforms.CURRENT_PLATFORM", platform): start_sampler(interval_s=60) @@ -115,16 +114,12 @@ def test_auto_attaches_when_sampling(self, monkeypatch): assert events[0]["inference"]["hardware"]["memory_available_bytes"] == 512 def test_no_hardware_when_not_sampling(self): - from wildedge.model import ModelHandle - events = [] handle = ModelHandle("m1", MagicMock(), events.append) handle.track_inference(duration_ms=10) assert "hardware" not in events[0]["inference"] def test_explicit_hardware_takes_precedence_over_sampler(self): - from wildedge.model import ModelHandle - platform = make_platform(HardwareContext(memory_available_bytes=1)) with patch("wildedge.platforms.CURRENT_PLATFORM", platform): start_sampler(interval_s=60) diff --git a/tests/test_tensorflow_integration.py b/tests/test_tensorflow_integration.py index f27d54d..b1bedf2 100644 --- a/tests/test_tensorflow_integration.py +++ b/tests/test_tensorflow_integration.py @@ -3,6 +3,7 @@ import types from unittest.mock import MagicMock, patch +import wildedge.integrations.tensorflow as tf_mod from wildedge.integrations.tensorflow import TensorflowExtractor from wildedge.model import ModelHandle, ModelInfo @@ -60,8 +61,6 @@ def test_install_hooks_tracks_predict(self, publish_spy): assert publish_spy.events[0]["event_type"] == "inference" def test_install_auto_load_patch_is_idempotent(self, monkeypatch): - import wildedge.integrations.tensorflow as tf_mod - def load_model(*args, **kwargs): return object() diff --git a/tests/test_tracing.py b/tests/test_tracing.py index b5de0fd..24d7a2c 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -22,14 +22,16 @@ def publish(event: dict) -> None: publish, ) - with trace_context( - trace_id="trace-123", - run_id="run-1", - agent_id="agent-1", - attributes={"trace_key": "trace_val"}, + with ( + trace_context( + trace_id="trace-123", + run_id="run-1", + agent_id="agent-1", + attributes={"trace_key": "trace_val"}, + ), + span_context(span_id="span-abc", step_index=2, attributes={"span_key": 2}), ): - with span_context(span_id="span-abc", step_index=2, attributes={"span_key": 2}): - handle.track_inference(duration_ms=5) + handle.track_inference(duration_ms=5) assert events[0]["trace_id"] == "trace-123" assert events[0]["parent_span_id"] == "span-abc" @@ -81,9 +83,11 @@ def test_nested_spans_correct_parent_chain(): events: list[dict] = [] client = _FakeClient(events) - with SpanContextManager(client, kind="agent_step", name="outer") as outer: - with SpanContextManager(client, kind="tool", name="inner") as inner: - pass + with ( + SpanContextManager(client, kind="agent_step", name="outer") as outer, + SpanContextManager(client, kind="tool", name="inner") as inner, + ): + pass assert len(events) == 2 inner_ev, outer_ev = events[0], events[1] diff --git a/tests/test_transmitter.py b/tests/test_transmitter.py index 68fb9fb..35a37b1 100644 --- a/tests/test_transmitter.py +++ b/tests/test_transmitter.py @@ -8,6 +8,9 @@ import pytest +from wildedge.consumer import Consumer +from wildedge.platforms.device_info import DeviceInfo +from wildedge.queue import EventQueue from wildedge.transmitter import IngestResponse, TransmitError, Transmitter @@ -75,29 +78,35 @@ def test_send_401_returns_unauthorized(self): def test_send_429_raises_transmit_error(self): t = Transmitter(api_key="test-key", host="https://app.wildedge.dev") - with patch.object( - t._opener, "open", side_effect=_make_http_error(429, "Rate limited") + with ( + patch.object( + t._opener, "open", side_effect=_make_http_error(429, "Rate limited") + ), + pytest.raises(TransmitError), ): - with pytest.raises(TransmitError): - t.send({"batch_id": "b-1", "events": []}) + t.send({"batch_id": "b-1", "events": []}) def test_send_500_raises_transmit_error(self): t = Transmitter(api_key="test-key", host="https://app.wildedge.dev") - with patch.object( - t._opener, "open", side_effect=_make_http_error(500, "Server error") + with ( + patch.object( + t._opener, "open", side_effect=_make_http_error(500, "Server error") + ), + pytest.raises(TransmitError), ): - with pytest.raises(TransmitError): - t.send({"batch_id": "b-1", "events": []}) + t.send({"batch_id": "b-1", "events": []}) def test_network_error_raises_transmit_error(self): t = Transmitter(api_key="test-key", host="https://app.wildedge.dev") - with patch.object( - t._opener, - "open", - side_effect=urllib.error.URLError("Connection refused"), + with ( + patch.object( + t._opener, + "open", + side_effect=urllib.error.URLError("Connection refused"), + ), + pytest.raises(TransmitError), ): - with pytest.raises(TransmitError): - t.send({"batch_id": "b-1", "events": []}) + t.send({"batch_id": "b-1", "events": []}) def test_auth_header_sent(self): captured = {} @@ -191,19 +200,20 @@ def test_send_unexpected_status_raises_transmit_error(status_code): # cause AttributeError in the consumer (response.status), silently killing the # background thread and freezing the queue. t = Transmitter(api_key="test-key", host="https://app.wildedge.dev") - with patch.object( - t._opener, "open", return_value=_make_response(status_code, {"status": "ok"}) + with ( + patch.object( + t._opener, + "open", + return_value=_make_response(status_code, {"status": "ok"}), + ), + pytest.raises(TransmitError, match=f"Unexpected HTTP {status_code}"), ): - with pytest.raises(TransmitError, match=f"Unexpected HTTP {status_code}"): - t.send({"batch_id": "b-1", "events": []}) + t.send({"batch_id": "b-1", "events": []}) def test_unexpected_status_keeps_events_in_consumer_queue(): # End-to-end regression: TransmitError from an unexpected status must cause the # consumer to retain events for retry, not lose them. - from wildedge.consumer import Consumer - from wildedge.platforms.device_info import DeviceInfo - from wildedge.queue import EventQueue queue = EventQueue(max_size=100) queue.add({"event_id": "e1", "event_type": "inference", "model_id": "m"}) @@ -215,7 +225,7 @@ def test_unexpected_status_keeps_events_in_consumer_queue(): queue=queue, transmitter=mock_transmitter, device=DeviceInfo(device_id="d", device_type="linux"), - get_models=lambda: {}, + get_models=dict, session_id="sess-1", ) result = consumer.drain_once() From 3eebea3cf20cc1e644690f88acba531780ea96ed Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 14:38:19 +0200 Subject: [PATCH 18/81] Send non-scalar span attributes as JSON strings Ingest rejects the whole batch with 422 when a span attribute value is a list or dict, so one bad value dropped every unrelated event sent with it. SpanEvent.to_dict now sends such values as JSON strings and logs one warning per key. Closes #61 --- CHANGELOG.md | 5 +++ docs/manual-tracking.md | 5 +++ tests/test_event_serialization.py | 63 +++++++++++++++++++++++++++++++ wildedge/events/common.py | 38 +++++++++++++++++++ wildedge/events/span.py | 4 +- 5 files changed, 113 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50efd7b..8536da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ under Unreleased and move into a version section at release time. calls, `prompt_type="chat"` and `turn_index` measured on the last user message. With attachments enabled, the prompt or chat messages and the reply are attached. +- A span attribute whose value is a list, dict or other non-scalar no longer + gets the whole event batch rejected. Ingest only accepts scalar attribute + values and answered 422 for the batch, dropping unrelated loads, inferences + and spans. Such values are now sent as JSON strings, with one warning per + key. ## 0.2.2 - 2026-09-30 diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index 9295c6c..ad5c747 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -311,6 +311,11 @@ client.track_span( ) ``` +Span attribute values should be strings, numbers, booleans or `None`. Lists, +dicts and other values are sent as JSON strings (`["a", "b"]` arrives as +`'["a", "b"]'`), and the SDK logs a warning the first time it sees each such +key. + You can also attach optional correlation fields (`trace_id`, `span_id`, `parent_span_id`, `run_id`, `agent_id`, `step_index`, `conversation_id`) to any event by passing them into `track_inference`, `track_error`, `track_feedback`, diff --git a/tests/test_event_serialization.py b/tests/test_event_serialization.py index 9d59ae9..1222c1d 100644 --- a/tests/test_event_serialization.py +++ b/tests/test_event_serialization.py @@ -132,3 +132,66 @@ def test_error_event_to_dict_http_status(): "duration_ms": 30_000, } assert "http_status" not in ErrorEvent("m1", "OOM").to_dict()["error"] + + +def test_span_event_sends_non_scalar_attributes_as_json_strings(): + event = SpanEvent( + kind="custom", + name="s", + duration_ms=1, + status="ok", + attributes={ + "rejections": ["English", "too long"], + "scores": {"a": 1}, + "count": 3, + "ratio": 0.5, + "ok": True, + "note": None, + "label": "x", + }, + ) + attrs = event.to_dict()["span"]["attributes"] + assert attrs == { + "rejections": '["English", "too long"]', + "scores": '{"a": 1}', + "count": 3, + "ratio": 0.5, + "ok": True, + "note": None, + "label": "x", + } + # The caller's dict is left as it was. + assert event.attributes["rejections"] == ["English", "too long"] + + +def test_span_attribute_flattening_handles_unserializable_values(): + class Thing: + def __str__(self): + return "thing" + + event = SpanEvent( + kind="custom", + name="s", + duration_ms=1, + status="ok", + attributes={"obj": Thing(), "tags": ("a", Thing())}, + ) + attrs = event.to_dict()["span"]["attributes"] + assert attrs == {"obj": '"thing"', "tags": '["a", "thing"]'} + + +def test_span_attribute_warning_is_logged_once_per_key(caplog): + from wildedge.events import common + + common._warned_attribute_keys.discard("warn_once_tags") + with caplog.at_level("WARNING", logger="wildedge"): + for _ in range(3): + SpanEvent( + kind="custom", + name="s", + duration_ms=1, + status="ok", + attributes={"warn_once_tags": ["a"]}, + ).to_dict() + warnings = [r for r in caplog.records if "warn_once_tags" in r.getMessage()] + assert len(warnings) == 1 diff --git a/wildedge/events/common.py b/wildedge/events/common.py index 399b55b..2d8daef 100644 --- a/wildedge/events/common.py +++ b/wildedge/events/common.py @@ -1,7 +1,15 @@ from __future__ import annotations +import json +import threading from typing import Any +from wildedge.logging import logger + +_SCALAR_TYPES = (str, int, float, bool, type(None)) +_warned_attribute_keys: set[str] = set() +_warned_lock = threading.Lock() + def add_optional_fields(event: dict, fields: dict[str, Any]) -> dict: """Add non-None fields to an event payload.""" @@ -9,3 +17,33 @@ def add_optional_fields(event: dict, fields: dict[str, Any]) -> dict: if value is not None: event[key] = value return event + + +def flatten_attributes(attributes: dict[str, Any]) -> dict[str, Any]: + """Return attributes with every value a scalar the ingest API accepts. + + The server rejects the whole batch when one attribute value is a list, + dict or other non-scalar. Such values are sent as JSON strings instead + (``["a", "b"]`` becomes ``'["a", "b"]'``), with one warning per key. + """ + flat: dict[str, Any] = {} + for key, value in attributes.items(): + key = str(key) + if isinstance(value, _SCALAR_TYPES): + flat[key] = value + continue + try: + flat[key] = json.dumps(value, default=str, ensure_ascii=False) + except (TypeError, ValueError): + flat[key] = str(value) + with _warned_lock: + first = key not in _warned_attribute_keys + _warned_attribute_keys.add(key) + if first: + logger.warning( + "wildedge: span attribute %r is a %s, not a str, int, float or " + "bool; sending it as a JSON string", + key, + type(value).__name__, + ) + return flat diff --git a/wildedge/events/span.py b/wildedge/events/span.py index 9d5be3c..b0785b5 100644 --- a/wildedge/events/span.py +++ b/wildedge/events/span.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from typing import Any, Literal -from wildedge.events.common import add_optional_fields +from wildedge.events.common import add_optional_fields, flatten_attributes SpanKind = Literal[ "agent_step", @@ -54,7 +54,7 @@ def to_dict(self) -> dict: if self.output_summary is not None: span_data["output_summary"] = self.output_summary if self.attributes is not None: - span_data["attributes"] = self.attributes + span_data["attributes"] = flatten_attributes(self.attributes) event = { "event_id": self.event_id, From 0b89bb6462495b4bf3b5543fc8d49e4e0cbde082 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 14:44:25 +0200 Subject: [PATCH 19/81] Cover the str() fallback for span attributes JSON can't encode --- tests/test_event_serialization.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_event_serialization.py b/tests/test_event_serialization.py index 1222c1d..77a7e4b 100644 --- a/tests/test_event_serialization.py +++ b/tests/test_event_serialization.py @@ -195,3 +195,17 @@ def test_span_attribute_warning_is_logged_once_per_key(caplog): ).to_dict() warnings = [r for r in caplog.records if "warn_once_tags" in r.getMessage()] assert len(warnings) == 1 + + +def test_span_attribute_falls_back_to_str_when_json_fails(): + circular: list = [] + circular.append(circular) + event = SpanEvent( + kind="custom", + name="s", + duration_ms=1, + status="ok", + attributes={"loop": circular, 7: "int key"}, + ) + attrs = event.to_dict()["span"]["attributes"] + assert attrs == {"loop": "[[...]]", "7": "int key"} From b97136aa181ba7130eba7e9bf1d4786c1b8ee6f2 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:22:17 +0200 Subject: [PATCH 20/81] Import the events common module at module level in the tests --- tests/test_event_serialization.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_event_serialization.py b/tests/test_event_serialization.py index 77a7e4b..0b0d0aa 100644 --- a/tests/test_event_serialization.py +++ b/tests/test_event_serialization.py @@ -1,5 +1,6 @@ from __future__ import annotations +from wildedge.events import common from wildedge.events.error import ErrorCode, ErrorEvent from wildedge.events.feedback import FeedbackEvent, FeedbackType from wildedge.events.inference import InferenceEvent, TextInputMeta @@ -181,8 +182,6 @@ def __str__(self): def test_span_attribute_warning_is_logged_once_per_key(caplog): - from wildedge.events import common - common._warned_attribute_keys.discard("warn_once_tags") with caplog.at_level("WARNING", logger="wildedge"): for _ in range(3): From 6dadbeacd96b878d10f60e3bd7a683e62d64ef44 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 14:41:15 +0200 Subject: [PATCH 21/81] Upload buffered attachments on close and flush with a timeout close() stopped the uploader without a final pass, so attachments captured in the last few seconds stayed on disk while their events were sent. With a timeout, close() and flush() now drain the attachment store in the time left after the event flush. Each request's timeout is capped at that deadline, and a lock keeps the background thread and the drain from sending the same attachment. Closes #62 --- CHANGELOG.md | 6 ++ tests/test_attachments.py | 196 +++++++++++++++++++++++++++++++++++++- wildedge/attachments.py | 93 ++++++++++++++++-- wildedge/client.py | 26 ++++- 4 files changed, 308 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8536da9..cb9cf0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ under Unreleased and move into a version section at release time. values and answered 422 for the batch, dropping unrelated loads, inferences and spans. Such values are now sent as JSON strings, with one warning per key. +- `close(timeout=...)` and `flush(timeout=...)` now upload buffered + attachments in the time left after sending events. `close()` used to stop + the attachment uploader without a final upload, so a short-lived process + lost the attachments of its last few seconds (the uploader runs every 5 s) + while its events still referenced them. `close()` without a timeout still + leaves them on disk for the next start. ## 0.2.2 - 2026-09-30 diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 46ff061..b877c0a 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -361,7 +361,7 @@ def __init__(self, outcomes): self.outcomes = list(outcomes) self.seen: list[str] = [] - def transmit(self, pending): + def transmit(self, pending, deadline=None): self.seen.append(pending.attachment_id) return self.outcomes.pop(0) @@ -478,3 +478,197 @@ def test_client_attachments_enabled_via_env(monkeypatch, tmp_path): assert client.attachment_manager is not None finally: client.close() + + +# --------------------------------------------------------------------------- # +# Upload on flush / close +# --------------------------------------------------------------------------- # + + +def test_transmit_past_deadline_is_transient_without_requests(monkeypatch, tmp_path): + monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen([])) + tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example") + outcome = tx.transmit(_pending(tmp_path), deadline=time.monotonic() - 1) + assert outcome is UploadOutcome.TRANSIENT + + +def test_transmit_caps_request_timeout_at_deadline(monkeypatch, tmp_path): + timeouts: list[float] = [] + responses = iter([PRESIGN_OK, _Resp(200)]) + + def opener(req, timeout=None): + timeouts.append(timeout) + return next(responses) + + monkeypatch.setattr(urllib.request, "urlopen", opener) + tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example") + outcome = tx.transmit(_pending(tmp_path), deadline=time.monotonic() + 1.0) + assert outcome is UploadOutcome.UPLOADED + assert len(timeouts) == 2 + assert all(0 < t <= 1.0 for t in timeouts) + + +def test_drain_uploads_more_than_one_batch(tmp_path): + from wildedge import constants + + n = constants.ATTACHMENT_PRESIGN_BATCH_SIZE * 2 + 1 + store = _filled_store(tmp_path, n) + tx = _StubTransmitter([UploadOutcome.UPLOADED] * n) + up = AttachmentUploader(store, tx) + up.drain(timeout=5.0) + assert store.length() == 0 + assert up.uploaded_count == n + + +def test_drain_stops_at_transient(tmp_path): + store = _filled_store(tmp_path, 3) + tx = _StubTransmitter([UploadOutcome.UPLOADED, UploadOutcome.TRANSIENT]) + up = AttachmentUploader(store, tx) + up.drain(timeout=5.0) + assert [p.attachment_id for p in store.peek_many(10)] == ["a1", "a2"] + + +def test_drain_respects_timeout(tmp_path): + store = _filled_store(tmp_path, 3) + + class Slow: + seen = 0 + + def transmit(self, pending, deadline=None): + Slow.seen += 1 + time.sleep(0.1) + if deadline is not None and time.monotonic() >= deadline: + return UploadOutcome.TRANSIENT + return UploadOutcome.UPLOADED + + up = AttachmentUploader(store, Slow()) + t0 = time.monotonic() + up.drain(timeout=0.15) + assert time.monotonic() - t0 < 0.5 + assert store.length() >= 1 + + +def test_drain_waits_for_background_batch_within_timeout(tmp_path): + store = _filled_store(tmp_path, 1) + up = AttachmentUploader(store, _StubTransmitter([UploadOutcome.UPLOADED])) + up.process_lock.acquire() # the background thread is mid-batch + try: + t0 = time.monotonic() + up.drain(timeout=0.1) + assert time.monotonic() - t0 < 0.5 + finally: + up.process_lock.release() + assert store.length() == 1 # left for the thread, not sent twice + + +def test_drain_after_feature_disabled_is_noop(tmp_path): + store = _filled_store(tmp_path, 2) + tx = _StubTransmitter([UploadOutcome.FEATURE_DISABLED]) + up = AttachmentUploader(store, tx) + up.drain(timeout=1.0) + _append(store, b"x", "late") + up.drain(timeout=1.0) + assert tx.seen == ["a0"] + + +def _client_with_pending(tmp_path, n=2): + from wildedge.client import WildEdge + + client = WildEdge( + dsn="https://test@test.com/key", + attachments_enabled=True, + attachment_dir=str(tmp_path), + sampling_interval_s=0, + ) + client.attachment_uploader.flush_interval_s = 3600 + tx = _StubTransmitter([UploadOutcome.UPLOADED] * n) + client.attachment_uploader.transmitter = tx + for i in range(n): + _append(client.attachment_uploader.store, b"x", f"c{i}") + return client, tx + + +def test_close_with_timeout_uploads_pending_attachments(tmp_path): + client, tx = _client_with_pending(tmp_path) + client.close(timeout=2.0) + assert tx.seen == ["c0", "c1"] + assert client.attachment_uploader.store.length() == 0 + + +def test_close_without_timeout_leaves_attachments_buffered(tmp_path): + client, tx = _client_with_pending(tmp_path) + client.close() + assert tx.seen == [] + assert client.attachment_uploader.store.length() == 2 + + +def test_flush_uploads_pending_attachments(tmp_path): + client, tx = _client_with_pending(tmp_path) + try: + client.flush(timeout=2.0) + assert tx.seen == ["c0", "c1"] + finally: + client.close() + + +def test_transmit_deadline_passing_after_presign_skips_put(monkeypatch, tmp_path): + calls: list[str] = [] + + def opener(req, timeout=None): + calls.append(req.get_method()) + time.sleep(0.06) + return PRESIGN_OK + + monkeypatch.setattr(urllib.request, "urlopen", opener) + tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example") + outcome = tx.transmit(_pending(tmp_path), deadline=time.monotonic() + 0.05) + assert outcome is UploadOutcome.TRANSIENT + assert calls == ["POST"] + + +def test_drain_stops_once_store_is_empty(tmp_path): + from wildedge import constants + + n = constants.ATTACHMENT_PRESIGN_BATCH_SIZE + store = _filled_store(tmp_path, n) + tx = _StubTransmitter([UploadOutcome.UPLOADED] * n) + up = AttachmentUploader(store, tx) + up.drain(timeout=5.0) + assert store.length() == 0 + assert len(tx.seen) == n + + +def test_flush_with_attachments_disabled(monkeypatch): + from wildedge import constants + from wildedge.client import WildEdge + + monkeypatch.delenv(constants.ENV_ATTACHMENTS_ENABLED, raising=False) + client = WildEdge(dsn="https://test@test.com/key") + try: + client.flush(timeout=0.1) # no uploader; must not raise + finally: + client.close() + + +def test_flush_with_no_time_left_skips_attachments(tmp_path): + client, tx = _client_with_pending(tmp_path) + try: + client.flush(timeout=0) + assert tx.seen == [] + finally: + client.close() + + +def test_flush_survives_attachment_drain_error(tmp_path, caplog): + client, _ = _client_with_pending(tmp_path) + + def boom(timeout): + raise RuntimeError("disk gone") + + client.attachment_uploader.drain = boom + try: + with caplog.at_level("WARNING", logger="wildedge"): + client.flush(timeout=1.0) + assert any("disk gone" in r.getMessage() for r in caplog.records) + finally: + client.close() diff --git a/wildedge/attachments.py b/wildedge/attachments.py index 9636120..594cf54 100644 --- a/wildedge/attachments.py +++ b/wildedge/attachments.py @@ -349,8 +349,18 @@ def __init__( self.host = host.rstrip("/") self.timeout = timeout - def transmit(self, pending: PendingAttachment) -> UploadOutcome: - outcome, upload_url = self._presign(pending) + def transmit( + self, pending: PendingAttachment, deadline: float | None = None + ) -> UploadOutcome: + """Presign and upload ``pending``. + + ``deadline`` (a ``time.monotonic()`` value) caps each request's timeout; + past it the upload counts as transient and stays buffered. + """ + timeout = self._request_timeout(deadline) + if timeout is None: + return UploadOutcome.TRANSIENT + outcome, upload_url = self._presign(pending, timeout) if upload_url is None: return outcome try: @@ -358,9 +368,22 @@ def transmit(self, pending: PendingAttachment) -> UploadOutcome: except Exception as exc: logger.warning("wildedge: attachment bytes unreadable: %s", exc) return UploadOutcome.PERMANENT - return self._put(upload_url, data, pending.content_type) + timeout = self._request_timeout(deadline) + if timeout is None: + return UploadOutcome.TRANSIENT + return self._put(upload_url, data, pending.content_type, timeout) - def _presign(self, pending: PendingAttachment) -> tuple[UploadOutcome, str | None]: + def _request_timeout(self, deadline: float | None) -> float | None: + if deadline is None: + return self.timeout + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + return min(self.timeout, remaining) + + def _presign( + self, pending: PendingAttachment, timeout: float + ) -> tuple[UploadOutcome, str | None]: url = f"{self.host}/api/attachments/presign" body = json.dumps( [ @@ -384,7 +407,7 @@ def _presign(self, pending: PendingAttachment) -> tuple[UploadOutcome, str | Non method="POST", ) try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: status = resp.status raw = resp.read() except urllib.error.HTTPError as exc: @@ -421,7 +444,9 @@ def _presign(self, pending: PendingAttachment) -> tuple[UploadOutcome, str | Non # 429 / 5xx / anything else: retry later. return UploadOutcome.TRANSIENT, None - def _put(self, upload_url: str, data: bytes, content_type: str) -> UploadOutcome: + def _put( + self, upload_url: str, data: bytes, content_type: str, timeout: float + ) -> UploadOutcome: req = urllib.request.Request( upload_url, data=data, @@ -429,7 +454,7 @@ def _put(self, upload_url: str, data: bytes, content_type: str) -> UploadOutcome method="PUT", ) try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: status = resp.status except urllib.error.HTTPError as exc: status = exc.code @@ -467,6 +492,10 @@ def __init__( self.uploaded_count = 0 self.dropped_count = 0 self.stop_event = threading.Event() + # Serialises the background thread and drain() so no attachment is + # sent twice. + self.process_lock = threading.Lock() + self.disabled = False self.thread = self._make_thread() def _make_thread(self) -> threading.Thread: @@ -501,10 +530,50 @@ def _run(self) -> None: except Exception as exc: # pragma: no cover - never kill the thread logger.warning("wildedge: attachment uploader error: %s", exc) - def process_pending(self) -> None: + def drain(self, timeout: float) -> None: + """Upload buffered attachments until none are left or ``timeout`` runs out. + + Stops early when an upload fails transiently; what is left stays on + disk for the next start. + """ + deadline = time.monotonic() + timeout + while self.process_pending(deadline=deadline): + pass + remaining = self.store.length() + if remaining and not self.disabled: + logger.debug( + "wildedge: %d attachment(s) left buffered for the next start", + remaining, + ) + + def process_pending(self, deadline: float | None = None) -> bool: + """Upload one batch, oldest first. Returns True when more may be pending. + + ``deadline`` (a ``time.monotonic()`` value) bounds the call, including + the wait for a batch the background thread is sending. + """ + if deadline is None: + acquired = self.process_lock.acquire() + else: + acquired = self.process_lock.acquire( + timeout=max(deadline - time.monotonic(), 0) + ) + if not acquired: + return False + try: + return self._process_batch(deadline) + finally: + self.process_lock.release() + + def _process_batch(self, deadline: float | None) -> bool: + if self.disabled: + return False # Evict stale buffered bytes from the front of the queue. cutoff = time.time() - self.max_age_s batch = self.store.peek_many(constants.ATTACHMENT_PRESIGN_BATCH_SIZE) + if not batch: + return False + full_batch = len(batch) == constants.ATTACHMENT_PRESIGN_BATCH_SIZE stale = [p for p in batch if p.registered_at < cutoff] if stale: self.store.remove(stale) @@ -517,8 +586,9 @@ def process_pending(self) -> None: batch = [p for p in batch if p.registered_at >= cutoff] done: list[PendingAttachment] = [] + more = full_batch for pending in batch: - outcome = self.transmitter.transmit(pending) + outcome = self.transmitter.transmit(pending, deadline=deadline) if outcome is UploadOutcome.UPLOADED: done.append(pending) self.uploaded_count += 1 @@ -530,10 +600,13 @@ def process_pending(self) -> None: self.on_feature_disabled() self.store.remove(done) self.store.clear() + self.disabled = True self.stop_event.set() - return + return False else: # TRANSIENT - stop, preserve order, retry next tick + more = False break if done: self.store.remove(done) + return more diff --git a/wildedge/client.py b/wildedge/client.py index 736c467..e830121 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -994,11 +994,20 @@ def _on_unload() -> None: return obj def flush(self, timeout: float = 5.0) -> None: - """Block until the event queue drains or timeout expires.""" + """Block until queued events and attachments are sent or timeout expires. + + Events go first; buffered attachments use the time that is left. + """ + deadline = time.monotonic() + timeout self.consumer.flush(timeout=timeout) + self._drain_attachments(deadline) def close(self, timeout: float | None = None) -> None: - """Best-effort shutdown; pass timeout to attempt bounded flush first.""" + """Best-effort shutdown; pass timeout to attempt bounded flush first. + + With a timeout, buffered attachments are uploaded in the time left after + the event flush. Without one, they stay on disk for the next start. + """ self.closed = True if not self.noop: stop_sampler() @@ -1007,7 +1016,20 @@ def close(self, timeout: float | None = None) -> None: if timeout is None: self.consumer.close() else: + deadline = time.monotonic() + timeout self.consumer.close(timeout=timeout) + self._drain_attachments(deadline) + + def _drain_attachments(self, deadline: float) -> None: + if self.attachment_uploader is None: + return + remaining = deadline - time.monotonic() + if remaining <= 0: + return + try: + self.attachment_uploader.drain(remaining) + except Exception as exc: + logger.warning("wildedge: attachment upload on flush failed: %s", exc) def pending_count(self) -> int: """Return the number of events currently buffered.""" From 50fab2b2bbf15bbebcb9652dd4310f0f100a82ea Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:22:44 +0200 Subject: [PATCH 22/81] Import constants and WildEdge at module level in the new attachment tests --- tests/test_attachments.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index b877c0a..dfa95cf 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -6,6 +6,7 @@ import urllib.request from datetime import datetime, timezone +from wildedge import constants from wildedge.attachments import ( Attachment, AttachmentManager, @@ -15,6 +16,7 @@ PendingAttachment, UploadOutcome, ) +from wildedge.client import WildEdge from wildedge.events.inference import InferenceEvent from wildedge.model import ModelHandle, ModelInfo @@ -509,8 +511,6 @@ def opener(req, timeout=None): def test_drain_uploads_more_than_one_batch(tmp_path): - from wildedge import constants - n = constants.ATTACHMENT_PRESIGN_BATCH_SIZE * 2 + 1 store = _filled_store(tmp_path, n) tx = _StubTransmitter([UploadOutcome.UPLOADED] * n) @@ -572,8 +572,6 @@ def test_drain_after_feature_disabled_is_noop(tmp_path): def _client_with_pending(tmp_path, n=2): - from wildedge.client import WildEdge - client = WildEdge( dsn="https://test@test.com/key", attachments_enabled=True, @@ -627,8 +625,6 @@ def opener(req, timeout=None): def test_drain_stops_once_store_is_empty(tmp_path): - from wildedge import constants - n = constants.ATTACHMENT_PRESIGN_BATCH_SIZE store = _filled_store(tmp_path, n) tx = _StubTransmitter([UploadOutcome.UPLOADED] * n) @@ -639,9 +635,6 @@ def test_drain_stops_once_store_is_empty(tmp_path): def test_flush_with_attachments_disabled(monkeypatch): - from wildedge import constants - from wildedge.client import WildEdge - monkeypatch.delenv(constants.ENV_ATTACHMENTS_ENABLED, raising=False) client = WildEdge(dsn="https://test@test.com/key") try: From c46afdf2fb873f8e8a88010a2904ac5cb07fcbd8 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 14:48:29 +0200 Subject: [PATCH 23/81] Name ONNX models after their file when the graph name is generic main_graph, the default graph name from newer torch.onnx.export, wasn't in the generic list, so every Piper voice registered as one model called main_graph. It now falls back to the session's file name, which also gives a manually registered session an id. model_version 0, negative or INT64_MAX counts as unset. Closes #63 --- CHANGELOG.md | 6 +++ tests/test_integrations.py | 91 +++++++++++++++++++++++++++++++++++ wildedge/integrations/onnx.py | 58 +++++++++++++++++++--- 3 files changed, 147 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9cf0b..99dc1f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ under Unreleased and move into a version section at release time. lost the attachments of its last few seconds (the uploader runs every 5 s) while its events still referenced them. `close()` without a timeout still leaves them on disk for the next start. +- ONNX models exported with the default `main_graph` graph name (newer + `torch.onnx.export`, Piper TTS voices) are now named after their file, so + different voices no longer show up as one model called `main_graph`. A + session created without an `id` falls back to the file name too, and + `model_version` 0, negative or INT64_MAX (the exporters' "unset") is sent as + `unknown` instead of `9223372036854775807`. ## 0.2.2 - 2026-09-30 diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 90e14ce..c6b127b 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -25,6 +25,9 @@ from wildedge.integrations.keras import detect_accelerator as keras_detect_accelerator from wildedge.integrations.onnx import ( OnnxExtractor, + model_file_stem, + model_id_from_path, + parse_model_version, ) from wildedge.integrations.onnx import ( detect_accelerator as onnx_detect_accelerator, @@ -213,6 +216,94 @@ def test_extract_info_none_model_id_when_graph_name_empty(self): model_id, _ = self.extractor.extract_info(session, {}) assert model_id is None + def _piper_session(self, path="/voices/pl_PL-gosia-medium.onnx"): + session = _FakeOrtSession() + session.get_modelmeta = lambda: MagicMock( + graph_name="main_graph", version=2**63 - 1 + ) + session._model_path = path + return session + + def test_main_graph_name_falls_back_to_file_stem(self): + model_id, info = self.extractor.extract_info(self._piper_session(), {}) + assert model_id == "pl_PL-gosia-medium" + assert info.model_name == "pl_PL-gosia-medium" + + def test_int64_max_version_is_unset(self): + _, info = self.extractor.extract_info(self._piper_session(), {}) + assert info.model_version == "unknown" + + def test_different_voices_get_different_ids(self): + a, _ = self.extractor.extract_info(self._piper_session("/v/en_US-amy.onnx"), {}) + b, _ = self.extractor.extract_info( + self._piper_session("/v/de_DE-thorsten.onnx"), {} + ) + assert a != b + + def test_hub_path_keeps_repo_id_and_names_model_after_file(self): + path = ( + "/hf/hub/models--rhasspy--piper-voices/snapshots/abc/pl/" + "pl_PL-gosia-medium.onnx" + ) + model_id, info = self.extractor.extract_info(self._piper_session(path), {}) + assert model_id == "rhasspy/piper-voices" + assert info.model_name == "pl_PL-gosia-medium" + + def test_session_from_bytes_without_name_still_needs_an_id(self): + session = self._piper_session(path=None) + model_id, info = self.extractor.extract_info(session, {}) + assert model_id is None + assert info.model_name == "onnx-model" + + def test_id_override_names_model_when_graph_and_file_are_generic(self): + session = self._piper_session(path="/models/model.onnx") + model_id, info = self.extractor.extract_info(session, {"id": "my-voice"}) + assert model_id == "my-voice" + assert info.model_name == "my-voice" + + def test_real_graph_name_wins_over_file_stem(self): + session = _FakeOrtSession() + session._model_path = "/models/exported-v3.onnx" + model_id, info = self.extractor.extract_info(session, {}) + assert model_id == "resnet50" + assert info.model_name == "resnet50" + + @pytest.mark.parametrize( + "raw, expected", + [ + (None, None), + (0, None), + (-1, None), + (2**63 - 1, None), + (1, "1"), + (42, "42"), + ("7", "7"), + ("v2", "v2"), + (" ", None), + ], + ) + def test_parse_model_version(self, raw, expected): + assert parse_model_version(raw) == expected + + @pytest.mark.parametrize( + "path, expected", + [ + ("/m/pl_PL-gosia-medium.onnx", "pl_PL-gosia-medium"), + ("C:\\voices\\en_US-amy.onnx", "en_US-amy"), + ("/m/model.onnx", None), + ("/m/main_graph.onnx", None), + ("", None), + (None, None), + (b"bytes", None), + ], + ) + def test_model_file_stem(self, path, expected): + assert model_file_stem(path) == expected + + def test_model_id_from_path_skips_generic_stems(self): + assert model_id_from_path("/m/main_graph.onnx") is None + assert model_id_from_path("/m/yolo.onnx") == "yolo" + def test_install_hooks_publishes_inference_on_run(self, publish_spy): session = _FakeOrtSession() handle = make_handle(publish_spy) diff --git a/wildedge/integrations/onnx.py b/wildedge/integrations/onnx.py index 0fa07b6..adca054 100644 --- a/wildedge/integrations/onnx.py +++ b/wildedge/integrations/onnx.py @@ -43,7 +43,19 @@ _ort_patched = False _ORT_PATCH_LOCK = threading.Lock() -_GENERIC_GRAPH_NAMES = {"torch_jit", "main", "model", "network", "graph", "onnx_model"} +# Default graph names from exporters (torch.onnx.export uses torch_jit or +# main_graph), so they say nothing about which model this is. +_GENERIC_GRAPH_NAMES = { + "torch_jit", + "main_graph", + "main", + "model", + "network", + "graph", + "onnx_model", +} +# Exporters write INT64_MAX into model_version to mean "unset". +_UNSET_MODEL_VERSION = 2**63 - 1 ONNX_AUTO_LOAD_PATCH_NAME = "onnx_auto_load" @@ -60,10 +72,34 @@ def model_id_from_path(path: str) -> str | None: for part in path.replace("\\", "/").split("/"): if part.startswith("models--"): return part[len("models--") :].replace("--", "/", 1) - stem = os.path.splitext(os.path.basename(path))[0] + return model_file_stem(path) + + +def model_file_stem(path: object) -> str | None: + """File name without extension, unless it is a generic name like model.onnx.""" + if not isinstance(path, str) or not path: + return None + stem = os.path.splitext(os.path.basename(path.replace("\\", "/")))[0] return stem if stem and stem not in _GENERIC_GRAPH_NAMES else None +def parse_model_version(raw: object) -> str | None: + """Return the ONNX model_version as a string, or None when it is unset. + + 0, negative values and INT64_MAX all mean the exporter left it unset. + """ + if raw is None: + return None + try: + number = int(raw) + except (TypeError, ValueError): + text = str(raw).strip() + return text or None + if number <= 0 or number >= _UNSET_MODEL_VERSION: + return None + return str(number) + + def is_ort_session(obj: object) -> bool: return ort is not None and isinstance(obj, ort.InferenceSession) @@ -151,12 +187,15 @@ def extract_info( graph_name = ( raw_name if raw_name and raw_name not in _GENERIC_GRAPH_NAMES else None ) - model_id = overrides.pop("id", None) or graph_name - - raw_version = str(meta.version) if meta.version else None - version = overrides.pop("version", None) or ( - raw_version if raw_version and raw_version != "0" else None + # onnxruntime keeps the path a session was created from (None for bytes). + model_path = getattr(obj, "_model_path", None) + model_id = ( + overrides.pop("id", None) + or graph_name + or (model_id_from_path(model_path) if isinstance(model_path, str) else None) ) + + version = overrides.pop("version", None) or parse_model_version(meta.version) if version is None: logger.warning( "wildedge: ONNX model version could not be detected - sending as null" @@ -180,7 +219,10 @@ def extract_info( source = overrides.pop("source", "local") info = ModelInfo( - model_name=graph_name or model_id or "onnx-model", + model_name=graph_name + or model_file_stem(model_path) + or model_id + or "onnx-model", model_version=version, model_source=source, model_format="onnx", From 9725f2c69a4417354d3665fc8f032c0e7092657d Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 16:54:21 +0200 Subject: [PATCH 24/81] Use a fake clock in the attachment deadline tests The presign/PUT deadline test slept 60 ms against a 50 ms deadline, and Windows' coarse monotonic clock (about 15 ms ticks) could still read the deadline as unreached, so the PUT went out and the test failed. The deadline tests now move a fake time.monotonic in wildedge.attachments instead of sleeping, and assert exact timeouts and upload order. --- tests/test_attachments.py | 65 +++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index dfa95cf..4f56c3c 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -2,11 +2,14 @@ import json import time +import types import urllib.error import urllib.request from datetime import datetime, timezone -from wildedge import constants +import pytest + +from wildedge import attachments, constants from wildedge.attachments import ( Attachment, AttachmentManager, @@ -487,6 +490,32 @@ def test_client_attachments_enabled_via_env(monkeypatch, tmp_path): # --------------------------------------------------------------------------- # +class FakeClock: + """Stands in for time.monotonic() in wildedge.attachments; tests move it.""" + + def __init__(self) -> None: + self.now = 1000.0 + + def monotonic(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +@pytest.fixture +def clock(monkeypatch) -> FakeClock: + fake = FakeClock() + monkeypatch.setattr( + attachments, + "time", + types.SimpleNamespace( + monotonic=fake.monotonic, time=time.time, time_ns=time.time_ns + ), + ) + return fake + + def test_transmit_past_deadline_is_transient_without_requests(monkeypatch, tmp_path): monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen([])) tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example") @@ -494,20 +523,21 @@ def test_transmit_past_deadline_is_transient_without_requests(monkeypatch, tmp_p assert outcome is UploadOutcome.TRANSIENT -def test_transmit_caps_request_timeout_at_deadline(monkeypatch, tmp_path): +def test_transmit_caps_request_timeout_at_deadline(monkeypatch, tmp_path, clock): timeouts: list[float] = [] responses = iter([PRESIGN_OK, _Resp(200)]) def opener(req, timeout=None): timeouts.append(timeout) + clock.advance(0.25) return next(responses) monkeypatch.setattr(urllib.request, "urlopen", opener) tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example") - outcome = tx.transmit(_pending(tmp_path), deadline=time.monotonic() + 1.0) + outcome = tx.transmit(_pending(tmp_path), deadline=clock.now + 1.0) assert outcome is UploadOutcome.UPLOADED - assert len(timeouts) == 2 - assert all(0 < t <= 1.0 for t in timeouts) + # Each request gets what is left of the deadline, below the 10 s default. + assert timeouts == [1.0, 0.75] def test_drain_uploads_more_than_one_batch(tmp_path): @@ -528,24 +558,23 @@ def test_drain_stops_at_transient(tmp_path): assert [p.attachment_id for p in store.peek_many(10)] == ["a1", "a2"] -def test_drain_respects_timeout(tmp_path): +def test_drain_respects_timeout(tmp_path, clock): store = _filled_store(tmp_path, 3) + seen: list[str] = [] class Slow: - seen = 0 - def transmit(self, pending, deadline=None): - Slow.seen += 1 - time.sleep(0.1) - if deadline is not None and time.monotonic() >= deadline: + seen.append(pending.attachment_id) + clock.advance(0.1) + if deadline is not None and clock.now >= deadline: return UploadOutcome.TRANSIENT return UploadOutcome.UPLOADED up = AttachmentUploader(store, Slow()) - t0 = time.monotonic() up.drain(timeout=0.15) - assert time.monotonic() - t0 < 0.5 - assert store.length() >= 1 + # The first upload ends inside the deadline, the second past it. + assert seen == ["a0", "a1"] + assert [p.attachment_id for p in store.peek_many(10)] == ["a1", "a2"] def test_drain_waits_for_background_batch_within_timeout(tmp_path): @@ -609,17 +638,19 @@ def test_flush_uploads_pending_attachments(tmp_path): client.close() -def test_transmit_deadline_passing_after_presign_skips_put(monkeypatch, tmp_path): +def test_transmit_deadline_passing_after_presign_skips_put( + monkeypatch, tmp_path, clock +): calls: list[str] = [] def opener(req, timeout=None): calls.append(req.get_method()) - time.sleep(0.06) + clock.advance(0.06) return PRESIGN_OK monkeypatch.setattr(urllib.request, "urlopen", opener) tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example") - outcome = tx.transmit(_pending(tmp_path), deadline=time.monotonic() + 0.05) + outcome = tx.transmit(_pending(tmp_path), deadline=clock.now + 0.05) assert outcome is UploadOutcome.TRANSIENT assert calls == ["POST"] From f5872e074565ae47059ee888241b579db741e1d0 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 17:06:37 +0200 Subject: [PATCH 25/81] Fix lint findings from the PRs merged since --- tests/compat/test_gguf_compat.py | 3 ++- tests/test_integrations.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/compat/test_gguf_compat.py b/tests/compat/test_gguf_compat.py index 1b6b656..0d628cc 100644 --- a/tests/compat/test_gguf_compat.py +++ b/tests/compat/test_gguf_compat.py @@ -55,7 +55,8 @@ def test_create_completion_is_tracked_with_its_config(compat_client, llm): assert config["max_tokens"] == 4 assert config["stop_sequences_count"] == 1 # Arguments not passed are reported with llama-cpp-python's defaults. - assert "top_p" in config and "top_k" in config + assert "top_p" in config + assert "top_k" in config def test_chat_completion_is_tracked_once_as_chat(compat_client, llm): diff --git a/tests/test_integrations.py b/tests/test_integrations.py index c6b127b..fdde21d 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -269,7 +269,7 @@ def test_real_graph_name_wins_over_file_stem(self): assert info.model_name == "resnet50" @pytest.mark.parametrize( - "raw, expected", + ("raw", "expected"), [ (None, None), (0, None), @@ -286,7 +286,7 @@ def test_parse_model_version(self, raw, expected): assert parse_model_version(raw) == expected @pytest.mark.parametrize( - "path, expected", + ("path", "expected"), [ ("/m/pl_PL-gosia-medium.onnx", "pl_PL-gosia-medium"), ("C:\\voices\\en_US-amy.onnx", "en_US-amy"), From 363d7145a6ec3e84e63472cc54e9fa705dbb41e2 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 17:09:21 +0200 Subject: [PATCH 26/81] Track each transformers pipeline call once pipeline() loads the model before Pipeline.__init__ runs, so the model was registered with forward hooks of its own, and every pipeline call produced two inference events: one from the pipeline patch and one from the model inside it. The pipeline patch now marks the call on a thread-local counter and the model's forward hook skips while it is set. Direct model calls are still tracked. Closes #70 --- CHANGELOG.md | 6 + tests/compat/test_transformers_compat.py | 6 - tests/test_integrations_transformers.py | 164 +++++++++++++++++++++++ wildedge/integrations/transformers.py | 17 ++- 4 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 tests/test_integrations_transformers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 99dc1f5..fae6204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ under Unreleased and move into a version section at release time. session created without an `id` falls back to the file name too, and `model_version` 0, negative or INT64_MAX (the exporters' "unset") is sent as `unknown` instead of `9223372036854775807`. +- transformers pipelines record one inference event per call instead of two. + `pipeline()` loads the model before `Pipeline.__init__` runs, so the model + was registered with its own forward hooks, and each pipeline call was + tracked by the pipeline and again by the model inside it. The model's hooks + now skip calls made inside a tracked pipeline call; calling the model + directly is still tracked. ## 0.2.2 - 2026-09-30 diff --git a/tests/compat/test_transformers_compat.py b/tests/compat/test_transformers_compat.py index 9d4e8ae..0ddb803 100644 --- a/tests/compat/test_transformers_compat.py +++ b/tests/compat/test_transformers_compat.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pytest import torch import transformers @@ -34,11 +33,6 @@ def test_from_pretrained_load_and_forward_are_tracked(compat_client, tmp_path): assert inference["inference"]["success"] is True -@pytest.mark.xfail( - strict=True, - reason="pipeline() calls are counted twice: the inner model keeps its " - "forward hooks after the pipeline supersedes it (#70)", -) def test_pipeline_load_and_call_are_tracked(compat_client, tmp_path): _save_tiny_bert(tmp_path) vocab = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]", "hello", "world"] diff --git a/tests/test_integrations_transformers.py b/tests/test_integrations_transformers.py new file mode 100644 index 0000000..6bdf3db --- /dev/null +++ b/tests/test_integrations_transformers.py @@ -0,0 +1,164 @@ +"""Inference tracking for transformers pipelines and models (#70).""" + +from __future__ import annotations + +import threading +import types + +import pytest + +import wildedge.integrations.transformers as tf_mod +from wildedge.integrations.transformers import TransformersExtractor +from wildedge.model import ModelHandle, ModelInfo + + +class FakeModel: + """Enough of a torch module for install_hooks: forward hooks and a config.""" + + config = types.SimpleNamespace( + name_or_path="tiny-bert", + model_type="bert", + architectures=["BertForSequenceClassification"], + ) + + def __init__(self) -> None: + self.pre_hooks: list = [] + self.post_hooks: list = [] + + def register_forward_pre_hook(self, hook): + self.pre_hooks.append(hook) + + def register_forward_hook(self, hook): + self.post_hooks.append(hook) + + def __call__(self, input_ids): + args = (input_ids,) + for hook in self.pre_hooks: + hook(self, args) + output = [[0.1, 0.9]] + for hook in self.post_hooks: + hook(self, args, output) + return output + + +class FakeTensor: + shape = (1, 4) + + +def make_pipeline_class(): + class Pipeline: + task = "text-classification" + + def __init__(self, model, fail=False): + self.model = model + self.fail = fail + + def __call__(self, inputs, *args, **kwargs): + if self.fail: + raise RuntimeError("tokenizer exploded") + self.model(FakeTensor()) + return [{"label": "LABEL_1", "score": 0.9}] + + # is_pipeline() looks for a class named Pipeline from a transformers module. + Pipeline.__module__ = "transformers.pipelines.base" + return Pipeline + + +@pytest.fixture +def patched_pipeline(monkeypatch): + pipeline_cls = make_pipeline_class() + fake_transformers = types.SimpleNamespace(Pipeline=pipeline_cls) + monkeypatch.setattr(tf_mod, "_transformers", fake_transformers) + TransformersExtractor._patch_pipeline_call() + return pipeline_cls + + +def make_handle(events: list) -> ModelHandle: + info = ModelInfo( + model_name="tiny-bert", + model_version="1", + model_source="local", + model_format="transformers", + ) + return ModelHandle(model_id="tiny-bert", info=info, publish=events.append) + + +def tracked_pipeline(pipeline_cls, events, fail=False): + """A pipeline wrapping a model, both with hooks, as after pipeline() loads.""" + extractor = TransformersExtractor() + handle = make_handle(events) + model = FakeModel() + extractor.install_hooks(model, handle) # from_pretrained ran first + pipe = pipeline_cls(model, fail=fail) + extractor.install_hooks(pipe, handle) # then the pipeline superseded it + return pipe, model + + +def inference_events(events: list) -> list: + return [e for e in events if e["event_type"] == "inference"] + + +def test_pipeline_call_is_tracked_once(patched_pipeline): + events: list = [] + pipe, _ = tracked_pipeline(patched_pipeline, events) + + pipe("hello world") + + inferences = inference_events(events) + assert len(inferences) == 1 + inference = inferences[0]["inference"] + # The pipeline-level event, with what only the pipeline knows. + assert inference["batch_size"] == 1 + assert inference["input_meta"]["word_count"] == 2 + assert inference["output_meta"]["task"] == "classification" + + +def test_direct_model_call_is_still_tracked(patched_pipeline): + events: list = [] + _, model = tracked_pipeline(patched_pipeline, events) + + model(FakeTensor()) + + inferences = inference_events(events) + assert len(inferences) == 1 + assert inferences[0]["inference"]["input_meta"]["token_count"] == 4 + + +def test_failed_pipeline_call_resets_suppression(patched_pipeline): + events: list = [] + pipe, model = tracked_pipeline(patched_pipeline, events, fail=True) + + with pytest.raises(RuntimeError, match="tokenizer exploded"): + pipe("hello") + assert [e["event_type"] for e in events] == ["error"] + + model(FakeTensor()) + assert len(inference_events(events)) == 1 + + +def test_untracked_pipeline_does_not_suppress_model_hooks(patched_pipeline): + events: list = [] + model = FakeModel() + TransformersExtractor().install_hooks(model, make_handle(events)) + pipe = patched_pipeline(model) # no handle on the pipeline itself + + pipe("hello") + + assert len(inference_events(events)) == 1 + + +def test_suppression_is_per_thread(patched_pipeline): + events: list = [] + _, model = tracked_pipeline(patched_pipeline, events) + + # This thread is inside a tracked pipeline call; another thread is not. + tf_mod._tl.pipeline_call_depth = 1 + try: + model(FakeTensor()) # suppressed: the pipeline call records it + other = threading.Thread(target=lambda: model(FakeTensor())) + other.start() + other.join() + finally: + tf_mod._tl.pipeline_call_depth = 0 + + assert len(inference_events(events)) == 1 # only the other thread's call diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index e5a3b2c..5e0dc20 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -41,11 +41,18 @@ # Per-instance attribute that stores the ModelHandle on a pipeline object. PIPELINE_HANDLE_ATTR = "__wildedge_pipeline_handle__" -# Thread-local flag: suppress from_pretrained tracking when called inside -# Pipeline.__init__ so the pipeline-level tracking takes precedence. +# Thread-local state: +# - inside_pipeline_init: suppress from_pretrained tracking when called inside +# Pipeline.__init__ so the pipeline-level tracking takes precedence. +# - pipeline_call_depth: > 0 while a tracked pipeline call runs, so the forward +# hooks of the model inside it don't record the same inference again. _tl = threading.local() +def inside_tracked_pipeline_call() -> bool: + return getattr(_tl, "pipeline_call_depth", 0) > 0 + + debug_transformers_failure = functools.partial(debug_failure, "transformers") @@ -308,6 +315,9 @@ def pre_hook(module, args): # type: ignore[no-untyped-def] _local.t0 = time.perf_counter() def post_hook(module, args, output): # type: ignore[no-untyped-def] + # A pipeline call around this forward pass records the event. + if inside_tracked_pipeline_call(): + return t0 = getattr(_local, "t0", None) duration_ms = elapsed_ms(t0) if t0 is not None else 0 @@ -419,6 +429,7 @@ def patched_call(self, inputs, *args, **kwargs): # type: ignore[no-untyped-def] input_modality, output_modality = pipeline_modalities(task) t0 = time.perf_counter() + _tl.pipeline_call_depth = getattr(_tl, "pipeline_call_depth", 0) + 1 try: outputs = original_call(self, inputs, *args, **kwargs) duration_ms = elapsed_ms(t0) @@ -439,6 +450,8 @@ def patched_call(self, inputs, *args, **kwargs): # type: ignore[no-untyped-def] error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], ) raise + finally: + _tl.pipeline_call_depth -= 1 patched_call.__wildedge_patch_name__ = PIPELINE_CALL_PATCH_NAME # type: ignore[attr-defined] patched_call.__wildedge_original_call__ = original_call # type: ignore[attr-defined] From 622900474c17d88379311e26b0d13b7cfb90a186 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 17:18:02 +0200 Subject: [PATCH 27/81] Keep pipeline tracking state in a typed thread-local The two per-thread flags were attributes on a bare threading.local read with getattr defaults. A threading.local subclass now declares both, the forward pre-hook skips inside a tracked pipeline call like the post-hook does, and the cross-thread test checks behaviour instead of setting the counter by hand. --- tests/test_integrations_transformers.py | 39 ++++++++++++++++++------- wildedge/integrations/transformers.py | 33 +++++++++++++-------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/tests/test_integrations_transformers.py b/tests/test_integrations_transformers.py index 6bdf3db..3b26520 100644 --- a/tests/test_integrations_transformers.py +++ b/tests/test_integrations_transformers.py @@ -147,18 +147,35 @@ def test_untracked_pipeline_does_not_suppress_model_hooks(patched_pipeline): assert len(inference_events(events)) == 1 -def test_suppression_is_per_thread(patched_pipeline): - events: list = [] - _, model = tracked_pipeline(patched_pipeline, events) +class CallsFromAnotherThreadToo: + """Pipeline model that also runs the hooked model on a second thread.""" + + def __init__(self, model: FakeModel) -> None: + self.model = model - # This thread is inside a tracked pipeline call; another thread is not. - tf_mod._tl.pipeline_call_depth = 1 - try: - model(FakeTensor()) # suppressed: the pipeline call records it - other = threading.Thread(target=lambda: model(FakeTensor())) + def __call__(self, input_ids): + other = threading.Thread(target=self.model, args=(input_ids,)) other.start() other.join() - finally: - tf_mod._tl.pipeline_call_depth = 0 + return self.model(input_ids) + + +def test_model_call_on_another_thread_during_pipeline_call_is_tracked( + patched_pipeline, +): + events: list = [] + extractor = TransformersExtractor() + handle = make_handle(events) + model = FakeModel() + extractor.install_hooks(model, handle) + pipe = patched_pipeline(CallsFromAnotherThreadToo(model)) + extractor.install_hooks(pipe, handle) + + pipe("hello") - assert len(inference_events(events)) == 1 # only the other thread's call + # The pipeline's own event, and the other thread's model call; the model + # call on the pipeline's thread is part of the pipeline event. + metas = [e["inference"]["input_meta"] for e in inference_events(events)] + assert len(metas) == 2 + assert {"char_count": 5, "word_count": 1} in metas # the pipeline call + assert {"token_count": 4} in metas # the other thread's model call diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index 5e0dc20..bf33700 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -41,16 +41,22 @@ # Per-instance attribute that stores the ModelHandle on a pipeline object. PIPELINE_HANDLE_ATTR = "__wildedge_pipeline_handle__" -# Thread-local state: -# - inside_pipeline_init: suppress from_pretrained tracking when called inside -# Pipeline.__init__ so the pipeline-level tracking takes precedence. -# - pipeline_call_depth: > 0 while a tracked pipeline call runs, so the forward -# hooks of the model inside it don't record the same inference again. -_tl = threading.local() +class _ThreadState(threading.local): + """Per-thread flags that keep pipeline-level tracking the only record. -def inside_tracked_pipeline_call() -> bool: - return getattr(_tl, "pipeline_call_depth", 0) > 0 + inside_pipeline_init: from_pretrained calls made by Pipeline.__init__ are + not tracked on their own. + pipeline_call_depth: while above zero, a tracked pipeline call is running + and the forward hooks of the model inside it record nothing. + """ + + def __init__(self) -> None: + self.inside_pipeline_init = False + self.pipeline_call_depth = 0 + + +_tl = _ThreadState() debug_transformers_failure = functools.partial(debug_failure, "transformers") @@ -311,12 +317,15 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: _, _, arch = extract_model_config(obj) task_hint = infer_task_from_arch(arch) + # Forward passes inside a tracked pipeline call are recorded by + # the pipeline, so both hooks skip them. def pre_hook(module, args): # type: ignore[no-untyped-def] + if _tl.pipeline_call_depth: + return _local.t0 = time.perf_counter() def post_hook(module, args, output): # type: ignore[no-untyped-def] - # A pipeline call around this forward pass records the event. - if inside_tracked_pipeline_call(): + if _tl.pipeline_call_depth: return t0 = getattr(_local, "t0", None) duration_ms = elapsed_ms(t0) if t0 is not None else 0 @@ -429,7 +438,7 @@ def patched_call(self, inputs, *args, **kwargs): # type: ignore[no-untyped-def] input_modality, output_modality = pipeline_modalities(task) t0 = time.perf_counter() - _tl.pipeline_call_depth = getattr(_tl, "pipeline_call_depth", 0) + 1 + _tl.pipeline_call_depth += 1 try: outputs = original_call(self, inputs, *args, **kwargs) duration_ms = elapsed_ms(t0) @@ -470,7 +479,7 @@ def _patch_from_pretrained(cls, client_ref: object) -> None: def patched_from_pretrained(model_cls, *args, **kwargs): # type: ignore[no-untyped-def] # Don't double-track models loaded inside Pipeline.__init__ - if getattr(_tl, "inside_pipeline_init", False): + if _tl.inside_pipeline_init: return original_func(model_cls, *args, **kwargs) c = client_ref() # type: ignore[call-arg] hub_before = ( From 2fd5324da6fd10b2ff6b6d6e6199318e678a022f Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 17:27:03 +0200 Subject: [PATCH 28/81] Only suppress the pipeline's own model during a pipeline call Review of #77: the hook guard checked whether any tracked pipeline call was running on the thread, so a custom pipeline that also ran a second tracked model silently dropped that model's events. Each thread now counts the models wrapped by running tracked pipeline calls (by id, for nesting), and a forward hook skips only when its own model is among them. The changelog no longer claims one event per call for generator and dataset inputs, which run lazily after __call__ returns; that is #78. --- CHANGELOG.md | 14 ++--- tests/test_integrations_transformers.py | 69 ++++++++++++++++++++----- wildedge/integrations/transformers.py | 25 +++++---- 3 files changed, 78 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fae6204..8e7d2c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,12 +35,14 @@ under Unreleased and move into a version section at release time. session created without an `id` falls back to the file name too, and `model_version` 0, negative or INT64_MAX (the exporters' "unset") is sent as `unknown` instead of `9223372036854775807`. -- transformers pipelines record one inference event per call instead of two. - `pipeline()` loads the model before `Pipeline.__init__` runs, so the model - was registered with its own forward hooks, and each pipeline call was - tracked by the pipeline and again by the model inside it. The model's hooks - now skip calls made inside a tracked pipeline call; calling the model - directly is still tracked. +- transformers pipelines called with a string or a list record one inference + event per call instead of two. `pipeline()` loads the model before + `Pipeline.__init__` runs, so the model was registered with its own forward + hooks, and each pipeline call was tracked by the pipeline and again by the + model inside it. That model's hooks now skip forward passes inside a + tracked call of its pipeline; other models the pipeline runs, and direct + calls to the model, are still tracked. Generator and dataset inputs, which + run lazily, are not covered yet (#78). ## 0.2.2 - 2026-09-30 diff --git a/tests/test_integrations_transformers.py b/tests/test_integrations_transformers.py index 3b26520..c11193b 100644 --- a/tests/test_integrations_transformers.py +++ b/tests/test_integrations_transformers.py @@ -49,14 +49,18 @@ def make_pipeline_class(): class Pipeline: task = "text-classification" - def __init__(self, model, fail=False): + def __init__(self, model, fail=False, extra_models=()): self.model = model self.fail = fail + # Other models a custom pipeline runs, e.g. from _forward. + self.extra_models = list(extra_models) def __call__(self, inputs, *args, **kwargs): if self.fail: raise RuntimeError("tokenizer exploded") self.model(FakeTensor()) + for extra in self.extra_models: + extra(FakeTensor()) return [{"label": "LABEL_1", "score": 0.9}] # is_pipeline() looks for a class named Pipeline from a transformers module. @@ -73,14 +77,14 @@ def patched_pipeline(monkeypatch): return pipeline_cls -def make_handle(events: list) -> ModelHandle: +def make_handle(events: list, model_id: str = "tiny-bert") -> ModelHandle: info = ModelInfo( - model_name="tiny-bert", + model_name=model_id, model_version="1", model_source="local", model_format="transformers", ) - return ModelHandle(model_id="tiny-bert", info=info, publish=events.append) + return ModelHandle(model_id=model_id, info=info, publish=events.append) def tracked_pipeline(pipeline_cls, events, fail=False): @@ -147,17 +151,13 @@ def test_untracked_pipeline_does_not_suppress_model_hooks(patched_pipeline): assert len(inference_events(events)) == 1 -class CallsFromAnotherThreadToo: - """Pipeline model that also runs the hooked model on a second thread.""" - - def __init__(self, model: FakeModel) -> None: - self.model = model - - def __call__(self, input_ids): - other = threading.Thread(target=self.model, args=(input_ids,)) +def call_on_another_thread(model: FakeModel): + def run(input_ids): + other = threading.Thread(target=model, args=(input_ids,)) other.start() other.join() - return self.model(input_ids) + + return run def test_model_call_on_another_thread_during_pipeline_call_is_tracked( @@ -168,7 +168,7 @@ def test_model_call_on_another_thread_during_pipeline_call_is_tracked( handle = make_handle(events) model = FakeModel() extractor.install_hooks(model, handle) - pipe = patched_pipeline(CallsFromAnotherThreadToo(model)) + pipe = patched_pipeline(model, extra_models=[call_on_another_thread(model)]) extractor.install_hooks(pipe, handle) pipe("hello") @@ -179,3 +179,44 @@ def test_model_call_on_another_thread_during_pipeline_call_is_tracked( assert len(metas) == 2 assert {"char_count": 5, "word_count": 1} in metas # the pipeline call assert {"token_count": 4} in metas # the other thread's model call + + +def test_other_model_called_during_pipeline_call_is_tracked(patched_pipeline): + """A custom pipeline that also runs a second tracked model keeps both.""" + events: list = [] + extractor = TransformersExtractor() + main_model, reranker = FakeModel(), FakeModel() + main_handle = make_handle(events) + reranker_handle = make_handle(events, model_id="reranker") + extractor.install_hooks(main_model, main_handle) + extractor.install_hooks(reranker, reranker_handle) + pipe = patched_pipeline(main_model, extra_models=[reranker]) + extractor.install_hooks(pipe, main_handle) + + pipe("hello") + + ids = sorted(e["model_id"] for e in inference_events(events)) + assert ids == ["reranker", "tiny-bert"] + + +def test_nested_pipeline_calls_keep_suppressing_until_the_outer_ends( + patched_pipeline, +): + events: list = [] + extractor = TransformersExtractor() + handle = make_handle(events) + model = FakeModel() + extractor.install_hooks(model, handle) + inner = patched_pipeline(model) + extractor.install_hooks(inner, handle) + # The outer pipeline wraps the same model and calls the inner one, then + # runs the model again after the inner call has returned. + outer = patched_pipeline(model, extra_models=[lambda _: inner("x"), model]) + extractor.install_hooks(outer, handle) + + outer("hello") + + # One event per pipeline call; no model events from inside either. + inferences = inference_events(events) + assert len(inferences) == 2 + assert all("word_count" in e["inference"]["input_meta"] for e in inferences) diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index bf33700..5c50956 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -6,6 +6,7 @@ import os import threading import time +from collections import Counter from typing import TYPE_CHECKING from wildedge import constants @@ -43,17 +44,18 @@ class _ThreadState(threading.local): - """Per-thread flags that keep pipeline-level tracking the only record. + """Per-thread state that keeps pipeline-level tracking the only record. inside_pipeline_init: from_pretrained calls made by Pipeline.__init__ are not tracked on their own. - pipeline_call_depth: while above zero, a tracked pipeline call is running - and the forward hooks of the model inside it record nothing. + pipeline_models: id() of the model each running tracked pipeline call + wraps, counted for nesting. That model's forward hooks record nothing + while it is here; other models called during the pipeline call do. """ def __init__(self) -> None: self.inside_pipeline_init = False - self.pipeline_call_depth = 0 + self.pipeline_models: Counter[int] = Counter() _tl = _ThreadState() @@ -317,15 +319,15 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: _, _, arch = extract_model_config(obj) task_hint = infer_task_from_arch(arch) - # Forward passes inside a tracked pipeline call are recorded by - # the pipeline, so both hooks skip them. + # A tracked pipeline call records its own model's forward passes, + # so both hooks skip them. def pre_hook(module, args): # type: ignore[no-untyped-def] - if _tl.pipeline_call_depth: + if _tl.pipeline_models[id(module)]: return _local.t0 = time.perf_counter() def post_hook(module, args, output): # type: ignore[no-untyped-def] - if _tl.pipeline_call_depth: + if _tl.pipeline_models[id(module)]: return t0 = getattr(_local, "t0", None) duration_ms = elapsed_ms(t0) if t0 is not None else 0 @@ -438,7 +440,8 @@ def patched_call(self, inputs, *args, **kwargs): # type: ignore[no-untyped-def] input_modality, output_modality = pipeline_modalities(task) t0 = time.perf_counter() - _tl.pipeline_call_depth += 1 + model_key = id(getattr(self, "model", None)) + _tl.pipeline_models[model_key] += 1 try: outputs = original_call(self, inputs, *args, **kwargs) duration_ms = elapsed_ms(t0) @@ -460,7 +463,9 @@ def patched_call(self, inputs, *args, **kwargs): # type: ignore[no-untyped-def] ) raise finally: - _tl.pipeline_call_depth -= 1 + _tl.pipeline_models[model_key] -= 1 + if not _tl.pipeline_models[model_key]: + del _tl.pipeline_models[model_key] patched_call.__wildedge_patch_name__ = PIPELINE_CALL_PATCH_NAME # type: ignore[attr-defined] patched_call.__wildedge_original_call__ = original_call # type: ignore[attr-defined] From 77c52eaf23418248bfe3a81f9236cbe8f71e52f6 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 17:35:29 +0200 Subject: [PATCH 29/81] Require ultralytics 8.0.200 and warn on older releases Releases before 8.0.200 keep their helpers under ultralytics.yolo.utils, so the integration's import of ultralytics.utils failed and the whole integration turned off without a word, including on 8.0.0, the documented minimum. The compat minimum moves to 8.0.200 (which also drops the setuptools pin 8.0.0 needed on Python 3.12+), and instrument("ultralytics") logs a warning naming the installed version when it is older. Closes #71 --- CHANGELOG.md | 8 +++++++ docs/compatibility.md | 4 +--- scripts/compat_matrix.py | 10 +-------- tests/compat/test_ultralytics_compat.py | 15 +------------ tests/test_integrations_ultralytics.py | 19 ++++++++++++++++ wildedge/integrations/ultralytics.py | 30 +++++++++++++++++++++---- 6 files changed, 56 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99dc1f5..4a0cca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Changed + +- The ultralytics integration now requires ultralytics 8.0.200 or later + (was 8.0.0). Earlier 8.0.x releases keep their helpers under + `ultralytics.yolo.utils`, and the integration turned itself off on them + without a word. `instrument("ultralytics")` now logs a warning naming the + installed version instead. + ### Fixed - The gguf integration now tracks `Llama.create_chat_completion()` and diff --git a/docs/compatibility.md b/docs/compatibility.md index cae8d07..d75350e 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -64,8 +64,6 @@ To run the full matrix locally: `python scripts/run_compat_local.py`. | `transformers` | `min` | `transformers==4.40.0`, `torch==2.4.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | | `transformers` | `min` (`py3.13` override) | `transformers==4.45.0`, `torch==2.5.0`, `numpy==2.1.3` | 3.13 | | `transformers` | `current` | `transformers==5.20.1`, `torch==2.14.1`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | -| `ultralytics` | `min` | `ultralytics==8.0.0`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | -| `ultralytics` | `min` (`py3.12` override) | `ultralytics==8.0.0`, `numpy==1.26.4`, `setuptools==75.8.0` | 3.12 | -| `ultralytics` | `min` (`py3.13` override) | `ultralytics==8.0.0`, `numpy==1.26.4`, `setuptools==75.8.0` | 3.13 | +| `ultralytics` | `min` | `ultralytics==8.0.200`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | | `ultralytics` | `current` | `ultralytics==8.4.182`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | | `mlx` | `current` | `mlx==0.32.4`, `mlx-lm==0.32.2` | 3.12, 3.13 | diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 95caa85..03b618d 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -52,7 +52,7 @@ "current": ["transformers==5.20.1", "torch==2.14.1", "numpy==2.2.6"], }, "ultralytics": { - "min": ["ultralytics==8.0.0", "numpy==1.26.4"], + "min": ["ultralytics==8.0.200", "numpy==1.26.4"], "current": ["ultralytics==8.4.182", "numpy==2.2.6"], }, "mlx": { @@ -110,14 +110,6 @@ "3.10": ["tensorflow==2.21.0", "keras==3.12.4", "numpy==2.2.6"], }, }, - "ultralytics": { - # ultralytics 8.0.0 imports pkg_resources without declaring setuptools, - # which Python 3.12+ environments no longer include. - "min": { - "3.12": ["ultralytics==8.0.0", "numpy==1.26.4", "setuptools==75.8.0"], - "3.13": ["ultralytics==8.0.0", "numpy==1.26.4", "setuptools==75.8.0"], - }, - }, "transformers": { "min": { "3.13": ["transformers==4.45.0", "torch==2.5.0", "numpy==2.1.3"], diff --git a/tests/compat/test_ultralytics_compat.py b/tests/compat/test_ultralytics_compat.py index ffd8e4f..f22267f 100644 --- a/tests/compat/test_ultralytics_compat.py +++ b/tests/compat/test_ultralytics_compat.py @@ -1,20 +1,9 @@ from __future__ import annotations -import importlib.util - import pytest import ultralytics from PIL import Image -# Early 8.0.x releases keep their helpers in ultralytics.yolo.utils. The -# integration imports ultralytics.utils, so on those releases it is off. -LEGACY_LAYOUT = importlib.util.find_spec("ultralytics.utils") is None -legacy_xfail = pytest.mark.xfail( - LEGACY_LAYOUT, - strict=True, - reason="integration is disabled on ultralytics without ultralytics.utils (#71)", -) - @pytest.fixture def yolo(compat_client): @@ -25,19 +14,17 @@ def yolo(compat_client): @pytest.fixture def image(tmp_path) -> str: - # A file path: ultralytics 8.0.0 can't take a numpy array as the source. + # A file path is a source every supported ultralytics release accepts. path = tmp_path / "blank.png" Image.new("RGB", (64, 64)).save(path) return str(path) -@legacy_xfail def test_yolo_load_is_tracked(compat_client, yolo): compat_client.one("model_load") assert compat_client.of_type("model_download") == [] -@legacy_xfail def test_yolo_call_is_tracked(compat_client, yolo, image): results = yolo(image, imgsz=64, verbose=False) assert len(results) == 1 diff --git a/tests/test_integrations_ultralytics.py b/tests/test_integrations_ultralytics.py index c740418..dc49c2d 100644 --- a/tests/test_integrations_ultralytics.py +++ b/tests/test_integrations_ultralytics.py @@ -2,10 +2,12 @@ from __future__ import annotations +import types from unittest.mock import MagicMock, patch import pytest +import wildedge.integrations.ultralytics as ultralytics_mod from wildedge.integrations.ultralytics import ( YOLO_FAMILY_RE, UltralyticsExtractor, @@ -445,3 +447,20 @@ def test_extractor_install_hooks_batch_size_from_list_source(publish_spy): frames = [np.zeros((480, 640, 3), dtype=np.uint8)] * 3 model(frames) assert publish_spy.events[0]["inference"]["batch_size"] == 3 + + +def test_unsupported_ultralytics_version_warns_and_does_not_patch(monkeypatch, caplog): + fake_yolo = type("YOLO", (), {"__init__": lambda self, *a, **k: None}) + original_init = fake_yolo.__init__ + monkeypatch.setattr( + ultralytics_mod, "_ultralytics", types.SimpleNamespace(YOLO=fake_yolo) + ) + monkeypatch.setattr(ultralytics_mod, "_unsupported_ultralytics_version", "8.0.0") + monkeypatch.setattr(ultralytics_mod, "_ultralytics_patched", False) + + with caplog.at_level("WARNING", logger="wildedge"): + UltralyticsExtractor.install_auto_load_patch(lambda: None) + + assert fake_yolo.__init__ is original_init + messages = [r.getMessage() for r in caplog.records] + assert any("ultralytics 8.0.0 is older than 8.0.200" in m for m in messages) diff --git a/wildedge/integrations/ultralytics.py b/wildedge/integrations/ultralytics.py index e96a9e9..4a822d3 100644 --- a/wildedge/integrations/ultralytics.py +++ b/wildedge/integrations/ultralytics.py @@ -27,14 +27,28 @@ from wildedge.model import ModelInfo from wildedge.timing import elapsed_ms +# Oldest supported release; earlier ones keep their helpers under +# ultralytics.yolo.utils instead of ultralytics.utils. +MIN_ULTRALYTICS_VERSION = "8.0.200" + try: import ultralytics as _ultralytics - from ultralytics.utils import ( - WEIGHTS_DIR as _ULTRALYTICS_WEIGHTS_DIR, # type: ignore[import-untyped] - ) except ImportError: _ultralytics = None # type: ignore[assignment] - _ULTRALYTICS_WEIGHTS_DIR = None # type: ignore[assignment] + +_ULTRALYTICS_WEIGHTS_DIR = None +# Set when ultralytics is installed but older than MIN_ULTRALYTICS_VERSION. +_unsupported_ultralytics_version: str | None = None +if _ultralytics is not None: + try: + from ultralytics.utils import ( + WEIGHTS_DIR as _ULTRALYTICS_WEIGHTS_DIR, # type: ignore[import-untyped] + ) + except ImportError: + _unsupported_ultralytics_version = getattr( + _ultralytics, "__version__", "unknown" + ) + _ultralytics = None # type: ignore[assignment] try: import numpy as np # type: ignore[import-untyped] @@ -393,6 +407,14 @@ def install_auto_load_patch(cls, client_ref: object) -> None: is emitted with the file size and the total load duration. """ global _ultralytics_patched + if _unsupported_ultralytics_version is not None: + logger.warning( + "wildedge: ultralytics %s is older than %s, the oldest supported " + "release; the ultralytics integration is disabled", + _unsupported_ultralytics_version, + MIN_ULTRALYTICS_VERSION, + ) + return if _ultralytics_patched or _ultralytics is None: return From 7a16e635780882f30d7c6d7a19c1a882fde36900 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 17:49:59 +0200 Subject: [PATCH 30/81] Add OpenAI Responses API instrumentation --- CHANGELOG.md | 8 + README.md | 4 + docs/providers.md | 55 ++++++ examples/openai_responses_example.py | 101 +++++++++++ tests/compat/test_openai_compat.py | 146 ++++++++++++++++ tests/test_integrations_openai_responses.py | 141 +++++++++++++++ wildedge/integrations/openai.py | 180 +++++++++++++++++++- 7 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 examples/openai_responses_example.py create mode 100644 tests/test_integrations_openai_responses.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bc71e07..7294af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Added + +- The OpenAI integration now tracks `responses.create()` for synchronous and + asynchronous clients, including streaming. Records input/output token usage, + cached and reasoning tokens, response status or incomplete reason, resolved + model metadata, and time to first output delta for streams. Older OpenAI SDKs + without Responses remain supported. + ### Changed - The ultralytics integration now requires ultralytics 8.0.200 or later diff --git a/README.md b/README.md index b45abcf..4352e0a 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ client libraries? Wrap the call in to get the same events: [llm_api_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/llm_api_example.py). +The `openai` integration also tracks `client.responses.create(...)`, including +async clients and `stream=True`. See [Responses tracking](docs/providers.md#responses-api) +for coverage and streaming behavior. + **Hub tracking** Pass `hubs=` to track model download provenance. Hubs are framework-agnostic and can be combined with any integration. diff --git a/docs/providers.md b/docs/providers.md index 8f78cea..5335150 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -50,3 +50,58 @@ wildedge.register_model(client, source="ollama", model_id="gemma-7b") providers omit them; they record as `None`, not `0`. - Streamed responses carry no usage unless you pass `stream_options={"include_usage": True}`. Timing and TTFT are unaffected. + + +## Responses API + +The `openai` integration instruments `client.responses.create()` and +`await client.responses.create()`, with or without `stream=True`. Enable it +before constructing the OpenAI client, as with Chat Completions. + +```python +import wildedge +from openai import OpenAI + +wildedge.init(integrations=["openai"]) +client = OpenAI() +response = client.responses.create(model="gpt-4o-mini", input="Say hello") +print(response.output_text) +wildedge.flush() +``` + +Usage comes from Responses' `input_tokens` / `output_tokens`, including cached +input and reasoning tokens when provided. The stop reason is the response +status, or the incomplete reason such as `max_output_tokens`. Failed responses +are recorded as unsuccessful. No prompt or response text is uploaded by this +adapter. String input and simple user messages provide text input metadata; +input iterators are passed through without being consumed by instrumentation. + +For streams, consume the iterator to exhaustion to record the terminal response +and its usage. Time to first output ignores lifecycle events and measures the +first nonempty text, refusal, function argument, or audio delta. Streams without +a terminal response do not produce a successful inference event. Early closure, +cancellation, background response polling, and individual tool spans are outside +this initial coverage. API exceptions retain their original behavior and use +the same error tracking as Chat Completions. + +OpenAI-compatible hosts must implement the Responses endpoint to use this path. +Older OpenAI SDK versions without a `responses` resource continue to support +Chat Completions. + + +### End-to-end check + +From the SDK root, with `OPENAI_API_KEY` and `WILDEDGE_DSN` set: + +```bash +uv run examples/openai_responses_example.py +``` + +This uses the local SDK checkout and makes four small, billable requests: +sync, sync streaming, async, and async streaming. Set `OPENAI_MODEL` to override +the default `gpt-4o-mini`. The script checks completion and usage, prints a +unique `responses-e2e-...` run ID, and closes the SDK with a 15-second deadline. +In WildEdge, locate that run and verify four inference events beneath the four +named spans, with input/output token usage and first-output timing on the two +streams. Provider checks alone do not prove telemetry delivery; confirm these +events in the dashboard. diff --git a/examples/openai_responses_example.py b/examples/openai_responses_example.py new file mode 100644 index 0000000..1ae6baa --- /dev/null +++ b/examples/openai_responses_example.py @@ -0,0 +1,101 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["wildedge-sdk", "openai>=1.66.0"] +# +# [tool.uv.sources] +# wildedge-sdk = { path = "..", editable = true } +# /// +"""Exercise all four Responses paths against OpenAI and WildEdge. + +From the SDK root: + uv run examples/openai_responses_example.py + +Requires OPENAI_API_KEY and WILDEDGE_DSN. Optionally set OPENAI_MODEL. +Makes four small, billable API calls. Find the printed run ID in WildEdge. +""" + +from __future__ import annotations + +import asyncio +import os +import uuid + +from openai import AsyncOpenAI, OpenAI + +import wildedge + +PROMPT = "Reply with exactly: Hello from Responses" +MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") + + +def check_response(result: object, label: str) -> None: + if result.status != "completed" or not result.output_text: + raise RuntimeError(f"{label}: response did not complete with text") + if result.usage is None: + raise RuntimeError(f"{label}: provider returned no usage") + print( + f"{label}: completed; input={result.usage.input_tokens}, output={result.usage.output_tokens}" + ) + + +def run_sync() -> None: + with OpenAI() as client: + with wildedge.span(kind="agent_step", name="responses-sync"): + result = client.responses.create( + model=MODEL, input=PROMPT, max_output_tokens=64 + ) + check_response(result, "sync") + with wildedge.span(kind="agent_step", name="responses-sync-stream"): + terminal = None + with client.responses.create( + model=MODEL, input=PROMPT, max_output_tokens=64, stream=True + ) as stream: + for event in stream: + if event.type == "response.completed": + terminal = event.response + if terminal is None: + raise RuntimeError("sync stream: missing completed response") + check_response(terminal, "sync stream") + + +async def run_async() -> None: + async with AsyncOpenAI() as client: + with wildedge.span(kind="agent_step", name="responses-async"): + result = await client.responses.create( + model=MODEL, input=PROMPT, max_output_tokens=64 + ) + check_response(result, "async") + with wildedge.span(kind="agent_step", name="responses-async-stream"): + terminal = None + async with await client.responses.create( + model=MODEL, input=PROMPT, max_output_tokens=64, stream=True + ) as stream: + async for event in stream: + if event.type == "response.completed": + terminal = event.response + if terminal is None: + raise RuntimeError("async stream: missing completed response") + check_response(terminal, "async stream") + + +def main() -> None: + missing = [ + key for key in ("OPENAI_API_KEY", "WILDEDGE_DSN") if not os.environ.get(key) + ] + if missing: + raise SystemExit("Set required environment variables: " + ", ".join(missing)) + run_id = f"responses-e2e-{uuid.uuid4().hex[:12]}" + print(f"Run ID: {run_id}") + sdk = wildedge.init(integrations=["openai"], app_version="responses-e2e") + try: + with wildedge.trace(run_id=run_id): + run_sync() + asyncio.run(run_async()) + finally: + sdk.close(timeout=15) + print("Provider checks passed. SDK shutdown finished; verify delivery in WildEdge.") + print("Expect four inference events under this run, one beneath each named span.") + + +if __name__ == "__main__": + main() diff --git a/tests/compat/test_openai_compat.py b/tests/compat/test_openai_compat.py index 925686f..a3c836e 100644 --- a/tests/compat/test_openai_compat.py +++ b/tests/compat/test_openai_compat.py @@ -1,8 +1,11 @@ from __future__ import annotations import json +import runpy +from pathlib import Path import openai +import pytest from conftest import sdk_http_module httpx = sdk_http_module("openai") @@ -116,3 +119,146 @@ def test_api_error_is_tracked(compat_client): error = compat_client.one("error") assert error["error"]["http_status"] == 429 assert error["error"]["provider_error_code"] == "rate_limit_exceeded" + + +RESPONSE = { + "id": "resp-compat", + "object": "response", + "created_at": 1700000000, + "model": "gpt-4o-mini", + "status": "completed", + "error": None, + "incomplete_details": None, + "output": [], + "parallel_tool_calls": True, + "tools": [], + "tool_choice": "auto", + "usage": { + "input_tokens": 9, + "output_tokens": 2, + "total_tokens": 11, + "input_tokens_details": {"cached_tokens": 3}, + "output_tokens_details": {"reasoning_tokens": 1}, + }, +} + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +async def test_responses_is_tracked(compat_client, asynchronous, streaming): + compat_client.instrument("openai") + with openai.OpenAI(api_key="sk-compat") as probe: + if not hasattr(probe, "responses"): + return # The minimum supported openai version predates Responses. + + def handler(request): + assert request.url.path == "/v1/responses" + if not streaming: + return httpx.Response(200, json=RESPONSE) + events = [ + { + "type": "response.created", + "sequence_number": 0, + "response": {**RESPONSE, "status": "in_progress", "usage": None}, + }, + { + "type": "response.output_text.delta", + "sequence_number": 1, + "item_id": "msg-1", + "output_index": 0, + "content_index": 0, + "delta": "Hi", + "logprobs": [], + }, + {"type": "response.completed", "sequence_number": 2, "response": RESPONSE}, + ] + body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events) + return httpx.Response( + 200, text=body, headers={"content-type": "text/event-stream"} + ) + + if asynchronous: + client = openai.AsyncOpenAI( + api_key="sk-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + async with client: + result = await client.responses.create( + model="gpt-4o-mini", input="Say hello", stream=streaming + ) + if streaming: + assert [e.type async for e in result][-1] == "response.completed" + else: + assert result.id == RESPONSE["id"] + else: + with _client(handler) as client: + result = client.responses.create( + model="gpt-4o-mini", input="Say hello", stream=streaming + ) + if streaming: + assert [e.type for e in result][-1] == "response.completed" + else: + assert result.id == RESPONSE["id"] + event = compat_client.one("inference") + assert event["model_id"] == "gpt-4o-mini" + output = event["inference"]["output_meta"] + assert output["tokens_in"] == 9 + assert output["tokens_out"] == 2 + assert output["cached_input_tokens"] == 3 + assert output["reasoning_tokens_out"] == 1 + assert output["stop_reason"] == "completed" + + +async def test_responses_example(compat_client): + compat_client.instrument("openai") + with openai.OpenAI(api_key="sk-compat") as probe: + if not hasattr(probe, "responses"): + return + example = runpy.run_path( + str(Path(__file__).parents[2] / "examples" / "openai_responses_example.py") + ) + payload = { + **RESPONSE, + "output": [ + { + "id": "msg-1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Hello from Responses", + "annotations": [], + } + ], + } + ], + } + + def handler(request): + if not json.loads(request.content).get("stream"): + return httpx.Response(200, json=payload) + event = { + "type": "response.completed", + "sequence_number": 0, + "response": payload, + } + return httpx.Response( + 200, + text=f"event: response.completed\ndata: {json.dumps(event)}\n\n", + headers={"content-type": "text/event-stream"}, + ) + + example["run_sync"].__globals__["OpenAI"] = lambda: _client(handler) + example["run_async"].__globals__["AsyncOpenAI"] = lambda: openai.AsyncOpenAI( + api_key="sk-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + with compat_client.trace(run_id="responses-example-test"): + example["run_sync"]() + await example["run_async"]() + events = compat_client.of_type("inference") + assert len(events) == 4 + assert all(e["run_id"] == "responses-example-test" for e in events) + assert len({e["parent_span_id"] for e in events}) == 4 diff --git a/tests/test_integrations_openai_responses.py b/tests/test_integrations_openai_responses.py new file mode 100644 index 0000000..3d43655 --- /dev/null +++ b/tests/test_integrations_openai_responses.py @@ -0,0 +1,141 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from wildedge.integrations.openai import ( + make_responses_stream_callbacks, + wrap_async_responses, + wrap_sync_responses, +) + + +def response(status="completed"): + return SimpleNamespace( + model="resolved", + status=status, + error=None, + incomplete_details=SimpleNamespace(reason="max_output_tokens") + if status == "incomplete" + else None, + usage=SimpleNamespace( + input_tokens=12, + output_tokens=4, + input_tokens_details=SimpleNamespace(cached_tokens=3), + output_tokens_details=SimpleNamespace(reasoning_tokens=2), + ), + ) + + +class AsyncItems: + def __init__(self, items): + self.items = items + + async def __aiter__(self): + for item in self.items: + yield item + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("status", ["completed", "incomplete", "failed"]) +async def test_responses(asynchronous, streaming, status): + result = response(status) + events = [ + SimpleNamespace(type="response.created"), + SimpleNamespace(type="response.output_text.delta", delta="hello"), + SimpleNamespace(type=f"response.{status}", response=result), + ] + returned = ( + (AsyncItems(events) if asynchronous else iter(events)) if streaming else result + ) + original = (AsyncMock if asynchronous else MagicMock)(return_value=returned) + resource = SimpleNamespace(create=original) + handle = MagicMock() + client = SimpleNamespace( + closed=False, register_model=MagicMock(return_value=handle) + ) + (wrap_async_responses if asynchronous else wrap_sync_responses)( + resource, "openai", lambda: client + ) + call = resource.create(model="model", input="hello world", stream=streaming) + actual = await call if asynchronous else call + if streaming: + assert ( + [event async for event in actual] if asynchronous else list(actual) + ) == events + else: + assert actual is result + handle.track_inference.assert_called_once() + data = handle.track_inference.call_args.kwargs + assert data["success"] == (status != "failed") + assert data["input_meta"].char_count == 11 + output = data["output_meta"] + assert ( + output.tokens_in, + output.tokens_out, + output.cached_input_tokens, + output.reasoning_tokens_out, + ) == (12, 4, 3, 2) + assert output.stop_reason == ( + "max_output_tokens" if status == "incomplete" else status + ) + assert data["api_meta"].resolved_model_id == "resolved" + + +def test_ttft_and_once(monkeypatch): + handle = MagicMock() + on_chunk, on_done = make_responses_stream_callbacks(handle, [], 0) + on_chunk(SimpleNamespace(type="response.created")) + monkeypatch.setattr("wildedge.integrations.openai.elapsed_ms", lambda _: 42) + on_chunk(SimpleNamespace(type="response.output_text.delta", delta="hi")) + on_chunk(SimpleNamespace(type="response.completed", response=response())) + on_done(100, 1) + on_done(101, 1) + handle.track_inference.assert_called_once() + assert ( + handle.track_inference.call_args.kwargs["output_meta"].time_to_first_token_ms + == 42 + ) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_error_and_closed_client(asynchronous): + error = TimeoutError("timeout") + original = (AsyncMock if asynchronous else MagicMock)(side_effect=error) + resource = SimpleNamespace(create=original) + handle = MagicMock() + client = SimpleNamespace( + closed=False, register_model=MagicMock(return_value=handle) + ) + (wrap_async_responses if asynchronous else wrap_sync_responses)( + resource, "openai", lambda: client + ) + + async def invoke(): + call = resource.create(model="model", input="hello") + return await call if asynchronous else call + + with pytest.raises(TimeoutError) as caught: + await invoke() + assert caught.value is error + handle.track_error.assert_called_once() + handle.track_inference.assert_not_called() + client.closed = True + original.side_effect = None + original.return_value = "unchanged" + call = resource.create(model="model", input="hello") + assert (await call if asynchronous else call) == "unchanged" + assert client.register_model.call_count == 1 + + +def test_unfinished_and_error_streams(): + handle = MagicMock() + on_chunk, on_done = make_responses_stream_callbacks(handle, [], 0) + on_chunk(SimpleNamespace(type="response.created")) + on_done(100, 1) + handle.track_inference.assert_not_called() + on_chunk(SimpleNamespace(type="error", code="server_error")) + on_done(100, 1) + handle.track_error.assert_called_once() + handle.track_inference.assert_not_called() diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index 4ced640..a639951 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -249,6 +249,168 @@ async def patched_create(*args, **kwargs): completions.create = patched_create # type: ignore[attr-defined] +def responses_messages(value: object) -> list: + """Normalize text inputs without consuming arbitrary input iterables.""" + if isinstance(value, str): + return [{"role": "user", "content": value}] + return value if isinstance(value, list) else [] + + +def build_responses_output_meta( + response: object, duration_ms: int +) -> GenerationOutputMeta: + usage = getattr(response, "usage", None) + tokens_out = getattr(usage, "output_tokens", None) + status = getattr(response, "status", None) + reason = getattr(getattr(response, "incomplete_details", None), "reason", None) + return GenerationOutputMeta( + tokens_in=getattr(usage, "input_tokens", None), + tokens_out=tokens_out, + cached_input_tokens=getattr( + getattr(usage, "input_tokens_details", None), "cached_tokens", None + ), + reasoning_tokens_out=getattr( + getattr(usage, "output_tokens_details", None), "reasoning_tokens", None + ), + tokens_per_second=round(tokens_out / duration_ms * 1000, 1) + if duration_ms > 0 and tokens_out + else None, + stop_reason=reason or status, + ) + + +def record_response( + handle: ModelHandle, + response: object, + messages: list, + duration: int, + ttft_ms: int | None = None, +) -> None: + if getattr(response, "status", None) in ("queued", "in_progress"): + return + output = build_responses_output_meta(response, duration) + output.time_to_first_token_ms = ttft_ms + error = getattr(response, "error", None) + handle.track_inference( + duration_ms=duration, + input_modality="text", + output_modality="generation", + success=getattr(response, "status", None) != "failed" and error is None, + error_code=getattr(error, "code", None), + input_meta=build_input_meta(messages, output.tokens_in), + output_meta=output, + api_meta=build_api_meta(response), + ) + + +def make_responses_stream_callbacks( + handle: ModelHandle, messages: list, t0: float +) -> tuple: + response = None + ttft = None + recorded = False + + def on_chunk(chunk: object) -> None: + nonlocal response, ttft, recorded + event_type = getattr(chunk, "type", "") + if ( + event_type + in ( + "response.output_text.delta", + "response.refusal.delta", + "response.function_call_arguments.delta", + "response.output_audio.delta", + ) + and getattr(chunk, "delta", None) + and ttft is None + ): + ttft = elapsed_ms(t0) + if event_type in ( + "response.completed", + "response.incomplete", + "response.failed", + ): + response = getattr(chunk, "response", None) + elif event_type == "error" and not recorded: + handle.track_error( + error_code=getattr(chunk, "code", None) or "PROVIDER_ERROR", + duration_ms=elapsed_ms(t0), + ) + recorded = True + + def on_done(duration_ms: int, _first_event_ms: int | None) -> None: + nonlocal recorded + if response is not None and not recorded: + recorded = True + record_response(handle, response, messages, duration_ms, ttft) + + return on_chunk, on_done + + +def wrap_sync_responses(responses: object, source: str, client_ref: object) -> None: + original_create = responses.create + model_handles: dict[str, ModelHandle] = {} + + @functools.wraps(original_create) + def patched_create(*args, **kwargs): + client = client_ref() + model_id = kwargs.get("model") + if client is None or client.closed or not model_id: + return original_create(*args, **kwargs) + handle = resolve_handle(model_id, responses, model_handles, client, source) + t0 = time.perf_counter() + try: + result = original_create(*args, **kwargs) + except Exception as exc: + if handle is not None: + track_api_error(handle, exc, elapsed_ms(t0)) + raise + if handle is not None: + try: + messages = responses_messages(kwargs.get("input")) + if kwargs.get("stream"): + callbacks = make_responses_stream_callbacks(handle, messages, t0) + return SyncStreamWrapper(result, handle, t0, *callbacks) + record_response(handle, result, messages, elapsed_ms(t0)) + except Exception as exc: + debug_openai_failure("responses tracking", exc) + return result + + responses.create = patched_create + + +def wrap_async_responses(responses: object, source: str, client_ref: object) -> None: + original_create = responses.create + model_handles: dict[str, ModelHandle] = {} + + @functools.wraps(original_create) + async def patched_create(*args, **kwargs): + client = client_ref() + model_id = kwargs.get("model") + if client is None or client.closed or not model_id: + return await original_create(*args, **kwargs) + handle = resolve_handle(model_id, responses, model_handles, client, source) + t0 = time.perf_counter() + try: + result = await original_create(*args, **kwargs) + except Exception as exc: + if handle is not None: + track_api_error(handle, exc, elapsed_ms(t0)) + raise + if handle is not None: + try: + messages = responses_messages(kwargs.get("input")) + if kwargs.get("stream"): + callbacks = make_responses_stream_callbacks(handle, messages, t0) + return AsyncStreamWrapper(result, handle, t0, *callbacks) + record_response(handle, result, messages, elapsed_ms(t0)) + except Exception as exc: + debug_openai_failure("responses tracking", exc) + return result + + responses.create = patched_create + + class OpenAIExtractor(BaseExtractor): def can_handle(self, obj: object) -> bool: return type(obj).__name__ in ( @@ -256,6 +418,8 @@ def can_handle(self, obj: object) -> bool: "AsyncOpenAI", "Completions", "AsyncCompletions", + "Responses", + "AsyncResponses", ) def extract_info( @@ -280,7 +444,7 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: @classmethod def install_auto_load_patch(cls, client_ref: object) -> None: - """Patch openai.OpenAI and openai.AsyncOpenAI to wrap chat.completions.create.""" + """Patch new OpenAI clients to track Chat Completions and Responses.""" global _openai_patched if _openai_patched or _openai is None: return @@ -312,6 +476,13 @@ def patched_sync_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-d ) except Exception as exc: debug_openai_failure("sync client wrap", exc) + if hasattr(self_inner, "responses"): + try: + wrap_sync_responses( + self_inner.responses, source, client_ref + ) + except Exception as exc: + debug_openai_failure("sync responses wrap", exc) def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] original_async_init(self_inner, *args, **kwargs) @@ -326,6 +497,13 @@ def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped- ) except Exception as exc: debug_openai_failure("async client wrap", exc) + if hasattr(self_inner, "responses"): + try: + wrap_async_responses( + self_inner.responses, source, client_ref + ) + except Exception as exc: + debug_openai_failure("async responses wrap", exc) patched_sync_init.__wildedge_patch_name__ = OPENAI_INIT_PATCH_NAME # type: ignore[attr-defined] patched_sync_init.__wildedge_original_call__ = original_sync_init # type: ignore[attr-defined] From 95770472ababdeb1709d6d2464badb394e30a665 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 17:57:51 +0200 Subject: [PATCH 31/81] Add OpenAI embeddings instrumentation --- CHANGELOG.md | 5 + README.md | 4 + docs/providers.md | 32 +++++ examples/openai_embeddings_example.py | 76 +++++++++++ tests/compat/test_openai_compat.py | 58 +++++++++ tests/test_integrations_openai_embeddings.py | 102 +++++++++++++++ wildedge/events/inference.py | 3 + wildedge/integrations/openai.py | 125 ++++++++++++++++++- 8 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 examples/openai_embeddings_example.py create mode 100644 tests/test_integrations_openai_embeddings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7294af1..b308d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ under Unreleased and move into a version section at release time. ### Added +- OpenAI sync and async `embeddings.create()` instrumentation records latency, + input token usage, batch size, vector dimensions, model metadata and errors. + Embedding vectors and raw inputs are never attached by this adapter. + `EmbeddingOutputMeta` now accepts optional `tokens_in` for analytics. + - The OpenAI integration now tracks `responses.create()` for synchronous and asynchronous clients, including streaming. Records input/output token usage, cached and reasoning tokens, response status or incomplete reason, resolved diff --git a/README.md b/README.md index 4352e0a..e2687a9 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ The `openai` integration also tracks `client.responses.create(...)`, including async clients and `stream=True`. See [Responses tracking](docs/providers.md#responses-api) for coverage and streaming behavior. +The `openai` integration also tracks sync and async `embeddings.create()` calls. +See [Embeddings](docs/providers.md#embeddings) and the +[example](examples/openai_embeddings_example.py). + **Hub tracking** Pass `hubs=` to track model download provenance. Hubs are framework-agnostic and can be combined with any integration. diff --git a/docs/providers.md b/docs/providers.md index 5335150..14e72e1 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -105,3 +105,35 @@ In WildEdge, locate that run and verify four inference events beneath the four named spans, with input/output token usage and first-output timing on the two streams. Provider checks alone do not prove telemetry delivery; confirm these events in the dashboard. + + +## Embeddings + +The `openai` integration also tracks sync and async `client.embeddings.create()` +calls. Enable instrumentation before constructing the client. OpenAI-compatible +providers work through the same integration if they implement the embeddings +endpoint. + +Records latency, resolved model, input token usage, batch size, and dimensions. +Text, batches of strings, token ID arrays, and batches of token ID arrays are +supported. Arbitrary iterables pass through without instrumentation consuming +them; their batch size is omitted. Dimensions come from the returned vector +length, or float32 byte length for explicit base64 output. Usage comes from the +provider, not a local tokenizer. Both float and base64 vectors remain in the +application response and are never included in telemetry. Raw inputs are also +not uploaded by this adapter. + +Input usage is recorded in `input_meta.token_count` and `output_meta.tokens_in` +so existing analytics can query it. Embeddings do not emit generated-token +counts, throughput, or TTFT. API exceptions use the existing error tracking. + +With `OPENAI_API_KEY` and `WILDEDGE_DSN` configured, run from the SDK root: + +```bash +uv run examples/openai_embeddings_example.py +``` + +This makes two small billable calls, one sync and one async. Find the printed +run ID in WildEdge and verify two inference events, each linked to its named +span, with batch size 2, dimensions, and input usage. Set +`OPENAI_EMBEDDING_MODEL` to override `text-embedding-3-small`. diff --git a/examples/openai_embeddings_example.py b/examples/openai_embeddings_example.py new file mode 100644 index 0000000..06a436c --- /dev/null +++ b/examples/openai_embeddings_example.py @@ -0,0 +1,76 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["wildedge-sdk", "openai>=1.30.0"] +# +# [tool.uv.sources] +# wildedge-sdk = { path = "..", editable = true } +# /// +"""Run two small billable embedding calls; requires OPENAI_API_KEY and WILDEDGE_DSN. + +From the SDK root: uv run examples/openai_embeddings_example.py +Optionally set OPENAI_EMBEDDING_MODEL. Find the printed run ID in WildEdge. +""" + +from __future__ import annotations + +import asyncio +import os +import uuid + +from openai import AsyncOpenAI, OpenAI + +import wildedge + +MODEL = os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") +INPUT = ["Hello from WildEdge", "Embedding instrumentation check"] + + +def check_result(result: object) -> None: + if len(result.data) != len(INPUT) or not result.data[0].embedding: + raise RuntimeError("Expected two nonempty embeddings") + if result.usage is None: + raise RuntimeError("Provider returned no usage") + print( + f"Completed: batch={len(result.data)}, dimensions={len(result.data[0].embedding)}, input_tokens={result.usage.prompt_tokens}" + ) + + +def run_sync() -> None: + with OpenAI() as client, wildedge.span(kind="agent_step", name="embeddings-sync"): + check_result( + client.embeddings.create(model=MODEL, input=INPUT, encoding_format="float") + ) + + +async def run_async() -> None: + async with AsyncOpenAI() as client: + with wildedge.span(kind="agent_step", name="embeddings-async"): + check_result( + await client.embeddings.create( + model=MODEL, input=INPUT, encoding_format="float" + ) + ) + + +def main() -> None: + missing = [ + key for key in ("OPENAI_API_KEY", "WILDEDGE_DSN") if not os.environ.get(key) + ] + if missing: + raise SystemExit("Set required environment variables: " + ", ".join(missing)) + run_id = "embeddings-e2e-" + uuid.uuid4().hex[:12] + print(f"Run ID: {run_id}") + sdk = wildedge.init(integrations=["openai"], app_version="embeddings-e2e") + try: + with wildedge.trace(run_id=run_id): + run_sync() + asyncio.run(run_async()) + finally: + sdk.close(timeout=15) + print( + "Provider checks passed. Verify two inference events and two spans in WildEdge." + ) + + +if __name__ == "__main__": + main() diff --git a/tests/compat/test_openai_compat.py b/tests/compat/test_openai_compat.py index a3c836e..587b482 100644 --- a/tests/compat/test_openai_compat.py +++ b/tests/compat/test_openai_compat.py @@ -262,3 +262,61 @@ def handler(request): assert len(events) == 4 assert all(e["run_id"] == "responses-example-test" for e in events) assert len({e["parent_span_id"] for e in events}) == 4 + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("encoding", ["float", "base64"]) +async def test_embeddings_is_tracked(compat_client, asynchronous, encoding): + compat_client.instrument("openai") + + def handler(request): + assert request.url.path == "/v1/embeddings" + return httpx.Response( + 200, + json={ + "object": "list", + "model": "text-embedding-3-small", + "data": [ + { + "object": "embedding", + "index": i, + "embedding": [0.25, 0.5, 0.75] + if encoding == "float" + else "AACAPgAAAD8AAEA/", + } + for i in range(2) + ], + "usage": {"prompt_tokens": 7, "total_tokens": 7}, + }, + ) + + if asynchronous: + client = openai.AsyncOpenAI( + api_key="sk-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + async with client: + result = await client.embeddings.create( + model="text-embedding-3-small", + input=["hello", "world"], + encoding_format=encoding, + ) + else: + with _client(handler) as client: + result = client.embeddings.create( + model="text-embedding-3-small", + input=["hello", "world"], + encoding_format=encoding, + ) + assert len(result.data) == 2 + event = compat_client.one("inference") + inference = event["inference"] + assert inference["batch_size"] == 2 + assert inference["input_meta"]["token_count"] == 7 + assert inference["output_meta"] == { + "task": "embedding", + "dimensions": 3, + "tokens_in": 7, + } + assert inference["api_meta"]["resolved_model_id"] == "text-embedding-3-small" + assert "embedding" not in inference["output_meta"] diff --git a/tests/test_integrations_openai_embeddings.py b/tests/test_integrations_openai_embeddings.py new file mode 100644 index 0000000..b64fc32 --- /dev/null +++ b/tests/test_integrations_openai_embeddings.py @@ -0,0 +1,102 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from wildedge.integrations.openai import wrap_async_embeddings, wrap_sync_embeddings + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + ("value", "batch_size", "chars"), + [ + ("hello", 1, 5), + (["hello", "world"], 2, 10), + ([1, 2, 3], 1, None), + ([[1, 2], [3]], 2, None), + ], +) +async def test_metadata_and_passthrough(asynchronous, value, batch_size, chars): + result = SimpleNamespace( + model="resolved-model", + usage=SimpleNamespace(prompt_tokens=7), + data=[SimpleNamespace(embedding=[0.1, 0.2, 0.3])], + ) + resource = SimpleNamespace( + create=(AsyncMock if asynchronous else MagicMock)(return_value=result) + ) + handle = MagicMock() + client = SimpleNamespace( + closed=False, register_model=MagicMock(return_value=handle) + ) + (wrap_async_embeddings if asynchronous else wrap_sync_embeddings)( + resource, "openai", lambda: client + ) + call = resource.create(model="model", input=value) + assert (await call if asynchronous else call) is result + handle.track_inference.assert_called_once() + event = handle.track_inference.call_args.kwargs + assert event["batch_size"] == batch_size + assert event["input_meta"].token_count == 7 + assert event["input_meta"].char_count == chars + assert event["output_meta"].tokens_in == 7 + assert event["output_meta"].dimensions == 3 + assert event["api_meta"].resolved_model_id == "resolved-model" + assert "hello" not in str(event) + client.closed = True + call = resource.create(model="model", input=value) + assert (await call if asynchronous else call) is result + assert handle.track_inference.call_count == 1 + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_error_and_telemetry_failure(asynchronous): + original = (AsyncMock if asynchronous else MagicMock)( + side_effect=TimeoutError("timeout") + ) + resource = SimpleNamespace(create=original) + handle = MagicMock() + client = SimpleNamespace( + closed=False, register_model=MagicMock(return_value=handle) + ) + (wrap_async_embeddings if asynchronous else wrap_sync_embeddings)( + resource, "openai", lambda: client + ) + + async def invoke(): + call = resource.create(model="model", input="hello") + return await call if asynchronous else call + + with pytest.raises(TimeoutError, match="timeout"): + await invoke() + handle.track_error.assert_called_once() + handle.track_inference.assert_not_called() + result = SimpleNamespace(model="model", usage=None, data=[]) + original.side_effect = None + original.return_value = result + handle.track_inference.side_effect = RuntimeError("telemetry broken") + assert await invoke() is result + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_input_iterator_is_not_consumed(asynchronous): + def values(): + raise AssertionError("instrumentation must not iterate inputs") + yield "unreachable" + + result = SimpleNamespace(model="model", usage=None, data=[]) + resource = SimpleNamespace( + create=(AsyncMock if asynchronous else MagicMock)(return_value=result) + ) + handle = MagicMock() + client = SimpleNamespace( + closed=False, register_model=MagicMock(return_value=handle) + ) + (wrap_async_embeddings if asynchronous else wrap_sync_embeddings)( + resource, "openai", lambda: client + ) + call = resource.create(model="model", input=values()) + assert (await call if asynchronous else call) is result + event = handle.track_inference.call_args.kwargs + assert event["batch_size"] is None + assert event["output_meta"].dimensions is None diff --git a/wildedge/events/inference.py b/wildedge/events/inference.py index d8acc40..2c64d19 100644 --- a/wildedge/events/inference.py +++ b/wildedge/events/inference.py @@ -275,11 +275,14 @@ def to_dict(self) -> dict: class EmbeddingOutputMeta: task: str = "embedding" dimensions: int | None = None + tokens_in: int | None = None def to_dict(self) -> dict: d: dict[str, Any] = {"task": self.task} if self.dimensions is not None: d["dimensions"] = self.dimensions + if self.tokens_in is not None: + d["tokens_in"] = self.tokens_in return d diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index a639951..f8b8f2e 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -2,12 +2,18 @@ from __future__ import annotations +import base64 import functools import threading import time from typing import TYPE_CHECKING -from wildedge.events.inference import ApiMeta, GenerationOutputMeta +from wildedge.events.inference import ( + ApiMeta, + EmbeddingOutputMeta, + GenerationOutputMeta, + TextInputMeta, +) from wildedge.integrations.base import BaseExtractor from wildedge.integrations.common import ( SOURCE_BY_HOSTNAME, # noqa: F401 (re-exported; moved to common) @@ -411,6 +417,105 @@ async def patched_create(*args, **kwargs): responses.create = patched_create +def record_embedding( + handle: ModelHandle, result: object, value: object, duration: int +) -> None: + """Record metadata only; never retain input text or embedding vectors.""" + usage = getattr(result, "usage", None) + tokens = getattr(usage, "prompt_tokens", None) + batch_size = None + chars = words = None + if isinstance(value, str): + batch_size = 1 + chars, words = len(value), len(value.split()) + elif isinstance(value, (list, tuple)): + if value and isinstance(value[0], int): + batch_size = 1 + else: + batch_size = len(value) + if value and all(isinstance(item, str) for item in value): + chars = sum(len(item) for item in value) + words = sum(len(item.split()) for item in value) + data = getattr(result, "data", None) + dimensions = None + if data: + vector = getattr(data[0], "embedding", None) + if isinstance(vector, (list, tuple)): + dimensions = len(vector) + elif isinstance(vector, str): + byte_count = len(base64.b64decode(vector, validate=True)) + if byte_count % 4 == 0: + dimensions = byte_count // 4 + handle.track_inference( + duration_ms=duration, + input_modality="text", + output_modality="embedding", + success=True, + batch_size=batch_size, + input_meta=TextInputMeta( + char_count=chars, word_count=words, token_count=tokens + ), + output_meta=EmbeddingOutputMeta(dimensions=dimensions, tokens_in=tokens), + api_meta=build_api_meta(result), + ) + + +def wrap_sync_embeddings(embeddings: object, source: str, client_ref: object) -> None: + original_create = embeddings.create + model_handles: dict[str, ModelHandle] = {} + + @functools.wraps(original_create) + def patched_create(*args, **kwargs): + client = client_ref() + model_id = kwargs.get("model") + if client is None or client.closed or not model_id: + return original_create(*args, **kwargs) + handle = resolve_handle(model_id, embeddings, model_handles, client, source) + t0 = time.perf_counter() + try: + result = original_create(*args, **kwargs) + except Exception as exc: + if handle is not None: + track_api_error(handle, exc, elapsed_ms(t0)) + raise + if handle is not None: + try: + record_embedding(handle, result, kwargs.get("input"), elapsed_ms(t0)) + except Exception as exc: + debug_openai_failure("embedding tracking", exc) + return result + + embeddings.create = patched_create + + +def wrap_async_embeddings(embeddings: object, source: str, client_ref: object) -> None: + original_create = embeddings.create + model_handles: dict[str, ModelHandle] = {} + + @functools.wraps(original_create) + async def patched_create(*args, **kwargs): + client = client_ref() + model_id = kwargs.get("model") + if client is None or client.closed or not model_id: + return await original_create(*args, **kwargs) + handle = resolve_handle(model_id, embeddings, model_handles, client, source) + t0 = time.perf_counter() + try: + result = await original_create(*args, **kwargs) + except Exception as exc: + if handle is not None: + track_api_error(handle, exc, elapsed_ms(t0)) + raise + if handle is not None: + try: + record_embedding(handle, result, kwargs.get("input"), elapsed_ms(t0)) + except Exception as exc: + debug_openai_failure("embedding tracking", exc) + return result + + embeddings.create = patched_create + + class OpenAIExtractor(BaseExtractor): def can_handle(self, obj: object) -> bool: return type(obj).__name__ in ( @@ -420,6 +525,8 @@ def can_handle(self, obj: object) -> bool: "AsyncCompletions", "Responses", "AsyncResponses", + "Embeddings", + "AsyncEmbeddings", ) def extract_info( @@ -444,7 +551,7 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: @classmethod def install_auto_load_patch(cls, client_ref: object) -> None: - """Patch new OpenAI clients to track Chat Completions and Responses.""" + """Patch new OpenAI clients to track Chat Completions, Responses, and embeddings.""" global _openai_patched if _openai_patched or _openai is None: return @@ -483,6 +590,13 @@ def patched_sync_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-d ) except Exception as exc: debug_openai_failure("sync responses wrap", exc) + if hasattr(self_inner, "embeddings"): + try: + wrap_sync_embeddings( + self_inner.embeddings, source, client_ref + ) + except Exception as exc: + debug_openai_failure("sync embeddings wrap", exc) def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] original_async_init(self_inner, *args, **kwargs) @@ -504,6 +618,13 @@ def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped- ) except Exception as exc: debug_openai_failure("async responses wrap", exc) + if hasattr(self_inner, "embeddings"): + try: + wrap_async_embeddings( + self_inner.embeddings, source, client_ref + ) + except Exception as exc: + debug_openai_failure("async embeddings wrap", exc) patched_sync_init.__wildedge_patch_name__ = OPENAI_INIT_PATCH_NAME # type: ignore[attr-defined] patched_sync_init.__wildedge_original_call__ = original_sync_init # type: ignore[attr-defined] From b0982aa3d5d745e1c8b3d7224c7085c50f26f906 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 18:08:04 +0200 Subject: [PATCH 32/81] Track streaming lifecycle outcomes exactly once --- CHANGELOG.md | 9 + README.md | 1 + docs/providers.md | 9 +- docs/streaming.md | 60 +++++ tests/compat/test_anthropic_compat.py | 70 ++++++ tests/compat/test_openai_compat.py | 40 ++++ tests/test_integrations_openai_responses.py | 5 +- tests/test_streaming_lifecycle.py | 253 ++++++++++++++++++++ wildedge/integrations/anthropic.py | 7 +- wildedge/integrations/common.py | 237 +++++++++++++----- wildedge/integrations/gguf.py | 7 +- wildedge/integrations/openai.py | 27 ++- 12 files changed, 660 insertions(+), 65 deletions(-) create mode 100644 docs/streaming.md create mode 100644 tests/test_streaming_lifecycle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b308d81..f57a0d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Fixed + +- Streaming wrappers now finalize once across exhaustion, explicit closure, + cancellation, and iterator errors. Early closure/cancellation records an + unsuccessful inference with partial metadata when available. TTFT ignores + lifecycle/role-only chunks; telemetry callbacks cannot break iteration and + retain the trace context captured at stream creation. Responses streams + ending without a terminal response record `STREAM_INCOMPLETE`. + ### Added - OpenAI sync and async `embeddings.create()` instrumentation records latency, diff --git a/README.md b/README.md index e2687a9..81ac433 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ Report security and privacy issues to: support@wildedge.dev - [Manual tracking](https://github.com/wild-edge/wildedge-python/blob/main/docs/manual-tracking.md) - [LLM API tracking](https://github.com/wild-edge/wildedge-python/blob/main/docs/llm_api.md) - [Compatibility Matrix](https://github.com/wild-edge/wildedge-python/blob/main/docs/compatibility.md) +- [Streaming lifecycle](docs/streaming.md) - [Changelog](https://github.com/wild-edge/wildedge-python/blob/main/CHANGELOG.md) - [License](https://github.com/wild-edge/wildedge-python/blob/main/LICENSE) diff --git a/docs/providers.md b/docs/providers.md index 14e72e1..2adb279 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -76,12 +76,15 @@ are recorded as unsuccessful. No prompt or response text is uploaded by this adapter. String input and simple user messages provide text input metadata; input iterators are passed through without being consumed by instrumentation. +See [Streaming lifecycle](streaming.md) for completion, closure, cancellation, +and coverage limits. + For streams, consume the iterator to exhaustion to record the terminal response and its usage. Time to first output ignores lifecycle events and measures the first nonempty text, refusal, function argument, or audio delta. Streams without -a terminal response do not produce a successful inference event. Early closure, -cancellation, background response polling, and individual tool spans are outside -this initial coverage. API exceptions retain their original behavior and use +a terminal response record an unsuccessful inference. Explicit early closure and +async cancellation are tracked; background response polling and individual tool +spans remain outside coverage. API exceptions retain their original behavior and use the same error tracking as Chat Completions. OpenAI-compatible hosts must implement the Responses endpoint to use this path. diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..89df744 --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,60 @@ +# Streaming lifecycle and coverage + +The shared wrappers track OpenAI Chat Completions and Responses +`create(stream=True)`, Anthropic `messages.create(stream=True)` (sync and async), +and llama-cpp-python/GGUF completion iterators (sync). + +| Outcome | Telemetry | +|---|---| +| Iterator exhausted | One inference event; Responses requires a terminal response | +| Explicit `close()` / `aclose()` or early context exit | One unsuccessful inference with `STREAM_CLOSED`, unless already finalized | +| Async cancellation while awaiting a chunk or exiting its context | One unsuccessful inference with `STREAM_CANCELLED`; cancellation is re-raised | +| Provider/iterator exception | One error event using existing error classification; original exception is re-raised | +| Responses EOF without terminal response | One unsuccessful inference with `STREAM_INCOMPLETE` | + +Repeated exhaustion and closing after finalization do not emit another outcome. +Partial usage is retained when supplied by the provider, but missing totals are +not estimated. Responses interrupted before a terminal response has no final +usage; first-output timing is retained when available. Telemetry callback failures +are logged at debug level and do not break application iteration. + +TTFT measures from the original call until the first nonempty output delta, +ignoring role-only chunks, usage chunks, and message/response lifecycle events. +Output can be text, refusal, tool arguments, or supported audio/reasoning deltas; +it need not be user-visible text. No output means TTFT is omitted. Callbacks use +the trace context captured when the stream wrapper was created, even when the +application consumes it later under another context. + +Use a context manager or explicitly close the stream when stopping early: + +```python +with client.chat.completions.create(model=model, messages=messages, stream=True) as stream: + for chunk in stream: + if enough_output(chunk): + break +``` + +Async streams support `async with`, `await stream.close()`, and +`await stream.aclose()`. A bare `break` without closing, or simply abandoning an +unconsumed stream, does not provide an observable end of the operation. There is +no garbage-collection finalizer. Keep stream consumption within the lifetime of +the SDK client and do not consume one iterator concurrently from multiple tasks. + +## Audit findings outside these wrappers + +- Anthropic's `messages.stream()` helper calls its transport directly and bypasses + the currently instrumented `messages.create()`. Use `create(stream=True)` for + the coverage above. Adding native helper support needs its own instrumentation + for `text_stream` and `get_final_message()`. +- Provider convenience properties/methods that consume an underlying stream + directly can bypass iteration on the wrapper. They are not covered by the + lifecycle contract above; use the wrapped event iterator. +- MLX instruments `mlx_lm.generate()` and model calls, not + `mlx_lm.stream_generate()`. Streaming generation requires a dedicated adapter + and suppression of nested token-loop model calls. +- Transformers instruments pipeline/model calls; user-supplied streamers and + background generation threads are not wrapped stream iterators. +- `wildedge.llm_api()` is a manual context manager: its lifetime is controlled by + the caller, and the caller must supply usage and first-output marks. + +The audit does not claim coverage for streaming APIs that are not instrumented. diff --git a/tests/compat/test_anthropic_compat.py b/tests/compat/test_anthropic_compat.py index 93a7566..db25941 100644 --- a/tests/compat/test_anthropic_compat.py +++ b/tests/compat/test_anthropic_compat.py @@ -1,6 +1,9 @@ from __future__ import annotations +import json + import anthropic +import pytest from conftest import sdk_http_module httpx = sdk_http_module("anthropic") @@ -70,3 +73,70 @@ def test_api_error_is_tracked(compat_client): error = compat_client.one("error") assert error["error"]["http_status"] == 529 assert error["error"]["provider_error_code"] == "overloaded_error" + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("early_close", [False, True]) +async def test_stream_lifecycle(compat_client, asynchronous, early_close): + compat_client.instrument("anthropic") + events = [ + { + "type": "message_start", + "message": { + **MESSAGE, + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 12, "output_tokens": 0}, + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hello"}, + }, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 3}, + }, + {"type": "message_stop"}, + ] + body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events) + + def handler(request): + return httpx.Response( + 200, text=body, headers={"content-type": "text/event-stream"} + ) + + if asynchronous: + async with anthropic.AsyncAnthropic( + api_key="sk-ant-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as client: + async with await client.messages.create( + model="claude-haiku-4-5", max_tokens=32, messages=[], stream=True + ) as stream: + if early_close: + await anext(stream) + else: + assert len([event async for event in stream]) == 4 + await stream.close() + else: + with _client(handler) as client: + with client.messages.create( + model="claude-haiku-4-5", max_tokens=32, messages=[], stream=True + ) as stream: + if early_close: + next(stream) + else: + assert len(list(stream)) == 4 + stream.close() + inference = compat_client.one("inference")["inference"] + assert inference["success"] is not early_close + assert inference["output_meta"]["tokens_in"] == 12 + if early_close: + assert inference["error_code"] == "STREAM_CLOSED" + assert inference["output_meta"].get("time_to_first_token_ms") is None + else: + assert inference["output_meta"]["tokens_out"] == 3 + assert inference["output_meta"]["time_to_first_token_ms"] is not None diff --git a/tests/compat/test_openai_compat.py b/tests/compat/test_openai_compat.py index 587b482..6e26539 100644 --- a/tests/compat/test_openai_compat.py +++ b/tests/compat/test_openai_compat.py @@ -320,3 +320,43 @@ def handler(request): } assert inference["api_meta"]["resolved_model_id"] == "text-embedding-3-small" assert "embedding" not in inference["output_meta"] + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_early_stream_close_is_tracked_once(compat_client, asynchronous): + compat_client.instrument("openai") + chunk = { + "id": "c", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {"content": "Hi"}}], + } + body = f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n" + + def handler(request): + return httpx.Response( + 200, text=body, headers={"content-type": "text/event-stream"} + ) + + if asynchronous: + async with openai.AsyncOpenAI( + api_key="sk-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as client: + async with await client.chat.completions.create( + model="gpt-4o-mini", messages=[], stream=True + ) as stream: + assert (await anext(stream)).choices[0].delta.content == "Hi" + await stream.close() + else: + with _client(handler) as client: + with client.chat.completions.create( + model="gpt-4o-mini", messages=[], stream=True + ) as stream: + assert next(stream).choices[0].delta.content == "Hi" + stream.close() + inference = compat_client.one("inference")["inference"] + assert inference["success"] is False + assert inference["error_code"] == "STREAM_CLOSED" + assert inference["output_meta"]["time_to_first_token_ms"] is not None diff --git a/tests/test_integrations_openai_responses.py b/tests/test_integrations_openai_responses.py index 3d43655..19c48d8 100644 --- a/tests/test_integrations_openai_responses.py +++ b/tests/test_integrations_openai_responses.py @@ -134,7 +134,10 @@ def test_unfinished_and_error_streams(): on_chunk, on_done = make_responses_stream_callbacks(handle, [], 0) on_chunk(SimpleNamespace(type="response.created")) on_done(100, 1) - handle.track_inference.assert_not_called() + handle.track_inference.assert_called_once() + assert handle.track_inference.call_args.kwargs["error_code"] == "STREAM_INCOMPLETE" + handle = MagicMock() + on_chunk, on_done = make_responses_stream_callbacks(handle, [], 0) on_chunk(SimpleNamespace(type="error", code="server_error")) on_done(100, 1) handle.track_error.assert_called_once() diff --git a/tests/test_streaming_lifecycle.py b/tests/test_streaming_lifecycle.py new file mode 100644 index 0000000..f93c1fe --- /dev/null +++ b/tests/test_streaming_lifecycle.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import asyncio +import time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from wildedge.integrations.anthropic import make_anthropic_stream_callbacks +from wildedge.integrations.common import ( + AsyncStreamWrapper, + SyncStreamWrapper, + stream_chunk_has_output, +) +from wildedge.integrations.gguf import make_gguf_stream_callbacks +from wildedge.integrations.openai import ( + make_openai_stream_callbacks, + make_responses_stream_callbacks, +) +from wildedge.tracing import get_trace_context, trace_context + + +def wrapper(original, on_chunk=None, on_done=None): + handle = MagicMock() + done = on_done or MagicMock() + return ( + SyncStreamWrapper(original, handle, time.perf_counter(), on_chunk, done), + handle, + done, + ) + + +def test_single_pass_completion_and_close(): + stream, handle, done = wrapper(iter([1, 2])) + assert next(stream) == 1 + assert list(stream) == [2] + assert list(stream) == [] + stream.close() + done.assert_called_once() + handle.track_error.assert_not_called() + + +def test_explicit_close_and_context_exit(): + original = MagicMock() + original.__iter__.return_value = iter([1, 2]) + stream, _, done = wrapper(original) + with stream: + assert next(stream) == 1 + original.__exit__.assert_called_once() + stream.close() + done.assert_called_once() + assert done.call_args.kwargs == {"outcome": "closed"} + + +def test_midstream_error_preserved_once(): + error = TimeoutError("broken stream") + + def chunks(): + yield 1 + raise error + + stream, handle, done = wrapper(chunks()) + assert next(stream) == 1 + with pytest.raises(TimeoutError) as caught: + next(stream) + assert caught.value is error + stream.close() + handle.track_error.assert_called_once() + done.assert_not_called() + + +def test_callback_failures_do_not_break_iteration(): + stream, handle, _ = wrapper( + iter([1, 2]), + MagicMock(side_effect=ValueError()), + MagicMock(side_effect=ValueError()), + ) + assert list(stream) == [1, 2] + handle.track_error.assert_not_called() + + +def test_correlation_uses_creation_context(): + seen = [] + with trace_context(trace_id="origin"): + stream, _, _ = wrapper( + iter([1]), on_done=lambda *_: seen.append(get_trace_context().trace_id) + ) + with trace_context(trace_id="consumer"): + list(stream) + assert seen == ["origin"] + + +class AsyncChunks: + def __init__(self, items, error=None): + self.items = iter(items) + self.error = error + self.closed = False + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.items) + except StopIteration: + if self.error is not None: + raise self.error from None + raise StopAsyncIteration from None + + async def aclose(self): + self.closed = True + + +@pytest.mark.parametrize( + "error", [None, TimeoutError("timeout"), asyncio.CancelledError()] +) +async def test_async_lifecycle(error): + original = AsyncChunks([1], error) + handle, done = MagicMock(), MagicMock() + stream = AsyncStreamWrapper(original, handle, time.perf_counter(), None, done) + assert await anext(stream) == 1 + if error is None: + assert [item async for item in stream] == [] + done.assert_called_once() + else: + with pytest.raises(type(error)) as caught: + await anext(stream) + assert caught.value is error + if isinstance(error, asyncio.CancelledError): + assert done.call_args.kwargs == {"outcome": "cancelled"} + else: + handle.track_error.assert_called_once() + done.assert_not_called() + await stream.aclose() + assert original.closed + assert [item async for item in stream] == [] + assert done.call_count + handle.track_error.call_count == 1 + + +async def test_async_context_early_exit(): + original = AsyncChunks([1, 2]) + done = MagicMock() + stream = AsyncStreamWrapper(original, MagicMock(), time.perf_counter(), None, done) + async with stream: + assert await anext(stream) == 1 + assert original.closed + assert done.call_args.kwargs == {"outcome": "closed"} + + +@pytest.mark.parametrize( + "factory", + [ + lambda h: make_openai_stream_callbacks(h, []), + lambda h: make_anthropic_stream_callbacks(h, []), + lambda h: make_gguf_stream_callbacks(h, "hello"), + lambda h: make_responses_stream_callbacks(h, [], time.perf_counter()), + ], +) +@pytest.mark.parametrize("outcome", ["closed", "cancelled"]) +def test_all_integrations_record_interruption(factory, outcome): + handle = MagicMock() + _, on_done = factory(handle) + on_done(100, None, outcome=outcome) + handle.track_inference.assert_called_once() + event = handle.track_inference.call_args.kwargs + assert event["success"] is False + assert event["error_code"] == "STREAM_" + outcome.upper() + + +@pytest.mark.parametrize( + ("chunk", "expected"), + [ + ({"choices": [{"delta": {"role": "assistant"}}]}, False), + ({"choices": [], "usage": {}}, False), + ({"choices": [{"delta": {"content": "hello"}}]}, True), + ({"choices": [{"text": "hello"}]}, True), + (SimpleNamespace(type="message_start"), False), + ( + SimpleNamespace( + type="content_block_delta", delta=SimpleNamespace(text="hello") + ), + True, + ), + (SimpleNamespace(type="response.created"), False), + ], +) +def test_first_output_detection(chunk, expected): + assert stream_chunk_has_output(chunk) is expected + + +async def test_task_cancellation_while_waiting(): + entered = asyncio.Event() + + async def chunks(): + entered.set() + await asyncio.Event().wait() + yield 1 + + handle, done = MagicMock(), MagicMock() + stream = AsyncStreamWrapper(chunks(), handle, time.perf_counter(), None, done) + task = asyncio.create_task(anext(stream)) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await stream.aclose() + done.assert_called_once() + assert done.call_args.kwargs == {"outcome": "cancelled"} + handle.track_error.assert_not_called() + + +def test_ttft_waits_for_output(monkeypatch): + clock = [1] + monkeypatch.setattr("wildedge.integrations.common.elapsed_ms", lambda _: clock[0]) + chunks = iter( + [ + {"choices": [{"delta": {"role": "assistant"}}]}, + {"choices": [{"delta": {"content": "hello"}}]}, + ] + ) + stream, _, done = wrapper(chunks) + next(stream) + clock[0] = 42 + next(stream) + clock[0] = 100 + list(stream) + assert done.call_args.args == (100, 42) + + +def test_underlying_close_error_is_preserved(): + original = MagicMock() + original.close.side_effect = ValueError("close failed") + stream, _, done = wrapper(original) + with pytest.raises(ValueError, match="close failed"): + stream.close() + done.assert_called_once() + assert done.call_args.kwargs == {"outcome": "closed"} + + +async def test_async_telemetry_and_context(): + seen = [] + with trace_context(trace_id="origin"): + stream = AsyncStreamWrapper( + AsyncChunks([1]), + MagicMock(), + time.perf_counter(), + MagicMock(side_effect=ValueError()), + lambda *_: seen.append(get_trace_context().trace_id), + ) + with trace_context(trace_id="consumer"): + assert [item async for item in stream] == [1] + assert seen == ["origin"] diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index 4544802..c4edfde 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -173,7 +173,9 @@ def on_chunk(event: object) -> None: if reason: stop_reason[0] = reason - def on_done(duration_ms: int, ttft_ms: int | None) -> None: + def on_done( + duration_ms: int, ttft_ms: int | None, *, outcome: str = "completed" + ) -> None: ti, to, cr, sr = tokens_in[0], tokens_out[0], cached_tokens[0], stop_reason[0] tps = round(to / duration_ms * 1000, 1) if duration_ms > 0 and to else None api_meta = ( @@ -183,7 +185,8 @@ def on_done(duration_ms: int, ttft_ms: int | None) -> None: duration_ms=duration_ms, input_modality="text", output_modality="generation", - success=True, + success=outcome == "completed", + error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), input_meta=build_input_meta(messages, ti), output_meta=GenerationOutputMeta( task="generation", diff --git a/wildedge/integrations/common.py b/wildedge/integrations/common.py index bd8f523..685bce8 100644 --- a/wildedge/integrations/common.py +++ b/wildedge/integrations/common.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import contextvars import itertools from collections.abc import Callable from typing import TYPE_CHECKING, Any @@ -268,105 +270,232 @@ def num_classes_from_output_shape(shape: tuple) -> int: # --------------------------------------------------------------------------- # Each integration provides: # on_chunk(chunk) -> None : update mutable state from a single chunk -# on_done(duration_ms, ttft_ms) : record inference once the stream is exhausted +# on_done(duration_ms, ttft_ms, *, outcome="completed") : record one outcome # # The wrappers handle TTFT capture, error tracking, context-manager delegation, # and attribute proxying so callers get a drop-in replacement for the raw stream. -class SyncStreamWrapper: - """Wraps a sync iterable stream to capture TTFT and record inference on exhaustion.""" +def stream_chunk_has_output(chunk: object) -> bool: + """Ignore role, usage, and lifecycle chunks when measuring first output.""" + + def field(value: object, name: str) -> Any: + return ( + value.get(name) if isinstance(value, dict) else getattr(value, name, None) + ) + + choices = field(chunk, "choices") + if choices is not None: + for choice in choices: + delta = field(choice, "delta") + if field(choice, "text") or any( + field(delta, key) + for key in ("content", "refusal", "tool_calls", "function_call") + ): + return True + return False + event_type = field(chunk, "type") + if event_type is not None: + if event_type == "content_block_delta": + delta = field(chunk, "delta") + return any( + field(delta, key) + for key in ("text", "thinking", "partial_json", "data") + ) + return event_type in ( + "response.output_text.delta", + "response.refusal.delta", + "response.function_call_arguments.delta", + "response.output_audio.delta", + ) and bool(field(chunk, "delta")) + return True + + +class StreamState: + """One lifecycle per stream, with telemetry isolated from application code.""" def __init__( self, - original: object, handle: ModelHandle, t0: float, on_chunk: Callable[[object], None] | None, - on_done: Callable[[int, int | None], None], + on_done: Callable[..., None], ) -> None: - self._original = original self._handle = handle self._t0 = t0 self._on_chunk = on_chunk self._on_done = on_done + self._finished = False + self._ttft_ms = None + self._context = contextvars.copy_context() + + def _safe(self, callback: Callable, *args: Any, **kwargs: Any) -> Any: + try: + return self._context.run(callback, *args, **kwargs) + except Exception as exc: + debug_failure("stream", "telemetry callback", exc) + return None + + def _chunk(self, chunk: object) -> None: + if self._ttft_ms is None and self._safe(stream_chunk_has_output, chunk): + self._ttft_ms = elapsed_ms(self._t0) + if self._on_chunk is not None: + self._safe(self._on_chunk, chunk) + + def _finish(self, outcome: str = "completed") -> None: + if self._finished: + return + self._finished = True + if outcome == "completed": + self._safe(self._on_done, elapsed_ms(self._t0), self._ttft_ms) + else: + self._safe( + self._on_done, elapsed_ms(self._t0), self._ttft_ms, outcome=outcome + ) - def __iter__(self): - return self._track() + def _error(self, exc: Exception) -> None: + if not self._finished: + self._finished = True + self._safe(track_api_error, self._handle, exc, elapsed_ms(self._t0)) - def _track(self): - ttft_ms: int | None = None + +class SyncStreamWrapper(StreamState): + """A single-pass iterator tracking exhaustion, failure, and explicit closure.""" + + def __init__( + self, + original: Any, + handle: ModelHandle, + t0: float, + on_chunk: Callable[[object], None] | None, + on_done: Callable[..., None], + ) -> None: + super().__init__(handle, t0, on_chunk, on_done) + self._original = original + self._iterator = None + + def __iter__(self) -> SyncStreamWrapper: + return self + + def __next__(self) -> Any: + if self._finished: + raise StopIteration try: - for chunk in self._original: # type: ignore[union-attr] - if ttft_ms is None: - ttft_ms = elapsed_ms(self._t0) - if self._on_chunk is not None: - self._on_chunk(chunk) - yield chunk + if self._iterator is None: + self._iterator = iter(self._original) + chunk = next(self._iterator) + except StopIteration: + self._finish() + raise except Exception as exc: - track_api_error(self._handle, exc, elapsed_ms(self._t0)) + self._error(exc) raise - else: - self._on_done(elapsed_ms(self._t0), ttft_ms) + self._chunk(chunk) + return chunk + + def close(self) -> Any: + try: + close = getattr(self._original, "close", None) + if close is not None: + return close() + return None + finally: + self._finish("closed") def __enter__(self) -> SyncStreamWrapper: if hasattr(self._original, "__enter__"): - self._original.__enter__() # type: ignore[union-attr] + self._original.__enter__() return self - def __exit__(self, *args: object) -> object: - if hasattr(self._original, "__exit__"): - return self._original.__exit__(*args) # type: ignore[union-attr] - return None - - def __getattr__(self, name: str) -> object: + def __exit__(self, *args: Any) -> Any: + try: + if hasattr(self._original, "__exit__"): + return self._original.__exit__(*args) + close = getattr(self._original, "close", None) + if close is not None: + close() + return None + finally: + self._finish("closed") + + def __getattr__(self, name: str) -> Any: return getattr(self._original, name) -class AsyncStreamWrapper: - """Wraps an async iterable stream to capture TTFT and record inference on exhaustion.""" +class AsyncStreamWrapper(StreamState): + """Async counterpart, including cancellation while waiting for a chunk.""" def __init__( self, - original: object, + original: Any, handle: ModelHandle, t0: float, on_chunk: Callable[[object], None] | None, - on_done: Callable[[int, int | None], None], + on_done: Callable[..., None], ) -> None: + super().__init__(handle, t0, on_chunk, on_done) self._original = original - self._handle = handle - self._t0 = t0 - self._on_chunk = on_chunk - self._on_done = on_done + self._iterator = None - def __aiter__(self): - return self._track() + def __aiter__(self) -> AsyncStreamWrapper: + return self - async def _track(self): - ttft_ms: int | None = None + async def __anext__(self) -> Any: + if self._finished: + raise StopAsyncIteration try: - async for chunk in self._original: # type: ignore[union-attr] - if ttft_ms is None: - ttft_ms = elapsed_ms(self._t0) - if self._on_chunk is not None: - self._on_chunk(chunk) - yield chunk + if self._iterator is None: + self._iterator = self._original.__aiter__() + chunk = await self._iterator.__anext__() + except StopAsyncIteration: + self._finish() + raise + except asyncio.CancelledError: + self._finish("cancelled") + raise except Exception as exc: - track_api_error(self._handle, exc, elapsed_ms(self._t0)) + self._error(exc) raise - else: - self._on_done(elapsed_ms(self._t0), ttft_ms) + self._chunk(chunk) + return chunk + + async def close(self) -> Any: + try: + close = getattr(self._original, "aclose", None) or getattr( + self._original, "close", None + ) + if close is not None: + return await close() + return None + finally: + self._finish("closed") + + async def aclose(self) -> Any: + return await self.close() async def __aenter__(self) -> AsyncStreamWrapper: if hasattr(self._original, "__aenter__"): - await self._original.__aenter__() # type: ignore[union-attr] + await self._original.__aenter__() return self - async def __aexit__(self, *args: object) -> object: - if hasattr(self._original, "__aexit__"): - return await self._original.__aexit__(*args) # type: ignore[union-attr] - return None - - def __getattr__(self, name: str) -> object: + async def __aexit__(self, *args: Any) -> Any: + try: + if hasattr(self._original, "__aexit__"): + return await self._original.__aexit__(*args) + close = getattr(self._original, "aclose", None) or getattr( + self._original, "close", None + ) + if close is not None: + await close() + return None + finally: + self._finish( + "cancelled" + if args + and args[0] is not None + and issubclass(args[0], asyncio.CancelledError) + else "closed" + ) + + def __getattr__(self, name: str) -> Any: return getattr(self._original, name) diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index f024766..df9c9db 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -237,14 +237,17 @@ def on_chunk(chunk: object) -> None: if text: reply_parts.append(text) - def on_done(duration_ms: int, ttft_ms: int | None) -> None: + def on_done( + duration_ms: int, ttft_ms: int | None, *, outcome: str = "completed" + ) -> None: ti, to, sr = tokens_in[0], tokens_out[0], stop_reason[0] handle.track_inference( # type: ignore[union-attr] duration_ms=duration_ms, input_modality="text", output_modality="generation", input_meta=make_gguf_input_meta(prompt, ti, messages), - success=True, + success=outcome == "completed", + error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), output_meta=make_gguf_output_meta(ti, to, sr, ttft_ms, duration_ms), generation_config=generation_config, attachments=make_gguf_attachments( diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index f8b8f2e..1e2e3f9 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -177,14 +177,17 @@ def on_chunk(chunk: object) -> None: if reason: stop_reason[0] = reason - def on_done(duration_ms: int, ttft_ms: int | None) -> None: + def on_done( + duration_ms: int, ttft_ms: int | None, *, outcome: str = "completed" + ) -> None: ti, to, sr = tokens_in[0], tokens_out[0], stop_reason[0] output_meta = build_streaming_output_meta(ttft_ms, ti, to, sr, duration_ms) handle.track_inference( duration_ms=duration_ms, input_modality="text", output_modality="generation", - success=True, + success=outcome == "completed", + error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), input_meta=build_input_meta(messages, ti), output_meta=output_meta, api_meta=build_api_meta(last_chunk[0]) @@ -344,8 +347,26 @@ def on_chunk(chunk: object) -> None: ) recorded = True - def on_done(duration_ms: int, _first_event_ms: int | None) -> None: + def on_done( + duration_ms: int, _first_event_ms: int | None, *, outcome: str = "completed" + ) -> None: nonlocal recorded + if response is None and not recorded: + recorded = True + handle.track_inference( + duration_ms=duration_ms, + input_modality="text", + output_modality="generation", + input_meta=build_input_meta(messages, None), + output_meta=GenerationOutputMeta( + time_to_first_token_ms=ttft, + stop_reason=outcome if outcome != "completed" else "incomplete", + ), + success=False, + error_code="STREAM_INCOMPLETE" + if outcome == "completed" + else "STREAM_" + outcome.upper(), + ) if response is not None and not recorded: recorded = True record_response(handle, response, messages, duration_ms, ttft) From 1b0192f35a346dda5eeee82f859f7a9a99cedce2 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 18:13:18 +0200 Subject: [PATCH 33/81] Classify truncated remote streams as incomplete --- CHANGELOG.md | 4 ++-- docs/streaming.md | 4 ++-- tests/compat/test_anthropic_compat.py | 13 +++++++++---- tests/test_integrations_anthropic.py | 3 +++ tests/test_streaming_lifecycle.py | 15 +++++++++++++++ wildedge/integrations/anthropic.py | 9 +++++++++ wildedge/integrations/openai.py | 2 ++ 7 files changed, 42 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f57a0d6..101074d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ under Unreleased and move into a version section at release time. cancellation, and iterator errors. Early closure/cancellation records an unsuccessful inference with partial metadata when available. TTFT ignores lifecycle/role-only chunks; telemetry callbacks cannot break iteration and - retain the trace context captured at stream creation. Responses streams - ending without a terminal response record `STREAM_INCOMPLETE`. + retain the trace context captured at stream creation. Remote API streams + ending without their completion marker record `STREAM_INCOMPLETE`. ### Added diff --git a/docs/streaming.md b/docs/streaming.md index 89df744..0891535 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -6,11 +6,11 @@ and llama-cpp-python/GGUF completion iterators (sync). | Outcome | Telemetry | |---|---| -| Iterator exhausted | One inference event; Responses requires a terminal response | +| Iterator exhausted | One inference event; remote APIs require their completion marker | | Explicit `close()` / `aclose()` or early context exit | One unsuccessful inference with `STREAM_CLOSED`, unless already finalized | | Async cancellation while awaiting a chunk or exiting its context | One unsuccessful inference with `STREAM_CANCELLED`; cancellation is re-raised | | Provider/iterator exception | One error event using existing error classification; original exception is re-raised | -| Responses EOF without terminal response | One unsuccessful inference with `STREAM_INCOMPLETE` | +| Remote API EOF without completion marker | One unsuccessful inference with `STREAM_INCOMPLETE` | Repeated exhaustion and closing after finalization do not emit another outcome. Partial usage is retained when supplied by the provider, but missing totals are diff --git a/tests/compat/test_anthropic_compat.py b/tests/compat/test_anthropic_compat.py index db25941..3e1b39c 100644 --- a/tests/compat/test_anthropic_compat.py +++ b/tests/compat/test_anthropic_compat.py @@ -77,7 +77,8 @@ def test_api_error_is_tracked(compat_client): @pytest.mark.parametrize("asynchronous", [False, True]) @pytest.mark.parametrize("early_close", [False, True]) -async def test_stream_lifecycle(compat_client, asynchronous, early_close): +@pytest.mark.parametrize("truncated", [False, True]) +async def test_stream_lifecycle(compat_client, asynchronous, early_close, truncated): compat_client.instrument("anthropic") events = [ { @@ -101,6 +102,8 @@ async def test_stream_lifecycle(compat_client, asynchronous, early_close): }, {"type": "message_stop"}, ] + if truncated: + events = events[:-1] body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events) def handler(request): @@ -119,7 +122,7 @@ def handler(request): if early_close: await anext(stream) else: - assert len([event async for event in stream]) == 4 + assert len([event async for event in stream]) == len(events) await stream.close() else: with _client(handler) as client: @@ -129,14 +132,16 @@ def handler(request): if early_close: next(stream) else: - assert len(list(stream)) == 4 + assert len(list(stream)) == len(events) stream.close() inference = compat_client.one("inference")["inference"] - assert inference["success"] is not early_close + assert inference["success"] == (not early_close and not truncated) assert inference["output_meta"]["tokens_in"] == 12 if early_close: assert inference["error_code"] == "STREAM_CLOSED" assert inference["output_meta"].get("time_to_first_token_ms") is None else: + if truncated: + assert inference["error_code"] == "STREAM_INCOMPLETE" assert inference["output_meta"]["tokens_out"] == 3 assert inference["output_meta"]["time_to_first_token_ms"] is not None diff --git a/tests/test_integrations_anthropic.py b/tests/test_integrations_anthropic.py index 68964b1..43f438f 100644 --- a/tests/test_integrations_anthropic.py +++ b/tests/test_integrations_anthropic.py @@ -426,6 +426,7 @@ def test_streaming_records_inference_on_exhaustion(self): ), make_content_block_delta_event("Hello world"), make_message_delta_event(output_tokens=20, stop_reason="end_turn"), + SimpleNamespace(type="message_stop"), ] messages = FakeStreamingMessages(events) client = make_fake_client() @@ -449,6 +450,7 @@ def test_streaming_captures_token_counts_from_events(self): events = [ make_message_start_event(input_tokens=8, cached=2), make_message_delta_event(output_tokens=15, stop_reason="end_turn"), + SimpleNamespace(type="message_stop"), ] messages = FakeStreamingMessages(events) client = make_fake_client() @@ -555,6 +557,7 @@ async def test_streaming_records_inference_on_exhaustion(self): make_message_start_event(input_tokens=10, model="claude-opus-4-6-20251101"), make_content_block_delta_event("Hello world"), make_message_delta_event(output_tokens=20, stop_reason="end_turn"), + SimpleNamespace(type="message_stop"), ] messages = FakeAsyncStreamingMessages(events) client = make_fake_client() diff --git a/tests/test_streaming_lifecycle.py b/tests/test_streaming_lifecycle.py index f93c1fe..dd03fbd 100644 --- a/tests/test_streaming_lifecycle.py +++ b/tests/test_streaming_lifecycle.py @@ -251,3 +251,18 @@ async def test_async_telemetry_and_context(): with trace_context(trace_id="consumer"): assert [item async for item in stream] == [1] assert seen == ["origin"] + + +@pytest.mark.parametrize( + "factory", [make_openai_stream_callbacks, make_anthropic_stream_callbacks] +) +def test_remote_eof_without_completion_is_incomplete(factory): + handle = MagicMock() + callbacks = factory(handle, []) + stream = SyncStreamWrapper(iter([]), handle, time.perf_counter(), *callbacks) + list(stream) + stream.close() + handle.track_inference.assert_called_once() + event = handle.track_inference.call_args.kwargs + assert event["success"] is False + assert event["error_code"] == "STREAM_INCOMPLETE" diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index c4edfde..399c59a 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -152,9 +152,16 @@ def make_anthropic_stream_callbacks( cached_tokens: list[int | None] = [None] stop_reason: list[str | None] = [None] resolved_model: list[str | None] = [None] + message_stopped = False def on_chunk(event: object) -> None: + nonlocal message_stopped event_type = type(event).__name__ + if ( + getattr(event, "type", None) == "message_stop" + or event_type == "RawMessageStopEvent" + ): + message_stopped = True if event_type == "RawMessageStartEvent": msg = getattr(event, "message", None) if msg is not None: @@ -176,6 +183,8 @@ def on_chunk(event: object) -> None: def on_done( duration_ms: int, ttft_ms: int | None, *, outcome: str = "completed" ) -> None: + if outcome == "completed" and not message_stopped: + outcome = "incomplete" ti, to, cr, sr = tokens_in[0], tokens_out[0], cached_tokens[0], stop_reason[0] tps = round(to / duration_ms * 1000, 1) if duration_ms > 0 and to else None api_meta = ( diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index 1e2e3f9..d295148 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -181,6 +181,8 @@ def on_done( duration_ms: int, ttft_ms: int | None, *, outcome: str = "completed" ) -> None: ti, to, sr = tokens_in[0], tokens_out[0], stop_reason[0] + if outcome == "completed" and sr is None: + outcome = "incomplete" output_meta = build_streaming_output_meta(ttft_ms, ti, to, sr, duration_ms) handle.track_inference( duration_ms=duration_ms, From b548b46997dbc7a90726ca68b4ec3b7b1668ebc8 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 18:26:10 +0200 Subject: [PATCH 34/81] Handle Anthropic stream event names across supported SDKs --- wildedge/integrations/anthropic.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index 399c59a..d39d55a 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -162,7 +162,10 @@ def on_chunk(event: object) -> None: or event_type == "RawMessageStopEvent" ): message_stopped = True - if event_type == "RawMessageStartEvent": + if ( + event_type == "RawMessageStartEvent" + or getattr(event, "type", None) == "message_start" + ): msg = getattr(event, "message", None) if msg is not None: usage = getattr(msg, "usage", None) @@ -170,7 +173,10 @@ def on_chunk(event: object) -> None: tokens_in[0] = getattr(usage, "input_tokens", None) cached_tokens[0] = getattr(usage, "cache_read_input_tokens", None) resolved_model[0] = getattr(msg, "model", None) - elif event_type == "RawMessageDeltaEvent": + elif ( + event_type == "RawMessageDeltaEvent" + or getattr(event, "type", None) == "message_delta" + ): usage = getattr(event, "usage", None) if usage is not None: tokens_out[0] = getattr(usage, "output_tokens", None) From bf04c461de7d9207cb2f4130b31a33c104608267 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 18:28:21 +0200 Subject: [PATCH 35/81] Instrument Anthropic native streaming helpers --- CHANGELOG.md | 4 + docs/streaming.md | 32 ++++- examples/anthropic_stream_example.py | 80 +++++++++++ tests/compat/test_anthropic_compat.py | 197 ++++++++++++++++++++++++++ tests/test_anthropic_native_stream.py | 71 ++++++++++ wildedge/integrations/anthropic.py | 119 +++++++++++++++- 6 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 examples/anthropic_stream_example.py create mode 100644 tests/test_anthropic_native_stream.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 101074d..23f8ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ under Unreleased and move into a version section at release time. ### Added +- Anthropic native `messages.stream()` helpers are now instrumented for sync + and async clients, including event iteration, `text_stream`, and final-message + helpers, with the shared streaming lifecycle contract. + - OpenAI sync and async `embeddings.create()` instrumentation records latency, input token usage, batch size, vector dimensions, model metadata and errors. Embedding vectors and raw inputs are never attached by this adapter. diff --git a/docs/streaming.md b/docs/streaming.md index 0891535..e535a2d 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -2,7 +2,8 @@ The shared wrappers track OpenAI Chat Completions and Responses `create(stream=True)`, Anthropic `messages.create(stream=True)` (sync and async), -and llama-cpp-python/GGUF completion iterators (sync). +Anthropic `messages.stream()` and its `text_stream` / `get_final_message()` +helpers (sync and async), and llama-cpp-python/GGUF completion iterators (sync). | Outcome | Telemetry | |---|---| @@ -42,10 +43,6 @@ the SDK client and do not consume one iterator concurrently from multiple tasks. ## Audit findings outside these wrappers -- Anthropic's `messages.stream()` helper calls its transport directly and bypasses - the currently instrumented `messages.create()`. Use `create(stream=True)` for - the coverage above. Adding native helper support needs its own instrumentation - for `text_stream` and `get_final_message()`. - Provider convenience properties/methods that consume an underlying stream directly can bypass iteration on the wrapper. They are not covered by the lifecycle contract above; use the wrapped event iterator. @@ -58,3 +55,28 @@ the SDK client and do not consume one iterator concurrently from multiple tasks. the caller, and the caller must supply usage and first-output marks. The audit does not claim coverage for streaming APIs that are not instrumented. + +## Anthropic native helper + +```python +with client.messages.stream(model=model, max_tokens=64, messages=messages) as stream: + for text in stream.text_stream: + print(text, end="") + message = stream.get_final_message() +``` + +The async equivalent uses `async with`, `async for`, and +`await stream.get_final_message()`. Event iteration, text iteration, and final +message helpers share a single tracked iterator and produce one outcome. Early +context exit, cancellation, request/iteration errors, and truncated EOF use the +same lifecycle contract above. HTTP timing starts at context entry, when the +native helper sends its request, rather than when its lazy manager is created. + +The adapter preserves the native stream object and parser. It wraps the SDK's +internal `_iterator` and delegates native close behavior; compatibility is tested +against both the minimum supported Anthropic SDK and the current pinned SDK. + +Run `uv run examples/anthropic_stream_example.py` from the SDK root with +`ANTHROPIC_API_KEY` and `WILDEDGE_DSN` set. It makes two small billable calls, +prints a run ID, and checks provider output and usage. Confirm two inference +events and two spans under that run in WildEdge to verify telemetry delivery. diff --git a/examples/anthropic_stream_example.py b/examples/anthropic_stream_example.py new file mode 100644 index 0000000..dc41a12 --- /dev/null +++ b/examples/anthropic_stream_example.py @@ -0,0 +1,80 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["wildedge-sdk", "anthropic>=0.25.0"] +# +# [tool.uv.sources] +# wildedge-sdk = { path = "..", editable = true } +# /// +"""Two small billable native streaming calls, sync text and async final message. + +Requires ANTHROPIC_API_KEY and WILDEDGE_DSN; optionally ANTHROPIC_MODEL. +From the SDK root: uv run examples/anthropic_stream_example.py +""" + +from __future__ import annotations + +import asyncio +import os +import uuid + +from anthropic import Anthropic, AsyncAnthropic + +import wildedge + +MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-haiku-4-5") +MESSAGES = [{"role": "user", "content": "Reply with exactly: Hello from WildEdge"}] + + +def run_sync() -> None: + with ( + Anthropic() as client, + wildedge.span(kind="agent_step", name="anthropic-native-text"), + ): + with client.messages.stream( + model=MODEL, max_tokens=64, messages=MESSAGES + ) as stream: + text = "".join(stream.text_stream) + result = stream.get_final_message() + if not text or not result.usage: + raise RuntimeError("Missing output or usage") + print( + f"Sync text: input={result.usage.input_tokens}, output={result.usage.output_tokens}" + ) + + +async def run_async() -> None: + async with AsyncAnthropic() as client: + with wildedge.span(kind="agent_step", name="anthropic-native-final"): + async with client.messages.stream( + model=MODEL, max_tokens=64, messages=MESSAGES + ) as stream: + result = await stream.get_final_message() + if not result.content or not result.usage: + raise RuntimeError("Missing output or usage") + print( + f"Async final: input={result.usage.input_tokens}, output={result.usage.output_tokens}" + ) + + +def main() -> None: + missing = [ + name + for name in ("ANTHROPIC_API_KEY", "WILDEDGE_DSN") + if not os.environ.get(name) + ] + if missing: + raise SystemExit("Set required environment variables: " + ", ".join(missing)) + run_id = "anthropic-native-e2e-" + uuid.uuid4().hex[:12] + print(f"Run ID: {run_id}") + sdk = wildedge.init(integrations=["anthropic"], app_version="anthropic-native-e2e") + try: + with wildedge.trace(run_id=run_id): + run_sync() + asyncio.run(run_async()) + finally: + sdk.close(timeout=15) + print("Provider checks passed. Verify two inferences and two spans in WildEdge.") + + +if __name__ == "__main__": + main() diff --git a/tests/compat/test_anthropic_compat.py b/tests/compat/test_anthropic_compat.py index 3e1b39c..c0be842 100644 --- a/tests/compat/test_anthropic_compat.py +++ b/tests/compat/test_anthropic_compat.py @@ -145,3 +145,200 @@ def handler(request): assert inference["error_code"] == "STREAM_INCOMPLETE" assert inference["output_meta"]["tokens_out"] == 3 assert inference["output_meta"]["time_to_first_token_ms"] is not None + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("mode", ["events", "text", "final", "close", "truncated"]) +async def test_native_stream_helper(compat_client, asynchronous, mode): + compat_client.instrument("anthropic") + events = [ + { + "type": "message_start", + "message": { + **MESSAGE, + "content": [], + "stop_reason": None, + "usage": {"input_tokens": 12, "output_tokens": 0}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hello there"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 3}, + }, + {"type": "message_stop"}, + ] + if mode == "truncated": + events = events[:-1] + body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events) + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response( + 200, text=body, headers={"content-type": "text/event-stream"} + ) + + if asynchronous: + async with ( + anthropic.AsyncAnthropic( + api_key="sk-ant-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as client, + client.messages.stream( + model="claude-haiku-4-5", + max_tokens=32, + messages=[{"role": "user", "content": "hi"}], + ) as stream, + ): + if mode == "text": + assert ( + "".join([text async for text in stream.text_stream]) + == "Hello there" + ) + elif mode in ("final", "truncated"): + assert (await stream.get_final_message()).content[ + 0 + ].text == "Hello there" + elif mode == "events": + assert [e.type async for e in stream][-1] == "message_stop" + else: + await anext(stream) + else: + with ( + _client(handler) as client, + client.messages.stream( + model="claude-haiku-4-5", + max_tokens=32, + messages=[{"role": "user", "content": "hi"}], + ) as stream, + ): + if mode == "text": + assert "".join(stream.text_stream) == "Hello there" + elif mode in ("final", "truncated"): + assert stream.get_final_message().content[0].text == "Hello there" + elif mode == "events": + assert [e.type for e in stream][-1] == "message_stop" + else: + next(stream) + assert len(requests) == 1 + inference = compat_client.one("inference")["inference"] + assert inference["success"] == (mode not in ("close", "truncated")) + assert inference["output_meta"]["tokens_in"] == 12 + if mode == "close": + assert inference["error_code"] == "STREAM_CLOSED" + else: + if mode == "truncated": + assert inference["error_code"] == "STREAM_INCOMPLETE" + assert inference["output_meta"]["tokens_out"] == 3 + assert inference["output_meta"]["time_to_first_token_ms"] is not None + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_native_stream_request_error(compat_client, asynchronous): + compat_client.instrument("anthropic") + + def handler(request): + return httpx.Response( + 429, + json={ + "type": "error", + "error": {"type": "rate_limit_error", "message": "slow down"}, + }, + ) + + async def invoke_async(): + async with ( + anthropic.AsyncAnthropic( + api_key="sk-ant-compat", + max_retries=0, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as client, + client.messages.stream( + model="claude-haiku-4-5", max_tokens=32, messages=[] + ) as stream, + ): + await stream.get_final_message() + + def invoke_sync(): + with ( + _client(handler) as client, + client.messages.stream( + model="claude-haiku-4-5", max_tokens=32, messages=[] + ) as stream, + ): + stream.get_final_message() + + if asynchronous: + with pytest.raises(anthropic.RateLimitError): + await invoke_async() + else: + with pytest.raises(anthropic.RateLimitError): + invoke_sync() + error = compat_client.one("error")["error"] + assert error["http_status"] == 429 + assert error["provider_error_code"] == "rate_limit_error" + assert not compat_client.of_type("inference") + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_native_stream_provider_error_midstream(compat_client, asynchronous): + compat_client.instrument("anthropic") + events = [ + { + "type": "message_start", + "message": {**MESSAGE, "content": [], "stop_reason": None}, + }, + { + "type": "error", + "error": {"type": "overloaded_error", "message": "overloaded"}, + }, + ] + body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events) + + def handler(request): + return httpx.Response( + 200, text=body, headers={"content-type": "text/event-stream"} + ) + + async def invoke_async(): + async with ( + anthropic.AsyncAnthropic( + api_key="sk-ant-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as client, + client.messages.stream( + model="claude-haiku-4-5", max_tokens=32, messages=[] + ) as stream, + ): + await stream.get_final_message() + + def invoke_sync(): + with ( + _client(handler) as client, + client.messages.stream( + model="claude-haiku-4-5", max_tokens=32, messages=[] + ) as stream, + ): + stream.get_final_message() + + if asynchronous: + with pytest.raises(anthropic.APIError): + await invoke_async() + else: + with pytest.raises(anthropic.APIError): + invoke_sync() + error = compat_client.one("error")["error"] + assert error["provider_error_code"] == "overloaded_error" + assert not compat_client.of_type("inference") diff --git a/tests/test_anthropic_native_stream.py b/tests/test_anthropic_native_stream.py new file mode 100644 index 0000000..a5f8695 --- /dev/null +++ b/tests/test_anthropic_native_stream.py @@ -0,0 +1,71 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from wildedge.integrations.anthropic import AnthropicStreamManager, wrap_native_stream + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_request_failure_preserves_exception_when_telemetry_fails(asynchronous): + original_error = TimeoutError("request timeout") + handle = MagicMock() + handle.track_error.side_effect = ValueError("telemetry failed") + original = MagicMock() + original.__enter__.side_effect = original_error + original.__aenter__.side_effect = original_error + manager = AnthropicStreamManager(original, handle, []) + + async def enter(): + return await manager.__aenter__() if asynchronous else manager.__enter__() + + with pytest.raises(TimeoutError) as caught: + await enter() + assert caught.value is original_error + handle.track_error.assert_called_once() + + +async def test_cancelled_before_entry_preserves_cancellation(): + original = MagicMock() + original.__aenter__.side_effect = asyncio.CancelledError() + handle = MagicMock() + handle.track_inference.side_effect = ValueError("telemetry failed") + manager = AnthropicStreamManager(original, handle, []) + with pytest.raises(asyncio.CancelledError): + await manager.__aenter__() + assert handle.track_inference.call_args.kwargs["error_code"] == "STREAM_CANCELLED" + + +async def test_cancelled_inside_native_context(): + async def chunks(): + yield SimpleNamespace(type="message_start") + + native = SimpleNamespace(_iterator=chunks(), close=AsyncMock()) + original = MagicMock() + original.__aenter__.return_value = native + + async def exit_context(*args): + await native.close() + + original.__aexit__.side_effect = exit_context + handle = MagicMock() + manager = AnthropicStreamManager(original, handle, []) + + async def consume(): + async with manager as stream: + assert stream is native + await anext(stream._iterator) + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await consume() + handle.track_inference.assert_called_once() + assert handle.track_inference.call_args.kwargs["error_code"] == "STREAM_CANCELLED" + + +def test_closed_client_returns_original_manager(): + original_manager = object() + resource = SimpleNamespace(stream=MagicMock(return_value=original_manager)) + wrap_native_stream(resource, lambda: SimpleNamespace(closed=True)) + assert resource.stream(model="model") is original_manager diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index d39d55a..c0bed75 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -2,10 +2,11 @@ from __future__ import annotations +import asyncio import functools import threading import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from wildedge.events.inference import ApiMeta, GenerationOutputMeta, TextInputMeta from wildedge.integrations.base import BaseExtractor @@ -258,6 +259,7 @@ def patched_create(*args, **kwargs): raise messages_obj.create = patched_create # type: ignore[attr-defined] + wrap_native_stream(messages_obj, client_ref) def wrap_async_messages(messages_obj: object, client_ref: object) -> None: @@ -300,6 +302,121 @@ async def patched_create(*args, **kwargs): raise messages_obj.create = patched_create # type: ignore[attr-defined] + wrap_native_stream(messages_obj, client_ref) + + +class AnthropicStreamManager: + """Instrument the native helper's raw iterator while preserving its public API.""" + + def __init__(self, manager: Any, handle: ModelHandle, messages: list) -> None: + self._manager = manager + self._handle = handle + self._messages = messages + self._tracked = None + + def _safe(self, callback, *args, **kwargs) -> None: + try: + callback(*args, **kwargs) + except Exception as exc: + debug_anthropic_failure("native stream telemetry", exc) + + def _wrap(self, stream: Any, t0: float, asynchronous: bool) -> Any: + # Both old and current SDKs route event/text/final-message helpers + # through _iterator. Preserve the SDK parser and returned stream object. + try: + callbacks = make_anthropic_stream_callbacks(self._handle, self._messages) + wrapper = AsyncStreamWrapper if asynchronous else SyncStreamWrapper + tracked = wrapper(stream._iterator, self._handle, t0, *callbacks) + original_close = stream.close + if asynchronous: + + @functools.wraps(original_close) + async def close(): + try: + return await original_close() + finally: + tracked._finish("closed") + else: + + @functools.wraps(original_close) + def close(): + try: + return original_close() + finally: + tracked._finish("closed") + + stream._iterator = tracked + stream.close = close + self._tracked = tracked + except Exception as exc: + debug_anthropic_failure("native stream wrap", exc) + return stream + + def __enter__(self) -> Any: + t0 = time.perf_counter() + try: + stream = self._manager.__enter__() + except Exception as exc: + self._safe(track_api_error, self._handle, exc, elapsed_ms(t0)) + raise + return self._wrap(stream, t0, False) + + def __exit__(self, *args: Any) -> Any: + return self._manager.__exit__(*args) + + async def __aenter__(self) -> Any: + t0 = time.perf_counter() + try: + stream = await self._manager.__aenter__() + except asyncio.CancelledError: + self._safe( + self._handle.track_inference, + duration_ms=elapsed_ms(t0), + success=False, + error_code="STREAM_CANCELLED", + ) + raise + except Exception as exc: + self._safe(track_api_error, self._handle, exc, elapsed_ms(t0)) + raise + return self._wrap(stream, t0, True) + + async def __aexit__(self, *args: Any) -> Any: + if ( + self._tracked is not None + and args + and args[0] is not None + and issubclass(args[0], asyncio.CancelledError) + ): + self._tracked._finish("cancelled") + return await self._manager.__aexit__(*args) + + def __getattr__(self, name: str) -> Any: + return getattr(self._manager, name) + + +def wrap_native_stream(messages_obj: object, client_ref: object) -> None: + original_stream = getattr(messages_obj, "stream", None) + if original_stream is None: + return + model_handles: dict[str, ModelHandle] = {} + + @functools.wraps(original_stream) + def patched_stream(*args, **kwargs): + client = client_ref() + model_id = kwargs.get("model") + if client is None or client.closed or not model_id: + return original_stream(*args, **kwargs) + handle = resolve_handle(model_id, messages_obj, model_handles, client) + manager = original_stream(*args, **kwargs) + if handle is None: + return manager + messages = kwargs.get("messages", []) + return AnthropicStreamManager( + manager, handle, messages if isinstance(messages, list) else [] + ) + + messages_obj.stream = patched_stream class AnthropicExtractor(BaseExtractor): From 98e6a7c23a466cf51016e02dd0fcb2168e8fe043 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 18:45:18 +0200 Subject: [PATCH 36/81] Track Ultralytics predict through the common entry point --- CHANGELOG.md | 4 +++ tests/compat/test_ultralytics_compat.py | 11 ++++--- tests/test_integrations_ultralytics.py | 40 +++++++++++++++++++++++-- wildedge/integrations/ultralytics.py | 6 ++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f8ec5..5461a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ under Unreleased and move into a version section at release time. ### Fixed +- Ultralytics `model.predict()` now records inference telemetry. Instrumentation + patches `predict`, which `model(...)` delegates to, so both entry points are + tracked exactly once. + - Streaming wrappers now finalize once across exhaustion, explicit closure, cancellation, and iterator errors. Early closure/cancellation records an unsuccessful inference with partial metadata when available. TTFT ignores diff --git a/tests/compat/test_ultralytics_compat.py b/tests/compat/test_ultralytics_compat.py index f22267f..818aada 100644 --- a/tests/compat/test_ultralytics_compat.py +++ b/tests/compat/test_ultralytics_compat.py @@ -35,10 +35,13 @@ def test_yolo_call_is_tracked(compat_client, yolo, image): assert inference["inference"]["success"] is True -@pytest.mark.xfail( - strict=True, - reason="model.predict() is not tracked, only model() (#72)", -) def test_yolo_predict_is_tracked(compat_client, yolo, image): yolo.predict(image, imgsz=64, verbose=False) compat_client.one("inference") + + +def test_predict_and_call_each_track_once(compat_client, yolo, image): + yolo.predict(image, imgsz=64, verbose=False) + assert len(compat_client.of_type("inference")) == 1 + yolo(image, imgsz=64, verbose=False) + assert len(compat_client.of_type("inference")) == 2 diff --git a/tests/test_integrations_ultralytics.py b/tests/test_integrations_ultralytics.py index dc49c2d..885c430 100644 --- a/tests/test_integrations_ultralytics.py +++ b/tests/test_integrations_ultralytics.py @@ -84,18 +84,21 @@ class YOLO: model = FakeInnerModel() def __call__(self, source=None, stream=False, **kwargs): + return self.predict(source, stream=stream, **kwargs) + + def predict(self, source=None, stream=False, **kwargs): return [FakeResult([(10, 20, 100, 200, 0.9, 0)])] class FailingYOLO(YOLO): - def __call__(self, source=None, stream=False, **kwargs): + def predict(self, source=None, stream=False, **kwargs): raise RuntimeError("cuda oom") class ClassifyYOLO(YOLO): task = "classify" - def __call__(self, source=None, stream=False, **kwargs): + def predict(self, source=None, stream=False, **kwargs): return [FakeClassifyResult()] @@ -464,3 +467,36 @@ def test_unsupported_ultralytics_version_warns_and_does_not_patch(monkeypatch, c assert fake_yolo.__init__ is original_init messages = [r.getMessage() for r in caplog.records] assert any("ultralytics 8.0.0 is older than 8.0.200" in m for m in messages) + + +@pytest.mark.parametrize("entrypoint", ["predict", "__call__"]) +def test_predict_and_call_track_once_with_repeated_registration( + publish_spy, entrypoint +): + model = YOLO() + handle = make_handle(publish_spy) + _extractor.install_hooks(model, handle) + _extractor.install_hooks(model, handle) + results = getattr(model, entrypoint)(source="image.png", verbose=False) + assert len(results) == 1 + assert len(publish_spy.events) == 1 + assert publish_spy.events[0]["event_type"] == "inference" + assert publish_spy.events[0]["inference"]["output_meta"]["num_predictions"] == 1 + + +def test_predict_error_is_preserved_and_tracked_once(publish_spy): + model = FailingYOLO() + _extractor.install_hooks(model, make_handle(publish_spy)) + with pytest.raises(RuntimeError, match="cuda oom"): + model.predict(source="image.png") + assert len(publish_spy.events) == 1 + assert publish_spy.events[0]["event_type"] == "error" + + +def test_predict_does_not_instrument_unregistered_instances(publish_spy): + tracked, untouched = YOLO(), YOLO() + _extractor.install_hooks(tracked, make_handle(publish_spy)) + untouched.predict() + assert not publish_spy.events + tracked.predict() + assert len(publish_spy.events) == 1 diff --git a/wildedge/integrations/ultralytics.py b/wildedge/integrations/ultralytics.py index 4a822d3..f929672 100644 --- a/wildedge/integrations/ultralytics.py +++ b/wildedge/integrations/ultralytics.py @@ -63,7 +63,7 @@ _ULTRALYTICS_PATCH_LOCK = threading.Lock() # --- Marker names written onto patched objects and classes --- -YOLO_CALL_PATCH_NAME = "ultralytics_call" +YOLO_PREDICT_PATCH_NAME = "ultralytics_predict" YOLO_HANDLE_ATTR = "__wildedge_yolo_handle__" YOLO_AUTO_LOAD_PATCH_NAME = "ultralytics_auto_load" @@ -391,9 +391,11 @@ def memory_bytes(self, obj: object) -> int | None: def install_hooks(self, obj: object, handle: ModelHandle) -> None: handle.detected_accelerator = detect_accelerator(obj) setattr(obj, YOLO_HANDLE_ATTR, handle) + # YOLO.__call__ delegates to predict; patch their common entry point. patch_instance_call_once( obj, - patch_name=YOLO_CALL_PATCH_NAME, + patch_name=YOLO_PREDICT_PATCH_NAME, + method="predict", make_patched_call=build_patched_call, ) From e41064830841a5de21e13e9b0ac96b732c262d87 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 18:56:15 +0200 Subject: [PATCH 37/81] Preserve timm architecture names in model identity --- CHANGELOG.md | 5 +++ tests/compat/test_timm_compat.py | 33 ++++++++++++++----- tests/test_integrations_pytorch.py | 52 ++++++++++++++++++++++++++++++ wildedge/integrations/pytorch.py | 32 +++++++++++++++--- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5461a70..8748b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ under Unreleased and move into a version section at release time. ### Fixed +- timm models now use the requested architecture/variant name for model identity + instead of the Python class name, so models such as resnet18 and resnet50 no + longer share telemetry. Manual registration falls back to timm architecture + metadata, and explicit model IDs retain priority. + - Ultralytics `model.predict()` now records inference telemetry. Instrumentation patches `predict`, which `model(...)` delegates to, so both entry points are tracked exactly once. diff --git a/tests/compat/test_timm_compat.py b/tests/compat/test_timm_compat.py index 2afdedc..2ee3553 100644 --- a/tests/compat/test_timm_compat.py +++ b/tests/compat/test_timm_compat.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pytest import timm import torch @@ -18,14 +17,32 @@ def test_timm_create_model_and_forward_are_tracked(compat_client): assert inference["inference"]["input_modality"] == "image" -@pytest.mark.xfail( - strict=True, - reason="timm models are registered by class name, so architectures collide (#73)", -) def test_timm_architectures_get_their_own_ids(compat_client): compat_client.instrument("timm") # Neither is loaded by another test, so each should add one model_load. - timm.create_model("resnet34", pretrained=False) - timm.create_model("resnet50", pretrained=False) + models = [ + timm.create_model(name, pretrained=False).eval() + for name in ("resnet34", "resnet50") + ] + with torch.no_grad(): + for model in models: + model(torch.randn(1, 3, 64, 64)) ids = {e["model_id"] for e in compat_client.of_type("model_load")} - assert len(ids) == 2 + assert ids == {"resnet34", "resnet50"} + assert {e["model_id"] for e in compat_client.of_type("inference")} == ids + assert {compat_client.registry.models[name].model_name for name in ids} == ids + + +def test_repeated_architecture_reuses_identity(compat_client): + compat_client.instrument("timm") + models = [ + timm.create_model(model_name="resnet18", pretrained=False).eval() + for _ in range(2) + ] + with torch.no_grad(): + for model in models: + model(torch.randn(1, 3, 64, 64)) + events = compat_client.of_type("inference") + assert len(events) == 2 + assert {event["model_id"] for event in events} == {"resnet18"} + assert compat_client.registry.models["resnet18"].model_name == "resnet18" diff --git a/tests/test_integrations_pytorch.py b/tests/test_integrations_pytorch.py index 357079b..91d3041 100644 --- a/tests/test_integrations_pytorch.py +++ b/tests/test_integrations_pytorch.py @@ -2,8 +2,12 @@ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + +import wildedge.integrations.pytorch as pytorch_mod from wildedge.integrations.pytorch import ( PytorchExtractor, ) @@ -163,3 +167,51 @@ def test_install_hooks_sets_detected_accelerator(self, publish_spy): handle = make_handle(publish_spy) self.extractor.install_hooks(model, handle) assert handle.detected_accelerator == "cpu" + + +def test_timm_config_identity_and_explicit_override(): + model = _FakeTorchModel() + model.pretrained_cfg = {"architecture": "resnet50"} + extractor = PytorchExtractor() + model_id, info = extractor.extract_info(model, {}) + assert model_id == info.model_name == "resnet50" + model_id, info = extractor.extract_info(model, {"id": "custom-model"}) + assert model_id == "custom-model" + assert info.model_name == "resnet50" + + +def test_timm_requested_variant_takes_priority_over_config(): + model = _FakeTorchModel() + model.pretrained_cfg = {"architecture": "resnet50"} + model.__wildedge_timm_model_name__ = "resnet50.a1_in1k" + model_id, info = PytorchExtractor().extract_info(model, {}) + assert model_id == info.model_name == "resnet50.a1_in1k" + + +def test_timm_invalid_config_falls_back_to_class(): + model = _FakeTorchModel() + model.pretrained_cfg = {"architecture": None} + model.default_cfg = {"architecture": 123} + model_id, info = PytorchExtractor().extract_info(model, {}) + assert model_id == info.model_name == "_FakeTorchModel" + + +@pytest.mark.parametrize("keyword", [False, True]) +@pytest.mark.parametrize("name", ["resnet50.a1_in1k", "hf-hub:example/resnet50"]) +def test_timm_patch_retains_requested_name(monkeypatch, keyword, name): + model = _FakeTorchModel() + fake_timm = SimpleNamespace(create_model=MagicMock(return_value=model)) + monkeypatch.setattr(pytorch_mod, "_timm", fake_timm) + monkeypatch.setattr(pytorch_mod, "_timm_patched", False) + client = MagicMock(closed=False) + client._diff_hub_caches.return_value = [] + PytorchExtractor.install_timm_patch(lambda: client) + result = ( + fake_timm.create_model(model_name=name) + if keyword + else fake_timm.create_model(name) + ) + assert result is model + model_id, info = PytorchExtractor().extract_info(result, {}) + assert model_id == info.model_name == name + client._on_model_auto_loaded.assert_called_once() diff --git a/wildedge/integrations/pytorch.py b/wildedge/integrations/pytorch.py index b81029b..1171a39 100644 --- a/wildedge/integrations/pytorch.py +++ b/wildedge/integrations/pytorch.py @@ -44,6 +44,7 @@ _timm_patched = False _TIMM_PATCH_LOCK = threading.Lock() TIMM_AUTO_LOAD_PATCH_NAME = "timm_auto_load" +TIMM_MODEL_NAME_ATTR = "__wildedge_timm_model_name__" debug_pytorch_failure = functools.partial(debug_failure, "pytorch") @@ -164,6 +165,20 @@ def classification_output_meta( ) +def timm_model_name(obj: object) -> str | None: + """Prefer the full requested name, including pretrained tags or hub paths.""" + name = getattr(obj, TIMM_MODEL_NAME_ATTR, None) + if isinstance(name, str) and name: + return name + for key in ("pretrained_cfg", "default_cfg"): + config = getattr(obj, key, None) + if isinstance(config, dict): + name = config.get("architecture") + if isinstance(name, str) and name: + return name + return None + + class PytorchExtractor(BaseExtractor): def can_handle(self, obj: object) -> bool: return is_torch_module(obj) @@ -172,9 +187,15 @@ def extract_info( self, obj: object, overrides: dict ) -> tuple[str | None, ModelInfo]: class_name = type(obj).__name__ - model_id = overrides.pop("id", None) or class_name - - if class_name in _GENERIC_MODULE_NAMES: + model_name = timm_model_name(obj) or class_name + explicit_id = overrides.pop("id", None) + model_id = explicit_id or model_name + + if ( + model_name == class_name + and class_name in _GENERIC_MODULE_NAMES + and not explicit_id + ): logger.warning( "wildedge: PyTorch model_id defaults to generic class name '%s' - " "pass model_id= to register_model() for a stable unique ID", @@ -187,7 +208,7 @@ def extract_info( source = overrides.pop("source", "local") info = ModelInfo( - model_name=class_name, + model_name=model_name, model_version=version, model_source=source, model_format="pytorch", @@ -315,6 +336,9 @@ def patched_create_model(*args, **kwargs): # type: ignore[no-untyped-def] load_ms = elapsed_ms(t0) logger.debug("wildedge: timm.create_model done load_ms=%d", load_ms) if c is not None and not c.closed: + name = args[0] if args else kwargs.get("model_name") + if isinstance(name, str) and name: + setattr(model, TIMM_MODEL_NAME_ATTR, name) downloads = c._diff_hub_caches(hub_before, load_ms) or None c._on_model_auto_loaded(model, load_ms=load_ms, downloads=downloads) return model From c5b8d0fa9cfcb84f821055e166c5d02a05d4003e Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 19:27:34 +0200 Subject: [PATCH 38/81] Support sequential auto-instrumentation client replacement --- CHANGELOG.md | 7 + docs/deployment.md | 40 +++- tests/compat/conftest.py | 30 +-- tests/compat/test_anthropic_compat.py | 42 +++- tests/compat/test_onnx_compat.py | 22 ++- tests/compat/test_openai_compat.py | 50 ++++- tests/conftest.py | 9 + tests/test_init.py | 6 + tests/test_instrumentation_ownership.py | 245 ++++++++++++++++++++++++ wildedge/client.py | 20 +- wildedge/convenience.py | 30 ++- wildedge/hubs/base.py | 6 +- wildedge/hubs/huggingface.py | 23 ++- wildedge/instrumentation.py | 93 +++++++++ wildedge/integrations/anthropic.py | 7 +- wildedge/integrations/openai.py | 17 +- 16 files changed, 597 insertions(+), 50 deletions(-) create mode 100644 tests/test_instrumentation_ownership.py create mode 100644 wildedge/instrumentation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8748b4c..7dbea81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ under Unreleased and move into a version section at release time. ### Fixed +- Process-wide integrations and hub patches now support sequential SDK client + replacement after the previous owner closes. Reinitialization replaces a closed + default; live ownership conflicts raise explicitly. Existing models/provider + clients retain their original destination and must be recreated. Attempts to + use a closed telemetry destination warn once instead of silently dropping + inference/error events. + - timm models now use the requested architecture/variant name for model identity instead of the Python class name, so models such as resnet18 and resnet50 no longer share telemetry. Manual registration falls back to timm architecture diff --git a/docs/deployment.md b/docs/deployment.md index 713c016..48727fa 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -82,9 +82,11 @@ them unwrapped or unset `WILDEDGE_DSN` there. ## One client per process -- Framework patches are installed at most once per process; the first client - to instrument owns the patch. -- `init()` reuses the existing default client unless you pass `dsn` +- Framework patches are installed at most once per process. One live client + owns process-wide auto-instrumentation; another client attempting to enable + it raises `RuntimeError` until that owner is closed. Manual tracking does not + claim ownership. +- `init()` reuses the live default client unless you pass `dsn` explicitly, so the CLI-installed client and application code share one client instead of racing. - Trace and span correlation lives in contextvars at module level, not on a @@ -92,6 +94,38 @@ them unwrapped or unset `WILDEDGE_DSN` there. `wildedge.span(...)` blocks correlate into the same trace no matter which client emits them. +### Replacing a client + +Sequential replacement is supported. Finish any in-flight work and close the +old SDK client before initializing its replacement. Enable integrations and hubs +explicitly on the replacement: previously enabled integrations do not carry over. +Factory patches resolve the new owner without being installed again. Buffered +HuggingFace download provenance is scoped to its owner session and does not +carry over to another project. + +```python +first = wildedge.init(dsn=dsn, integrations=["onnx"]) +session = ort.InferenceSession("model.onnx") +# Run and finish work with session. +first.close(timeout=10) + +second = wildedge.init(dsn=other_dsn, integrations=["onnx"]) +session = ort.InferenceSession("model.onnx") # create after replacement init +``` + +Existing model objects, provider clients, model handles and in-flight streams +remain bound to the original client; they are not automatically transferred to +another project. Recreate auto-instrumented models/provider clients after +initializing the replacement. Attempts to emit inference/error telemetry through +a closed client are dropped with a one-time warning; application inference can +still run. Simply changing the default client does not change instrumentation +ownership. Repeated `close()` calls are harmless. + +`init()` replaces a closed default even when no explicit `dsn` is supplied, +reading the new client's configuration from the environment. If initialization +fails (including an ownership conflict), a newly created client is closed and +the existing default is preserved. + ## Verifying a deployment `wildedge doctor` answers "will events actually flow from this machine" diff --git a/tests/compat/conftest.py b/tests/compat/conftest.py index 891cbb1..fbc19e7 100644 --- a/tests/compat/conftest.py +++ b/tests/compat/conftest.py @@ -113,7 +113,14 @@ def __init__(self, client: wildedge.WildEdge) -> None: self.events: list[dict] = [] # Handles keep a reference to client.publish, so swap it before any # model is registered. - client.publish = self.events.append # type: ignore[method-assign] + original_publish = client.publish + + def publish(event): + original_publish(event) + if not client.closed: + self.events.append(event) + + client.publish = publish # type: ignore[method-assign] def __getattr__(self, name: str): return getattr(self.client, name) @@ -128,10 +135,8 @@ def one(self, event_type: str) -> dict: return found[0] -@pytest.fixture(scope="session") -def _session_client(): - # One client per process, as in a real app: integration patches are - # process-wide and stay bound to the client that installed them (#74). +def make_compat_client(): + """Construct a recording client without background network services.""" with ( patch( "wildedge.client.detect_device", @@ -146,13 +151,14 @@ def _session_client(): enable_offline_persistence=False, sampling_interval_s=0, ) - wrapped = CompatClient(client) - yield wrapped - client.close() + return CompatClient(client) @pytest.fixture -def compat_client(_session_client): - """The shared client, with the events of earlier tests cleared.""" - _session_client.events.clear() - return _session_client +def compat_client(): + """Fresh clients exercise replacement through already-installed process patches.""" + wrapped = make_compat_client() + try: + yield wrapped + finally: + wrapped.close() diff --git a/tests/compat/test_anthropic_compat.py b/tests/compat/test_anthropic_compat.py index c0be842..fe32033 100644 --- a/tests/compat/test_anthropic_compat.py +++ b/tests/compat/test_anthropic_compat.py @@ -4,7 +4,7 @@ import anthropic import pytest -from conftest import sdk_http_module +from conftest import make_compat_client, sdk_http_module httpx = sdk_http_module("anthropic") @@ -342,3 +342,43 @@ def invoke_sync(): error = compat_client.one("error")["error"] assert error["provider_error_code"] == "overloaded_error" assert not compat_client.of_type("inference") + + +def test_replacement_client_keeps_native_helper_ownership(compat_client): + compat_client.instrument("anthropic") + old = _client(lambda request: httpx.Response(200, json=MESSAGE)) + old.messages.create(model="claude-haiku-4-5", max_tokens=32, messages=[]) + compat_client.close() + replacement = make_compat_client() + try: + replacement.instrument("anthropic") + old.messages.create(model="claude-haiku-4-5", max_tokens=32, messages=[]) + events = [ + { + "type": "message_start", + "message": {**MESSAGE, "content": [], "stop_reason": None}, + }, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 3}, + }, + {"type": "message_stop"}, + ] + body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events) + with ( + _client( + lambda request: httpx.Response( + 200, text=body, headers={"content-type": "text/event-stream"} + ) + ) as new, + new.messages.stream( + model="claude-haiku-4-5", max_tokens=32, messages=[] + ) as stream, + ): + assert stream.get_final_message().usage.output_tokens == 3 + assert len(compat_client.of_type("inference")) == 1 + replacement.one("inference") + finally: + old.close() + replacement.close() diff --git a/tests/compat/test_onnx_compat.py b/tests/compat/test_onnx_compat.py index 541d7fc..3436ce6 100644 --- a/tests/compat/test_onnx_compat.py +++ b/tests/compat/test_onnx_compat.py @@ -2,7 +2,7 @@ import numpy as np import onnxruntime as ort -from conftest import FIXTURES +from conftest import FIXTURES, make_compat_client def test_inference_session_load_and_run_are_tracked(compat_client): @@ -24,3 +24,23 @@ def test_inference_session_load_and_run_are_tracked(compat_client): inference = compat_client.one("inference") assert inference["model_id"] == "identity" assert inference["inference"]["success"] is True + + +def test_replacement_client_tracks_new_sessions_only(compat_client): + compat_client.instrument("onnx") + path = str(FIXTURES / "identity.onnx") + old = ort.InferenceSession(path, providers=["CPUExecutionProvider"]) + x = np.arange(4, dtype=np.float32).reshape(1, 4) + old.run(None, {"x": x}) + compat_client.close() + replacement = make_compat_client() + try: + replacement.instrument("onnx") + old.run(None, {"x": x}) + new = ort.InferenceSession(path, providers=["CPUExecutionProvider"]) + assert np.array_equal(new.run(None, {"x": x})[0], x) + assert len(compat_client.of_type("inference")) == 1 + replacement.one("model_load") + replacement.one("inference") + finally: + replacement.close() diff --git a/tests/compat/test_openai_compat.py b/tests/compat/test_openai_compat.py index 6e26539..8641959 100644 --- a/tests/compat/test_openai_compat.py +++ b/tests/compat/test_openai_compat.py @@ -6,7 +6,7 @@ import openai import pytest -from conftest import sdk_http_module +from conftest import make_compat_client, sdk_http_module httpx = sdk_http_module("openai") @@ -360,3 +360,51 @@ def handler(request): assert inference["success"] is False assert inference["error_code"] == "STREAM_CLOSED" assert inference["output_meta"]["time_to_first_token_ms"] is not None + + +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_replacement_client_gets_new_provider_clients( + compat_client, asynchronous +): + compat_client.instrument("openai") + + def handler(request): + return httpx.Response(200, json=COMPLETION) + + if asynchronous: + old = openai.AsyncOpenAI( + api_key="sk-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + await old.chat.completions.create(model="gpt-4o-mini", messages=[]) + else: + old = _client(handler) + old.chat.completions.create(model="gpt-4o-mini", messages=[]) + compat_client.one("inference") + compat_client.close() + replacement = make_compat_client() + try: + replacement.instrument("openai") + if asynchronous: + await old.chat.completions.create(model="gpt-4o-mini", messages=[]) + async with openai.AsyncOpenAI( + api_key="sk-compat", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as new: + await new.chat.completions.create(model="gpt-4o-mini", messages=[]) + await new.chat.completions.create(model="gpt-4o-mini", messages=[]) + await old.close() + else: + old.chat.completions.create(model="gpt-4o-mini", messages=[]) + with _client(handler) as new: + new.chat.completions.create(model="gpt-4o-mini", messages=[]) + new.chat.completions.create(model="gpt-4o-mini", messages=[]) + old.close() + assert len(compat_client.of_type("inference")) == 1 + assert len(replacement.of_type("inference")) == 2 + assert ( + replacement.registry.handles["gpt-4o-mini"] + is not compat_client.registry.handles["gpt-4o-mini"] + ) + finally: + replacement.close() diff --git a/tests/conftest.py b/tests/conftest.py index 88f2f0a..e9f04ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ import pytest +import wildedge.hubs.huggingface as huggingface +import wildedge.instrumentation as instrumentation import wildedge.paths as paths from wildedge.client import WildEdge from wildedge.defaults import set_default_client @@ -123,3 +125,10 @@ def client_with_stubbed_runtime(): dsn="https://secret@ingest.wildedge.dev/key", sampling_interval_s=None, ) + + +@pytest.fixture(autouse=True) +def isolate_instrumentation_owner(monkeypatch): + monkeypatch.setattr(instrumentation, "_owner", None) + monkeypatch.setattr(instrumentation, "_enabled", set()) + monkeypatch.setattr(huggingface, "_client_route", None) diff --git a/tests/test_init.py b/tests/test_init.py index f09e510..37eed7b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -74,6 +74,8 @@ def test_init_reuses_existing_default_client(monkeypatch): calls: list[tuple[str | None, list[str] | None]] = [] class ExistingClient: + closed = False + def instrument(self, integration, *, hubs=None): calls.append((integration, hubs)) @@ -89,6 +91,8 @@ def instrument(self, integration, *, hubs=None): def test_init_with_dsn_replaces_default_client(monkeypatch): class ExistingClient: + closed = False + def instrument(self, integration, *, hubs=None): raise AssertionError("existing client must not be touched") @@ -112,6 +116,8 @@ def instrument(self, integration, *, hubs=None): def test_init_reuse_warns_about_ignored_kwargs(monkeypatch): class ExistingClient: + closed = False + def instrument(self, integration, *, hubs=None): pass diff --git a/tests/test_instrumentation_ownership.py b/tests/test_instrumentation_ownership.py new file mode 100644 index 0000000..2ac27dc --- /dev/null +++ b/tests/test_instrumentation_ownership.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import gc +import threading +import weakref +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import MagicMock, patch + +import pytest + +import wildedge +import wildedge.convenience as convenience +import wildedge.hubs.huggingface as huggingface +import wildedge.instrumentation as instrumentation +from wildedge.client import WildEdge +from wildedge.defaults import set_default_client +from wildedge.instrumentation import BoundClientReference, claim_instrumentation +from wildedge.platforms.device_info import DeviceInfo + + +class Owner: + closed = False + + +def test_sequential_replacement_and_opt_in(): + first = Owner() + route = claim_instrumentation(first, "onnx") + assert route() is first + assert claim_instrumentation(first, "onnx")() is first + first.closed = True + assert route() is None + second = Owner() + other = claim_instrumentation(second, "openai") + assert other() is second + assert route() is None + claim_instrumentation(second, "onnx") + assert route() is second + + +def test_live_owner_conflict_does_not_redirect(): + first, second = Owner(), Owner() + route = claim_instrumentation(first, "onnx") + with pytest.raises(RuntimeError, match="another live"): + claim_instrumentation(second, "openai") + assert route() is first + + +def test_dead_owner_can_be_replaced(): + first = Owner() + ref = weakref.ref(first) + route = claim_instrumentation(first, "onnx") + del first + gc.collect() + assert ref() is None + second = Owner() + claim_instrumentation(second, "onnx") + assert route() is second + + +def test_provider_reference_never_follows_replacement(caplog): + caplog.set_level("WARNING", logger="wildedge") + first, second = Owner(), Owner() + claim_instrumentation(first, "openai") + ref = BoundClientReference(first) + first.closed = True + claim_instrumentation(second, "openai") + assert ref() is None + assert ref() is None + assert len([r for r in caplog.records if "recreate" in r.message]) == 1 + + +@pytest.fixture +def clients(): + created = [] + with ( + patch( + "wildedge.client.detect_device", + return_value=DeviceInfo(device_id="replacement-test", device_type="linux"), + ), + patch("wildedge.client.Consumer"), + patch("wildedge.client.Transmitter"), + ): + + def create(): + client = WildEdge( + dsn="https://test@localhost/test", + sampling_interval_s=0, + enable_offline_persistence=False, + ) + created.append(client) + return client + + yield create + for client in created: + client.close() + + +@pytest.mark.parametrize("integration", sorted(WildEdge.PATCH_INSTALLERS)) +def test_shared_route_reuses_first_factory_binding(clients, monkeypatch, integration): + routes = [] + installer = MagicMock( + side_effect=lambda route: routes.append(route) if not routes else None + ) + monkeypatch.setitem(WildEdge.PATCH_INSTALLERS, integration, installer) + first = clients() + first.instrument(integration) + assert routes[0]() is first + first.close() + second = clients() + second.instrument(integration) + assert routes[0]() is second + assert len(routes) == 1 + + +def test_closed_model_handle_does_not_move_projects(clients, caplog): + caplog.set_level("WARNING", logger="wildedge") + first = clients() + handle = first.register_model(object(), model_id="model", auto_instrument=False) + first.close() + second = clients() + handle.track_inference(duration_ms=1) + handle.track_inference(duration_ms=2) + assert first.pending_count() == second.pending_count() == 0 + assert len([r for r in caplog.records if "closed SDK client" in r.message]) == 1 + + +def test_init_replaces_closed_default(clients, monkeypatch): + first = clients() + set_default_client(first) + first.close() + second = clients() + monkeypatch.setattr(convenience, "WildEdge", lambda **kwargs: second) + assert wildedge.init() is second + + +def test_close_is_idempotent(clients): + client = clients() + client.close() + client.close() + client.consumer.close.assert_called_once() + + +def test_init_conflict_cleans_up_new_client_without_stopping_owner_sampler( + clients, monkeypatch +): + first = clients() + claim_instrumentation(first, "onnx") + set_default_client(first) + second = clients() + monkeypatch.setattr(convenience, "WildEdge", lambda **kwargs: second) + with patch("wildedge.client.stop_sampler") as stop: + with pytest.raises(RuntimeError, match="another live"): + wildedge.init(dsn="https://other@localhost/other", integrations=["onnx"]) + stop.assert_not_called() + assert second.closed + assert not first.closed + assert convenience.peek_default_client() is first + + +@pytest.mark.parametrize("hub", ["huggingface", "torchhub"]) +def test_hub_factory_route_follows_replacement(clients, monkeypatch, hub): + routes = [] + + class Tracker: + def can_install(self): + return True + + def install_patch(self, route): + if not routes: + routes.append(route) + + monkeypatch.setitem(WildEdge.HUB_TRACKER_CLASSES, hub, Tracker) + first = clients() + first.instrument(None, hubs=[hub]) + assert routes[0]() is first + first.close() + second = clients() + second.instrument(None, hubs=[hub]) + assert routes[0]() is second + assert first.hub_trackers[hub] is not second.hub_trackers[hub] + + +def test_changing_default_does_not_change_instrumentation_owner(clients): + first, second = clients(), clients() + route = claim_instrumentation(first, "openai") + set_default_client(second) + assert route() is first + + +def test_closed_client_cannot_instrument(clients): + client = clients() + client.close() + with pytest.raises(RuntimeError, match="closed"): + client.instrument("onnx") + + +def test_fork_child_replaces_inherited_lock(): + first = Owner() + route = claim_instrumentation(first, "onnx") + with instrumentation._lock: + instrumentation._reset_lock_after_fork() + assert route() is first + + +def test_concurrent_claims_choose_one_owner(): + owners = [Owner(), Owner()] + barrier = threading.Barrier(2, timeout=2) + + def acquire(owner): + barrier.wait() + try: + return claim_instrumentation(owner, "onnx") + except RuntimeError: + return None + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(acquire, owners)) + routes = [route for route in results if route is not None] + assert len(routes) == 1 + assert routes[0]() in owners + + +def test_hub_provenance_buffer_does_not_cross_owners(clients, monkeypatch): + first = clients() + route = claim_instrumentation(first, "hub:huggingface") + monkeypatch.setattr(huggingface, "_client_route", route) + monkeypatch.setattr(huggingface, "_local", threading.local()) + record = { + "repo_id": "first-project/model", + "size": 1, + "duration_ms": 1, + "cache_hit": True, + "bandwidth_bps": None, + } + huggingface._record_download(**record) + first.close() + second = clients() + claim_instrumentation(second, "onnx") + huggingface._record_download(**record) + claim_instrumentation(second, "hub:huggingface") + assert huggingface.HuggingFaceHubTracker().drain() == [] + huggingface._record_download(**{**record, "repo_id": "second-project/model"}) + assert [r["repo_id"] for r in huggingface.HuggingFaceHubTracker().drain()] == [ + "second-project/model" + ] diff --git a/wildedge/client.py b/wildedge/client.py index d21fded..1a5acb2 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -25,6 +25,7 @@ from wildedge.hubs.huggingface import HuggingFaceHubTracker from wildedge.hubs.registry import supported_hubs from wildedge.hubs.torchhub import TorchHubTracker +from wildedge.instrumentation import claim_instrumentation, has_other_live_owner from wildedge.integrations.anthropic import AnthropicExtractor from wildedge.integrations.base import BaseExtractor from wildedge.integrations.gguf import GgufExtractor @@ -482,6 +483,15 @@ def _init_noop(self, *, debug: bool, device: DeviceInfo | None) -> None: def publish(self, event_dict: dict) -> None: if self.closed or self.noop: + if ( + not self.noop + and event_dict.get("event_type") in ("inference", "error") + and not getattr(self, "_closed_publish_warned", False) + ): + self._closed_publish_warned = True + logger.warning( + "wildedge: event belongs to a closed SDK client; recreate models after initializing its replacement" + ) return if self.debug: @@ -707,7 +717,7 @@ def _activate_hub(self, hub_name: str) -> None: "wildedge: hub '%s' library not installed; skipping", hub_name ) return - tracker.install_patch(weakref.ref(self)) + tracker.install_patch(claim_instrumentation(self, "hub:" + hub_name)) self.hub_trackers[hub_name] = tracker if self.debug: logger.debug("wildedge: hub tracker '%s' activated", hub_name) @@ -814,6 +824,8 @@ def instrument( if self.debug: logger.debug("wildedge: instrument skipped (no DSN configured)") return + if self.closed: + raise RuntimeError("Cannot instrument with a closed WildEdge client") if integration is None: if not hubs: raise ValueError( @@ -859,7 +871,7 @@ def instrument( logger.debug(LOG_INSTRUMENT_TORCH_KERAS, integration) else: installer = self.PATCH_INSTALLERS[integration] - installer(weakref.ref(self)) + installer(claim_instrumentation(self, integration)) for hub_name in hubs or []: self._activate_hub(hub_name) @@ -1008,8 +1020,10 @@ def close(self, timeout: float | None = None) -> None: With a timeout, buffered attachments are uploaded in the time left after the event flush. Without one, they stay on disk for the next start. """ + if self.closed: + return self.closed = True - if not self.noop: + if not self.noop and not has_other_live_owner(self): stop_sampler() if self.attachment_uploader is not None: self.attachment_uploader.stop() diff --git a/wildedge/convenience.py b/wildedge/convenience.py index bfa2f1a..4086d8d 100644 --- a/wildedge/convenience.py +++ b/wildedge/convenience.py @@ -25,34 +25,44 @@ def init( """ Initialize the process default client and instrument integrations. - When a default client already exists (typically installed by ``wildedge + When a live default client already exists (typically installed by ``wildedge run`` before user code loaded) and no ``dsn`` is passed, that client is reused: the requested integrations are applied to it (instrumenting is idempotent per process) and it is returned. Passing ``dsn`` always - constructs a fresh client and makes it the new default. + constructs a fresh client and makes it the new default after instrumentation + succeeds. A closed default is replaced. Another live instrumentation owner + must be closed before enabling process-wide patches on a new client. Additional keyword arguments are forwarded to WildEdge(...) when a new client is constructed. """ client = None if "dsn" in kwargs else peek_default_client() + if client is not None and client.closed: + client = None if client is not None and kwargs: logger.warning( "wildedge: init() reusing the existing default client; ignoring %s", ", ".join(sorted(kwargs)), ) - if client is None: + created = client is None + if created: client = WildEdge(**kwargs) normalized_integrations = _normalize_list(integrations) normalized_hubs = _normalize_list(hubs) - if normalized_integrations: - for integration in normalized_integrations: - client.instrument(integration, hubs=normalized_hubs or None) - elif normalized_hubs: - client.instrument(None, hubs=normalized_hubs) - elif getattr(client, "debug", False): - logger.debug("wildedge: init called without integrations or hubs") + try: + if normalized_integrations: + for integration in normalized_integrations: + client.instrument(integration, hubs=normalized_hubs or None) + elif normalized_hubs: + client.instrument(None, hubs=normalized_hubs) + elif getattr(client, "debug", False): + logger.debug("wildedge: init called without integrations or hubs") + except Exception: + if created: + client.close() + raise set_default_client(client) return client diff --git a/wildedge/hubs/base.py b/wildedge/hubs/base.py index 1e41461..ca2ad73 100644 --- a/wildedge/hubs/base.py +++ b/wildedge/hubs/base.py @@ -45,9 +45,9 @@ def install_patch(self, client_ref: object) -> None: """ Monkey-patch the hub library's download or load functions. - Called at most once per process. ``client_ref`` is a ``weakref.ref`` - to the ``WildEdge`` client; dereference it before use and guard against - ``None`` (client may have been closed or garbage-collected). + Patches are installed at most once per process. ``client_ref`` is a + callable resolving the current enabled owner, or ``None`` when inactive. + Call it before publishing; activation may repeat for a replacement owner. """ @abstractmethod diff --git a/wildedge/hubs/huggingface.py b/wildedge/hubs/huggingface.py index 239ea69..9e72b37 100644 --- a/wildedge/hubs/huggingface.py +++ b/wildedge/hubs/huggingface.py @@ -15,6 +15,7 @@ import time from wildedge.hubs.base import BaseHubTracker +from wildedge.instrumentation import ClientRoute from wildedge.logging import logger from wildedge.timing import elapsed_ms @@ -28,9 +29,16 @@ _local = threading.local() _hf_hub_download_patched = False _snapshot_download_patched = False +_client_route: ClientRoute | None = None def _buffer() -> list[dict]: + if _client_route is not None: + client = _client_route() + session = client.session_id if client is not None else None + if getattr(_local, "owner_session", None) != session: + _local.records = [] + _local.owner_session = session if not hasattr(_local, "records"): _local.records = [] return _local.records @@ -44,7 +52,10 @@ def _record_download( cache_hit: bool, bandwidth_bps: int | None, ) -> None: - _buffer().append( + records = _buffer() + if _client_route is not None and _client_route() is None: + return + records.append( { "repo_id": repo_id, "size": size, @@ -246,12 +257,12 @@ def install_patch(self, client_ref: object) -> None: """ Patch ``hf_hub_download`` and ``snapshot_download`` for thread-local tracking. - ``client_ref`` is accepted for interface consistency but not used: the HF - hub tracker buffers records in a thread-local store and relies on the - client draining them via ``_drain_hub_trackers()`` in - ``_on_model_auto_loaded``. + Thread-local records are scoped to the enabled owner's session so a + replacement cannot drain provenance buffered for a previous project. """ - global _hf_hub_download_patched, _snapshot_download_patched + global _hf_hub_download_patched, _snapshot_download_patched, _client_route + if isinstance(client_ref, ClientRoute): + _client_route = client_ref if _hf_hub_download_patched and _snapshot_download_patched: return diff --git a/wildedge/instrumentation.py b/wildedge/instrumentation.py new file mode 100644 index 0000000..9b988b2 --- /dev/null +++ b/wildedge/instrumentation.py @@ -0,0 +1,93 @@ +"""Ownership of process-wide instrumentation, independent of the default client.""" + +from __future__ import annotations + +import os +import threading +import weakref +from typing import TYPE_CHECKING + +from wildedge.logging import logger + +if TYPE_CHECKING: + from wildedge.client import WildEdge + +_lock = threading.Lock() +_owner: weakref.ReferenceType[WildEdge] | None = None +_enabled: set[str] = set() + + +class ClientRoute: + """Stable callable captured by a factory patch, resolving its current owner.""" + + def __init__(self, integration: str) -> None: + self.integration = integration + + def __call__(self) -> WildEdge | None: + with _lock: + client = _owner() if _owner is not None else None + if client is None or client.closed or self.integration not in _enabled: + return None + return client + + +class BoundClientReference: + """Existing provider clients stay with their original telemetry destination.""" + + def __init__(self, client: WildEdge) -> None: + self._ref = weakref.ref(client) + self._warned = False + + def __call__(self) -> WildEdge | None: + client = self._ref() + if client is None or client.closed: + if not self._warned: + logger.warning( + "wildedge: provider client belongs to an unavailable SDK client; " + "recreate it after initializing the replacement SDK client" + ) + self._warned = True + return None + return client + + +def bind_client_reference(reference: object, client: WildEdge) -> object: + """Snapshot a shared factory route when creating an individual provider client.""" + return ( + BoundClientReference(client) + if isinstance(reference, ClientRoute) + else reference + ) + + +def claim_instrumentation(client: WildEdge, integration: str) -> ClientRoute: + global _owner + with _lock: + if client.closed: + raise RuntimeError("Cannot instrument with a closed WildEdge client") + owner = _owner() if _owner is not None else None + if owner is not None and not owner.closed and owner is not client: + raise RuntimeError( + "Auto-instrumentation is owned by another live WildEdge client; " + "close that client before enabling instrumentation on its replacement" + ) + if owner is not client: + _enabled.clear() + _owner = weakref.ref(client) + _enabled.add(integration) + return ClientRoute(integration) + + +def has_other_live_owner(client: WildEdge) -> bool: + with _lock: + owner = _owner() if _owner is not None else None + return owner is not None and not owner.closed and owner is not client + + +def _reset_lock_after_fork() -> None: + global _lock + _lock = threading.Lock() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reset_lock_after_fork) diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index c0bed75..89682f9 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any from wildedge.events.inference import ApiMeta, GenerationOutputMeta, TextInputMeta +from wildedge.instrumentation import bind_client_reference from wildedge.integrations.base import BaseExtractor from wildedge.integrations.common import ( AsyncStreamWrapper, @@ -470,8 +471,9 @@ def patched_sync_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-d original_sync_init(self_inner, *args, **kwargs) c = client_ref() # type: ignore[call-arg] if c is not None and not c.closed: + bound_ref = bind_client_reference(client_ref, c) try: - wrap_sync_messages(self_inner.messages, client_ref) + wrap_sync_messages(self_inner.messages, bound_ref) except Exception as exc: debug_anthropic_failure("sync client wrap", exc) @@ -479,8 +481,9 @@ def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped- original_async_init(self_inner, *args, **kwargs) c = client_ref() # type: ignore[call-arg] if c is not None and not c.closed: + bound_ref = bind_client_reference(client_ref, c) try: - wrap_async_messages(self_inner.messages, client_ref) + wrap_async_messages(self_inner.messages, bound_ref) except Exception as exc: debug_anthropic_failure("async client wrap", exc) diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index d295148..a95b97b 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -14,6 +14,7 @@ GenerationOutputMeta, TextInputMeta, ) +from wildedge.instrumentation import bind_client_reference from wildedge.integrations.base import BaseExtractor from wildedge.integrations.common import ( SOURCE_BY_HOSTNAME, # noqa: F401 (re-exported; moved to common) @@ -597,26 +598,25 @@ def patched_sync_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-d original_sync_init(self_inner, *args, **kwargs) c = client_ref() # type: ignore[call-arg] if c is not None and not c.closed: + bound_ref = bind_client_reference(client_ref, c) source = source_from_base_url( str(getattr(self_inner, "base_url", None) or "") ) try: wrap_sync_completions( - self_inner.chat.completions, source, client_ref + self_inner.chat.completions, source, bound_ref ) except Exception as exc: debug_openai_failure("sync client wrap", exc) if hasattr(self_inner, "responses"): try: - wrap_sync_responses( - self_inner.responses, source, client_ref - ) + wrap_sync_responses(self_inner.responses, source, bound_ref) except Exception as exc: debug_openai_failure("sync responses wrap", exc) if hasattr(self_inner, "embeddings"): try: wrap_sync_embeddings( - self_inner.embeddings, source, client_ref + self_inner.embeddings, source, bound_ref ) except Exception as exc: debug_openai_failure("sync embeddings wrap", exc) @@ -625,26 +625,27 @@ def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped- original_async_init(self_inner, *args, **kwargs) c = client_ref() # type: ignore[call-arg] if c is not None and not c.closed: + bound_ref = bind_client_reference(client_ref, c) source = source_from_base_url( str(getattr(self_inner, "base_url", None) or "") ) try: wrap_async_completions( - self_inner.chat.completions, source, client_ref + self_inner.chat.completions, source, bound_ref ) except Exception as exc: debug_openai_failure("async client wrap", exc) if hasattr(self_inner, "responses"): try: wrap_async_responses( - self_inner.responses, source, client_ref + self_inner.responses, source, bound_ref ) except Exception as exc: debug_openai_failure("async responses wrap", exc) if hasattr(self_inner, "embeddings"): try: wrap_async_embeddings( - self_inner.embeddings, source, client_ref + self_inner.embeddings, source, bound_ref ) except Exception as exc: debug_openai_failure("async embeddings wrap", exc) From db42ed638234cd62a5922eb1844d531ed5887a1c Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Tue, 20 Oct 2026 20:03:57 +0200 Subject: [PATCH 39/81] Add local Whisper transcription instrumentation --- .github/workflows/compat.yml | 6 +- README.md | 1 + docs/compatibility.md | 2 + docs/whisper.md | 35 +++ examples/whisper_example.py | 48 +++++ scripts/compat_matrix.py | 7 + tests/compat/test_faster_whisper_compat.py | 31 +++ tests/compat/test_mlx_whisper_compat.py | 49 +++++ tests/test_whisper.py | 237 +++++++++++++++++++++ wildedge/client.py | 12 ++ wildedge/integrations/faster_whisper.py | 125 +++++++++++ wildedge/integrations/mlx_whisper.py | 110 ++++++++++ wildedge/integrations/registry.py | 2 + wildedge/integrations/whisper.py | 186 ++++++++++++++++ 14 files changed, 849 insertions(+), 2 deletions(-) create mode 100644 docs/whisper.md create mode 100644 examples/whisper_example.py create mode 100644 tests/compat/test_faster_whisper_compat.py create mode 100644 tests/compat/test_mlx_whisper_compat.py create mode 100644 tests/test_whisper.py create mode 100644 wildedge/integrations/faster_whisper.py create mode 100644 wildedge/integrations/mlx_whisper.py create mode 100644 wildedge/integrations/whisper.py diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 4921e49..1e71865 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -71,10 +71,12 @@ jobs: --version-set "${{ matrix.version-set }}" \ --python-version "${{ matrix.python-version }}" - name: Cache downloaded test models - if: matrix.integration == 'gguf' + if: matrix.integration == 'gguf' || matrix.integration == 'faster_whisper' uses: actions/cache@v4 with: - path: tests/compat/.cache + path: | + tests/compat/.cache + ~/.cache/huggingface/hub key: compat-models-${{ matrix.integration }}-${{ hashFiles(format('tests/compat/test_{0}_compat.py', matrix.integration)) }} - name: Run compatibility tests run: python -m pytest "tests/compat/test_${{ matrix.integration }}_compat.py" -q -rxX diff --git a/README.md b/README.md index 81ac433..bf3528e 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ for client lifecycle and the full contract. | Integration | Example | |---|---| | `transformers` | [transformers_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/transformers_example.py) | +| `mlx_whisper` / `faster_whisper` | [whisper_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/whisper_example.py) | | `mlx` | [mlx_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/mlx_example.py) | | `timm` | [timm_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/timm_example.py) | | `gguf` | [gguf_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/gguf_example.py) | diff --git a/docs/compatibility.md b/docs/compatibility.md index d75350e..7e7f92c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -38,6 +38,8 @@ To run the full matrix locally: `python scripts/run_compat_local.py`. | Integration | Version set | Dependencies | Supported Python | |---|---|---|---| +| `faster_whisper` | `current` | `faster-whisper==1.2.1`, `ctranslate2==4.8.2`, `numpy==2.2.6` | 3.12, 3.13 | +| `mlx_whisper` | `current` | `mlx-whisper==0.4.3`, `mlx==0.32.3`, `numpy==2.2.6` | 3.12, 3.13 | | `anthropic` | `min` | `anthropic==0.25.0` | 3.10, 3.11, 3.12, 3.13 | | `anthropic` | `current` | `anthropic==1.15.0` | 3.10, 3.11, 3.12, 3.13 | | `onnx` | `min` | `onnxruntime==1.18.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13, 3.14 | diff --git a/docs/whisper.md b/docs/whisper.md new file mode 100644 index 0000000..391a375 --- /dev/null +++ b/docs/whisper.md @@ -0,0 +1,35 @@ +# Local Whisper speech recognition + +Enable `mlx_whisper` on Apple Silicon or `faster_whisper` for CTranslate2 before loading the model or importing a transcription function by name: + +```python +import wildedge + +client = wildedge.init(integrations=["mlx_whisper"], attachments_enabled=False) +import mlx_whisper + +result = mlx_whisper.transcribe("speech.wav", path_or_hf_repo="/models/whisper") +``` + +For faster-whisper, consume the returned segments to finish the inference: + +```python +client = wildedge.init(integrations=["faster_whisper"], attachments_enabled=False) +from faster_whisper import WhisperModel + +model = WhisperModel("/models/whisper", device="cpu", compute_type="int8") +segments, info = model.transcribe("speech.wav") +text = "".join(segment.text for segment in segments) +client.flush() +client.close() +``` + +The SDK records model load time, backend-specific model identity, format (`mlx` or `ctranslate2`), family (`whisper`), accelerator and compute type/quantization. MLX's internal model cache produces one load per SDK owner; an already cached model is reported with zero load time and `model_cache_hit=true`. Warm-up transcriptions are tracked too. + +Transcriptions include wall-clock duration, audio metadata, `transcript_word_count` and detected `language` attributes. `language_confidence` is included when faster-whisper supplies it; MLX does not expose it. WAV paths provide original duration, sample rate, channels and bit depth. One-dimensional arrays are treated as 16 kHz mono, as required by these APIs. Other file formats retain the duration supplied by faster-whisper when available; the SDK does not decode files a second time to inspect them. These APIs do not supply text token usage or token-level TTFT, so the SDK does not invent those values. + +faster-whisper timing runs from `transcribe()` through segment exhaustion, including time between iterator pulls. Keep consuming within the trace/span that should own the operation. Explicit `segments.close()` emits one unsuccessful inference with `STREAM_CLOSED` and the word count consumed so far. Decoder failures emit one error event and propagate unchanged. Merely abandoning an iterator does not imply completion; explicitly close it when stopping early. Existing faster-whisper model objects stay bound to their original SDK client after replacement; recreate them for a new client. + +Audio and transcript attachments require `attachments_enabled=True`. Capture is off by default. The adapter caps retained transcript/audio attachment size at 10 MiB, and the configured attachment manager may apply stricter limits or a filter. No transcript text enters scalar event attributes. Use an `attachment_filter` when an application allows transcript capture but forbids audio upload. Raw audio file attachments must still exist when the transcription finishes. + +See [the runnable example](../examples/whisper_example.py). Compatibility CI tests real decoding with random-weight MLX Whisper and the pinned public faster-whisper tiny model. Offline faster-whisper tests can use `WILDEDGE_FASTER_WHISPER_MODEL=/path/to/model`. diff --git a/examples/whisper_example.py b/examples/whisper_example.py new file mode 100644 index 0000000..9e298ae --- /dev/null +++ b/examples/whisper_example.py @@ -0,0 +1,48 @@ +"""Transcribe a WAV using a local Whisper model with automatic instrumentation. + +Install mlx-whisper (Apple Silicon) or faster-whisper, set WILDEDGE_DSN, then: + python examples/whisper_example.py mlx_whisper /path/to/model /path/to/audio.wav + python examples/whisper_example.py faster_whisper /path/to/model /path/to/audio.wav + +See docs/whisper.md for lifecycle and opt-in attachment capture behavior. +""" + +import argparse + +import wildedge + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("backend", choices=["mlx_whisper", "faster_whisper"]) + parser.add_argument("model") + parser.add_argument("audio") + parser.add_argument( + "--capture", action="store_true", help="Attach audio and transcript" + ) + args = parser.parse_args() + client = wildedge.init( + integrations=[args.backend], attachments_enabled=args.capture + ) + try: + with client.trace(), client.span(name="transcribe"): + if args.backend == "mlx_whisper": + import mlx_whisper # noqa: PLC0415 -- optional runtime + + result = mlx_whisper.transcribe(args.audio, path_or_hf_repo=args.model) + print(result["text"]) + else: + from faster_whisper import ( # noqa: PLC0415 -- optional runtime + WhisperModel, + ) + + model = WhisperModel(args.model, device="cpu", compute_type="int8") + segments, _info = model.transcribe(args.audio) + print("".join(segment.text for segment in segments)) + client.flush() + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 03b618d..7c72d5b 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -9,6 +9,10 @@ from importlib import metadata MATRIX = { + "faster_whisper": { + "current": ["faster-whisper==1.2.1", "ctranslate2==4.8.2", "numpy==2.2.6"] + }, + "mlx_whisper": {"current": ["mlx-whisper==0.4.3", "mlx==0.32.3", "numpy==2.2.6"]}, "anthropic": { "min": ["anthropic==0.25.0"], "current": ["anthropic==1.15.0"], @@ -61,6 +65,8 @@ } SUPPORTED_PYTHON = { + "faster_whisper": ["3.12", "3.13"], + "mlx_whisper": ["3.12", "3.13"], "anthropic": ["3.10", "3.11", "3.12", "3.13"], "onnx": ["3.10", "3.11", "3.12", "3.13", "3.14"], "torch": ["3.10", "3.11", "3.12", "3.13", "3.14"], @@ -80,6 +86,7 @@ # Integrations that need a specific runner; everything else runs on Linux. RUNNER = { + "mlx_whisper": "macos-14", "mlx": "macos-14", } diff --git a/tests/compat/test_faster_whisper_compat.py b/tests/compat/test_faster_whisper_compat.py new file mode 100644 index 0000000..aeda487 --- /dev/null +++ b/tests/compat/test_faster_whisper_compat.py @@ -0,0 +1,31 @@ +"""Real CTranslate2 model load and lazy transcription using Whisper tiny.""" + +import os +from pathlib import Path + +import faster_whisper +import numpy as np +from huggingface_hub import snapshot_download + + +def test_load_and_lazy_transcribe(compat_client): + # A local model can be supplied for offline development. CI caches HF downloads. + path = os.environ.get("WILDEDGE_FASTER_WHISPER_MODEL") or snapshot_download( + "Systran/faster-whisper-tiny", + revision="d90ca5fe260221311c53c58e660288d3deb8d356", + allow_patterns=["config.json", "model.bin", "tokenizer.json", "vocabulary.*"], + ) + assert Path(path).is_dir() + compat_client.instrument("faster_whisper") + model = faster_whisper.WhisperModel(path, device="cpu", compute_type="int8") + load = compat_client.one("model_load") + segments, info = model.transcribe( + np.zeros(16000, dtype=np.float32), language="en", beam_size=1 + ) + assert not compat_client.of_type("inference") + text = " ".join(segment.text for segment in segments) + event = compat_client.one("inference") + assert event["model_id"] == load["model_id"] + assert event["attributes"]["transcript_word_count"] == len(text.split()) + assert event["attributes"]["language_confidence"] == info.language_probability + assert event["inference"]["input_meta"]["duration_ms"] == 1000 diff --git a/tests/compat/test_mlx_whisper_compat.py b/tests/compat/test_mlx_whisper_compat.py new file mode 100644 index 0000000..386a05b --- /dev/null +++ b/tests/compat/test_mlx_whisper_compat.py @@ -0,0 +1,49 @@ +"""Real MLX Whisper loader and decoding, with locally built random weights.""" + +import importlib +import json +from dataclasses import asdict + +import mlx.core as mx +import mlx_whisper +import numpy as np +from mlx.utils import tree_flatten +from mlx_whisper.whisper import ModelDimensions, Whisper + + +def test_load_and_transcribe(compat_client, tmp_path): + dimensions = ModelDimensions( + n_mels=80, + n_audio_ctx=1500, + n_audio_state=8, + n_audio_head=2, + n_audio_layer=1, + n_vocab=51865, + n_text_ctx=448, + n_text_state=8, + n_text_head=2, + n_text_layer=1, + ) + mx.random.seed(0) + model = Whisper(dimensions, mx.float32) + (tmp_path / "config.json").write_text(json.dumps(asdict(dimensions))) + mx.save_safetensors( + str(tmp_path / "weights.safetensors"), dict(tree_flatten(model.parameters())) + ) + importlib.import_module("mlx_whisper.transcribe").ModelHolder.model = None + compat_client.instrument("mlx_whisper") + result = mlx_whisper.transcribe( + np.zeros(16000, dtype=np.float32), + path_or_hf_repo=str(tmp_path), + language="en", + fp16=False, + sample_len=2, + temperature=0, + condition_on_previous_text=False, + ) + load = compat_client.one("model_load") + event = compat_client.one("inference") + assert event["model_id"] == load["model_id"] + assert event["attributes"]["transcript_word_count"] == len(result["text"].split()) + assert event["inference"]["input_meta"]["duration_ms"] == 1000 + assert event["inference"]["input_meta"]["sample_rate"] == 16000 diff --git a/tests/test_whisper.py b/tests/test_whisper.py new file mode 100644 index 0000000..ce2774a --- /dev/null +++ b/tests/test_whisper.py @@ -0,0 +1,237 @@ +"""Whisper adapters preserve results, lazy lifecycles, and capture privacy.""" + +import sys +import wave +from types import ModuleType, SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from wildedge.instrumentation import claim_instrumentation +from wildedge.integrations.faster_whisper import ( + install_auto_load_patch, + wrap_transcribe, +) +from wildedge.integrations.mlx_whisper import install_auto_load_patch as install_mlx +from wildedge.integrations.whisper import ( + DESCRIPTOR_ATTR, + HANDLE_ATTR, + WhisperDescriptor, + WhisperExtractor, + audio_meta, + track_transcription, +) + + +@pytest.fixture +def handle(): + return SimpleNamespace( + capture_attachments=None, track_inference=MagicMock(), track_error=MagicMock() + ) + + +def test_metadata_and_no_default_capture(tmp_path, handle): + path = tmp_path / "input.wav" + with wave.open(str(path), "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(16000) + audio.writeframes(b"\0\0" * 8000) + track_transcription(handle, path, "hello world", "en", 123, probability=0.9) + event = handle.track_inference.call_args.kwargs + assert event["input_meta"].to_dict() == { + "duration_ms": 500, + "sample_rate": 16000, + "channels": 1, + "bit_depth": 16, + "format": "wav", + } + assert event["context"] == { + "transcript_word_count": 2, + "language": "en", + "language_confidence": 0.9, + } + assert event["attachments"] is None + handle.capture_attachments = MagicMock() + track_transcription(handle, path, "hello world", "en", 123) + attachments = handle.track_inference.call_args.kwargs["attachments"] + assert [a.role for a in attachments] == ["input", "output"] + assert attachments[1].data == b"hello world" + + +def test_unknown_audio_is_not_consumed(): + source = MagicMock() + assert audio_meta(source).to_dict() == {} + source.read.assert_not_called() + + +def test_extractor(tmp_path): + model = SimpleNamespace() + setattr( + model, + DESCRIPTOR_ATTR, + WhisperDescriptor(str(tmp_path), "faster_whisper", "cpu", "int8"), + ) + extractor = WhisperExtractor() + ident, info = extractor.extract_info(model, {}) + assert ident == f"faster_whisper:{tmp_path.name}" + assert info.model_format == "ctranslate2" + assert info.model_family == "whisper" + assert info.quantization == "int8" + + +@pytest.mark.parametrize("outcome", ["completed", "closed", "error"]) +def test_lazy_segments(handle, outcome): + segment = SimpleNamespace(text="hello world") + info = SimpleNamespace(language="en", language_probability=0.8, duration=2) + + def segments(): + yield segment + if outcome == "error": + raise ValueError("decoder failure") + + model = SimpleNamespace() + setattr(model, HANDLE_ATTR, handle) + call = wrap_transcribe(lambda *_: (segments(), info)) + stream, returned_info = call(model, "audio.wav") + assert returned_info is info + handle.track_inference.assert_not_called() + assert next(stream) is segment + if outcome == "closed": + stream.close() + stream.close() + elif outcome == "error": + with pytest.raises(ValueError, match="decoder failure"): + next(stream) + handle.track_error.assert_called_once() + handle.track_inference.assert_not_called() + return + else: + assert list(stream) == [] + handle.track_inference.assert_called_once() + event = handle.track_inference.call_args.kwargs + assert event["success"] == (outcome == "completed") + assert event["context"]["transcript_word_count"] == 2 + assert event["input_meta"].duration_ms == 2000 + + +def test_faster_constructor_routes_once(monkeypatch): + module = ModuleType("faster_whisper") + + class Model: + def __init__(self, model_size_or_path, device="cpu", compute_type="int8"): + self.model = SimpleNamespace(device=device, compute_type=compute_type) + + def transcribe(self, audio): + return iter([]), SimpleNamespace() + + module.WhisperModel = Model + monkeypatch.setitem(sys.modules, "faster_whisper", module) + client = SimpleNamespace( + closed=False, _on_model_auto_loaded=MagicMock(), register_model=MagicMock() + ) + install_auto_load_patch(lambda: client) + install_auto_load_patch(lambda: client) + model = Model("small", device="cuda", compute_type="float16") + client._on_model_auto_loaded.assert_called_once() + assert getattr(model, DESCRIPTOR_ATTR).accelerator == "gpu" + assert getattr(model, DESCRIPTOR_ATTR).quantization == "float16" + + +def test_mlx_result_identity_and_cached_load(monkeypatch, handle): + root, module, loaders = ( + ModuleType(n) + for n in ("mlx_whisper", "mlx_whisper.transcribe", "mlx_whisper.load_models") + ) + result = {"text": "hello world", "language": "en"} + module.ModelHolder = SimpleNamespace(model=SimpleNamespace()) + module.transcribe = lambda *a, **kw: result + module.load_model = loaders.load_model = lambda *a, **kw: SimpleNamespace() + for mod in (root, module, loaders): + monkeypatch.setitem(sys.modules, mod.__name__, mod) + client = SimpleNamespace( + closed=False, + auto_loaded=set(), + register_model=MagicMock(return_value=handle), + _on_model_auto_loaded=MagicMock(), + ) + handle.model_id = "mlx_whisper:test" + install_mlx(lambda: client) + assert root.transcribe("audio.wav", path_or_hf_repo="test") is result + client._on_model_auto_loaded.assert_called_once() + assert client._on_model_auto_loaded.call_args.kwargs["load_kwargs"]["context"][ + "model_cache_hit" + ] + handle.track_inference.assert_called_once() + + +def test_transcribe_errors_preserve_exception(handle): + failure = ValueError("invalid audio") + + def original(*_args): + raise failure + + model = SimpleNamespace() + setattr(model, HANDLE_ATTR, handle) + handle.track_error.side_effect = RuntimeError("telemetry failed") + with pytest.raises(ValueError, match="invalid audio") as raised: + wrap_transcribe(original)(model, "missing.wav") + assert raised.value is failure + handle.track_inference.assert_not_called() + + +def test_lazy_telemetry_failure_does_not_break_iterator(handle): + handle.track_inference.side_effect = RuntimeError("telemetry failed") + model = SimpleNamespace() + setattr(model, HANDLE_ATTR, handle) + segment = SimpleNamespace(text="hello") + stream, _ = wrap_transcribe(lambda *_: (iter([segment]), SimpleNamespace()))( + model, "audio.wav" + ) + assert list(stream) == [segment] + assert list(stream) == [] + handle.track_inference.assert_called_once() + + +def test_mlx_load_quantization_cache_and_closed_owner( + monkeypatch, client_with_stubbed_runtime, handle +): + root, module, loaders = ( + ModuleType(n) + for n in ("mlx_whisper", "mlx_whisper.transcribe", "mlx_whisper.load_models") + ) + model = SimpleNamespace(named_modules=lambda: [("", SimpleNamespace(bits=4))]) + module.ModelHolder = SimpleNamespace(model=None) + module.load_model = loaders.load_model = lambda *_a, **_kw: model + + def original(_audio, **kwargs): + if module.ModelHolder.model is None: + module.ModelHolder.model = module.load_model(kwargs["path_or_hf_repo"]) + return {"text": "hello world", "language": "en"} + + module.transcribe = original + for mod in (root, module, loaders): + monkeypatch.setitem(sys.modules, mod.__name__, mod) + client = client_with_stubbed_runtime + events = [] + client.publish = events.append + install_mlx(claim_instrumentation(client, "mlx_whisper")) + root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") + root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") + assert len([e for e in events if e["event_type"] == "model_load"]) == 1 + assert len([e for e in events if e["event_type"] == "inference"]) == 2 + assert client.registry.models["mlx_whisper:org/model-q4"].quantization == "q4" + client.close() + root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") + assert len([e for e in events if e["event_type"] == "inference"]) == 2 + replacement = MagicMock(closed=False, auto_loaded=set()) + handle.model_id = "mlx_whisper:org/model-q4" + replacement.register_model.return_value = handle + claim_instrumentation(replacement, "mlx_whisper") + root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") + replacement._on_model_auto_loaded.assert_called_once() + assert replacement._on_model_auto_loaded.call_args.kwargs["load_kwargs"]["context"][ + "model_cache_hit" + ] + handle.track_inference.assert_called_once() + assert len([e for e in events if e["event_type"] == "inference"]) == 2 diff --git a/wildedge/client.py b/wildedge/client.py index 1a5acb2..86ce4fe 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -28,9 +28,15 @@ from wildedge.instrumentation import claim_instrumentation, has_other_live_owner from wildedge.integrations.anthropic import AnthropicExtractor from wildedge.integrations.base import BaseExtractor +from wildedge.integrations.faster_whisper import ( + install_auto_load_patch as install_faster_whisper, +) from wildedge.integrations.gguf import GgufExtractor from wildedge.integrations.keras import KerasExtractor from wildedge.integrations.mlx import MlxExtractor +from wildedge.integrations.mlx_whisper import ( + install_auto_load_patch as install_mlx_whisper, +) from wildedge.integrations.onnx import OnnxExtractor from wildedge.integrations.openai import OpenAIExtractor from wildedge.integrations.pytorch import PytorchExtractor @@ -38,6 +44,7 @@ from wildedge.integrations.tensorflow import TensorflowExtractor from wildedge.integrations.transformers import TransformersExtractor from wildedge.integrations.ultralytics import UltralyticsExtractor +from wildedge.integrations.whisper import WhisperExtractor from wildedge.logging import enable_debug, logger from wildedge.model import ModelHandle, ModelInfo, ModelRegistry from wildedge.paths import ( @@ -120,6 +127,7 @@ def parse_dsn(dsn: str) -> tuple[str, str, str]: AnthropicExtractor(), UltralyticsExtractor(), TransformersExtractor(), + WhisperExtractor(), MlxExtractor(), PytorchExtractor(), TensorflowExtractor(), @@ -253,6 +261,8 @@ class WildEdge: "anthropic": AnthropicExtractor.install_auto_load_patch, "gguf": GgufExtractor.install_auto_load_patch, "mlx": MlxExtractor.install_auto_load_patch, + "mlx_whisper": install_mlx_whisper, + "faster_whisper": install_faster_whisper, "onnx": OnnxExtractor.install_auto_load_patch, "openai": OpenAIExtractor.install_auto_load_patch, "timm": PytorchExtractor.install_timm_patch, @@ -787,6 +797,8 @@ def instrument( ``"onnx"`` Replaces ``ort.InferenceSession`` with a timed subclass. Requires ``onnxruntime``. + ``"mlx_whisper"`` / ``"faster_whisper"`` + Tracks local speech recognition, including lazy segment consumption. ``"timm"`` Patches ``timm.create_model``. Requires ``timm``. ``"tensorflow"`` diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py new file mode 100644 index 0000000..19120f0 --- /dev/null +++ b/wildedge/integrations/faster_whisper.py @@ -0,0 +1,125 @@ +"""Opt-in instrumentation for faster-whisper's lazy transcription API.""" + +from __future__ import annotations + +import functools +import inspect +import threading +import time + +from wildedge.integrations.common import ( + SyncStreamWrapper, + debug_failure, + track_api_error, +) +from wildedge.integrations.whisper import ( + DESCRIPTOR_ATTR, + HANDLE_ATTR, + WhisperDescriptor, + track_transcription, +) +from wildedge.timing import elapsed_ms + + +def wrap_transcribe(original): + @functools.wraps(original) + def transcribe(self, audio, *args, **kwargs): + handle = getattr(self, HANDLE_ATTR, None) + if handle is None: + return original(self, audio, *args, **kwargs) + t0 = time.perf_counter() + try: + segments, info = original(self, audio, *args, **kwargs) + except Exception as exc: + try: + track_api_error(handle, exc, elapsed_ms(t0)) + except Exception as telemetry_exc: + debug_failure("faster_whisper", "error tracking", telemetry_exc) + raise + words = 0 + parts = [] + captured_bytes = 0 + + def chunk(segment): + nonlocal words, captured_bytes + text = segment.text + words += len(text.split()) + if ( + handle.capture_attachments is not None + and captured_bytes <= 10 * 1024 * 1024 + ): + captured_bytes += len(text.encode()) + if captured_bytes <= 10 * 1024 * 1024: + parts.append(text) + else: + parts.clear() + + def done(duration_ms, _ttft_ms, *, outcome="completed"): + track_transcription( + handle, + audio, + "".join(parts), + getattr(info, "language", None), + duration_ms, + probability=getattr(info, "language_probability", None), + duration=getattr(info, "duration", None), + words=words, + outcome=outcome, + ) + + return SyncStreamWrapper(segments, handle, t0, chunk, done), info + + return transcribe + + +_PATCH_LOCK = threading.Lock() + + +def install_auto_load_patch(client_ref): + with _PATCH_LOCK: + _install(client_ref) + + +def _install(client_ref): + import faster_whisper # noqa: PLC0415 -- optional runtime + + cls = faster_whisper.WhisperModel + original = cls.__init__ + if getattr(original, "__wildedge_patch_name__", None) == "faster_whisper": + return + signature = inspect.signature(original) + + @functools.wraps(original) + def init(self, *args, **kwargs): + client = client_ref() + t0 = time.perf_counter() + original(self, *args, **kwargs) + if client is None or client.closed: + return + try: + bound = signature.bind(self, *args, **kwargs) + bound.apply_defaults() + path = bound.arguments["model_size_or_path"] + runtime = self.model + device = getattr(runtime, "device", bound.arguments.get("device", "cpu")) + compute = getattr( + runtime, "compute_type", bound.arguments.get("compute_type") + ) + setattr( + self, + DESCRIPTOR_ATTR, + WhisperDescriptor( + str(path), + "faster_whisper", + "gpu" if device == "cuda" else "cpu", + compute, + ), + ) + client._on_model_auto_loaded(self, load_ms=elapsed_ms(t0)) + setattr(self, HANDLE_ATTR, client.register_model(self)) + except Exception as exc: + debug_failure("faster_whisper", "model registration", exc) + + init.__wildedge_patch_name__ = "faster_whisper" + cls.__init__ = init + cls.transcribe = wrap_transcribe(cls.transcribe) diff --git a/wildedge/integrations/mlx_whisper.py b/wildedge/integrations/mlx_whisper.py new file mode 100644 index 0000000..9ffc475 --- /dev/null +++ b/wildedge/integrations/mlx_whisper.py @@ -0,0 +1,110 @@ +"""Opt-in instrumentation for mlx-whisper's function API and model cache.""" + +from __future__ import annotations + +import contextvars +import functools +import importlib +import threading +import time + +from wildedge.integrations.common import debug_failure, track_api_error +from wildedge.integrations.whisper import ( + DESCRIPTOR_ATTR, + descriptor, + mlx_descriptor, + track_transcription, +) +from wildedge.timing import elapsed_ms + +_PATCH_LOCK = threading.Lock() + + +def install_auto_load_patch(client_ref): + with _PATCH_LOCK: + _install(client_ref) + + +def _install(client_ref): + root = importlib.import_module("mlx_whisper") + module = importlib.import_module("mlx_whisper.transcribe") + loaders = importlib.import_module("mlx_whisper.load_models") + original = module.transcribe + if getattr(original, "__wildedge_patch_name__", None) == "mlx_whisper": + return + original_load = module.load_model + active_client = contextvars.ContextVar("wildedge_mlx_whisper_client", default=None) + + @functools.wraps(original_load) + def load(path_or_hf_repo, *args, **kwargs): + client = active_client.get() or client_ref() + t0 = time.perf_counter() + model = original_load(path_or_hf_repo, *args, **kwargs) + try: + dtype = kwargs.get("dtype", args[0] if args else "float32") + info = mlx_descriptor(path_or_hf_repo, dtype) + for _, layer in model.named_modules(): + bits = getattr(layer, "bits", None) + if isinstance(bits, int): + info.quantization = f"q{bits}" + break + setattr(model, DESCRIPTOR_ATTR, info) + if client is not None and not client.closed: + client._on_model_auto_loaded(model, load_ms=elapsed_ms(t0)) + except Exception as exc: + debug_failure("mlx_whisper", "model registration", exc) + return model + + @functools.wraps(original) + def transcribe(audio, *args, **kwargs): + client = client_ref() + if client is None: + return original(audio, *args, **kwargs) + t0 = time.perf_counter() + token = active_client.set(client) + try: + result = original(audio, *args, **kwargs) + except Exception as exc: + try: + info = mlx_descriptor( + kwargs.get("path_or_hf_repo", "mlx-community/whisper-tiny"), + "float16" if kwargs.get("fp16", True) else "float32", + ) + track_api_error(client.register_model(info), exc, elapsed_ms(t0)) + except Exception as telemetry_exc: + debug_failure("mlx_whisper", "error tracking", telemetry_exc) + raise + finally: + active_client.reset(token) + if not client.closed: + try: + model = module.ModelHolder.model + info = descriptor(model) or mlx_descriptor( + kwargs.get("path_or_hf_repo", "mlx-community/whisper-tiny"), + "float16" if kwargs.get("fp16", True) else "float32", + ) + if model is not None: + setattr(model, DESCRIPTOR_ATTR, info) + if info.model_id not in client.auto_loaded: + client._on_model_auto_loaded( + model, + load_ms=0, + load_kwargs={"context": {"model_cache_hit": True}}, + ) + handle = client.register_model(model if model is not None else info) + track_transcription( + handle, + audio, + result.get("text", ""), + result.get("language"), + elapsed_ms(t0), + ) + except Exception as exc: + debug_failure("mlx_whisper", "transcription tracking", exc) + return result + + transcribe.__wildedge_patch_name__ = "mlx_whisper" + module.load_model = load + loaders.load_model = load + module.transcribe = transcribe + root.transcribe = transcribe diff --git a/wildedge/integrations/registry.py b/wildedge/integrations/registry.py index 8321720..df1279e 100644 --- a/wildedge/integrations/registry.py +++ b/wildedge/integrations/registry.py @@ -33,6 +33,8 @@ class IntegrationSpec: IntegrationSpec("tensorflow", ("tensorflow",), "client_patch"), IntegrationSpec("ultralytics", ("ultralytics",), "client_patch"), IntegrationSpec("transformers", ("transformers",), "client_patch"), + IntegrationSpec("faster_whisper", ("faster_whisper",), "client_patch"), + IntegrationSpec("mlx_whisper", ("mlx_whisper",), "client_patch"), IntegrationSpec("mlx", ("mlx_lm",), "client_patch"), ) diff --git a/wildedge/integrations/whisper.py b/wildedge/integrations/whisper.py new file mode 100644 index 0000000..4717312 --- /dev/null +++ b/wildedge/integrations/whisper.py @@ -0,0 +1,186 @@ +"""Shared metadata and opt-in capture for local Whisper runtimes.""" + +from __future__ import annotations + +import io +import json +import mimetypes +import wave +from dataclasses import dataclass +from pathlib import Path + +from wildedge.attachments import Attachment +from wildedge.events.inference import AudioInputMeta +from wildedge.integrations.base import BaseExtractor +from wildedge.integrations.common import debug_failure +from wildedge.model import ModelHandle, ModelInfo + +DESCRIPTOR_ATTR = "__wildedge_whisper_descriptor__" +HANDLE_ATTR = "__wildedge_whisper_handle__" +SAMPLE_RATE = 16000 + + +@dataclass +class WhisperDescriptor: + path: str + backend: str + accelerator: str + quantization: str | None = None + + @property + def name(self) -> str: + return Path(self.path).name if Path(self.path).exists() else self.path + + @property + def model_id(self) -> str: + return f"{self.backend}:{self.name}" + + +def descriptor(obj: object) -> WhisperDescriptor | None: + return ( + obj + if isinstance(obj, WhisperDescriptor) + else getattr(obj, DESCRIPTOR_ATTR, None) + ) + + +class WhisperExtractor(BaseExtractor): + def can_handle(self, obj: object) -> bool: + return isinstance(descriptor(obj), WhisperDescriptor) + + def extract_info(self, obj: object, overrides: dict) -> tuple[str, ModelInfo]: + info = descriptor(obj) + assert info is not None + return overrides.pop("id", None) or info.model_id, ModelInfo( + model_name=info.name, + model_version=overrides.pop("version", "unknown"), + model_source=overrides.pop( + "source", "local" if Path(info.path).exists() else "huggingface" + ), + model_format=overrides.pop( + "format", "mlx" if info.backend == "mlx_whisper" else "ctranslate2" + ), + model_family=overrides.pop("family", "whisper"), + quantization=overrides.pop("quantization", None) or info.quantization, + ) + + def install_hooks(self, obj: object, handle: ModelHandle) -> None: + info = descriptor(obj) + assert info is not None + handle.detected_accelerator = info.accelerator + + +def mlx_descriptor(path: object, dtype: object = "float16") -> WhisperDescriptor: + quantization = "fp16" if "16" in str(dtype) else "fp32" + try: + config = Path(str(path)) / "config.json" + if config.is_file(): + quant = json.loads(config.read_text()).get("quantization") or {} + if isinstance(quant.get("bits"), int): + quantization = f"q{quant['bits']}" + except Exception as exc: + debug_failure("mlx_whisper", "quantization extraction", exc) + return WhisperDescriptor(str(path), "mlx_whisper", "gpu", quantization) + + +def audio_meta(audio: object, duration: float | None = None) -> AudioInputMeta: + meta = AudioInputMeta() + if isinstance(audio, (str, Path)): + try: + with wave.open(str(audio), "rb") as wav: + meta.duration_ms = round(wav.getnframes() / wav.getframerate() * 1000) + meta.sample_rate = wav.getframerate() + meta.channels = wav.getnchannels() + meta.bit_depth = wav.getsampwidth() * 8 + meta.format = "wav" + except (OSError, EOFError, wave.Error): + pass + elif getattr(audio, "ndim", None) == 1: + meta.duration_ms = round(audio.shape[0] / SAMPLE_RATE * 1000) + meta.sample_rate = SAMPLE_RATE + meta.channels = 1 + if duration is not None: + meta.duration_ms = round(duration * 1000) + return meta + + +def audio_attachments(audio: object, limit: int) -> list[Attachment]: + if isinstance(audio, (str, Path)): + path = Path(audio) + if path.is_file() and path.stat().st_size <= limit: + content_type = ( + mimetypes.guess_type(str(path))[0] or "application/octet-stream" + ) + return [ + Attachment( + content_type=content_type, + role="input", + path=str(path), + name=path.name, + ) + ] + elif getattr(audio, "ndim", None) == 1 and audio.shape[0] * 2 + 44 <= limit: + import numpy as np # noqa: PLC0415 -- optional; provided by the active Whisper runtime + + pcm = (np.clip(np.asarray(audio), -1, 1) * 32767).astype(" None: + context = {"transcript_word_count": len(text.split()) if words is None else words} + if language is not None: + context["language"] = language + if probability is not None: + context["language_confidence"] = probability + attachments = None + if handle.capture_attachments is not None: + try: + # The manager enforces its own configured size/count limits as well. + limit = 10 * 1024 * 1024 + attachments = audio_attachments(audio, limit) + if text and len(text.encode()) <= limit: + attachments.append( + Attachment( + content_type="text/plain", + role="output", + data=text.encode(), + name="transcript.txt", + ) + ) + except Exception as exc: + debug_failure("whisper", "attachment capture", exc) + handle.track_inference( + duration_ms=duration_ms, + input_modality="audio", + output_modality="text", + input_meta=audio_meta(audio, duration), + context=context, + attachments=attachments, + success=outcome == "completed", + error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), + ) From 642b6199493d4760f11377464148c8a15c068cfd Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 10:23:55 +0200 Subject: [PATCH 40/81] Polish Whisper instrumentation from review - Name models once at load: Hugging Face cache paths use the repo name instead of the snapshot hash, and local folders resolve relative paths. - Record every real load, including MLX reloads after switching models and repeat WhisperModel instances. - Track BatchedInferencePipeline, which does not go through WhisperModel.transcribe. - Report a faster-whisper loop that exits early as STREAM_ABANDONED when the iterator is collected; never-iterated streams send nothing. - Bind faster-whisper models with bind_client_reference like provider clients, so a replaced client warns once. - Report time_to_first_segment_ms; leave out word counts for languages written without spaces. - Detect the MLX device and bf16, and read quantization from layers for models cached before instrumentation. - Share one capture limit constant, clearer track_transcription arguments, a transcript collector class and one MLX fallback helper. - Split and extend tests; restructure docs/whisper.md; add a changelog entry. --- CHANGELOG.md | 8 + docs/compatibility.md | 4 +- docs/streaming.md | 5 +- docs/whisper.md | 92 ++++- scripts/compat_matrix.py | 14 +- tests/compat/test_faster_whisper_compat.py | 26 +- tests/test_whisper.py | 394 ++++++++++++++++----- wildedge/integrations/faster_whisper.py | 127 +++++-- wildedge/integrations/mlx_whisper.py | 118 ++++-- wildedge/integrations/whisper.py | 146 +++++--- 10 files changed, 701 insertions(+), 233 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbea81..2ca5c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Added + +- `mlx_whisper` and `faster_whisper` integrations track local Whisper model + loads and transcriptions, including faster-whisper's + `BatchedInferencePipeline`. Transcriptions record duration, language, word + count and audio metadata. With `attachments_enabled=True` they also upload + the input audio and transcript. See docs/whisper.md. + ### Fixed - Process-wide integrations and hub patches now support sequential SDK client diff --git a/docs/compatibility.md b/docs/compatibility.md index 7e7f92c..aab6547 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -38,8 +38,6 @@ To run the full matrix locally: `python scripts/run_compat_local.py`. | Integration | Version set | Dependencies | Supported Python | |---|---|---|---| -| `faster_whisper` | `current` | `faster-whisper==1.2.1`, `ctranslate2==4.8.2`, `numpy==2.2.6` | 3.12, 3.13 | -| `mlx_whisper` | `current` | `mlx-whisper==0.4.3`, `mlx==0.32.3`, `numpy==2.2.6` | 3.12, 3.13 | | `anthropic` | `min` | `anthropic==0.25.0` | 3.10, 3.11, 3.12, 3.13 | | `anthropic` | `current` | `anthropic==1.15.0` | 3.10, 3.11, 3.12, 3.13 | | `onnx` | `min` | `onnxruntime==1.18.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13, 3.14 | @@ -69,3 +67,5 @@ To run the full matrix locally: `python scripts/run_compat_local.py`. | `ultralytics` | `min` | `ultralytics==8.0.200`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | | `ultralytics` | `current` | `ultralytics==8.4.182`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | | `mlx` | `current` | `mlx==0.32.4`, `mlx-lm==0.32.2` | 3.12, 3.13 | +| `faster_whisper` | `current` | `faster-whisper==1.2.1`, `ctranslate2==4.8.2`, `numpy==2.2.6` | 3.12, 3.13 | +| `mlx_whisper` | `current` | `mlx-whisper==0.4.3`, `mlx==0.32.3`, `numpy==2.2.6` | 3.12, 3.13 | diff --git a/docs/streaming.md b/docs/streaming.md index e535a2d..6d2931e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -38,8 +38,9 @@ with client.chat.completions.create(model=model, messages=messages, stream=True) Async streams support `async with`, `await stream.close()`, and `await stream.aclose()`. A bare `break` without closing, or simply abandoning an unconsumed stream, does not provide an observable end of the operation. There is -no garbage-collection finalizer. Keep stream consumption within the lifetime of -the SDK client and do not consume one iterator concurrently from multiple tasks. +no garbage-collection finalizer, except for faster-whisper segments (see +[whisper.md](whisper.md)). Keep stream consumption within the lifetime of the +SDK client and do not consume one iterator concurrently from multiple tasks. ## Audit findings outside these wrappers diff --git a/docs/whisper.md b/docs/whisper.md index 391a375..baab3bd 100644 --- a/docs/whisper.md +++ b/docs/whisper.md @@ -1,35 +1,105 @@ # Local Whisper speech recognition -Enable `mlx_whisper` on Apple Silicon or `faster_whisper` for CTranslate2 before loading the model or importing a transcription function by name: +WildEdge tracks local Whisper transcription with two integrations: + +- `mlx_whisper` for [mlx-whisper](https://github.com/ml-explore/mlx-examples/tree/main/whisper) on Apple Silicon +- `faster_whisper` for [faster-whisper](https://github.com/SYSTRAN/faster-whisper) (CTranslate2) + +Initialize the SDK before loading a model or importing `transcribe` by name: ```python import wildedge -client = wildedge.init(integrations=["mlx_whisper"], attachments_enabled=False) +client = wildedge.init(integrations=["mlx_whisper"]) import mlx_whisper result = mlx_whisper.transcribe("speech.wav", path_or_hf_repo="/models/whisper") ``` -For faster-whisper, consume the returned segments to finish the inference: +faster-whisper decodes lazily, so the transcription is recorded once you have +read all of its segments: ```python -client = wildedge.init(integrations=["faster_whisper"], attachments_enabled=False) +client = wildedge.init(integrations=["faster_whisper"]) from faster_whisper import WhisperModel model = WhisperModel("/models/whisper", device="cpu", compute_type="int8") segments, info = model.transcribe("speech.wav") text = "".join(segment.text for segment in segments) -client.flush() -client.close() ``` -The SDK records model load time, backend-specific model identity, format (`mlx` or `ctranslate2`), family (`whisper`), accelerator and compute type/quantization. MLX's internal model cache produces one load per SDK owner; an already cached model is reported with zero load time and `model_cache_hit=true`. Warm-up transcriptions are tracked too. +`BatchedInferencePipeline(model).transcribe(...)` is tracked the same way. + +## What's recorded + +**Model loads** + +- Load time, format (`mlx` or `ctranslate2`), family (`whisper`), accelerator + and compute type or quantization. +- The model ID is the backend plus the model name, for example + `faster_whisper:Systran/faster-whisper-tiny`. A Hugging Face repo keeps its + repo name, also when loaded from a cache path. A local folder is named after + the folder. +- Every load is recorded, including a second `WhisperModel` for the same model. +- mlx-whisper caches one model at a time and reloads when you switch models. + Each reload is recorded. A model that was already cached before the SDK + started is recorded once, with zero load time and `model_cache_hit=true`. + +**Transcriptions** + +- Wall-clock duration and `language`. +- `language_confidence` (faster-whisper only; mlx-whisper doesn't report it). +- `transcript_word_count`, except for languages written without spaces between + words, such as Chinese, Japanese and Thai. +- `time_to_first_segment_ms` (faster-whisper only). +- Audio metadata: + - WAV files: duration, sample rate, channels and bit depth. + - 1-D arrays: treated as 16 kHz mono, as both libraries expect. + - Other formats: duration only, when faster-whisper reports it. The SDK + doesn't decode the audio a second time. + +Token counts aren't reported; neither library exposes them. + +## faster-whisper segment lifecycle + +Timing runs from `transcribe()` until the last segment is read, including time +your code spends between segments. Read the segments inside the trace or span +that should own the transcription. + +| How the segments end | Recorded as | +| --- | --- | +| All segments read | One successful transcription | +| `segments.close()` | One unsuccessful transcription, `STREAM_CLOSED` | +| Loop exits early without `close()` | One unsuccessful transcription, `STREAM_ABANDONED`, sent when the iterator is garbage collected and timed up to the last segment read | +| Never iterated (for example, only `info.language` was used) | Nothing | +| Decoder error | One error event; the exception reaches your code unchanged | + +A `WhisperModel` stays with the SDK client that was active when it was created. +After you replace the client, create the model again; until then, it isn't +tracked and the SDK warns once. + +## Capturing audio and transcripts + +Capture is off by default. With `attachments_enabled=True`, each transcription +uploads: + +- the input audio: the original file for a path, or a 16-bit WAV for an array +- the transcript as `transcript.txt` -Transcriptions include wall-clock duration, audio metadata, `transcript_word_count` and detected `language` attributes. `language_confidence` is included when faster-whisper supplies it; MLX does not expose it. WAV paths provide original duration, sample rate, channels and bit depth. One-dimensional arrays are treated as 16 kHz mono, as required by these APIs. Other file formats retain the duration supplied by faster-whisper when available; the SDK does not decode files a second time to inspect them. These APIs do not supply text token usage or token-level TTFT, so the SDK does not invent those values. +Keep in mind: -faster-whisper timing runs from `transcribe()` through segment exhaustion, including time between iterator pulls. Keep consuming within the trace/span that should own the operation. Explicit `segments.close()` emits one unsuccessful inference with `STREAM_CLOSED` and the word count consumed so far. Decoder failures emit one error event and propagate unchanged. Merely abandoning an iterator does not imply completion; explicitly close it when stopping early. Existing faster-whisper model objects stay bound to their original SDK client after replacement; recreate them for a new client. +- Each attachment is capped at 10 MiB, and the client's own attachment limits + also apply. A transcript over the cap isn't uploaded at all. +- Audio passed as a file-like object isn't captured. +- An audio file must still exist when the transcription finishes. +- To upload transcripts but not audio, pass an `attachment_filter` that keeps + only output attachments: + `attachment_filter=lambda items: [a for a in items if a.role == "output"]`. +- Transcript text is never added to event attributes. -Audio and transcript attachments require `attachments_enabled=True`. Capture is off by default. The adapter caps retained transcript/audio attachment size at 10 MiB, and the configured attachment manager may apply stricter limits or a filter. No transcript text enters scalar event attributes. Use an `attachment_filter` when an application allows transcript capture but forbids audio upload. Raw audio file attachments must still exist when the transcription finishes. +## Examples and tests -See [the runnable example](../examples/whisper_example.py). Compatibility CI tests real decoding with random-weight MLX Whisper and the pinned public faster-whisper tiny model. Offline faster-whisper tests can use `WILDEDGE_FASTER_WHISPER_MODEL=/path/to/model`. +See [the runnable example](../examples/whisper_example.py). Compatibility CI +decodes real audio with a random-weight MLX Whisper model and the pinned public +faster-whisper tiny model. To run the faster-whisper tests offline, set +`WILDEDGE_FASTER_WHISPER_MODEL=/path/to/model`. diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 7c72d5b..f0cd352 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -9,10 +9,6 @@ from importlib import metadata MATRIX = { - "faster_whisper": { - "current": ["faster-whisper==1.2.1", "ctranslate2==4.8.2", "numpy==2.2.6"] - }, - "mlx_whisper": {"current": ["mlx-whisper==0.4.3", "mlx==0.32.3", "numpy==2.2.6"]}, "anthropic": { "min": ["anthropic==0.25.0"], "current": ["anthropic==1.15.0"], @@ -62,11 +58,13 @@ "mlx": { "current": ["mlx==0.32.4", "mlx-lm==0.32.2"], }, + "faster_whisper": { + "current": ["faster-whisper==1.2.1", "ctranslate2==4.8.2", "numpy==2.2.6"] + }, + "mlx_whisper": {"current": ["mlx-whisper==0.4.3", "mlx==0.32.3", "numpy==2.2.6"]}, } SUPPORTED_PYTHON = { - "faster_whisper": ["3.12", "3.13"], - "mlx_whisper": ["3.12", "3.13"], "anthropic": ["3.10", "3.11", "3.12", "3.13"], "onnx": ["3.10", "3.11", "3.12", "3.13", "3.14"], "torch": ["3.10", "3.11", "3.12", "3.13", "3.14"], @@ -77,6 +75,8 @@ "transformers": ["3.10", "3.11", "3.12", "3.13"], "ultralytics": ["3.10", "3.11", "3.12", "3.13"], "mlx": ["3.12", "3.13"], + "faster_whisper": ["3.12", "3.13"], + "mlx_whisper": ["3.12", "3.13"], } # Integrations without a row of their own, and the row that exercises them. @@ -86,8 +86,8 @@ # Integrations that need a specific runner; everything else runs on Linux. RUNNER = { - "mlx_whisper": "macos-14", "mlx": "macos-14", + "mlx_whisper": "macos-14", } # Python versions run as non-blocking canaries, and only with current pins. diff --git a/tests/compat/test_faster_whisper_compat.py b/tests/compat/test_faster_whisper_compat.py index aeda487..8578b78 100644 --- a/tests/compat/test_faster_whisper_compat.py +++ b/tests/compat/test_faster_whisper_compat.py @@ -5,10 +5,12 @@ import faster_whisper import numpy as np +import pytest from huggingface_hub import snapshot_download -def test_load_and_lazy_transcribe(compat_client): +@pytest.fixture(scope="module") +def model_path(): # A local model can be supplied for offline development. CI caches HF downloads. path = os.environ.get("WILDEDGE_FASTER_WHISPER_MODEL") or snapshot_download( "Systran/faster-whisper-tiny", @@ -16,9 +18,16 @@ def test_load_and_lazy_transcribe(compat_client): allow_patterns=["config.json", "model.bin", "tokenizer.json", "vocabulary.*"], ) assert Path(path).is_dir() + return path + + +def test_load_and_lazy_transcribe(compat_client, model_path): compat_client.instrument("faster_whisper") - model = faster_whisper.WhisperModel(path, device="cpu", compute_type="int8") + model = faster_whisper.WhisperModel(model_path, device="cpu", compute_type="int8") load = compat_client.one("model_load") + if "WILDEDGE_FASTER_WHISPER_MODEL" not in os.environ: + # Named after the hub repo, not the snapshot hash in the cache path. + assert load["model_id"] == "faster_whisper:Systran/faster-whisper-tiny" segments, info = model.transcribe( np.zeros(16000, dtype=np.float32), language="en", beam_size=1 ) @@ -29,3 +38,16 @@ def test_load_and_lazy_transcribe(compat_client): assert event["attributes"]["transcript_word_count"] == len(text.split()) assert event["attributes"]["language_confidence"] == info.language_probability assert event["inference"]["input_meta"]["duration_ms"] == 1000 + + +def test_batched_pipeline(compat_client, model_path): + compat_client.instrument("faster_whisper") + model = faster_whisper.WhisperModel(model_path, device="cpu", compute_type="int8") + load = compat_client.one("model_load") + pipeline = faster_whisper.BatchedInferencePipeline(model) + segments, _info = pipeline.transcribe( + np.zeros(16000, dtype=np.float32), language="en", batch_size=2 + ) + list(segments) + event = compat_client.one("inference") + assert event["model_id"] == load["model_id"] diff --git a/tests/test_whisper.py b/tests/test_whisper.py index ce2774a..a5230d1 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -1,5 +1,7 @@ """Whisper adapters preserve results, lazy lifecycles, and capture privacy.""" +import gc +import io import sys import wave from types import ModuleType, SimpleNamespace @@ -8,17 +10,19 @@ import pytest from wildedge.instrumentation import claim_instrumentation +from wildedge.integrations import faster_whisper as faster from wildedge.integrations.faster_whisper import ( install_auto_load_patch, wrap_transcribe, ) from wildedge.integrations.mlx_whisper import install_auto_load_patch as install_mlx from wildedge.integrations.whisper import ( + CLIENT_ATTR, DESCRIPTOR_ATTR, HANDLE_ATTR, WhisperDescriptor, WhisperExtractor, - audio_meta, + model_name_and_source, track_transcription, ) @@ -30,15 +34,43 @@ def handle(): ) -def test_metadata_and_no_default_capture(tmp_path, handle): +@pytest.fixture +def wav(tmp_path): path = tmp_path / "input.wav" with wave.open(str(path), "wb") as audio: audio.setnchannels(1) audio.setsampwidth(2) audio.setframerate(16000) audio.writeframes(b"\0\0" * 8000) - track_transcription(handle, path, "hello world", "en", 123, probability=0.9) + return path + + +def tracked_model(handle): + """A faster-whisper model object as the constructor patch leaves it.""" + model = SimpleNamespace() + setattr(model, HANDLE_ATTR, handle) + setattr(model, CLIENT_ATTR, lambda: object()) + return model + + +def events_of(events, event_type): + return [e for e in events if e["event_type"] == event_type] + + +# Shared metadata ------------------------------------------------------------ + + +def test_transcription_metadata(wav, handle): + track_transcription( + handle, + wav, + "hello world", + duration_ms=123, + language="en", + language_probability=0.9, + ) event = handle.track_inference.call_args.kwargs + assert event["duration_ms"] == 123 assert event["input_meta"].to_dict() == { "duration_ms": 500, "sample_rate": 16000, @@ -51,35 +83,77 @@ def test_metadata_and_no_default_capture(tmp_path, handle): "language": "en", "language_confidence": 0.9, } - assert event["attachments"] is None + + +def test_nothing_is_attached_without_capture(wav, handle): + track_transcription(handle, wav, "hello world", duration_ms=1) + assert handle.track_inference.call_args.kwargs["attachments"] is None + + +def test_capture_attaches_audio_and_transcript(wav, handle): handle.capture_attachments = MagicMock() - track_transcription(handle, path, "hello world", "en", 123) + track_transcription(handle, wav, "hello world", duration_ms=1) attachments = handle.track_inference.call_args.kwargs["attachments"] assert [a.role for a in attachments] == ["input", "output"] + assert attachments[0].path == str(wav) assert attachments[1].data == b"hello world" -def test_unknown_audio_is_not_consumed(): - source = MagicMock() - assert audio_meta(source).to_dict() == {} - source.read.assert_not_called() +def test_file_like_audio_is_not_read(handle): + source = io.BytesIO(b"RIFF....") + handle.capture_attachments = MagicMock() + track_transcription(handle, source, "hello", duration_ms=1) + event = handle.track_inference.call_args.kwargs + assert event["input_meta"].to_dict() == {} + assert [a.name for a in event["attachments"]] == ["transcript.txt"] + assert source.tell() == 0 + + +def test_word_count_is_left_out_for_unspaced_languages(handle): + track_transcription(handle, None, "你好世界", duration_ms=1, language="zh") + context = handle.track_inference.call_args.kwargs["context"] + assert "transcript_word_count" not in context + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ( + "/cache/hub/models--Systran--faster-whisper-tiny/snapshots/d90ca5fe", + ("Systran/faster-whisper-tiny", "huggingface"), + ), + ("mlx-community/whisper-tiny", ("mlx-community/whisper-tiny", "huggingface")), + ("small", ("small", "huggingface")), + ], +) +def test_model_name_and_source(path, expected): + assert model_name_and_source(path) == expected + + +def test_local_model_is_named_after_its_folder(tmp_path, monkeypatch): + (tmp_path / "whisper-small").mkdir() + monkeypatch.chdir(tmp_path / "whisper-small") + assert model_name_and_source(".") == ("whisper-small", "local") -def test_extractor(tmp_path): +def test_extractor(): model = SimpleNamespace() setattr( model, DESCRIPTOR_ATTR, - WhisperDescriptor(str(tmp_path), "faster_whisper", "cpu", "int8"), + WhisperDescriptor("whisper-small", "local", "faster_whisper", "cpu", "int8"), ) - extractor = WhisperExtractor() - ident, info = extractor.extract_info(model, {}) - assert ident == f"faster_whisper:{tmp_path.name}" + ident, info = WhisperExtractor().extract_info(model, {}) + assert ident == "faster_whisper:whisper-small" + assert info.model_source == "local" assert info.model_format == "ctranslate2" assert info.model_family == "whisper" assert info.quantization == "int8" +# faster-whisper segment stream --------------------------------------------- + + @pytest.mark.parametrize("outcome", ["completed", "closed", "error"]) def test_lazy_segments(handle, outcome): segment = SimpleNamespace(text="hello world") @@ -90,10 +164,8 @@ def segments(): if outcome == "error": raise ValueError("decoder failure") - model = SimpleNamespace() - setattr(model, HANDLE_ATTR, handle) call = wrap_transcribe(lambda *_: (segments(), info)) - stream, returned_info = call(model, "audio.wav") + stream, returned_info = call(tracked_model(handle), "audio.wav") assert returned_info is info handle.track_inference.assert_not_called() assert next(stream) is segment @@ -112,33 +184,194 @@ def segments(): event = handle.track_inference.call_args.kwargs assert event["success"] == (outcome == "completed") assert event["context"]["transcript_word_count"] == 2 + assert "time_to_first_segment_ms" in event["context"] assert event["input_meta"].duration_ms == 2000 -def test_faster_constructor_routes_once(monkeypatch): +def test_loop_that_stops_early_is_reported_as_abandoned(handle): + segments = [SimpleNamespace(text="one"), SimpleNamespace(text="two")] + stream, _ = wrap_transcribe(lambda *_: (iter(segments), SimpleNamespace()))( + tracked_model(handle), "audio.wav" + ) + for _segment in stream: + break + handle.track_inference.assert_not_called() + del stream + gc.collect() + event = handle.track_inference.call_args.kwargs + assert event["error_code"] == "STREAM_ABANDONED" + assert event["context"]["transcript_word_count"] == 1 + + +def test_stream_that_was_never_iterated_sends_nothing(handle): + stream, _ = wrap_transcribe(lambda *_: (iter([]), SimpleNamespace()))( + tracked_model(handle), "audio.wav" + ) + del stream + gc.collect() + handle.track_inference.assert_not_called() + + +def test_transcript_over_the_limit_is_not_attached(handle, monkeypatch): + monkeypatch.setattr(faster, "MAX_CAPTURE_BYTES", 8) + handle.capture_attachments = MagicMock() + segments = [SimpleNamespace(text="hello "), SimpleNamespace(text="world")] + stream, _ = wrap_transcribe(lambda *_: (iter(segments), SimpleNamespace()))( + tracked_model(handle), object() + ) + assert list(stream) == segments + event = handle.track_inference.call_args.kwargs + assert event["attachments"] == [] + assert event["context"]["transcript_word_count"] == 2 + + +def test_transcribe_errors_preserve_exception(handle): + failure = ValueError("invalid audio") + + def original(*_args): + raise failure + + handle.track_error.side_effect = RuntimeError("telemetry failed") + with pytest.raises(ValueError, match="invalid audio") as raised: + wrap_transcribe(original)(tracked_model(handle), "missing.wav") + assert raised.value is failure + handle.track_inference.assert_not_called() + + +def test_lazy_telemetry_failure_does_not_break_iterator(handle): + handle.track_inference.side_effect = RuntimeError("telemetry failed") + segment = SimpleNamespace(text="hello") + stream, _ = wrap_transcribe(lambda *_: (iter([segment]), SimpleNamespace()))( + tracked_model(handle), "audio.wav" + ) + assert list(stream) == [segment] + assert list(stream) == [] + handle.track_inference.assert_called_once() + + +# faster-whisper model patch ------------------------------------------------- + + +@pytest.fixture +def fake_faster_whisper(monkeypatch): module = ModuleType("faster_whisper") - class Model: + class WhisperModel: def __init__(self, model_size_or_path, device="cpu", compute_type="int8"): self.model = SimpleNamespace(device=device, compute_type=compute_type) def transcribe(self, audio): - return iter([]), SimpleNamespace() + return iter([SimpleNamespace(text="hello")]), SimpleNamespace() - module.WhisperModel = Model + class BatchedInferencePipeline: + def __init__(self, model): + self.model = model + + def transcribe(self, audio): + return iter([SimpleNamespace(text="hello")]), SimpleNamespace() + + module.WhisperModel = WhisperModel + module.BatchedInferencePipeline = BatchedInferencePipeline monkeypatch.setitem(sys.modules, "faster_whisper", module) - client = SimpleNamespace( - closed=False, _on_model_auto_loaded=MagicMock(), register_model=MagicMock() + return module + + +@pytest.fixture +def instrumented_client(client_with_stubbed_runtime): + client = client_with_stubbed_runtime + client.events = [] + client.publish = client.events.append + return client + + +def test_faster_constructor_describes_the_runtime( + fake_faster_whisper, instrumented_client +): + route = claim_instrumentation(instrumented_client, "faster_whisper") + install_auto_load_patch(route) + install_auto_load_patch(route) + model = fake_faster_whisper.WhisperModel( + "small", device="cuda", compute_type="float16" ) - install_auto_load_patch(lambda: client) - install_auto_load_patch(lambda: client) - model = Model("small", device="cuda", compute_type="float16") - client._on_model_auto_loaded.assert_called_once() - assert getattr(model, DESCRIPTOR_ATTR).accelerator == "gpu" - assert getattr(model, DESCRIPTOR_ATTR).quantization == "float16" + info = getattr(model, DESCRIPTOR_ATTR) + assert info.model_id == "faster_whisper:small" + assert info.accelerator == "gpu" + assert info.quantization == "float16" + assert len(events_of(instrumented_client.events, "model_load")) == 1 + + +def test_each_faster_model_instance_reports_its_load( + fake_faster_whisper, instrumented_client +): + install_auto_load_patch( + claim_instrumentation(instrumented_client, "faster_whisper") + ) + fake_faster_whisper.WhisperModel("small") + fake_faster_whisper.WhisperModel("small") + assert len(events_of(instrumented_client.events, "model_load")) == 2 + + +def test_batched_pipeline_is_tracked(fake_faster_whisper, instrumented_client): + install_auto_load_patch( + claim_instrumentation(instrumented_client, "faster_whisper") + ) + model = fake_faster_whisper.WhisperModel("small") + pipeline = fake_faster_whisper.BatchedInferencePipeline(model) + segments, _ = pipeline.transcribe("audio.wav") + assert [s.text for s in segments] == ["hello"] + inference = events_of(instrumented_client.events, "inference") + assert [e["model_id"] for e in inference] == ["faster_whisper:small"] + + +def test_faster_model_stays_with_its_original_client( + fake_faster_whisper, instrumented_client +): + install_auto_load_patch( + claim_instrumentation(instrumented_client, "faster_whisper") + ) + model = fake_faster_whisper.WhisperModel("small") + instrumented_client.close() + segments, _ = model.transcribe("audio.wav") + assert not isinstance(segments, faster.SegmentStream) + assert [s.text for s in segments] == ["hello"] + assert not events_of(instrumented_client.events, "inference") + + +# mlx-whisper ---------------------------------------------------------------- + + +@pytest.fixture +def fake_mlx_whisper(monkeypatch): + """mlx_whisper with ModelHolder's caching: one model, reloaded on change.""" + root, module, loaders = ( + ModuleType(n) + for n in ("mlx_whisper", "mlx_whisper.transcribe", "mlx_whisper.load_models") + ) + + def load_model(path, dtype="float32"): + return SimpleNamespace(named_modules=lambda: [("", SimpleNamespace(bits=4))]) + + class ModelHolder: + model = None + model_path = None + + def transcribe(_audio, *, path_or_hf_repo="mlx-community/whisper-tiny", **_kw): + if path_or_hf_repo == "broken": + raise ValueError("cannot load") + if ModelHolder.model is None or ModelHolder.model_path != path_or_hf_repo: + ModelHolder.model = module.load_model(path_or_hf_repo) + ModelHolder.model_path = path_or_hf_repo + return {"text": "hello world", "language": "en"} + + module.ModelHolder = ModelHolder + module.transcribe = transcribe + module.load_model = loaders.load_model = load_model + for mod in (root, module, loaders): + monkeypatch.setitem(sys.modules, mod.__name__, mod) + return root -def test_mlx_result_identity_and_cached_load(monkeypatch, handle): +def test_mlx_returns_the_original_result(monkeypatch, handle): root, module, loaders = ( ModuleType(n) for n in ("mlx_whisper", "mlx_whisper.transcribe", "mlx_whisper.load_models") @@ -155,9 +388,9 @@ def test_mlx_result_identity_and_cached_load(monkeypatch, handle): register_model=MagicMock(return_value=handle), _on_model_auto_loaded=MagicMock(), ) - handle.model_id = "mlx_whisper:test" install_mlx(lambda: client) assert root.transcribe("audio.wav", path_or_hf_repo="test") is result + # The model was cached before instrumentation, so it is reported as a hit. client._on_model_auto_loaded.assert_called_once() assert client._on_model_auto_loaded.call_args.kwargs["load_kwargs"]["context"][ "model_cache_hit" @@ -165,73 +398,60 @@ def test_mlx_result_identity_and_cached_load(monkeypatch, handle): handle.track_inference.assert_called_once() -def test_transcribe_errors_preserve_exception(handle): - failure = ValueError("invalid audio") +def test_mlx_load_reads_quantization(fake_mlx_whisper, instrumented_client): + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model-q4") + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model-q4") + assert len(events_of(instrumented_client.events, "model_load")) == 1 + assert len(events_of(instrumented_client.events, "inference")) == 2 + registered = instrumented_client.registry.models["mlx_whisper:org/model-q4"] + assert registered.quantization == "q4" - def original(*_args): - raise failure - model = SimpleNamespace() - setattr(model, HANDLE_ATTR, handle) - handle.track_error.side_effect = RuntimeError("telemetry failed") - with pytest.raises(ValueError, match="invalid audio") as raised: - wrap_transcribe(original)(model, "missing.wav") - assert raised.value is failure - handle.track_inference.assert_not_called() +def test_mlx_reload_after_switching_models_is_reported( + fake_mlx_whisper, instrumented_client +): + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + for repo in ("org/a", "org/b", "org/a"): + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo=repo) + loads = events_of(instrumented_client.events, "model_load") + assert [e["model_id"] for e in loads] == [ + "mlx_whisper:org/a", + "mlx_whisper:org/b", + "mlx_whisper:org/a", + ] -def test_lazy_telemetry_failure_does_not_break_iterator(handle): - handle.track_inference.side_effect = RuntimeError("telemetry failed") - model = SimpleNamespace() - setattr(model, HANDLE_ATTR, handle) - segment = SimpleNamespace(text="hello") - stream, _ = wrap_transcribe(lambda *_: (iter([segment]), SimpleNamespace()))( - model, "audio.wav" - ) - assert list(stream) == [segment] - assert list(stream) == [] - handle.track_inference.assert_called_once() +def test_mlx_error_is_tracked_and_reraised(fake_mlx_whisper, instrumented_client): + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + with pytest.raises(ValueError, match="cannot load"): + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="broken") + errors = events_of(instrumented_client.events, "error") + assert [e["model_id"] for e in errors] == ["mlx_whisper:broken"] -def test_mlx_load_quantization_cache_and_closed_owner( - monkeypatch, client_with_stubbed_runtime, handle -): - root, module, loaders = ( - ModuleType(n) - for n in ("mlx_whisper", "mlx_whisper.transcribe", "mlx_whisper.load_models") - ) - model = SimpleNamespace(named_modules=lambda: [("", SimpleNamespace(bits=4))]) - module.ModelHolder = SimpleNamespace(model=None) - module.load_model = loaders.load_model = lambda *_a, **_kw: model +def test_mlx_sends_nothing_after_client_closes(fake_mlx_whisper, instrumented_client): + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") + instrumented_client.close() + assert fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") + assert len(events_of(instrumented_client.events, "inference")) == 1 - def original(_audio, **kwargs): - if module.ModelHolder.model is None: - module.ModelHolder.model = module.load_model(kwargs["path_or_hf_repo"]) - return {"text": "hello world", "language": "en"} - module.transcribe = original - for mod in (root, module, loaders): - monkeypatch.setitem(sys.modules, mod.__name__, mod) - client = client_with_stubbed_runtime - events = [] - client.publish = events.append - install_mlx(claim_instrumentation(client, "mlx_whisper")) - root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") - root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") - assert len([e for e in events if e["event_type"] == "model_load"]) == 1 - assert len([e for e in events if e["event_type"] == "inference"]) == 2 - assert client.registry.models["mlx_whisper:org/model-q4"].quantization == "q4" - client.close() - root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") - assert len([e for e in events if e["event_type"] == "inference"]) == 2 +def test_mlx_replacement_client_reports_the_cached_model( + fake_mlx_whisper, instrumented_client, handle +): + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") + instrumented_client.close() replacement = MagicMock(closed=False, auto_loaded=set()) - handle.model_id = "mlx_whisper:org/model-q4" replacement.register_model.return_value = handle claim_instrumentation(replacement, "mlx_whisper") - root.transcribe("audio.wav", path_or_hf_repo="org/model-q4") + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") replacement._on_model_auto_loaded.assert_called_once() - assert replacement._on_model_auto_loaded.call_args.kwargs["load_kwargs"]["context"][ - "model_cache_hit" + load_context = replacement._on_model_auto_loaded.call_args.kwargs["load_kwargs"][ + "context" ] + assert load_context["model_cache_hit"] handle.track_inference.assert_called_once() - assert len([e for e in events if e["event_type"] == "inference"]) == 2 + assert len(events_of(instrumented_client.events, "inference")) == 1 diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py index 19120f0..63c966b 100644 --- a/wildedge/integrations/faster_whisper.py +++ b/wildedge/integrations/faster_whisper.py @@ -7,24 +7,88 @@ import threading import time +from wildedge.instrumentation import bind_client_reference from wildedge.integrations.common import ( SyncStreamWrapper, debug_failure, track_api_error, ) from wildedge.integrations.whisper import ( + CLIENT_ATTR, DESCRIPTOR_ATTR, HANDLE_ATTR, + MAX_CAPTURE_BYTES, WhisperDescriptor, + report_load, track_transcription, ) from wildedge.timing import elapsed_ms +_PATCH_LOCK = threading.Lock() + + +class TranscriptCollector: + """Counts words as segments arrive and keeps the text when capture is on.""" + + def __init__(self, t0: float, capture: bool) -> None: + self._t0 = t0 + self.words = 0 + self.last_segment_ms: int | None = None + self._parts: list[str] | None = [] if capture else None + self._size = 0 + + def add(self, segment) -> None: + self.last_segment_ms = elapsed_ms(self._t0) + text = segment.text + self.words += len(text.split()) + if self._parts is None: + return + self._size += len(text.encode()) + if self._size > MAX_CAPTURE_BYTES: + # A cut-off transcript is not worth uploading. + self._parts = None + else: + self._parts.append(text) + + @property + def text(self) -> str: + return "" if self._parts is None else "".join(self._parts) + + +class SegmentStream(SyncStreamWrapper): + """Segment iterator that also reports a loop that stopped early. + + ``for segment in segments: break`` drops the generator without closing + it. The event is then sent when the iterator is garbage collected, as + ``STREAM_ABANDONED`` with the time up to the last segment received. A + stream that was never iterated, for example when only ``info.language`` + was needed, decoded nothing and sends no event. + """ + + def __del__(self) -> None: + # vars() avoids __getattr__, which forwards to the wrapped generator. + state = vars(self) + if state.get("_finished") is False and state.get("_iterator") is not None: + self._finish("abandoned") + + +def tracked_handle(model): + """The model's handle while its SDK client is still open, else None.""" + client_ref = getattr(model, CLIENT_ATTR, None) + if client_ref is None or client_ref() is None: + return None + return getattr(model, HANDLE_ATTR, None) + + +def wrap_transcribe(original, model_of=lambda owner: owner): + """Wrap ``WhisperModel.transcribe`` or ``BatchedInferencePipeline.transcribe``. + + ``model_of`` finds the ``WhisperModel`` behind the object being called. + """ -def wrap_transcribe(original): @functools.wraps(original) def transcribe(self, audio, *args, **kwargs): - handle = getattr(self, HANDLE_ATTR, None) + handle = tracked_handle(model_of(self)) if handle is None: return original(self, audio, *args, **kwargs) t0 = time.perf_counter() @@ -36,45 +100,29 @@ def transcribe(self, audio, *args, **kwargs): except Exception as telemetry_exc: debug_failure("faster_whisper", "error tracking", telemetry_exc) raise - words = 0 - parts = [] - captured_bytes = 0 - - def chunk(segment): - nonlocal words, captured_bytes - text = segment.text - words += len(text.split()) - if ( - handle.capture_attachments is not None - and captured_bytes <= 10 * 1024 * 1024 - ): - captured_bytes += len(text.encode()) - if captured_bytes <= 10 * 1024 * 1024: - parts.append(text) - else: - parts.clear() - - def done(duration_ms, _ttft_ms, *, outcome="completed"): + collector = TranscriptCollector(t0, handle.capture_attachments is not None) + + def done(duration_ms, first_segment_ms, *, outcome="completed"): + if outcome == "abandoned" and collector.last_segment_ms is not None: + duration_ms = collector.last_segment_ms track_transcription( handle, audio, - "".join(parts), - getattr(info, "language", None), - duration_ms, - probability=getattr(info, "language_probability", None), - duration=getattr(info, "duration", None), - words=words, + collector.text, + duration_ms=duration_ms, + language=getattr(info, "language", None), + language_probability=getattr(info, "language_probability", None), + audio_duration_s=getattr(info, "duration", None), + word_count=collector.words, + first_segment_ms=first_segment_ms, outcome=outcome, ) - return SyncStreamWrapper(segments, handle, t0, chunk, done), info + return SegmentStream(segments, handle, t0, collector.add, done), info return transcribe -_PATCH_LOCK = threading.Lock() - - def install_auto_load_patch(client_ref): with _PATCH_LOCK: _install(client_ref) @@ -99,7 +147,6 @@ def init(self, *args, **kwargs): try: bound = signature.bind(self, *args, **kwargs) bound.apply_defaults() - path = bound.arguments["model_size_or_path"] runtime = self.model device = getattr(runtime, "device", bound.arguments.get("device", "cpu")) compute = getattr( @@ -108,18 +155,28 @@ def init(self, *args, **kwargs): setattr( self, DESCRIPTOR_ATTR, - WhisperDescriptor( - str(path), + WhisperDescriptor.from_path( + bound.arguments["model_size_or_path"], "faster_whisper", "gpu" if device == "cuda" else "cpu", compute, ), ) - client._on_model_auto_loaded(self, load_ms=elapsed_ms(t0)) + report_load(client, self, elapsed_ms(t0)) + # Already registered by the load above; this returns its handle. setattr(self, HANDLE_ATTR, client.register_model(self)) + # Keep this model with the client that loaded it, as provider + # clients do. After a client replacement it warns once instead. + setattr(self, CLIENT_ATTR, bind_client_reference(client_ref, client)) except Exception as exc: debug_failure("faster_whisper", "model registration", exc) init.__wildedge_patch_name__ = "faster_whisper" cls.__init__ = init cls.transcribe = wrap_transcribe(cls.transcribe) + batched = getattr(faster_whisper, "BatchedInferencePipeline", None) + if batched is not None: + batched.transcribe = wrap_transcribe( + batched.transcribe, + model_of=lambda pipeline: getattr(pipeline, "model", None), + ) diff --git a/wildedge/integrations/mlx_whisper.py b/wildedge/integrations/mlx_whisper.py index 9ffc475..c9fd819 100644 --- a/wildedge/integrations/mlx_whisper.py +++ b/wildedge/integrations/mlx_whisper.py @@ -8,17 +8,59 @@ import threading import time -from wildedge.integrations.common import debug_failure, track_api_error +from wildedge.integrations.common import ( + debug_failure, + dtype_to_quantization, + track_api_error, +) from wildedge.integrations.whisper import ( DESCRIPTOR_ATTR, + WhisperDescriptor, descriptor, - mlx_descriptor, + report_load, track_transcription, ) from wildedge.timing import elapsed_ms _PATCH_LOCK = threading.Lock() +# mlx_whisper.transcribe's default for path_or_hf_repo. +DEFAULT_REPO = "mlx-community/whisper-tiny" + + +def mlx_accelerator() -> str: + try: + import mlx.core as mx # noqa: PLC0415 -- optional runtime + + return "cpu" if "cpu" in str(mx.default_device()) else "gpu" + except Exception as exc: + debug_failure("mlx_whisper", "device detection", exc) + return "gpu" + + +def mlx_quantization(model: object, dtype: object) -> str: + """Bit width of quantized layers, else the weight dtype.""" + try: + for _, layer in model.named_modules(): # type: ignore[union-attr] + bits = getattr(layer, "bits", None) + if isinstance(bits, int): + return f"q{bits}" + except Exception as exc: + debug_failure("mlx_whisper", "quantization detection", exc) + return dtype_to_quantization(str(dtype)) or "f32" + + +def describe(model: object, path: object, dtype: object) -> WhisperDescriptor: + return WhisperDescriptor.from_path( + path, "mlx_whisper", mlx_accelerator(), mlx_quantization(model, dtype) + ) + + +def requested_model(kwargs: dict) -> tuple[str, str]: + """Model path and dtype that ``mlx_whisper.transcribe`` uses for these arguments.""" + dtype = "float16" if kwargs.get("fp16", True) else "float32" + return kwargs.get("path_or_hf_repo", DEFAULT_REPO), dtype + def install_auto_load_patch(client_ref): with _PATCH_LOCK: @@ -33,6 +75,8 @@ def _install(client_ref): if getattr(original, "__wildedge_patch_name__", None) == "mlx_whisper": return original_load = module.load_model + # The client that owns the transcription in progress, so a load inside it + # reports to the same client even if the default changes meanwhile. active_client = contextvars.ContextVar("wildedge_mlx_whisper_client", default=None) @functools.wraps(original_load) @@ -42,15 +86,9 @@ def load(path_or_hf_repo, *args, **kwargs): model = original_load(path_or_hf_repo, *args, **kwargs) try: dtype = kwargs.get("dtype", args[0] if args else "float32") - info = mlx_descriptor(path_or_hf_repo, dtype) - for _, layer in model.named_modules(): - bits = getattr(layer, "bits", None) - if isinstance(bits, int): - info.quantization = f"q{bits}" - break - setattr(model, DESCRIPTOR_ATTR, info) + setattr(model, DESCRIPTOR_ATTR, describe(model, path_or_hf_repo, dtype)) if client is not None and not client.closed: - client._on_model_auto_loaded(model, load_ms=elapsed_ms(t0)) + report_load(client, model, elapsed_ms(t0)) except Exception as exc: debug_failure("mlx_whisper", "model registration", exc) return model @@ -66,41 +104,43 @@ def transcribe(audio, *args, **kwargs): result = original(audio, *args, **kwargs) except Exception as exc: try: - info = mlx_descriptor( - kwargs.get("path_or_hf_repo", "mlx-community/whisper-tiny"), - "float16" if kwargs.get("fp16", True) else "float32", - ) - track_api_error(client.register_model(info), exc, elapsed_ms(t0)) + if not client.closed: + path, dtype = requested_model(kwargs) + handle = client.register_model(describe(None, path, dtype)) + track_api_error(handle, exc, elapsed_ms(t0)) except Exception as telemetry_exc: debug_failure("mlx_whisper", "error tracking", telemetry_exc) raise finally: active_client.reset(token) - if not client.closed: - try: - model = module.ModelHolder.model - info = descriptor(model) or mlx_descriptor( - kwargs.get("path_or_hf_repo", "mlx-community/whisper-tiny"), - "float16" if kwargs.get("fp16", True) else "float32", - ) - if model is not None: - setattr(model, DESCRIPTOR_ATTR, info) - if info.model_id not in client.auto_loaded: - client._on_model_auto_loaded( - model, - load_ms=0, - load_kwargs={"context": {"model_cache_hit": True}}, - ) - handle = client.register_model(model if model is not None else info) - track_transcription( - handle, - audio, - result.get("text", ""), - result.get("language"), - elapsed_ms(t0), + if client.closed: + return result + try: + # mlx_whisper keeps the last model in ModelHolder. It was either + # loaded by this call (and reported by load above) or reused. + model = module.ModelHolder.model + info = descriptor(model) + if info is None: + # Loaded before instrumentation started. + info = describe(model, *requested_model(kwargs)) + setattr(model, DESCRIPTOR_ATTR, info) + if info.model_id not in client.auto_loaded: + # Cached before this client existed: report it once, with no + # load time of its own. + client._on_model_auto_loaded( + model, + load_ms=0, + load_kwargs={"context": {"model_cache_hit": True}}, ) - except Exception as exc: - debug_failure("mlx_whisper", "transcription tracking", exc) + track_transcription( + client.register_model(model), + audio, + result.get("text", ""), + duration_ms=elapsed_ms(t0), + language=result.get("language"), + ) + except Exception as exc: + debug_failure("mlx_whisper", "transcription tracking", exc) return result transcribe.__wildedge_patch_name__ = "mlx_whisper" diff --git a/wildedge/integrations/whisper.py b/wildedge/integrations/whisper.py index 4717312..4559f40 100644 --- a/wildedge/integrations/whisper.py +++ b/wildedge/integrations/whisper.py @@ -3,7 +3,6 @@ from __future__ import annotations import io -import json import mimetypes import wave from dataclasses import dataclass @@ -17,25 +16,59 @@ DESCRIPTOR_ATTR = "__wildedge_whisper_descriptor__" HANDLE_ATTR = "__wildedge_whisper_handle__" +CLIENT_ATTR = "__wildedge_whisper_client__" SAMPLE_RATE = 16000 +# Upper bound for each captured audio or transcript attachment. The attachment +# manager may apply a stricter limit of its own. +MAX_CAPTURE_BYTES = 10 * 1024 * 1024 + +# Languages written without spaces between words, where splitting on +# whitespace does not give a word count. +UNSPACED_LANGUAGES = frozenset({"bo", "ja", "km", "lo", "my", "th", "yue", "zh"}) + @dataclass class WhisperDescriptor: - path: str + """Model identity, worked out once when the model is loaded.""" + + name: str + source: str # "local" or "huggingface" backend: str accelerator: str quantization: str | None = None - @property - def name(self) -> str: - return Path(self.path).name if Path(self.path).exists() else self.path + @classmethod + def from_path( + cls, + path: object, + backend: str, + accelerator: str, + quantization: str | None = None, + ) -> WhisperDescriptor: + name, source = model_name_and_source(str(path)) + return cls(name, source, backend, accelerator, quantization) @property def model_id(self) -> str: return f"{self.backend}:{self.name}" +def model_name_and_source(path: str) -> tuple[str, str]: + """Name a model by its hub repo, its local folder, or the ID as given. + + A Hugging Face cache path (``.../models--org--name/snapshots/``) is + named ``org/name`` rather than after the snapshot hash. + """ + for part in path.replace("\\", "/").split("/"): + if part.startswith("models--"): + return part[len("models--") :].replace("--", "/", 1), "huggingface" + local = Path(path).expanduser() + if local.exists(): + return local.resolve().name, "local" + return path, "huggingface" + + def descriptor(obj: object) -> WhisperDescriptor | None: return ( obj @@ -44,6 +77,19 @@ def descriptor(obj: object) -> WhisperDescriptor | None: ) +def report_load(client, model: object, load_ms: int, **kwargs) -> None: + """Record a model load, including a reload of a model ID seen before. + + The client skips the load event for an ID it already knows, which suits + wrappers around an already loaded model. Whisper loads always read the + weights again, for example when MLX switches back to an earlier model. + """ + info = descriptor(model) + if info is not None: + client.auto_loaded.discard(info.model_id) + client._on_model_auto_loaded(model, load_ms=load_ms, **kwargs) + + class WhisperExtractor(BaseExtractor): def can_handle(self, obj: object) -> bool: return isinstance(descriptor(obj), WhisperDescriptor) @@ -54,9 +100,7 @@ def extract_info(self, obj: object, overrides: dict) -> tuple[str, ModelInfo]: return overrides.pop("id", None) or info.model_id, ModelInfo( model_name=info.name, model_version=overrides.pop("version", "unknown"), - model_source=overrides.pop( - "source", "local" if Path(info.path).exists() else "huggingface" - ), + model_source=overrides.pop("source", info.source), model_format=overrides.pop( "format", "mlx" if info.backend == "mlx_whisper" else "ctranslate2" ), @@ -70,20 +114,7 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: handle.detected_accelerator = info.accelerator -def mlx_descriptor(path: object, dtype: object = "float16") -> WhisperDescriptor: - quantization = "fp16" if "16" in str(dtype) else "fp32" - try: - config = Path(str(path)) / "config.json" - if config.is_file(): - quant = json.loads(config.read_text()).get("quantization") or {} - if isinstance(quant.get("bits"), int): - quantization = f"q{quant['bits']}" - except Exception as exc: - debug_failure("mlx_whisper", "quantization extraction", exc) - return WhisperDescriptor(str(path), "mlx_whisper", "gpu", quantization) - - -def audio_meta(audio: object, duration: float | None = None) -> AudioInputMeta: +def audio_meta(audio: object, audio_duration_s: float | None = None) -> AudioInputMeta: meta = AudioInputMeta() if isinstance(audio, (str, Path)): try: @@ -99,15 +130,15 @@ def audio_meta(audio: object, duration: float | None = None) -> AudioInputMeta: meta.duration_ms = round(audio.shape[0] / SAMPLE_RATE * 1000) meta.sample_rate = SAMPLE_RATE meta.channels = 1 - if duration is not None: - meta.duration_ms = round(duration * 1000) + if audio_duration_s is not None: + meta.duration_ms = round(audio_duration_s * 1000) return meta -def audio_attachments(audio: object, limit: int) -> list[Attachment]: +def audio_attachments(audio: object) -> list[Attachment]: if isinstance(audio, (str, Path)): path = Path(audio) - if path.is_file() and path.stat().st_size <= limit: + if path.is_file() and path.stat().st_size <= MAX_CAPTURE_BYTES: content_type = ( mimetypes.guess_type(str(path))[0] or "application/octet-stream" ) @@ -119,7 +150,10 @@ def audio_attachments(audio: object, limit: int) -> list[Attachment]: name=path.name, ) ] - elif getattr(audio, "ndim", None) == 1 and audio.shape[0] * 2 + 44 <= limit: + elif ( + getattr(audio, "ndim", None) == 1 + and audio.shape[0] * 2 + 44 <= MAX_CAPTURE_BYTES + ): import numpy as np # noqa: PLC0415 -- optional; provided by the active Whisper runtime pcm = (np.clip(np.asarray(audio), -1, 1) * 32767).astype(" list[Attachment]: return [] +def capture(audio: object, text: str) -> list[Attachment]: + attachments = audio_attachments(audio) + encoded = text.encode() + if encoded and len(encoded) <= MAX_CAPTURE_BYTES: + attachments.append( + Attachment( + content_type="text/plain", + role="output", + data=encoded, + name="transcript.txt", + ) + ) + return attachments + + def track_transcription( handle: ModelHandle, audio: object, text: str, - language: str | None, - duration_ms: int, *, - probability: float | None = None, - duration: float | None = None, + duration_ms: int, + language: str | None = None, + language_probability: float | None = None, + audio_duration_s: float | None = None, + word_count: int | None = None, + first_segment_ms: int | None = None, outcome: str = "completed", - words: int | None = None, ) -> None: - context = {"transcript_word_count": len(text.split()) if words is None else words} + """Record one transcription. + + ``duration_ms`` is how long the transcription took; ``audio_duration_s`` + is the length of the audio when the runtime reports it. + """ + context: dict = {} + if language not in UNSPACED_LANGUAGES: + context["transcript_word_count"] = ( + len(text.split()) if word_count is None else word_count + ) if language is not None: context["language"] = language - if probability is not None: - context["language_confidence"] = probability + if language_probability is not None: + context["language_confidence"] = language_probability + if first_segment_ms is not None: + context["time_to_first_segment_ms"] = first_segment_ms attachments = None if handle.capture_attachments is not None: try: - # The manager enforces its own configured size/count limits as well. - limit = 10 * 1024 * 1024 - attachments = audio_attachments(audio, limit) - if text and len(text.encode()) <= limit: - attachments.append( - Attachment( - content_type="text/plain", - role="output", - data=text.encode(), - name="transcript.txt", - ) - ) + attachments = capture(audio, text) except Exception as exc: debug_failure("whisper", "attachment capture", exc) handle.track_inference( duration_ms=duration_ms, input_modality="audio", output_modality="text", - input_meta=audio_meta(audio, duration), + input_meta=audio_meta(audio, audio_duration_s), context=context, attachments=attachments, success=outcome == "completed", From 00dd90e4a5ff59c8928e2612548ae0c4dc9ac938 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 10:38:23 +0200 Subject: [PATCH 41/81] Name Whisper magic values and tidy shared helpers - Name backend, source, WAV encoding and upstream-default constants, and move 16-bit WAV encoding into its own helper. - Share Hugging Face cache path parsing with the ONNX integration, and STREAM_* error codes with the stream integrations. - Add reload= to the client's auto-load callback instead of editing its dedupe set from the integration; record cached MLX loads through one helper. - Expose started/finished on stream wrappers so the segment stream no longer reads private state. - Compare MLX devices directly, leave unknown dtypes unlabelled, bind the MLX loader's signature, and reuse registered handles per transcription. - Clearer names and keyword arguments; tests share the MLX fixture and use a weak-referenceable fake model. --- tests/test_whisper.py | 91 +++++++++++-------- wildedge/client.py | 10 ++- wildedge/integrations/anthropic.py | 3 +- wildedge/integrations/common.py | 26 ++++++ wildedge/integrations/faster_whisper.py | 50 +++++++---- wildedge/integrations/gguf.py | 3 +- wildedge/integrations/mlx_whisper.py | 83 ++++++++++-------- wildedge/integrations/onnx.py | 6 +- wildedge/integrations/openai.py | 5 +- wildedge/integrations/whisper.py | 111 ++++++++++++++++-------- 10 files changed, 252 insertions(+), 136 deletions(-) diff --git a/tests/test_whisper.py b/tests/test_whisper.py index a5230d1..37f246c 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -5,10 +5,11 @@ import sys import wave from types import ModuleType, SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest +from wildedge.client import WildEdge from wildedge.instrumentation import claim_instrumentation from wildedge.integrations import faster_whisper as faster from wildedge.integrations.faster_whisper import ( @@ -25,6 +26,7 @@ model_name_and_source, track_transcription, ) +from wildedge.platforms.device_info import DeviceInfo @pytest.fixture @@ -53,6 +55,18 @@ def tracked_model(handle): return model +def stubbed_client(): + """A second client, set up like the client_with_stubbed_runtime fixture.""" + with ( + patch("wildedge.client.detect_device", return_value=DeviceInfo("id", "linux")), + patch("wildedge.client.Transmitter"), + patch("wildedge.client.Consumer"), + ): + return WildEdge( + dsn="https://secret@ingest.wildedge.dev/key", sampling_interval_s=None + ) + + def events_of(events, event_type): return [e for e in events if e["event_type"] == event_type] @@ -340,6 +354,16 @@ def test_faster_model_stays_with_its_original_client( # mlx-whisper ---------------------------------------------------------------- +class FakeMlxModel: + """Stands in for an MLX Whisper model; weak-referenceable like the real one.""" + + def __init__(self, bits=None): + self.layers = [("", SimpleNamespace(bits=bits))] if bits else [] + + def named_modules(self): + return self.layers + + @pytest.fixture def fake_mlx_whisper(monkeypatch): """mlx_whisper with ModelHolder's caching: one model, reloaded on change.""" @@ -348,8 +372,8 @@ def fake_mlx_whisper(monkeypatch): for n in ("mlx_whisper", "mlx_whisper.transcribe", "mlx_whisper.load_models") ) - def load_model(path, dtype="float32"): - return SimpleNamespace(named_modules=lambda: [("", SimpleNamespace(bits=4))]) + def load_model(path_or_hf_repo, dtype="float32"): + return FakeMlxModel(bits=4) class ModelHolder: model = None @@ -368,34 +392,29 @@ def transcribe(_audio, *, path_or_hf_repo="mlx-community/whisper-tiny", **_kw): module.load_model = loaders.load_model = load_model for mod in (root, module, loaders): monkeypatch.setitem(sys.modules, mod.__name__, mod) + root.holder = ModelHolder return root -def test_mlx_returns_the_original_result(monkeypatch, handle): - root, module, loaders = ( - ModuleType(n) - for n in ("mlx_whisper", "mlx_whisper.transcribe", "mlx_whisper.load_models") - ) - result = {"text": "hello world", "language": "en"} - module.ModelHolder = SimpleNamespace(model=SimpleNamespace()) - module.transcribe = lambda *a, **kw: result - module.load_model = loaders.load_model = lambda *a, **kw: SimpleNamespace() - for mod in (root, module, loaders): - monkeypatch.setitem(sys.modules, mod.__name__, mod) - client = SimpleNamespace( - closed=False, - auto_loaded=set(), - register_model=MagicMock(return_value=handle), - _on_model_auto_loaded=MagicMock(), - ) - install_mlx(lambda: client) - assert root.transcribe("audio.wav", path_or_hf_repo="test") is result - # The model was cached before instrumentation, so it is reported as a hit. - client._on_model_auto_loaded.assert_called_once() - assert client._on_model_auto_loaded.call_args.kwargs["load_kwargs"]["context"][ - "model_cache_hit" - ] - handle.track_inference.assert_called_once() +def test_mlx_returns_the_original_result(fake_mlx_whisper, instrumented_client): + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + result = fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") + assert result == {"text": "hello world", "language": "en"} + assert len(events_of(instrumented_client.events, "inference")) == 1 + + +def test_mlx_model_cached_before_init_is_reported_as_a_hit( + fake_mlx_whisper, instrumented_client +): + fake_mlx_whisper.holder.model = FakeMlxModel() + fake_mlx_whisper.holder.model_path = "org/model" + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") + (load,) = events_of(instrumented_client.events, "model_load") + assert load["model_id"] == "mlx_whisper:org/model" + assert load["attributes"]["model_cache_hit"] is True + registered = instrumented_client.registry.models["mlx_whisper:org/model"] + assert registered.quantization == "f16" def test_mlx_load_reads_quantization(fake_mlx_whisper, instrumented_client): @@ -439,19 +458,17 @@ def test_mlx_sends_nothing_after_client_closes(fake_mlx_whisper, instrumented_cl def test_mlx_replacement_client_reports_the_cached_model( - fake_mlx_whisper, instrumented_client, handle + fake_mlx_whisper, instrumented_client ): install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") instrumented_client.close() - replacement = MagicMock(closed=False, auto_loaded=set()) - replacement.register_model.return_value = handle + replacement = stubbed_client() + replacement_events = [] + replacement.publish = replacement_events.append claim_instrumentation(replacement, "mlx_whisper") fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model") - replacement._on_model_auto_loaded.assert_called_once() - load_context = replacement._on_model_auto_loaded.call_args.kwargs["load_kwargs"][ - "context" - ] - assert load_context["model_cache_hit"] - handle.track_inference.assert_called_once() + (load,) = events_of(replacement_events, "model_load") + assert load["attributes"]["model_cache_hit"] is True + assert len(events_of(replacement_events, "inference")) == 1 assert len(events_of(instrumented_client.events, "inference")) == 1 diff --git a/wildedge/client.py b/wildedge/client.py index 86ce4fe..4ae38dd 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -895,8 +895,14 @@ def _on_model_auto_loaded( downloads: list[dict] | None = None, model_id: str | None = None, load_kwargs: dict | None = None, + reload: bool = False, ) -> None: - """Callback invoked by auto-patches after a model is constructed.""" + """Callback invoked by auto-patches after a model is constructed. + + A model ID seen before normally means a wrapper around a model that is + already loaded, so no second load event is sent. ``reload=True`` says + the weights were loaded again and records the load anyway. + """ if self.closed: return # Always drain thread-local hub buffers to keep them clean. @@ -908,7 +914,7 @@ def _on_model_auto_loaded( downloads = thread_records handle = self.register_model(obj, model_id=model_id) - already_tracked = handle.model_id in self.auto_loaded + already_tracked = handle.model_id in self.auto_loaded and not reload self.auto_loaded.add(handle.model_id) if already_tracked: diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index 89682f9..9e604c8 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -15,6 +15,7 @@ AsyncStreamWrapper, SyncStreamWrapper, debug_failure, + stream_error_code, track_api_error, ) from wildedge.model import ModelInfo @@ -203,7 +204,7 @@ def on_done( input_modality="text", output_modality="generation", success=outcome == "completed", - error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), + error_code=stream_error_code(outcome), input_meta=build_input_meta(messages, ti), output_meta=GenerationOutputMeta( task="generation", diff --git a/wildedge/integrations/common.py b/wildedge/integrations/common.py index 685bce8..282be78 100644 --- a/wildedge/integrations/common.py +++ b/wildedge/integrations/common.py @@ -23,6 +23,23 @@ def debug_failure(framework: str, context: str, exc: BaseException) -> None: logger.debug("wildedge: %s %s failed: %s", framework, context, exc) +# Folder prefix of a repo in the Hugging Face hub cache: models--org--name. +HF_CACHE_REPO_PREFIX = "models--" + + +def hf_repo_from_cache_path(path: str) -> str | None: + """``org/name`` for a path inside the Hugging Face hub cache, else None.""" + for part in path.replace("\\", "/").split("/"): + if part.startswith(HF_CACHE_REPO_PREFIX): + return part[len(HF_CACHE_REPO_PREFIX) :].replace("--", "/", 1) + return None + + +def stream_error_code(outcome: str) -> str | None: + """None for a stream that completed, else ``STREAM_``.""" + return None if outcome == "completed" else "STREAM_" + outcome.upper() + + # --------------------------------------------------------------------------- # Chat API helpers, shared by the openai integration and wildedge.llm_api # --------------------------------------------------------------------------- @@ -329,6 +346,10 @@ def __init__( self._ttft_ms = None self._context = contextvars.copy_context() + @property + def finished(self) -> bool: + return self._finished + def _safe(self, callback: Callable, *args: Any, **kwargs: Any) -> Any: try: return self._context.run(callback, *args, **kwargs) @@ -374,6 +395,11 @@ def __init__( self._original = original self._iterator = None + @property + def started(self) -> bool: + """Whether iteration has begun.""" + return self._iterator is not None + def __iter__(self) -> SyncStreamWrapper: return self diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py index 63c966b..25b2a76 100644 --- a/wildedge/integrations/faster_whisper.py +++ b/wildedge/integrations/faster_whisper.py @@ -16,6 +16,7 @@ from wildedge.integrations.whisper import ( CLIENT_ATTR, DESCRIPTOR_ATTR, + FASTER_WHISPER, HANDLE_ATTR, MAX_CAPTURE_BYTES, WhisperDescriptor, @@ -26,11 +27,25 @@ _PATCH_LOCK = threading.Lock() +# WhisperModel.__init__'s first parameter: a size name, hub repo or local path. +MODEL_PATH_PARAM = "model_size_or_path" + +# Outcome of a segment loop that stopped without reading to the end. +OUTCOME_ABANDONED = "abandoned" + + +def accelerator_for(device: str) -> str: + """WildEdge accelerator for a CTranslate2 device ("cuda" or "cpu"). + + The runtime reports the resolved device, so "auto" doesn't reach here. + """ + return "gpu" if device == "cuda" else "cpu" + class TranscriptCollector: """Counts words as segments arrive and keeps the text when capture is on.""" - def __init__(self, t0: float, capture: bool) -> None: + def __init__(self, t0: float, *, capture: bool) -> None: self._t0 = t0 self.words = 0 self.last_segment_ms: int | None = None @@ -65,11 +80,14 @@ class SegmentStream(SyncStreamWrapper): was needed, decoded nothing and sends no event. """ + # Class-level defaults keep __del__ safe if __init__ never finished; + # otherwise these lookups would fall through to the wrapped generator. + _finished = True + _iterator = None + def __del__(self) -> None: - # vars() avoids __getattr__, which forwards to the wrapped generator. - state = vars(self) - if state.get("_finished") is False and state.get("_iterator") is not None: - self._finish("abandoned") + if self.started and not self.finished: + self._finish(OUTCOME_ABANDONED) def tracked_handle(model): @@ -98,12 +116,14 @@ def transcribe(self, audio, *args, **kwargs): try: track_api_error(handle, exc, elapsed_ms(t0)) except Exception as telemetry_exc: - debug_failure("faster_whisper", "error tracking", telemetry_exc) + debug_failure(FASTER_WHISPER, "error tracking", telemetry_exc) raise - collector = TranscriptCollector(t0, handle.capture_attachments is not None) + collector = TranscriptCollector( + t0, capture=handle.capture_attachments is not None + ) def done(duration_ms, first_segment_ms, *, outcome="completed"): - if outcome == "abandoned" and collector.last_segment_ms is not None: + if outcome == OUTCOME_ABANDONED and collector.last_segment_ms is not None: duration_ms = collector.last_segment_ms track_transcription( handle, @@ -133,7 +153,7 @@ def _install(client_ref): cls = faster_whisper.WhisperModel original = cls.__init__ - if getattr(original, "__wildedge_patch_name__", None) == "faster_whisper": + if getattr(original, "__wildedge_patch_name__", None) == FASTER_WHISPER: return signature = inspect.signature(original) @@ -147,7 +167,7 @@ def init(self, *args, **kwargs): try: bound = signature.bind(self, *args, **kwargs) bound.apply_defaults() - runtime = self.model + runtime = self.model # the ctranslate2.models.Whisper instance device = getattr(runtime, "device", bound.arguments.get("device", "cpu")) compute = getattr( runtime, "compute_type", bound.arguments.get("compute_type") @@ -156,9 +176,9 @@ def init(self, *args, **kwargs): self, DESCRIPTOR_ATTR, WhisperDescriptor.from_path( - bound.arguments["model_size_or_path"], - "faster_whisper", - "gpu" if device == "cuda" else "cpu", + bound.arguments[MODEL_PATH_PARAM], + FASTER_WHISPER, + accelerator_for(device), compute, ), ) @@ -169,9 +189,9 @@ def init(self, *args, **kwargs): # clients do. After a client replacement it warns once instead. setattr(self, CLIENT_ATTR, bind_client_reference(client_ref, client)) except Exception as exc: - debug_failure("faster_whisper", "model registration", exc) + debug_failure(FASTER_WHISPER, "model registration", exc) - init.__wildedge_patch_name__ = "faster_whisper" + init.__wildedge_patch_name__ = FASTER_WHISPER cls.__init__ = init cls.transcribe = wrap_transcribe(cls.transcribe) batched = getattr(faster_whisper, "BatchedInferencePipeline", None) diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index df9c9db..b7d6caf 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -24,6 +24,7 @@ SyncStreamWrapper, build_input_meta, debug_failure, + stream_error_code, ) from wildedge.logging import logger from wildedge.model import ModelInfo @@ -247,7 +248,7 @@ def on_done( output_modality="generation", input_meta=make_gguf_input_meta(prompt, ti, messages), success=outcome == "completed", - error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), + error_code=stream_error_code(outcome), output_meta=make_gguf_output_meta(ti, to, sr, ttft_ms, duration_ms), generation_config=generation_config, attachments=make_gguf_attachments( diff --git a/wildedge/integrations/mlx_whisper.py b/wildedge/integrations/mlx_whisper.py index c9fd819..410e6c5 100644 --- a/wildedge/integrations/mlx_whisper.py +++ b/wildedge/integrations/mlx_whisper.py @@ -5,6 +5,7 @@ import contextvars import functools import importlib +import inspect import threading import time @@ -15,8 +16,10 @@ ) from wildedge.integrations.whisper import ( DESCRIPTOR_ATTR, + MLX_WHISPER, WhisperDescriptor, descriptor, + report_cached_load, report_load, track_transcription, ) @@ -24,41 +27,46 @@ _PATCH_LOCK = threading.Lock() -# mlx_whisper.transcribe's default for path_or_hf_repo. +# Defaults of mlx_whisper.transcribe, which picks the model and its dtype from +# path_or_hf_repo and decode_options["fp16"]. DEFAULT_REPO = "mlx-community/whisper-tiny" +DEFAULT_FP16 = True def mlx_accelerator() -> str: try: import mlx.core as mx # noqa: PLC0415 -- optional runtime - return "cpu" if "cpu" in str(mx.default_device()) else "gpu" + return "cpu" if mx.default_device() == mx.cpu else "gpu" except Exception as exc: - debug_failure("mlx_whisper", "device detection", exc) + debug_failure(MLX_WHISPER, "device detection", exc) return "gpu" -def mlx_quantization(model: object, dtype: object) -> str: - """Bit width of quantized layers, else the weight dtype.""" - try: - for _, layer in model.named_modules(): # type: ignore[union-attr] - bits = getattr(layer, "bits", None) - if isinstance(bits, int): - return f"q{bits}" - except Exception as exc: - debug_failure("mlx_whisper", "quantization detection", exc) - return dtype_to_quantization(str(dtype)) or "f32" +def mlx_quantization(model: object, dtype: object) -> str | None: + """Bit width of quantized layers, else the weight dtype, if recognised.""" + if model is not None: + try: + for _, layer in model.named_modules(): # type: ignore[attr-defined] + bits = getattr(layer, "bits", None) + if isinstance(bits, int): + return f"q{bits}" + except Exception as exc: + debug_failure(MLX_WHISPER, "quantization detection", exc) + return dtype_to_quantization(str(dtype)) -def describe(model: object, path: object, dtype: object) -> WhisperDescriptor: +def describe(path: object, dtype: object, model: object = None) -> WhisperDescriptor: + """Describe a model; without ``model`` (when loading failed) only the dtype + is known.""" return WhisperDescriptor.from_path( - path, "mlx_whisper", mlx_accelerator(), mlx_quantization(model, dtype) + path, MLX_WHISPER, mlx_accelerator(), mlx_quantization(model, dtype) ) def requested_model(kwargs: dict) -> tuple[str, str]: """Model path and dtype that ``mlx_whisper.transcribe`` uses for these arguments.""" - dtype = "float16" if kwargs.get("fp16", True) else "float32" + dtype = "float16" if kwargs.get("fp16", DEFAULT_FP16) else "float32" return kwargs.get("path_or_hf_repo", DEFAULT_REPO), dtype @@ -72,25 +80,32 @@ def _install(client_ref): module = importlib.import_module("mlx_whisper.transcribe") loaders = importlib.import_module("mlx_whisper.load_models") original = module.transcribe - if getattr(original, "__wildedge_patch_name__", None) == "mlx_whisper": + if getattr(original, "__wildedge_patch_name__", None) == MLX_WHISPER: return original_load = module.load_model + load_signature = inspect.signature(original_load) # The client that owns the transcription in progress, so a load inside it # reports to the same client even if the default changes meanwhile. active_client = contextvars.ContextVar("wildedge_mlx_whisper_client", default=None) @functools.wraps(original_load) - def load(path_or_hf_repo, *args, **kwargs): + def load(*args, **kwargs): client = active_client.get() or client_ref() t0 = time.perf_counter() - model = original_load(path_or_hf_repo, *args, **kwargs) + model = original_load(*args, **kwargs) try: - dtype = kwargs.get("dtype", args[0] if args else "float32") - setattr(model, DESCRIPTOR_ATTR, describe(model, path_or_hf_repo, dtype)) + bound = load_signature.bind(*args, **kwargs) + bound.apply_defaults() + info = describe( + bound.arguments["path_or_hf_repo"], + bound.arguments.get("dtype"), + model, + ) + setattr(model, DESCRIPTOR_ATTR, info) if client is not None and not client.closed: report_load(client, model, elapsed_ms(t0)) except Exception as exc: - debug_failure("mlx_whisper", "model registration", exc) + debug_failure(MLX_WHISPER, "model registration", exc) return model @functools.wraps(original) @@ -105,11 +120,10 @@ def transcribe(audio, *args, **kwargs): except Exception as exc: try: if not client.closed: - path, dtype = requested_model(kwargs) - handle = client.register_model(describe(None, path, dtype)) + handle = client.register_model(describe(*requested_model(kwargs))) track_api_error(handle, exc, elapsed_ms(t0)) except Exception as telemetry_exc: - debug_failure("mlx_whisper", "error tracking", telemetry_exc) + debug_failure(MLX_WHISPER, "error tracking", telemetry_exc) raise finally: active_client.reset(token) @@ -122,28 +136,25 @@ def transcribe(audio, *args, **kwargs): info = descriptor(model) if info is None: # Loaded before instrumentation started. - info = describe(model, *requested_model(kwargs)) + info = describe(*requested_model(kwargs), model) setattr(model, DESCRIPTOR_ATTR, info) if info.model_id not in client.auto_loaded: - # Cached before this client existed: report it once, with no - # load time of its own. - client._on_model_auto_loaded( - model, - load_ms=0, - load_kwargs={"context": {"model_cache_hit": True}}, - ) + report_cached_load(client, model) + handle = client.registry.handles.get( + info.model_id + ) or client.register_model(model) track_transcription( - client.register_model(model), + handle, audio, result.get("text", ""), duration_ms=elapsed_ms(t0), language=result.get("language"), ) except Exception as exc: - debug_failure("mlx_whisper", "transcription tracking", exc) + debug_failure(MLX_WHISPER, "transcription tracking", exc) return result - transcribe.__wildedge_patch_name__ = "mlx_whisper" + transcribe.__wildedge_patch_name__ = MLX_WHISPER module.load_model = load loaders.load_model = load module.transcribe = transcribe diff --git a/wildedge/integrations/onnx.py b/wildedge/integrations/onnx.py index adca054..005b31a 100644 --- a/wildedge/integrations/onnx.py +++ b/wildedge/integrations/onnx.py @@ -20,6 +20,7 @@ from wildedge.integrations.common import ( debug_failure, dtype_to_quantization, + hf_repo_from_cache_path, image_brightness_histogram, infer_input_modality_from_names, ) @@ -69,10 +70,7 @@ def model_id_from_path(path: str) -> str | None: - HF Hub cache path (``models--org--name/...``): returns ``org/name`` - Plain file path: returns the stem if it's not a generic name """ - for part in path.replace("\\", "/").split("/"): - if part.startswith("models--"): - return part[len("models--") :].replace("--", "/", 1) - return model_file_stem(path) + return hf_repo_from_cache_path(path) or model_file_stem(path) def model_file_stem(path: object) -> str | None: diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index a95b97b..4781600 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -23,6 +23,7 @@ build_input_meta, debug_failure, source_from_base_url, + stream_error_code, track_api_error, ) from wildedge.model import ModelInfo @@ -190,7 +191,7 @@ def on_done( input_modality="text", output_modality="generation", success=outcome == "completed", - error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), + error_code=stream_error_code(outcome), input_meta=build_input_meta(messages, ti), output_meta=output_meta, api_meta=build_api_meta(last_chunk[0]) @@ -368,7 +369,7 @@ def on_done( success=False, error_code="STREAM_INCOMPLETE" if outcome == "completed" - else "STREAM_" + outcome.upper(), + else stream_error_code(outcome), ) if response is not None and not recorded: recorded = True diff --git a/wildedge/integrations/whisper.py b/wildedge/integrations/whisper.py index 4559f40..886ba8c 100644 --- a/wildedge/integrations/whisper.py +++ b/wildedge/integrations/whisper.py @@ -11,18 +11,37 @@ from wildedge.attachments import Attachment from wildedge.events.inference import AudioInputMeta from wildedge.integrations.base import BaseExtractor -from wildedge.integrations.common import debug_failure +from wildedge.integrations.common import ( + debug_failure, + hf_repo_from_cache_path, + stream_error_code, +) from wildedge.model import ModelHandle, ModelInfo +# Backends, used in model IDs and as the integration names. +FASTER_WHISPER = "faster_whisper" +MLX_WHISPER = "mlx_whisper" +MODEL_FORMATS = {FASTER_WHISPER: "ctranslate2", MLX_WHISPER: "mlx"} + +SOURCE_LOCAL = "local" +SOURCE_HUGGINGFACE = "huggingface" + DESCRIPTOR_ATTR = "__wildedge_whisper_descriptor__" HANDLE_ATTR = "__wildedge_whisper_handle__" CLIENT_ATTR = "__wildedge_whisper_client__" + +# Both libraries expect 1-D sample arrays at this rate. SAMPLE_RATE = 16000 # Upper bound for each captured audio or transcript attachment. The attachment # manager may apply a stricter limit of its own. MAX_CAPTURE_BYTES = 10 * 1024 * 1024 +# Captured arrays are encoded as mono 16-bit PCM WAV. +PCM16_SAMPLE_BYTES = 2 +PCM16_MAX = 32767 +WAV_HEADER_BYTES = 44 + # Languages written without spaces between words, where splitting on # whitespace does not give a word count. UNSPACED_LANGUAGES = frozenset({"bo", "ja", "km", "lo", "my", "th", "yue", "zh"}) @@ -33,7 +52,7 @@ class WhisperDescriptor: """Model identity, worked out once when the model is loaded.""" name: str - source: str # "local" or "huggingface" + source: str backend: str accelerator: str quantization: str | None = None @@ -53,6 +72,10 @@ def from_path( def model_id(self) -> str: return f"{self.backend}:{self.name}" + @property + def model_format(self) -> str: + return MODEL_FORMATS[self.backend] + def model_name_and_source(path: str) -> tuple[str, str]: """Name a model by its hub repo, its local folder, or the ID as given. @@ -60,13 +83,13 @@ def model_name_and_source(path: str) -> tuple[str, str]: A Hugging Face cache path (``.../models--org--name/snapshots/``) is named ``org/name`` rather than after the snapshot hash. """ - for part in path.replace("\\", "/").split("/"): - if part.startswith("models--"): - return part[len("models--") :].replace("--", "/", 1), "huggingface" + repo = hf_repo_from_cache_path(path) + if repo is not None: + return repo, SOURCE_HUGGINGFACE local = Path(path).expanduser() if local.exists(): - return local.resolve().name, "local" - return path, "huggingface" + return local.resolve().name, SOURCE_LOCAL + return path, SOURCE_HUGGINGFACE def descriptor(obj: object) -> WhisperDescriptor | None: @@ -77,20 +100,24 @@ def descriptor(obj: object) -> WhisperDescriptor | None: ) -def report_load(client, model: object, load_ms: int, **kwargs) -> None: - """Record a model load, including a reload of a model ID seen before. +def report_load(client, model: object, load_ms: int) -> None: + """Record a model load. Whisper loads always read the weights again, so a + model ID seen before is still a new load, for example when MLX switches + back to an earlier model.""" + client._on_model_auto_loaded(model, load_ms=load_ms, reload=True) - The client skips the load event for an ID it already knows, which suits - wrappers around an already loaded model. Whisper loads always read the - weights again, for example when MLX switches back to an earlier model. - """ - info = descriptor(model) - if info is not None: - client.auto_loaded.discard(info.model_id) - client._on_model_auto_loaded(model, load_ms=load_ms, **kwargs) + +def report_cached_load(client, model: object) -> None: + """Record a model that was already in memory before this client existed.""" + client._on_model_auto_loaded( + model, load_ms=0, load_kwargs={"context": {"model_cache_hit": True}} + ) class WhisperExtractor(BaseExtractor): + """Must come before MlxExtractor in the client's extractor order, which + would otherwise claim MLX Whisper models as plain MLX models.""" + def can_handle(self, obj: object) -> bool: return isinstance(descriptor(obj), WhisperDescriptor) @@ -101,9 +128,7 @@ def extract_info(self, obj: object, overrides: dict) -> tuple[str, ModelInfo]: model_name=info.name, model_version=overrides.pop("version", "unknown"), model_source=overrides.pop("source", info.source), - model_format=overrides.pop( - "format", "mlx" if info.backend == "mlx_whisper" else "ctranslate2" - ), + model_format=overrides.pop("format", info.model_format), model_family=overrides.pop("family", "whisper"), quantization=overrides.pop("quantization", None) or info.quantization, ) @@ -114,6 +139,10 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: handle.detected_accelerator = info.accelerator +def is_sample_array(audio: object) -> bool: + return getattr(audio, "ndim", None) == 1 + + def audio_meta(audio: object, audio_duration_s: float | None = None) -> AudioInputMeta: meta = AudioInputMeta() if isinstance(audio, (str, Path)): @@ -126,7 +155,7 @@ def audio_meta(audio: object, audio_duration_s: float | None = None) -> AudioInp meta.format = "wav" except (OSError, EOFError, wave.Error): pass - elif getattr(audio, "ndim", None) == 1: + elif is_sample_array(audio): meta.duration_ms = round(audio.shape[0] / SAMPLE_RATE * 1000) meta.sample_rate = SAMPLE_RATE meta.channels = 1 @@ -135,6 +164,24 @@ def audio_meta(audio: object, audio_duration_s: float | None = None) -> AudioInp return meta +def pcm16_wav_size(sample_count: int) -> int: + return WAV_HEADER_BYTES + sample_count * PCM16_SAMPLE_BYTES + + +def pcm16_wav(samples: object) -> bytes: + """Encode float samples in [-1, 1] as a mono 16-bit WAV file.""" + import numpy as np # noqa: PLC0415 -- optional; provided by the active Whisper runtime + + pcm = (np.clip(np.asarray(samples), -1, 1) * PCM16_MAX).astype(" list[Attachment]: if isinstance(audio, (str, Path)): path = Path(audio) @@ -150,31 +197,19 @@ def audio_attachments(audio: object) -> list[Attachment]: name=path.name, ) ] - elif ( - getattr(audio, "ndim", None) == 1 - and audio.shape[0] * 2 + 44 <= MAX_CAPTURE_BYTES - ): - import numpy as np # noqa: PLC0415 -- optional; provided by the active Whisper runtime - - pcm = (np.clip(np.asarray(audio), -1, 1) * 32767).astype(" list[Attachment]: +def transcription_attachments(audio: object, text: str) -> list[Attachment]: attachments = audio_attachments(audio) encoded = text.encode() if encoded and len(encoded) <= MAX_CAPTURE_BYTES: @@ -221,7 +256,7 @@ def track_transcription( attachments = None if handle.capture_attachments is not None: try: - attachments = capture(audio, text) + attachments = transcription_attachments(audio, text) except Exception as exc: debug_failure("whisper", "attachment capture", exc) handle.track_inference( @@ -232,5 +267,5 @@ def track_transcription( context=context, attachments=attachments, success=outcome == "completed", - error_code=None if outcome == "completed" else "STREAM_" + outcome.upper(), + error_code=stream_error_code(outcome), ) From 68998df8b83bde03eacbf8465139c5aa9369d3d8 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:08:35 +0200 Subject: [PATCH 42/81] Report Whisper accelerators with the SDK's names Whisper reported "gpu", which no other integration uses. faster-whisper now passes through CTranslate2's resolved device ("cpu" or "cuda") and MLX reports "mps" for the Apple GPU, matching GGUF, TensorFlow and Keras on the same machine. Accelerator names live in the new wildedge.accelerators module. --- tests/test_whisper.py | 5 +++-- wildedge/accelerators.py | 13 +++++++++++++ wildedge/integrations/faster_whisper.py | 17 ++++++----------- wildedge/integrations/mlx_whisper.py | 6 ++++-- 4 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 wildedge/accelerators.py diff --git a/tests/test_whisper.py b/tests/test_whisper.py index 37f246c..b05ff66 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -309,7 +309,7 @@ def test_faster_constructor_describes_the_runtime( ) info = getattr(model, DESCRIPTOR_ATTR) assert info.model_id == "faster_whisper:small" - assert info.accelerator == "gpu" + assert info.accelerator == "cuda" assert info.quantization == "float16" assert len(events_of(instrumented_client.events, "model_load")) == 1 @@ -421,7 +421,8 @@ def test_mlx_load_reads_quantization(fake_mlx_whisper, instrumented_client): install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model-q4") fake_mlx_whisper.transcribe("audio.wav", path_or_hf_repo="org/model-q4") - assert len(events_of(instrumented_client.events, "model_load")) == 1 + (load,) = events_of(instrumented_client.events, "model_load") + assert load["load"]["accelerator"] == "mps" assert len(events_of(instrumented_client.events, "inference")) == 2 registered = instrumented_client.registry.models["mlx_whisper:org/model-q4"] assert registered.quantization == "q4" diff --git a/wildedge/accelerators.py b/wildedge/accelerators.py new file mode 100644 index 0000000..617e395 --- /dev/null +++ b/wildedge/accelerators.py @@ -0,0 +1,13 @@ +"""Accelerator names reported in model load events and hardware context. + +Integrations report the most specific name they know. PyTorch device types +(for example ``xpu``) pass through as they are, so this list is not closed. +""" + +CPU = "cpu" +CUDA = "cuda" # NVIDIA GPU +ROCM = "rocm" # AMD GPU +MPS = "mps" # Apple GPU (Metal) +COREML = "coreml" # Apple Core ML, which may use the GPU or Neural Engine +DIRECTML = "directml" # Windows DirectML +OPENVINO = "openvino" # Intel OpenVINO diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py index 25b2a76..86940bf 100644 --- a/wildedge/integrations/faster_whisper.py +++ b/wildedge/integrations/faster_whisper.py @@ -7,6 +7,7 @@ import threading import time +from wildedge import accelerators from wildedge.instrumentation import bind_client_reference from wildedge.integrations.common import ( SyncStreamWrapper, @@ -34,14 +35,6 @@ OUTCOME_ABANDONED = "abandoned" -def accelerator_for(device: str) -> str: - """WildEdge accelerator for a CTranslate2 device ("cuda" or "cpu"). - - The runtime reports the resolved device, so "auto" doesn't reach here. - """ - return "gpu" if device == "cuda" else "cpu" - - class TranscriptCollector: """Counts words as segments arrive and keeps the text when capture is on.""" @@ -167,8 +160,10 @@ def init(self, *args, **kwargs): try: bound = signature.bind(self, *args, **kwargs) bound.apply_defaults() - runtime = self.model # the ctranslate2.models.Whisper instance - device = getattr(runtime, "device", bound.arguments.get("device", "cpu")) + # The ctranslate2.models.Whisper instance. Its device is the + # resolved one ("cpu" or "cuda"), already an accelerator name. + runtime = self.model + device = getattr(runtime, "device", None) or accelerators.CPU compute = getattr( runtime, "compute_type", bound.arguments.get("compute_type") ) @@ -178,7 +173,7 @@ def init(self, *args, **kwargs): WhisperDescriptor.from_path( bound.arguments[MODEL_PATH_PARAM], FASTER_WHISPER, - accelerator_for(device), + device, compute, ), ) diff --git a/wildedge/integrations/mlx_whisper.py b/wildedge/integrations/mlx_whisper.py index 410e6c5..61b1372 100644 --- a/wildedge/integrations/mlx_whisper.py +++ b/wildedge/integrations/mlx_whisper.py @@ -9,6 +9,7 @@ import threading import time +from wildedge import accelerators from wildedge.integrations.common import ( debug_failure, dtype_to_quantization, @@ -34,13 +35,14 @@ def mlx_accelerator() -> str: + """MLX runs on the Apple GPU unless the app switched its default to CPU.""" try: import mlx.core as mx # noqa: PLC0415 -- optional runtime - return "cpu" if mx.default_device() == mx.cpu else "gpu" + return accelerators.CPU if mx.default_device() == mx.cpu else accelerators.MPS except Exception as exc: debug_failure(MLX_WHISPER, "device detection", exc) - return "gpu" + return accelerators.MPS def mlx_quantization(model: object, dtype: object) -> str | None: From 87dc3357f150d6c4efd63368f665e7f3364561ee Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:11:36 +0200 Subject: [PATCH 43/81] Use shared accelerator names across the SDK - Platforms, model defaults, events and every integration use the constants in wildedge.accelerators instead of bare strings. - One helper each for torch module devices (PyTorch, Transformers, Ultralytics), TensorFlow/Keras weight devices, and the MLX default device (MLX-LM, MLX Whisper), replacing per-integration copies. - MLX-LM models now report their device; they always reported "cpu". - PyTorch no longer reports the string "None" for a parameter without a device type. --- CHANGELOG.md | 5 ++ tests/test_accelerators.py | 80 +++++++++++++++++++++++++++ tests/test_integrations.py | 2 +- wildedge/events/model_load.py | 3 +- wildedge/integrations/common.py | 36 +++++++++++- wildedge/integrations/gguf.py | 4 +- wildedge/integrations/keras.py | 12 ++-- wildedge/integrations/mlx.py | 3 +- wildedge/integrations/mlx_whisper.py | 13 +---- wildedge/integrations/onnx.py | 18 +++--- wildedge/integrations/pytorch.py | 20 ++----- wildedge/integrations/tensorflow.py | 12 ++-- wildedge/integrations/transformers.py | 14 +++-- wildedge/integrations/ultralytics.py | 15 ++--- wildedge/model.py | 3 +- wildedge/platforms/base.py | 4 +- wildedge/platforms/linux.py | 11 ++-- wildedge/platforms/macos.py | 5 +- wildedge/platforms/unknown.py | 3 +- wildedge/platforms/windows.py | 5 +- 20 files changed, 183 insertions(+), 85 deletions(-) create mode 100644 tests/test_accelerators.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca5c44..84b615e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ under Unreleased and move into a version section at release time. ### Fixed +- MLX-LM models now report the device they run on (`mps` for the Apple GPU) + instead of always `cpu`. Accelerator names are defined once in + `wildedge.accelerators`, and PyTorch, Transformers, Ultralytics, + TensorFlow and Keras share their device detection. + - Process-wide integrations and hub patches now support sequential SDK client replacement after the previous owner closes. Reinitialization replaces a closed default; live ownership conflicts raise explicitly. Existing models/provider diff --git a/tests/test_accelerators.py b/tests/test_accelerators.py new file mode 100644 index 0000000..dccf2c8 --- /dev/null +++ b/tests/test_accelerators.py @@ -0,0 +1,80 @@ +"""Accelerator names are shared across integrations and reported consistently.""" + +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + +import pytest + +from wildedge import accelerators +from wildedge.integrations.common import ( + mlx_accelerator, + tf_weights_accelerator, + torch_module_accelerator, +) +from wildedge.integrations.mlx import MlxExtractor + + +def torch_like(*device_types): + params = [SimpleNamespace(device=SimpleNamespace(type=t)) for t in device_types] + return SimpleNamespace(parameters=lambda: iter(params)) + + +@pytest.mark.parametrize("device_type", ["cpu", "cuda", "mps", "xpu"]) +def test_torch_module_reports_its_parameter_device(device_type): + assert torch_module_accelerator(torch_like(device_type)) == device_type + + +def test_torch_module_without_parameters_has_no_device(): + assert torch_module_accelerator(torch_like()) is None + + +@pytest.mark.parametrize( + ("device", "expected"), + [ + ("/job:localhost/replica:0/task:0/device:GPU:0", accelerators.CUDA), + ("/job:localhost/replica:0/task:0/device:CPU:0", accelerators.CPU), + ], +) +def test_tf_weights_report_the_platform_gpu(device, expected): + model = SimpleNamespace(weights=[SimpleNamespace(device=device)]) + with patch( + "wildedge.platforms.CURRENT_PLATFORM.gpu_accelerator_for_offload", + return_value=accelerators.CUDA, + ): + assert tf_weights_accelerator(model) == expected + + +def test_tf_model_without_weights_is_cpu(): + assert tf_weights_accelerator(SimpleNamespace(weights=[])) == accelerators.CPU + + +@pytest.fixture +def fake_mlx(monkeypatch): + mlx, core = ModuleType("mlx"), ModuleType("mlx.core") + core.cpu, core.gpu = "cpu-device", "gpu-device" + core.default = core.gpu + core.default_device = lambda: core.default + mlx.core = core + monkeypatch.setitem(sys.modules, "mlx", mlx) + monkeypatch.setitem(sys.modules, "mlx.core", core) + return core + + +def test_mlx_reports_the_apple_gpu_by_default(fake_mlx): + assert mlx_accelerator() == accelerators.MPS + + +def test_mlx_follows_a_cpu_default_device(fake_mlx): + fake_mlx.default = fake_mlx.cpu + assert mlx_accelerator() == accelerators.CPU + + +def test_mlx_lm_models_report_their_device(fake_mlx): + class Model: + def __call__(self, *args): + return None + + handle = SimpleNamespace(detected_accelerator=accelerators.CPU) + MlxExtractor().install_hooks(Model(), handle) + assert handle.detected_accelerator == accelerators.MPS diff --git a/tests/test_integrations.py b/tests/test_integrations.py index fdde21d..e6f42fe 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -667,7 +667,7 @@ def test_detect_accelerator_gpu_from_weight_device(self): model = _FakeKerasModel() model.weights = [MagicMock(device="/GPU:0")] with patch( - "wildedge.integrations.keras.CURRENT_PLATFORM.gpu_accelerator_for_offload", + "wildedge.platforms.CURRENT_PLATFORM.gpu_accelerator_for_offload", return_value="cuda", ): assert keras_detect_accelerator(model) == "cuda" diff --git a/wildedge/events/model_load.py b/wildedge/events/model_load.py index 43f1ad2..b58ed16 100644 --- a/wildedge/events/model_load.py +++ b/wildedge/events/model_load.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from typing import Any +from wildedge import accelerators from wildedge.events.common import add_optional_fields @@ -42,7 +43,7 @@ class ModelLoadEvent: model_id: str duration_ms: int memory_bytes: int | None = None - accelerator: str = "cpu" + accelerator: str = accelerators.CPU success: bool = True error_code: str | None = None peak_memory_bytes: int | None = None diff --git a/wildedge/integrations/common.py b/wildedge/integrations/common.py index 282be78..5bd31f9 100644 --- a/wildedge/integrations/common.py +++ b/wildedge/integrations/common.py @@ -9,10 +9,11 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse -from wildedge import constants +from wildedge import accelerators, constants from wildedge.events.error import ErrorCode from wildedge.events.inference import TextInputMeta from wildedge.logging import logger +from wildedge.platforms import CURRENT_PLATFORM from wildedge.timing import elapsed_ms if TYPE_CHECKING: @@ -35,6 +36,39 @@ def hf_repo_from_cache_path(path: str) -> str | None: return None +def torch_module_accelerator(module: object) -> str | None: + """Device type of a torch module's first parameter, such as cpu, cuda, + mps or xpu. None when the module has no parameters.""" + try: + first = next(module.parameters()) # type: ignore[attr-defined] + except StopIteration: + return None + device_type = getattr(first.device, "type", None) + return str(device_type) if device_type else None + + +def tf_weights_accelerator(model: object) -> str: + """The platform's GPU when a TensorFlow/Keras model's first weight is on a + GPU, else CPU. TF names devices like ``/job:localhost/.../device:GPU:0``.""" + weights = getattr(model, "weights", None) or [] + if weights: + device = str(getattr(weights[0], "device", "") or "").upper() + if "GPU" in device or "CUDA" in device: + return CURRENT_PLATFORM.gpu_accelerator_for_offload() + return accelerators.CPU + + +def mlx_accelerator() -> str: + """MLX runs on the Apple GPU unless the app switched its default to CPU.""" + try: + import mlx.core as mx # noqa: PLC0415 -- optional runtime + + return accelerators.CPU if mx.default_device() == mx.cpu else accelerators.MPS + except Exception as exc: + debug_failure("mlx", "device detection", exc) + return accelerators.MPS + + def stream_error_code(outcome: str) -> str | None: """None for a stream that completed, else ``STREAM_``.""" return None if outcome == "completed" else "STREAM_" + outcome.upper() diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index b7d6caf..460b1eb 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import constants +from wildedge import accelerators, constants from wildedge.attachments import Attachment from wildedge.events.inference import ( GenerationConfig, @@ -76,7 +76,7 @@ def detect_accelerator(obj: object) -> str: return CURRENT_PLATFORM.gpu_accelerator_for_offload() except Exception as exc: debug_gguf_failure("accelerator detection", exc) - return "cpu" + return accelerators.CPU def parse_quantization(filename: str) -> str | None: diff --git a/wildedge/integrations/keras.py b/wildedge/integrations/keras.py index 51b55d5..f54c8a0 100644 --- a/wildedge/integrations/keras.py +++ b/wildedge/integrations/keras.py @@ -11,16 +11,16 @@ except ImportError: _np = None # type: ignore[assignment] -from wildedge import constants +from wildedge import accelerators, constants from wildedge.events.inference import ClassificationOutputMeta, TopKPrediction from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( debug_failure, infer_input_modality_from_layer_types, num_classes_from_output_shape, + tf_weights_accelerator, ) from wildedge.model import ModelInfo -from wildedge.platforms import CURRENT_PLATFORM from wildedge.timing import elapsed_ms if TYPE_CHECKING: @@ -108,14 +108,10 @@ def patched_call(self_inner, *args, **kwargs): def detect_accelerator(obj: object) -> str: try: - weights = getattr(obj, "weights", None) or [] - if weights: - device = str(getattr(weights[0], "device", "") or "") - if any(g in device.upper() for g in ("GPU", "CUDA")): - return CURRENT_PLATFORM.gpu_accelerator_for_offload() + return tf_weights_accelerator(obj) except Exception as exc: debug_keras_failure("accelerator detection", exc) - return "cpu" + return accelerators.CPU class KerasExtractor(BaseExtractor): diff --git a/wildedge/integrations/mlx.py b/wildedge/integrations/mlx.py index fbc243e..9a63a6f 100644 --- a/wildedge/integrations/mlx.py +++ b/wildedge/integrations/mlx.py @@ -10,7 +10,7 @@ from wildedge import constants from wildedge.events.inference import GenerationOutputMeta, TextInputMeta from wildedge.integrations.base import BaseExtractor, patch_instance_call_once -from wildedge.integrations.common import debug_failure +from wildedge.integrations.common import debug_failure, mlx_accelerator from wildedge.model import ModelInfo from wildedge.timing import elapsed_ms @@ -171,6 +171,7 @@ def memory_bytes(self, obj: object) -> int | None: return None def install_hooks(self, obj: object, handle: ModelHandle) -> None: + handle.detected_accelerator = mlx_accelerator() setattr(obj, MLX_HANDLE_ATTR, handle) patch_instance_call_once( obj, diff --git a/wildedge/integrations/mlx_whisper.py b/wildedge/integrations/mlx_whisper.py index 61b1372..7d00415 100644 --- a/wildedge/integrations/mlx_whisper.py +++ b/wildedge/integrations/mlx_whisper.py @@ -9,10 +9,10 @@ import threading import time -from wildedge import accelerators from wildedge.integrations.common import ( debug_failure, dtype_to_quantization, + mlx_accelerator, track_api_error, ) from wildedge.integrations.whisper import ( @@ -34,17 +34,6 @@ DEFAULT_FP16 = True -def mlx_accelerator() -> str: - """MLX runs on the Apple GPU unless the app switched its default to CPU.""" - try: - import mlx.core as mx # noqa: PLC0415 -- optional runtime - - return accelerators.CPU if mx.default_device() == mx.cpu else accelerators.MPS - except Exception as exc: - debug_failure(MLX_WHISPER, "device detection", exc) - return accelerators.MPS - - def mlx_quantization(model: object, dtype: object) -> str | None: """Bit width of quantized layers, else the weight dtype, if recognised.""" if model is not None: diff --git a/wildedge/integrations/onnx.py b/wildedge/integrations/onnx.py index 005b31a..73f7c28 100644 --- a/wildedge/integrations/onnx.py +++ b/wildedge/integrations/onnx.py @@ -8,7 +8,7 @@ import time from typing import TYPE_CHECKING, Any -from wildedge import constants +from wildedge import accelerators, constants from wildedge.events.inference import ( ClassificationOutputMeta, HistogramSummary, @@ -103,13 +103,13 @@ def is_ort_session(obj: object) -> bool: _PROVIDER_TO_ACCELERATOR: dict[str, str] = { - "CUDAExecutionProvider": "cuda", - "TensorrtExecutionProvider": "cuda", - "ROCMExecutionProvider": "rocm", - "MIGraphXExecutionProvider": "rocm", - "CoreMLExecutionProvider": "coreml", - "DmlExecutionProvider": "directml", - "OpenVINOExecutionProvider": "openvino", + "CUDAExecutionProvider": accelerators.CUDA, + "TensorrtExecutionProvider": accelerators.CUDA, + "ROCMExecutionProvider": accelerators.ROCM, + "MIGraphXExecutionProvider": accelerators.ROCM, + "CoreMLExecutionProvider": accelerators.COREML, + "DmlExecutionProvider": accelerators.DIRECTML, + "OpenVINOExecutionProvider": accelerators.OPENVINO, } @@ -121,7 +121,7 @@ def detect_accelerator(session: Any) -> str: return acc except Exception as exc: debug_onnx_failure("accelerator detection", exc) - return "cpu" + return accelerators.CPU def detect_quantization(session: Any) -> str | None: diff --git a/wildedge/integrations/pytorch.py b/wildedge/integrations/pytorch.py index 1171a39..1557418 100644 --- a/wildedge/integrations/pytorch.py +++ b/wildedge/integrations/pytorch.py @@ -7,6 +7,7 @@ import time from typing import TYPE_CHECKING +from wildedge import accelerators from wildedge.events.inference import ( ClassificationOutputMeta, HistogramSummary, @@ -20,6 +21,7 @@ dtype_to_quantization, image_brightness_histogram, infer_input_modality_from_names, + torch_module_accelerator, ) from wildedge.logging import logger from wildedge.model import ModelInfo @@ -57,22 +59,12 @@ def is_torch_module(obj: object) -> bool: ) -def parameter_device_type(obj: object) -> str | None: +def detect_accelerator(obj: object) -> str: try: - first_param = next(obj.parameters()) # type: ignore[union-attr] - except StopIteration: - return None + return torch_module_accelerator(obj) or accelerators.CPU except Exception as exc: - debug_pytorch_failure("parameter device discovery", exc) - return None - return str(getattr(first_param.device, "type", "") or None) - - -def detect_accelerator(obj: object) -> str: - device_type = parameter_device_type(obj) - if device_type: - return device_type # 'cpu', 'cuda', 'mps', 'xpu', etc. - return "cpu" + debug_pytorch_failure("accelerator detection", exc) + return accelerators.CPU def detect_quantization(obj: object) -> str | None: diff --git a/wildedge/integrations/tensorflow.py b/wildedge/integrations/tensorflow.py index d8b3c6f..3deb40c 100644 --- a/wildedge/integrations/tensorflow.py +++ b/wildedge/integrations/tensorflow.py @@ -12,16 +12,16 @@ except ImportError: _np = None # type: ignore[assignment] -from wildedge import constants +from wildedge import accelerators, constants from wildedge.events.inference import ClassificationOutputMeta, TopKPrediction from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( debug_failure, infer_input_modality_from_layer_types, num_classes_from_output_shape, + tf_weights_accelerator, ) from wildedge.model import ModelInfo -from wildedge.platforms import CURRENT_PLATFORM from wildedge.timing import elapsed_ms if TYPE_CHECKING: @@ -80,14 +80,10 @@ def is_tensorflow_model(obj: object) -> bool: def detect_accelerator(obj: object) -> str: try: - weights = getattr(obj, "weights", None) or [] - if weights: - device = str(getattr(weights[0], "device", "") or "") - if any(g in device.upper() for g in ("GPU", "CUDA")): - return CURRENT_PLATFORM.gpu_accelerator_for_offload() + return tf_weights_accelerator(obj) except Exception as exc: debug_tensorflow_failure("accelerator detection", exc) - return "cpu" + return accelerators.CPU def build_patched_call( diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index 5c50956..1cf3078 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -9,7 +9,7 @@ from collections import Counter from typing import TYPE_CHECKING -from wildedge import constants +from wildedge import accelerators, constants from wildedge.events.inference import ( ClassificationOutputMeta, EmbeddingOutputMeta, @@ -18,7 +18,11 @@ TopKPrediction, ) from wildedge.integrations.base import BaseExtractor -from wildedge.integrations.common import debug_failure, dtype_to_quantization +from wildedge.integrations.common import ( + debug_failure, + dtype_to_quantization, + torch_module_accelerator, +) from wildedge.model import ModelInfo from wildedge.timing import elapsed_ms @@ -136,12 +140,12 @@ def detect_quantization(obj: object) -> str | None: def detect_accelerator(obj: object) -> str: try: + # A pipeline wraps the model; a bare model is its own module. model = getattr(obj, "model", obj) - first = next(model.parameters()) # type: ignore[union-attr] - return str(getattr(first.device, "type", "cpu") or "cpu") + return torch_module_accelerator(model) or accelerators.CPU except Exception as exc: debug_transformers_failure("accelerator detection", exc) - return "cpu" + return accelerators.CPU def infer_task_from_arch(arch: str | None) -> str | None: diff --git a/wildedge/integrations/ultralytics.py b/wildedge/integrations/ultralytics.py index f929672..7547bf0 100644 --- a/wildedge/integrations/ultralytics.py +++ b/wildedge/integrations/ultralytics.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import constants +from wildedge import accelerators, constants from wildedge.events.inference import ( ClassificationOutputMeta, DetectionOutputMeta, @@ -22,6 +22,7 @@ debug_failure, dtype_to_quantization, image_brightness_histogram, + torch_module_accelerator, ) from wildedge.logging import logger from wildedge.model import ModelInfo @@ -81,18 +82,12 @@ def is_yolo(obj: object) -> bool: def detect_accelerator(obj: object) -> str: try: - inner = getattr(obj, "model", None) + inner = getattr(obj, "model", None) # the torch module inside YOLO if inner is not None: - try: - first_param = next(inner.parameters()) - device_type = str(getattr(first_param.device, "type", "")) - if device_type: - return device_type - except StopIteration: - pass + return torch_module_accelerator(inner) or accelerators.CPU except Exception as exc: debug_ultralytics_failure("accelerator detection", exc) - return "cpu" + return accelerators.CPU def detect_quantization(obj: object) -> str | None: diff --git a/wildedge/model.py b/wildedge/model.py index bfbcab8..119c646 100644 --- a/wildedge/model.py +++ b/wildedge/model.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +from wildedge import accelerators from wildedge.attachments import Attachment from wildedge.events import ( ApiMeta, @@ -68,7 +69,7 @@ def __init__( self.info = info self.publish = publish self.capture_attachments = capture_attachments - self.detected_accelerator: str = "cpu" + self.detected_accelerator: str = accelerators.CPU self.last_inference_id: str | None = None def track_load( diff --git a/wildedge/platforms/base.py b/wildedge/platforms/base.py index 62df959..7ebefd5 100644 --- a/wildedge/platforms/base.py +++ b/wildedge/platforms/base.py @@ -11,7 +11,7 @@ from datetime import datetime from pathlib import Path -from wildedge import constants +from wildedge import accelerators, constants from wildedge.logging import logger from wildedge.platforms.device_info import DeviceInfo from wildedge.platforms.hardware import HardwareContext, ThermalContext @@ -113,7 +113,7 @@ def detect_device( cpu_cores=os.cpu_count(), ram_total_bytes=self.ram_bytes(), disk_total_bytes=self.disk_bytes(), - accelerators=["cpu", *gpu_accs], + accelerators=[accelerators.CPU, *gpu_accs], gpu_name=gpu_name, ) if overrides: diff --git a/wildedge/platforms/linux.py b/wildedge/platforms/linux.py index c189e4a..13e70ba 100644 --- a/wildedge/platforms/linux.py +++ b/wildedge/platforms/linux.py @@ -5,6 +5,7 @@ import shutil from pathlib import Path +from wildedge import accelerators from wildedge.platforms.base import ( Platform, cuda_device_count, @@ -91,18 +92,18 @@ def disk_bytes(self) -> int | None: return None def gpu_accelerators(self) -> tuple[list[str], str | None]: - accelerators = [] + found = [] gpu_name = None if cuda_device_count("libcuda.so.1") > 0: - accelerators.append("cuda") + found.append(accelerators.CUDA) gpu_name = nvml_gpu_name("libnvidia-ml.so.1") if hip_device_count("libamdhip64.so") > 0: - accelerators.append("rocm") - return accelerators, gpu_name + found.append(accelerators.ROCM) + return found, gpu_name def gpu_accelerator_for_offload(self) -> str: accs, _ = self.gpu_accelerators() - return accs[0] if accs else "cpu" + return accs[0] if accs else accelerators.CPU def cpu_freq(self) -> tuple[int | None, int | None]: """Read current and max CPU frequency (MHz) from cpufreq for cpu0.""" diff --git a/wildedge/platforms/macos.py b/wildedge/platforms/macos.py index 99ef144..2321356 100644 --- a/wildedge/platforms/macos.py +++ b/wildedge/platforms/macos.py @@ -5,6 +5,7 @@ import shutil from pathlib import Path +from wildedge import accelerators from wildedge.platforms.base import Platform, debug_detection_failure from wildedge.platforms.hardware import ThermalContext @@ -111,11 +112,11 @@ def os_version(self) -> str | None: def gpu_accelerators(self) -> tuple[list[str], str | None]: if platform.machine() == "arm64": - return ["mps"], None + return [accelerators.MPS], None return [], None def gpu_accelerator_for_offload(self) -> str: - return "mps" if platform.machine() == "arm64" else "cpu" + return accelerators.MPS if platform.machine() == "arm64" else accelerators.CPU def cpu_freq(self) -> tuple[int | None, int | None]: """Read CPU frequency via sysctl. diff --git a/wildedge/platforms/unknown.py b/wildedge/platforms/unknown.py index 973dd4e..f89aecc 100644 --- a/wildedge/platforms/unknown.py +++ b/wildedge/platforms/unknown.py @@ -5,6 +5,7 @@ import sys from pathlib import Path +from wildedge import accelerators from wildedge.platforms.base import Platform, debug_detection_failure @@ -41,4 +42,4 @@ def gpu_accelerators(self) -> tuple[list[str], str | None]: return [], None def gpu_accelerator_for_offload(self) -> str: - return "cpu" + return accelerators.CPU diff --git a/wildedge/platforms/windows.py b/wildedge/platforms/windows.py index 67dbd48..7aca452 100644 --- a/wildedge/platforms/windows.py +++ b/wildedge/platforms/windows.py @@ -6,6 +6,7 @@ import shutil from pathlib import Path +from wildedge import accelerators from wildedge.platforms.base import ( Platform, cuda_device_count, @@ -99,12 +100,12 @@ def disk_bytes(self) -> int | None: def gpu_accelerators(self) -> tuple[list[str], str | None]: if cuda_device_count("nvcuda.dll") > 0: - return ["cuda"], nvml_gpu_name("nvml.dll") + return [accelerators.CUDA], nvml_gpu_name("nvml.dll") return [], None def gpu_accelerator_for_offload(self) -> str: accs, _ = self.gpu_accelerators() - return accs[0] if accs else "cpu" + return accs[0] if accs else accelerators.CPU def battery(self) -> tuple[float | None, bool | None]: """Read battery level and charging state via GetSystemPowerStatus.""" From d20e737bc3b1261553d09b04cada2cbca0d3afb4 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:23:54 +0200 Subject: [PATCH 44/81] Use shared quantization labels across the SDK - Add wildedge.quantizations with the shared labels and int_bits(). - One MLX layer check for MLX-LM and MLX Whisper; N-bit MLX weights report int like Transformers instead of q. - faster-whisper maps CTranslate2 compute types to weight precision. - GGUF maps numeric general.file_type values to type names and reports the i8 filename suffix as int8. - MLX Whisper builds labels directly instead of round-tripping dtype names. --- CHANGELOG.md | 10 ++++ tests/test_quantizations.py | 75 +++++++++++++++++++++++++ tests/test_whisper.py | 4 +- wildedge/integrations/common.py | 32 ++++++++--- wildedge/integrations/faster_whisper.py | 17 +++++- wildedge/integrations/gguf.py | 61 ++++++++++++++++++-- wildedge/integrations/mlx.py | 22 ++++---- wildedge/integrations/mlx_whisper.py | 34 +++++------ wildedge/integrations/pytorch.py | 6 +- wildedge/integrations/transformers.py | 4 +- wildedge/quantizations.py | 17 ++++++ 11 files changed, 233 insertions(+), 49 deletions(-) create mode 100644 tests/test_quantizations.py create mode 100644 wildedge/quantizations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b615e..f091760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ under Unreleased and move into a version section at release time. ### Fixed +- Quantization labels are defined once in `wildedge.quantizations` and used + by every integration. Behaviour changes: + - MLX-LM reports N-bit weights as `int4`/`int8`, like Transformers, instead + of `q4`/`q8`. + - GGUF models labelled from `general.file_type` report the type name (for + example `q4_k_m`) instead of its number, and the `i8` filename suffix + reports `int8`. + - faster-whisper reports weight precision (`int8`, `f16`) instead of + CTranslate2 compute types such as `int8_float32`. + - MLX-LM models now report the device they run on (`mps` for the Apple GPU) instead of always `cpu`. Accelerator names are defined once in `wildedge.accelerators`, and PyTorch, Transformers, Ultralytics, diff --git a/tests/test_quantizations.py b/tests/test_quantizations.py new file mode 100644 index 0000000..d16db31 --- /dev/null +++ b/tests/test_quantizations.py @@ -0,0 +1,75 @@ +"""Quantization labels are shared across integrations.""" + +from types import SimpleNamespace + +import pytest + +from wildedge import quantizations +from wildedge.integrations.common import dtype_to_quantization, mlx_layer_quantization +from wildedge.integrations.faster_whisper import COMPUTE_TYPE_QUANTIZATION +from wildedge.integrations.gguf import file_type_quantization, parse_quantization + + +@pytest.mark.parametrize( + ("dtype", "expected"), + [ + ("torch.bfloat16", quantizations.BF16), + ("torch.float16", quantizations.F16), + ("tensor(float16)", quantizations.F16), + ("mlx.core.float32", quantizations.F32), + ("torch.qint8", quantizations.INT8), + ], +) +def test_dtype_names(dtype, expected): + assert dtype_to_quantization(dtype) == expected + + +@pytest.mark.parametrize( + ("compute_type", "expected"), + [ + ("int8_float16", quantizations.INT8), + ("int8_bfloat16", quantizations.INT8), + ("float16", quantizations.F16), + ("int16", "int16"), + ], +) +def test_ctranslate2_compute_types_use_weight_precision(compute_type, expected): + assert COMPUTE_TYPE_QUANTIZATION[compute_type] == expected + + +@pytest.mark.parametrize( + ("filename", "expected"), + [("llama-7b.Q4_K_M", "q4_k_m"), ("model-f16", "f16"), ("model.i8", "int8")], +) +def test_gguf_filename_suffixes(filename, expected): + assert parse_quantization(filename) == expected + + +@pytest.mark.parametrize( + ("file_type", "expected"), + [("15", "q4_k_m"), (0, quantizations.F32), ("32", quantizations.BF16)], +) +def test_gguf_file_types(file_type, expected): + assert file_type_quantization(file_type) == expected + + +@pytest.mark.parametrize("file_type", ["1024", "not-a-number", "5"]) +def test_unknown_gguf_file_types_are_not_reported(file_type): + assert file_type_quantization(file_type) is None + + +class QuantizedLinear: + pass + + +@pytest.mark.parametrize( + ("layers", "expected"), + [ + ([SimpleNamespace(bits=4)], "int4"), + ([QuantizedLinear()], quantizations.QUANTIZED), + ([SimpleNamespace()], None), + ], +) +def test_mlx_layers(layers, expected): + model = SimpleNamespace(named_modules=lambda: [("", layer) for layer in layers]) + assert mlx_layer_quantization(model) == expected diff --git a/tests/test_whisper.py b/tests/test_whisper.py index b05ff66..c89efb4 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -310,7 +310,7 @@ def test_faster_constructor_describes_the_runtime( info = getattr(model, DESCRIPTOR_ATTR) assert info.model_id == "faster_whisper:small" assert info.accelerator == "cuda" - assert info.quantization == "float16" + assert info.quantization == "f16" assert len(events_of(instrumented_client.events, "model_load")) == 1 @@ -425,7 +425,7 @@ def test_mlx_load_reads_quantization(fake_mlx_whisper, instrumented_client): assert load["load"]["accelerator"] == "mps" assert len(events_of(instrumented_client.events, "inference")) == 2 registered = instrumented_client.registry.models["mlx_whisper:org/model-q4"] - assert registered.quantization == "q4" + assert registered.quantization == "int4" def test_mlx_reload_after_switching_models_is_reported( diff --git a/wildedge/integrations/common.py b/wildedge/integrations/common.py index 5bd31f9..f51d8ec 100644 --- a/wildedge/integrations/common.py +++ b/wildedge/integrations/common.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse -from wildedge import accelerators, constants +from wildedge import accelerators, constants, quantizations from wildedge.events.error import ErrorCode from wildedge.events.inference import TextInputMeta from wildedge.logging import logger @@ -69,6 +69,19 @@ def mlx_accelerator() -> str: return accelerators.MPS +def mlx_layer_quantization(model: object) -> str | None: + """Quantization of an MLX model's layers: the bit width of the first + quantized layer, ``quantized`` when the width is unknown, or None when no + layer is quantized.""" + for _, layer in model.named_modules(): # type: ignore[attr-defined] + bits = getattr(layer, "bits", None) + if isinstance(bits, int): + return quantizations.int_bits(bits) + if "quantized" in type(layer).__name__.lower(): + return quantizations.QUANTIZED + return None + + def stream_error_code(outcome: str) -> str | None: """None for a stream that completed, else ``STREAM_``.""" return None if outcome == "completed" else "STREAM_" + outcome.upper() @@ -215,14 +228,17 @@ def build_input_meta(messages: list, tokens_in: int | None) -> TextInputMeta | N ) +# Substrings of framework dtype names (torch.float16, tensor(float16), +# mlx.core.bfloat16, torch.qint8). Checked in order, so bfloat16 must come +# before float16, which it contains. DTYPE_QUANTIZATION_MAP: dict[str, str] = { - "bfloat16": "bf16", - "float16": "f16", - "float32": "f32", - "int8": "int8", - "qint": "int8", - "quint": "int8", - "uint8": "int8", + "bfloat16": quantizations.BF16, + "float16": quantizations.F16, + "float32": quantizations.F32, + "int8": quantizations.INT8, + "qint": quantizations.INT8, + "quint": quantizations.INT8, + "uint8": quantizations.INT8, } diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py index 86940bf..7f10ea7 100644 --- a/wildedge/integrations/faster_whisper.py +++ b/wildedge/integrations/faster_whisper.py @@ -7,7 +7,7 @@ import threading import time -from wildedge import accelerators +from wildedge import accelerators, quantizations from wildedge.instrumentation import bind_client_reference from wildedge.integrations.common import ( SyncStreamWrapper, @@ -31,6 +31,19 @@ # WhisperModel.__init__'s first parameter: a size name, hub repo or local path. MODEL_PATH_PARAM = "model_size_or_path" +# CTranslate2 compute types, labelled by weight precision. The activation type +# after "int8_" isn't reported. Unknown types pass through as they are. +COMPUTE_TYPE_QUANTIZATION = { + "float32": quantizations.F32, + "float16": quantizations.F16, + "bfloat16": quantizations.BF16, + "int8": quantizations.INT8, + "int8_float32": quantizations.INT8, + "int8_float16": quantizations.INT8, + "int8_bfloat16": quantizations.INT8, + "int16": quantizations.int_bits(16), +} + # Outcome of a segment loop that stopped without reading to the end. OUTCOME_ABANDONED = "abandoned" @@ -174,7 +187,7 @@ def init(self, *args, **kwargs): bound.arguments[MODEL_PATH_PARAM], FASTER_WHISPER, device, - compute, + COMPUTE_TYPE_QUANTIZATION.get(compute, compute), ), ) report_load(client, self, elapsed_ms(t0)) diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index 460b1eb..e88cc28 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import accelerators, constants +from wildedge import accelerators, constants, quantizations from wildedge.attachments import Attachment from wildedge.events.inference import ( GenerationConfig, @@ -79,13 +79,66 @@ def detect_accelerator(obj: object) -> str: return accelerators.CPU +# Filename suffixes that have a shared label under another name. +_SUFFIX_LABELS = {"i8": quantizations.INT8} + +# llama.cpp file types (gguf-py's LlamaFileType) as stored in the model's +# general.file_type metadata. Removed and unknown types are left out. +GGUF_FILE_TYPES = { + 0: quantizations.F32, + 1: quantizations.F16, + 2: "q4_0", + 3: "q4_1", + 7: "q8_0", + 8: "q5_0", + 9: "q5_1", + 10: "q2_k", + 11: "q3_k_s", + 12: "q3_k_m", + 13: "q3_k_l", + 14: "q4_k_s", + 15: "q4_k_m", + 16: "q5_k_s", + 17: "q5_k_m", + 18: "q6_k", + 19: "iq2_xxs", + 20: "iq2_xs", + 21: "q2_k_s", + 22: "iq3_xs", + 23: "iq3_xxs", + 24: "iq1_s", + 25: "iq4_nl", + 26: "iq3_s", + 27: "iq3_m", + 28: "iq2_s", + 29: "iq2_m", + 30: "iq4_xs", + 31: "iq1_m", + 32: quantizations.BF16, + 36: "tq1_0", + 37: "tq2_0", + 38: "mxfp4_moe", + 39: "nvfp4", + 40: "q1_0", +} + + def parse_quantization(filename: str) -> str | None: match = _QUANT_RE.search(filename) if match: - return match.group(1).lower() + suffix = match.group(1).lower() + return _SUFFIX_LABELS.get(suffix, suffix) return None +def file_type_quantization(file_type: object) -> str | None: + """Label for a general.file_type metadata value, such as "15" (q4_k_m).""" + try: + return GGUF_FILE_TYPES.get(int(str(file_type))) + except ValueError: + return None + + # Messages of the create_chat_completion call in progress, so the # create_completion it runs can record the event as a chat turn. _chat_messages: contextvars.ContextVar[list | None] = contextvars.ContextVar( @@ -388,8 +441,8 @@ def extract_info( try: metadata = getattr(obj, "metadata", {}) or {} file_type = metadata.get("general.file_type") - if file_type: - quantization = str(file_type).lower() + if file_type is not None: + quantization = file_type_quantization(file_type) except Exception as exc: debug_gguf_failure("quantization metadata read", exc) if quantization is None: diff --git a/wildedge/integrations/mlx.py b/wildedge/integrations/mlx.py index 9a63a6f..0777a71 100644 --- a/wildedge/integrations/mlx.py +++ b/wildedge/integrations/mlx.py @@ -7,10 +7,14 @@ import time from typing import TYPE_CHECKING -from wildedge import constants +from wildedge import constants, quantizations from wildedge.events.inference import GenerationOutputMeta, TextInputMeta from wildedge.integrations.base import BaseExtractor, patch_instance_call_once -from wildedge.integrations.common import debug_failure, mlx_accelerator +from wildedge.integrations.common import ( + debug_failure, + mlx_accelerator, + mlx_layer_quantization, +) from wildedge.model import ModelInfo from wildedge.timing import elapsed_ms @@ -64,7 +68,7 @@ def extract_model_args(obj: object) -> tuple[str | None, str | None]: quant = getattr(args, "quantization", None) if quant is not None: bits = getattr(quant, "bits", None) - q_str = f"q{int(bits)}" if bits else "quantized" + q_str = quantizations.int_bits(bits) if bits else quantizations.QUANTIZED else: q_str = detect_quantization_from_layers(obj) return model_type, q_str @@ -74,15 +78,11 @@ def extract_model_args(obj: object) -> tuple[str | None, str | None]: def detect_quantization_from_layers(obj: object) -> str | None: - """Inspect layer class names for quantized linear layers.""" try: - for _, module in obj.named_modules(): # type: ignore[union-attr] - cls_name = type(module).__name__ - if "Quantized" in cls_name or "quantized" in cls_name: - return "quantized" - except Exception: - pass - return None + return mlx_layer_quantization(obj) + except Exception as exc: + debug_mlx_failure("quantization detection", exc) + return None def count_tokens(tokenizer: object, text: str) -> int | None: diff --git a/wildedge/integrations/mlx_whisper.py b/wildedge/integrations/mlx_whisper.py index 7d00415..df91ff8 100644 --- a/wildedge/integrations/mlx_whisper.py +++ b/wildedge/integrations/mlx_whisper.py @@ -9,10 +9,12 @@ import threading import time +from wildedge import quantizations from wildedge.integrations.common import ( debug_failure, dtype_to_quantization, mlx_accelerator, + mlx_layer_quantization, track_api_error, ) from wildedge.integrations.whisper import ( @@ -34,31 +36,29 @@ DEFAULT_FP16 = True -def mlx_quantization(model: object, dtype: object) -> str | None: - """Bit width of quantized layers, else the weight dtype, if recognised.""" +def describe( + path: object, weights: str | None, model: object = None +) -> WhisperDescriptor: + """Describe a model. ``weights`` is the weight dtype label, reported unless + the model's layers are quantized. Without ``model`` (when loading failed) + only ``weights`` is known.""" + quantization = weights if model is not None: try: - for _, layer in model.named_modules(): # type: ignore[attr-defined] - bits = getattr(layer, "bits", None) - if isinstance(bits, int): - return f"q{bits}" + quantization = mlx_layer_quantization(model) or weights except Exception as exc: debug_failure(MLX_WHISPER, "quantization detection", exc) - return dtype_to_quantization(str(dtype)) - - -def describe(path: object, dtype: object, model: object = None) -> WhisperDescriptor: - """Describe a model; without ``model`` (when loading failed) only the dtype - is known.""" return WhisperDescriptor.from_path( - path, MLX_WHISPER, mlx_accelerator(), mlx_quantization(model, dtype) + path, MLX_WHISPER, mlx_accelerator(), quantization ) def requested_model(kwargs: dict) -> tuple[str, str]: - """Model path and dtype that ``mlx_whisper.transcribe`` uses for these arguments.""" - dtype = "float16" if kwargs.get("fp16", DEFAULT_FP16) else "float32" - return kwargs.get("path_or_hf_repo", DEFAULT_REPO), dtype + """Model path and weight dtype label that ``mlx_whisper.transcribe`` uses + for these arguments.""" + fp16 = kwargs.get("fp16", DEFAULT_FP16) + weights = quantizations.F16 if fp16 else quantizations.F32 + return kwargs.get("path_or_hf_repo", DEFAULT_REPO), weights def install_auto_load_patch(client_ref): @@ -89,7 +89,7 @@ def load(*args, **kwargs): bound.apply_defaults() info = describe( bound.arguments["path_or_hf_repo"], - bound.arguments.get("dtype"), + dtype_to_quantization(str(bound.arguments.get("dtype"))), model, ) setattr(model, DESCRIPTOR_ATTR, info) diff --git a/wildedge/integrations/pytorch.py b/wildedge/integrations/pytorch.py index 1557418..f135553 100644 --- a/wildedge/integrations/pytorch.py +++ b/wildedge/integrations/pytorch.py @@ -7,7 +7,7 @@ import time from typing import TYPE_CHECKING -from wildedge import accelerators +from wildedge import accelerators, quantizations from wildedge.events.inference import ( ClassificationOutputMeta, HistogramSummary, @@ -72,10 +72,10 @@ def detect_quantization(obj: object) -> str | None: for module in obj.modules(): # type: ignore[union-attr] cls = type(module) if "quantized" in cls.__module__ or "quant" in cls.__name__.lower(): - return "int8" + return quantizations.INT8 for p in obj.parameters(): # type: ignore[union-attr] q = dtype_to_quantization(str(p.dtype)) - if q and q != "f32": + if q and q != quantizations.F32: return q except Exception as exc: debug_pytorch_failure("quantization detection", exc) diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index 1cf3078..af7f0ee 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -9,7 +9,7 @@ from collections import Counter from typing import TYPE_CHECKING -from wildedge import accelerators, constants +from wildedge import accelerators, constants, quantizations from wildedge.events.inference import ( ClassificationOutputMeta, EmbeddingOutputMeta, @@ -127,7 +127,7 @@ def detect_quantization(obj: object) -> str | None: if quant_type: return str(quant_type).lower() if bits: - return f"int{int(bits)}" + return quantizations.int_bits(bits) # Fall back to model dtype model = getattr(obj, "model", obj) dtype = getattr(model, "dtype", None) diff --git a/wildedge/quantizations.py b/wildedge/quantizations.py new file mode 100644 index 0000000..1d3cf90 --- /dev/null +++ b/wildedge/quantizations.py @@ -0,0 +1,17 @@ +"""Quantization labels reported in model metadata. + +Common weight precisions use the names below. Format-specific schemes pass +through as their runtime names them, such as GGUF's ``q4_k_m`` or +bitsandbytes' ``nf4``, so this list is not closed. +""" + +F32 = "f32" +F16 = "f16" +BF16 = "bf16" +INT8 = "int8" +QUANTIZED = "quantized" # quantized, with an unknown bit width + + +def int_bits(bits: int) -> str: + """Label for integer weights of the given width, such as ``int4``.""" + return f"int{int(bits)}" From 9098d9bc168ff76d7a1791bdb89074aa45e889bc Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:42:29 +0200 Subject: [PATCH 45/81] Name adapters and hub trackers by import path in the registries Each client_patch integration is activated by the install_auto_load_patch function of the adapter module with its name; class-based adapters export one as a module-level alias, and timm gets a small entry-point module. EXTRACTOR_SPECS lists the extractors with cheap checks for when each one is worth importing. wildedge.loading resolves these paths on first use. Nothing uses the registries yet; the client switches over next. --- wildedge/hubs/registry.py | 10 ++- wildedge/integrations/anthropic.py | 4 + wildedge/integrations/gguf.py | 4 + wildedge/integrations/mlx.py | 4 + wildedge/integrations/onnx.py | 4 + wildedge/integrations/openai.py | 4 + wildedge/integrations/registry.py | 112 +++++++++++++++++++++++++- wildedge/integrations/tensorflow.py | 4 + wildedge/integrations/timm.py | 9 +++ wildedge/integrations/transformers.py | 4 + wildedge/integrations/ultralytics.py | 4 + wildedge/integrations/whisper.py | 2 +- wildedge/loading.py | 37 +++++++++ 13 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 wildedge/integrations/timm.py create mode 100644 wildedge/loading.py diff --git a/wildedge/hubs/registry.py b/wildedge/hubs/registry.py index 8c53ffc..d6ec901 100644 --- a/wildedge/hubs/registry.py +++ b/wildedge/hubs/registry.py @@ -17,11 +17,17 @@ class HubSpec: name: str required_modules: tuple[str, ...] + tracker_class: str # tracker class in the wildedge.hubs module with this name + + @property + def tracker(self) -> str: + """Import path of the tracker class, loaded only when the hub is used.""" + return f"wildedge.hubs.{self.name}:{self.tracker_class}" HUB_SPECS: tuple[HubSpec, ...] = ( - HubSpec("huggingface", ("huggingface_hub",)), - HubSpec("torchhub", ("torch",)), + HubSpec("huggingface", ("huggingface_hub",), "HuggingFaceHubTracker"), + HubSpec("torchhub", ("torch",), "TorchHubTracker"), ) HUBS_BY_NAME: dict[str, HubSpec] = {spec.name: spec for spec in HUB_SPECS} diff --git a/wildedge/integrations/anthropic.py b/wildedge/integrations/anthropic.py index 9e604c8..f21ecd3 100644 --- a/wildedge/integrations/anthropic.py +++ b/wildedge/integrations/anthropic.py @@ -496,3 +496,7 @@ def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped- _anthropic.Anthropic.__init__ = patched_sync_init _anthropic.AsyncAnthropic.__init__ = patched_async_init _anthropic_patched = True + + +# Entry point named by the integration registry. +install_auto_load_patch = AnthropicExtractor.install_auto_load_patch diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index e88cc28..b2e538e 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -559,3 +559,7 @@ def patched_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] patched_init.__wildedge_original_call__ = original_init # type: ignore[attr-defined] _llama_cpp.Llama.__init__ = patched_init _llama_patched = True + + +# Entry point named by the integration registry. +install_auto_load_patch = GgufExtractor.install_auto_load_patch diff --git a/wildedge/integrations/mlx.py b/wildedge/integrations/mlx.py index 0777a71..f82d1db 100644 --- a/wildedge/integrations/mlx.py +++ b/wildedge/integrations/mlx.py @@ -307,3 +307,7 @@ def patched_generate(model, tokenizer, prompt, *args, **kwargs): # type: ignore patched_generate.__wildedge_patch_name__ = MLX_GENERATE_PATCH_NAME # type: ignore[attr-defined] patched_generate.__wildedge_original_call__ = original_generate # type: ignore[attr-defined] _mlx_lm.generate = patched_generate + + +# Entry point named by the integration registry. +install_auto_load_patch = MlxExtractor.install_auto_load_patch diff --git a/wildedge/integrations/onnx.py b/wildedge/integrations/onnx.py index 73f7c28..1777709 100644 --- a/wildedge/integrations/onnx.py +++ b/wildedge/integrations/onnx.py @@ -391,3 +391,7 @@ def patched_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] ) ort.InferenceSession = patched_cls _ort_patched = True + + +# Entry point named by the integration registry. +install_auto_load_patch = OnnxExtractor.install_auto_load_patch diff --git a/wildedge/integrations/openai.py b/wildedge/integrations/openai.py index 4781600..10ad19a 100644 --- a/wildedge/integrations/openai.py +++ b/wildedge/integrations/openai.py @@ -659,3 +659,7 @@ def patched_async_init(self_inner, *args, **kwargs): # type: ignore[no-untyped- _openai.OpenAI.__init__ = patched_sync_init _openai.AsyncOpenAI.__init__ = patched_async_init _openai_patched = True + + +# Entry point named by the integration registry. +install_auto_load_patch = OpenAIExtractor.install_auto_load_patch diff --git a/wildedge/integrations/registry.py b/wildedge/integrations/registry.py index df1279e..8e892c6 100644 --- a/wildedge/integrations/registry.py +++ b/wildedge/integrations/registry.py @@ -3,24 +3,43 @@ Contains only ML *framework* integrations (inference tracking, load/unload timing). Model hub and repository trackers (download provenance) live in ``wildedge.hubs.registry``. They have different activation semantics. + +Adapters are listed by name and imported only when used, so importing the SDK +does not import any ML framework (see ``wildedge.loading``). """ from __future__ import annotations +import sys from dataclasses import dataclass +from functools import cache from typing import Literal +from wildedge.loading import resolve + IntegrationKind = Literal["noop", "client_patch"] +ADAPTER_PACKAGE = "wildedge.integrations" + @dataclass(frozen=True) class IntegrationSpec: - """Describes one integration's runtime behavior and import requirements.""" + """Describes one integration's runtime behavior and import requirements. + + A ``client_patch`` integration is activated by the + ``install_auto_load_patch`` function of the adapter module with its name. + """ name: str required_modules: tuple[str, ...] kind: IntegrationKind + @property + def installer(self) -> str | None: + if self.kind != "client_patch": + return None + return f"{ADAPTER_PACKAGE}.{self.name}:install_auto_load_patch" + INTEGRATION_SPECS: tuple[IntegrationSpec, ...] = ( IntegrationSpec("anthropic", ("anthropic",), "client_patch"), @@ -51,3 +70,94 @@ def supported_integrations() -> set[str]: def noop_integrations() -> set[str]: """Return integrations that intentionally perform no global patching.""" return {name for name, spec in INTEGRATIONS_BY_NAME.items() if spec.kind == "noop"} + + +@dataclass(frozen=True) +class ExtractorSpec: + """An extractor, and cheap checks for when it is worth importing. + + ``could_handle`` only decides whether to import the adapter; its + ``can_handle`` still makes the final call. + """ + + module: str # adapter module in wildedge.integrations + class_name: str # extractor class in that module + # Packages whose classes the extractor handles, matched anywhere in the + # object's class hierarchy so application subclasses count too. + packages: tuple[str, ...] = () + # Exact class names, for extractors whose can_handle checks the name. + class_names: tuple[str, ...] = () + # Attribute the SDK sets on objects it has already described. + marker_attr: str | None = None + + def could_handle(self, obj: object) -> bool: + if f"{ADAPTER_PACKAGE}.{self.module}" in sys.modules: + return True # already imported, so asking it costs nothing + if type(obj).__name__ in self.class_names: + return True + if self.marker_attr and getattr(obj, self.marker_attr, None) is not None: + return True + return any( + cls.__module__.partition(".")[0] in self.packages + for cls in type(obj).__mro__ + ) + + def load(self): + return load_extractor(f"{ADAPTER_PACKAGE}.{self.module}:{self.class_name}") + + +@cache +def load_extractor(target: str): + return resolve(target)() + + +# Order matters: the first extractor that handles a model wins, so wrappers +# and specialized formats come before the generic tensor frameworks. +EXTRACTOR_SPECS: tuple[ExtractorSpec, ...] = ( + ExtractorSpec("onnx", "OnnxExtractor", ("onnxruntime",)), + ExtractorSpec("gguf", "GgufExtractor", class_names=("Llama",)), + ExtractorSpec( + "openai", + "OpenAIExtractor", + class_names=( + "OpenAI", + "AsyncOpenAI", + "Completions", + "AsyncCompletions", + "Responses", + "AsyncResponses", + "Embeddings", + "AsyncEmbeddings", + ), + ), + ExtractorSpec( + "anthropic", + "AnthropicExtractor", + class_names=("Anthropic", "AsyncAnthropic", "Messages", "AsyncMessages"), + ), + ExtractorSpec("ultralytics", "UltralyticsExtractor", class_names=("YOLO",)), + ExtractorSpec("transformers", "TransformersExtractor", ("transformers",)), + # Before MLX, which would otherwise claim MLX Whisper models. + ExtractorSpec( + "whisper", + "WhisperExtractor", + class_names=("WhisperDescriptor",), + marker_attr="__wildedge_whisper_descriptor__", + ), + ExtractorSpec("mlx", "MlxExtractor", ("mlx",)), + ExtractorSpec("pytorch", "PytorchExtractor", ("torch",)), + ExtractorSpec( + "tensorflow", "TensorflowExtractor", ("tensorflow", "keras", "tf_keras") + ), + ExtractorSpec("keras", "KerasExtractor", ("keras", "tf_keras")), +) + + +def find_extractor(obj: object): + """The first extractor that handles ``obj``, or None.""" + for spec in EXTRACTOR_SPECS: + if spec.could_handle(obj): + extractor = spec.load() + if extractor.can_handle(obj): + return extractor + return None diff --git a/wildedge/integrations/tensorflow.py b/wildedge/integrations/tensorflow.py index 3deb40c..aa58153 100644 --- a/wildedge/integrations/tensorflow.py +++ b/wildedge/integrations/tensorflow.py @@ -277,3 +277,7 @@ def install_auto_load_patch(cls, client_ref: object) -> None: saved_model.load = build_load_patch(client_ref, saved_model_load) _tf_auto_patched = True + + +# Entry point named by the integration registry. +install_auto_load_patch = TensorflowExtractor.install_auto_load_patch diff --git a/wildedge/integrations/timm.py b/wildedge/integrations/timm.py new file mode 100644 index 0000000..187dd8f --- /dev/null +++ b/wildedge/integrations/timm.py @@ -0,0 +1,9 @@ +"""timm integration: times ``timm.create_model`` and records its downloads. + +The instrumentation lives with the PyTorch adapter, since timm models are +PyTorch modules. This module is the entry point the integration registry names. +""" + +from wildedge.integrations.pytorch import PytorchExtractor + +install_auto_load_patch = PytorchExtractor.install_timm_patch diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index af7f0ee..b448978 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -509,3 +509,7 @@ def patched_from_pretrained(model_cls, *args, **kwargs): # type: ignore[no-unty _transformers.PreTrainedModel.from_pretrained = classmethod( # type: ignore[assignment] patched_from_pretrained ) + + +# Entry point named by the integration registry. +install_auto_load_patch = TransformersExtractor.install_auto_load_patch diff --git a/wildedge/integrations/ultralytics.py b/wildedge/integrations/ultralytics.py index 7547bf0..2f5ed8b 100644 --- a/wildedge/integrations/ultralytics.py +++ b/wildedge/integrations/ultralytics.py @@ -450,3 +450,7 @@ def patched_init(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] patched_init.__wildedge_original_call__ = original_init # type: ignore[attr-defined] _ultralytics.YOLO.__init__ = patched_init _ultralytics_patched = True + + +# Entry point named by the integration registry. +install_auto_load_patch = UltralyticsExtractor.install_auto_load_patch diff --git a/wildedge/integrations/whisper.py b/wildedge/integrations/whisper.py index 886ba8c..b61da7d 100644 --- a/wildedge/integrations/whisper.py +++ b/wildedge/integrations/whisper.py @@ -115,7 +115,7 @@ def report_cached_load(client, model: object) -> None: class WhisperExtractor(BaseExtractor): - """Must come before MlxExtractor in the client's extractor order, which + """Must come before MlxExtractor in ``EXTRACTOR_SPECS``, which would otherwise claim MLX Whisper models as plain MLX models.""" def can_handle(self, obj: object) -> bool: diff --git a/wildedge/loading.py b/wildedge/loading.py new file mode 100644 index 0000000..e2328aa --- /dev/null +++ b/wildedge/loading.py @@ -0,0 +1,37 @@ +"""Import optional parts of the SDK only when a feature uses them. + +Importing ``wildedge`` must not import ML frameworks or API clients. The +registries name each adapter by import path instead, and these helpers load it +on first use. +""" + +from __future__ import annotations + +from functools import cache +from importlib import import_module +from types import ModuleType +from typing import Any + + +@cache +def resolve(target: str) -> Any: + """Import ``package.module:name`` and return that attribute.""" + module, name = target.split(":", 1) + return getattr(import_module(module), name) + + +def lazy_callable(target: str): + """A function that imports ``target`` on its first call and forwards to it.""" + + def call(*args, **kwargs): + return resolve(target)(*args, **kwargs) + + return call + + +def optional_module(name: str) -> ModuleType | None: + """Import an optional third-party runtime, or None if it is not installed.""" + try: + return import_module(name) + except ImportError: + return None From b1b299e39e1802002263bdadaa09aeb3cae67140 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:42:29 +0200 Subject: [PATCH 46/81] Load adapters and hub trackers only when they are used The client no longer imports every adapter at import time. Patch installers and hub trackers are resolved from the registries when activated, and model registration imports only the extractors whose checks match the model. wildedge.hubs keeps re-exporting the tracker classes, imported on access. --- wildedge/client.py | 65 ++++++++------------------------------- wildedge/hubs/__init__.py | 13 ++++++-- 2 files changed, 24 insertions(+), 54 deletions(-) diff --git a/wildedge/client.py b/wildedge/client.py index 4ae38dd..e755e4f 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -22,29 +22,16 @@ from wildedge.events import SpanEvent from wildedge.events.span import SpanKind, SpanStatus from wildedge.hubs.base import BaseHubTracker -from wildedge.hubs.huggingface import HuggingFaceHubTracker -from wildedge.hubs.registry import supported_hubs -from wildedge.hubs.torchhub import TorchHubTracker +from wildedge.hubs.registry import HUB_SPECS, supported_hubs from wildedge.instrumentation import claim_instrumentation, has_other_live_owner -from wildedge.integrations.anthropic import AnthropicExtractor from wildedge.integrations.base import BaseExtractor -from wildedge.integrations.faster_whisper import ( - install_auto_load_patch as install_faster_whisper, +from wildedge.integrations.registry import ( + INTEGRATION_SPECS, + find_extractor, + noop_integrations, + supported_integrations, ) -from wildedge.integrations.gguf import GgufExtractor -from wildedge.integrations.keras import KerasExtractor -from wildedge.integrations.mlx import MlxExtractor -from wildedge.integrations.mlx_whisper import ( - install_auto_load_patch as install_mlx_whisper, -) -from wildedge.integrations.onnx import OnnxExtractor -from wildedge.integrations.openai import OpenAIExtractor -from wildedge.integrations.pytorch import PytorchExtractor -from wildedge.integrations.registry import noop_integrations, supported_integrations -from wildedge.integrations.tensorflow import TensorflowExtractor -from wildedge.integrations.transformers import TransformersExtractor -from wildedge.integrations.ultralytics import UltralyticsExtractor -from wildedge.integrations.whisper import WhisperExtractor +from wildedge.loading import lazy_callable from wildedge.logging import enable_debug, logger from wildedge.model import ModelHandle, ModelInfo, ModelRegistry from wildedge.paths import ( @@ -120,20 +107,6 @@ def parse_dsn(dsn: str) -> tuple[str, str, str]: # One informative log line per process for the intentional no-DSN mode. _noop_notice_logged = False -DEFAULT_EXTRACTORS: list[BaseExtractor] = [ - OnnxExtractor(), - GgufExtractor(), - OpenAIExtractor(), - AnthropicExtractor(), - UltralyticsExtractor(), - TransformersExtractor(), - WhisperExtractor(), - MlxExtractor(), - PytorchExtractor(), - TensorflowExtractor(), - KerasExtractor(), -] - class SpanContextManager: def __init__( @@ -258,24 +231,15 @@ class WildEdge: SUPPORTED_INTEGRATIONS = supported_integrations() NOOP_INTEGRATIONS = noop_integrations() PATCH_INSTALLERS: ClassVar[dict[str, Callable[[object], None]]] = { - "anthropic": AnthropicExtractor.install_auto_load_patch, - "gguf": GgufExtractor.install_auto_load_patch, - "mlx": MlxExtractor.install_auto_load_patch, - "mlx_whisper": install_mlx_whisper, - "faster_whisper": install_faster_whisper, - "onnx": OnnxExtractor.install_auto_load_patch, - "openai": OpenAIExtractor.install_auto_load_patch, - "timm": PytorchExtractor.install_timm_patch, - "tensorflow": TensorflowExtractor.install_auto_load_patch, - "transformers": TransformersExtractor.install_auto_load_patch, - "ultralytics": UltralyticsExtractor.install_auto_load_patch, + spec.name: lazy_callable(spec.installer) + for spec in INTEGRATION_SPECS + if spec.installer is not None } # Hub trackers: record download provenance (where models came from). SUPPORTED_HUBS = supported_hubs() - HUB_TRACKER_CLASSES: ClassVar[dict[str, type[BaseHubTracker]]] = { - "huggingface": HuggingFaceHubTracker, - "torchhub": TorchHubTracker, + HUB_TRACKER_CLASSES: ClassVar[dict[str, Callable[[], BaseHubTracker]]] = { + spec.name: lazy_callable(spec.tracker) for spec in HUB_SPECS } def __init__( @@ -706,10 +670,7 @@ def track_span( return correlation["span_id"] def _find_extractor(self, model_obj: object) -> BaseExtractor | None: - for candidate in DEFAULT_EXTRACTORS: - if candidate.can_handle(model_obj): - return candidate - return None + return find_extractor(model_obj) def _memory_bytes_for(self, model_obj: object) -> int | None: extractor = self._find_extractor(model_obj) diff --git a/wildedge/hubs/__init__.py b/wildedge/hubs/__init__.py index 29681ff..cb12a1e 100644 --- a/wildedge/hubs/__init__.py +++ b/wildedge/hubs/__init__.py @@ -32,9 +32,7 @@ """ from wildedge.hubs.base import BaseHubTracker -from wildedge.hubs.huggingface import HuggingFaceHubTracker from wildedge.hubs.registry import HUB_SPECS, HUBS_BY_NAME, HubSpec, supported_hubs -from wildedge.hubs.torchhub import TorchHubTracker __all__ = [ "HUBS_BY_NAME", @@ -45,3 +43,14 @@ "TorchHubTracker", "supported_hubs", ] + + +def __getattr__(name: str): + # Tracker classes are re-exported here but imported only when accessed, + # because importing a tracker imports its hub library. + from wildedge.loading import resolve # noqa: PLC0415 + + for spec in HUB_SPECS: + if spec.tracker_class == name: + return resolve(spec.tracker) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From 463ba851d0c8a282b91e6168be3a465cb289a2b0 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:42:29 +0200 Subject: [PATCH 47/81] Import framework runtimes when their integration is activated Importing the MLX, PyTorch and TensorFlow adapters no longer imports mlx-lm, timm or TensorFlow, so registering a model does not pull in its companion runtime. Activating the integration imports it, and a 1000-class PyTorch classifier still loads timm's ImageNet labels. --- wildedge/integrations/mlx.py | 16 ++++++++++------ wildedge/integrations/pytorch.py | 24 ++++++++++++++---------- wildedge/integrations/tensorflow.py | 16 ++++++++++------ 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/wildedge/integrations/mlx.py b/wildedge/integrations/mlx.py index f82d1db..2284329 100644 --- a/wildedge/integrations/mlx.py +++ b/wildedge/integrations/mlx.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import sys import threading import time from typing import TYPE_CHECKING @@ -15,6 +16,7 @@ mlx_accelerator, mlx_layer_quantization, ) +from wildedge.loading import optional_module from wildedge.model import ModelInfo from wildedge.timing import elapsed_ms @@ -27,10 +29,9 @@ _mlx_nn = None # type: ignore[assignment] _tree_flatten = None # type: ignore[assignment] -try: - import mlx_lm as _mlx_lm -except ImportError: - _mlx_lm = None # type: ignore[assignment] +# Imported when the integration is activated, so registering an MLX model +# does not import mlx-lm. +_mlx_lm = sys.modules.get("mlx_lm") if TYPE_CHECKING: from wildedge.model import ModelHandle @@ -196,8 +197,11 @@ def install_auto_load_patch(cls, client_ref: object) -> None: tokens/second. The autoregressive ``model()`` loop inside generate is suppressed via a thread-local guard so it does not double-count. """ - global _mlx_patched - if _mlx_patched or _mlx_lm is None: + global _mlx_patched, _mlx_lm + if _mlx_patched: + return + _mlx_lm = _mlx_lm or optional_module("mlx_lm") + if _mlx_lm is None: return with _MLX_PATCH_LOCK: diff --git a/wildedge/integrations/pytorch.py b/wildedge/integrations/pytorch.py index f135553..48119be 100644 --- a/wildedge/integrations/pytorch.py +++ b/wildedge/integrations/pytorch.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import sys import threading import time from typing import TYPE_CHECKING @@ -23,16 +24,14 @@ infer_input_modality_from_names, torch_module_accelerator, ) +from wildedge.loading import optional_module from wildedge.logging import logger from wildedge.model import ModelInfo from wildedge.timing import elapsed_ms -try: - import timm as _timm - from timm.data import ImageNetInfo as _ImageNetInfo -except ImportError: - _timm = None # type: ignore[assignment] - _ImageNetInfo = None # type: ignore[assignment,misc] +# Imported when the timm integration is activated, so registering a plain +# PyTorch model does not import timm. +_timm = sys.modules.get("timm") try: import torch as _torch @@ -119,7 +118,9 @@ def image_input_meta(tensor: object) -> ImageInputMeta | None: def build_imagenet_labels() -> list[str] | None: """Return a 1000-entry description list using timm's bundled ImageNet metadata.""" try: - info = _ImageNetInfo() # type: ignore[misc] + from timm.data import ImageNetInfo # noqa: PLC0415 -- optional, 1000-class only + + info = ImageNetInfo() return [info.index_to_description(i) for i in range(1000)] except Exception as exc: debug_pytorch_failure("imagenet label build", exc) @@ -234,7 +235,7 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: nc = getattr(obj, "num_classes", 0) if isinstance(nc, int) and nc > 1: num_classes = nc - if num_classes == 1000 and _timm is not None: + if num_classes == 1000: labels = build_imagenet_labels() except Exception as exc: debug_pytorch_failure("num_classes detection", exc) @@ -302,8 +303,11 @@ def install_timm_patch(cls, client_ref: object) -> None: the duration of the call and emitted as a model_download event. Inference tracking is handled by the existing PyTorch forward hooks. """ - global _timm_patched - if _timm_patched or _timm is None: + global _timm_patched, _timm + if _timm_patched: + return + _timm = _timm or optional_module("timm") + if _timm is None: return with _TIMM_PATCH_LOCK: diff --git a/wildedge/integrations/tensorflow.py b/wildedge/integrations/tensorflow.py index aa58153..c5ed639 100644 --- a/wildedge/integrations/tensorflow.py +++ b/wildedge/integrations/tensorflow.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import sys import threading import time from typing import TYPE_CHECKING @@ -21,16 +22,16 @@ num_classes_from_output_shape, tf_weights_accelerator, ) +from wildedge.loading import optional_module from wildedge.model import ModelInfo from wildedge.timing import elapsed_ms if TYPE_CHECKING: from wildedge.model import ModelHandle -try: - import tensorflow as _tf # type: ignore[import-untyped] -except ImportError: - _tf = None # type: ignore[assignment] +# Imported when the integration is activated, so registering a Keras model +# does not import TensorFlow. +_tf = sys.modules.get("tensorflow") TENSORFLOW_CALL_PATCH_NAME = "tensorflow_call" TENSORFLOW_HANDLE_ATTR = "__wildedge_tensorflow_handle__" @@ -245,8 +246,11 @@ def install_hooks(self, obj: object, handle: ModelHandle) -> None: @classmethod def install_auto_load_patch(cls, client_ref: object) -> None: - global _tf_auto_patched - if _tf_auto_patched or _tf is None: + global _tf_auto_patched, _tf + if _tf_auto_patched: + return + _tf = _tf or optional_module("tensorflow") + if _tf is None: return with _TF_AUTO_PATCH_LOCK: From cb9b5ca23933d53ffc3bb380ab87115570f7a50a Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:42:29 +0200 Subject: [PATCH 48/81] Test and document that SDK import loads no ML libraries --- CHANGELOG.md | 4 + docs/configuration.md | 9 ++ tests/test_lazy_loading.py | 249 +++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 tests/test_lazy_loading.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f091760..3cc00e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ under Unreleased and move into a version section at release time. ### Fixed +- `import wildedge` no longer imports every installed ML framework and API + client. Each library is imported when its integration is activated or a + model from it is registered, so SDK import is faster and a broken unrelated + package no longer stops the SDK from starting. - Quantization labels are defined once in `wildedge.quantizations` and used by every integration. Behaviour changes: - MLX-LM reports N-bit weights as `int4`/`int8`, like Transformers, instead diff --git a/docs/configuration.md b/docs/configuration.md index 2487ebe..232ff36 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,3 +42,12 @@ event flushes never block on an upload. | `attachment_dir` | per `app_identity` | - | Override the local buffer directory | See [Track attachments](manual-tracking.md#track-attachments) for usage. + +### When ML libraries are imported + +`import wildedge` does not import any ML framework or API client. The SDK imports a library only when you use it: + +- activating an integration (`wildedge.init(integrations=[...])`, `client.instrument(...)` or `wildedge run`) imports that library; +- registering a model imports the library the model came from, and nothing else. For example, registering a PyTorch model does not import timm (unless it is a 1000-class classifier, for which timm supplies the ImageNet labels), and registering a Keras model does not import TensorFlow. + +Which integrations are activated by default is unchanged. `wildedge.init()` activates only the ones you list, while `wildedge run` activates every installed one unless you pass `--integrations`. diff --git a/tests/test_lazy_loading.py b/tests/test_lazy_loading.py new file mode 100644 index 0000000..c06982b --- /dev/null +++ b/tests/test_lazy_loading.py @@ -0,0 +1,249 @@ +"""Import isolation must hold in a fresh interpreter, regardless of installed ML packages.""" + +from __future__ import annotations + +import importlib +import json +import os +import subprocess +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +from wildedge.hubs.base import BaseHubTracker +from wildedge.hubs.registry import HUB_SPECS +from wildedge.integrations.base import BaseExtractor +from wildedge.integrations.registry import EXTRACTOR_SPECS, INTEGRATION_SPECS +from wildedge.loading import resolve + +ROOT = Path(__file__).resolve().parents[1] +OPTIONAL = ( + "torch", + "timm", + "tensorflow", + "keras", + "mlx", + "mlx_lm", + "mlx_whisper", + "faster_whisper", + "ctranslate2", + "onnxruntime", + "ultralytics", + "transformers", + "llama_cpp", + "openai", + "anthropic", + "huggingface_hub", + "numpy", +) + + +def isolated(tmp_path, code, *, working=(), sources=None): + """Make every optional package discoverable but broken unless explicitly supplied.""" + for name in OPTIONAL: + path = tmp_path / name + path.mkdir() + (path / "__init__.py").write_text( + (sources or {}).get( + name, + "" + if name in working + else f'raise RuntimeError("unexpected import: {name}")\n', + ) + ) + env = dict(os.environ) + env.pop("WILDEDGE_AUTOLOAD", None) + env.pop("WILDEDGE_DSN", None) + bootstrap = f"import sys; sys.path[:0] = [{str(tmp_path)!r}, {str(ROOT)!r}]\n" + result = subprocess.run( + [sys.executable, "-c", bootstrap + code], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +def test_import_and_unconfigured_init_ignore_broken_installed_frameworks(tmp_path): + isolated( + tmp_path, + f""" +import importlib.util +# These are installed/discoverable, rather than merely absent. +for name in {OPTIONAL!r}: + assert importlib.util.find_spec(name) is not None +import wildedge +import wildedge.cli +from wildedge.hubs import BaseHubTracker +client = wildedge.init(dsn="", integrations=["openai"]) +assert client.noop +client.register_model(object(), model_id="manual-model", auto_instrument=False) +assert not ({set(OPTIONAL)!r} & set(sys.modules)) +assert not any(name.startswith("wildedge.integrations.") and name.rsplit(".", 1)[-1] not in ("base", "registry", "common") for name in sys.modules) +client.close() +""", + ) + + +def test_selected_integration_does_not_import_siblings(tmp_path): + isolated( + tmp_path, + """ +from unittest.mock import patch +import wildedge +from wildedge.platforms.device_info import DeviceInfo +with patch("wildedge.client.Consumer"), patch("wildedge.client.Transmitter"), patch("wildedge.paths.default_sdk_state_dir", return_value=__import__("pathlib").Path("state")): + client = wildedge.WildEdge(dsn="https://secret@example.invalid/project", device=DeviceInfo("test", "linux"), sampling_interval_s=None) + client.instrument("openai") + assert "openai" in sys.modules + assert "wildedge.integrations.openai" in sys.modules + assert not ({"torch", "timm", "mlx", "tensorflow", "huggingface_hub", "anthropic"} & set(sys.modules)) + client.close() +""", + sources={"openai": "class OpenAI: pass\nclass AsyncOpenAI: pass\n"}, + ) + + +@pytest.mark.parametrize( + ("hub", "package"), [("huggingface", "huggingface_hub"), ("torchhub", "torch")] +) +def test_public_hub_reexports_load_only_the_requested_tracker(tmp_path, hub, package): + output = isolated( + tmp_path, + f""" +import json +from wildedge import hubs +tracker = getattr(hubs, {("HuggingFaceHubTracker" if hub == "huggingface" else "TorchHubTracker")!r}) +assert tracker().name == {hub!r} +print(json.dumps(sorted(set(sys.modules) & {set(OPTIONAL)!r}))) +""", + working=(package,), + ) + # HF's file_download is absent in this minimal fixture, but importing its root + # still proves the optional package is touched only for the requested tracker. + assert json.loads(output) == [package] + + +def test_manual_pytorch_subclass_registration_does_not_need_timm(tmp_path): + isolated( + tmp_path, + """ +from unittest.mock import patch +from pathlib import Path +import wildedge +from wildedge.platforms.device_info import DeviceInfo +# The base belongs to torch; the application subclass belongs to __main__. +Module = type("Module", (), {"__module__": "torch.nn"}) +class ApplicationModel(Module): + pass +with patch("wildedge.client.Consumer"), patch("wildedge.client.Transmitter"), patch("wildedge.paths.default_sdk_state_dir", return_value=Path("state")): + client = wildedge.WildEdge(dsn="https://secret@example.invalid/project", device=DeviceInfo("test", "linux"), sampling_interval_s=None) + handle = client.register_model(ApplicationModel(), model_id="application-model", auto_instrument=False) + assert handle.info.model_format == "pytorch" + assert "torch" in sys.modules + assert not ({"timm", "tensorflow", "transformers", "huggingface_hub"} & set(sys.modules)) + client.close() +""", + working=("torch", "numpy"), + ) + + +@pytest.mark.parametrize( + ("base_name", "base_module", "format_name", "working"), + [ + ("Model", "keras.src.models.model", "tensorflow", ("numpy",)), + ("Model", "tf_keras.src.engine.training", "tensorflow", ("numpy",)), + ("Module", "mlx.nn.layers.base", "mlx", ("mlx", "numpy")), + ], +) +def test_manual_registration_does_not_load_companion_runtime( + tmp_path, base_name, base_module, format_name, working +): + isolated( + tmp_path, + f""" +from unittest.mock import patch +from pathlib import Path +import wildedge +from wildedge.platforms.device_info import DeviceInfo +Base = type({base_name!r}, (), {{"__module__": {base_module!r}}}) +class ApplicationModel(Base): + name = "application-model" + weights = [] +with patch("wildedge.client.Consumer"), patch("wildedge.client.Transmitter"), patch("wildedge.paths.default_sdk_state_dir", return_value=Path("state")): + client = wildedge.WildEdge(dsn="https://secret@example.invalid/project", device=DeviceInfo("test", "linux"), sampling_interval_s=None) + handle = client.register_model(ApplicationModel(), model_id="application-model", auto_instrument=False) + assert handle.info.model_format == {format_name!r} + assert not ({{"tensorflow", "mlx_lm", "timm", "transformers"}} & set(sys.modules)) + client.close() +""", + working=working, + ) + + +@pytest.mark.parametrize( + ("adapter", "runtime_name", "runtime_attr", "flag", "extractor", "method"), + [ + ( + "pytorch", + "timm", + "_timm", + "_timm_patched", + "PytorchExtractor", + "install_timm_patch", + ), + ( + "mlx", + "mlx_lm", + "_mlx_lm", + "_mlx_patched", + "MlxExtractor", + "install_auto_load_patch", + ), + ( + "tensorflow", + "tensorflow", + "_tf", + "_tf_auto_patched", + "TensorflowExtractor", + "install_auto_load_patch", + ), + ], +) +def test_activation_imports_runtime_after_extractor_was_used( + monkeypatch, adapter, runtime_name, runtime_attr, flag, extractor, method +): + module = importlib.import_module(f"wildedge.integrations.{adapter}") + runtime = ModuleType(runtime_name) + if adapter == "pytorch": + runtime.create_model = lambda *_a, **_kw: None + elif adapter == "mlx": + runtime.load = lambda *_a, **_kw: None + runtime.generate = lambda *_a, **_kw: None + else: + runtime.keras = SimpleNamespace( + models=SimpleNamespace(load_model=lambda *_a, **_kw: None) + ) + runtime.saved_model = SimpleNamespace(load=lambda *_a, **_kw: None) + monkeypatch.setitem(sys.modules, runtime_name, runtime) + monkeypatch.setattr(module, runtime_attr, None) + monkeypatch.setattr(module, flag, False) + getattr(getattr(module, extractor), method)(lambda: None) + assert getattr(module, runtime_attr) is runtime + assert getattr(module, flag) is True + + +def test_registry_entries_point_at_real_code(): + for spec in INTEGRATION_SPECS: + if spec.installer is not None: + assert callable(resolve(spec.installer)), spec.name + for spec in EXTRACTOR_SPECS: + assert isinstance(spec.load(), BaseExtractor), spec.class_name + for spec in HUB_SPECS: + assert issubclass(resolve(spec.tracker), BaseHubTracker), spec.name From 24c6887fe40407ea097d5500acdeea7d586ad72b Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:55:38 +0200 Subject: [PATCH 49/81] Let apps choose which kinds of data attachments may upload attachment_modalities (env WILDEDGE_ATTACHMENT_MODALITIES, CLI --attachment-modalities) lists the modalities that may leave the device: text, image, audio, video or other. The default is text, so voice, images and video need an explicit opt-in. The rule applies to every attachment, manual or automatic, before attachment_filter runs. Integrations ask the handle before building an attachment, so Whisper does not encode audio that would be dropped. Attachment gains an optional modality for content types that don't identify the data. --- CHANGELOG.md | 16 ++- docs/configuration.md | 26 ++++ docs/manual-tracking.md | 9 +- docs/whisper.md | 14 +- examples/attachments_example.py | 2 + tests/test_attachments.py | 5 +- tests/test_cli.py | 27 ++++ tests/test_modalities.py | 171 ++++++++++++++++++++++++ tests/test_whisper.py | 37 ++++- wildedge/attachments.py | 54 +++++++- wildedge/cli.py | 22 ++- wildedge/client.py | 25 ++-- wildedge/constants.py | 1 + wildedge/integrations/faster_whisper.py | 4 +- wildedge/integrations/gguf.py | 4 +- wildedge/integrations/whisper.py | 19 ++- wildedge/modalities.py | 62 +++++++++ wildedge/model.py | 14 ++ wildedge/settings.py | 2 + 19 files changed, 476 insertions(+), 38 deletions(-) create mode 100644 tests/test_modalities.py create mode 100644 wildedge/modalities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc00e9..5dbab1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,27 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Changed + +- Attachments only upload text by default. The new `attachment_modalities` + setting (`WILDEDGE_ATTACHMENT_MODALITIES`, `wildedge run + --attachment-modalities`) lists the kinds of data that may be uploaded: + `text`, `image`, `audio`, `video` or `other`. It applies to manual and + automatic attachments alike. **Apps that upload images or other non-text + attachments must now allow them**, for example + `attachment_modalities=["text", "image"]`. The SDK warns once per blocked + modality. `Attachment` gains an optional `modality` field for content types + that don't identify the data. Whisper uploads the input audio only when + `audio` is allowed. + ### Added - `mlx_whisper` and `faster_whisper` integrations track local Whisper model loads and transcriptions, including faster-whisper's `BatchedInferencePipeline`. Transcriptions record duration, language, word count and audio metadata. With `attachments_enabled=True` they also upload - the input audio and transcript. See docs/whisper.md. + the transcript, and the input audio when `audio` is in + `attachment_modalities`. See docs/whisper.md. ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index 232ff36..8d0bb47 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,12 +35,38 @@ event flushes never block on an upload. | Parameter | Default | Env var | Description | |---|---|---|---| | `attachments_enabled` | `false` | `WILDEDGE_ATTACHMENTS_ENABLED` | Enable raw input/output upload | +| `attachment_modalities` | `["text"]` | `WILDEDGE_ATTACHMENT_MODALITIES` | Kinds of data that may be uploaded: `text`, `image`, `audio`, `video`, `other`, or `"all"` | | `max_attachments_per_inference` | `10` | - | Cap on attachments buffered per inference event | | `max_attachment_size_bytes` | `10485760` | - | Per-attachment size limit (10 MB); larger ones are dropped | | `attachment_storage_strategy` | `"file"` | - | `"file"` (bytes on disk) or `"inline"` (small blobs embedded in the record) | | `attachment_filter` | `None` | - | `Callable[[list[Attachment]], list[Attachment]]` to redact/drop before buffering | | `attachment_dir` | per `app_identity` | - | Override the local buffer directory | +### Choosing what may be uploaded + +`attachment_modalities` decides which kinds of data leave the device. It +applies to every attachment, whether an integration captured it or you passed +it to `track_inference`. The default is text only: prompts, replies and +transcripts. Voice recordings, images and video can identify a person, so they +are uploaded only when you list them: + +```python +wildedge.init(attachments_enabled=True, attachment_modalities=["text", "audio"]) +``` + +From the CLI: `wildedge run --attachments --attachment-modalities text,audio`. + +Each attachment's modality comes from its content type (`audio/wav` is +`audio`, `application/json` is `text`); anything else, such as raw tensors, +is `other`. The first time an attachment is dropped this way, the SDK logs a +warning naming the setting to change (only a debug message if you set +`attachment_modalities` yourself). Blocked attachments are dropped before +`attachment_filter` runs, and integrations skip building them at all, so a +Whisper client without `audio` never encodes the audio. + +For finer rules, such as uploading inputs but not outputs, use +`attachment_filter`. + See [Track attachments](manual-tracking.md#track-attachments) for usage. ### When ML libraries are imported diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index ad5c747..f663ef4 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -267,7 +267,7 @@ Enable it at init, then pass `attachments=` to `track_inference`: import wildedge from wildedge import Attachment -client = wildedge.init(attachments_enabled=True) +client = wildedge.init(attachments_enabled=True, attachment_modalities=["text", "image"]) handle = client.register_model(model, model_id="doc-classifier-v1") handle.track_inference( @@ -282,12 +282,15 @@ handle.track_inference( ``` An `Attachment` carries either in-memory `data` or a file `path`, plus a -`role` (`"input"` or `"output"`) and `content_type`. The SDK writes a reference +`role` (`"input"` or `"output"`) and `content_type`. Only text is uploaded +unless `attachment_modalities` allows more, which is why the example lists +`"image"`. The modality comes from `content_type`; pass `modality=` when the +content type doesn't say, such as audio in `application/octet-stream`. The SDK writes a reference into the inference event immediately and uploads the bytes in the background via a presigned URL. A failed or disabled upload never blocks telemetry. Bytes are buffered to disk and survive restarts. Capture is gated by -`max_attachments_per_inference`, `max_attachment_size_bytes`, and an optional +`attachment_modalities`, `max_attachments_per_inference`, `max_attachment_size_bytes`, and an optional `attachment_filter` hook. See [Configuration](configuration.md#attachments) and [`examples/attachments_example.py`](../examples/attachments_example.py) for the full reference. diff --git a/docs/whisper.md b/docs/whisper.md index baab3bd..6eca3c2 100644 --- a/docs/whisper.md +++ b/docs/whisper.md @@ -81,10 +81,14 @@ tracked and the SDK warns once. ## Capturing audio and transcripts Capture is off by default. With `attachments_enabled=True`, each transcription -uploads: +uploads the transcript as `transcript.txt`. The input audio is a voice +recording, so it is uploaded only when you also allow audio: -- the input audio: the original file for a path, or a 16-bit WAV for an array -- the transcript as `transcript.txt` +```python +wildedge.init(attachments_enabled=True, attachment_modalities=["text", "audio"]) +``` + +The audio is the original file for a path, or a 16-bit WAV for an array. Keep in mind: @@ -92,9 +96,7 @@ Keep in mind: also apply. A transcript over the cap isn't uploaded at all. - Audio passed as a file-like object isn't captured. - An audio file must still exist when the transcription finishes. -- To upload transcripts but not audio, pass an `attachment_filter` that keeps - only output attachments: - `attachment_filter=lambda items: [a for a in items if a.role == "output"]`. +- To upload audio but not transcripts, use `attachment_modalities=["audio"]`. - Transcript text is never added to event attributes. ## Examples and tests diff --git a/examples/attachments_example.py b/examples/attachments_example.py index 4669561..4c501d7 100644 --- a/examples/attachments_example.py +++ b/examples/attachments_example.py @@ -29,6 +29,8 @@ def redact(attachments: list[Attachment]) -> list[Attachment]: wildedge.init( app_version="1.0.0", attachments_enabled=True, + # Only text is uploaded unless other kinds of data are allowed here. + attachment_modalities=["text", "image"], max_attachments_per_inference=5, max_attachment_size_bytes=5 * 1024 * 1024, attachment_storage_strategy="file", # or "inline" for small blobs diff --git a/tests/test_attachments.py b/tests/test_attachments.py index c07205a..6b8cc38 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -9,7 +9,7 @@ import pytest -from wildedge import attachments, constants +from wildedge import attachments, constants, modalities from wildedge.attachments import ( Attachment, AttachmentManager, @@ -104,6 +104,7 @@ def test_store_peek_oldest_first(tmp_path): def _manager(tmp_path, **kwargs): store = AttachmentStore(str(tmp_path), strategy="file") + kwargs.setdefault("allowed_modalities", modalities.ALL) return store, AttachmentManager(store, **kwargs) @@ -215,7 +216,7 @@ def test_text_input_meta_no_has_attachments(): def _make_handle(tmp_path): store = AttachmentStore(str(tmp_path), strategy="file") - mgr = AttachmentManager(store) + mgr = AttachmentManager(store, allowed_modalities=modalities.ALL) published: list[dict] = [] info = ModelInfo( model_name="m", model_version="1", model_source="local", model_format="onnx" diff --git a/tests/test_cli.py b/tests/test_cli.py index 882bf3b..c862e29 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -578,3 +578,30 @@ def test_doctor_json_includes_environment_and_statuses(monkeypatch, capsys): assert data["environment"]["autoload_on_pythonpath"] is False assert data["config_status"] == "PASS" assert data["connectivity_status"] == "SKIP" + + +def test_cli_run_passes_attachment_modalities(monkeypatch): + captured: dict = {} + monkeypatch.setattr(cli.os, "execle", _fake_execle(captured)) + monkeypatch.setattr(cli.shutil, "which", lambda cmd: f"/usr/bin/{cmd}") + rc = cli.main( + [ + "run", + "--dsn", + "https://secret@ingest.wildedge.dev/key", + "--attachments", + "--attachment-modalities", + "text,audio", + "--", + "python", + "app.py", + ] + ) + assert rc == 0 + assert captured["env"][constants.ENV_ATTACHMENT_MODALITIES] == "text,audio" + + +def test_cli_run_rejects_unknown_attachment_modality(capsys): + with pytest.raises(SystemExit): + cli.main(["run", "--attachment-modalities", "audios", "--", "python"]) + assert "audios" in capsys.readouterr().err diff --git a/tests/test_modalities.py b/tests/test_modalities.py new file mode 100644 index 0000000..9ec3398 --- /dev/null +++ b/tests/test_modalities.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +import pytest + +from wildedge import constants, modalities +from wildedge.attachments import Attachment, AttachmentManager, AttachmentStore +from wildedge.client import WildEdge +from wildedge.model import ModelHandle, ModelInfo + + +@pytest.mark.parametrize( + ("content_type", "modality"), + [ + ("text/plain", "text"), + ("text/plain; charset=utf-8", "text"), + ("application/json", "text"), + ("application/ld+json", "text"), + ("image/jpeg", "image"), + ("audio/wav", "audio"), + ("AUDIO/MPEG", "audio"), + ("video/mp4", "video"), + ("application/octet-stream", "other"), + ("application/x-npy", "other"), + ], +) +def test_modality_follows_content_type(content_type, modality): + assert modalities.from_content_type(content_type) == modality + assert Attachment(content_type=content_type, data=b"x").modality == modality + + +def test_explicit_modality_overrides_content_type(): + attachment = Attachment( + content_type="application/octet-stream", data=b"x", modality="audio" + ) + assert attachment.modality == "audio" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("text,audio", {"text", "audio"}), + (" Text , AUDIO ", {"text", "audio"}), + ("all", set(modalities.ALL)), + (["image"], {"image"}), + ((), set()), + ], +) +def test_parse_accepts_lists_and_all(value, expected): + assert modalities.parse(value) == expected + + +def test_parse_rejects_unknown_names(): + with pytest.raises(ValueError, match="audios"): + modalities.parse("text,audios") + + +def capture(manager, *attachments): + return manager.capture(list(attachments), "inf-1", datetime.now(timezone.utc)) + + +def text(): + return Attachment(content_type="text/plain", data=b"hi") + + +def image(): + return Attachment(content_type="image/jpeg", data=b"img") + + +def test_default_uploads_text_only_and_warns_once(tmp_path, caplog): + store = AttachmentStore(str(tmp_path)) + manager = AttachmentManager(store) + with caplog.at_level(logging.DEBUG, logger="wildedge"): + refs = capture(manager, image(), text()) + capture(manager, image()) + assert [ref["content_type"] for ref in refs] == ["text/plain"] + assert store.length() == 1 + warnings = [r for r in caplog.records if "image attachments" in r.message] + assert len(warnings) == 1 + assert warnings[0].levelno == logging.WARNING + + +def test_explicit_choice_logs_blocked_modalities_at_debug(tmp_path, caplog): + manager = AttachmentManager( + AttachmentStore(str(tmp_path)), allowed_modalities={"text"} + ) + with caplog.at_level(logging.DEBUG, logger="wildedge"): + capture(manager, image()) + (record,) = [r for r in caplog.records if "image attachments" in r.message] + assert record.levelno == logging.DEBUG + + +def test_filter_never_sees_blocked_attachments(tmp_path): + seen = [] + manager = AttachmentManager( + AttachmentStore(str(tmp_path)), + attachment_filter=lambda items: seen.extend(items) or items, + ) + capture(manager, image(), text()) + assert [a.modality for a in seen] == ["text"] + + +def test_allowed_modalities_upload(tmp_path): + manager = AttachmentManager( + AttachmentStore(str(tmp_path)), allowed_modalities={"image"} + ) + refs = capture(manager, image(), text()) + assert [ref["content_type"] for ref in refs] == ["image/jpeg"] + + +def make_handle(**kwargs): + info = ModelInfo( + model_name="m", model_version="1", model_source="local", model_format="onnx" + ) + return ModelHandle("m", info, lambda _event: None, **kwargs) + + +def test_handle_wants_nothing_without_attachments(): + assert not make_handle().wants_attachment("text") + + +def test_handle_asks_the_client_policy(tmp_path): + manager = AttachmentManager(AttachmentStore(str(tmp_path))) + handle = make_handle( + capture_attachments=manager.capture, accepts_attachment=manager.accepts + ) + assert handle.wants_attachment("text") + assert not handle.wants_attachment("audio") + + +def client(tmp_path, **kwargs): + return WildEdge( + dsn="https://test@test.com/key", + attachments_enabled=True, + attachment_dir=str(tmp_path), + sampling_interval_s=0, + **kwargs, + ) + + +def test_client_takes_modalities_argument(tmp_path): + wildedge = client(tmp_path, attachment_modalities=["text", "audio"]) + try: + assert wildedge.attachment_manager.allowed_modalities == {"text", "audio"} + finally: + wildedge.close() + + +def test_client_reads_modalities_from_env(tmp_path, monkeypatch): + monkeypatch.setenv(constants.ENV_ATTACHMENT_MODALITIES, "all") + wildedge = client(tmp_path) + try: + assert wildedge.attachment_manager.allowed_modalities == set(modalities.ALL) + finally: + wildedge.close() + + +def test_client_defaults_to_text(tmp_path, monkeypatch): + monkeypatch.delenv(constants.ENV_ATTACHMENT_MODALITIES, raising=False) + wildedge = client(tmp_path) + try: + assert wildedge.attachment_manager.allowed_modalities == {"text"} + finally: + wildedge.close() + + +def test_client_rejects_unknown_modality(tmp_path): + with pytest.raises(ValueError, match="pictures"): + client(tmp_path, attachment_modalities="pictures") diff --git a/tests/test_whisper.py b/tests/test_whisper.py index c89efb4..5ba0f52 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -9,6 +9,7 @@ import pytest +from wildedge.attachments import AttachmentManager, AttachmentStore from wildedge.client import WildEdge from wildedge.instrumentation import claim_instrumentation from wildedge.integrations import faster_whisper as faster @@ -26,14 +27,22 @@ model_name_and_source, track_transcription, ) +from wildedge.model import ModelHandle, ModelInfo from wildedge.platforms.device_info import DeviceInfo @pytest.fixture def handle(): - return SimpleNamespace( - capture_attachments=None, track_inference=MagicMock(), track_error=MagicMock() + info = ModelInfo( + model_name="whisper", + model_version="1", + model_source="local", + model_format="ctranslate2", ) + handle = ModelHandle("whisper", info, MagicMock()) + handle.track_inference = MagicMock() + handle.track_error = MagicMock() + return handle @pytest.fixture @@ -473,3 +482,27 @@ def test_mlx_replacement_client_reports_the_cached_model( assert load["attributes"]["model_cache_hit"] is True assert len(events_of(replacement_events, "inference")) == 1 assert len(events_of(instrumented_client.events, "inference")) == 1 + + +def test_audio_is_left_out_unless_allowed(wav, handle, tmp_path): + manager = AttachmentManager(AttachmentStore(str(tmp_path))) + handle.capture_attachments = manager.capture + handle.accepts_attachment = manager.accepts + track_transcription(handle, wav, "hello world", duration_ms=1) + attachments = handle.track_inference.call_args.kwargs["attachments"] + assert [a.name for a in attachments] == ["transcript.txt"] + + +def test_allowed_audio_file_is_attached_as_audio(tmp_path, handle): + path = tmp_path / "clip.unknown-extension" + path.write_bytes(b"\x00" * 16) + manager = AttachmentManager( + AttachmentStore(str(tmp_path / "store")), allowed_modalities={"audio"} + ) + handle.capture_attachments = manager.capture + handle.accepts_attachment = manager.accepts + track_transcription(handle, path, "hello", duration_ms=1) + attachments = handle.track_inference.call_args.kwargs["attachments"] + assert [(a.name, a.modality) for a in attachments] == [ + ("clip.unknown-extension", "audio") + ] diff --git a/wildedge/attachments.py b/wildedge/attachments.py index 594cf54..300c685 100644 --- a/wildedge/attachments.py +++ b/wildedge/attachments.py @@ -18,19 +18,20 @@ import base64 import json +import logging import threading import time import urllib.error import urllib.request import uuid -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime from enum import Enum from pathlib import Path from typing import Any -from wildedge import constants +from wildedge import constants, modalities from wildedge.logging import logger AttachmentStorageStrategy = str # "file" | "inline" @@ -42,7 +43,10 @@ class Attachment: Provide exactly one of ``data`` (in-memory bytes) or ``path`` (a file on disk). ``content_type`` is the MIME type baked into the presigned URL and - echoed in the inference event reference. + echoed in the inference event reference. ``modality`` (see + ``wildedge.modalities``) decides whether the client's + ``attachment_modalities`` allow the upload; it defaults to the one + ``content_type`` belongs to. """ content_type: str @@ -50,6 +54,11 @@ class Attachment: data: bytes | None = None path: str | None = None name: str | None = None + modality: str | None = None + + def __post_init__(self) -> None: + if self.modality is None: + self.modality = modalities.from_content_type(self.content_type) def resolve_bytes(self) -> bytes: if self.data is not None: @@ -247,8 +256,9 @@ def _load(self, path: Path) -> PendingAttachment | None: class AttachmentManager: - """Capture-side gate: applies the filter hook and per-inference caps, then - buffers eligible attachments and returns the event references.""" + """Capture-side gate: keeps the allowed modalities, applies the filter hook + and per-inference caps, then buffers eligible attachments and returns the + event references.""" def __init__( self, @@ -257,9 +267,21 @@ def __init__( max_per_inference: int = constants.DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE, max_size_bytes: int = constants.DEFAULT_MAX_ATTACHMENT_SIZE_BYTES, attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, + allowed_modalities: Iterable[str] | None = None, debug: bool = False, ) -> None: self.store = store + # None means the default. Then a blocked modality is logged as a + # warning, since the app may not know about the setting; an explicit + # choice is only logged in debug mode. + self.allowed_modalities = frozenset( + modalities.DEFAULT if allowed_modalities is None else allowed_modalities + ) + self.blocked_log_level = ( + logging.WARNING if allowed_modalities is None else logging.DEBUG + ) + # Modalities already reported as blocked, so each one warns once. + self.blocked_reported: set[str] = set() self.max_per_inference = max_per_inference self.max_size_bytes = max_size_bytes self.attachment_filter = attachment_filter @@ -270,6 +292,21 @@ def disable(self) -> None: """Stop accepting attachments (e.g. the project has the feature off).""" self.enabled = False + def accepts(self, modality: str) -> bool: + """Whether attachments of this modality may be uploaded.""" + if modality in self.allowed_modalities: + return True + if modality not in self.blocked_reported: + self.blocked_reported.add(modality) + logger.log( + self.blocked_log_level, + "wildedge: not uploading %s attachments; add %r to " + "attachment_modalities to allow them", + modality, + modality, + ) + return False + def capture( self, attachments: list[Attachment], @@ -279,10 +316,13 @@ def capture( if not self.enabled or not attachments: return [] - selected = attachments + # Blocked modalities are dropped first, so the filter never sees them. + selected = [a for a in attachments if self.accepts(str(a.modality))] + if not selected: + return [] if self.attachment_filter is not None: try: - selected = self.attachment_filter(list(attachments)) + selected = self.attachment_filter(selected) except Exception as exc: # pragma: no cover - user callback failures logger.warning("wildedge: attachment_filter raised: %s", exc) return [] diff --git a/wildedge/cli.py b/wildedge/cli.py index 2199335..58c1ea4 100644 --- a/wildedge/cli.py +++ b/wildedge/cli.py @@ -16,7 +16,7 @@ from pathlib import Path from urllib.parse import urlparse -from wildedge import constants +from wildedge import constants, modalities from wildedge.batch import build_batch from wildedge.client import parse_dsn from wildedge.events.span import SpanEvent @@ -68,6 +68,15 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Enable opt-in raw input/output attachment upload (paid feature).", ) + run.add_argument( + "--attachment-modalities", + default=None, + type=modality_list, + help=( + "Comma-separated kinds of data attachments may contain: text, image, " + "audio, video, other, or all. Default: text." + ), + ) run.add_argument( "--debug", action="store_true", @@ -267,6 +276,8 @@ def run_command(parsed: argparse.Namespace) -> int: env[constants.ENV_FLUSH_TIMEOUT] = str(parsed.flush_timeout) if parsed.attachments: env[constants.ENV_ATTACHMENTS_ENABLED] = "1" + if parsed.attachment_modalities: + env[constants.ENV_ATTACHMENT_MODALITIES] = parsed.attachment_modalities env[constants.ENV_PROPAGATE] = "1" if parsed.propagate else "0" env[constants.ENV_STRICT_INTEGRATIONS] = "1" if parsed.strict_integrations else "0" env[constants.ENV_STRICT] = "1" if parsed.strict else "0" @@ -302,6 +313,15 @@ def hub_list(value: str | None) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] +def modality_list(value: str) -> str: + """Validate ``--attachment-modalities`` before the child process starts.""" + try: + modalities.parse(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from None + return value + + def check_writable_dir(path: Path) -> tuple[bool, str]: try: path.mkdir(parents=True, exist_ok=True) diff --git a/wildedge/client.py b/wildedge/client.py index e755e4f..6074295 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -4,12 +4,12 @@ import time import uuid import weakref -from collections.abc import Callable +from collections.abc import Callable, Iterable from datetime import datetime, timezone from typing import Any, ClassVar from urllib.parse import urlparse -from wildedge import constants +from wildedge import constants, modalities from wildedge.attachments import ( Attachment, AttachmentManager, @@ -274,6 +274,7 @@ def __init__( ), attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, attachment_dir: str | None = None, + attachment_modalities: str | Iterable[str] | None = None, ): env = read_client_env(dsn=dsn, debug=debug, app_identity=app_identity) dsn = env.dsn @@ -371,6 +372,13 @@ def __init__( if env.attachments_enabled is not None else constants.DEFAULT_ATTACHMENTS_ENABLED ) + if attachment_modalities is None: + attachment_modalities = env.attachment_modalities + allowed_modalities = ( + None + if attachment_modalities is None + else modalities.parse(attachment_modalities) + ) self.attachment_manager: AttachmentManager | None = None self.attachment_uploader: AttachmentUploader | None = None if attachments_enabled: @@ -386,6 +394,7 @@ def __init__( max_per_inference=max_attachments_per_inference, max_size_bytes=max_attachment_size_bytes, attachment_filter=attachment_filter, + allowed_modalities=allowed_modalities, debug=debug, ) self.attachment_uploader = AttachmentUploader( @@ -537,13 +546,13 @@ def register_model( "Pass id='your-model-id' to register_model()." ) - capture_attachments = ( - self.attachment_manager.capture - if self.attachment_manager is not None - else None - ) + manager = self.attachment_manager handle, is_new = self.registry.register( - model_id, info, self.publish, capture_attachments + model_id, + info, + self.publish, + capture_attachments=manager.capture if manager is not None else None, + accepts_attachment=manager.accepts if manager is not None else None, ) if not is_new: diff --git a/wildedge/constants.py b/wildedge/constants.py index 22ef327..7fe0017 100644 --- a/wildedge/constants.py +++ b/wildedge/constants.py @@ -21,6 +21,7 @@ ENV_PRINT_STARTUP_REPORT = "WILDEDGE_PRINT_STARTUP_REPORT" ENV_SAMPLING_INTERVAL = "WILDEDGE_SAMPLING_INTERVAL_S" ENV_ATTACHMENTS_ENABLED = "WILDEDGE_ATTACHMENTS_ENABLED" +ENV_ATTACHMENT_MODALITIES = "WILDEDGE_ATTACHMENT_MODALITIES" # Defaults DEFAULT_SAMPLING_INTERVAL_S = 30.0 diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py index 7f10ea7..63c8095 100644 --- a/wildedge/integrations/faster_whisper.py +++ b/wildedge/integrations/faster_whisper.py @@ -7,7 +7,7 @@ import threading import time -from wildedge import accelerators, quantizations +from wildedge import accelerators, modalities, quantizations from wildedge.instrumentation import bind_client_reference from wildedge.integrations.common import ( SyncStreamWrapper, @@ -125,7 +125,7 @@ def transcribe(self, audio, *args, **kwargs): debug_failure(FASTER_WHISPER, "error tracking", telemetry_exc) raise collector = TranscriptCollector( - t0, capture=handle.capture_attachments is not None + t0, capture=handle.wants_attachment(modalities.TEXT) ) def done(duration_ms, first_segment_ms, *, outcome="completed"): diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index b2e538e..3e518d0 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import accelerators, constants, quantizations +from wildedge import accelerators, constants, modalities, quantizations from wildedge.attachments import Attachment from wildedge.events.inference import ( GenerationConfig, @@ -216,7 +216,7 @@ def make_gguf_attachments( handle: object, prompt: object, messages: list | None, reply: str | None ) -> list[Attachment] | None: """Prompt (or chat messages) and reply, only when attachments are enabled.""" - if getattr(handle, "capture_attachments", None) is None: + if not handle.wants_attachment(modalities.TEXT): return None attachments: list[Attachment] = [] try: diff --git a/wildedge/integrations/whisper.py b/wildedge/integrations/whisper.py index b61da7d..7b4e813 100644 --- a/wildedge/integrations/whisper.py +++ b/wildedge/integrations/whisper.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from pathlib import Path +from wildedge import modalities from wildedge.attachments import Attachment from wildedge.events.inference import AudioInputMeta from wildedge.integrations.base import BaseExtractor @@ -195,6 +196,7 @@ def audio_attachments(audio: object) -> list[Attachment]: role="input", path=str(path), name=path.name, + modality=modalities.AUDIO, ) ] elif is_sample_array(audio) and pcm16_wav_size(audio.shape[0]) <= MAX_CAPTURE_BYTES: @@ -209,10 +211,19 @@ def audio_attachments(audio: object) -> list[Attachment]: return [] -def transcription_attachments(audio: object, text: str) -> list[Attachment]: - attachments = audio_attachments(audio) +def transcription_attachments( + handle: ModelHandle, audio: object, text: str +) -> list[Attachment]: + """The input audio and the transcript, each if the client allows it.""" + attachments = [] + if handle.wants_attachment(modalities.AUDIO): + attachments.extend(audio_attachments(audio)) encoded = text.encode() - if encoded and len(encoded) <= MAX_CAPTURE_BYTES: + if ( + encoded + and len(encoded) <= MAX_CAPTURE_BYTES + and handle.wants_attachment(modalities.TEXT) + ): attachments.append( Attachment( content_type="text/plain", @@ -256,7 +267,7 @@ def track_transcription( attachments = None if handle.capture_attachments is not None: try: - attachments = transcription_attachments(audio, text) + attachments = transcription_attachments(handle, audio, text) except Exception as exc: debug_failure("whisper", "attachment capture", exc) handle.track_inference( diff --git a/wildedge/modalities.py b/wildedge/modalities.py new file mode 100644 index 0000000..9293f47 --- /dev/null +++ b/wildedge/modalities.py @@ -0,0 +1,62 @@ +"""Attachment modalities: the kind of data an attachment holds. + +``attachment_modalities`` lists the ones a client may upload. Unlike the +accelerator and quantization labels, this set is closed, because the client +rejects names it doesn't know instead of silently uploading nothing. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +TEXT = "text" +IMAGE = "image" +AUDIO = "audio" +VIDEO = "video" +OTHER = "other" # any content type not covered above, such as tensors + +ALL: tuple[str, ...] = (TEXT, IMAGE, AUDIO, VIDEO, OTHER) + +# Without an explicit choice only text is uploaded. Voice and images can +# identify a person, so they are sent only when the app opts in. +DEFAULT: tuple[str, ...] = (TEXT,) + +# Structured text formats under application/. +TEXT_APPLICATION_TYPES = frozenset( + { + "application/json", + "application/x-ndjson", + "application/xml", + "application/yaml", + "application/x-yaml", + } +) +TEXT_SUFFIXES = ("+json", "+xml") +MEDIA_TOP_LEVEL_TYPES = frozenset({TEXT, IMAGE, AUDIO, VIDEO}) + + +def from_content_type(content_type: str) -> str: + """The modality a MIME type belongs to, such as ``audio`` for ``audio/wav``.""" + media_type = content_type.split(";", 1)[0].strip().lower() + top_level = media_type.partition("/")[0] + if top_level in MEDIA_TOP_LEVEL_TYPES: + return top_level + if media_type in TEXT_APPLICATION_TYPES or media_type.endswith(TEXT_SUFFIXES): + return TEXT + return OTHER + + +def parse(value: str | Iterable[str]) -> frozenset[str]: + """Validate a modality list; a string is comma-separated, ``"all"`` allows all.""" + if isinstance(value, str): + if value.strip().lower() == "all": + return frozenset(ALL) + value = value.split(",") + names = frozenset(item.strip().lower() for item in value if item.strip()) + unknown = names - set(ALL) + if unknown: + raise ValueError( + f"unknown attachment modality {', '.join(sorted(unknown))}; " + f"expected any of {', '.join(ALL)} or 'all'" + ) + return names diff --git a/wildedge/model.py b/wildedge/model.py index 119c646..77cdd3d 100644 --- a/wildedge/model.py +++ b/wildedge/model.py @@ -64,14 +64,26 @@ def __init__( publish: Callable[[dict], None], capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] | None = None, + accepts_attachment: Callable[[str], bool] | None = None, ): self.model_id = model_id self.info = info self.publish = publish self.capture_attachments = capture_attachments + self.accepts_attachment = accepts_attachment self.detected_accelerator: str = accelerators.CPU self.last_inference_id: str | None = None + def wants_attachment(self, modality: str) -> bool: + """Whether an attachment of this modality would be uploaded. + + Integrations check this before building an attachment, so a blocked + one costs nothing. + """ + if self.capture_attachments is None: + return False + return self.accepts_attachment is None or self.accepts_attachment(modality) + def track_load( self, duration_ms: int, @@ -405,6 +417,7 @@ def register( publish: Callable[[dict], None], capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] | None = None, + accepts_attachment: Callable[[str], bool] | None = None, ) -> tuple[ModelHandle, bool]: """Return (handle, is_new). is_new=False means already registered; skip install_hooks.""" if model_id in self.handles: @@ -419,6 +432,7 @@ def register( info=self.models[model_id], publish=publish, capture_attachments=capture_attachments, + accepts_attachment=accepts_attachment, ) self.handles[model_id] = handle return handle, True diff --git a/wildedge/settings.py b/wildedge/settings.py index 6c445b8..612af4e 100644 --- a/wildedge/settings.py +++ b/wildedge/settings.py @@ -15,6 +15,7 @@ class ClientEnv: debug: bool app_identity: str | None attachments_enabled: bool | None + attachment_modalities: str | None @dataclass(frozen=True) @@ -88,6 +89,7 @@ def read_client_env( debug=resolved_debug, app_identity=resolved_identity, attachments_enabled=attachments_enabled, + attachment_modalities=env.get(constants.ENV_ATTACHMENT_MODALITIES) or None, ) From f66f3b49be56a93d00ef1090fa1dde9ffb0f5bab Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 11:59:26 +0200 Subject: [PATCH 50/81] Warn when attachment modalities are set but attachments are off --- docs/configuration.md | 4 ++++ tests/test_modalities.py | 26 ++++++++++++++++++++++++++ wildedge/client.py | 6 ++++++ 3 files changed, 36 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 8d0bb47..dc33113 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,6 +64,10 @@ warning naming the setting to change (only a debug message if you set `attachment_filter` runs, and integrations skip building them at all, so a Whisper client without `audio` never encodes the audio. +`attachment_modalities` doesn't turn uploads on by itself: +`attachments_enabled` is still required, and the SDK warns if the list is set +without it. + For finer rules, such as uploading inputs but not outputs, use `attachment_filter`. diff --git a/tests/test_modalities.py b/tests/test_modalities.py index 9ec3398..6ac1e12 100644 --- a/tests/test_modalities.py +++ b/tests/test_modalities.py @@ -169,3 +169,29 @@ def test_client_defaults_to_text(tmp_path, monkeypatch): def test_client_rejects_unknown_modality(tmp_path): with pytest.raises(ValueError, match="pictures"): client(tmp_path, attachment_modalities="pictures") + + +@pytest.mark.parametrize("from_env", [False, True]) +def test_modalities_without_attachments_enabled_warn(monkeypatch, caplog, from_env): + monkeypatch.delenv(constants.ENV_ATTACHMENTS_ENABLED, raising=False) + kwargs = {} + if from_env: + monkeypatch.setenv(constants.ENV_ATTACHMENT_MODALITIES, "audio") + else: + kwargs["attachment_modalities"] = ["audio"] + with caplog.at_level(logging.WARNING, logger="wildedge"): + wildedge = WildEdge(dsn="https://test@test.com/key", **kwargs) + try: + assert wildedge.attachment_manager is None + assert any("attachments_enabled=True" in r.message for r in caplog.records) + finally: + wildedge.close() + + +def test_enabled_attachments_with_modalities_do_not_warn(tmp_path, caplog): + with caplog.at_level(logging.WARNING, logger="wildedge"): + wildedge = client(tmp_path, attachment_modalities=["audio"]) + try: + assert not any("attachments_enabled" in r.message for r in caplog.records) + finally: + wildedge.close() diff --git a/wildedge/client.py b/wildedge/client.py index 6074295..316d876 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -379,6 +379,12 @@ def __init__( if attachment_modalities is None else modalities.parse(attachment_modalities) ) + if allowed_modalities is not None and not attachments_enabled: + # The list only says what may be uploaded; it doesn't turn uploads on. + logger.warning( + "wildedge: attachment_modalities is set but attachments are " + "disabled; set attachments_enabled=True to upload them" + ) self.attachment_manager: AttachmentManager | None = None self.attachment_uploader: AttachmentUploader | None = None if attachments_enabled: From 998a6ed88f539e0769c19d0324f7f8d3b8e77e58 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 12:03:07 +0200 Subject: [PATCH 51/81] Add a Modality enum and accept it wherever modalities are named Strings still work, so the env var, CLI and existing code are unaffected. --- CHANGELOG.md | 4 +- docs/configuration.md | 12 +++-- docs/manual-tracking.md | 2 +- tests/test_modalities.py | 20 +++++++- wildedge/__init__.py | 2 + wildedge/attachments.py | 22 +++++---- wildedge/client.py | 6 +-- wildedge/integrations/faster_whisper.py | 5 +- wildedge/integrations/gguf.py | 5 +- wildedge/integrations/whisper.py | 8 +-- wildedge/modalities.py | 65 ++++++++++++++++--------- wildedge/model.py | 5 +- 12 files changed, 103 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbab1f..cf057fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ under Unreleased and move into a version section at release time. `text`, `image`, `audio`, `video` or `other`. It applies to manual and automatic attachments alike. **Apps that upload images or other non-text attachments must now allow them**, for example - `attachment_modalities=["text", "image"]`. The SDK warns once per blocked - modality. `Attachment` gains an optional `modality` field for content types + `attachment_modalities=["text", "image"]` or the new `wildedge.Modality` + enum. The SDK warns once per blocked modality. `Attachment` gains an optional `modality` field for content types that don't identify the data. Whisper uploads the input audio only when `audio` is allowed. diff --git a/docs/configuration.md b/docs/configuration.md index dc33113..db9f310 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,13 +51,19 @@ transcripts. Voice recordings, images and video can identify a person, so they are uploaded only when you list them: ```python -wildedge.init(attachments_enabled=True, attachment_modalities=["text", "audio"]) +from wildedge import Modality + +wildedge.init( + attachments_enabled=True, + attachment_modalities=[Modality.TEXT, Modality.AUDIO], +) ``` -From the CLI: `wildedge run --attachments --attachment-modalities text,audio`. +Plain strings work too (`["text", "audio"]`), and `"all"` allows every +modality. From the CLI: `wildedge run --attachments --attachment-modalities text,audio`. Each attachment's modality comes from its content type (`audio/wav` is -`audio`, `application/json` is `text`); anything else, such as raw tensors, +`audio`, `application/json` is `text`). Anything else, such as raw tensors, is `other`. The first time an attachment is dropped this way, the SDK logs a warning naming the setting to change (only a debug message if you set `attachment_modalities` yourself). Blocked attachments are dropped before diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index f663ef4..e14593c 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -284,7 +284,7 @@ handle.track_inference( An `Attachment` carries either in-memory `data` or a file `path`, plus a `role` (`"input"` or `"output"`) and `content_type`. Only text is uploaded unless `attachment_modalities` allows more, which is why the example lists -`"image"`. The modality comes from `content_type`; pass `modality=` when the +`"image"`. The modality comes from `content_type`. Pass `modality=` when the content type doesn't say, such as audio in `application/octet-stream`. The SDK writes a reference into the inference event immediately and uploads the bytes in the background via a presigned URL. A failed or disabled upload never blocks telemetry. diff --git a/tests/test_modalities.py b/tests/test_modalities.py index 6ac1e12..47cf108 100644 --- a/tests/test_modalities.py +++ b/tests/test_modalities.py @@ -5,7 +5,7 @@ import pytest -from wildedge import constants, modalities +from wildedge import Modality, constants, modalities from wildedge.attachments import Attachment, AttachmentManager, AttachmentStore from wildedge.client import WildEdge from wildedge.model import ModelHandle, ModelInfo @@ -195,3 +195,21 @@ def test_enabled_attachments_with_modalities_do_not_warn(tmp_path, caplog): assert not any("attachments_enabled" in r.message for r in caplog.records) finally: wildedge.close() + + +def test_enum_members_and_strings_are_interchangeable(tmp_path): + manager = AttachmentManager( + AttachmentStore(str(tmp_path)), allowed_modalities=[Modality.AUDIO, "text"] + ) + assert manager.allowed_modalities == {"audio", "text"} + assert manager.accepts(Modality.TEXT) + assert manager.accepts("audio") + attachment = Attachment( + content_type="application/octet-stream", data=b"x", modality=Modality.AUDIO + ) + assert attachment.modality == "audio" + + +def test_unknown_attachment_modality_is_rejected(): + with pytest.raises(ValueError, match="sound"): + Attachment(content_type="text/plain", data=b"x", modality="sound") diff --git a/wildedge/__init__.py b/wildedge/__init__.py index 63deebc..2d14a91 100644 --- a/wildedge/__init__.py +++ b/wildedge/__init__.py @@ -30,6 +30,7 @@ ) from wildedge.events.span import SpanKind, SpanStatus from wildedge.llm_api import LLMCall, llm_api +from wildedge.modalities import Modality from wildedge.platforms import capture_hardware from wildedge.platforms.device_info import DeviceInfo from wildedge.platforms.hardware import HardwareContext, ThermalContext @@ -59,6 +60,7 @@ "HardwareContext", "ImageInputMeta", "LLMCall", + "Modality", "QueuePolicy", "SpanContext", "SpanContextManager", diff --git a/wildedge/attachments.py b/wildedge/attachments.py index 300c685..9381c2a 100644 --- a/wildedge/attachments.py +++ b/wildedge/attachments.py @@ -45,7 +45,7 @@ class Attachment: disk). ``content_type`` is the MIME type baked into the presigned URL and echoed in the inference event reference. ``modality`` (see ``wildedge.modalities``) decides whether the client's - ``attachment_modalities`` allow the upload; it defaults to the one + ``attachment_modalities`` allow the upload. It defaults to the one ``content_type`` belongs to. """ @@ -54,11 +54,14 @@ class Attachment: data: bytes | None = None path: str | None = None name: str | None = None - modality: str | None = None + modality: modalities.Modality | str | None = None def __post_init__(self) -> None: - if self.modality is None: - self.modality = modalities.from_content_type(self.content_type) + self.modality = ( + modalities.from_content_type(self.content_type) + if self.modality is None + else modalities.normalize(self.modality) + ) def resolve_bytes(self) -> bytes: if self.data is not None: @@ -267,14 +270,14 @@ def __init__( max_per_inference: int = constants.DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE, max_size_bytes: int = constants.DEFAULT_MAX_ATTACHMENT_SIZE_BYTES, attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, - allowed_modalities: Iterable[str] | None = None, + allowed_modalities: Iterable[modalities.Modality | str] | None = None, debug: bool = False, ) -> None: self.store = store # None means the default. Then a blocked modality is logged as a - # warning, since the app may not know about the setting; an explicit + # warning, since the app may not know about the setting. An explicit # choice is only logged in debug mode. - self.allowed_modalities = frozenset( + self.allowed_modalities = modalities.parse( modalities.DEFAULT if allowed_modalities is None else allowed_modalities ) self.blocked_log_level = ( @@ -292,15 +295,16 @@ def disable(self) -> None: """Stop accepting attachments (e.g. the project has the feature off).""" self.enabled = False - def accepts(self, modality: str) -> bool: + def accepts(self, modality: modalities.Modality | str) -> bool: """Whether attachments of this modality may be uploaded.""" + modality = modalities.normalize(modality) if modality in self.allowed_modalities: return True if modality not in self.blocked_reported: self.blocked_reported.add(modality) logger.log( self.blocked_log_level, - "wildedge: not uploading %s attachments; add %r to " + "wildedge: not uploading %s attachments. Add %r to " "attachment_modalities to allow them", modality, modality, diff --git a/wildedge/client.py b/wildedge/client.py index 316d876..6ae838c 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -274,7 +274,7 @@ def __init__( ), attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, attachment_dir: str | None = None, - attachment_modalities: str | Iterable[str] | None = None, + attachment_modalities: str | Iterable[modalities.Modality | str] | None = None, ): env = read_client_env(dsn=dsn, debug=debug, app_identity=app_identity) dsn = env.dsn @@ -380,10 +380,10 @@ def __init__( else modalities.parse(attachment_modalities) ) if allowed_modalities is not None and not attachments_enabled: - # The list only says what may be uploaded; it doesn't turn uploads on. + # The list only says what may be uploaded. It doesn't turn uploads on. logger.warning( "wildedge: attachment_modalities is set but attachments are " - "disabled; set attachments_enabled=True to upload them" + "disabled. Set attachments_enabled=True to upload them" ) self.attachment_manager: AttachmentManager | None = None self.attachment_uploader: AttachmentUploader | None = None diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py index 63c8095..f6cc6e5 100644 --- a/wildedge/integrations/faster_whisper.py +++ b/wildedge/integrations/faster_whisper.py @@ -7,7 +7,7 @@ import threading import time -from wildedge import accelerators, modalities, quantizations +from wildedge import accelerators, quantizations from wildedge.instrumentation import bind_client_reference from wildedge.integrations.common import ( SyncStreamWrapper, @@ -24,6 +24,7 @@ report_load, track_transcription, ) +from wildedge.modalities import Modality from wildedge.timing import elapsed_ms _PATCH_LOCK = threading.Lock() @@ -125,7 +126,7 @@ def transcribe(self, audio, *args, **kwargs): debug_failure(FASTER_WHISPER, "error tracking", telemetry_exc) raise collector = TranscriptCollector( - t0, capture=handle.wants_attachment(modalities.TEXT) + t0, capture=handle.wants_attachment(Modality.TEXT) ) def done(duration_ms, first_segment_ms, *, outcome="completed"): diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index 3e518d0..8706608 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import accelerators, constants, modalities, quantizations +from wildedge import accelerators, constants, quantizations from wildedge.attachments import Attachment from wildedge.events.inference import ( GenerationConfig, @@ -27,6 +27,7 @@ stream_error_code, ) from wildedge.logging import logger +from wildedge.modalities import Modality from wildedge.model import ModelInfo from wildedge.platforms import CURRENT_PLATFORM from wildedge.timing import elapsed_ms @@ -216,7 +217,7 @@ def make_gguf_attachments( handle: object, prompt: object, messages: list | None, reply: str | None ) -> list[Attachment] | None: """Prompt (or chat messages) and reply, only when attachments are enabled.""" - if not handle.wants_attachment(modalities.TEXT): + if not handle.wants_attachment(Modality.TEXT): return None attachments: list[Attachment] = [] try: diff --git a/wildedge/integrations/whisper.py b/wildedge/integrations/whisper.py index 7b4e813..6bd9e3c 100644 --- a/wildedge/integrations/whisper.py +++ b/wildedge/integrations/whisper.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from pathlib import Path -from wildedge import modalities from wildedge.attachments import Attachment from wildedge.events.inference import AudioInputMeta from wildedge.integrations.base import BaseExtractor @@ -17,6 +16,7 @@ hf_repo_from_cache_path, stream_error_code, ) +from wildedge.modalities import Modality from wildedge.model import ModelHandle, ModelInfo # Backends, used in model IDs and as the integration names. @@ -196,7 +196,7 @@ def audio_attachments(audio: object) -> list[Attachment]: role="input", path=str(path), name=path.name, - modality=modalities.AUDIO, + modality=Modality.AUDIO, ) ] elif is_sample_array(audio) and pcm16_wav_size(audio.shape[0]) <= MAX_CAPTURE_BYTES: @@ -216,13 +216,13 @@ def transcription_attachments( ) -> list[Attachment]: """The input audio and the transcript, each if the client allows it.""" attachments = [] - if handle.wants_attachment(modalities.AUDIO): + if handle.wants_attachment(Modality.AUDIO): attachments.extend(audio_attachments(audio)) encoded = text.encode() if ( encoded and len(encoded) <= MAX_CAPTURE_BYTES - and handle.wants_attachment(modalities.TEXT) + and handle.wants_attachment(Modality.TEXT) ): attachments.append( Attachment( diff --git a/wildedge/modalities.py b/wildedge/modalities.py index 9293f47..07f7944 100644 --- a/wildedge/modalities.py +++ b/wildedge/modalities.py @@ -1,25 +1,30 @@ """Attachment modalities: the kind of data an attachment holds. -``attachment_modalities`` lists the ones a client may upload. Unlike the -accelerator and quantization labels, this set is closed, because the client -rejects names it doesn't know instead of silently uploading nothing. +``attachment_modalities`` lists the ones a client may upload, as ``Modality`` +members or their string values (``"audio"``), which is what the env var and +CLI use. Unlike the accelerator and quantization labels, this set is closed. +The client rejects names it doesn't know instead of silently uploading nothing. """ from __future__ import annotations from collections.abc import Iterable +from enum import Enum -TEXT = "text" -IMAGE = "image" -AUDIO = "audio" -VIDEO = "video" -OTHER = "other" # any content type not covered above, such as tensors -ALL: tuple[str, ...] = (TEXT, IMAGE, AUDIO, VIDEO, OTHER) +class Modality(str, Enum): + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + VIDEO = "video" + OTHER = "other" # any content type not covered above, such as tensors + + +ALL: tuple[str, ...] = tuple(modality.value for modality in Modality) # Without an explicit choice only text is uploaded. Voice and images can # identify a person, so they are sent only when the app opts in. -DEFAULT: tuple[str, ...] = (TEXT,) +DEFAULT: tuple[str, ...] = (Modality.TEXT.value,) # Structured text formats under application/. TEXT_APPLICATION_TYPES = frozenset( @@ -32,7 +37,25 @@ } ) TEXT_SUFFIXES = ("+json", "+xml") -MEDIA_TOP_LEVEL_TYPES = frozenset({TEXT, IMAGE, AUDIO, VIDEO}) +MEDIA_TOP_LEVEL_TYPES = frozenset( + { + Modality.TEXT.value, + Modality.IMAGE.value, + Modality.AUDIO.value, + Modality.VIDEO.value, + } +) + + +def normalize(modality: Modality | str) -> str: + """The plain string value, so enum members and strings compare alike.""" + try: + return Modality(modality.strip().lower()).value + except ValueError: + raise ValueError( + f"unknown attachment modality {modality!r}, " + f"expected any of {', '.join(ALL)}" + ) from None def from_content_type(content_type: str) -> str: @@ -42,21 +65,15 @@ def from_content_type(content_type: str) -> str: if top_level in MEDIA_TOP_LEVEL_TYPES: return top_level if media_type in TEXT_APPLICATION_TYPES or media_type.endswith(TEXT_SUFFIXES): - return TEXT - return OTHER + return Modality.TEXT.value + return Modality.OTHER.value -def parse(value: str | Iterable[str]) -> frozenset[str]: - """Validate a modality list; a string is comma-separated, ``"all"`` allows all.""" +def parse(value: str | Iterable[Modality | str]) -> frozenset[str]: + """Validate a modality list. A string is comma-separated, and ``"all"`` + allows every modality.""" if isinstance(value, str): if value.strip().lower() == "all": return frozenset(ALL) - value = value.split(",") - names = frozenset(item.strip().lower() for item in value if item.strip()) - unknown = names - set(ALL) - if unknown: - raise ValueError( - f"unknown attachment modality {', '.join(sorted(unknown))}; " - f"expected any of {', '.join(ALL)} or 'all'" - ) - return names + value = [item for item in value.split(",") if item.strip()] + return frozenset(normalize(item) for item in value) diff --git a/wildedge/model.py b/wildedge/model.py index 77cdd3d..b0c360f 100644 --- a/wildedge/model.py +++ b/wildedge/model.py @@ -29,6 +29,7 @@ TextInputMeta, ) from wildedge.logging import logger +from wildedge.modalities import Modality from wildedge.platforms import capture_hardware, is_sampling from wildedge.platforms.hardware import HardwareContext from wildedge.tracing import _merge_correlation_fields @@ -64,7 +65,7 @@ def __init__( publish: Callable[[dict], None], capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] | None = None, - accepts_attachment: Callable[[str], bool] | None = None, + accepts_attachment: Callable[[Modality | str], bool] | None = None, ): self.model_id = model_id self.info = info @@ -74,7 +75,7 @@ def __init__( self.detected_accelerator: str = accelerators.CPU self.last_inference_id: str | None = None - def wants_attachment(self, modality: str) -> bool: + def wants_attachment(self, modality: Modality | str) -> bool: """Whether an attachment of this modality would be uploaded. Integrations check this before building an attachment, so a blocked From ff64a7cad07002c3204cdc1a708ff390bc41c1c9 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 12:05:05 +0200 Subject: [PATCH 52/81] Show Modality constants in every Python example --- CHANGELOG.md | 19 ++++++++++--------- docs/configuration.md | 9 ++++++--- docs/manual-tracking.md | 12 ++++++++---- docs/whisper.md | 9 +++++++-- examples/attachments_example.py | 4 ++-- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf057fe..1a41ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,15 @@ under Unreleased and move into a version section at release time. - Attachments only upload text by default. The new `attachment_modalities` setting (`WILDEDGE_ATTACHMENT_MODALITIES`, `wildedge run - --attachment-modalities`) lists the kinds of data that may be uploaded: - `text`, `image`, `audio`, `video` or `other`. It applies to manual and - automatic attachments alike. **Apps that upload images or other non-text - attachments must now allow them**, for example - `attachment_modalities=["text", "image"]` or the new `wildedge.Modality` - enum. The SDK warns once per blocked modality. `Attachment` gains an optional `modality` field for content types - that don't identify the data. Whisper uploads the input audio only when - `audio` is allowed. + --attachment-modalities`) lists the kinds of data that may be uploaded, as + members of the new `wildedge.Modality` enum: `TEXT`, `IMAGE`, `AUDIO`, + `VIDEO` or `OTHER`. It applies to manual and automatic attachments alike. + **Apps that upload images or other non-text attachments must now allow + them**, for example + `attachment_modalities=[Modality.TEXT, Modality.IMAGE]`. The SDK warns once + per blocked modality. `Attachment` gains an optional `modality` field for + content types that don't identify the data. Whisper uploads the input audio + only when `Modality.AUDIO` is allowed. ### Added @@ -24,7 +25,7 @@ under Unreleased and move into a version section at release time. loads and transcriptions, including faster-whisper's `BatchedInferencePipeline`. Transcriptions record duration, language, word count and audio metadata. With `attachments_enabled=True` they also upload - the transcript, and the input audio when `audio` is in + the transcript, and the input audio when `Modality.AUDIO` is in `attachment_modalities`. See docs/whisper.md. ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index db9f310..8fc8473 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,7 +35,7 @@ event flushes never block on an upload. | Parameter | Default | Env var | Description | |---|---|---|---| | `attachments_enabled` | `false` | `WILDEDGE_ATTACHMENTS_ENABLED` | Enable raw input/output upload | -| `attachment_modalities` | `["text"]` | `WILDEDGE_ATTACHMENT_MODALITIES` | Kinds of data that may be uploaded: `text`, `image`, `audio`, `video`, `other`, or `"all"` | +| `attachment_modalities` | `[Modality.TEXT]` | `WILDEDGE_ATTACHMENT_MODALITIES` | Kinds of data that may be uploaded, as `Modality` members: `TEXT`, `IMAGE`, `AUDIO`, `VIDEO`, `OTHER` | | `max_attachments_per_inference` | `10` | - | Cap on attachments buffered per inference event | | `max_attachment_size_bytes` | `10485760` | - | Per-attachment size limit (10 MB); larger ones are dropped | | `attachment_storage_strategy` | `"file"` | - | `"file"` (bytes on disk) or `"inline"` (small blobs embedded in the record) | @@ -59,8 +59,11 @@ wildedge.init( ) ``` -Plain strings work too (`["text", "audio"]`), and `"all"` allows every -modality. From the CLI: `wildedge run --attachments --attachment-modalities text,audio`. +To allow every modality, pass `list(Modality)`. + +The env var and CLI take the lowercase names, comma-separated, or `all`: +`WILDEDGE_ATTACHMENT_MODALITIES=text,audio` or +`wildedge run --attachments --attachment-modalities text,audio`. Each attachment's modality comes from its content type (`audio/wav` is `audio`, `application/json` is `text`). Anything else, such as raw tensors, diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index e14593c..adaba06 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -265,9 +265,12 @@ Enable it at init, then pass `attachments=` to `track_inference`: ```python import wildedge -from wildedge import Attachment +from wildedge import Attachment, Modality -client = wildedge.init(attachments_enabled=True, attachment_modalities=["text", "image"]) +client = wildedge.init( + attachments_enabled=True, + attachment_modalities=[Modality.TEXT, Modality.IMAGE], +) handle = client.register_model(model, model_id="doc-classifier-v1") handle.track_inference( @@ -284,8 +287,9 @@ handle.track_inference( An `Attachment` carries either in-memory `data` or a file `path`, plus a `role` (`"input"` or `"output"`) and `content_type`. Only text is uploaded unless `attachment_modalities` allows more, which is why the example lists -`"image"`. The modality comes from `content_type`. Pass `modality=` when the -content type doesn't say, such as audio in `application/octet-stream`. The SDK writes a reference +`Modality.IMAGE`. The modality comes from `content_type`. Pass `modality=` when the +content type doesn't say, such as `modality=Modality.AUDIO` for audio in +`application/octet-stream`. The SDK writes a reference into the inference event immediately and uploads the bytes in the background via a presigned URL. A failed or disabled upload never blocks telemetry. diff --git a/docs/whisper.md b/docs/whisper.md index 6eca3c2..05b4afb 100644 --- a/docs/whisper.md +++ b/docs/whisper.md @@ -85,7 +85,12 @@ uploads the transcript as `transcript.txt`. The input audio is a voice recording, so it is uploaded only when you also allow audio: ```python -wildedge.init(attachments_enabled=True, attachment_modalities=["text", "audio"]) +from wildedge import Modality + +wildedge.init( + attachments_enabled=True, + attachment_modalities=[Modality.TEXT, Modality.AUDIO], +) ``` The audio is the original file for a path, or a 16-bit WAV for an array. @@ -96,7 +101,7 @@ Keep in mind: also apply. A transcript over the cap isn't uploaded at all. - Audio passed as a file-like object isn't captured. - An audio file must still exist when the transcription finishes. -- To upload audio but not transcripts, use `attachment_modalities=["audio"]`. +- To upload audio but not transcripts, use `attachment_modalities=[Modality.AUDIO]`. - Transcript text is never added to event attributes. ## Examples and tests diff --git a/examples/attachments_example.py b/examples/attachments_example.py index 4c501d7..95da94c 100644 --- a/examples/attachments_example.py +++ b/examples/attachments_example.py @@ -18,7 +18,7 @@ """ import wildedge -from wildedge import Attachment +from wildedge import Attachment, Modality # Optional: redact / drop attachments before they are buffered. @@ -30,7 +30,7 @@ def redact(attachments: list[Attachment]) -> list[Attachment]: app_version="1.0.0", attachments_enabled=True, # Only text is uploaded unless other kinds of data are allowed here. - attachment_modalities=["text", "image"], + attachment_modalities=[Modality.TEXT, Modality.IMAGE], max_attachments_per_inference=5, max_attachment_size_bytes=5 * 1024 * 1024, attachment_storage_strategy="file", # or "inline" for small blobs From 193f51e3f9a248b2ef480418a8e2234329b20819 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 12:37:10 +0200 Subject: [PATCH 53/81] Require explicit attachments across Python integrations --- CHANGELOG.md | 20 +-- docs/configuration.md | 41 +---- docs/manual-tracking.md | 51 ++++-- docs/whisper.md | 46 +++-- examples/attachments_example.py | 4 +- examples/whisper_example.py | 9 +- tests/test_attachments.py | 17 +- tests/test_cli.py | 27 --- tests/test_integrations.py | 70 ++++---- tests/test_modalities.py | 215 ------------------------ tests/test_whisper.py | 79 ++++----- wildedge/__init__.py | 2 - wildedge/attachments.py | 58 +------ wildedge/cli.py | 22 +-- wildedge/client.py | 31 +--- wildedge/constants.py | 1 - wildedge/integrations/faster_whisper.py | 29 +--- wildedge/integrations/gguf.py | 61 ------- wildedge/integrations/whisper.py | 93 +--------- wildedge/modalities.py | 79 --------- wildedge/model.py | 15 -- wildedge/settings.py | 2 - 22 files changed, 178 insertions(+), 794 deletions(-) delete mode 100644 tests/test_modalities.py delete mode 100644 wildedge/modalities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a41ad9..96189c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,18 @@ under Unreleased and move into a version section at release time. ### Changed -- Attachments only upload text by default. The new `attachment_modalities` - setting (`WILDEDGE_ATTACHMENT_MODALITIES`, `wildedge run - --attachment-modalities`) lists the kinds of data that may be uploaded, as - members of the new `wildedge.Modality` enum: `TEXT`, `IMAGE`, `AUDIO`, - `VIDEO` or `OTHER`. It applies to manual and automatic attachments alike. - **Apps that upload images or other non-text attachments must now allow - them**, for example - `attachment_modalities=[Modality.TEXT, Modality.IMAGE]`. The SDK warns once - per blocked modality. `Attachment` gains an optional `modality` field for - content types that don't identify the data. Whisper uploads the input audio - only when `Modality.AUDIO` is allowed. +- Automatic integrations no longer attach raw payloads, including GGUF prompts, + chat messages and replies. `attachments_enabled=True` enables uploads only for + attachments explicitly passed to `track_inference(attachments=[...])`. + Apps relying on automatic GGUF capture must supply attachments explicitly. ### Added - `mlx_whisper` and `faster_whisper` integrations track local Whisper model loads and transcriptions, including faster-whisper's `BatchedInferencePipeline`. Transcriptions record duration, language, word - count and audio metadata. With `attachments_enabled=True` they also upload - the transcript, and the input audio when `Modality.AUDIO` is in - `attachment_modalities`. See docs/whisper.md. + count and audio metadata. Audio and transcript attachments must be supplied + explicitly through the manual tracking API. See docs/whisper.md. ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index 8fc8473..02f6f46 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,50 +35,15 @@ event flushes never block on an upload. | Parameter | Default | Env var | Description | |---|---|---|---| | `attachments_enabled` | `false` | `WILDEDGE_ATTACHMENTS_ENABLED` | Enable raw input/output upload | -| `attachment_modalities` | `[Modality.TEXT]` | `WILDEDGE_ATTACHMENT_MODALITIES` | Kinds of data that may be uploaded, as `Modality` members: `TEXT`, `IMAGE`, `AUDIO`, `VIDEO`, `OTHER` | | `max_attachments_per_inference` | `10` | - | Cap on attachments buffered per inference event | | `max_attachment_size_bytes` | `10485760` | - | Per-attachment size limit (10 MB); larger ones are dropped | | `attachment_storage_strategy` | `"file"` | - | `"file"` (bytes on disk) or `"inline"` (small blobs embedded in the record) | | `attachment_filter` | `None` | - | `Callable[[list[Attachment]], list[Attachment]]` to redact/drop before buffering | | `attachment_dir` | per `app_identity` | - | Override the local buffer directory | -### Choosing what may be uploaded - -`attachment_modalities` decides which kinds of data leave the device. It -applies to every attachment, whether an integration captured it or you passed -it to `track_inference`. The default is text only: prompts, replies and -transcripts. Voice recordings, images and video can identify a person, so they -are uploaded only when you list them: - -```python -from wildedge import Modality - -wildedge.init( - attachments_enabled=True, - attachment_modalities=[Modality.TEXT, Modality.AUDIO], -) -``` - -To allow every modality, pass `list(Modality)`. - -The env var and CLI take the lowercase names, comma-separated, or `all`: -`WILDEDGE_ATTACHMENT_MODALITIES=text,audio` or -`wildedge run --attachments --attachment-modalities text,audio`. - -Each attachment's modality comes from its content type (`audio/wav` is -`audio`, `application/json` is `text`). Anything else, such as raw tensors, -is `other`. The first time an attachment is dropped this way, the SDK logs a -warning naming the setting to change (only a debug message if you set -`attachment_modalities` yourself). Blocked attachments are dropped before -`attachment_filter` runs, and integrations skip building them at all, so a -Whisper client without `audio` never encodes the audio. - -`attachment_modalities` doesn't turn uploads on by itself: -`attachments_enabled` is still required, and the SDK warns if the list is set -without it. - -For finer rules, such as uploading inputs but not outputs, use -`attachment_filter`. +`attachments_enabled=True` permits uploads of attachments explicitly supplied to +`track_inference(attachments=[...])`. Automatic integrations do not attach raw +inputs or outputs. See [Track attachments](manual-tracking.md#track-attachments) for usage. diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index adaba06..cd185a8 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -261,17 +261,19 @@ handle.feedback(FeedbackType.THUMBS_DOWN) Opt-in upload of the raw bytes behind an inference, such as the source image, audio clip, or generated text, so you can inspect or curate them later. This is off by default and requires the attachment feature enabled on your project. -Enable it at init, then pass `attachments=` to `track_inference`: +Enable it at init, then pass `attachments=` to `track_inference`. Automatic +integrations, including GGUF and Whisper, do not supply attachments. When tracking +the inference manually, use `auto_instrument=False` at model registration to avoid +recording the same call twice: ```python import wildedge -from wildedge import Attachment, Modality +from wildedge import Attachment -client = wildedge.init( - attachments_enabled=True, - attachment_modalities=[Modality.TEXT, Modality.IMAGE], +client = wildedge.init(attachments_enabled=True) +handle = client.register_model( + model, model_id="doc-classifier-v1", auto_instrument=False ) -handle = client.register_model(model, model_id="doc-classifier-v1") handle.track_inference( duration_ms=120, @@ -285,20 +287,45 @@ handle.track_inference( ``` An `Attachment` carries either in-memory `data` or a file `path`, plus a -`role` (`"input"` or `"output"`) and `content_type`. Only text is uploaded -unless `attachment_modalities` allows more, which is why the example lists -`Modality.IMAGE`. The modality comes from `content_type`. Pass `modality=` when the -content type doesn't say, such as `modality=Modality.AUDIO` for audio in -`application/octet-stream`. The SDK writes a reference +`role` (`"input"` or `"output"`) and `content_type`. The SDK writes a reference into the inference event immediately and uploads the bytes in the background via a presigned URL. A failed or disabled upload never blocks telemetry. Bytes are buffered to disk and survive restarts. Capture is gated by -`attachment_modalities`, `max_attachments_per_inference`, `max_attachment_size_bytes`, and an optional +`max_attachments_per_inference`, `max_attachment_size_bytes`, and an optional `attachment_filter` hook. See [Configuration](configuration.md#attachments) and [`examples/attachments_example.py`](../examples/attachments_example.py) for the full reference. +### Explicit GGUF prompt and reply attachments + +Use manual tracking when you want to attach GGUF payloads: + +```python +import wildedge +from llama_cpp import Llama +from wildedge import Attachment +from wildedge.timing import Timer + +client = wildedge.init(integrations=[], attachments_enabled=True) +llm = Llama(model_path="/models/model.gguf") +handle = client.register_model(llm, auto_instrument=False) +prompt = "Explain how rain forms." +with Timer() as timer: + result = llm.create_completion(prompt=prompt, max_tokens=128) +reply = result["choices"][0]["text"] +handle.track_inference( + duration_ms=timer.elapsed_ms, + input_modality="text", + output_modality="generation", + attachments=[ + Attachment(content_type="text/plain", role="input", data=prompt.encode()), + Attachment(content_type="text/plain", role="output", data=reply.encode()), + ], +) +client.close(timeout=10) +``` + ## Track spans for agentic workflows Use span events to track non-inference steps like planning, tool calls, retrieval, or memory updates. diff --git a/docs/whisper.md b/docs/whisper.md index 05b4afb..e7dff27 100644 --- a/docs/whisper.md +++ b/docs/whisper.md @@ -78,31 +78,41 @@ A `WhisperModel` stays with the SDK client that was active when it was created. After you replace the client, create the model again; until then, it isn't tracked and the SDK warns once. -## Capturing audio and transcripts +## Explicit audio and transcript attachments -Capture is off by default. With `attachments_enabled=True`, each transcription -uploads the transcript as `transcript.txt`. The input audio is a voice -recording, so it is uploaded only when you also allow audio: +Automatic instrumentation records metadata only, even with +`attachments_enabled=True`. To upload audio or transcript text, supply the +attachments explicitly through [manual tracking](manual-tracking.md#track-attachments). +For example, with a faster-whisper model: ```python -from wildedge import Modality +import time +import wildedge +from wildedge import Attachment + +# Use manual tracking for this call so it produces one inference event. +client = wildedge.init(integrations=[], attachments_enabled=True) +from faster_whisper import WhisperModel -wildedge.init( - attachments_enabled=True, - attachment_modalities=[Modality.TEXT, Modality.AUDIO], +model = WhisperModel("/models/whisper", device="cpu", compute_type="int8") +handle = client.register_model(model, auto_instrument=False) +started = time.perf_counter() +segments, info = model.transcribe("speech.wav") +text = "".join(segment.text for segment in segments) +handle.track_inference( + duration_ms=round((time.perf_counter() - started) * 1000), + input_modality="audio", + output_modality="text", + attachments=[ + Attachment(content_type="audio/wav", role="input", path="speech.wav"), + Attachment(content_type="text/plain", role="output", data=text.encode()), + ], ) +client.close(timeout=10) ``` -The audio is the original file for a path, or a 16-bit WAV for an array. - -Keep in mind: - -- Each attachment is capped at 10 MiB, and the client's own attachment limits - also apply. A transcript over the cap isn't uploaded at all. -- Audio passed as a file-like object isn't captured. -- An audio file must still exist when the transcription finishes. -- To upload audio but not transcripts, use `attachment_modalities=[Modality.AUDIO]`. -- Transcript text is never added to event attributes. +Choose either attachment or both. Files must exist when `track_inference` runs; +normal attachment size limits and the optional `attachment_filter` apply. ## Examples and tests diff --git a/examples/attachments_example.py b/examples/attachments_example.py index 95da94c..4669561 100644 --- a/examples/attachments_example.py +++ b/examples/attachments_example.py @@ -18,7 +18,7 @@ """ import wildedge -from wildedge import Attachment, Modality +from wildedge import Attachment # Optional: redact / drop attachments before they are buffered. @@ -29,8 +29,6 @@ def redact(attachments: list[Attachment]) -> list[Attachment]: wildedge.init( app_version="1.0.0", attachments_enabled=True, - # Only text is uploaded unless other kinds of data are allowed here. - attachment_modalities=[Modality.TEXT, Modality.IMAGE], max_attachments_per_inference=5, max_attachment_size_bytes=5 * 1024 * 1024, attachment_storage_strategy="file", # or "inline" for small blobs diff --git a/examples/whisper_example.py b/examples/whisper_example.py index 9e298ae..7c5a1f2 100644 --- a/examples/whisper_example.py +++ b/examples/whisper_example.py @@ -4,7 +4,7 @@ python examples/whisper_example.py mlx_whisper /path/to/model /path/to/audio.wav python examples/whisper_example.py faster_whisper /path/to/model /path/to/audio.wav -See docs/whisper.md for lifecycle and opt-in attachment capture behavior. +See docs/whisper.md for lifecycle and explicit attachment examples. """ import argparse @@ -17,13 +17,8 @@ def main(): parser.add_argument("backend", choices=["mlx_whisper", "faster_whisper"]) parser.add_argument("model") parser.add_argument("audio") - parser.add_argument( - "--capture", action="store_true", help="Attach audio and transcript" - ) args = parser.parse_args() - client = wildedge.init( - integrations=[args.backend], attachments_enabled=args.capture - ) + client = wildedge.init(integrations=[args.backend]) try: with client.trace(), client.span(name="transcribe"): if args.backend == "mlx_whisper": diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 6b8cc38..0616c22 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -9,7 +9,7 @@ import pytest -from wildedge import attachments, constants, modalities +from wildedge import attachments, constants from wildedge.attachments import ( Attachment, AttachmentManager, @@ -104,7 +104,6 @@ def test_store_peek_oldest_first(tmp_path): def _manager(tmp_path, **kwargs): store = AttachmentStore(str(tmp_path), strategy="file") - kwargs.setdefault("allowed_modalities", modalities.ALL) return store, AttachmentManager(store, **kwargs) @@ -216,7 +215,7 @@ def test_text_input_meta_no_has_attachments(): def _make_handle(tmp_path): store = AttachmentStore(str(tmp_path), strategy="file") - mgr = AttachmentManager(store, allowed_modalities=modalities.ALL) + mgr = AttachmentManager(store) published: list[dict] = [] info = ModelInfo( model_name="m", model_version="1", model_source="local", model_format="onnx" @@ -225,17 +224,23 @@ def _make_handle(tmp_path): return handle, store, published -def test_track_inference_wires_attachments(tmp_path): +@pytest.mark.parametrize( + "content_type", + ["text/plain", "image/jpeg", "audio/wav", "video/mp4", "application/octet-stream"], +) +def test_track_inference_wires_attachments(tmp_path, content_type): handle, store, published = _make_handle(tmp_path) handle.track_inference( duration_ms=10, input_modality="image", - attachments=[Attachment(content_type="image/jpeg", role="input", data=b"img")], + attachments=[ + Attachment(content_type=content_type, role="input", data=b"payload") + ], ) assert store.length() == 1 event = published[0] assert len(event["attachments"]) == 1 - assert event["attachments"][0]["content_type"] == "image/jpeg" + assert event["attachments"][0]["content_type"] == content_type # The buffered attachment is keyed to this inference event. assert store.peek_many(1)[0].inference_id == event["inference"]["inference_id"] diff --git a/tests/test_cli.py b/tests/test_cli.py index c862e29..882bf3b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -578,30 +578,3 @@ def test_doctor_json_includes_environment_and_statuses(monkeypatch, capsys): assert data["environment"]["autoload_on_pythonpath"] is False assert data["config_status"] == "PASS" assert data["connectivity_status"] == "SKIP" - - -def test_cli_run_passes_attachment_modalities(monkeypatch): - captured: dict = {} - monkeypatch.setattr(cli.os, "execle", _fake_execle(captured)) - monkeypatch.setattr(cli.shutil, "which", lambda cmd: f"/usr/bin/{cmd}") - rc = cli.main( - [ - "run", - "--dsn", - "https://secret@ingest.wildedge.dev/key", - "--attachments", - "--attachment-modalities", - "text,audio", - "--", - "python", - "app.py", - ] - ) - assert rc == 0 - assert captured["env"][constants.ENV_ATTACHMENT_MODALITIES] == "text,audio" - - -def test_cli_run_rejects_unknown_attachment_modality(capsys): - with pytest.raises(SystemExit): - cli.main(["run", "--attachment-modalities", "audios", "--", "python"]) - assert "audios" in capsys.readouterr().err diff --git a/tests/test_integrations.py b/tests/test_integrations.py index e6f42fe..1f678df 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -3,16 +3,15 @@ from __future__ import annotations import inspect -import json from unittest.mock import MagicMock, patch import pytest +from wildedge.attachments import AttachmentManager, AttachmentStore from wildedge.integrations import gguf as gguf_integration from wildedge.integrations.gguf import ( GgufExtractor, call_arguments, - make_gguf_attachments, make_gguf_generation_config, make_gguf_output_meta, make_gguf_stream_callbacks, @@ -113,7 +112,7 @@ def __call__(self, prompt="", **kwargs): def create_chat_completion(self, messages, **kwargs): # The chat handler formats the messages, then calls llama.create_completion. - prompt = "".join(m["content"] for m in messages) + prompt = "".join(str(m["content"]) for m in messages) result = self.create_completion(prompt=prompt, **kwargs) if kwargs.get("stream"): return result @@ -510,25 +509,40 @@ def test_no_attachments_when_disabled(self, publish_spy): llm.create_chat_completion(messages=[{"role": "user", "content": "hello"}]) assert "attachments" not in publish_spy.events[0] - def test_chat_attachments_when_enabled(self, publish_spy): - captured = [] - - def capture(attachments, inference_id, timestamp): - captured.extend(attachments) - return [{"attachment_id": str(i)} for i in range(len(attachments))] - + @pytest.mark.parametrize("streaming", [False, True]) + @pytest.mark.parametrize("chat", [False, True]) + def test_enabled_uploads_never_capture_payloads( + self, publish_spy, tmp_path, streaming, chat + ): + store = AttachmentStore(str(tmp_path)) + manager = AttachmentManager(store) llm = Llama() handle = make_handle(publish_spy) - handle.capture_attachments = capture + handle.capture_attachments = manager.capture self.extractor.install_hooks(llm, handle) - messages = [{"role": "user", "content": "hello"}] - llm.create_chat_completion(messages=messages) - assert [(a.role, a.content_type) for a in captured] == [ - ("input", "application/json"), - ("output", "text/plain"), - ] - assert json.loads(captured[0].data) == messages - assert captured[1].data == b"hi!" + if chat: + result = llm.create_chat_completion( + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "private prompt"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,PRIVATE"}, + }, + ], + } + ], + stream=streaming, + ) + else: + result = llm.create_completion(prompt="private prompt", stream=streaming) + if streaming: + assert list(result) + assert len(publish_spy.events) == 1 + assert "attachments" not in publish_spy.events[0] + assert store.length() == 0 def test_stop_given_as_string_counts_one_sequence(self): config = make_gguf_generation_config({"stop": ""}) @@ -619,26 +633,10 @@ def test_stream_callbacks_read_chat_deltas_and_skip_non_dicts(self, publish_spy) on_chunk({"choices": [{"delta": {"content": "he"}}]}) on_chunk({"choices": [{"delta": {"content": "y"}, "finish_reason": "stop"}]}) on_done(10, 2) - assert captured[-1].data == b"hey" + assert captured == [] meta = publish_spy.events[0]["inference"]["output_meta"] assert meta["stop_reason"] == "stop" - def test_attachment_build_error_is_swallowed(self, publish_spy): - handle = make_handle(publish_spy) - handle.capture_attachments = lambda *a: [] - circular: list = [] - circular.append(circular) - assert make_gguf_attachments(handle, "p", [circular], "reply") is None - - def test_streamed_completion_attaches_joined_reply(self, publish_spy): - captured = [] - llm = Llama() - handle = make_handle(publish_spy) - handle.capture_attachments = lambda a, *_: captured.extend(a) or [] - self.extractor.install_hooks(llm, handle) - list(llm.create_completion(prompt="hello", stream=True)) - assert [a.data for a in captured] == [b"hello", b"hi!"] - # --------------------------------------------------------------------------- # Keras diff --git a/tests/test_modalities.py b/tests/test_modalities.py deleted file mode 100644 index 47cf108..0000000 --- a/tests/test_modalities.py +++ /dev/null @@ -1,215 +0,0 @@ -from __future__ import annotations - -import logging -from datetime import datetime, timezone - -import pytest - -from wildedge import Modality, constants, modalities -from wildedge.attachments import Attachment, AttachmentManager, AttachmentStore -from wildedge.client import WildEdge -from wildedge.model import ModelHandle, ModelInfo - - -@pytest.mark.parametrize( - ("content_type", "modality"), - [ - ("text/plain", "text"), - ("text/plain; charset=utf-8", "text"), - ("application/json", "text"), - ("application/ld+json", "text"), - ("image/jpeg", "image"), - ("audio/wav", "audio"), - ("AUDIO/MPEG", "audio"), - ("video/mp4", "video"), - ("application/octet-stream", "other"), - ("application/x-npy", "other"), - ], -) -def test_modality_follows_content_type(content_type, modality): - assert modalities.from_content_type(content_type) == modality - assert Attachment(content_type=content_type, data=b"x").modality == modality - - -def test_explicit_modality_overrides_content_type(): - attachment = Attachment( - content_type="application/octet-stream", data=b"x", modality="audio" - ) - assert attachment.modality == "audio" - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - ("text,audio", {"text", "audio"}), - (" Text , AUDIO ", {"text", "audio"}), - ("all", set(modalities.ALL)), - (["image"], {"image"}), - ((), set()), - ], -) -def test_parse_accepts_lists_and_all(value, expected): - assert modalities.parse(value) == expected - - -def test_parse_rejects_unknown_names(): - with pytest.raises(ValueError, match="audios"): - modalities.parse("text,audios") - - -def capture(manager, *attachments): - return manager.capture(list(attachments), "inf-1", datetime.now(timezone.utc)) - - -def text(): - return Attachment(content_type="text/plain", data=b"hi") - - -def image(): - return Attachment(content_type="image/jpeg", data=b"img") - - -def test_default_uploads_text_only_and_warns_once(tmp_path, caplog): - store = AttachmentStore(str(tmp_path)) - manager = AttachmentManager(store) - with caplog.at_level(logging.DEBUG, logger="wildedge"): - refs = capture(manager, image(), text()) - capture(manager, image()) - assert [ref["content_type"] for ref in refs] == ["text/plain"] - assert store.length() == 1 - warnings = [r for r in caplog.records if "image attachments" in r.message] - assert len(warnings) == 1 - assert warnings[0].levelno == logging.WARNING - - -def test_explicit_choice_logs_blocked_modalities_at_debug(tmp_path, caplog): - manager = AttachmentManager( - AttachmentStore(str(tmp_path)), allowed_modalities={"text"} - ) - with caplog.at_level(logging.DEBUG, logger="wildedge"): - capture(manager, image()) - (record,) = [r for r in caplog.records if "image attachments" in r.message] - assert record.levelno == logging.DEBUG - - -def test_filter_never_sees_blocked_attachments(tmp_path): - seen = [] - manager = AttachmentManager( - AttachmentStore(str(tmp_path)), - attachment_filter=lambda items: seen.extend(items) or items, - ) - capture(manager, image(), text()) - assert [a.modality for a in seen] == ["text"] - - -def test_allowed_modalities_upload(tmp_path): - manager = AttachmentManager( - AttachmentStore(str(tmp_path)), allowed_modalities={"image"} - ) - refs = capture(manager, image(), text()) - assert [ref["content_type"] for ref in refs] == ["image/jpeg"] - - -def make_handle(**kwargs): - info = ModelInfo( - model_name="m", model_version="1", model_source="local", model_format="onnx" - ) - return ModelHandle("m", info, lambda _event: None, **kwargs) - - -def test_handle_wants_nothing_without_attachments(): - assert not make_handle().wants_attachment("text") - - -def test_handle_asks_the_client_policy(tmp_path): - manager = AttachmentManager(AttachmentStore(str(tmp_path))) - handle = make_handle( - capture_attachments=manager.capture, accepts_attachment=manager.accepts - ) - assert handle.wants_attachment("text") - assert not handle.wants_attachment("audio") - - -def client(tmp_path, **kwargs): - return WildEdge( - dsn="https://test@test.com/key", - attachments_enabled=True, - attachment_dir=str(tmp_path), - sampling_interval_s=0, - **kwargs, - ) - - -def test_client_takes_modalities_argument(tmp_path): - wildedge = client(tmp_path, attachment_modalities=["text", "audio"]) - try: - assert wildedge.attachment_manager.allowed_modalities == {"text", "audio"} - finally: - wildedge.close() - - -def test_client_reads_modalities_from_env(tmp_path, monkeypatch): - monkeypatch.setenv(constants.ENV_ATTACHMENT_MODALITIES, "all") - wildedge = client(tmp_path) - try: - assert wildedge.attachment_manager.allowed_modalities == set(modalities.ALL) - finally: - wildedge.close() - - -def test_client_defaults_to_text(tmp_path, monkeypatch): - monkeypatch.delenv(constants.ENV_ATTACHMENT_MODALITIES, raising=False) - wildedge = client(tmp_path) - try: - assert wildedge.attachment_manager.allowed_modalities == {"text"} - finally: - wildedge.close() - - -def test_client_rejects_unknown_modality(tmp_path): - with pytest.raises(ValueError, match="pictures"): - client(tmp_path, attachment_modalities="pictures") - - -@pytest.mark.parametrize("from_env", [False, True]) -def test_modalities_without_attachments_enabled_warn(monkeypatch, caplog, from_env): - monkeypatch.delenv(constants.ENV_ATTACHMENTS_ENABLED, raising=False) - kwargs = {} - if from_env: - monkeypatch.setenv(constants.ENV_ATTACHMENT_MODALITIES, "audio") - else: - kwargs["attachment_modalities"] = ["audio"] - with caplog.at_level(logging.WARNING, logger="wildedge"): - wildedge = WildEdge(dsn="https://test@test.com/key", **kwargs) - try: - assert wildedge.attachment_manager is None - assert any("attachments_enabled=True" in r.message for r in caplog.records) - finally: - wildedge.close() - - -def test_enabled_attachments_with_modalities_do_not_warn(tmp_path, caplog): - with caplog.at_level(logging.WARNING, logger="wildedge"): - wildedge = client(tmp_path, attachment_modalities=["audio"]) - try: - assert not any("attachments_enabled" in r.message for r in caplog.records) - finally: - wildedge.close() - - -def test_enum_members_and_strings_are_interchangeable(tmp_path): - manager = AttachmentManager( - AttachmentStore(str(tmp_path)), allowed_modalities=[Modality.AUDIO, "text"] - ) - assert manager.allowed_modalities == {"audio", "text"} - assert manager.accepts(Modality.TEXT) - assert manager.accepts("audio") - attachment = Attachment( - content_type="application/octet-stream", data=b"x", modality=Modality.AUDIO - ) - assert attachment.modality == "audio" - - -def test_unknown_attachment_modality_is_rejected(): - with pytest.raises(ValueError, match="sound"): - Attachment(content_type="text/plain", data=b"x", modality="sound") diff --git a/tests/test_whisper.py b/tests/test_whisper.py index 5ba0f52..e0510ba 100644 --- a/tests/test_whisper.py +++ b/tests/test_whisper.py @@ -27,22 +27,14 @@ model_name_and_source, track_transcription, ) -from wildedge.model import ModelHandle, ModelInfo from wildedge.platforms.device_info import DeviceInfo @pytest.fixture def handle(): - info = ModelInfo( - model_name="whisper", - model_version="1", - model_source="local", - model_format="ctranslate2", + return SimpleNamespace( + capture_attachments=None, track_inference=MagicMock(), track_error=MagicMock() ) - handle = ModelHandle("whisper", info, MagicMock()) - handle.track_inference = MagicMock() - handle.track_error = MagicMock() - return handle @pytest.fixture @@ -108,18 +100,12 @@ def test_transcription_metadata(wav, handle): } -def test_nothing_is_attached_without_capture(wav, handle): +@pytest.mark.parametrize("uploads_enabled", [False, True]) +def test_transcription_never_attaches_payloads(wav, handle, uploads_enabled): + if uploads_enabled: + handle.capture_attachments = MagicMock() track_transcription(handle, wav, "hello world", duration_ms=1) - assert handle.track_inference.call_args.kwargs["attachments"] is None - - -def test_capture_attaches_audio_and_transcript(wav, handle): - handle.capture_attachments = MagicMock() - track_transcription(handle, wav, "hello world", duration_ms=1) - attachments = handle.track_inference.call_args.kwargs["attachments"] - assert [a.role for a in attachments] == ["input", "output"] - assert attachments[0].path == str(wav) - assert attachments[1].data == b"hello world" + assert "attachments" not in handle.track_inference.call_args.kwargs def test_file_like_audio_is_not_read(handle): @@ -128,7 +114,7 @@ def test_file_like_audio_is_not_read(handle): track_transcription(handle, source, "hello", duration_ms=1) event = handle.track_inference.call_args.kwargs assert event["input_meta"].to_dict() == {} - assert [a.name for a in event["attachments"]] == ["transcript.txt"] + assert "attachments" not in event assert source.tell() == 0 @@ -235,8 +221,7 @@ def test_stream_that_was_never_iterated_sends_nothing(handle): handle.track_inference.assert_not_called() -def test_transcript_over_the_limit_is_not_attached(handle, monkeypatch): - monkeypatch.setattr(faster, "MAX_CAPTURE_BYTES", 8) +def test_stream_counts_words_without_attaching_transcript(handle): handle.capture_attachments = MagicMock() segments = [SimpleNamespace(text="hello "), SimpleNamespace(text="world")] stream, _ = wrap_transcribe(lambda *_: (iter(segments), SimpleNamespace()))( @@ -244,7 +229,7 @@ def test_transcript_over_the_limit_is_not_attached(handle, monkeypatch): ) assert list(stream) == segments event = handle.track_inference.call_args.kwargs - assert event["attachments"] == [] + assert "attachments" not in event assert event["context"]["transcript_word_count"] == 2 @@ -484,25 +469,25 @@ def test_mlx_replacement_client_reports_the_cached_model( assert len(events_of(instrumented_client.events, "inference")) == 1 -def test_audio_is_left_out_unless_allowed(wav, handle, tmp_path): - manager = AttachmentManager(AttachmentStore(str(tmp_path))) - handle.capture_attachments = manager.capture - handle.accepts_attachment = manager.accepts - track_transcription(handle, wav, "hello world", duration_ms=1) - attachments = handle.track_inference.call_args.kwargs["attachments"] - assert [a.name for a in attachments] == ["transcript.txt"] - - -def test_allowed_audio_file_is_attached_as_audio(tmp_path, handle): - path = tmp_path / "clip.unknown-extension" - path.write_bytes(b"\x00" * 16) - manager = AttachmentManager( - AttachmentStore(str(tmp_path / "store")), allowed_modalities={"audio"} - ) - handle.capture_attachments = manager.capture - handle.accepts_attachment = manager.accepts - track_transcription(handle, path, "hello", duration_ms=1) - attachments = handle.track_inference.call_args.kwargs["attachments"] - assert [(a.name, a.modality) for a in attachments] == [ - ("clip.unknown-extension", "audio") - ] +@pytest.mark.parametrize("backend", ["faster_whisper", "batched", "mlx_whisper"]) +def test_instrumentation_buffers_no_payloads_with_uploads_enabled( + backend, fake_faster_whisper, fake_mlx_whisper, instrumented_client, tmp_path, wav +): + store = AttachmentStore(str(tmp_path / "attachments")) + instrumented_client.attachment_manager = AttachmentManager(store) + if backend == "mlx_whisper": + install_mlx(claim_instrumentation(instrumented_client, "mlx_whisper")) + result = fake_mlx_whisper.transcribe(str(wav), path_or_hf_repo="org/model") + assert result["text"] + else: + install_auto_load_patch( + claim_instrumentation(instrumented_client, "faster_whisper") + ) + model = fake_faster_whisper.WhisperModel("small") + if backend == "batched": + model = fake_faster_whisper.BatchedInferencePipeline(model) + segments, _ = model.transcribe(str(wav)) + assert list(segments) + (event,) = events_of(instrumented_client.events, "inference") + assert "attachments" not in event + assert store.length() == 0 diff --git a/wildedge/__init__.py b/wildedge/__init__.py index 2d14a91..63deebc 100644 --- a/wildedge/__init__.py +++ b/wildedge/__init__.py @@ -30,7 +30,6 @@ ) from wildedge.events.span import SpanKind, SpanStatus from wildedge.llm_api import LLMCall, llm_api -from wildedge.modalities import Modality from wildedge.platforms import capture_hardware from wildedge.platforms.device_info import DeviceInfo from wildedge.platforms.hardware import HardwareContext, ThermalContext @@ -60,7 +59,6 @@ "HardwareContext", "ImageInputMeta", "LLMCall", - "Modality", "QueuePolicy", "SpanContext", "SpanContextManager", diff --git a/wildedge/attachments.py b/wildedge/attachments.py index 9381c2a..594cf54 100644 --- a/wildedge/attachments.py +++ b/wildedge/attachments.py @@ -18,20 +18,19 @@ import base64 import json -import logging import threading import time import urllib.error import urllib.request import uuid -from collections.abc import Callable, Iterable +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from enum import Enum from pathlib import Path from typing import Any -from wildedge import constants, modalities +from wildedge import constants from wildedge.logging import logger AttachmentStorageStrategy = str # "file" | "inline" @@ -43,10 +42,7 @@ class Attachment: Provide exactly one of ``data`` (in-memory bytes) or ``path`` (a file on disk). ``content_type`` is the MIME type baked into the presigned URL and - echoed in the inference event reference. ``modality`` (see - ``wildedge.modalities``) decides whether the client's - ``attachment_modalities`` allow the upload. It defaults to the one - ``content_type`` belongs to. + echoed in the inference event reference. """ content_type: str @@ -54,14 +50,6 @@ class Attachment: data: bytes | None = None path: str | None = None name: str | None = None - modality: modalities.Modality | str | None = None - - def __post_init__(self) -> None: - self.modality = ( - modalities.from_content_type(self.content_type) - if self.modality is None - else modalities.normalize(self.modality) - ) def resolve_bytes(self) -> bytes: if self.data is not None: @@ -259,9 +247,8 @@ def _load(self, path: Path) -> PendingAttachment | None: class AttachmentManager: - """Capture-side gate: keeps the allowed modalities, applies the filter hook - and per-inference caps, then buffers eligible attachments and returns the - event references.""" + """Capture-side gate: applies the filter hook and per-inference caps, then + buffers eligible attachments and returns the event references.""" def __init__( self, @@ -270,21 +257,9 @@ def __init__( max_per_inference: int = constants.DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE, max_size_bytes: int = constants.DEFAULT_MAX_ATTACHMENT_SIZE_BYTES, attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, - allowed_modalities: Iterable[modalities.Modality | str] | None = None, debug: bool = False, ) -> None: self.store = store - # None means the default. Then a blocked modality is logged as a - # warning, since the app may not know about the setting. An explicit - # choice is only logged in debug mode. - self.allowed_modalities = modalities.parse( - modalities.DEFAULT if allowed_modalities is None else allowed_modalities - ) - self.blocked_log_level = ( - logging.WARNING if allowed_modalities is None else logging.DEBUG - ) - # Modalities already reported as blocked, so each one warns once. - self.blocked_reported: set[str] = set() self.max_per_inference = max_per_inference self.max_size_bytes = max_size_bytes self.attachment_filter = attachment_filter @@ -295,22 +270,6 @@ def disable(self) -> None: """Stop accepting attachments (e.g. the project has the feature off).""" self.enabled = False - def accepts(self, modality: modalities.Modality | str) -> bool: - """Whether attachments of this modality may be uploaded.""" - modality = modalities.normalize(modality) - if modality in self.allowed_modalities: - return True - if modality not in self.blocked_reported: - self.blocked_reported.add(modality) - logger.log( - self.blocked_log_level, - "wildedge: not uploading %s attachments. Add %r to " - "attachment_modalities to allow them", - modality, - modality, - ) - return False - def capture( self, attachments: list[Attachment], @@ -320,13 +279,10 @@ def capture( if not self.enabled or not attachments: return [] - # Blocked modalities are dropped first, so the filter never sees them. - selected = [a for a in attachments if self.accepts(str(a.modality))] - if not selected: - return [] + selected = attachments if self.attachment_filter is not None: try: - selected = self.attachment_filter(selected) + selected = self.attachment_filter(list(attachments)) except Exception as exc: # pragma: no cover - user callback failures logger.warning("wildedge: attachment_filter raised: %s", exc) return [] diff --git a/wildedge/cli.py b/wildedge/cli.py index 58c1ea4..2199335 100644 --- a/wildedge/cli.py +++ b/wildedge/cli.py @@ -16,7 +16,7 @@ from pathlib import Path from urllib.parse import urlparse -from wildedge import constants, modalities +from wildedge import constants from wildedge.batch import build_batch from wildedge.client import parse_dsn from wildedge.events.span import SpanEvent @@ -68,15 +68,6 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Enable opt-in raw input/output attachment upload (paid feature).", ) - run.add_argument( - "--attachment-modalities", - default=None, - type=modality_list, - help=( - "Comma-separated kinds of data attachments may contain: text, image, " - "audio, video, other, or all. Default: text." - ), - ) run.add_argument( "--debug", action="store_true", @@ -276,8 +267,6 @@ def run_command(parsed: argparse.Namespace) -> int: env[constants.ENV_FLUSH_TIMEOUT] = str(parsed.flush_timeout) if parsed.attachments: env[constants.ENV_ATTACHMENTS_ENABLED] = "1" - if parsed.attachment_modalities: - env[constants.ENV_ATTACHMENT_MODALITIES] = parsed.attachment_modalities env[constants.ENV_PROPAGATE] = "1" if parsed.propagate else "0" env[constants.ENV_STRICT_INTEGRATIONS] = "1" if parsed.strict_integrations else "0" env[constants.ENV_STRICT] = "1" if parsed.strict else "0" @@ -313,15 +302,6 @@ def hub_list(value: str | None) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] -def modality_list(value: str) -> str: - """Validate ``--attachment-modalities`` before the child process starts.""" - try: - modalities.parse(value) - except ValueError as exc: - raise argparse.ArgumentTypeError(str(exc)) from None - return value - - def check_writable_dir(path: Path) -> tuple[bool, str]: try: path.mkdir(parents=True, exist_ok=True) diff --git a/wildedge/client.py b/wildedge/client.py index 6ae838c..e755e4f 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -4,12 +4,12 @@ import time import uuid import weakref -from collections.abc import Callable, Iterable +from collections.abc import Callable from datetime import datetime, timezone from typing import Any, ClassVar from urllib.parse import urlparse -from wildedge import constants, modalities +from wildedge import constants from wildedge.attachments import ( Attachment, AttachmentManager, @@ -274,7 +274,6 @@ def __init__( ), attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None, attachment_dir: str | None = None, - attachment_modalities: str | Iterable[modalities.Modality | str] | None = None, ): env = read_client_env(dsn=dsn, debug=debug, app_identity=app_identity) dsn = env.dsn @@ -372,19 +371,6 @@ def __init__( if env.attachments_enabled is not None else constants.DEFAULT_ATTACHMENTS_ENABLED ) - if attachment_modalities is None: - attachment_modalities = env.attachment_modalities - allowed_modalities = ( - None - if attachment_modalities is None - else modalities.parse(attachment_modalities) - ) - if allowed_modalities is not None and not attachments_enabled: - # The list only says what may be uploaded. It doesn't turn uploads on. - logger.warning( - "wildedge: attachment_modalities is set but attachments are " - "disabled. Set attachments_enabled=True to upload them" - ) self.attachment_manager: AttachmentManager | None = None self.attachment_uploader: AttachmentUploader | None = None if attachments_enabled: @@ -400,7 +386,6 @@ def __init__( max_per_inference=max_attachments_per_inference, max_size_bytes=max_attachment_size_bytes, attachment_filter=attachment_filter, - allowed_modalities=allowed_modalities, debug=debug, ) self.attachment_uploader = AttachmentUploader( @@ -552,13 +537,13 @@ def register_model( "Pass id='your-model-id' to register_model()." ) - manager = self.attachment_manager + capture_attachments = ( + self.attachment_manager.capture + if self.attachment_manager is not None + else None + ) handle, is_new = self.registry.register( - model_id, - info, - self.publish, - capture_attachments=manager.capture if manager is not None else None, - accepts_attachment=manager.accepts if manager is not None else None, + model_id, info, self.publish, capture_attachments ) if not is_new: diff --git a/wildedge/constants.py b/wildedge/constants.py index 7fe0017..22ef327 100644 --- a/wildedge/constants.py +++ b/wildedge/constants.py @@ -21,7 +21,6 @@ ENV_PRINT_STARTUP_REPORT = "WILDEDGE_PRINT_STARTUP_REPORT" ENV_SAMPLING_INTERVAL = "WILDEDGE_SAMPLING_INTERVAL_S" ENV_ATTACHMENTS_ENABLED = "WILDEDGE_ATTACHMENTS_ENABLED" -ENV_ATTACHMENT_MODALITIES = "WILDEDGE_ATTACHMENT_MODALITIES" # Defaults DEFAULT_SAMPLING_INTERVAL_S = 30.0 diff --git a/wildedge/integrations/faster_whisper.py b/wildedge/integrations/faster_whisper.py index f6cc6e5..4bc2c31 100644 --- a/wildedge/integrations/faster_whisper.py +++ b/wildedge/integrations/faster_whisper.py @@ -19,12 +19,10 @@ DESCRIPTOR_ATTR, FASTER_WHISPER, HANDLE_ATTR, - MAX_CAPTURE_BYTES, WhisperDescriptor, report_load, track_transcription, ) -from wildedge.modalities import Modality from wildedge.timing import elapsed_ms _PATCH_LOCK = threading.Lock() @@ -50,31 +48,16 @@ class TranscriptCollector: - """Counts words as segments arrive and keeps the text when capture is on.""" + """Counts words and timing without retaining transcript text.""" - def __init__(self, t0: float, *, capture: bool) -> None: + def __init__(self, t0: float) -> None: self._t0 = t0 self.words = 0 self.last_segment_ms: int | None = None - self._parts: list[str] | None = [] if capture else None - self._size = 0 def add(self, segment) -> None: self.last_segment_ms = elapsed_ms(self._t0) - text = segment.text - self.words += len(text.split()) - if self._parts is None: - return - self._size += len(text.encode()) - if self._size > MAX_CAPTURE_BYTES: - # A cut-off transcript is not worth uploading. - self._parts = None - else: - self._parts.append(text) - - @property - def text(self) -> str: - return "" if self._parts is None else "".join(self._parts) + self.words += len(segment.text.split()) class SegmentStream(SyncStreamWrapper): @@ -125,9 +108,7 @@ def transcribe(self, audio, *args, **kwargs): except Exception as telemetry_exc: debug_failure(FASTER_WHISPER, "error tracking", telemetry_exc) raise - collector = TranscriptCollector( - t0, capture=handle.wants_attachment(Modality.TEXT) - ) + collector = TranscriptCollector(t0) def done(duration_ms, first_segment_ms, *, outcome="completed"): if outcome == OUTCOME_ABANDONED and collector.last_segment_ms is not None: @@ -135,7 +116,7 @@ def done(duration_ms, first_segment_ms, *, outcome="completed"): track_transcription( handle, audio, - collector.text, + "", duration_ms=duration_ms, language=getattr(info, "language", None), language_probability=getattr(info, "language_probability", None), diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index 8706608..a9959ef 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -5,7 +5,6 @@ import contextvars import functools import inspect -import json import re import threading import time @@ -13,7 +12,6 @@ from typing import TYPE_CHECKING from wildedge import accelerators, constants, quantizations -from wildedge.attachments import Attachment from wildedge.events.inference import ( GenerationConfig, GenerationOutputMeta, @@ -27,7 +25,6 @@ stream_error_code, ) from wildedge.logging import logger -from wildedge.modalities import Modality from wildedge.model import ModelInfo from wildedge.platforms import CURRENT_PLATFORM from wildedge.timing import elapsed_ms @@ -213,54 +210,6 @@ def make_gguf_generation_config(kwargs: dict) -> GenerationConfig | None: return config if config.to_dict() else None -def make_gguf_attachments( - handle: object, prompt: object, messages: list | None, reply: str | None -) -> list[Attachment] | None: - """Prompt (or chat messages) and reply, only when attachments are enabled.""" - if not handle.wants_attachment(Modality.TEXT): - return None - attachments: list[Attachment] = [] - try: - if messages is not None: - attachments.append( - Attachment( - content_type="application/json", - role="input", - data=json.dumps(messages, default=str).encode(), - name="messages.json", - ) - ) - elif isinstance(prompt, str) and prompt: - attachments.append( - Attachment( - content_type="text/plain", - role="input", - data=prompt.encode(), - name="prompt.txt", - ) - ) - if reply: - attachments.append( - Attachment( - content_type="text/plain", - role="output", - data=reply.encode(), - name="reply.txt", - ) - ) - except Exception as exc: - debug_gguf_failure("attachment build", exc) - return attachments or None - - -def _choice_text(choice: dict) -> str | None: - text = choice.get("text") - if text is None: - message = choice.get("message") or choice.get("delta") or {} - text = message.get("content") if isinstance(message, dict) else None - return text if isinstance(text, str) else None - - def make_gguf_stream_callbacks( handle: object, prompt: object, @@ -274,7 +223,6 @@ def make_gguf_stream_callbacks( tokens_in: list[int | None] = [None] tokens_out: list[int | None] = [None] stop_reason: list[str | None] = [None] - reply_parts: list[str] = [] def on_chunk(chunk: object) -> None: if not isinstance(chunk, dict): @@ -288,9 +236,6 @@ def on_chunk(chunk: object) -> None: reason = choices[0].get("finish_reason") if reason: stop_reason[0] = reason - text = _choice_text(choices[0]) - if text: - reply_parts.append(text) def on_done( duration_ms: int, ttft_ms: int | None, *, outcome: str = "completed" @@ -305,9 +250,6 @@ def on_done( error_code=stream_error_code(outcome), output_meta=make_gguf_output_meta(ti, to, sr, ttft_ms, duration_ms), generation_config=generation_config, - attachments=make_gguf_attachments( - handle, prompt, messages, "".join(reply_parts) - ), ) return on_chunk, on_done @@ -366,7 +308,6 @@ def patched_call(self_inner, *args, **kwargs): tokens_in = None tokens_out = None stop_reason = None - reply = None try: if isinstance(result, dict): usage = result.get("usage") or {} @@ -375,7 +316,6 @@ def patched_call(self_inner, *args, **kwargs): choices = result.get("choices") or [] if choices and isinstance(choices[0], dict): stop_reason = choices[0].get("finish_reason") - reply = _choice_text(choices[0]) except Exception as exc: debug_gguf_failure("usage extraction", exc) handle.track_inference( @@ -388,7 +328,6 @@ def patched_call(self_inner, *args, **kwargs): tokens_in, tokens_out, stop_reason, None, duration_ms ), generation_config=generation_config, - attachments=make_gguf_attachments(handle, prompt, messages, reply), ) return result except Exception as exc: diff --git a/wildedge/integrations/whisper.py b/wildedge/integrations/whisper.py index 6bd9e3c..55d5ba2 100644 --- a/wildedge/integrations/whisper.py +++ b/wildedge/integrations/whisper.py @@ -1,22 +1,17 @@ -"""Shared metadata and opt-in capture for local Whisper runtimes.""" +"""Shared metadata tracking for local Whisper runtimes.""" from __future__ import annotations -import io -import mimetypes import wave from dataclasses import dataclass from pathlib import Path -from wildedge.attachments import Attachment from wildedge.events.inference import AudioInputMeta from wildedge.integrations.base import BaseExtractor from wildedge.integrations.common import ( - debug_failure, hf_repo_from_cache_path, stream_error_code, ) -from wildedge.modalities import Modality from wildedge.model import ModelHandle, ModelInfo # Backends, used in model IDs and as the integration names. @@ -34,15 +29,6 @@ # Both libraries expect 1-D sample arrays at this rate. SAMPLE_RATE = 16000 -# Upper bound for each captured audio or transcript attachment. The attachment -# manager may apply a stricter limit of its own. -MAX_CAPTURE_BYTES = 10 * 1024 * 1024 - -# Captured arrays are encoded as mono 16-bit PCM WAV. -PCM16_SAMPLE_BYTES = 2 -PCM16_MAX = 32767 -WAV_HEADER_BYTES = 44 - # Languages written without spaces between words, where splitting on # whitespace does not give a word count. UNSPACED_LANGUAGES = frozenset({"bo", "ja", "km", "lo", "my", "th", "yue", "zh"}) @@ -165,76 +151,6 @@ def audio_meta(audio: object, audio_duration_s: float | None = None) -> AudioInp return meta -def pcm16_wav_size(sample_count: int) -> int: - return WAV_HEADER_BYTES + sample_count * PCM16_SAMPLE_BYTES - - -def pcm16_wav(samples: object) -> bytes: - """Encode float samples in [-1, 1] as a mono 16-bit WAV file.""" - import numpy as np # noqa: PLC0415 -- optional; provided by the active Whisper runtime - - pcm = (np.clip(np.asarray(samples), -1, 1) * PCM16_MAX).astype(" list[Attachment]: - if isinstance(audio, (str, Path)): - path = Path(audio) - if path.is_file() and path.stat().st_size <= MAX_CAPTURE_BYTES: - content_type = ( - mimetypes.guess_type(str(path))[0] or "application/octet-stream" - ) - return [ - Attachment( - content_type=content_type, - role="input", - path=str(path), - name=path.name, - modality=Modality.AUDIO, - ) - ] - elif is_sample_array(audio) and pcm16_wav_size(audio.shape[0]) <= MAX_CAPTURE_BYTES: - return [ - Attachment( - content_type="audio/wav", - role="input", - data=pcm16_wav(audio), - name="input.wav", - ) - ] - return [] - - -def transcription_attachments( - handle: ModelHandle, audio: object, text: str -) -> list[Attachment]: - """The input audio and the transcript, each if the client allows it.""" - attachments = [] - if handle.wants_attachment(Modality.AUDIO): - attachments.extend(audio_attachments(audio)) - encoded = text.encode() - if ( - encoded - and len(encoded) <= MAX_CAPTURE_BYTES - and handle.wants_attachment(Modality.TEXT) - ): - attachments.append( - Attachment( - content_type="text/plain", - role="output", - data=encoded, - name="transcript.txt", - ) - ) - return attachments - - def track_transcription( handle: ModelHandle, audio: object, @@ -264,19 +180,12 @@ def track_transcription( context["language_confidence"] = language_probability if first_segment_ms is not None: context["time_to_first_segment_ms"] = first_segment_ms - attachments = None - if handle.capture_attachments is not None: - try: - attachments = transcription_attachments(handle, audio, text) - except Exception as exc: - debug_failure("whisper", "attachment capture", exc) handle.track_inference( duration_ms=duration_ms, input_modality="audio", output_modality="text", input_meta=audio_meta(audio, audio_duration_s), context=context, - attachments=attachments, success=outcome == "completed", error_code=stream_error_code(outcome), ) diff --git a/wildedge/modalities.py b/wildedge/modalities.py deleted file mode 100644 index 07f7944..0000000 --- a/wildedge/modalities.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Attachment modalities: the kind of data an attachment holds. - -``attachment_modalities`` lists the ones a client may upload, as ``Modality`` -members or their string values (``"audio"``), which is what the env var and -CLI use. Unlike the accelerator and quantization labels, this set is closed. -The client rejects names it doesn't know instead of silently uploading nothing. -""" - -from __future__ import annotations - -from collections.abc import Iterable -from enum import Enum - - -class Modality(str, Enum): - TEXT = "text" - IMAGE = "image" - AUDIO = "audio" - VIDEO = "video" - OTHER = "other" # any content type not covered above, such as tensors - - -ALL: tuple[str, ...] = tuple(modality.value for modality in Modality) - -# Without an explicit choice only text is uploaded. Voice and images can -# identify a person, so they are sent only when the app opts in. -DEFAULT: tuple[str, ...] = (Modality.TEXT.value,) - -# Structured text formats under application/. -TEXT_APPLICATION_TYPES = frozenset( - { - "application/json", - "application/x-ndjson", - "application/xml", - "application/yaml", - "application/x-yaml", - } -) -TEXT_SUFFIXES = ("+json", "+xml") -MEDIA_TOP_LEVEL_TYPES = frozenset( - { - Modality.TEXT.value, - Modality.IMAGE.value, - Modality.AUDIO.value, - Modality.VIDEO.value, - } -) - - -def normalize(modality: Modality | str) -> str: - """The plain string value, so enum members and strings compare alike.""" - try: - return Modality(modality.strip().lower()).value - except ValueError: - raise ValueError( - f"unknown attachment modality {modality!r}, " - f"expected any of {', '.join(ALL)}" - ) from None - - -def from_content_type(content_type: str) -> str: - """The modality a MIME type belongs to, such as ``audio`` for ``audio/wav``.""" - media_type = content_type.split(";", 1)[0].strip().lower() - top_level = media_type.partition("/")[0] - if top_level in MEDIA_TOP_LEVEL_TYPES: - return top_level - if media_type in TEXT_APPLICATION_TYPES or media_type.endswith(TEXT_SUFFIXES): - return Modality.TEXT.value - return Modality.OTHER.value - - -def parse(value: str | Iterable[Modality | str]) -> frozenset[str]: - """Validate a modality list. A string is comma-separated, and ``"all"`` - allows every modality.""" - if isinstance(value, str): - if value.strip().lower() == "all": - return frozenset(ALL) - value = [item for item in value.split(",") if item.strip()] - return frozenset(normalize(item) for item in value) diff --git a/wildedge/model.py b/wildedge/model.py index b0c360f..119c646 100644 --- a/wildedge/model.py +++ b/wildedge/model.py @@ -29,7 +29,6 @@ TextInputMeta, ) from wildedge.logging import logger -from wildedge.modalities import Modality from wildedge.platforms import capture_hardware, is_sampling from wildedge.platforms.hardware import HardwareContext from wildedge.tracing import _merge_correlation_fields @@ -65,26 +64,14 @@ def __init__( publish: Callable[[dict], None], capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] | None = None, - accepts_attachment: Callable[[Modality | str], bool] | None = None, ): self.model_id = model_id self.info = info self.publish = publish self.capture_attachments = capture_attachments - self.accepts_attachment = accepts_attachment self.detected_accelerator: str = accelerators.CPU self.last_inference_id: str | None = None - def wants_attachment(self, modality: Modality | str) -> bool: - """Whether an attachment of this modality would be uploaded. - - Integrations check this before building an attachment, so a blocked - one costs nothing. - """ - if self.capture_attachments is None: - return False - return self.accepts_attachment is None or self.accepts_attachment(modality) - def track_load( self, duration_ms: int, @@ -418,7 +405,6 @@ def register( publish: Callable[[dict], None], capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] | None = None, - accepts_attachment: Callable[[str], bool] | None = None, ) -> tuple[ModelHandle, bool]: """Return (handle, is_new). is_new=False means already registered; skip install_hooks.""" if model_id in self.handles: @@ -433,7 +419,6 @@ def register( info=self.models[model_id], publish=publish, capture_attachments=capture_attachments, - accepts_attachment=accepts_attachment, ) self.handles[model_id] = handle return handle, True diff --git a/wildedge/settings.py b/wildedge/settings.py index 612af4e..6c445b8 100644 --- a/wildedge/settings.py +++ b/wildedge/settings.py @@ -15,7 +15,6 @@ class ClientEnv: debug: bool app_identity: str | None attachments_enabled: bool | None - attachment_modalities: str | None @dataclass(frozen=True) @@ -89,7 +88,6 @@ def read_client_env( debug=resolved_debug, app_identity=resolved_identity, attachments_enabled=attachments_enabled, - attachment_modalities=env.get(constants.ENV_ATTACHMENT_MODALITIES) or None, ) From 35e5dcf91512e33f2149744dd8de6ad88bdd8fa6 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 12:43:36 +0200 Subject: [PATCH 54/81] Let a new registration replace persisted model metadata (#56) ModelRegistry.register kept the metadata saved by an earlier process for any model loaded from disk, so a model's first registration on a machine won forever: version bumps and other metadata changes were ignored after restarts. The first registration in a process now replaces the persisted info, and the file is only rewritten when the info changed. Models that are not registered again keep their persisted info for offline replay. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++++-- tests/test_model.py | 38 ++++++++++++++++++++++++++++++++++++++ wildedge/model.py | 11 ++++++++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96189c6..72014a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ under Unreleased and move into a version section at release time. ### Fixed +- Changing a model's metadata (for example `register_model(..., version="v2")`) + now takes effect after a restart. With offline persistence on (the default), + the model registry saved by an earlier process kept the first metadata ever + registered for each model on that machine, so later changes were ignored. + This includes anthropic models already saved as `"unknown"`. + - `import wildedge` no longer imports every installed ML framework and API client. Each library is imported when its integration is activated or a model from it is registered, so SDK import is faster and a broken unrelated @@ -161,8 +167,6 @@ under Unreleased and move into a version section at release time. - Models tracked by the anthropic integration register with `model_format` `"api"`, like the openai integration. The Anthropic extractor was never in the client's extractor list, so they were registered as `"unknown"`. - Machines that already tracked these models keep the saved `"unknown"` until - the SDK's model registry file is deleted. ## 0.2.1 - 2026-09-01 diff --git a/tests/test_model.py b/tests/test_model.py index 823d75c..e103414 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,5 +1,7 @@ """Tests for ModelRegistry and ModelHandle.""" +from unittest.mock import patch + from wildedge.model import ModelHandle, ModelInfo, ModelRegistry @@ -52,6 +54,42 @@ def test_snapshot_reflects_registered_models(self): assert "model-1" in snap assert snap["model-1"]["model_name"] == "yolo" + def test_new_process_registration_replaces_persisted_info(self, tmp_path): + path = tmp_path / "model_registry.json" + ModelRegistry(path).register( + "model-1", make_info(model_version="1.0"), noop_publish + ) + + restarted = ModelRegistry(path) + handle, _ = restarted.register( + "model-1", make_info(model_version="2.0"), noop_publish + ) + + assert handle.info.model_version == "2.0" + assert restarted.snapshot()["model-1"]["model_version"] == "2.0" + assert ModelRegistry(path).models["model-1"].model_version == "2.0" + + def test_unchanged_info_does_not_rewrite_persisted_file(self, tmp_path): + path = tmp_path / "model_registry.json" + ModelRegistry(path).register("model-1", make_info(), noop_publish) + + restarted = ModelRegistry(path) + with patch.object(restarted, "save_to_disk") as save: + restarted.register("model-1", make_info(), noop_publish) + + save.assert_not_called() + + def test_persisted_model_not_registered_again_is_kept(self, tmp_path): + path = tmp_path / "model_registry.json" + ModelRegistry(path).register( + "model-1", make_info(model_name="yolo"), noop_publish + ) + + restarted = ModelRegistry(path) + restarted.register("model-2", make_info(), noop_publish) + + assert restarted.snapshot()["model-1"]["model_name"] == "yolo" + def test_snapshot_excludes_duplicate_registration(self): registry = ModelRegistry() registry.register("model-1", make_info(model_name="original"), noop_publish) diff --git a/wildedge/model.py b/wildedge/model.py index 119c646..1c86fd6 100644 --- a/wildedge/model.py +++ b/wildedge/model.py @@ -406,17 +406,22 @@ def register( capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]] | None = None, ) -> tuple[ModelHandle, bool]: - """Return (handle, is_new). is_new=False means already registered; skip install_hooks.""" + """Return (handle, is_new). is_new=False means already registered; skip install_hooks. + + The first registration in a process sets the model's info, replacing any + info persisted by an earlier process; the file only covers models that + queued events still reference but that are not registered again. + """ if model_id in self.handles: return self.handles[model_id], False - if model_id not in self.models: + if self.models.get(model_id) != info: self.models[model_id] = info self.save_to_disk() handle = ModelHandle( model_id=model_id, - info=self.models[model_id], + info=info, publish=publish, capture_attachments=capture_attachments, ) From 4384de00de8692f6d5f64ae68eac3a88e98caded Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:00:38 +0200 Subject: [PATCH 55/81] Name llama.cpp integration after its runtime and simplify README (#93) --- .github/workflows/compat.yml | 2 +- CHANGELOG.md | 5 ++ README.md | 49 ++++++------------- docs/compatibility.md | 8 +-- docs/deployment.md | 16 ++++++ examples/django_gemma/demo.sh | 6 +-- examples/django_gemma/gemmaapp/views.py | 2 +- examples/gguf_example.py | 4 +- scripts/compat_matrix.py | 4 +- tests/compat/fixtures/README.md | 4 +- ...guf_compat.py => test_llama_cpp_compat.py} | 2 +- tests/test_cli.py | 10 ++++ tests/test_integration_patching.py | 23 ++++++++- wildedge/cli.py | 16 ++++-- wildedge/client.py | 6 ++- wildedge/integrations/gguf.py | 2 +- wildedge/integrations/registry.py | 18 +++++-- wildedge/settings.py | 9 +++- 18 files changed, 125 insertions(+), 61 deletions(-) rename tests/compat/{test_gguf_compat.py => test_llama_cpp_compat.py} (98%) diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 1e71865..46ef03d 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -71,7 +71,7 @@ jobs: --version-set "${{ matrix.version-set }}" \ --python-version "${{ matrix.python-version }}" - name: Cache downloaded test models - if: matrix.integration == 'gguf' || matrix.integration == 'faster_whisper' + if: matrix.integration == 'llama_cpp' || matrix.integration == 'faster_whisper' uses: actions/cache@v4 with: path: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 72014a6..780819a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ under Unreleased and move into a version section at release time. ### Changed +- The llama.cpp Python integration is now named `llama_cpp` in Python, CLI, + and environment configuration. `gguf` remains a compatibility alias; `all` + selects the integration once under its new name. Model format metadata + remains `gguf`. + - Automatic integrations no longer attach raw payloads, including GGUF prompts, chat messages and replies. `attachments_enabled=True` enables uploads only for attachments explicitly passed to `track_inference(attachments=[...])`. diff --git a/README.md b/README.md index bf3528e..193fab2 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,14 @@ [![PyPI](https://img.shields.io/pypi/v/wildedge-sdk)](https://pypi.org/project/wildedge-sdk/) [![Coverage](https://codecov.io/gh/wild-edge/wildedge-python/branch/main/graph/badge.svg)](https://codecov.io/gh/wild-edge/wildedge-python) -ML inference monitoring for Python: on-device and remote models. Tracks latency, tokens, errors, and model metadata with no code changes, plus traces for agent pipelines. +Monitor local and remote ML inference from Python. Track latency, token usage, +errors, model metadata, and local hardware context across llama.cpp, MLX, +Transformers, OpenAI, and more. -**[Get a DSN](https://app.wildedge.dev)** · [Website](https://wildedge.dev) · [Docs](https://docs.wildedge.dev) · [Blog](https://blog.wildedge.dev) +Instrument supported libraries with one command, or add tracking in your code. +Prompts and outputs aren't uploaded unless you explicitly attach them. + +**[Get started](#install)** · [Docs](https://docs.wildedge.dev) · [Website](https://wildedge.dev) ## Install @@ -26,32 +31,19 @@ To send events, sign up at [app.wildedge.dev](https://app.wildedge.dev), create ## CLI -Drop `wildedge run` in front of your existing command. WildEdge instruments the runtime before your code starts. No SDK calls required in user code. +Monitor an existing application by adding `wildedge run` to its start command: ```bash WILDEDGE_DSN="https://@ingest.wildedge.dev/" \ -wildedge run --integrations timm -- python app.py -``` - -Validate your environment before deploying. `--send-test-event` proves the -whole pipeline end to end by sending one span event and reporting the ingest -response (exit codes: 0 pass, 1 config failure, 2 connectivity failure): - -```bash -wildedge doctor --integrations all --send-test-event +wildedge run --integrations llama_cpp -- python app.py ``` -Useful flags: +The `llama_cpp` integration instruments llama.cpp through `llama-cpp-python`. +Replace it with your [integration](#supported-integrations). +No SDK calls are required in your application code. -| Flag | Description | -|---|---| -| `--integrations` | Comma-separated list of integrations to activate (or `all`) | -| `--hubs` | Hub trackers to activate: `huggingface`, `torchhub` | -| `--print-startup-report` | Print per-integration status at startup | -| `--strict-integrations` | Exit (code 121) if a requested integration can't be instrumented | -| `--strict` | Exit (120 config, 122 internal) instead of running untracked when bootstrap fails | -| `--attachments` | Enable opt-in raw input/output attachment upload | -| `--no-propagate` | Don't pass WildEdge env vars to child processes | +See the [deployment guide](docs/deployment.md) for startup options, +environment validation, and production configuration. ## SDK @@ -91,7 +83,7 @@ for client lifecycle and the full contract. | `mlx_whisper` / `faster_whisper` | [whisper_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/whisper_example.py) | | `mlx` | [mlx_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/mlx_example.py) | | `timm` | [timm_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/timm_example.py) | -| `gguf` | [gguf_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/gguf_example.py) | +| llama.cpp (`llama_cpp`, via `llama-cpp-python`) | [gguf_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/gguf_example.py) | | `onnx` | [onnx_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/onnx_example.py) | | `ultralytics` | - | | `tensorflow` | [tensorflow_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/tensorflow_example.py) | @@ -148,17 +140,6 @@ For unsupported frameworks, see [Manual tracking](https://github.com/wild-edge/w For advanced options (batching, queue tuning, dead-letter storage, attachments), see [Configuration](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md). -## Projects using this SDK - -| Name | Link | -|---|---| -| outfitstudio.app | https://outfitstudio.app/ | -| agntr | [github.com/pmaciolek/agntr](https://github.com/pmaciolek/agntr) | -| demo-app | [github.com/wild-edge/demo-app](https://github.com/wild-edge/demo-app) | -| *(your project here)* | - | - -Using WildEdge in your project? Open a PR to add it to the list. - ## Security & Privacy By default the SDK transmits only anonymized telemetry, never raw model inputs diff --git a/docs/compatibility.md b/docs/compatibility.md index aab6547..0c08065 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -9,12 +9,12 @@ generated from `scripts/compat_matrix.py`, which also defines the CI jobs. `tests/compat/test__compat.py` loads a real model with the real library and checks the events the SDK records: a `model_load` event, then an `inference` event for the same model with the expected fields (token counts -for LLMs, input modality for vision models). Apart from one download for gguf, nothing +for LLMs, input modality for vision models). Apart from one download for llama_cpp, nothing touches the network: - openai and anthropic get canned responses through the SDK's own HTTP client (`MockTransport`). -- onnx loads a tiny model committed in `tests/compat/fixtures`. gguf +- onnx loads a tiny model committed in `tests/compat/fixtures`. llama_cpp downloads a 1.2 MB model on first use, checked against a pinned sha256. - torch, timm, tensorflow, transformers, ultralytics and mlx build models from a config, with random weights. @@ -57,8 +57,8 @@ To run the full matrix locally: `python scripts/run_compat_local.py`. | `tensorflow` | `min` | `tensorflow==2.16.1`, `keras==3.3.3`, `numpy==1.26.4` | 3.10, 3.11, 3.12 | | `tensorflow` | `current` | `tensorflow==2.21.0`, `keras==3.15.1`, `numpy==2.2.6` | 3.10, 3.11, 3.12 | | `tensorflow` | `current` (`py3.10` override) | `tensorflow==2.21.0`, `keras==3.12.4`, `numpy==2.2.6` | 3.10 | -| `gguf` | `min` | `llama-cpp-python==0.2.90`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | -| `gguf` | `current` | `llama-cpp-python==0.3.36`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | +| `llama_cpp` | `min` | `llama-cpp-python==0.2.90`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | +| `llama_cpp` | `current` | `llama-cpp-python==0.3.36`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | | `openai` | `min` | `openai==1.30.0` | 3.10, 3.11, 3.12, 3.13 | | `openai` | `current` | `openai==3.35.1` | 3.10, 3.11, 3.12, 3.13 | | `transformers` | `min` | `transformers==4.40.0`, `torch==2.4.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | diff --git a/docs/deployment.md b/docs/deployment.md index 48727fa..40829c6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -35,6 +35,22 @@ Under `wildedge run`, a missing DSN warns on stderr and your program runs untracked. Telemetry failing must not take production down; that is the default policy. +## CLI options + +| Flag | Description | +|---|---| +| `--integrations` | Comma-separated runtime integrations, such as `llama_cpp`, `onnx`, or `openai`; default: `all` | +| `--hubs` | Download tracking via `huggingface` or `torchhub` | +| `--print-startup-report` | Print activation status for each integration | +| `--strict-integrations` | Exit with 121 if a requested integration cannot be instrumented | +| `--strict` | Exit on bootstrap failure; see the exit codes below | +| `--attachments` | Enable upload of explicitly supplied attachments | +| `--no-propagate` | Scrub run-scoped environment variables after initialization | + +Use `llama_cpp` for the `llama-cpp-python` runtime. The older name `gguf` is +accepted as a compatibility alias in Python, CLI, and environment settings. +The recorded model format remains `gguf`. + ## Strict mode `wildedge run --strict` inverts the failure policy for deployments where diff --git a/examples/django_gemma/demo.sh b/examples/django_gemma/demo.sh index 78d9ba1..9914f6b 100755 --- a/examples/django_gemma/demo.sh +++ b/examples/django_gemma/demo.sh @@ -11,7 +11,7 @@ cd "${SCRIPT_DIR}" uv sync -uv run wildedge doctor --integrations gguf --hubs huggingface +uv run wildedge doctor --integrations llama_cpp --hubs huggingface # wildedge run replaces this process (os.execle) so sitecustomize.py # auto-installs the runtime before Django loads, patching Llama.__init__ @@ -26,13 +26,13 @@ uv run wildedge doctor --integrations gguf --hubs huggingface if [[ "$(uname)" == "Darwin" ]]; then uv run wildedge run \ --print-startup-report \ - --integrations gguf \ + --integrations llama_cpp \ --hubs huggingface \ -- waitress-serve --port=8100 gemmaapp.wsgi:application else uv run wildedge run \ --print-startup-report \ - --integrations gguf \ + --integrations llama_cpp \ --hubs huggingface \ -- gunicorn gemmaapp.wsgi:application --config gunicorn.conf.py fi diff --git a/examples/django_gemma/gemmaapp/views.py b/examples/django_gemma/gemmaapp/views.py index 2bc3cf2..ebad98e 100644 --- a/examples/django_gemma/gemmaapp/views.py +++ b/examples/django_gemma/gemmaapp/views.py @@ -1,6 +1,6 @@ """Gemma inference view. -The Llama constructor is patched automatically by `wildedge run --integrations gguf` +The Llama constructor is patched automatically by `wildedge run --integrations llama_cpp` via sitecustomize.py; load/unload/inference events are tracked without any wildedge imports here. diff --git a/examples/gguf_example.py b/examples/gguf_example.py index ae8983e..4f252b6 100644 --- a/examples/gguf_example.py +++ b/examples/gguf_example.py @@ -7,7 +7,7 @@ # /// """GGUF / llama.cpp integration example. Run with: uv run gguf_example.py -The gguf integration tracks every llama-cpp-python entry point, once per +The llama_cpp integration tracks every llama-cpp-python entry point, once per call: create_chat_completion (streamed or not), create_completion and llm(...). Each inference event carries token counts, tokens per second, the stop reason and the sampling settings; chat calls also record @@ -21,7 +21,7 @@ wildedge.init( app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op - integrations="gguf", + integrations="llama_cpp", hubs=["huggingface"], ) diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index f0cd352..038ab7b 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -39,7 +39,7 @@ "min": ["tensorflow==2.16.1", "keras==3.3.3", "numpy==1.26.4"], "current": ["tensorflow==2.21.0", "keras==3.15.1", "numpy==2.2.6"], }, - "gguf": { + "llama_cpp": { "min": ["llama-cpp-python==0.2.90", "numpy==1.26.4"], "current": ["llama-cpp-python==0.3.36", "numpy==2.2.6"], }, @@ -70,7 +70,7 @@ "torch": ["3.10", "3.11", "3.12", "3.13", "3.14"], "timm": ["3.10", "3.11", "3.12", "3.13", "3.14"], "tensorflow": ["3.10", "3.11", "3.12"], - "gguf": ["3.10", "3.11", "3.12", "3.13"], + "llama_cpp": ["3.10", "3.11", "3.12", "3.13"], "openai": ["3.10", "3.11", "3.12", "3.13"], "transformers": ["3.10", "3.11", "3.12", "3.13"], "ultralytics": ["3.10", "3.11", "3.12", "3.13"], diff --git a/tests/compat/fixtures/README.md b/tests/compat/fixtures/README.md index 3b3c7e4..91be61c 100644 --- a/tests/compat/fixtures/README.md +++ b/tests/compat/fixtures/README.md @@ -5,5 +5,5 @@ Larger models are downloaded on first use and checked against a sha256 (see `downloaded_fixture` in `../conftest.py`). They are cached in `tests/compat/.cache`, or in `$WILDEDGE_COMPAT_CACHE` when set, which CI -keeps between runs. The gguf test declares its model's URL and checksum at -the top of `test_gguf_compat.py`. +keeps between runs. The llama_cpp test declares its model's URL and checksum at +the top of `test_llama_cpp_compat.py`. diff --git a/tests/compat/test_gguf_compat.py b/tests/compat/test_llama_cpp_compat.py similarity index 98% rename from tests/compat/test_gguf_compat.py rename to tests/compat/test_llama_cpp_compat.py index 0d628cc..51cdd63 100644 --- a/tests/compat/test_gguf_compat.py +++ b/tests/compat/test_llama_cpp_compat.py @@ -16,7 +16,7 @@ @pytest.fixture def llm(compat_client): model = downloaded_fixture("stories260K-f32.gguf", MODEL_URL, MODEL_SHA256) - compat_client.instrument("gguf") + compat_client.instrument("llama_cpp") return llama_cpp.Llama(model_path=str(model), n_ctx=128, seed=1, verbose=False) diff --git a/tests/test_cli.py b/tests/test_cli.py index 882bf3b..9162a56 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,6 +11,7 @@ from wildedge.integrations.registry import IntegrationSpec from wildedge.platforms.device_info import DeviceInfo from wildedge.runtime import bootstrap +from wildedge.settings import parse_integration_list from wildedge.transmitter import IngestResponse, TransmitError @@ -578,3 +579,12 @@ def test_doctor_json_includes_environment_and_statuses(monkeypatch, capsys): assert data["environment"]["autoload_on_pythonpath"] is False assert data["config_status"] == "PASS" assert data["connectivity_status"] == "SKIP" + + +@pytest.mark.parametrize("names", ["gguf", "llama_cpp", "gguf,llama_cpp"]) +def test_llama_cpp_names_resolve_once_in_cli_and_environment(names): + assert cli.integration_list(names) == ["llama_cpp"] + assert parse_integration_list(names, []) == ["llama_cpp"] + assert cli.INTEGRATIONS_BY_NAME["gguf"] is cli.INTEGRATIONS_BY_NAME["llama_cpp"] + assert "llama_cpp" in cli.integration_list("all") + assert "gguf" not in cli.integration_list("all") diff --git a/tests/test_integration_patching.py b/tests/test_integration_patching.py index 479a5df..9482e29 100644 --- a/tests/test_integration_patching.py +++ b/tests/test_integration_patching.py @@ -217,7 +217,7 @@ def test_instrument_hubs_activates_requested_trackers(stub_client): activated = [] with ( patch.object(stub_client, "_activate_hub", side_effect=activated.append), - patch.dict(stub_client.PATCH_INSTALLERS, {"gguf": lambda ref: None}), + patch.dict(stub_client.PATCH_INSTALLERS, {"llama_cpp": lambda ref: None}), ): stub_client.instrument("gguf", hubs=["huggingface"]) @@ -244,3 +244,24 @@ def test_instrument_none_activates_hub(stub_client): with patch.object(stub_client, "_activate_hub", side_effect=activated.append): stub_client.instrument(None, hubs=["huggingface"]) assert activated == ["huggingface"] + + +@pytest.mark.parametrize("names", [("gguf", "llama_cpp"), ("llama_cpp", "gguf")]) +def test_llama_cpp_aliases_share_patch_and_client_route( + stub_client, monkeypatch, names +): + class FakeLlama: + def __init__(self, *args, **kwargs): + pass + + runtime = types.SimpleNamespace(Llama=FakeLlama) + monkeypatch.setattr(gguf_mod, "_llama_cpp", runtime) + monkeypatch.setattr(gguf_mod, "_llama_patched", False) + stub_client.instrument(names[0]) + first = runtime.Llama.__init__ + stub_client.instrument(names[1]) + assert runtime.Llama.__init__ is first + with patch.object(stub_client, "_on_model_auto_loaded") as loaded: + model = runtime.Llama("model.gguf") + loaded.assert_called_once() + assert loaded.call_args.args[0] is model diff --git a/wildedge/cli.py b/wildedge/cli.py index 2199335..5b6e11a 100644 --- a/wildedge/cli.py +++ b/wildedge/cli.py @@ -21,7 +21,11 @@ from wildedge.client import parse_dsn from wildedge.events.span import SpanEvent from wildedge.hubs.registry import HUBS_BY_NAME, supported_hubs -from wildedge.integrations.registry import INTEGRATIONS_BY_NAME, supported_integrations +from wildedge.integrations.registry import ( + INTEGRATIONS_BY_NAME, + canonical_integration, + supported_integrations, +) from wildedge.paths import default_dead_letter_dir, default_pending_queue_dir from wildedge.platforms import detect_device, get_device_id_path from wildedge.settings import read_client_env, resolve_app_identity @@ -44,7 +48,7 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument( "--integrations", default="all", - help="Comma-separated framework integrations to enable (gguf, onnx, timm, …). Default: all.", + help="Comma-separated framework integrations to enable (llama_cpp, onnx, timm, …). Default: all.", ) run.add_argument( "--hubs", @@ -291,7 +295,13 @@ def run_command(parsed: argparse.Namespace) -> int: def integration_list(value: str | None) -> list[str]: if not value or value == "all": return sorted(supported_integrations()) - return [item.strip() for item in value.split(",") if item.strip()] + return list( + dict.fromkeys( + canonical_integration(item.strip()) + for item in value.split(",") + if item.strip() + ) + ) def hub_list(value: str | None) -> list[str]: diff --git a/wildedge/client.py b/wildedge/client.py index e755e4f..c0a0f8d 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -27,6 +27,7 @@ from wildedge.integrations.base import BaseExtractor from wildedge.integrations.registry import ( INTEGRATION_SPECS, + canonical_integration, find_extractor, noop_integrations, supported_integrations, @@ -739,7 +740,7 @@ def instrument( Pass ``hubs=`` alongside a framework name to activate download provenance tracking for the hub(s) your code downloads from:: - client.instrument("gguf", hubs=["huggingface"]) + client.instrument("llama_cpp", hubs=["huggingface"]) client.instrument("onnx", hubs=["huggingface"]) client.instrument("timm", hubs=["huggingface", "torchhub"]) @@ -753,7 +754,7 @@ def instrument( Supported framework integrations --------------------------------- - ``"gguf"`` + ``"llama_cpp"`` (``"gguf"`` remains a compatibility alias) Patches ``llama_cpp.Llama.__init__``. Requires ``llama-cpp-python``. ``"onnx"`` Replaces ``ort.InferenceSession`` with a timed subclass. @@ -816,6 +817,7 @@ def instrument( self._activate_hub(hub_name) return + integration = canonical_integration(integration) if integration in self.SUPPORTED_HUBS: raise ValueError( f"{integration!r} is a hub, not a framework integration. " diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index a9959ef..2f54d6d 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -54,7 +54,7 @@ LLAMA_AUTO_LOAD_PATCH_NAME = "gguf_auto_load" -debug_gguf_failure = functools.partial(debug_failure, "gguf") +debug_gguf_failure = functools.partial(debug_failure, "llama_cpp") def n_gpu_layers(obj: object) -> int: diff --git a/wildedge/integrations/registry.py b/wildedge/integrations/registry.py index 8e892c6..94fc78c 100644 --- a/wildedge/integrations/registry.py +++ b/wildedge/integrations/registry.py @@ -33,17 +33,18 @@ class IntegrationSpec: name: str required_modules: tuple[str, ...] kind: IntegrationKind + adapter_module: str | None = None @property def installer(self) -> str | None: if self.kind != "client_patch": return None - return f"{ADAPTER_PACKAGE}.{self.name}:install_auto_load_patch" + return f"{ADAPTER_PACKAGE}.{self.adapter_module or self.name}:install_auto_load_patch" INTEGRATION_SPECS: tuple[IntegrationSpec, ...] = ( IntegrationSpec("anthropic", ("anthropic",), "client_patch"), - IntegrationSpec("gguf", ("llama_cpp",), "client_patch"), + IntegrationSpec("llama_cpp", ("llama_cpp",), "client_patch", adapter_module="gguf"), IntegrationSpec("onnx", ("onnxruntime",), "client_patch"), IntegrationSpec("openai", ("openai",), "client_patch"), IntegrationSpec("timm", ("timm",), "client_patch"), @@ -62,9 +63,20 @@ def installer(self) -> str | None: } +# Public names describe runtimes; retain the pre-0.3 name as an input alias. +INTEGRATION_ALIASES = {"gguf": "llama_cpp"} +INTEGRATIONS_BY_NAME.update( + {alias: INTEGRATIONS_BY_NAME[name] for alias, name in INTEGRATION_ALIASES.items()} +) + + +def canonical_integration(name: str) -> str: + return INTEGRATION_ALIASES.get(name, name) + + def supported_integrations() -> set[str]: """Return all supported integration names.""" - return set(INTEGRATIONS_BY_NAME.keys()) + return {spec.name for spec in INTEGRATION_SPECS} def noop_integrations() -> set[str]: diff --git a/wildedge/settings.py b/wildedge/settings.py index 6c445b8..855e9e1 100644 --- a/wildedge/settings.py +++ b/wildedge/settings.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from wildedge import constants +from wildedge.integrations.registry import canonical_integration TRUE_VALUES = {"1", "true", "yes", "on"} @@ -46,7 +47,13 @@ def parse_integration_list(value: str | None, all_values: list[str]) -> list[str """Parse ``--integrations`` value; defaults to all when unset.""" if not value or value == "all": return sorted(all_values) - return [item.strip() for item in value.split(",") if item.strip()] + return list( + dict.fromkeys( + canonical_integration(item.strip()) + for item in value.split(",") + if item.strip() + ) + ) def parse_hub_list(value: str | None, all_values: list[str]) -> list[str]: From 18386c27c6f9383bf874622fe3eb14f13a55aadf Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:01:57 +0200 Subject: [PATCH 56/81] Give README a complete local inference quickstart --- README.md | 54 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 193fab2..379cbcb 100644 --- a/README.md +++ b/README.md @@ -47,31 +47,53 @@ environment validation, and production configuration. ## SDK -Use the SDK when you can't wrap the process, or when you want traces and spans -around your own code. `wildedge run` covers everything else. +To try tracking in your code, run one local text-generation model on CPU. +Install the model's dependencies: + +```bash +uv add transformers torch +``` + +Save this as `app.py`. The first run downloads the model from Hugging Face; +no model-provider API key is needed. ```python import wildedge -from openai import OpenAI from transformers import pipeline -wildedge.init(integrations=["transformers", "openai"]) # optional under `wildedge run` +client = wildedge.init(integrations=["transformers"]) +try: + model = pipeline( + "text-generation", + model="HuggingFaceTB/SmolLM2-360M-Instruct", + device=-1, # CPU + ) + result = model( + "Explain how rain forms in one sentence.", + max_new_tokens=32, + do_sample=False, + return_full_text=False, + ) + print(result[0]["generated_text"]) +finally: + client.close(timeout=10) +``` -local = pipeline("text-generation", model="HuggingFaceTB/SmolLM2-360M-Instruct") -remote = OpenAI() +Set the DSN from your WildEdge project and run it: -with wildedge.trace(run_id="run-1"): - with wildedge.span(kind="agent_step", name="draft"): - draft = local(prompt)[0]["generated_text"] - with wildedge.span(kind="agent_step", name="refine"): - remote.chat.completions.create( - model="gpt-4o-mini", - messages=[{"role": "user", "content": draft}], - ) +```bash +export WILDEDGE_DSN="https://@ingest.wildedge.dev/" +uv run python app.py ``` -See [Deployment](https://github.com/wild-edge/wildedge-python/blob/main/docs/deployment.md) -for client lifecycle and the full contract. +Open your project at [app.wildedge.dev](https://app.wildedge.dev) to inspect the +model load and inference events, including duration and model metadata. The +generated text is printed locally; it isn't uploaded. + +Already have an application? Initialize WildEdge before creating your model or +API client. See [examples for each integration](#supported-integrations), +[traces and spans](docs/manual-tracking.md#track-spans-for-agentic-workflows), +and the [deployment guide](docs/deployment.md) for lifecycle details. ## Supported integrations From 9b765d295987f2c89caa4644d9c20f9cbc6b9147 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:02:38 +0200 Subject: [PATCH 57/81] Keep traced local and remote workflow as expandable example --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index 379cbcb..90dab96 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,49 @@ API client. See [examples for each integration](#supported-integrations), [traces and spans](docs/manual-tracking.md#track-spans-for-agentic-workflows), and the [deployment guide](docs/deployment.md) for lifecycle details. +
+Trace a workflow across local and remote models + +Combine integrations and group inference calls into traces and spans. This +example also requires `uv add openai` and an `OPENAI_API_KEY`, alongside the +Transformers dependencies and WildEdge DSN above. + +```python +import wildedge +from openai import OpenAI +from transformers import pipeline + +client = wildedge.init(integrations=["transformers", "openai"]) +try: + local = pipeline( + "text-generation", + model="HuggingFaceTB/SmolLM2-360M-Instruct", + device=-1, + ) + remote = OpenAI() + prompt = "Explain how rain forms in one sentence." + + with wildedge.trace(run_id="run-1"): + with wildedge.span(kind="agent_step", name="draft"): + draft = local( + prompt, max_new_tokens=32, return_full_text=False + )[0]["generated_text"] + with wildedge.span(kind="agent_step", name="refine"): + response = remote.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": draft}], + ) + print(response.choices[0].message.content) +finally: + client.close(timeout=10) +``` + +Both inference calls belong to one trace, with separate `draft` and `refine` +spans. See [manual tracking](docs/manual-tracking.md#track-spans-for-agentic-workflows) +for additional span types and context. + +
+ ## Supported integrations **On-device** From 3e5e2a92f715edf2264784a71d856449e6409c92 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:07:16 +0200 Subject: [PATCH 58/81] Add clickable demo performance preview to README --- README.md | 7 ++++++- docs/images/README.md | 12 ++++++++++++ docs/images/demo-model-performance.jpg | Bin 0 -> 34678 bytes 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 docs/images/README.md create mode 100644 docs/images/demo-model-performance.jpg diff --git a/README.md b/README.md index 90dab96..8d8cf05 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,12 @@ Transformers, OpenAI, and more. Instrument supported libraries with one command, or add tracking in your code. Prompts and outputs aren't uploaded unless you explicitly attach them. -**[Get started](#install)** · [Docs](https://docs.wildedge.dev) · [Website](https://wildedge.dev) +**[Get started](#install)** · [Explore the demo](https://wildedge.dev/demo) · [Docs](https://docs.wildedge.dev) · [Website](https://wildedge.dev) + +[![WildEdge demo showing a local summarizer's latency, time to first token, throughput, and model health](docs/images/demo-model-performance.jpg)](https://wildedge.dev/demo) + +*Spot performance changes in local models. [Explore the read-only demo](https://wildedge.dev/demo) +with sample data. Pictured: News Reader → Models → Article Summarizer.* ## Install diff --git a/docs/images/README.md b/docs/images/README.md new file mode 100644 index 0000000..41a450f --- /dev/null +++ b/docs/images/README.md @@ -0,0 +1,12 @@ +# README screenshot + +`demo-model-performance.jpg` is an unaltered crop captured from the public +WildEdge demo on 2026-10-21. It shows sample data, not measurements from the +Python quickstart. + +To refresh: open https://wildedge.dev/demo, switch to News Reader, then open +Models → Article Summarizer. Capture the model heading, health notice, and +performance cards. Keep metric values as displayed; do not fabricate data. + +The README links to the demo entry point because direct navigation to nested +model routes currently loads the marketing page. diff --git a/docs/images/demo-model-performance.jpg b/docs/images/demo-model-performance.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0dc595a3600adaf2b8d09328429e28643b282cef GIT binary patch literal 34678 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 Date: Wed, 21 Oct 2026 13:08:22 +0200 Subject: [PATCH 59/81] Capture demo at desktop size without sidebar overflow --- docs/images/README.md | 7 ++++--- docs/images/demo-model-performance.jpg | Bin 34678 -> 108249 bytes 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/images/README.md b/docs/images/README.md index 41a450f..d49f97e 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -1,12 +1,13 @@ # README screenshot -`demo-model-performance.jpg` is an unaltered crop captured from the public +`demo-model-performance.jpg` is an unaltered 1600 × 1100 desktop capture from the public WildEdge demo on 2026-10-21. It shows sample data, not measurements from the Python quickstart. To refresh: open https://wildedge.dev/demo, switch to News Reader, then open -Models → Article Summarizer. Capture the model heading, health notice, and -performance cards. Keep metric values as displayed; do not fabricate data. +Models → Article Summarizer. Use a 1600 × 1100 viewport so the sidebar fits without a scrollbar. Capture +the full viewport, including the model heading, health notice, performance +cards, and charts. Keep metric values as displayed; do not fabricate data. The README links to the demo entry point because direct navigation to nested model routes currently loads the marketing page. diff --git a/docs/images/demo-model-performance.jpg b/docs/images/demo-model-performance.jpg index 0dc595a3600adaf2b8d09328429e28643b282cef..a8a22c959e72314088dbf179947202bb3a9e6e88 100644 GIT binary patch literal 108249 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%0Dh)~2UqwkQp%rC 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 literal 34678 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 Date: Wed, 21 Oct 2026 13:08:58 +0200 Subject: [PATCH 60/81] Limit README demo preview to 640 pixels wide --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d8cf05..9f19c7e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ Prompts and outputs aren't uploaded unless you explicitly attach them. **[Get started](#install)** · [Explore the demo](https://wildedge.dev/demo) · [Docs](https://docs.wildedge.dev) · [Website](https://wildedge.dev) -[![WildEdge demo showing a local summarizer's latency, time to first token, throughput, and model health](docs/images/demo-model-performance.jpg)](https://wildedge.dev/demo) + + WildEdge demo showing a local summarizer's latency, time to first token, throughput, and model health + *Spot performance changes in local models. [Explore the read-only demo](https://wildedge.dev/demo) with sample data. Pictured: News Reader → Models → Article Summarizer.* From 7f6a00d1a25d4bd5d816a7779c1a06d644346fef Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:10:21 +0200 Subject: [PATCH 61/81] Use three small expandable README screenshot thumbnails --- README.md | 10 +++++----- docs/images/README.md | 11 ++++++++++- docs/images/demo-events.jpg | Bin 0 -> 162555 bytes docs/images/demo-hardware.jpg | Bin 0 -> 123571 bytes 4 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 docs/images/demo-events.jpg create mode 100644 docs/images/demo-hardware.jpg diff --git a/README.md b/README.md index 9f19c7e..b3396a2 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,12 @@ Prompts and outputs aren't uploaded unless you explicitly attach them. **[Get started](#install)** · [Explore the demo](https://wildedge.dev/demo) · [Docs](https://docs.wildedge.dev) · [Website](https://wildedge.dev) - - WildEdge demo showing a local summarizer's latency, time to first token, throughput, and model health - +| Model performance | Events | Hardware | +|:---:|:---:|:---:| +| Model health, latency, time to first token, and throughput | Inference and error events with model and device metadata | Thermal state, accelerator usage, and CPU throttling | -*Spot performance changes in local models. [Explore the read-only demo](https://wildedge.dev/demo) -with sample data. Pictured: News Reader → Models → Article Summarizer.* +*Demo sample data across WildEdge SDKs. Click a thumbnail for the full-size image, +or [explore the read-only demo](https://wildedge.dev/demo).* ## Install diff --git a/docs/images/README.md b/docs/images/README.md index d49f97e..b70fe17 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -1,4 +1,4 @@ -# README screenshot +# README screenshots `demo-model-performance.jpg` is an unaltered 1600 × 1100 desktop capture from the public WildEdge demo on 2026-10-21. It shows sample data, not measurements from the @@ -11,3 +11,12 @@ cards, and charts. Keep metric values as displayed; do not fabricate data. The README links to the demo entry point because direct navigation to nested model routes currently loads the marketing page. + +Additional views in the News Reader demo: + +- `demo-events.jpg`: Events. +- `demo-hardware.jpg`: Hardware. + +These sample views span WildEdge SDKs, including mobile hardware. They do not +represent output from the Python quickstart. The README uses 180-pixel-wide +thumbnails linked to the full-size images, with a separate live-demo link. diff --git a/docs/images/demo-events.jpg b/docs/images/demo-events.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5e4df1e4b671523ea4700288cbe93f7b59ee7849 GIT binary patch literal 162555 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 z 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

Z@0$P75jC*gP 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 literal 0 HcmV?d00001 From 94484838c5fe38550b6b7119070526cbf96aa27d Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:11:47 +0200 Subject: [PATCH 62/81] Remove release documentation bundle note from README --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index b3396a2..fb7b3ae 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,3 @@ Report security and privacy issues to: support@wildedge.dev - [Streaming lifecycle](docs/streaming.md) - [Changelog](https://github.com/wild-edge/wildedge-python/blob/main/CHANGELOG.md) - [License](https://github.com/wild-edge/wildedge-python/blob/main/LICENSE) - -Each GitHub release ships `llms.txt` and `llms-full.txt`: the full -documentation for that exact version in one file, built for AI assistants. From 68c8ff6dabeee884a39b603807abcc26509a0517 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:13:51 +0200 Subject: [PATCH 63/81] Clarify README setup, integration activation and telemetry --- README.md | 62 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index fb7b3ae..f90c06f 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,12 @@ Monitor an existing application by adding `wildedge run` to its start command: ```bash WILDEDGE_DSN="https://@ingest.wildedge.dev/" \ -wildedge run --integrations llama_cpp -- python app.py +uv run wildedge run --integrations llama_cpp -- python app.py ``` -The `llama_cpp` integration instruments llama.cpp through `llama-cpp-python`. -Replace it with your [integration](#supported-integrations). +This assumes your application already uses `llama-cpp-python`. The `llama_cpp` +integration instruments that library; replace it with your +[integration](#supported-integrations). No SDK calls are required in your application code. See the [deployment guide](docs/deployment.md) for startup options, @@ -147,27 +148,32 @@ for additional span types and context. ## Supported integrations -**On-device** - -| Integration | Example | -|---|---| -| `transformers` | [transformers_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/transformers_example.py) | -| `mlx_whisper` / `faster_whisper` | [whisper_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/whisper_example.py) | -| `mlx` | [mlx_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/mlx_example.py) | -| `timm` | [timm_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/timm_example.py) | -| llama.cpp (`llama_cpp`, via `llama-cpp-python`) | [gguf_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/gguf_example.py) | -| `onnx` | [onnx_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/onnx_example.py) | -| `ultralytics` | - | -| `tensorflow` | [tensorflow_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/tensorflow_example.py) | -| `torch` | [pytorch_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/pytorch_example.py) | -| `keras` | [keras_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/keras_example.py) | - -**Remote models** - -| Integration | Example | -|---|---| -| `anthropic` | [anthropic_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/anthropic_example.py) | -| `openai` | [openai_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/openai_example.py) | +Enable the integration before creating models or API clients, using +`--integrations` or `wildedge.init(integrations=[...])`. Automatic tracking +covers the entry points listed below. PyTorch and standalone Keras models need +explicit registration through `client.load(...)` or `client.register_model(...)`. + +**Local models** + +| Library | Integration key | How tracking starts | Example | +|---|---|---|---| +| Transformers | `transformers` | Automatically through `pipeline` / `from_pretrained` | [Example](examples/transformers_example.py) | +| llama.cpp (`llama-cpp-python`) | `llama_cpp` | Automatically when constructing `Llama` | [Example](examples/gguf_example.py) | +| MLX-LM | `mlx` | Automatically through `mlx_lm.load` | [Example](examples/mlx_example.py) | +| faster-whisper / MLX Whisper | `faster_whisper` / `mlx_whisper` | Automatically through supported model loaders | [Example](examples/whisper_example.py) | +| ONNX Runtime | `onnx` | Automatically when constructing `InferenceSession` | [Example](examples/onnx_example.py) | +| timm | `timm` | Automatically through `create_model` | [Example](examples/timm_example.py) | +| Ultralytics | `ultralytics` | Automatically when constructing `YOLO` | — | +| TensorFlow | `tensorflow` | Automatically through `keras.models.load_model` / `saved_model.load`; register models created in code | [Example](examples/tensorflow_example.py) | +| PyTorch | `torch` | Register the model explicitly | [Example](examples/pytorch_example.py) | +| Keras | `keras` | Register the model explicitly | [Example](examples/keras_example.py) | + +**API clients** + +| Library | Integration key | How tracking starts | Example | +|---|---|---|---| +| Anthropic | `anthropic` | Automatically for clients created after initialization | [Example](examples/anthropic_example.py) | +| OpenAI | `openai` | Automatically for clients created after initialization | [Example](examples/openai_example.py) | Fireworks, Together, Baseten, xAI, Mistral, Groq and other OpenAI-compatible hosts work through the `openai` integration — pass their `base_url`. See @@ -214,9 +220,11 @@ For advanced options (batching, queue tuning, dead-letter storage, attachments), ## Security & Privacy -By default the SDK transmits only anonymized telemetry, never raw model inputs -or outputs. The one exception is opt-in [attachments](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md#attachments) -(`attachments_enabled`), which upload raw bytes you explicitly pass in. +Automatic integrations send inference metrics, model metadata, and device/hardware +information, including a device identifier, OS information, locale, and timezone. +They do not attach raw inputs or outputs. Raw payload uploads require explicitly +supplied [attachments](docs/configuration.md#attachments) and +`attachments_enabled=True`. Report security and privacy issues to: support@wildedge.dev From c69008b793f93dfc71766c3bb4846c11bcc51281 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:21:13 +0200 Subject: [PATCH 64/81] Make integration overview compact with expandable reference --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index f90c06f..3c8bdc3 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,17 @@ for additional span types and context. ## Supported integrations +**Local inference:** llama.cpp, Transformers, MLX-LM, ONNX Runtime, PyTorch, +TensorFlow, Keras, Ultralytics, timm, faster-whisper, and MLX Whisper. + +**API clients:** OpenAI, Anthropic, and OpenAI-compatible providers. + +Most integrations track supported entry points automatically after activation. +PyTorch and standalone Keras models require explicit registration. + +

+Integration keys, setup, and examples + Enable the integration before creating models or API clients, using `--integrations` or `wildedge.init(integrations=[...])`. Automatic tracking covers the entry points listed below. PyTorch and standalone Keras models need @@ -175,6 +186,11 @@ explicit registration through `client.load(...)` or `client.register_model(...)` | Anthropic | `anthropic` | Automatically for clients created after initialization | [Example](examples/anthropic_example.py) | | OpenAI | `openai` | Automatically for clients created after initialization | [Example](examples/openai_example.py) | +
+ +
+API coverage and compatible providers + Fireworks, Together, Baseten, xAI, Mistral, Groq and other OpenAI-compatible hosts work through the `openai` integration — pass their `base_url`. See [Providers](https://github.com/wild-edge/wildedge-python/blob/main/docs/providers.md) @@ -194,6 +210,11 @@ The `openai` integration also tracks sync and async `embeddings.create()` calls. See [Embeddings](docs/providers.md#embeddings) and the [example](examples/openai_embeddings_example.py). +
+ +
+Model download tracking + **Hub tracking** Pass `hubs=` to track model download provenance. Hubs are framework-agnostic and can be combined with any integration. @@ -203,6 +224,8 @@ Pass `hubs=` to track model download provenance. Hubs are framework-agnostic and | `huggingface` | Downloads via `huggingface_hub` | | `torchhub` | Downloads via `torch.hub` | +
+ For unsupported frameworks, see [Manual tracking](https://github.com/wild-edge/wildedge-python/blob/main/docs/manual-tracking.md). ## Configuration From 3c9667dcb61b3e0d8985a042d155a3efcbc116f3 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:28:27 +0200 Subject: [PATCH 65/81] Move API-specific README details into provider documentation --- README.md | 15 ++------------- docs/providers.md | 6 ++++++ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3c8bdc3..4c8a405 100644 --- a/README.md +++ b/README.md @@ -196,19 +196,8 @@ work through the `openai` integration — pass their `base_url`. See [Providers](https://github.com/wild-edge/wildedge-python/blob/main/docs/providers.md) for every recognized endpoint. -Using httpx, requests or urllib directly instead of the `openai` / `anthropic` -client libraries? Wrap the call in -[`wildedge.llm_api()`](https://github.com/wild-edge/wildedge-python/blob/main/docs/llm_api.md) -to get the same events: -[llm_api_example.py](https://github.com/wild-edge/wildedge-python/blob/main/examples/llm_api_example.py). - -The `openai` integration also tracks `client.responses.create(...)`, including -async clients and `stream=True`. See [Responses tracking](docs/providers.md#responses-api) -for coverage and streaming behavior. - -The `openai` integration also tracks sync and async `embeddings.create()` calls. -See [Embeddings](docs/providers.md#embeddings) and the -[example](examples/openai_embeddings_example.py). +See [API tracking](docs/providers.md) for endpoint coverage, streaming, embeddings, +and tracking calls made without the official client libraries. diff --git a/docs/providers.md b/docs/providers.md index 2adb279..1c29502 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,6 +8,12 @@ mapped to a `model_source` name on every event. OpenAI(base_url="https://api.fireworks.ai/inference/v1") ``` +## Direct HTTP calls + +For calls made with httpx, requests, or urllib instead of the OpenAI or +Anthropic client libraries, use [`wildedge.llm_api()`](llm_api.md). +See the [direct HTTP example](../examples/llm_api_example.py) for usage. + ## Recognized providers | Provider | Base URL | `model_source` | From 68580ce4a4bcd6f4e6f59d3c8032aac61be8aa41 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 13:49:12 +0200 Subject: [PATCH 66/81] Show compact trace and SQL previews with extra views collapsed --- README.md | 13 +++++++++++-- docs/images/README.md | 10 ++++++++++ docs/images/demo-sql.jpg | Bin 0 -> 70506 bytes docs/images/demo-trace.jpg | Bin 0 -> 67701 bytes 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 docs/images/demo-sql.jpg create mode 100644 docs/images/demo-trace.jpg diff --git a/README.md b/README.md index 4c8a405..1d60004 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,22 @@ Prompts and outputs aren't uploaded unless you explicitly attach them. **[Get started](#install)** · [Explore the demo](https://wildedge.dev/demo) · [Docs](https://docs.wildedge.dev) · [Website](https://wildedge.dev) -| Model performance | Events | Hardware | +| Model performance | Workflow trace | SQL editor | |:---:|:---:|:---:| -| Model health, latency, time to first token, and throughput | Inference and error events with model and device metadata | Thermal state, accelerator usage, and CPU throttling | +| Model health, latency, time to first token, and throughput | Named workflow step and inference timing in a trace waterfall | SQL editor for exploring inference telemetry | *Demo sample data across WildEdge SDKs. Click a thumbnail for the full-size image, or [explore the read-only demo](https://wildedge.dev/demo).* +
+More screenshots: events and hardware + +| Events | Hardware | +|:---:|:---:| +| Inference and error events with model and device metadata | Thermal state, accelerator usage, and CPU throttling | + +
+ ## Install ```bash diff --git a/docs/images/README.md b/docs/images/README.md index b70fe17..15038c1 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -20,3 +20,13 @@ Additional views in the News Reader demo: These sample views span WildEdge SDKs, including mobile hardware. They do not represent output from the Python quickstart. The README uses 180-pixel-wide thumbnails linked to the full-size images, with a separate live-demo link. + +- `demo-trace.jpg`: News Reader → Runs → demo-run-1. Shows a named step and + a local summarizer inference; this sample does not include a cloud call. +- `demo-sql.jpg`: News Reader → Analytics → SQL. Shows the demo's default + query. SQL execution is disabled in this offline demo; no query results + were fabricated or added to the screenshot. + +The opening row contains performance, trace, and SQL previews. Events and +hardware remain in a collapsed More screenshots section. All five thumbnails +are 180 pixels wide and open their full-size image. diff --git a/docs/images/demo-sql.jpg b/docs/images/demo-sql.jpg new file mode 100644 index 0000000000000000000000000000000000000000..aba856834f63d8ecc7e459d096fb8ab9c424b7f8 GIT binary patch literal 70506 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 W7BL;;{aX|M?_R9`+2`%q>HmLZG6-Y< literal 0 HcmV?d00001 diff --git a/docs/images/demo-trace.jpg b/docs/images/demo-trace.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a95e27f39e9f7e485e14168ff0c4b70c201547b3 GIT binary patch literal 67701 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 J%bNGS{|P8T0xSRk literal 0 HcmV?d00001 From d98df57713caeb025d538c0888f49cc2c6f88015 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 14:00:53 +0200 Subject: [PATCH 67/81] Show computed sample SQL results as stacked model volumes --- README.md | 4 +- docs/images/README.md | 7 +- docs/images/demo-sql.jpg | Bin 70506 -> 129136 bytes docs/images/sql-preview/DemoAnalytics.vue | 40 ++++++++++++ docs/images/sql-preview/README.md | 27 ++++++++ docs/images/sql-preview/prepare.py | 76 ++++++++++++++++++++++ docs/images/sql-preview/query.sql | 10 +++ 7 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 docs/images/sql-preview/DemoAnalytics.vue create mode 100644 docs/images/sql-preview/README.md create mode 100644 docs/images/sql-preview/prepare.py create mode 100644 docs/images/sql-preview/query.sql diff --git a/README.md b/README.md index 1d60004..03c63e0 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ Prompts and outputs aren't uploaded unless you explicitly attach them. | Model performance | Workflow trace | SQL editor | |:---:|:---:|:---:| -| Model health, latency, time to first token, and throughput | Named workflow step and inference timing in a trace waterfall | SQL editor for exploring inference telemetry | +| Model health, latency, time to first token, and throughput | Named workflow step and inference timing in a trace waterfall | SQL query with stacked hourly inference volumes across local and cloud models | -*Demo sample data across WildEdge SDKs. Click a thumbnail for the full-size image, +*Illustrative sample data across WildEdge SDKs. Click a thumbnail for the full-size image, or [explore the read-only demo](https://wildedge.dev/demo).*
diff --git a/docs/images/README.md b/docs/images/README.md index 15038c1..c07d95d 100644 --- a/docs/images/README.md +++ b/docs/images/README.md @@ -23,9 +23,10 @@ thumbnails linked to the full-size images, with a separate live-demo link. - `demo-trace.jpg`: News Reader → Runs → demo-run-1. Shows a named step and a local summarizer inference; this sample does not include a cloud call. -- `demo-sql.jpg`: News Reader → Analytics → SQL. Shows the demo's default - query. SQL execution is disabled in this offline demo; no query results - were fabricated or added to the screenshot. +- `demo-sql.jpg`: a local screenshot-only composition of the dashboard's SQL + editor and chart result components. It shows query results computed with DuckDB + from generated fictional events, not the deployed offline demo or production + telemetry. See [sql-preview/README.md](sql-preview/README.md) to reproduce it. The opening row contains performance, trace, and SQL previews. Events and hardware remain in a collapsed More screenshots section. All five thumbnails diff --git a/docs/images/demo-sql.jpg b/docs/images/demo-sql.jpg index aba856834f63d8ecc7e459d096fb8ab9c424b7f8..2aff736c6d129c185c116d44676dad25800cc318 100644 GIT binary patch literal 129136 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/H$;J&p9ltev#g3MHo#yJ% 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 literal 70506 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 W7BL;;{aX|M?_R9`+2`%q>HmLZG6-Y< diff --git a/docs/images/sql-preview/DemoAnalytics.vue b/docs/images/sql-preview/DemoAnalytics.vue new file mode 100644 index 0000000..d69bd34 --- /dev/null +++ b/docs/images/sql-preview/DemoAnalytics.vue @@ -0,0 +1,40 @@ + + + diff --git a/docs/images/sql-preview/README.md b/docs/images/sql-preview/README.md new file mode 100644 index 0000000..ea06efc --- /dev/null +++ b/docs/images/sql-preview/README.md @@ -0,0 +1,27 @@ +# Reproduce the SQL screenshot + +This is an illustrative local preview, not a deployed demo feature or production +telemetry. The SQL runs against deterministic fictional events in DuckDB, and +the existing dashboard SqlExplorer and QueryResult components render the result. +No chart pixels or query results are fabricated independently of the query. + +Prerequisites: a wild-edge checkout with dashboard dependencies installed, Node, +and a Python environment containing DuckDB (the platform's `.venv` includes it). + +```bash +/path/to/wild-edge/.venv/bin/python prepare.py /path/to/wild-edge /private/tmp/sql-preview +cd /private/tmp/sql-preview +npm run typecheck +npm run build:demo +MOCKED_BE=true npm run preview -- --outDir dist-demo --host 127.0.0.1 --port 4180 +``` + +Open `http://127.0.0.1:4180/demo/wildedge-demolabs/news-reader/analytics?mode=sql` +at 1600 × 1100 and capture the full viewport. The preview uses the existing +compact chart layout to keep the query, axes, stacked bars, and legend visible. +The source checkout and production site are unchanged. + +`prepare.py` creates 12,876 events spanning 48 hours and three fictional model +workloads. It executes `query.sql` and checks that its 144 grouped rows account +for every generated event. `DemoAnalytics.vue` is a screenshot-only composition +of existing dashboard components; the editable SQL field does not execute queries. diff --git a/docs/images/sql-preview/prepare.py b/docs/images/sql-preview/prepare.py new file mode 100644 index 0000000..0654891 --- /dev/null +++ b/docs/images/sql-preview/prepare.py @@ -0,0 +1,76 @@ +"""Create an isolated, fictional SQL screenshot preview using the actual dashboard. + +Usage: python prepare.py /path/to/wild-edge /private/tmp/wildedge-sql-preview +Requires duckdb in the Python environment and installed dashboard node_modules. +""" + +import json +import math +import shutil +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import duckdb + +source = Path(__file__).resolve().parent +repository = Path(sys.argv[1]).resolve() +destination = Path(sys.argv[2]).resolve() +if destination.exists(): + raise SystemExit( + "Choose a new preview directory; existing files are never replaced." + ) +shutil.copytree( + repository / "dashboard", + destination, + ignore=shutil.ignore_patterns("node_modules", "dist", "dist-demo", ".vite"), +) +(destination / "node_modules").symlink_to( + repository / "dashboard" / "node_modules", target_is_directory=True +) +connection = duckdb.connect() +connection.execute( + "CREATE TABLE events(event_timestamp TIMESTAMP, event_type VARCHAR, model_name VARCHAR)" +) +events = [] +models = ["Gemma 3 4B · local", "Llama 3.2 3B · local", "GPT-4o mini · cloud"] +for hour in range(48): + timestamp = datetime(2026, 10, 19) + timedelta(hours=hour) + daytime = (1 + math.sin((hour % 24 - 7) * math.pi / 12)) / 2 + volumes = [ + round(45 + 175 * daytime + 20 * math.sin(hour * 0.7)), + round(30 + 115 * daytime + 12 * math.cos(hour * 0.9)), + round(15 + 55 * daytime + (35 if 31 <= hour <= 37 else 0)), + ] + for model, count in zip(models, volumes, strict=True): + events.extend( + (timestamp + timedelta(seconds=second * 7), "inference", model) + for second in range(count) + ) +connection.executemany("INSERT INTO events VALUES (?, ?, ?)", events) +query = (source / "query.sql").read_text() +cursor = connection.execute(query) +rows = [ + [value.isoformat() if isinstance(value, datetime) else value for value in row] + for row in cursor.fetchall() +] +assert len(rows) == 48 * 3 +assert sum(row[2] for row in rows) == len(events) +fixture = { + "columns": [description[0] for description in cursor.description], + "rows": rows, + "truncated": False, + "elapsed_ms": 0, +} + +(destination / "src/services/demo/screenshot-result.json").write_text( + json.dumps(fixture) +) +shutil.copyfile( + source / "query.sql", destination / "src/services/demo/screenshot-query.sql" +) +shutil.copyfile( + source / "DemoAnalytics.vue", destination / "src/views/DemoAnalytics.vue" +) +print(f"Computed {len(rows)} grouped rows from {len(events)} fictional events.") +print(f"Preview prepared in {destination}") diff --git a/docs/images/sql-preview/query.sql b/docs/images/sql-preview/query.sql new file mode 100644 index 0000000..7a914da --- /dev/null +++ b/docs/images/sql-preview/query.sql @@ -0,0 +1,10 @@ +SELECT + date_trunc('hour', event_timestamp) AS hour, + model_name, + count(*) AS inferences +FROM events +WHERE event_type = 'inference' + AND event_timestamp >= TIMESTAMP '2026-10-19 00:00:00' + AND event_timestamp < TIMESTAMP '2026-10-21 00:00:00' +GROUP BY 1, 2 +ORDER BY 1, 2; From d7fc6a617816a14979325586e8523ca795dce219 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:02:52 +0200 Subject: [PATCH 68/81] Fix whisper example span kind and add uv script header --- examples/whisper_example.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/examples/whisper_example.py b/examples/whisper_example.py index 7c5a1f2..f00a59d 100644 --- a/examples/whisper_example.py +++ b/examples/whisper_example.py @@ -1,8 +1,17 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["wildedge-sdk"] +# +# [tool.uv.sources] +# wildedge-sdk = { path = "..", editable = true } +# /// """Transcribe a WAV using a local Whisper model with automatic instrumentation. -Install mlx-whisper (Apple Silicon) or faster-whisper, set WILDEDGE_DSN, then: - python examples/whisper_example.py mlx_whisper /path/to/model /path/to/audio.wav - python examples/whisper_example.py faster_whisper /path/to/model /path/to/audio.wav +Set WILDEDGE_DSN, then add the backend you want to run: + uv run --with mlx-whisper examples/whisper_example.py mlx_whisper MODEL AUDIO + uv run --with faster-whisper examples/whisper_example.py faster_whisper MODEL AUDIO + +MODEL is a local path or Hugging Face repo, AUDIO is a WAV file. See docs/whisper.md for lifecycle and explicit attachment examples. """ @@ -20,7 +29,7 @@ def main(): args = parser.parse_args() client = wildedge.init(integrations=[args.backend]) try: - with client.trace(), client.span(name="transcribe"): + with client.trace(), client.span(kind="agent_step", name="transcribe"): if args.backend == "mlx_whisper": import mlx_whisper # noqa: PLC0415 -- optional runtime From faa5f77fbb6e117dadc434dd62e803a4ba7e679b Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:03:33 +0200 Subject: [PATCH 69/81] Pin httpx 0.27 for the openai min compat rows openai 1.30.0 passes proxies= to httpx, which httpx 0.28 removed. --- docs/compatibility.md | 2 +- scripts/compat_matrix.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index 0c08065..e5aec96 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -59,7 +59,7 @@ To run the full matrix locally: `python scripts/run_compat_local.py`. | `tensorflow` | `current` (`py3.10` override) | `tensorflow==2.21.0`, `keras==3.12.4`, `numpy==2.2.6` | 3.10 | | `llama_cpp` | `min` | `llama-cpp-python==0.2.90`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | | `llama_cpp` | `current` | `llama-cpp-python==0.3.36`, `numpy==2.2.6` | 3.10, 3.11, 3.12, 3.13 | -| `openai` | `min` | `openai==1.30.0` | 3.10, 3.11, 3.12, 3.13 | +| `openai` | `min` | `openai==1.30.0`, `httpx==0.27.2` | 3.10, 3.11, 3.12, 3.13 | | `openai` | `current` | `openai==3.35.1` | 3.10, 3.11, 3.12, 3.13 | | `transformers` | `min` | `transformers==4.40.0`, `torch==2.4.1`, `numpy==1.26.4` | 3.10, 3.11, 3.12, 3.13 | | `transformers` | `min` (`py3.13` override) | `transformers==4.45.0`, `torch==2.5.0`, `numpy==2.1.3` | 3.13 | diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index 038ab7b..97f424e 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -44,7 +44,7 @@ "current": ["llama-cpp-python==0.3.36", "numpy==2.2.6"], }, "openai": { - "min": ["openai==1.30.0"], + "min": ["openai==1.30.0", "httpx==0.27.2"], "current": ["openai==3.35.1"], }, "transformers": { From f1225f5e99bd1fdb8a5af1d71375b4f4245b064d Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:04:12 +0200 Subject: [PATCH 70/81] Merge duplicate changelog groups and list instrumentation ownership as a change Drops the llama.cpp line about automatic prompt and reply attachments, which the explicit-attachments change removed. --- CHANGELOG.md | 129 +++++++++++++++++++++++---------------------------- 1 file changed, 58 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 780819a..e3df8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,38 +5,48 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Added + +- `mlx_whisper` and `faster_whisper` integrations track local Whisper model + loads and transcriptions, including faster-whisper's + `BatchedInferencePipeline`. Transcriptions record duration, language, word + count and audio metadata. Audio and transcript attachments must be supplied + explicitly through the manual tracking API. See docs/whisper.md. +- The OpenAI integration now tracks `responses.create()` for synchronous and + asynchronous clients, including streaming. Records input/output token usage, + cached and reasoning tokens, response status or incomplete reason, resolved + model metadata, and time to first output delta for streams. Older OpenAI SDKs + without Responses remain supported. +- OpenAI sync and async `embeddings.create()` instrumentation records latency, + input token usage, batch size, vector dimensions, model metadata and errors. + Embedding vectors and raw inputs are never attached by this adapter. + `EmbeddingOutputMeta` now accepts optional `tokens_in` for analytics. +- Anthropic native `messages.stream()` helpers are now instrumented for sync + and async clients, including event iteration, `text_stream`, and final-message + helpers, with the shared streaming lifecycle contract. +- Process-wide integrations and hub patches now support sequential SDK client + replacement after the previous owner closes. Reinitialization replaces a + closed default. Existing models and provider clients keep their original + destination and must be recreated. Attempts to use a closed telemetry + destination warn once instead of silently dropping inference and error + events. + ### Changed - The llama.cpp Python integration is now named `llama_cpp` in Python, CLI, and environment configuration. `gguf` remains a compatibility alias; `all` selects the integration once under its new name. Model format metadata remains `gguf`. - - Automatic integrations no longer attach raw payloads, including GGUF prompts, chat messages and replies. `attachments_enabled=True` enables uploads only for attachments explicitly passed to `track_inference(attachments=[...])`. Apps relying on automatic GGUF capture must supply attachments explicitly. - -### Added - -- `mlx_whisper` and `faster_whisper` integrations track local Whisper model - loads and transcriptions, including faster-whisper's - `BatchedInferencePipeline`. Transcriptions record duration, language, word - count and audio metadata. Audio and transcript attachments must be supplied - explicitly through the manual tracking API. See docs/whisper.md. - -### Fixed - -- Changing a model's metadata (for example `register_model(..., version="v2")`) - now takes effect after a restart. With offline persistence on (the default), - the model registry saved by an earlier process kept the first metadata ever - registered for each model on that machine, so later changes were ignored. - This includes anthropic models already saved as `"unknown"`. - -- `import wildedge` no longer imports every installed ML framework and API - client. Each library is imported when its integration is activated or a - model from it is registered, so SDK import is faster and a broken unrelated - package no longer stops the SDK from starting. +- Enabling an integration or hub on a second live client now raises + `RuntimeError` while another client owns process-wide instrumentation. This + includes `wildedge.init(dsn=..., integrations=[...])` under `wildedge run`. + Previously the second client's patches were skipped without a warning and + automatic events kept going to the first client. Call `init()` without `dsn` + to reuse the client `wildedge run` installed, or close the owner first. - Quantization labels are defined once in `wildedge.quantizations` and used by every integration. Behaviour changes: - MLX-LM reports N-bit weights as `int4`/`int8`, like Transformers, instead @@ -46,73 +56,50 @@ under Unreleased and move into a version section at release time. reports `int8`. - faster-whisper reports weight precision (`int8`, `f16`) instead of CTranslate2 compute types such as `int8_float32`. +- The ultralytics integration now requires ultralytics 8.0.200 or later + (was 8.0.0). Earlier 8.0.x releases keep their helpers under + `ultralytics.yolo.utils`, and the integration turned itself off on them + without a word. `instrument("ultralytics")` now logs a warning naming the + installed version instead. + +### Fixed +- The llama.cpp integration now tracks `Llama.create_chat_completion()` and + `Llama.create_completion()`, streaming or not. It used to patch only + `Llama.__call__`, so chat apps on llama-cpp-python reported model loads but + no inference events. It now patches `create_completion`, which every entry + point runs, so each call is tracked once. +- llama.cpp inference events now include the stop reason for non-streamed + calls, the generation config (`temperature`, `top_p`, `top_k`, `max_tokens`, + `repeat_penalty`, penalties, `seed`, stop sequence count), and, for chat + calls, `prompt_type="chat"` and `turn_index` measured on the last user + message. +- `import wildedge` no longer imports every installed ML framework and API + client. Each library is imported when its integration is activated or a + model from it is registered, so SDK import is faster and a broken unrelated + package no longer stops the SDK from starting. +- Changing a model's metadata (for example `register_model(..., version="v2")`) + now takes effect after a restart. With offline persistence on (the default), + the model registry saved by an earlier process kept the first metadata ever + registered for each model on that machine, so later changes were ignored. + This includes anthropic models already saved as `"unknown"`. - MLX-LM models now report the device they run on (`mps` for the Apple GPU) instead of always `cpu`. Accelerator names are defined once in `wildedge.accelerators`, and PyTorch, Transformers, Ultralytics, TensorFlow and Keras share their device detection. - -- Process-wide integrations and hub patches now support sequential SDK client - replacement after the previous owner closes. Reinitialization replaces a closed - default; live ownership conflicts raise explicitly. Existing models/provider - clients retain their original destination and must be recreated. Attempts to - use a closed telemetry destination warn once instead of silently dropping - inference/error events. - - timm models now use the requested architecture/variant name for model identity instead of the Python class name, so models such as resnet18 and resnet50 no longer share telemetry. Manual registration falls back to timm architecture metadata, and explicit model IDs retain priority. - - Ultralytics `model.predict()` now records inference telemetry. Instrumentation patches `predict`, which `model(...)` delegates to, so both entry points are tracked exactly once. - - Streaming wrappers now finalize once across exhaustion, explicit closure, cancellation, and iterator errors. Early closure/cancellation records an unsuccessful inference with partial metadata when available. TTFT ignores lifecycle/role-only chunks; telemetry callbacks cannot break iteration and retain the trace context captured at stream creation. Remote API streams ending without their completion marker record `STREAM_INCOMPLETE`. - -### Added - -- Anthropic native `messages.stream()` helpers are now instrumented for sync - and async clients, including event iteration, `text_stream`, and final-message - helpers, with the shared streaming lifecycle contract. - -- OpenAI sync and async `embeddings.create()` instrumentation records latency, - input token usage, batch size, vector dimensions, model metadata and errors. - Embedding vectors and raw inputs are never attached by this adapter. - `EmbeddingOutputMeta` now accepts optional `tokens_in` for analytics. - -- The OpenAI integration now tracks `responses.create()` for synchronous and - asynchronous clients, including streaming. Records input/output token usage, - cached and reasoning tokens, response status or incomplete reason, resolved - model metadata, and time to first output delta for streams. Older OpenAI SDKs - without Responses remain supported. - -### Changed - -- The ultralytics integration now requires ultralytics 8.0.200 or later - (was 8.0.0). Earlier 8.0.x releases keep their helpers under - `ultralytics.yolo.utils`, and the integration turned itself off on them - without a word. `instrument("ultralytics")` now logs a warning naming the - installed version instead. - -### Fixed - -- The gguf integration now tracks `Llama.create_chat_completion()` and - `Llama.create_completion()`, streaming or not. It used to patch only - `Llama.__call__`, so chat apps on llama-cpp-python reported model loads but - no inference events. It now patches `create_completion`, which every entry - point runs, so each call is tracked once. -- gguf inference events now include the stop reason for non-streamed calls, - the generation config (`temperature`, `top_p`, `top_k`, `max_tokens`, - `repeat_penalty`, penalties, `seed`, stop sequence count), and, for chat - calls, `prompt_type="chat"` and `turn_index` measured on the last user - message. With attachments enabled, the prompt or chat messages and the reply - are attached. - A span attribute whose value is a list, dict or other non-scalar no longer gets the whole event batch rejected. Ingest only accepts scalar attribute values and answered 422 for the batch, dropping unrelated loads, inferences From cb23ef1836c6cc8802e57a2acb0475efe5dc5683 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:06:57 +0200 Subject: [PATCH 71/81] Serialize consumer drains so flush() cannot resend an in-flight batch The fork hooks hold the drain lock across fork() so a child never inherits it locked. --- CHANGELOG.md | 5 +++ tests/test_consumer.py | 69 ++++++++++++++++++++++++++++++++++++++++++ wildedge/consumer.py | 26 ++++++++++++++-- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3df8bf..3f1b0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,11 @@ under Unreleased and move into a version section at release time. tracked call of its pipeline; other models the pipeline runs, and direct calls to the model, are still tracked. Generator and dataset inputs, which run lazily, are not covered yet (#78). +- `flush()` no longer sends a batch the background sender is already sending. + Both used to send the same events and then each removed a batch from the + queue, so events were duplicated and, depending on timing, later events + could be removed without being sent. This showed up when a process started + with events saved by an earlier run and flushed soon after. ## 0.2.2 - 2026-09-30 diff --git a/tests/test_consumer.py b/tests/test_consumer.py index 3d61885..12846e0 100644 --- a/tests/test_consumer.py +++ b/tests/test_consumer.py @@ -1,6 +1,7 @@ """Tests for the background consumer.""" import os +import threading from unittest.mock import MagicMock, patch import pytest @@ -346,6 +347,48 @@ def fake_register(fn, *args, **kwargs): assert registered["fn"] == consumer.flush assert registered["args"] == (constants.DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SEC,) + def test_flush_does_not_resend_a_batch_already_in_flight(self, monkeypatch): + monkeypatch.setattr(Consumer, "run", lambda self: None) + queue = EventQueue(max_size=100) + for i in range(10): + queue.add({"event_id": str(i), "event_type": "inference", "model_id": "m"}) + sending = threading.Event() + release = threading.Event() + sent: list[str] = [] + + def slow_send(batch): + sent.extend(e["event_id"] for e in batch["events"]) + sending.set() + release.wait(5) + return make_success_response(len(batch["events"])) + + mock_transmitter = MagicMock(spec=Transmitter) + mock_transmitter.send.side_effect = slow_send + consumer = self._make_consumer(queue, mock_transmitter, batch_size=10) + + background = threading.Thread(target=consumer.drain_once) + background.start() + assert sending.wait(5) + flusher = threading.Thread(target=consumer.flush, kwargs={"timeout": 5}) + flusher.start() + release.set() + background.join(5) + flusher.join(5) + + assert sorted(sent, key=int) == [str(i) for i in range(10)] + assert queue.length() == 0 + + def test_drain_once_gives_up_when_lock_wait_times_out(self, monkeypatch): + monkeypatch.setattr(Consumer, "run", lambda self: None) + queue = EventQueue(max_size=100) + queue.add({"event_id": "e1", "event_type": "inference", "model_id": "m"}) + mock_transmitter = MagicMock(spec=Transmitter) + consumer = self._make_consumer(queue, mock_transmitter) + + with consumer.drain_lock: + assert consumer.drain_once(timeout=0.01) is False + mock_transmitter.send.assert_not_called() + class TestConsumerForkSafety: def setup_method(self): @@ -442,6 +485,32 @@ def test_resume_registers_atexit(self, monkeypatch): assert any(fn == consumer.flush for fn, _ in registered) + def test_pause_holds_drain_lock_until_resume(self, monkeypatch): + monkeypatch.setattr(Consumer, "run", lambda self: None) + consumer = self._make_consumer( + EventQueue(max_size=100), MagicMock(spec=Transmitter) + ) + + consumer._pause() + assert consumer.drain_lock.locked() + consumer._resume() + assert not consumer.drain_lock.locked() + + def test_resume_replaces_lock_held_by_in_flight_send(self, monkeypatch): + monkeypatch.setattr(Consumer, "run", lambda self: None) + consumer = self._make_consumer( + EventQueue(max_size=100), MagicMock(spec=Transmitter) + ) + stuck = consumer.drain_lock + # A send that outlived _pause() still holds the lock. + stuck.acquire() + consumer.fork_holds_drain_lock = False + consumer._resume() + + assert consumer.drain_lock is not stuck + assert not consumer.drain_lock.locked() + stuck.release() + class TestForkRegistration: @pytest.mark.skipif( diff --git a/wildedge/consumer.py b/wildedge/consumer.py index ef8c15d..44ed595 100644 --- a/wildedge/consumer.py +++ b/wildedge/consumer.py @@ -50,6 +50,10 @@ def __init__( self.stop_event = threading.Event() self.stopped = False + # One drain at a time: flush() on the caller's thread and the background + # thread would otherwise send the same batch and both remove it. + self.drain_lock = threading.Lock() + self.fork_holds_drain_lock = False self.backoff = constants.BACKOFF_MIN self.created_at = datetime.now(timezone.utc) @@ -133,7 +137,16 @@ def dead_letter_and_drop( self.queue.remove_first_n(len(events)) self.notify_delivery_failure(reason, len(events)) - def drain_once(self) -> bool: + def drain_once(self, timeout: float = -1) -> bool: + """Send one batch. Returns False if no progress was made.""" + if not self.drain_lock.acquire(timeout=timeout): + return False + try: + return self.drain_once_locked() + finally: + self.drain_lock.release() + + def drain_once_locked(self) -> bool: events = self.queue.peek_many(self.batch_size) if not events: return False @@ -218,7 +231,7 @@ def flush(self, timeout: float = 5.0) -> None: deadline = time.monotonic() + timeout backoff = constants.BACKOFF_MIN while self.queue.length() > 0 and time.monotonic() < deadline: - progressed = self.drain_once() + progressed = self.drain_once(timeout=max(0.0, deadline - time.monotonic())) if self.queue.length() == 0: break remaining = deadline - time.monotonic() @@ -243,6 +256,9 @@ def _pause(self) -> None: """ self.stop_event.set() self.thread.join(timeout=1.0) + # Hold the drain lock across fork() so the child never inherits it + # locked by a thread that does not exist there. + self.fork_holds_drain_lock = self.drain_lock.acquire(timeout=1.0) # Reset so _resume() can start a fresh thread in parent and child. self.stopped = False @@ -253,6 +269,12 @@ def _resume(self) -> None: Creates a new Event and Thread so parent and child each get an independent consumer. """ + if self.fork_holds_drain_lock: + self.drain_lock.release() + self.fork_holds_drain_lock = False + else: + # A send outlived _pause(). Start over rather than wait on it. + self.drain_lock = threading.Lock() self.stop_event = threading.Event() self.backoff = constants.BACKOFF_MIN self._held_snapshot = None From 0e24083f9d552162134e02d952b80704fdf6b72f Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:07:25 +0200 Subject: [PATCH 72/81] Add streaming and whisper docs to llms.txt --- scripts/build_llms_txt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/build_llms_txt.py b/scripts/build_llms_txt.py index 66f0cad..d4eaa3e 100644 --- a/scripts/build_llms_txt.py +++ b/scripts/build_llms_txt.py @@ -24,6 +24,8 @@ ("docs/manual-tracking.md", "manual-tracking", "Manual tracking"), ("docs/llm_api.md", "llm_api", "LLM API tracking"), ("docs/providers.md", "providers", "Providers"), + ("docs/streaming.md", "streaming", "Streaming lifecycle"), + ("docs/whisper.md", "whisper", "Whisper"), ("docs/compatibility.md", "compatibility", "Compatibility matrix"), ] From fd90cd05ea3e762a5a2b3f8be64f8420b035c6bd Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:14:34 +0200 Subject: [PATCH 73/81] Warn instead of raising on instrumentation ownership conflicts init(dsn=...) now reuses the default client when the DSN points to the same project. A second client enabling integrations while another live client owns them logs a warning and leaves automatic tracking with the owner, as 0.2.2 did silently. wildedge run --strict-integrations keeps the RuntimeError. --- CHANGELOG.md | 13 ++--- docs/deployment.md | 20 ++++--- docs/manual-tracking.md | 4 +- tests/conftest.py | 1 + tests/test_cli.py | 32 +++++++++++- tests/test_instrumentation_ownership.py | 69 ++++++++++++++++++++++++- wildedge/client.py | 38 ++++++++++++-- wildedge/convenience.py | 44 ++++++++++++---- wildedge/instrumentation.py | 17 +++++- wildedge/runtime/bootstrap.py | 5 ++ 10 files changed, 211 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f1b0f5..e2f671a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,12 +41,13 @@ under Unreleased and move into a version section at release time. chat messages and replies. `attachments_enabled=True` enables uploads only for attachments explicitly passed to `track_inference(attachments=[...])`. Apps relying on automatic GGUF capture must supply attachments explicitly. -- Enabling an integration or hub on a second live client now raises - `RuntimeError` while another client owns process-wide instrumentation. This - includes `wildedge.init(dsn=..., integrations=[...])` under `wildedge run`. - Previously the second client's patches were skipped without a warning and - automatic events kept going to the first client. Call `init()` without `dsn` - to reuse the client `wildedge run` installed, or close the owner first. +- `wildedge.init(dsn=...)` reuses the existing default client when the DSN + points to the same project, for example `dsn=os.environ["WILDEDGE_DSN"]` + under `wildedge run`. It used to create a second client. +- Enabling an integration or hub on a second client while another live client + owns process-wide instrumentation now logs a warning. Automatic events keep + going to the owner's project, as before, but this used to happen without a + word. With `wildedge run --strict-integrations` it raises `RuntimeError`. - Quantization labels are defined once in `wildedge.quantizations` and used by every integration. Behaviour changes: - MLX-LM reports N-bit weights as `int4`/`int8`, like Transformers, instead diff --git a/docs/deployment.md b/docs/deployment.md index 40829c6..d7f1a1f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,8 +16,9 @@ There are three ways a process gets its client, in order of coverage: All three produce the same thing: one process-wide default client that `wildedge.span()`, `wildedge.register_model()` and the rest delegate to. -`init()` without `dsn` reuses whatever client already exists, so code written -for in-process init runs unchanged under `wildedge run`. +`init()` without `dsn`, or with the DSN that client already uses, reuses +whatever client already exists, so code written for in-process init runs +unchanged under `wildedge run`. The lazy mode configures itself from the environment: `WILDEDGE_DSN`, `WILDEDGE_INTEGRATIONS`, `WILDEDGE_HUBS`. Unset integration variables mean @@ -99,12 +100,15 @@ them unwrapped or unset `WILDEDGE_DSN` there. ## One client per process - Framework patches are installed at most once per process. One live client - owns process-wide auto-instrumentation; another client attempting to enable - it raises `RuntimeError` until that owner is closed. Manual tracking does not - claim ownership. -- `init()` reuses the live default client unless you pass `dsn` - explicitly, so the CLI-installed client and application code share one - client instead of racing. + owns process-wide auto-instrumentation. When another client enables an + integration or hub while that owner is live, it logs a warning and + automatic events keep going to the owner's project. The new client still + sends its manual tracking. With `wildedge run --strict-integrations`, the + conflict raises `RuntimeError` instead. Manual tracking does not claim + ownership. +- `init()` reuses the live default client unless you pass a `dsn` for a + different project, so the CLI-installed client and application code share + one client instead of racing. - Trace and span correlation lives in contextvars at module level, not on a client. Auto-instrumented events emitted inside `wildedge.trace(...)` / `wildedge.span(...)` blocks correlate into the same trace no matter which diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index cd185a8..0bcc769 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -26,8 +26,8 @@ with wildedge.trace(run_id="run-123"): The default client comes from wherever initialization happened first: - `wildedge run` installs one before your code loads. A later - `wildedge.init()` without `dsn` reuses it, so the same code works with or - without the CLI wrapper. + `wildedge.init()` without `dsn`, or with the same DSN, reuses it, so the + same code works with or without the CLI wrapper. - `wildedge.init(...)` creates one (or reuses the existing default) and registers it. - Any module-level call (`wildedge.span`, `wildedge.register_model`, ...) diff --git a/tests/conftest.py b/tests/conftest.py index e9f04ae..8b011f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,4 +131,5 @@ def client_with_stubbed_runtime(): def isolate_instrumentation_owner(monkeypatch): monkeypatch.setattr(instrumentation, "_owner", None) monkeypatch.setattr(instrumentation, "_enabled", set()) + monkeypatch.setattr(instrumentation, "_strict_ownership", False) monkeypatch.setattr(huggingface, "_client_route", None) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9162a56..88e12bf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ import pytest -from wildedge import cli, constants +from wildedge import cli, constants, instrumentation from wildedge.defaults import peek_default_client from wildedge.integrations.registry import IntegrationSpec from wildedge.platforms.device_info import DeviceInfo @@ -237,6 +237,36 @@ def instrument(self, name): # type: ignore[no-untyped-def] bootstrap.install_runtime() +@pytest.mark.parametrize("strict", ["0", "1"]) +def test_install_runtime_records_strict_ownership(monkeypatch, strict): + class FakeWildEdge: + SUPPORTED_INTEGRATIONS = {"onnx"} + + def __init__(self, *, dsn, app_version, debug, sampling_interval_s=None): # type: ignore[no-untyped-def] + pass + + def instrument(self, name): # type: ignore[no-untyped-def] + pass + + def flush(self, timeout): # type: ignore[no-untyped-def] + pass + + def close(self): # type: ignore[no-untyped-def] + pass + + monkeypatch.setattr(bootstrap, "WildEdge", FakeWildEdge) + monkeypatch.setenv(constants.ENV_DSN, "https://secret@ingest.wildedge.dev/key") + monkeypatch.setenv(constants.ENV_INTEGRATIONS, "onnx") + monkeypatch.setenv(constants.ENV_STRICT_INTEGRATIONS, strict) + monkeypatch.setattr(bootstrap.importlib.util, "find_spec", lambda _: object()) + + context = bootstrap.install_runtime() + try: + assert instrumentation.strict_ownership() is (strict == "1") + finally: + context.shutdown() + + def test_doctor_reports_missing_dsn_and_unknown_integration(monkeypatch, capsys): monkeypatch.delenv("WILDEDGE_DSN", raising=False) rc = cli.main(["doctor", "--integrations", "madeup"]) diff --git a/tests/test_instrumentation_ownership.py b/tests/test_instrumentation_ownership.py index 2ac27dc..45f3ad1 100644 --- a/tests/test_instrumentation_ownership.py +++ b/tests/test_instrumentation_ownership.py @@ -140,9 +140,32 @@ def test_close_is_idempotent(clients): client.consumer.close.assert_called_once() -def test_init_conflict_cleans_up_new_client_without_stopping_owner_sampler( +def test_init_with_other_dsn_warns_and_leaves_tracking_with_owner( + clients, monkeypatch, caplog +): + caplog.set_level("WARNING", logger="wildedge") + first = clients() + route = claim_instrumentation(first, "onnx") + set_default_client(first) + second = clients() + monkeypatch.setattr(convenience, "WildEdge", lambda **kwargs: second) + with patch("wildedge.client.stop_sampler") as stop: + result = wildedge.init( + dsn="https://other@localhost/other", integrations=["onnx"] + ) + stop.assert_not_called() + assert result is second + assert not second.closed + assert not first.closed + assert route() is first + assert convenience.peek_default_client() is second + assert [r for r in caplog.records if "already owns automatic tracking" in r.message] + + +def test_strict_ownership_conflict_raises_and_cleans_up_new_client( clients, monkeypatch ): + instrumentation.set_strict_ownership(True) first = clients() claim_instrumentation(first, "onnx") set_default_client(first) @@ -157,6 +180,50 @@ def test_init_conflict_cleans_up_new_client_without_stopping_owner_sampler( assert convenience.peek_default_client() is first +def test_init_with_owner_dsn_reuses_owner(clients, monkeypatch, caplog): + caplog.set_level("WARNING", logger="wildedge") + first = clients() + first.instrument("onnx") + set_default_client(first) + monkeypatch.setattr( + convenience, "WildEdge", MagicMock(side_effect=AssertionError("new client")) + ) + assert ( + wildedge.init(dsn="https://test@localhost/test", integrations=["onnx"]) is first + ) + assert not [r for r in caplog.records if "ignoring" in r.message] + + +def test_init_with_dsn_none_reuses_owner_of_env_dsn(clients, monkeypatch): + first = clients() + set_default_client(first) + monkeypatch.setenv("WILDEDGE_DSN", "https://test@localhost/test") + monkeypatch.setattr( + convenience, "WildEdge", MagicMock(side_effect=AssertionError("new client")) + ) + assert wildedge.init(dsn=None) is first + + +def test_hub_conflict_warns_and_skips(clients, monkeypatch, caplog): + caplog.set_level("WARNING", logger="wildedge") + installs = [] + + class Tracker: + def can_install(self): + return True + + def install_patch(self, route): + installs.append(route) + + monkeypatch.setitem(WildEdge.HUB_TRACKER_CLASSES, "huggingface", Tracker) + first, second = clients(), clients() + first.instrument(None, hubs=["huggingface"]) + second.instrument(None, hubs=["huggingface"]) + assert len(installs) == 1 + assert "huggingface" not in second.hub_trackers + assert [r for r in caplog.records if "already owns automatic tracking" in r.message] + + @pytest.mark.parametrize("hub", ["huggingface", "torchhub"]) def test_hub_factory_route_follows_replacement(clients, monkeypatch, hub): routes = [] diff --git a/wildedge/client.py b/wildedge/client.py index c0a0f8d..83862ee 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -23,7 +23,13 @@ from wildedge.events.span import SpanKind, SpanStatus from wildedge.hubs.base import BaseHubTracker from wildedge.hubs.registry import HUB_SPECS, supported_hubs -from wildedge.instrumentation import claim_instrumentation, has_other_live_owner +from wildedge.instrumentation import ( + ClientRoute, + InstrumentationOwnedError, + claim_instrumentation, + has_other_live_owner, + strict_ownership, +) from wildedge.integrations.base import BaseExtractor from wildedge.integrations.registry import ( INTEGRATION_SPECS, @@ -280,10 +286,14 @@ def __init__( dsn = env.dsn debug = env.debug self.noop = False + # (secret, host, project_key), so init(dsn=...) can tell when a + # requested destination matches this client. None for a no-op client. + self.destination: tuple[str, str, str] | None = None if not dsn: self._init_noop(debug=debug, device=device) return api_key, host, project_key = parse_dsn(dsn) + self.destination = (api_key, host, project_key) app_identity = resolve_app_identity( explicit=env.app_identity, project_key=project_key, @@ -689,11 +699,31 @@ def _activate_hub(self, hub_name: str) -> None: "wildedge: hub '%s' library not installed; skipping", hub_name ) return - tracker.install_patch(claim_instrumentation(self, "hub:" + hub_name)) + route = self._claim_instrumentation("hub:" + hub_name) + if route is None: + return + tracker.install_patch(route) self.hub_trackers[hub_name] = tracker if self.debug: logger.debug("wildedge: hub tracker '%s' activated", hub_name) + def _claim_instrumentation(self, name: str) -> ClientRoute | None: + """Claim process-wide instrumentation, or warn and skip if owned.""" + try: + return claim_instrumentation(self, name) + except InstrumentationOwnedError: + if strict_ownership(): + raise + logger.warning( + "wildedge: another WildEdge client (for example the one " + "`wildedge run` installed) already owns automatic tracking, so " + "'%s' events keep going to that client's project. Call " + "wildedge.init() without dsn to reuse that client, or close it " + "before enabling integrations on this one.", + name, + ) + return None + def _snapshot_hub_caches(self) -> dict[str, dict[str, int]]: """Return a before-snapshot of all active hub cache directories.""" return { @@ -846,7 +876,9 @@ def instrument( logger.debug(LOG_INSTRUMENT_TORCH_KERAS, integration) else: installer = self.PATCH_INSTALLERS[integration] - installer(claim_instrumentation(self, integration)) + route = self._claim_instrumentation(integration) + if route is not None: + installer(route) for hub_name in hubs or []: self._activate_hub(hub_name) diff --git a/wildedge/convenience.py b/wildedge/convenience.py index 4086d8d..1f15de9 100644 --- a/wildedge/convenience.py +++ b/wildedge/convenience.py @@ -3,9 +3,10 @@ from collections.abc import Iterable from typing import Any -from wildedge.client import WildEdge +from wildedge.client import WildEdge, parse_dsn from wildedge.defaults import peek_default_client, set_default_client from wildedge.logging import logger +from wildedge.settings import read_client_env def _normalize_list(value: str | Iterable[str] | None) -> list[str]: @@ -16,6 +17,20 @@ def _normalize_list(value: str | Iterable[str] | None) -> list[str]: return [item for item in value if item] +def _same_destination(client: WildEdge, dsn: str | None) -> bool: + """True when ``dsn`` (or WILDEDGE_DSN) points where ``client`` sends.""" + destination = getattr(client, "destination", None) + if destination is None: + return False + resolved = read_client_env(dsn=dsn).dsn + if not resolved: + return False + try: + return parse_dsn(resolved) == destination + except ValueError: + return False + + def init( *, integrations: str | Iterable[str] | None = None, @@ -26,23 +41,32 @@ def init( Initialize the process default client and instrument integrations. When a live default client already exists (typically installed by ``wildedge - run`` before user code loaded) and no ``dsn`` is passed, that client is - reused: the requested integrations are applied to it (instrumenting is - idempotent per process) and it is returned. Passing ``dsn`` always - constructs a fresh client and makes it the new default after instrumentation - succeeds. A closed default is replaced. Another live instrumentation owner - must be closed before enabling process-wide patches on a new client. + run`` before user code loaded) and no ``dsn`` is passed, or ``dsn`` points + to the same project, that client is reused: the requested integrations are + applied to it (instrumenting is idempotent per process) and it is returned. + A ``dsn`` for another project constructs a fresh client and makes it the new + default. A closed default is replaced. While another live client owns + process-wide instrumentation, the new client logs a warning and leaves + automatic tracking with that owner. With ``wildedge run + --strict-integrations`` it raises ``RuntimeError`` instead. Additional keyword arguments are forwarded to WildEdge(...) when a new client is constructed. """ - client = None if "dsn" in kwargs else peek_default_client() + client = peek_default_client() if client is not None and client.closed: client = None - if client is not None and kwargs: + if ( + client is not None + and "dsn" in kwargs + and not _same_destination(client, kwargs["dsn"]) + ): + client = None + ignored = sorted(key for key in kwargs if key != "dsn") + if client is not None and ignored: logger.warning( "wildedge: init() reusing the existing default client; ignoring %s", - ", ".join(sorted(kwargs)), + ", ".join(ignored), ) created = client is None if created: diff --git a/wildedge/instrumentation.py b/wildedge/instrumentation.py index 9b988b2..25dbdcb 100644 --- a/wildedge/instrumentation.py +++ b/wildedge/instrumentation.py @@ -15,6 +15,21 @@ _lock = threading.Lock() _owner: weakref.ReferenceType[WildEdge] | None = None _enabled: set[str] = set() +_strict_ownership = False + + +class InstrumentationOwnedError(RuntimeError): + """Another live client already owns process-wide instrumentation.""" + + +def set_strict_ownership(strict: bool) -> None: + """Raise on ownership conflicts instead of warning (`--strict-integrations`).""" + global _strict_ownership + _strict_ownership = strict + + +def strict_ownership() -> bool: + return _strict_ownership class ClientRoute: @@ -67,7 +82,7 @@ def claim_instrumentation(client: WildEdge, integration: str) -> ClientRoute: raise RuntimeError("Cannot instrument with a closed WildEdge client") owner = _owner() if _owner is not None else None if owner is not None and not owner.closed and owner is not client: - raise RuntimeError( + raise InstrumentationOwnedError( "Auto-instrumentation is owned by another live WildEdge client; " "close that client before enabling instrumentation on its replacement" ) diff --git a/wildedge/runtime/bootstrap.py b/wildedge/runtime/bootstrap.py index 63e0ee4..308b9b6 100644 --- a/wildedge/runtime/bootstrap.py +++ b/wildedge/runtime/bootstrap.py @@ -16,6 +16,7 @@ from wildedge.constants import ENV_DSN, WILDEDGE_AUTOLOAD from wildedge.defaults import set_default_client from wildedge.hubs.registry import HUBS_BY_NAME, supported_hubs +from wildedge.instrumentation import set_strict_ownership from wildedge.integrations.registry import INTEGRATIONS_BY_NAME, supported_integrations from wildedge.settings import read_runtime_env @@ -208,6 +209,10 @@ def install_runtime(*, install_signal_handlers: bool = True) -> RuntimeContext: file=os.sys.stderr, ) + # The app may call init() with its own DSN later. Under + # --strict-integrations an ownership conflict then raises instead of warning. + set_strict_ownership(env.strict_integrations) + if env.strict_integrations: failures = [row for row in statuses if row["status"] in STRICT_FAILURE_STATUSES] if failures: From d45c19a12d6aa21b6afe0d6896fed241ba60a7be Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:15:09 +0200 Subject: [PATCH 74/81] Release 0.3.0 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f671a..7963027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ under Unreleased and move into a version section at release time. ## Unreleased +## 0.3.0 - 2026-10-21 + ### Added - `mlx_whisper` and `faster_whisper` integrations track local Whisper model diff --git a/pyproject.toml b/pyproject.toml index 71f5656..7127eec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wildedge-sdk" -version = "0.2.2" +version = "0.3.0" description = "ML inference monitoring for Python: on-device and remote models" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 8ff0ac1..a33da49 100644 --- a/uv.lock +++ b/uv.lock @@ -419,7 +419,7 @@ wheels = [ [[package]] name = "wildedge-sdk" -version = "0.2.2" +version = "0.3.0" source = { editable = "." } [package.dev-dependencies] From 8adbacada46d0805e38310062c665544953f3fc3 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:25:48 +0200 Subject: [PATCH 75/81] Scan module dicts when patching Hugging Face download functions getattr on transformers' lazy modules imported unused model modules and logged a debug line for each one that failed to import. --- CHANGELOG.md | 9 +++++++++ tests/test_hubs.py | 33 +++++++++++++++++++++++++++++++++ wildedge/hubs/huggingface.py | 11 ++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7963027..ae89cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ under Unreleased and move into a version section at release time. ## Unreleased +### Fixed + +- Enabling the `huggingface` hub no longer imports unused transformers model + modules. To find references to `hf_hub_download` and `snapshot_download`, + the hub tracker looked up the name on every loaded module, and transformers' + lazy modules import code on lookup. With transformers loaded this imported + about 140 extra modules, took about 0.15 s and logged about 430 "failed to + patch" debug lines. The tracker now reads each module's namespace instead. + ## 0.3.0 - 2026-10-21 ### Added diff --git a/tests/test_hubs.py b/tests/test_hubs.py index fff52d9..c1c4c37 100644 --- a/tests/test_hubs.py +++ b/tests/test_hubs.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import sys import types from unittest.mock import patch @@ -188,3 +189,35 @@ def test_hf_tracker_drain_returns_and_clears_buffer(monkeypatch): assert result[0]["repo_id"] == "test/model" # Buffer should be cleared assert tracker.drain() == [] + + +class _LazyModule(types.ModuleType): + """Imports on attribute access, like transformers' lazy modules.""" + + def __init__(self, name: str) -> None: + super().__init__(name) + self.lookups: list[str] = [] + + def __getattr__(self, name: str): + self.lookups.append(name) + raise ModuleNotFoundError("No module named 'torchvision'") + + +def test_patch_symbol_references_skips_lazy_attribute_lookup(monkeypatch): + def original(): + pass + + def replacement(): + pass + + eager = types.ModuleType("eager_mod") + eager.hf_hub_download = original + lazy = _LazyModule("lazy_mod") + monkeypatch.setitem(sys.modules, "eager_mod", eager) + monkeypatch.setitem(sys.modules, "lazy_mod", lazy) + + count = hf_mod._patch_symbol_references("hf_hub_download", original, replacement) + + assert count >= 1 + assert eager.hf_hub_download is replacement + assert lazy.lookups == [] diff --git a/wildedge/hubs/huggingface.py b/wildedge/hubs/huggingface.py index 9e72b37..d531ecc 100644 --- a/wildedge/hubs/huggingface.py +++ b/wildedge/hubs/huggingface.py @@ -72,7 +72,16 @@ def _patch_symbol_references(symbol: str, original: object, replacement: object) patched_count = 0 for mod in list(sys.modules.values()): try: - if getattr(mod, symbol, None) is original: + # Read the module dict, not getattr: lazy modules (transformers) + # import submodules on attribute access. A name a lazy module has + # not loaded yet cannot hold a reference to the original. The lazy + # huggingface_hub package resolves names from its submodules on + # each access, so patching those covers `from huggingface_hub import`. + namespace = vars(mod) + except TypeError: + continue + try: + if namespace.get(symbol) is original: setattr(mod, symbol, replacement) patched_count += 1 except Exception as exc: From 6c3a54e154d76191a267652ba74fb566d4b85d35 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Wed, 21 Oct 2026 19:40:48 +0200 Subject: [PATCH 76/81] Move the Hugging Face hub fix into the 0.3.0 changelog --- CHANGELOG.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae89cc9..9dd9397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,6 @@ under Unreleased and move into a version section at release time. ## Unreleased -### Fixed - -- Enabling the `huggingface` hub no longer imports unused transformers model - modules. To find references to `hf_hub_download` and `snapshot_download`, - the hub tracker looked up the name on every loaded module, and transformers' - lazy modules import code on lookup. With transformers loaded this imported - about 140 extra modules, took about 0.15 s and logged about 430 "failed to - patch" debug lines. The tracker now reads each module's namespace instead. - ## 0.3.0 - 2026-10-21 ### Added @@ -142,6 +133,12 @@ under Unreleased and move into a version section at release time. queue, so events were duplicated and, depending on timing, later events could be removed without being sent. This showed up when a process started with events saved by an earlier run and flushed soon after. +- Enabling the `huggingface` hub no longer imports unused transformers model + modules. To find references to `hf_hub_download` and `snapshot_download`, + the hub tracker looked up the name on every loaded module, and transformers' + lazy modules import code on lookup. With transformers loaded this imported + about 140 extra modules, took about 0.15 s and logged about 430 "failed to + patch" debug lines. The tracker now reads each module's namespace instead. ## 0.2.2 - 2026-09-30 From 1c14a3ca077b62689f843d9a871c842a4332854b Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Thu, 22 Oct 2026 15:41:04 +0200 Subject: [PATCH 77/81] Cap error_message and provider_error_code for manual track_error calls The integrations cut error messages to ERROR_MSG_MAX_LEN and provider codes to PROVIDER_ERROR_CODE_MAX_LEN before calling track_error(). Manual calls sent the full text. ErrorEvent now applies both limits when serializing, so every error event gets them. --- CHANGELOG.md | 4 ++++ tests/test_event_serialization.py | 12 ++++++++++++ wildedge/events/error.py | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd9397..f1e727b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,10 @@ under Unreleased and move into a version section at release time. ### Fixed +- `track_error()` now caps `error_message` at 200 characters and + `provider_error_code` at 64, the limits the integrations already used. + Manual calls sent the full text, so a long exception message could make + large error events. - The llama.cpp integration now tracks `Llama.create_chat_completion()` and `Llama.create_completion()`, streaming or not. It used to patch only `Llama.__call__`, so chat apps on llama-cpp-python reported model loads but diff --git a/tests/test_event_serialization.py b/tests/test_event_serialization.py index 0b0d0aa..74e6a95 100644 --- a/tests/test_event_serialization.py +++ b/tests/test_event_serialization.py @@ -1,5 +1,6 @@ from __future__ import annotations +from wildedge import constants from wildedge.events import common from wildedge.events.error import ErrorCode, ErrorEvent from wildedge.events.feedback import FeedbackEvent, FeedbackType @@ -77,6 +78,17 @@ def test_feedback_event_enum_and_string_forms(): assert string_event.to_dict()["feedback"]["feedback_type"] == "reject" +def test_error_event_caps_message_and_provider_code(): + event = ErrorEvent( + model_id="m1", + error_code=ErrorCode.UNKNOWN, + error_message="x" * 5000, + provider_error_code="c" * 500, + ).to_dict()["error"] + assert len(event["error_message"]) == constants.ERROR_MSG_MAX_LEN + assert len(event["provider_error_code"]) == constants.PROVIDER_ERROR_CODE_MAX_LEN + + def test_span_event_to_dict_includes_required_fields(): event = SpanEvent( kind="tool", diff --git a/wildedge/events/error.py b/wildedge/events/error.py index c5272c8..4ba5dd3 100644 --- a/wildedge/events/error.py +++ b/wildedge/events/error.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Any +from wildedge import constants from wildedge.events.common import add_optional_fields @@ -19,6 +20,10 @@ class ErrorCode(str, Enum): UNKNOWN = "UNKNOWN" +def truncate(value: str | None, limit: int) -> str | None: + return None if value is None else str(value)[:limit] + + @dataclass class ErrorEvent: model_id: str @@ -49,11 +54,17 @@ def to_dict(self) -> dict: error_data = add_optional_fields( {"error_code": code}, { - "error_message": self.error_message, + # Capped here so manual track_error() calls get the same + # limits as the integrations. + "error_message": truncate( + self.error_message, constants.ERROR_MSG_MAX_LEN + ), "stack_trace_hash": self.stack_trace_hash, "related_event_id": self.related_event_id, "http_status": self.http_status, - "provider_error_code": self.provider_error_code, + "provider_error_code": truncate( + self.provider_error_code, constants.PROVIDER_ERROR_CODE_MAX_LEN + ), "duration_ms": self.duration_ms, }, ) From 451ef866795443daa70ae5d33d3b9c9c8c708cf9 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Thu, 22 Oct 2026 15:57:52 +0200 Subject: [PATCH 78/81] Keep the start and end of long error messages, up to 500 characters Measured across 37 typical failures, 9 messages were over 200 characters. Several put their cause at the end. mlx-whisper audio errors start with the ffmpeg banner, and torch state_dict errors end with the shapes. The integrations kept only the first 200 characters. ErrorEvent now keeps the start and the end of a message over 500 characters, with an ellipsis between. The integrations and decorators no longer cut the message themselves, so the limit is applied once. --- CHANGELOG.md | 10 ++++++---- tests/test_event_serialization.py | 16 ++++++++++++++++ wildedge/constants.py | 2 +- wildedge/decorators.py | 7 ++----- wildedge/events/error.py | 25 ++++++++++++++++++++++--- wildedge/integrations/common.py | 2 +- wildedge/integrations/gguf.py | 4 ++-- wildedge/integrations/keras.py | 4 ++-- wildedge/integrations/mlx.py | 6 +++--- wildedge/integrations/onnx.py | 4 ++-- wildedge/integrations/tensorflow.py | 6 +++--- wildedge/integrations/transformers.py | 4 ++-- wildedge/integrations/ultralytics.py | 4 ++-- 13 files changed, 64 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e727b..fdbfe59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,10 +67,12 @@ under Unreleased and move into a version section at release time. ### Fixed -- `track_error()` now caps `error_message` at 200 characters and - `provider_error_code` at 64, the limits the integrations already used. - Manual calls sent the full text, so a long exception message could make - large error events. +- Every error event now caps `error_message` at 500 characters, keeping the + start and the end of a longer message. Integrations used to cut it to the + first 200, which lost the cause in messages that end with it, such as + mlx-whisper audio errors that start with the ffmpeg banner. Manual + `track_error()` calls had no limit at all. `provider_error_code` is capped + at 64 characters for every event. - The llama.cpp integration now tracks `Llama.create_chat_completion()` and `Llama.create_completion()`, streaming or not. It used to patch only `Llama.__call__`, so chat apps on llama-cpp-python reported model loads but diff --git a/tests/test_event_serialization.py b/tests/test_event_serialization.py index 74e6a95..d6850e3 100644 --- a/tests/test_event_serialization.py +++ b/tests/test_event_serialization.py @@ -89,6 +89,22 @@ def test_error_event_caps_message_and_provider_code(): assert len(event["provider_error_code"]) == constants.PROVIDER_ERROR_CODE_MAX_LEN +def test_long_error_message_keeps_start_and_end(): + message = "ffmpeg banner " * 100 + "Error opening input file audio.wav" + sent = ErrorEvent( + model_id="m1", error_code=ErrorCode.UNKNOWN, error_message=message + ).to_dict()["error"]["error_message"] + assert len(sent) == constants.ERROR_MSG_MAX_LEN + assert sent.startswith("ffmpeg banner") + assert sent.endswith("Error opening input file audio.wav") + assert " … " in sent + + +def test_short_error_message_is_unchanged(): + event = ErrorEvent(model_id="m1", error_code="UNKNOWN", error_message="boom") + assert event.to_dict()["error"]["error_message"] == "boom" + + def test_span_event_to_dict_includes_required_fields(): event = SpanEvent( kind="tool", diff --git a/wildedge/constants.py b/wildedge/constants.py index 22ef327..acd0fcc 100644 --- a/wildedge/constants.py +++ b/wildedge/constants.py @@ -53,7 +53,7 @@ IDLE_POLL_INTERVAL = 5.0 # Error message truncation -ERROR_MSG_MAX_LEN = 200 +ERROR_MSG_MAX_LEN = 500 PROVIDER_ERROR_CODE_MAX_LEN = 64 # Device ID persistence diff --git a/wildedge/decorators.py b/wildedge/decorators.py index 86cbd65..23e90b2 100644 --- a/wildedge/decorators.py +++ b/wildedge/decorators.py @@ -4,7 +4,6 @@ import time from typing import TYPE_CHECKING, Any -from wildedge import constants from wildedge.timing import elapsed_ms if TYPE_CHECKING: @@ -87,7 +86,7 @@ def wrapper(*args, **kwargs): if self.capture_errors: self.handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), **self._correlation, ) raise @@ -106,9 +105,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self.capture_errors: self.handle.track_error( error_code="UNKNOWN", - error_message=str(exc_val)[: constants.ERROR_MSG_MAX_LEN] - if exc_val - else None, + error_message=str(exc_val) if exc_val else None, **self._correlation, ) else: diff --git a/wildedge/events/error.py b/wildedge/events/error.py index 4ba5dd3..a3d6bd2 100644 --- a/wildedge/events/error.py +++ b/wildedge/events/error.py @@ -20,10 +20,29 @@ class ErrorCode(str, Enum): UNKNOWN = "UNKNOWN" +ELLIPSIS = " … " + + def truncate(value: str | None, limit: int) -> str | None: return None if value is None else str(value)[:limit] +def truncate_message(value: str | None, limit: int) -> str | None: + """Keep the start and the end of a long message. + + Errors put their detail at either end. mlx-whisper prints the ffmpeg banner + first and the failure last, and Hugging Face Hub starts with a request id. + """ + if value is None: + return None + value = str(value) + if len(value) <= limit: + return value + keep = limit - len(ELLIPSIS) + head = keep - keep // 2 + return value[:head] + ELLIPSIS + value[len(value) - keep // 2 :] + + @dataclass class ErrorEvent: model_id: str @@ -54,9 +73,9 @@ def to_dict(self) -> dict: error_data = add_optional_fields( {"error_code": code}, { - # Capped here so manual track_error() calls get the same - # limits as the integrations. - "error_message": truncate( + # Capped here, so every error event gets the same limit, + # from an integration or a manual track_error() call. + "error_message": truncate_message( self.error_message, constants.ERROR_MSG_MAX_LEN ), "stack_trace_hash": self.stack_trace_hash, diff --git a/wildedge/integrations/common.py b/wildedge/integrations/common.py index f51d8ec..86ffb01 100644 --- a/wildedge/integrations/common.py +++ b/wildedge/integrations/common.py @@ -196,7 +196,7 @@ def track_api_error( error_code = no_response_error_code(exc) or fallback_code handle.track_error( error_code=error_code, - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), http_status=http_status, provider_error_code=provider_error_code, duration_ms=duration_ms, diff --git a/wildedge/integrations/gguf.py b/wildedge/integrations/gguf.py index 2f54d6d..7e4ead8 100644 --- a/wildedge/integrations/gguf.py +++ b/wildedge/integrations/gguf.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import accelerators, constants, quantizations +from wildedge import accelerators, quantizations from wildedge.events.inference import ( GenerationConfig, GenerationOutputMeta, @@ -333,7 +333,7 @@ def patched_call(self_inner, *args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/keras.py b/wildedge/integrations/keras.py index f54c8a0..f1f101b 100644 --- a/wildedge/integrations/keras.py +++ b/wildedge/integrations/keras.py @@ -11,7 +11,7 @@ except ImportError: _np = None # type: ignore[assignment] -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ClassificationOutputMeta, TopKPrediction from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( @@ -99,7 +99,7 @@ def patched_call(self_inner, *args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/mlx.py b/wildedge/integrations/mlx.py index 2284329..b78a5e3 100644 --- a/wildedge/integrations/mlx.py +++ b/wildedge/integrations/mlx.py @@ -8,7 +8,7 @@ import time from typing import TYPE_CHECKING -from wildedge import constants, quantizations +from wildedge import quantizations from wildedge.events.inference import GenerationOutputMeta, TextInputMeta from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( @@ -116,7 +116,7 @@ def patched_call(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise @@ -273,7 +273,7 @@ def patched_generate(model, tokenizer, prompt, *args, **kwargs): # type: ignore if handle is not None: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise finally: diff --git a/wildedge/integrations/onnx.py b/wildedge/integrations/onnx.py index 1777709..894aaee 100644 --- a/wildedge/integrations/onnx.py +++ b/wildedge/integrations/onnx.py @@ -8,7 +8,7 @@ import time from typing import TYPE_CHECKING, Any -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ( ClassificationOutputMeta, HistogramSummary, @@ -330,7 +330,7 @@ def patched_run(output_names, input_feed, run_options=None): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/tensorflow.py b/wildedge/integrations/tensorflow.py index c5ed639..114ba4a 100644 --- a/wildedge/integrations/tensorflow.py +++ b/wildedge/integrations/tensorflow.py @@ -13,7 +13,7 @@ except ImportError: _np = None # type: ignore[assignment] -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ClassificationOutputMeta, TopKPrediction from wildedge.integrations.base import BaseExtractor, patch_instance_call_once from wildedge.integrations.common import ( @@ -117,7 +117,7 @@ def patched_call(self_inner, *args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise @@ -161,7 +161,7 @@ def patched_predict(*args, **kwargs): except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise diff --git a/wildedge/integrations/transformers.py b/wildedge/integrations/transformers.py index b448978..bdf4a27 100644 --- a/wildedge/integrations/transformers.py +++ b/wildedge/integrations/transformers.py @@ -9,7 +9,7 @@ from collections import Counter from typing import TYPE_CHECKING -from wildedge import accelerators, constants, quantizations +from wildedge import accelerators, quantizations from wildedge.events.inference import ( ClassificationOutputMeta, EmbeddingOutputMeta, @@ -463,7 +463,7 @@ def patched_call(self, inputs, *args, **kwargs): # type: ignore[no-untyped-def] except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise finally: diff --git a/wildedge/integrations/ultralytics.py b/wildedge/integrations/ultralytics.py index 2f5ed8b..397a26e 100644 --- a/wildedge/integrations/ultralytics.py +++ b/wildedge/integrations/ultralytics.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from wildedge import accelerators, constants +from wildedge import accelerators from wildedge.events.inference import ( ClassificationOutputMeta, DetectionOutputMeta, @@ -323,7 +323,7 @@ def patched_call(self_inner, *args, **kwargs): # type: ignore[no-untyped-def] except Exception as exc: handle.track_error( error_code="UNKNOWN", - error_message=str(exc)[: constants.ERROR_MSG_MAX_LEN], + error_message=str(exc), ) raise From db8d8593a8237f0278ca9ba4f3c95fd03060c59b Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Thu, 22 Oct 2026 17:07:18 +0200 Subject: [PATCH 79/81] Build release notes from the version's CHANGELOG.md section The notes listed every commit subject since the previous tag, including merge commits and release housekeeping such as changelog moves. They are now the curated CHANGELOG.md section for the version. A missing or empty section fails the build instead of publishing empty notes. --- CONTRIBUTING.md | 2 +- scripts/build_changelog_comment.py | 42 +++++++----- tests/test_build_changelog_comment.py | 99 +++++++++++++++++++-------- 3 files changed, 94 insertions(+), 49 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bd3aac..f01f4fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ Releases follow a `release/` branching flow: 1. Cut a `release/` branch from `devel`. -2. Bump the version in `pyproject.toml`. +2. Bump the version in `pyproject.toml` and move the Unreleased entries in `CHANGELOG.md` into a `## - ` section. The release notes are that section, so the release fails without it. 3. Open a pull request targeting `main`. CI will automatically run the full compatibility matrix and post a changelog preview comment. 4. Once merged, tag the commit as `v`. The release workflow publishes to PyPI and creates a GitHub release. diff --git a/scripts/build_changelog_comment.py b/scripts/build_changelog_comment.py index f05bdc6..e88bec5 100644 --- a/scripts/build_changelog_comment.py +++ b/scripts/build_changelog_comment.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 """Generate changelog text for release PRs and for tag-build release notes. +The notes are the version's section of CHANGELOG.md, so they list what changed +for users rather than every commit and merge since the previous tag. + The two callers differ only in wording: release-pr.yml renders a preview of notes that do not exist yet, while release.yml (which sets TAG_NAME) renders the notes themselves. @@ -13,6 +16,8 @@ import subprocess from pathlib import Path +CHANGELOG = Path("CHANGELOG.md") + def get_version() -> str: # Regex instead of tomllib so the script also runs on Python 3.10. @@ -24,9 +29,6 @@ def get_version() -> str: return match.group(1) -RELEASE_COMMIT_RE = re.compile(r"^Release \d+\.\d+\.\d+$") - - def get_previous_tag() -> str | None: result = subprocess.run( ["git", "describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^"], @@ -37,26 +39,28 @@ def get_previous_tag() -> str | None: return tag if result.returncode == 0 and tag else None -def get_commits_since(prev_tag: str | None) -> list[str]: - revision = f"{prev_tag}..HEAD" if prev_tag else "HEAD" - result = subprocess.run( - ["git", "log", revision, "--pretty=format:%s"], - capture_output=True, - text=True, - check=True, - ) - lines = [line for line in result.stdout.splitlines() if line.strip()] - return [line for line in lines if not RELEASE_COMMIT_RE.match(line)] +def changelog_section(text: str, version: str) -> str: + """Body of the ``## `` section, up to the next ``## `` heading.""" + heading = re.compile(rf"^## \[?{re.escape(version)}\]?(?:\s|$)", re.MULTILINE) + match = heading.search(text) + if match is None: + raise SystemExit(f"CHANGELOG.md has no section for {version}") + line_end = text.find("\n", match.start()) + start = len(text) if line_end == -1 else line_end + 1 + end = re.compile(r"^## ", re.MULTILINE).search(text, start) + body = text[start : end.start() if end else len(text)].strip() + if not body: + raise SystemExit(f"CHANGELOG.md section for {version} is empty") + return body def build_comment( tag_name: str, - commits: list[str], + notes: str, prev_tag: str | None, *, preview: bool = True, ) -> str: - items = "\n".join(f"- {c}" for c in commits) if commits else "- No changes." range_str = f"{prev_tag}...{tag_name}" if prev_tag else tag_name heading = "Changelog preview for" if preview else "Changelog for" blurb = ( @@ -67,22 +71,22 @@ def build_comment( return ( f"## {heading} `{tag_name}`\n\n" f"{blurb}" - f"**What's Changed**\n\n{items}\n\n" + f"{notes}\n\n" f"**Full Changelog**: {range_str}\n" ) def main() -> None: output = os.environ.get("OUTPUT", "/tmp/changelog-preview.md") - # release.yml sets TAG_NAME on tag builds; release-pr.yml never does, so its + # release.yml sets TAG_NAME on tag builds and release-pr.yml never does. Its # absence is what distinguishes a PR preview from the real release notes. tag_name = os.environ.get("TAG_NAME") preview = not tag_name tag_name = tag_name or f"v{get_version()}" + notes = changelog_section(CHANGELOG.read_text(), tag_name.removeprefix("v")) prev_tag = get_previous_tag() - commits = get_commits_since(prev_tag) - Path(output).write_text(build_comment(tag_name, commits, prev_tag, preview=preview)) + Path(output).write_text(build_comment(tag_name, notes, prev_tag, preview=preview)) if __name__ == "__main__": diff --git a/tests/test_build_changelog_comment.py b/tests/test_build_changelog_comment.py index f3c860c..fa5af30 100644 --- a/tests/test_build_changelog_comment.py +++ b/tests/test_build_changelog_comment.py @@ -3,9 +3,34 @@ import importlib.util from pathlib import Path +import pytest + SCRIPT = Path(__file__).parent.parent / "scripts" / "build_changelog_comment.py" -COMMITS = ["Add a thing (#1)", "Fix another thing (#2)"] +CHANGELOG = """# Changelog + +Intro text. + +## Unreleased + +- Not released yet. + +## 1.2.3 - 2026-10-21 + +### Added + +- A thing. + +### Fixed + +- Another thing. + +## 1.2.2 - 2026-09-30 + +### Fixed + +- An older thing. +""" def load_script(): @@ -15,58 +40,74 @@ def load_script(): return module +def test_notes_are_the_version_section_of_the_changelog(): + module = load_script() + notes = module.changelog_section(CHANGELOG, "1.2.3") + + assert notes == "### Added\n\n- A thing.\n\n### Fixed\n\n- Another thing." + + +def test_last_section_runs_to_the_end_of_the_file(): + module = load_script() + assert ( + module.changelog_section(CHANGELOG, "1.2.2") == "### Fixed\n\n- An older thing." + ) + + +def test_bracketed_version_headings_are_found(): + module = load_script() + text = "## [2.0.0] - 2026-11-01\n\n- New.\n" + assert module.changelog_section(text, "2.0.0") == "- New." + + +def test_version_prefix_does_not_match_a_longer_version(): + module = load_script() + with pytest.raises(SystemExit, match=r"no section for 1\.2$"): + module.changelog_section(CHANGELOG, "1.2") + + +def test_missing_or_empty_section_fails_the_build(): + module = load_script() + with pytest.raises(SystemExit, match=r"no section for 9\.9\.9"): + module.changelog_section(CHANGELOG, "9.9.9") + with pytest.raises(SystemExit, match="is empty"): + module.changelog_section("## 1.0.0\n\n## 0.9.0\n\n- Old.\n", "1.0.0") + + def test_preview_wording_on_pull_requests(): module = load_script() - text = module.build_comment("v1.2.3", COMMITS, "v1.2.2", preview=True) + text = module.build_comment("v1.2.3", "- A thing.", "v1.2.2", preview=True) assert text.startswith("## Changelog preview for `v1.2.3`") assert "> Preview of the release notes" in text + assert "- A thing." in text assert "**Full Changelog**: v1.2.2...v1.2.3" in text - for commit in COMMITS: - assert f"- {commit}" in text def test_release_notes_drop_the_preview_wording(): """Tag builds render the notes themselves, not a preview of them.""" module = load_script() - text = module.build_comment("v1.2.3", COMMITS, "v1.2.2", preview=False) + text = module.build_comment("v1.2.3", "- A thing.", "v1.2.2", preview=False) assert text.startswith("## Changelog for `v1.2.3`") assert "preview" not in text.lower() assert "**Full Changelog**: v1.2.2...v1.2.3" in text - for commit in COMMITS: - assert f"- {commit}" in text def test_first_release_falls_back_to_the_tag_alone(): module = load_script() - text = module.build_comment("v0.1.0", COMMITS, None, preview=False) + text = module.build_comment("v0.1.0", "- First.", None, preview=False) assert "**Full Changelog**: v0.1.0" in text -def test_empty_commit_range_is_reported(): - module = load_script() - text = module.build_comment("v1.2.3", [], "v1.2.2", preview=False) - - assert "- No changes." in text - - -def test_release_commits_are_filtered_from_the_log(monkeypatch): +def test_get_version_reads_pyproject_without_tomllib(): module = load_script() - log = "Release 1.2.3\nAdd a thing (#1)\n\nBump version to 1.2.3\n" - monkeypatch.setattr( - module.subprocess, - "run", - lambda *a, **k: type("R", (), {"stdout": log, "returncode": 0})(), - ) - - assert module.get_commits_since("v1.2.2") == [ - "Add a thing (#1)", - "Bump version to 1.2.3", - ] + assert module.get_version().count(".") >= 1 -def test_get_version_reads_pyproject_without_tomllib(): +def test_current_version_has_a_changelog_section(): + """Catches a release whose CHANGELOG.md entries were left under Unreleased.""" module = load_script() - assert module.get_version().count(".") >= 1 + changelog = (SCRIPT.parent.parent / "CHANGELOG.md").read_text() + assert module.changelog_section(changelog, module.get_version()) From e8e70f94da50fd9b1bd2f5620d4acb7bafd7f021 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Sat, 24 Oct 2026 21:32:36 +0200 Subject: [PATCH 80/81] Correct the 0.3.0 attachments changelog entry 0.2.x already uploaded only explicit attachments. Automatic GGUF capture was added and removed during 0.3.0 development and never shipped, so the entry described no change for users upgrading. --- CHANGELOG.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdbfe59..cc476a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,10 +39,6 @@ under Unreleased and move into a version section at release time. and environment configuration. `gguf` remains a compatibility alias; `all` selects the integration once under its new name. Model format metadata remains `gguf`. -- Automatic integrations no longer attach raw payloads, including GGUF prompts, - chat messages and replies. `attachments_enabled=True` enables uploads only for - attachments explicitly passed to `track_inference(attachments=[...])`. - Apps relying on automatic GGUF capture must supply attachments explicitly. - `wildedge.init(dsn=...)` reuses the existing default client when the DSN points to the same project, for example `dsn=os.environ["WILDEDGE_DSN"]` under `wildedge run`. It used to create a second client. @@ -82,7 +78,8 @@ under Unreleased and move into a version section at release time. calls, the generation config (`temperature`, `top_p`, `top_k`, `max_tokens`, `repeat_penalty`, penalties, `seed`, stop sequence count), and, for chat calls, `prompt_type="chat"` and `turn_index` measured on the last user - message. + message. Prompts, chat messages and replies are never attached. As before, + attachments upload only when passed to `track_inference(attachments=[...])`. - `import wildedge` no longer imports every installed ML framework and API client. Each library is imported when its integration is activated or a model from it is registered, so SDK import is faster and a broken unrelated From 61fb79983e62017430f068d8c3cf698327bee7af Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Sat, 24 Oct 2026 21:42:04 +0200 Subject: [PATCH 81/81] Date the 0.3.0 release 2026-10-24 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc476a1..4d7b234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ under Unreleased and move into a version section at release time. ## Unreleased -## 0.3.0 - 2026-10-21 +## 0.3.0 - 2026-10-24 ### Added