Skip to content

Commit 4e45f5b

Browse files
authored
Add wildedge.llm_api(): track LLM calls made over any HTTP API (#43)
1 parent 893ed1a commit 4e45f5b

10 files changed

Lines changed: 627 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ under Unreleased and move into a version section at release time.
77

88
### Added
99

10+
- `wildedge.llm_api()`: provider-agnostic tracking for LLM calls made with any HTTP client (OpenRouter, vLLM, Ollama, OpenAI/Anthropic-compatible endpoints). Times the block, normalizes usage payloads from either provider shape via `call.usage()` / `call.response()`, supports TTFT marks and async use, and records exceptions as error events. See `docs/llm_api.md`.
11+
- `register_model()` without a matching extractor defaults the model name to the explicit `model_id` instead of the placeholder object's type name.
1012
- Process-wide default client: `wildedge.get_client()`, `wildedge.set_default_client()`, and module-level `trace`, `span`, `track_span`, `register_model`, `flush` delegating to it (#41)
1113
- `init()` reuses the default client installed by `wildedge run` unless `dsn` is passed, so CLI and in-process init share one client (#41)
1214
- `SpanContextManager.set_attributes()` and `fail()` for recording outcomes on an open span (#41)

docs/llm_api.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Tracking LLM calls without a client library
2+
3+
`wildedge.llm_api()` records LLM API calls made with any HTTP client: httpx or
4+
requests against OpenRouter, vLLM, Ollama, or any OpenAI-compatible or
5+
Anthropic-compatible endpoint. Use it when auto-instrumentation does not
6+
apply because your app does not use the `openai` or `anthropic` packages. If
7+
it does use them, prefer the integrations; they capture the same events with
8+
zero code.
9+
10+
The name is deliberate: this tracks calls to an LLM behind an *API boundary*.
11+
An LLM running inside your own process (llama.cpp, transformers, MLX) is
12+
covered by the [framework integrations](manual-tracking.md), which also
13+
capture what no API boundary can expose: quantization from the model
14+
artifact, memory footprint, load/unload timing, and hardware linkage.
15+
16+
The block is timed automatically, events correlate with surrounding
17+
`wildedge.trace()` / `wildedge.span()` blocks, and everything is a silent
18+
no-op without a DSN.
19+
20+
## Quickstart
21+
22+
```python
23+
import httpx
24+
import wildedge
25+
26+
async def generate(prompt: str, model: str) -> dict:
27+
with wildedge.llm_api(model=model, provider="openrouter", prompt=prompt) as call:
28+
async with httpx.AsyncClient(timeout=300) as http:
29+
response = await http.post(
30+
"https://openrouter.ai/api/v1/chat/completions",
31+
headers={"Authorization": f"Bearer {API_KEY}"},
32+
json={"model": model, "messages": [{"role": "user", "content": prompt}]},
33+
)
34+
data = response.json()
35+
call.response(data)
36+
return data
37+
```
38+
39+
`call.response()` pulls token usage, stop reason, and API metadata from the
40+
full response payload, dict or SDK object, OpenAI shape
41+
(`usage.prompt_tokens`, `choices[0].finish_reason`) or Anthropic shape
42+
(`usage.input_tokens`, top-level `stop_reason`).
43+
44+
## Recording pieces individually
45+
46+
When you do not have a full response payload, set what you know:
47+
48+
```python
49+
with wildedge.llm_api(model="gemma-7b", base_url="http://localhost:11434") as call:
50+
result = post_to_ollama(...)
51+
call.usage(result["usage"]) # payload in either provider shape
52+
call.usage(tokens_in=52, tokens_out=209) # or explicit fields; these win
53+
call.stop_reason = "stop"
54+
call.first_token() # TTFT mark for streaming
55+
call.success = False # delivered but unusable output
56+
```
57+
58+
An exception escaping the block records an error event with the exception
59+
class as the error code, and no inference event.
60+
61+
## Model identity
62+
63+
Events register under `model` as the model id. `provider` names the source
64+
directly; alternatively `base_url` derives it (`openrouter.ai` becomes
65+
`openrouter`, unknown hosts record the hostname). Pass `prompt` or `messages`
66+
(chat format) to attach input metadata such as prompt length.
67+
68+
## Agentic pipelines
69+
70+
Combine with traces for multi-step visibility:
71+
72+
```python
73+
with wildedge.trace(run_id=run_id, agent_id="skin-generator"):
74+
for attempt in range(2):
75+
with wildedge.span(kind="agent_step", name="generate", step_index=attempt):
76+
with wildedge.llm_api(model=model, provider="openrouter", prompt=prompt) as call:
77+
data = await post_chat_completion(prompt)
78+
call.response(data)
79+
```

docs/manual-tracking.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Use manual tracking when your framework is not covered by a WildEdge integration
55
## When to use it
66

77
- Your model class is custom (e.g. a `torch.nn.Module` subclass not loaded via `timm` or `transformers`)
8-
- You are calling a remote API not yet covered by an integration
8+
- You are calling a remote API not yet covered by an integration (for LLM
9+
APIs called over raw HTTP, [`wildedge.llm_api()`](llm_api.md) is the shortcut)
910
- You want to attach input/output metadata (token counts, image dimensions, confidence scores, etc.)
1011
- You want to record user feedback tied to a specific inference
1112

tests/test_llm_api.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from types import SimpleNamespace
5+
6+
import pytest
7+
8+
import wildedge
9+
from wildedge import constants
10+
from wildedge.defaults import set_default_client
11+
12+
13+
class FakeHandle:
14+
def __init__(self):
15+
self.inferences: list[dict] = []
16+
self.errors: list[dict] = []
17+
18+
def track_inference(self, **kwargs):
19+
self.inferences.append(kwargs)
20+
21+
def track_error(self, error_code=None, *, error_message=None, **kwargs):
22+
self.errors.append({"error_code": error_code, "error_message": error_message})
23+
24+
25+
class FakeClient:
26+
def __init__(self):
27+
self.handle = FakeHandle()
28+
self.registered: list[dict] = []
29+
30+
def register_model(self, model_obj, **kwargs):
31+
self.registered.append({"model_obj": model_obj, **kwargs})
32+
return self.handle
33+
34+
35+
@pytest.fixture
36+
def client():
37+
fake = FakeClient()
38+
set_default_client(fake)
39+
return fake
40+
41+
42+
def test_openai_shape_usage(client):
43+
with wildedge.llm_api(model="openai/gpt-4o-mini", provider="openrouter") as call:
44+
call.usage(
45+
{
46+
"prompt_tokens": 100,
47+
"completion_tokens": 50,
48+
"prompt_tokens_details": {"cached_tokens": 25},
49+
"completion_tokens_details": {"reasoning_tokens": 10},
50+
}
51+
)
52+
call.stop_reason = "stop"
53+
54+
assert client.registered[0]["model_id"] == "openai/gpt-4o-mini"
55+
assert client.registered[0]["source"] == "openrouter"
56+
event = client.handle.inferences[0]
57+
assert event["input_modality"] == "text"
58+
assert event["output_modality"] == "generation"
59+
assert event["success"] is True
60+
meta = event["output_meta"]
61+
assert meta.tokens_in == 100
62+
assert meta.tokens_out == 50
63+
assert meta.cached_input_tokens == 25
64+
assert meta.reasoning_tokens_out == 10
65+
assert meta.stop_reason == "stop"
66+
67+
68+
def test_anthropic_shape_usage(client):
69+
with wildedge.llm_api(model="claude-sonnet-4-5", provider="anthropic") as call:
70+
call.usage(
71+
{"input_tokens": 10, "output_tokens": 5, "cache_read_input_tokens": 3}
72+
)
73+
74+
meta = client.handle.inferences[0]["output_meta"]
75+
assert meta.tokens_in == 10
76+
assert meta.tokens_out == 5
77+
assert meta.cached_input_tokens == 3
78+
79+
80+
def test_attribute_object_usage(client):
81+
usage = SimpleNamespace(
82+
prompt_tokens=7,
83+
completion_tokens=3,
84+
prompt_tokens_details=SimpleNamespace(cached_tokens=2),
85+
completion_tokens_details=None,
86+
)
87+
with wildedge.llm_api(model="m", provider="p") as call:
88+
call.usage(usage)
89+
90+
meta = client.handle.inferences[0]["output_meta"]
91+
assert meta.tokens_in == 7
92+
assert meta.tokens_out == 3
93+
assert meta.cached_input_tokens == 2
94+
95+
96+
def test_explicit_field_usage_wins_over_payload(client):
97+
with wildedge.llm_api(model="m", provider="p") as call:
98+
call.usage({"prompt_tokens": 1}, tokens_in=11, tokens_out=22)
99+
100+
meta = client.handle.inferences[0]["output_meta"]
101+
assert meta.tokens_in == 11
102+
assert meta.tokens_out == 22
103+
104+
105+
def test_response_openai_shape(client):
106+
with wildedge.llm_api(model="m", provider="p") as call:
107+
call.response(
108+
{
109+
"model": "gpt-4o-mini-2024-07-18",
110+
"system_fingerprint": "fp_1",
111+
"usage": {"prompt_tokens": 4, "completion_tokens": 2},
112+
"choices": [{"finish_reason": "length", "message": {}}],
113+
}
114+
)
115+
116+
event = client.handle.inferences[0]
117+
assert event["output_meta"].stop_reason == "length"
118+
assert event["output_meta"].tokens_in == 4
119+
assert event["api_meta"].resolved_model_id == "gpt-4o-mini-2024-07-18"
120+
assert event["api_meta"].system_fingerprint == "fp_1"
121+
122+
123+
def test_response_anthropic_shape(client):
124+
with wildedge.llm_api(model="m", provider="anthropic") as call:
125+
call.response(
126+
{
127+
"model": "claude-sonnet-4-5",
128+
"stop_reason": "end_turn",
129+
"usage": {"input_tokens": 9, "output_tokens": 1},
130+
}
131+
)
132+
133+
meta = client.handle.inferences[0]["output_meta"]
134+
assert meta.stop_reason == "end_turn"
135+
assert meta.tokens_in == 9
136+
137+
138+
def test_exception_records_error_not_inference(client):
139+
with pytest.raises(ValueError):
140+
with wildedge.llm_api(model="m", provider="p"):
141+
raise ValueError("bad payload")
142+
143+
assert client.handle.inferences == []
144+
assert client.handle.errors == [
145+
{"error_code": "ValueError", "error_message": "bad payload"}
146+
]
147+
148+
149+
def test_prompt_input_meta(client):
150+
with wildedge.llm_api(
151+
model="m", provider="p", prompt="a knight with two swords"
152+
) as call:
153+
call.usage(tokens_in=6)
154+
155+
meta = client.handle.inferences[0]["input_meta"]
156+
assert meta.char_count == len("a knight with two swords")
157+
assert meta.word_count == 5
158+
assert meta.token_count == 6
159+
assert meta.prompt_type == "chat"
160+
161+
162+
def test_messages_input_meta(client):
163+
messages = [
164+
{"role": "system", "content": "be brief"},
165+
{"role": "user", "content": "hello there"},
166+
]
167+
with wildedge.llm_api(model="m", provider="p", messages=messages) as call:
168+
call.usage(tokens_in=3)
169+
170+
meta = client.handle.inferences[0]["input_meta"]
171+
assert meta.char_count == len("hello there")
172+
assert meta.word_count == 2
173+
174+
175+
def test_first_token_sets_ttft(client):
176+
with wildedge.llm_api(model="m", provider="p") as call:
177+
call.first_token()
178+
call.usage(tokens_out=1)
179+
180+
assert client.handle.inferences[0]["output_meta"].time_to_first_token_ms is not None
181+
182+
183+
def test_async_context_manager(client):
184+
async def run():
185+
async with wildedge.llm_api(model="m", provider="p") as call:
186+
call.usage(tokens_in=1)
187+
188+
asyncio.run(run())
189+
assert len(client.handle.inferences) == 1
190+
191+
192+
def test_source_derived_from_base_url(client):
193+
with wildedge.llm_api(model="m", base_url="https://openrouter.ai/api/v1"):
194+
pass
195+
196+
assert client.registered[0]["source"] == "openrouter"
197+
198+
199+
def test_success_flag_recorded(client):
200+
with wildedge.llm_api(model="m", provider="p") as call:
201+
call.usage(tokens_out=1)
202+
call.success = False
203+
204+
assert client.handle.inferences[0]["success"] is False
205+
206+
207+
def test_no_output_meta_when_nothing_known(client):
208+
with wildedge.llm_api(model="m", provider="p"):
209+
pass
210+
211+
event = client.handle.inferences[0]
212+
assert event["output_meta"] is None
213+
assert event["api_meta"] is None
214+
215+
216+
def test_noop_without_dsn_end_to_end(monkeypatch):
217+
monkeypatch.delenv(constants.ENV_DSN, raising=False)
218+
monkeypatch.delenv(constants.ENV_INTEGRATIONS, raising=False)
219+
220+
with wildedge.llm_api(model="m", provider="p") as call:
221+
call.usage(tokens_in=1, tokens_out=2)
222+
223+
assert wildedge.get_client().noop is True

tests/test_offline_replay.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ def test_offline_replay_restores_model_registry_for_pending_events(tmp_path):
3737
),
3838
patch("wildedge.client.Transmitter"),
3939
patch("wildedge.client.Consumer", _DummyConsumer),
40+
# The registry path has no constructor override; without this patch the
41+
# test reads and writes machine-global state across runs.
42+
patch(
43+
"wildedge.client.default_model_registry_path",
44+
return_value=tmp_path / "model_registry.json",
45+
),
4046
):
4147
client_a = WildEdge(
4248
dsn="https://secret@ingest.wildedge.dev/proj",
@@ -69,4 +75,5 @@ def test_offline_replay_restores_model_registry_for_pending_events(tmp_path):
6975
assert client_b.queue.length() == 1
7076
models = client_b.registry.snapshot()
7177
assert "ResNet" in models
72-
assert models["ResNet"]["model_name"] == "_Model"
78+
# register_model with no matching extractor names the model after model_id
79+
assert models["ResNet"]["model_name"] == "ResNet"

wildedge/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
TextInputMeta,
3030
)
3131
from wildedge.events.span import SpanKind, SpanStatus
32+
from wildedge.llm_api import LLMCall, llm_api
3233
from wildedge.platforms import capture_hardware
3334
from wildedge.platforms.device_info import DeviceInfo
3435
from wildedge.platforms.hardware import HardwareContext, ThermalContext
@@ -52,6 +53,8 @@
5253
"track_span",
5354
"register_model",
5455
"flush",
56+
"llm_api",
57+
"LLMCall",
5558
"Attachment",
5659
"capture_hardware",
5760
"HardwareContext",

wildedge/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -529,8 +529,10 @@ def register_model(
529529
else:
530530
# No extractor matched - require explicit id
531531
model_id = overrides.pop("id", None)
532-
model_name = overrides.pop("model_name", None) or (
533-
str(type(model_obj).__name__)
532+
model_name = (
533+
overrides.pop("model_name", None)
534+
or model_id
535+
or str(type(model_obj).__name__)
534536
)
535537
info = ModelInfo(
536538
model_name=model_name,

0 commit comments

Comments
 (0)