wiretruth 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {wiretruth-0.2.0/src/wiretruth.egg-info → wiretruth-0.3.0}/PKG-INFO +63 -36
  2. {wiretruth-0.2.0 → wiretruth-0.3.0}/README.md +62 -35
  3. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/__init__.py +1 -1
  4. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/checks.py +2 -2
  5. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/cli.py +20 -4
  6. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/envs.py +41 -1
  7. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/formats.py +2 -0
  8. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/grading.py +22 -1
  9. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/lint.py +14 -0
  10. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/recorder.py +12 -0
  11. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/results.py +15 -5
  12. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/runner.py +6 -2
  13. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/scenario.schema.json +12 -0
  14. {wiretruth-0.2.0 → wiretruth-0.3.0/src/wiretruth.egg-info}/PKG-INFO +63 -36
  15. {wiretruth-0.2.0 → wiretruth-0.3.0}/LICENSE +0 -0
  16. {wiretruth-0.2.0 → wiretruth-0.3.0}/pyproject.toml +0 -0
  17. {wiretruth-0.2.0 → wiretruth-0.3.0}/setup.cfg +0 -0
  18. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/__main__.py +0 -0
  19. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/catalog.py +0 -0
  20. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/__init__.py +0 -0
  21. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/anthropic.py +0 -0
  22. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/gemini.py +0 -0
  23. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/ollama.py +0 -0
  24. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/ollama_openai.py +0 -0
  25. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/openai_chat.py +0 -0
  26. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/openai_responses.py +0 -0
  27. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/replay.py +0 -0
  28. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/bug-classes.schema.json +0 -0
  29. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/call.schema.json +0 -0
  30. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/common.schema.json +0 -0
  31. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/fixture.schema.json +0 -0
  32. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/observation.schema.json +0 -0
  33. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/results.schema.json +0 -0
  34. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/shim-manifest.schema.json +0 -0
  35. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/upstream.schema.json +0 -0
  36. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/secret_scan.py +0 -0
  37. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/transforms.py +0 -0
  38. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/SOURCES.txt +0 -0
  39. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/dependency_links.txt +0 -0
  40. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/entry_points.txt +0 -0
  41. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/requires.txt +0 -0
  42. {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/top_level.txt +0 -0
  43. {wiretruth-0.2.0 → wiretruth-0.3.0}/tests/test_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wiretruth
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Replays real LLM provider responses through LLM SDKs and checks whether each SDK reports what actually happened.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/kartsan03/wiretruth
@@ -38,8 +38,10 @@ happened.
38
38
  [![PyPI](https://img.shields.io/pypi/v/wiretruth?label=PyPI)](https://pypi.org/project/wiretruth/)
39
39
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/kartsan03/wiretruth/blob/main/LICENSE)
40
40
 
41
- So far wiretruth tests one library, any-llm, on six APIs: OpenAI Chat Completions and Responses, Ollama's native
42
- and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic Messages. That is 24 scenarios and 95 cells.
41
+ wiretruth tests any-llm, LiteLLM, pydantic-ai, the Vercel AI SDK and TanStack AI across six APIs: OpenAI Chat
42
+ Completions and Responses, Ollama's native and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic
43
+ Messages. That is 24 scenarios and 100 cells per library. pydantic-ai and the Vercel AI SDK support only the OpenAI-compatible Ollama
44
+ endpoint; their native Ollama cells are UNSUPPORTED.
43
45
  The Anthropic responses are transcribed from Anthropic's documentation rather than recorded: each of those fixtures
44
46
  is marked `documented` and links the page it comes from. More providers and libraries follow, and a public
45
47
  results matrix arrives in v0.4. Until then, the grades are in
@@ -52,11 +54,21 @@ A model hits its output token limit halfway through a JSON object. The provider
52
54
  parse error, nothing tells you that a higher limit would fix it. If it returns the partial object as a success,
53
55
  your pipeline stores an incomplete record.
54
56
 
55
- wiretruth has this case recorded as `truncation.structured-output` on `openai-chat`. Replayed through any-llm:
57
+ wiretruth has this case recorded as `truncation.structured-output` on `openai-chat`. Replayed through each library:
56
58
 
57
59
  | Library | What the caller gets | Grade |
58
60
  |---|---|---|
59
- | any-llm 1.33.0 | Raises `openai.LengthFinishReasonError`. Its `completion` has `finish_reason: "length"` and the token usage. | ✓ PASS |
61
+ | any-llm 1.40.2 | Raises `openai.LengthFinishReasonError`. Its `completion` has `finish_reason: "length"` and the token usage. | ✓ PASS |
62
+ | LiteLLM 1.105.5 | Returns the cut-off JSON as text in `message.content` with `finish_reason: "length"` and parses none of it. | ✓ PASS |
63
+ | pydantic-ai 2.61.0 | Retries once, then raises `UnexpectedModelBehavior("Exceeded maximum output retries (1)")`. The `length` finish is only in the messages that `capture_run_messages()` keeps. | ~ LOSSY |
64
+ | Vercel AI SDK 7.0.153 | Raises `NoObjectGeneratedError` with `finishReason: "length"` and the token usage. | ✓ PASS |
65
+
66
+ pydantic-ai's grades for structured output use `NativeOutput`, the provider's own JSON mode, because that is how
67
+ the fixtures were recorded. It is opt-in: by default pydantic-ai asks for a forced tool call instead on every API
68
+ here except Anthropic, and no scenario covers that path yet. On `gpt-6-luna` that default request is rejected with the same 400
69
+ that `request.tools-with-reasoning-chat` replays (function tools without `reasoning_effort: "none"`). The Vercel AI
70
+ SDK sends a JSON schema by default on every API here except OpenAI-compatible Ollama, where it defaults to JSON-object
71
+ mode; its shim opts into the documented `supportsStructuredOutputs` setting there.
60
72
 
61
73
  wiretruth runs the same recorded edge cases through every library it supports and grades each one against what the
62
74
  provider sent.
@@ -93,6 +105,7 @@ Each scenario lists its requirements. MUST requirements set the grade. SHOULD re
93
105
  partial text in the truncation scenarios, add a note when they aren't met. How grades are computed:
94
106
  [docs/grading.md](https://github.com/kartsan03/wiretruth/blob/main/docs/grading.md).
95
107
  Scenario catalog: [docs/scenarios.md](https://github.com/kartsan03/wiretruth/blob/main/docs/scenarios.md).
108
+ Fixed-bug validation: [docs/validation.md](https://github.com/kartsan03/wiretruth/blob/main/docs/validation.md).
96
109
 
97
110
  ## Quickstart
98
111
 
@@ -101,12 +114,21 @@ You need Python 3.11 or newer and [uv](https://docs.astral.sh/uv/), which builds
101
114
  ```bash
102
115
  git clone https://github.com/kartsan03/wiretruth && cd wiretruth
103
116
  uv run wiretruth run --lib any-llm
117
+ uv run wiretruth run --lib litellm
118
+ uv run wiretruth run --lib pydantic-ai
119
+ uv run wiretruth run --lib vercel-ai
120
+ uv run wiretruth run --lib tanstack-ai
104
121
  ```
105
122
 
123
+ The Vercel AI SDK and TanStack AI shims also need Node.js and npm. The runner builds their pinned dependencies
124
+ automatically; see [Shims](shims/README.md) for supported providers and structured-output modes.
125
+
126
+ The any-llm run reports (see [`results/any-llm/1.40.2.json`](https://github.com/kartsan03/wiretruth/blob/main/results/any-llm/1.40.2.json)):
127
+
106
128
  ```text
107
129
  scenario anthropic gemini ollama ollama-openai openai-chat openai-responses
108
130
  finish.stop-sequence ✓ PASS ✓ PASS ✓ PASS ✓ PASS
109
- finish.unknown-value ✗ WRONG ✗ WRONG ✗ WRONG ✗ WRONG ✗ WRONG ✓ PASS
131
+ finish.unknown-value ✗ WRONG ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
110
132
  request.max-tokens-field ✓ PASS
111
133
  request.max-tokens-ollama-openai ✗ WRONG
112
134
  request.no-injected-options ✓ PASS
@@ -116,8 +138,8 @@ stream.missing-finish ~ LOSSY ~ LOSSY ~ LOSSY ~ LOS
116
138
  stream.missing-finish-abort ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
117
139
  stream.utf8 ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
118
140
  stream.utf8-split ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
119
- tools.parallel-calls ✓ PASS ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
120
- tools.parallel-calls-stream ✓ PASS ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
141
+ tools.parallel-calls ✓ PASS ✓ PASS ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
142
+ tools.parallel-calls-stream ✓ PASS ✓ PASS ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
121
143
  truncation.reasoning-only ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
122
144
  truncation.structured-output ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ~ LOSSY
123
145
  truncation.structured-output-forced-tool – UNSUPPORTED
@@ -126,8 +148,8 @@ truncation.text-stream ✓ PASS ✓ PASS ✓ PASS
126
148
  truncation.text-stream-usage ✓ PASS ✓ PASS
127
149
  truncation.tool-call-args ✓ PASS ✓ PASS ✓ PASS
128
150
  truncation.tool-call-args-stream ✓ PASS ✓ PASS ✓ PASS
129
- usage.cached-tokens ✓ PASS
130
- usage.reasoning-tokens ✓ PASS
151
+ usage.cached-tokens ✓ PASS ✓ PASS ✓ PASS
152
+ usage.reasoning-tokens ✓ PASS ✓ PASS ✓ PASS ✓ PASS
131
153
  usage.stream-final-chunk ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
132
154
 
133
155
  finish.stop-sequence gemini
@@ -146,18 +168,6 @@ finish.unknown-value ollama
146
168
  MUST finish-is-other: CONTRADICTED: finish.value="stop" (primary: choices[0].finish_reason); truth.finish="other"; required finish="other"
147
169
  SHOULD raw-finish-reported: CONTRADICTED: finish.raw="stop"; truth.raw_finish="wiretruth_unknown_reason"
148
170
 
149
- finish.unknown-value ollama-openai
150
- MUST response-returned: CONTRADICTED: outcome=raised; error.category="validation"; truth.error=null
151
- MUST finish-is-other: NOT_OBSERVABLE: finish.value=null (absent); truth.finish="other"; required finish="other"
152
- MUST text-reported: NOT_OBSERVABLE: text.value=null (absent); truth.text="Octopuses, like many marine invertebrates, employ sophisticated methods to camouflage"
153
- SHOULD raw-finish-reported: NOT_OBSERVABLE: finish.raw=null; truth.raw_finish="wiretruth_unknown_reason"
154
-
155
- finish.unknown-value openai-chat
156
- MUST response-returned: CONTRADICTED: outcome=raised; error.category="validation"; truth.error=null
157
- MUST finish-is-other: NOT_OBSERVABLE: finish.value=null (absent); truth.finish="other"; required finish="other"
158
- MUST text-reported: NOT_OBSERVABLE: text.value=null (absent); truth.text="Io, Europa, and Ganymede."
159
- SHOULD raw-finish-reported: NOT_OBSERVABLE: finish.raw=null; truth.raw_finish="wiretruth_unknown_reason"
160
-
161
171
  request.max-tokens-ollama-openai ollama-openai
162
172
  MUST limit-field: CONTRADICTED: request[0].json_body.max_tokens=absent; required=16
163
173
 
@@ -190,18 +200,16 @@ stream.utf8-split gemini
190
200
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
191
201
 
192
202
  tools.parallel-calls gemini
193
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="call_-5201471136765337120_0", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_120847", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="call_-5201471136765337120_1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_120848", name="get_weather", args={"city": "Zürich"}, parsed=true
194
203
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
195
204
 
196
205
  tools.parallel-calls ollama
197
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="3972c760-858b-4728-a05a-279fc6e6a649", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_9epwsqu6", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="4d3747a6-5e75-4637-b82e-f269adbba01e", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_bvzy1ra9", name="get_weather", args={"city": "Zürich"}, parsed=true
206
+ MUST calls-reported: CONTRADICTED: tool_calls[0] id="77ecdd8a-566d-4ba1-b098-d7a3b3082999", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_9epwsqu6", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="3a4cba16-a57a-4657-b28a-8ac89eaea7d9", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_bvzy1ra9", name="get_weather", args={"city": "Zürich"}, parsed=true
198
207
 
199
208
  tools.parallel-calls-stream gemini
200
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="call_-1615951408272134819_0", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_89893", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="call_-1615951408272134819_1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_89896", name="get_weather", args={"city": "Zürich"}, parsed=true
201
209
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
202
210
 
203
211
  tools.parallel-calls-stream ollama
204
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="8b75c529-6eb6-4a63-a375-4551bfe56f39", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_8uaqob2r", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="8e864d64-6145-4ca4-81ba-cbdbad0523e1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_nyjtbrz4", name="get_weather", args={"city": "Zürich"}, parsed=true
212
+ MUST calls-reported: CONTRADICTED: tool_calls[0] id="fc700b93-a9ac-4907-8da1-a7b38a28658c", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_8uaqob2r", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="265e0a1f-ef64-4209-b6b9-e3b5c9e3a181", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_nyjtbrz4", name="get_weather", args={"city": "Zürich"}, parsed=true
205
213
 
206
214
  truncation.structured-output gemini
207
215
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
@@ -212,7 +220,7 @@ truncation.structured-output openai-responses
212
220
  SHOULD usage-reported: NOT_OBSERVABLE: usage.input=null (absent); truth.usage.input=68; usage.output=null (absent); truth.usage.output=24
213
221
 
214
222
  truncation.structured-output-forced-tool anthropic
215
- any-llm completion maps typed response_format to native output_config.format and has no structured parser for an explicitly forced tool. (https://github.com/mozilla-ai/any-llm/blob/1.33.0/src/any_llm/providers/anthropic/utils.py#L628-L655)
223
+ any-llm completion maps typed response_format to native output_config.format and has no structured parser for an explicitly forced tool. (https://github.com/mozilla-ai/any-llm/blob/1.40.2/src/any_llm/providers/anthropic/utils.py#L660-L686)
216
224
 
217
225
  truncation.text gemini
218
226
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
@@ -223,27 +231,45 @@ truncation.text-stream gemini
223
231
  truncation.text-stream-usage gemini
224
232
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
225
233
 
234
+ usage.reasoning-tokens anthropic
235
+ SHOULD reasoning-reported: NOT_OBSERVABLE: usage.reasoning=null (absent); truth.usage.reasoning=312
236
+
226
237
  usage.stream-final-chunk gemini
227
238
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
228
239
 
229
- any-llm 1.33.0: 95 cells, 78 PASS, 6 LOSSY, 10 WRONG, 1 UNSUPPORTED
230
- stderr: .wiretruth/runs/20261013T134918.993664Z
240
+ any-llm 1.40.2: 100 cells, 87 PASS, 6 LOSSY, 6 WRONG, 1 UNSUPPORTED
241
+ stderr: .wiretruth/runs/20261109T005301.682490Z
231
242
  ```
232
243
 
233
- No API keys are needed. Everything runs against stored responses on `127.0.0.1`. The first run downloads any-llm
234
- into `.wiretruth/envs/`; later runs work offline. `uv run wiretruth run --shim shims/reference` runs the
235
- built-in reference client, which must pass every cell. The scenarios, fixtures and shims live in this repository,
236
- so wiretruth runs from a clone.
244
+ The LiteLLM run grades its 100 cells 86 PASS, 13 WRONG and 1 UNSUPPORTED. Every cell, with its evidence, is in
245
+ [`results/litellm/1.105.5.json`](https://github.com/kartsan03/wiretruth/blob/main/results/litellm/1.105.5.json).
246
+ The pydantic-ai run grades its 100 cells 63 PASS, 16 LOSSY, 6 WRONG and 15 UNSUPPORTED (native Ollama), in
247
+ [`results/pydantic-ai/2.61.0.json`](https://github.com/kartsan03/wiretruth/blob/main/results/pydantic-ai/2.61.0.json).
248
+ The Vercel AI SDK run grades its 100 cells 84 PASS and 16 UNSUPPORTED (native Ollama, and the Anthropic
249
+ forced-tool fixture, since current Claude models get native structured output), in
250
+ [`results/vercel-ai/7.0.153.json`](https://github.com/kartsan03/wiretruth/blob/main/results/vercel-ai/7.0.153.json).
251
+ The TanStack AI run grades its 100 cells 48 PASS, 2 LOSSY, 2 WRONG and 48 UNSUPPORTED, in
252
+ [`results/tanstack-ai/1.0.0.json`](https://github.com/kartsan03/wiretruth/blob/main/results/tanstack-ai/1.0.0.json).
253
+ Its `chat()` always requests a streamed response, so the non-streamed fixtures don't apply to it.
254
+
255
+ No API keys are needed. Everything runs against stored responses on `127.0.0.1`. The first run downloads the
256
+ selected library into `.wiretruth/envs/`; later runs work offline. `uv run wiretruth run --shim shims/reference`
257
+ runs the built-in reference client, which must pass every cell. The scenarios, fixtures and shims live in this
258
+ repository, so wiretruth runs from a clone.
237
259
 
238
260
  Releases are also on [PyPI](https://pypi.org/project/wiretruth/) (`pip install wiretruth`). The package is the
239
- command only, so run it inside a checkout of the matching release tag (for example `v0.2.0`); on `main`, use
261
+ command only, so run it inside a checkout of the matching release tag (for example `v0.3.0`); on `main`, use
240
262
  `uv run` as above.
241
263
 
242
264
  ## Add your library
243
265
 
244
266
  A shim is a small program. It reads a call spec as JSON on stdin, calls your library the way its docs show, and
245
267
  prints what the library reported as JSON on stdout. The
246
- [any-llm shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/any-llm/shim.py) is 150 lines. The
268
+ [any-llm shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/any-llm/shim.py),
269
+ [LiteLLM shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/litellm/shim.py),
270
+ [pydantic-ai shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/pydantic-ai/shim.py),
271
+ [Vercel AI SDK shim](https://github.com/kartsan03/wiretruth/blob/main/shims/js/vercel-ai/shim.mjs) and
272
+ [TanStack AI shim](https://github.com/kartsan03/wiretruth/blob/main/shims/js/tanstack-ai/shim.mjs) are examples. The
247
273
  protocol is in [shims/README.md](https://github.com/kartsan03/wiretruth/blob/main/shims/README.md); to propose a
248
274
  library, [open a new-library issue](https://github.com/kartsan03/wiretruth/issues/new?template=new-library.yml).
249
275
 
@@ -267,7 +293,8 @@ library, [open a new-library issue](https://github.com/kartsan03/wiretruth/issue
267
293
  ## Author note
268
294
 
269
295
  I've contributed fixes to libraries that wiretruth tests or will test:
270
- [mozilla-ai/any-llm#1389](https://github.com/mozilla-ai/any-llm/pull/1389) and
296
+ [mozilla-ai/any-llm#1389](https://github.com/mozilla-ai/any-llm/pull/1389),
297
+ [mozilla-ai/any-llm#1433](https://github.com/mozilla-ai/any-llm/pull/1433) and
271
298
  [TanStack/ai#1548](https://github.com/TanStack/ai/pull/1548). Grades are computed by the code in this repository.
272
299
  If you think a grade is wrong, please
273
300
  [open a grade dispute](https://github.com/kartsan03/wiretruth/issues/new?template=grade-dispute.yml).
@@ -9,8 +9,10 @@ happened.
9
9
  [![PyPI](https://img.shields.io/pypi/v/wiretruth?label=PyPI)](https://pypi.org/project/wiretruth/)
10
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/kartsan03/wiretruth/blob/main/LICENSE)
11
11
 
12
- So far wiretruth tests one library, any-llm, on six APIs: OpenAI Chat Completions and Responses, Ollama's native
13
- and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic Messages. That is 24 scenarios and 95 cells.
12
+ wiretruth tests any-llm, LiteLLM, pydantic-ai, the Vercel AI SDK and TanStack AI across six APIs: OpenAI Chat
13
+ Completions and Responses, Ollama's native and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic
14
+ Messages. That is 24 scenarios and 100 cells per library. pydantic-ai and the Vercel AI SDK support only the OpenAI-compatible Ollama
15
+ endpoint; their native Ollama cells are UNSUPPORTED.
14
16
  The Anthropic responses are transcribed from Anthropic's documentation rather than recorded: each of those fixtures
15
17
  is marked `documented` and links the page it comes from. More providers and libraries follow, and a public
16
18
  results matrix arrives in v0.4. Until then, the grades are in
@@ -23,11 +25,21 @@ A model hits its output token limit halfway through a JSON object. The provider
23
25
  parse error, nothing tells you that a higher limit would fix it. If it returns the partial object as a success,
24
26
  your pipeline stores an incomplete record.
25
27
 
26
- wiretruth has this case recorded as `truncation.structured-output` on `openai-chat`. Replayed through any-llm:
28
+ wiretruth has this case recorded as `truncation.structured-output` on `openai-chat`. Replayed through each library:
27
29
 
28
30
  | Library | What the caller gets | Grade |
29
31
  |---|---|---|
30
- | any-llm 1.33.0 | Raises `openai.LengthFinishReasonError`. Its `completion` has `finish_reason: "length"` and the token usage. | ✓ PASS |
32
+ | any-llm 1.40.2 | Raises `openai.LengthFinishReasonError`. Its `completion` has `finish_reason: "length"` and the token usage. | ✓ PASS |
33
+ | LiteLLM 1.105.5 | Returns the cut-off JSON as text in `message.content` with `finish_reason: "length"` and parses none of it. | ✓ PASS |
34
+ | pydantic-ai 2.61.0 | Retries once, then raises `UnexpectedModelBehavior("Exceeded maximum output retries (1)")`. The `length` finish is only in the messages that `capture_run_messages()` keeps. | ~ LOSSY |
35
+ | Vercel AI SDK 7.0.153 | Raises `NoObjectGeneratedError` with `finishReason: "length"` and the token usage. | ✓ PASS |
36
+
37
+ pydantic-ai's grades for structured output use `NativeOutput`, the provider's own JSON mode, because that is how
38
+ the fixtures were recorded. It is opt-in: by default pydantic-ai asks for a forced tool call instead on every API
39
+ here except Anthropic, and no scenario covers that path yet. On `gpt-6-luna` that default request is rejected with the same 400
40
+ that `request.tools-with-reasoning-chat` replays (function tools without `reasoning_effort: "none"`). The Vercel AI
41
+ SDK sends a JSON schema by default on every API here except OpenAI-compatible Ollama, where it defaults to JSON-object
42
+ mode; its shim opts into the documented `supportsStructuredOutputs` setting there.
31
43
 
32
44
  wiretruth runs the same recorded edge cases through every library it supports and grades each one against what the
33
45
  provider sent.
@@ -64,6 +76,7 @@ Each scenario lists its requirements. MUST requirements set the grade. SHOULD re
64
76
  partial text in the truncation scenarios, add a note when they aren't met. How grades are computed:
65
77
  [docs/grading.md](https://github.com/kartsan03/wiretruth/blob/main/docs/grading.md).
66
78
  Scenario catalog: [docs/scenarios.md](https://github.com/kartsan03/wiretruth/blob/main/docs/scenarios.md).
79
+ Fixed-bug validation: [docs/validation.md](https://github.com/kartsan03/wiretruth/blob/main/docs/validation.md).
67
80
 
68
81
  ## Quickstart
69
82
 
@@ -72,12 +85,21 @@ You need Python 3.11 or newer and [uv](https://docs.astral.sh/uv/), which builds
72
85
  ```bash
73
86
  git clone https://github.com/kartsan03/wiretruth && cd wiretruth
74
87
  uv run wiretruth run --lib any-llm
88
+ uv run wiretruth run --lib litellm
89
+ uv run wiretruth run --lib pydantic-ai
90
+ uv run wiretruth run --lib vercel-ai
91
+ uv run wiretruth run --lib tanstack-ai
75
92
  ```
76
93
 
94
+ The Vercel AI SDK and TanStack AI shims also need Node.js and npm. The runner builds their pinned dependencies
95
+ automatically; see [Shims](shims/README.md) for supported providers and structured-output modes.
96
+
97
+ The any-llm run reports (see [`results/any-llm/1.40.2.json`](https://github.com/kartsan03/wiretruth/blob/main/results/any-llm/1.40.2.json)):
98
+
77
99
  ```text
78
100
  scenario anthropic gemini ollama ollama-openai openai-chat openai-responses
79
101
  finish.stop-sequence ✓ PASS ✓ PASS ✓ PASS ✓ PASS
80
- finish.unknown-value ✗ WRONG ✗ WRONG ✗ WRONG ✗ WRONG ✗ WRONG ✓ PASS
102
+ finish.unknown-value ✗ WRONG ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
81
103
  request.max-tokens-field ✓ PASS
82
104
  request.max-tokens-ollama-openai ✗ WRONG
83
105
  request.no-injected-options ✓ PASS
@@ -87,8 +109,8 @@ stream.missing-finish ~ LOSSY ~ LOSSY ~ LOSSY ~ LOS
87
109
  stream.missing-finish-abort ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
88
110
  stream.utf8 ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
89
111
  stream.utf8-split ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
90
- tools.parallel-calls ✓ PASS ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
91
- tools.parallel-calls-stream ✓ PASS ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
112
+ tools.parallel-calls ✓ PASS ✓ PASS ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
113
+ tools.parallel-calls-stream ✓ PASS ✓ PASS ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
92
114
  truncation.reasoning-only ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
93
115
  truncation.structured-output ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ~ LOSSY
94
116
  truncation.structured-output-forced-tool – UNSUPPORTED
@@ -97,8 +119,8 @@ truncation.text-stream ✓ PASS ✓ PASS ✓ PASS
97
119
  truncation.text-stream-usage ✓ PASS ✓ PASS
98
120
  truncation.tool-call-args ✓ PASS ✓ PASS ✓ PASS
99
121
  truncation.tool-call-args-stream ✓ PASS ✓ PASS ✓ PASS
100
- usage.cached-tokens ✓ PASS
101
- usage.reasoning-tokens ✓ PASS
122
+ usage.cached-tokens ✓ PASS ✓ PASS ✓ PASS
123
+ usage.reasoning-tokens ✓ PASS ✓ PASS ✓ PASS ✓ PASS
102
124
  usage.stream-final-chunk ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
103
125
 
104
126
  finish.stop-sequence gemini
@@ -117,18 +139,6 @@ finish.unknown-value ollama
117
139
  MUST finish-is-other: CONTRADICTED: finish.value="stop" (primary: choices[0].finish_reason); truth.finish="other"; required finish="other"
118
140
  SHOULD raw-finish-reported: CONTRADICTED: finish.raw="stop"; truth.raw_finish="wiretruth_unknown_reason"
119
141
 
120
- finish.unknown-value ollama-openai
121
- MUST response-returned: CONTRADICTED: outcome=raised; error.category="validation"; truth.error=null
122
- MUST finish-is-other: NOT_OBSERVABLE: finish.value=null (absent); truth.finish="other"; required finish="other"
123
- MUST text-reported: NOT_OBSERVABLE: text.value=null (absent); truth.text="Octopuses, like many marine invertebrates, employ sophisticated methods to camouflage"
124
- SHOULD raw-finish-reported: NOT_OBSERVABLE: finish.raw=null; truth.raw_finish="wiretruth_unknown_reason"
125
-
126
- finish.unknown-value openai-chat
127
- MUST response-returned: CONTRADICTED: outcome=raised; error.category="validation"; truth.error=null
128
- MUST finish-is-other: NOT_OBSERVABLE: finish.value=null (absent); truth.finish="other"; required finish="other"
129
- MUST text-reported: NOT_OBSERVABLE: text.value=null (absent); truth.text="Io, Europa, and Ganymede."
130
- SHOULD raw-finish-reported: NOT_OBSERVABLE: finish.raw=null; truth.raw_finish="wiretruth_unknown_reason"
131
-
132
142
  request.max-tokens-ollama-openai ollama-openai
133
143
  MUST limit-field: CONTRADICTED: request[0].json_body.max_tokens=absent; required=16
134
144
 
@@ -161,18 +171,16 @@ stream.utf8-split gemini
161
171
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
162
172
 
163
173
  tools.parallel-calls gemini
164
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="call_-5201471136765337120_0", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_120847", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="call_-5201471136765337120_1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_120848", name="get_weather", args={"city": "Zürich"}, parsed=true
165
174
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
166
175
 
167
176
  tools.parallel-calls ollama
168
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="3972c760-858b-4728-a05a-279fc6e6a649", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_9epwsqu6", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="4d3747a6-5e75-4637-b82e-f269adbba01e", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_bvzy1ra9", name="get_weather", args={"city": "Zürich"}, parsed=true
177
+ MUST calls-reported: CONTRADICTED: tool_calls[0] id="77ecdd8a-566d-4ba1-b098-d7a3b3082999", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_9epwsqu6", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="3a4cba16-a57a-4657-b28a-8ac89eaea7d9", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_bvzy1ra9", name="get_weather", args={"city": "Zürich"}, parsed=true
169
178
 
170
179
  tools.parallel-calls-stream gemini
171
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="call_-1615951408272134819_0", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_89893", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="call_-1615951408272134819_1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_89896", name="get_weather", args={"city": "Zürich"}, parsed=true
172
180
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
173
181
 
174
182
  tools.parallel-calls-stream ollama
175
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="8b75c529-6eb6-4a63-a375-4551bfe56f39", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_8uaqob2r", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="8e864d64-6145-4ca4-81ba-cbdbad0523e1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_nyjtbrz4", name="get_weather", args={"city": "Zürich"}, parsed=true
183
+ MUST calls-reported: CONTRADICTED: tool_calls[0] id="fc700b93-a9ac-4907-8da1-a7b38a28658c", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_8uaqob2r", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="265e0a1f-ef64-4209-b6b9-e3b5c9e3a181", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_nyjtbrz4", name="get_weather", args={"city": "Zürich"}, parsed=true
176
184
 
177
185
  truncation.structured-output gemini
178
186
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
@@ -183,7 +191,7 @@ truncation.structured-output openai-responses
183
191
  SHOULD usage-reported: NOT_OBSERVABLE: usage.input=null (absent); truth.usage.input=68; usage.output=null (absent); truth.usage.output=24
184
192
 
185
193
  truncation.structured-output-forced-tool anthropic
186
- any-llm completion maps typed response_format to native output_config.format and has no structured parser for an explicitly forced tool. (https://github.com/mozilla-ai/any-llm/blob/1.33.0/src/any_llm/providers/anthropic/utils.py#L628-L655)
194
+ any-llm completion maps typed response_format to native output_config.format and has no structured parser for an explicitly forced tool. (https://github.com/mozilla-ai/any-llm/blob/1.40.2/src/any_llm/providers/anthropic/utils.py#L660-L686)
187
195
 
188
196
  truncation.text gemini
189
197
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
@@ -194,27 +202,45 @@ truncation.text-stream gemini
194
202
  truncation.text-stream-usage gemini
195
203
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
196
204
 
205
+ usage.reasoning-tokens anthropic
206
+ SHOULD reasoning-reported: NOT_OBSERVABLE: usage.reasoning=null (absent); truth.usage.reasoning=312
207
+
197
208
  usage.stream-final-chunk gemini
198
209
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
199
210
 
200
- any-llm 1.33.0: 95 cells, 78 PASS, 6 LOSSY, 10 WRONG, 1 UNSUPPORTED
201
- stderr: .wiretruth/runs/20261013T134918.993664Z
211
+ any-llm 1.40.2: 100 cells, 87 PASS, 6 LOSSY, 6 WRONG, 1 UNSUPPORTED
212
+ stderr: .wiretruth/runs/20261109T005301.682490Z
202
213
  ```
203
214
 
204
- No API keys are needed. Everything runs against stored responses on `127.0.0.1`. The first run downloads any-llm
205
- into `.wiretruth/envs/`; later runs work offline. `uv run wiretruth run --shim shims/reference` runs the
206
- built-in reference client, which must pass every cell. The scenarios, fixtures and shims live in this repository,
207
- so wiretruth runs from a clone.
215
+ The LiteLLM run grades its 100 cells 86 PASS, 13 WRONG and 1 UNSUPPORTED. Every cell, with its evidence, is in
216
+ [`results/litellm/1.105.5.json`](https://github.com/kartsan03/wiretruth/blob/main/results/litellm/1.105.5.json).
217
+ The pydantic-ai run grades its 100 cells 63 PASS, 16 LOSSY, 6 WRONG and 15 UNSUPPORTED (native Ollama), in
218
+ [`results/pydantic-ai/2.61.0.json`](https://github.com/kartsan03/wiretruth/blob/main/results/pydantic-ai/2.61.0.json).
219
+ The Vercel AI SDK run grades its 100 cells 84 PASS and 16 UNSUPPORTED (native Ollama, and the Anthropic
220
+ forced-tool fixture, since current Claude models get native structured output), in
221
+ [`results/vercel-ai/7.0.153.json`](https://github.com/kartsan03/wiretruth/blob/main/results/vercel-ai/7.0.153.json).
222
+ The TanStack AI run grades its 100 cells 48 PASS, 2 LOSSY, 2 WRONG and 48 UNSUPPORTED, in
223
+ [`results/tanstack-ai/1.0.0.json`](https://github.com/kartsan03/wiretruth/blob/main/results/tanstack-ai/1.0.0.json).
224
+ Its `chat()` always requests a streamed response, so the non-streamed fixtures don't apply to it.
225
+
226
+ No API keys are needed. Everything runs against stored responses on `127.0.0.1`. The first run downloads the
227
+ selected library into `.wiretruth/envs/`; later runs work offline. `uv run wiretruth run --shim shims/reference`
228
+ runs the built-in reference client, which must pass every cell. The scenarios, fixtures and shims live in this
229
+ repository, so wiretruth runs from a clone.
208
230
 
209
231
  Releases are also on [PyPI](https://pypi.org/project/wiretruth/) (`pip install wiretruth`). The package is the
210
- command only, so run it inside a checkout of the matching release tag (for example `v0.2.0`); on `main`, use
232
+ command only, so run it inside a checkout of the matching release tag (for example `v0.3.0`); on `main`, use
211
233
  `uv run` as above.
212
234
 
213
235
  ## Add your library
214
236
 
215
237
  A shim is a small program. It reads a call spec as JSON on stdin, calls your library the way its docs show, and
216
238
  prints what the library reported as JSON on stdout. The
217
- [any-llm shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/any-llm/shim.py) is 150 lines. The
239
+ [any-llm shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/any-llm/shim.py),
240
+ [LiteLLM shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/litellm/shim.py),
241
+ [pydantic-ai shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/pydantic-ai/shim.py),
242
+ [Vercel AI SDK shim](https://github.com/kartsan03/wiretruth/blob/main/shims/js/vercel-ai/shim.mjs) and
243
+ [TanStack AI shim](https://github.com/kartsan03/wiretruth/blob/main/shims/js/tanstack-ai/shim.mjs) are examples. The
218
244
  protocol is in [shims/README.md](https://github.com/kartsan03/wiretruth/blob/main/shims/README.md); to propose a
219
245
  library, [open a new-library issue](https://github.com/kartsan03/wiretruth/issues/new?template=new-library.yml).
220
246
 
@@ -238,7 +264,8 @@ library, [open a new-library issue](https://github.com/kartsan03/wiretruth/issue
238
264
  ## Author note
239
265
 
240
266
  I've contributed fixes to libraries that wiretruth tests or will test:
241
- [mozilla-ai/any-llm#1389](https://github.com/mozilla-ai/any-llm/pull/1389) and
267
+ [mozilla-ai/any-llm#1389](https://github.com/mozilla-ai/any-llm/pull/1389),
268
+ [mozilla-ai/any-llm#1433](https://github.com/mozilla-ai/any-llm/pull/1433) and
242
269
  [TanStack/ai#1548](https://github.com/TanStack/ai/pull/1548). Grades are computed by the code in this repository.
243
270
  If you think a grade is wrong, please
244
271
  [open a grade dispute](https://github.com/kartsan03/wiretruth/issues/new?template=grade-dispute.yml).
@@ -1,3 +1,3 @@
1
1
  """wiretruth: replay real LLM provider responses through LLM SDKs and grade what they report."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -291,7 +291,7 @@ def tool_calls_match_truth(
291
291
  def no_complete_tool_call_from_partial_args(
292
292
  obs: Observation, truth: Truth, req_log: Sequence[LoggedRequest], args: JsonObject
293
293
  ) -> CheckResult:
294
- """Reject parsed calls returned without an error when their truth arguments were partial.
294
+ """Reject parsed calls without an error when truth arguments were partial or unmatched.
295
295
 
296
296
  A raised outcome, an error object or a primary ``error`` finish satisfies first. Match truth
297
297
  by id, falling back to position when no id matches. Unparsed arguments are visibly partial
@@ -305,7 +305,7 @@ def no_complete_tool_call_from_partial_args(
305
305
  (item for item in truth.tool_calls if item.id is not None and item.id == call.id),
306
306
  truth.tool_calls[index] if index < len(truth.tool_calls) else None,
307
307
  )
308
- if call.arguments_parsed_ok and expected is not None and not expected.arguments_parsed_ok:
308
+ if call.arguments_parsed_ok and (expected is None or not expected.arguments_parsed_ok):
309
309
  return CheckResult(
310
310
  "CONTRADICTED",
311
311
  f"tool_calls[{index}] id={_show(call.id)} returned without error; "
@@ -18,7 +18,9 @@ from wiretruth import __version__
18
18
  from wiretruth.catalog import render as render_catalog
19
19
  from wiretruth.envs import (
20
20
  RunError,
21
+ build_javascript_env,
21
22
  find_library,
23
+ installed_javascript_packages,
22
24
  installed_packages,
23
25
  list_envs,
24
26
  prune_envs,
@@ -147,8 +149,12 @@ def build_parser() -> argparse.ArgumentParser:
147
149
  )
148
150
  elif name == "envs":
149
151
  action = subparser.add_mutually_exclusive_group()
150
- action.add_argument("--list", action="store_true", help="list environments (default)")
151
- action.add_argument("--prune", action="store_true", help="remove stale environments")
152
+ action.add_argument(
153
+ "--list", action="store_true", help="list Python environments (default)"
154
+ )
155
+ action.add_argument(
156
+ "--prune", action="store_true", help="remove stale Python environments"
157
+ )
152
158
  action.add_argument(
153
159
  "--build", metavar="NAME", help="build or reuse a library environment"
154
160
  )
@@ -317,6 +323,8 @@ def run_record(args: argparse.Namespace) -> int:
317
323
  if args.dry_run:
318
324
  for scenario, request in zip(scenarios, requests, strict=True):
319
325
  print(f"# {scenario.id}")
326
+ if scenario.warm_up:
327
+ print("# Warm-up: send this request twice; keep only the second response.")
320
328
  print(describe_request(provider, request, provider.API_BASE))
321
329
  return 0
322
330
  key = api_key(provider, os.environ)
@@ -478,8 +486,13 @@ def run_shim(args: argparse.Namespace) -> int:
478
486
  if saved is not None:
479
487
  try:
480
488
  interpreter = resolve_interpreter(Path(), manifest)
481
- if interpreter is not None:
482
- changes = package_changes(saved.packages, installed_packages(interpreter))
489
+ if manifest.language == "javascript" or interpreter is not None:
490
+ packages = (
491
+ installed_javascript_packages(shim_dir)
492
+ if manifest.language == "javascript"
493
+ else installed_packages(cast(str, interpreter))
494
+ )
495
+ changes = package_changes(saved.packages, packages)
483
496
  if changes:
484
497
  print(f"packages differ from {path}: {', '.join(changes)}")
485
498
  except (RunError, OSError) as error:
@@ -499,6 +512,9 @@ def run_envs(args: argparse.Namespace) -> int:
499
512
  if args.build is not None:
500
513
  shim_dir = find_library(Path(), args.build)
501
514
  manifest = load_shim_manifest(shim_dir / "wiretruth-shim.json")
515
+ if manifest.language == "javascript":
516
+ print(build_javascript_env(shim_dir))
517
+ return 0
502
518
  interpreter = resolve_interpreter(Path(), manifest)
503
519
  if interpreter is None:
504
520
  raise RunError("only Python library environments can be built")
@@ -1,7 +1,9 @@
1
- """Cached Python environments for pinned library shims."""
1
+ """Cached environments for pinned library shims."""
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import os
6
+ import re
5
7
  import shutil
6
8
  import subprocess
7
9
  import sys
@@ -99,6 +101,44 @@ def installed_packages(interpreter: str) -> dict[str, str]:
99
101
  return parse_installed_packages(result.stdout)
100
102
 
101
103
 
104
+ def build_javascript_env(shim_dir: Path) -> Path:
105
+ path = shim_dir.resolve() / "node_modules"
106
+ marker = path / MARKER
107
+ try:
108
+ digest = hashlib.sha256((shim_dir / "package-lock.json").read_bytes()).hexdigest()
109
+ if marker.is_file() and marker.read_bytes() == digest.encode():
110
+ return path
111
+ marker.unlink(missing_ok=True)
112
+ command = ["npm", "ci", "--ignore-scripts", "--prefix", str(shim_dir.resolve())]
113
+ result = subprocess.run(command, stdout=sys.stderr, check=False)
114
+ if result.returncode:
115
+ raise RunError(f"{' '.join(command)} exited with code {result.returncode}")
116
+ path.mkdir(exist_ok=True)
117
+ marker.write_text(digest)
118
+ except OSError as error:
119
+ raise RunError(f"JavaScript environment: {error}") from error
120
+ return path
121
+
122
+
123
+ def installed_javascript_packages(shim_dir: Path) -> dict[str, str]:
124
+ path = shim_dir / "node_modules" / ".package-lock.json"
125
+ try:
126
+ packages = json.loads(path.read_text())["packages"]
127
+ if not isinstance(packages, dict):
128
+ raise ValueError("expected a packages object")
129
+ installed = {}
130
+ for name, entry in packages.items():
131
+ match = re.fullmatch(r"node_modules/((?:@[^/]+/)?[^/]+)", name)
132
+ if match is not None:
133
+ version = entry["version"]
134
+ if not isinstance(version, str) or not version:
135
+ raise ValueError("expected a package version")
136
+ installed[match[1]] = version
137
+ return installed
138
+ except (OSError, ValueError, KeyError, TypeError) as error:
139
+ raise RunError(f"{path}: cannot read installed packages: {error}") from error
140
+
141
+
102
142
  def list_envs(root: Path) -> tuple[tuple[Path, bool], ...]:
103
143
  """List cached directories and whether their installed pins match a current manifest."""
104
144
  manifests = [load_shim_manifest(path) for path in (root / "shims").rglob("wiretruth-shim.json")]
@@ -575,6 +575,7 @@ class Scenario:
575
575
  call_overrides: Mapping[str, Call] # the full call, override applied, per overridden provider
576
576
  grading_notes: str | None
577
577
  references: tuple[str, ...]
578
+ warm_up: bool = False
578
579
 
579
580
  def call_for(self, provider: str) -> Call:
580
581
  return self.call_overrides.get(provider, self.call)
@@ -610,6 +611,7 @@ def load_scenario(path: Path | str) -> Scenario:
610
611
  ),
611
612
  grading_notes=_opt_str(doc, "grading_notes"),
612
613
  references=_strings(doc, "references"),
614
+ warm_up=cast(bool, _object(doc, "recording").get("warm_up", False)),
613
615
  )
614
616
 
615
617
 
@@ -98,6 +98,26 @@ def _requirements(
98
98
  return tuple(results)
99
99
 
100
100
 
101
+ def is_model_lookup(
102
+ provider: str, request: LoggedRequest, request_log: Sequence[LoggedRequest]
103
+ ) -> bool:
104
+ """Native Ollama's unmatched ``POST /api/show`` beside a ``/api/chat`` that reached its fixture.
105
+
106
+ Model discovery is not an inference call. It stays in the request log and its count, but
107
+ neither the mismatch check nor the timeout rule treats it as the inference request.
108
+ """
109
+ return (
110
+ provider == "ollama"
111
+ and request.method == "POST"
112
+ and request.path == "/api/show"
113
+ and request.exchange is None
114
+ and any(
115
+ item.method == "POST" and item.path == "/api/chat" and item.exchange is not None
116
+ for item in request_log
117
+ )
118
+ )
119
+
120
+
101
121
  def grade_cell(
102
122
  run: ShimRun,
103
123
  *,
@@ -117,7 +137,8 @@ def grade_cell(
117
137
  """
118
138
  applicable = _applicable(scenario, provider)
119
139
  received = bool(run.request_log)
120
- served = received and run.request_log[-1].response_complete
140
+ inference = [r for r in run.request_log if not is_model_lookup(provider, r, run.request_log)]
141
+ served = bool(inference) and inference[-1].response_complete
121
142
  if run.timed_out:
122
143
  if served:
123
144
  return CellGrade("HANG", 1, (), ("timed out after the response was served",))
@@ -38,7 +38,9 @@ from wiretruth.formats import (
38
38
  load_document,
39
39
  load_fixture,
40
40
  load_scenario,
41
+ load_shim_manifest,
41
42
  parse_json,
43
+ read_json,
42
44
  )
43
45
  from wiretruth.providers import PROVIDERS
44
46
  from wiretruth.secret_scan import scan_headers, scan_text
@@ -279,5 +281,17 @@ def lint(root: Path) -> Report:
279
281
  message = f"{library[key]!r} doesn't match the file path: expected {expected!r}"
280
282
  problem(path, f"$.library.{key}", message)
281
283
 
284
+ for path in sorted((root / "shims").glob("*/*/wiretruth-shim.json")):
285
+ report.files += 1
286
+ _scan_secrets(path, report)
287
+ manifest = _load(load_shim_manifest, path, report)
288
+ if manifest is not None and manifest.language == "javascript":
289
+ package_path = path.with_name("package.json")
290
+ report.files += 1
291
+ _scan_secrets(package_path, report)
292
+ package = _load(read_json, package_path, report)
293
+ if not isinstance(package, dict) or package.get("dependencies") != manifest.packages:
294
+ problem(path, "$.packages", "must equal package.json dependencies exactly")
295
+
282
296
  report.problems = sorted(map(_quiet, report.problems), key=lambda found: found.file)
283
297
  return report
@@ -451,6 +451,10 @@ def record_scenario(
451
451
  target = destination(
452
452
  root, provider.ID, scenario.id, replace=replace
453
453
  ) # before paying for a call
454
+ if scenario.warm_up:
455
+ warm_up = send(provider, request, base_url, key)
456
+ if not 200 <= warm_up.status < 300:
457
+ raise RecordError(f"warm-up returned HTTP {warm_up.status}; nothing was written")
454
458
  recorded_at = datetime.now(UTC)
455
459
  response = send(provider, request, base_url, key)
456
460
  raw = b"".join(response.pieces)
@@ -464,6 +468,14 @@ def record_scenario(
464
468
  truth = provider.extract_truth([build_exchange(exchange)], request.body)
465
469
  except ValueError as error:
466
470
  raise RecordError(f"can't compute the truth: {error}") from None
471
+ if scenario.warm_up: # a warm-up exists to get a cache hit; a fixture without one tests nothing
472
+ usage = truth.get("usage")
473
+ cached = usage.get("cached_input") if isinstance(usage, dict) else None
474
+ if not isinstance(cached, int) or cached <= 0:
475
+ raise RecordError(
476
+ f"the response after the warm-up reports no cached input tokens "
477
+ f"(HTTP {response.status}, cached input {cached}); nothing was written"
478
+ )
467
479
  path = target.path
468
480
  fixture: JsonObject = {
469
481
  "format": "wiretruth.fixture/v1",
@@ -11,7 +11,13 @@ from typing import NamedTuple, cast
11
11
 
12
12
  from wiretruth import __version__
13
13
  from wiretruth.checks import Status
14
- from wiretruth.envs import RunError, installed_packages, resolve_interpreter
14
+ from wiretruth.envs import (
15
+ RunError,
16
+ find_library,
17
+ installed_javascript_packages,
18
+ installed_packages,
19
+ resolve_interpreter,
20
+ )
15
21
  from wiretruth.formats import (
16
22
  FormatError,
17
23
  JsonObject,
@@ -106,16 +112,20 @@ def collect_results(root: Path, report: RunReport, manifest: ShimManifest) -> Js
106
112
  if all((server, repository, run_id))
107
113
  else None
108
114
  )
109
- interpreter = resolve_interpreter(root, manifest)
110
- if interpreter is None:
111
- raise RunError("--write-results needs a Python shim environment")
115
+ if manifest.language == "javascript":
116
+ packages = installed_javascript_packages(find_library(root, manifest.name))
117
+ else:
118
+ interpreter = resolve_interpreter(root, manifest)
119
+ if interpreter is None:
120
+ raise RunError("--write-results needs a Python shim environment")
121
+ packages = installed_packages(interpreter)
112
122
  return results_document(
113
123
  report,
114
124
  manifest,
115
125
  at=datetime.now(UTC),
116
126
  git_sha=git_sha,
117
127
  ci_run=ci_run,
118
- packages=installed_packages(interpreter),
128
+ packages=packages,
119
129
  )
120
130
 
121
131
 
@@ -18,7 +18,7 @@ from typing import cast, get_args
18
18
 
19
19
  from wiretruth.checks import Status
20
20
  from wiretruth.envs import RunError as RunError
21
- from wiretruth.envs import resolve_interpreter
21
+ from wiretruth.envs import build_javascript_env, resolve_interpreter
22
22
  from wiretruth.formats import (
23
23
  CallSpec,
24
24
  Fixture,
@@ -33,7 +33,7 @@ from wiretruth.formats import (
33
33
  parse_json,
34
34
  read_json,
35
35
  )
36
- from wiretruth.grading import CellGrade, Grade, ShimRun, grade_cell
36
+ from wiretruth.grading import CellGrade, Grade, ShimRun, grade_cell, is_model_lookup
37
37
  from wiretruth.providers import PROVIDERS
38
38
  from wiretruth.recorder import find_scenarios
39
39
  from wiretruth.replay import BASE_PATHS, HOST, LoggedRequest, ReplayServer
@@ -156,6 +156,8 @@ def process_to_run(
156
156
  invalid = (str(error).splitlines() or [type(error).__name__])[0][:300]
157
157
  mismatches = []
158
158
  for index, request in enumerate(request_log):
159
+ if is_model_lookup(provider, request, request_log):
160
+ continue
159
161
  if request.exchange is None:
160
162
  mismatches.append(f"request {index}: no fixture for {request.method} {request.path}")
161
163
  requested = PROVIDERS[provider].requested_stream(request.path, request.json_body)
@@ -263,6 +265,8 @@ def run_suite(
263
265
  shim_dir = shim_dir.resolve()
264
266
  manifest = load_shim_manifest(shim_dir / "wiretruth-shim.json")
265
267
  cells = find_cells(root, pattern, provider)
268
+ if manifest.language == "javascript":
269
+ build_javascript_env(shim_dir)
266
270
  interpreter = resolve_interpreter(root, manifest)
267
271
  stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
268
272
  stderr_dir = root / ".wiretruth" / "runs" / stamp
@@ -52,6 +52,18 @@
52
52
  }
53
53
  },
54
54
  "grading_notes": { "$ref": "common.schema.json#/$defs/nonempty_string" },
55
+ "recording": {
56
+ "description": "Recorder options; these do not change the replayed call.",
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "properties": {
60
+ "warm_up": {
61
+ "description": "Send the identical request once first, discard its response, and record only the second exchange, which must report cached input tokens.",
62
+ "type": "boolean",
63
+ "default": false
64
+ }
65
+ }
66
+ },
55
67
  "references": {
56
68
  "type": "array",
57
69
  "items": { "$ref": "common.schema.json#/$defs/url" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wiretruth
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Replays real LLM provider responses through LLM SDKs and checks whether each SDK reports what actually happened.
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/kartsan03/wiretruth
@@ -38,8 +38,10 @@ happened.
38
38
  [![PyPI](https://img.shields.io/pypi/v/wiretruth?label=PyPI)](https://pypi.org/project/wiretruth/)
39
39
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/kartsan03/wiretruth/blob/main/LICENSE)
40
40
 
41
- So far wiretruth tests one library, any-llm, on six APIs: OpenAI Chat Completions and Responses, Ollama's native
42
- and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic Messages. That is 24 scenarios and 95 cells.
41
+ wiretruth tests any-llm, LiteLLM, pydantic-ai, the Vercel AI SDK and TanStack AI across six APIs: OpenAI Chat
42
+ Completions and Responses, Ollama's native and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic
43
+ Messages. That is 24 scenarios and 100 cells per library. pydantic-ai and the Vercel AI SDK support only the OpenAI-compatible Ollama
44
+ endpoint; their native Ollama cells are UNSUPPORTED.
43
45
  The Anthropic responses are transcribed from Anthropic's documentation rather than recorded: each of those fixtures
44
46
  is marked `documented` and links the page it comes from. More providers and libraries follow, and a public
45
47
  results matrix arrives in v0.4. Until then, the grades are in
@@ -52,11 +54,21 @@ A model hits its output token limit halfway through a JSON object. The provider
52
54
  parse error, nothing tells you that a higher limit would fix it. If it returns the partial object as a success,
53
55
  your pipeline stores an incomplete record.
54
56
 
55
- wiretruth has this case recorded as `truncation.structured-output` on `openai-chat`. Replayed through any-llm:
57
+ wiretruth has this case recorded as `truncation.structured-output` on `openai-chat`. Replayed through each library:
56
58
 
57
59
  | Library | What the caller gets | Grade |
58
60
  |---|---|---|
59
- | any-llm 1.33.0 | Raises `openai.LengthFinishReasonError`. Its `completion` has `finish_reason: "length"` and the token usage. | ✓ PASS |
61
+ | any-llm 1.40.2 | Raises `openai.LengthFinishReasonError`. Its `completion` has `finish_reason: "length"` and the token usage. | ✓ PASS |
62
+ | LiteLLM 1.105.5 | Returns the cut-off JSON as text in `message.content` with `finish_reason: "length"` and parses none of it. | ✓ PASS |
63
+ | pydantic-ai 2.61.0 | Retries once, then raises `UnexpectedModelBehavior("Exceeded maximum output retries (1)")`. The `length` finish is only in the messages that `capture_run_messages()` keeps. | ~ LOSSY |
64
+ | Vercel AI SDK 7.0.153 | Raises `NoObjectGeneratedError` with `finishReason: "length"` and the token usage. | ✓ PASS |
65
+
66
+ pydantic-ai's grades for structured output use `NativeOutput`, the provider's own JSON mode, because that is how
67
+ the fixtures were recorded. It is opt-in: by default pydantic-ai asks for a forced tool call instead on every API
68
+ here except Anthropic, and no scenario covers that path yet. On `gpt-6-luna` that default request is rejected with the same 400
69
+ that `request.tools-with-reasoning-chat` replays (function tools without `reasoning_effort: "none"`). The Vercel AI
70
+ SDK sends a JSON schema by default on every API here except OpenAI-compatible Ollama, where it defaults to JSON-object
71
+ mode; its shim opts into the documented `supportsStructuredOutputs` setting there.
60
72
 
61
73
  wiretruth runs the same recorded edge cases through every library it supports and grades each one against what the
62
74
  provider sent.
@@ -93,6 +105,7 @@ Each scenario lists its requirements. MUST requirements set the grade. SHOULD re
93
105
  partial text in the truncation scenarios, add a note when they aren't met. How grades are computed:
94
106
  [docs/grading.md](https://github.com/kartsan03/wiretruth/blob/main/docs/grading.md).
95
107
  Scenario catalog: [docs/scenarios.md](https://github.com/kartsan03/wiretruth/blob/main/docs/scenarios.md).
108
+ Fixed-bug validation: [docs/validation.md](https://github.com/kartsan03/wiretruth/blob/main/docs/validation.md).
96
109
 
97
110
  ## Quickstart
98
111
 
@@ -101,12 +114,21 @@ You need Python 3.11 or newer and [uv](https://docs.astral.sh/uv/), which builds
101
114
  ```bash
102
115
  git clone https://github.com/kartsan03/wiretruth && cd wiretruth
103
116
  uv run wiretruth run --lib any-llm
117
+ uv run wiretruth run --lib litellm
118
+ uv run wiretruth run --lib pydantic-ai
119
+ uv run wiretruth run --lib vercel-ai
120
+ uv run wiretruth run --lib tanstack-ai
104
121
  ```
105
122
 
123
+ The Vercel AI SDK and TanStack AI shims also need Node.js and npm. The runner builds their pinned dependencies
124
+ automatically; see [Shims](shims/README.md) for supported providers and structured-output modes.
125
+
126
+ The any-llm run reports (see [`results/any-llm/1.40.2.json`](https://github.com/kartsan03/wiretruth/blob/main/results/any-llm/1.40.2.json)):
127
+
106
128
  ```text
107
129
  scenario anthropic gemini ollama ollama-openai openai-chat openai-responses
108
130
  finish.stop-sequence ✓ PASS ✓ PASS ✓ PASS ✓ PASS
109
- finish.unknown-value ✗ WRONG ✗ WRONG ✗ WRONG ✗ WRONG ✗ WRONG ✓ PASS
131
+ finish.unknown-value ✗ WRONG ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
110
132
  request.max-tokens-field ✓ PASS
111
133
  request.max-tokens-ollama-openai ✗ WRONG
112
134
  request.no-injected-options ✓ PASS
@@ -116,8 +138,8 @@ stream.missing-finish ~ LOSSY ~ LOSSY ~ LOSSY ~ LOS
116
138
  stream.missing-finish-abort ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
117
139
  stream.utf8 ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
118
140
  stream.utf8-split ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
119
- tools.parallel-calls ✓ PASS ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
120
- tools.parallel-calls-stream ✓ PASS ✗ WRONG ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
141
+ tools.parallel-calls ✓ PASS ✓ PASS ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
142
+ tools.parallel-calls-stream ✓ PASS ✓ PASS ✗ WRONG ✓ PASS ✓ PASS ✓ PASS
121
143
  truncation.reasoning-only ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
122
144
  truncation.structured-output ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS ~ LOSSY
123
145
  truncation.structured-output-forced-tool – UNSUPPORTED
@@ -126,8 +148,8 @@ truncation.text-stream ✓ PASS ✓ PASS ✓ PASS
126
148
  truncation.text-stream-usage ✓ PASS ✓ PASS
127
149
  truncation.tool-call-args ✓ PASS ✓ PASS ✓ PASS
128
150
  truncation.tool-call-args-stream ✓ PASS ✓ PASS ✓ PASS
129
- usage.cached-tokens ✓ PASS
130
- usage.reasoning-tokens ✓ PASS
151
+ usage.cached-tokens ✓ PASS ✓ PASS ✓ PASS
152
+ usage.reasoning-tokens ✓ PASS ✓ PASS ✓ PASS ✓ PASS
131
153
  usage.stream-final-chunk ✓ PASS ✓ PASS ✓ PASS ✓ PASS ✓ PASS
132
154
 
133
155
  finish.stop-sequence gemini
@@ -146,18 +168,6 @@ finish.unknown-value ollama
146
168
  MUST finish-is-other: CONTRADICTED: finish.value="stop" (primary: choices[0].finish_reason); truth.finish="other"; required finish="other"
147
169
  SHOULD raw-finish-reported: CONTRADICTED: finish.raw="stop"; truth.raw_finish="wiretruth_unknown_reason"
148
170
 
149
- finish.unknown-value ollama-openai
150
- MUST response-returned: CONTRADICTED: outcome=raised; error.category="validation"; truth.error=null
151
- MUST finish-is-other: NOT_OBSERVABLE: finish.value=null (absent); truth.finish="other"; required finish="other"
152
- MUST text-reported: NOT_OBSERVABLE: text.value=null (absent); truth.text="Octopuses, like many marine invertebrates, employ sophisticated methods to camouflage"
153
- SHOULD raw-finish-reported: NOT_OBSERVABLE: finish.raw=null; truth.raw_finish="wiretruth_unknown_reason"
154
-
155
- finish.unknown-value openai-chat
156
- MUST response-returned: CONTRADICTED: outcome=raised; error.category="validation"; truth.error=null
157
- MUST finish-is-other: NOT_OBSERVABLE: finish.value=null (absent); truth.finish="other"; required finish="other"
158
- MUST text-reported: NOT_OBSERVABLE: text.value=null (absent); truth.text="Io, Europa, and Ganymede."
159
- SHOULD raw-finish-reported: NOT_OBSERVABLE: finish.raw=null; truth.raw_finish="wiretruth_unknown_reason"
160
-
161
171
  request.max-tokens-ollama-openai ollama-openai
162
172
  MUST limit-field: CONTRADICTED: request[0].json_body.max_tokens=absent; required=16
163
173
 
@@ -190,18 +200,16 @@ stream.utf8-split gemini
190
200
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
191
201
 
192
202
  tools.parallel-calls gemini
193
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="call_-5201471136765337120_0", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_120847", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="call_-5201471136765337120_1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_120848", name="get_weather", args={"city": "Zürich"}, parsed=true
194
203
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
195
204
 
196
205
  tools.parallel-calls ollama
197
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="3972c760-858b-4728-a05a-279fc6e6a649", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_9epwsqu6", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="4d3747a6-5e75-4637-b82e-f269adbba01e", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_bvzy1ra9", name="get_weather", args={"city": "Zürich"}, parsed=true
206
+ MUST calls-reported: CONTRADICTED: tool_calls[0] id="77ecdd8a-566d-4ba1-b098-d7a3b3082999", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_9epwsqu6", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="3a4cba16-a57a-4657-b28a-8ac89eaea7d9", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_bvzy1ra9", name="get_weather", args={"city": "Zürich"}, parsed=true
198
207
 
199
208
  tools.parallel-calls-stream gemini
200
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="call_-1615951408272134819_0", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_89893", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="call_-1615951408272134819_1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_89896", name="get_weather", args={"city": "Zürich"}, parsed=true
201
209
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
202
210
 
203
211
  tools.parallel-calls-stream ollama
204
- MUST calls-reported: CONTRADICTED: tool_calls[0] id="8b75c529-6eb6-4a63-a375-4551bfe56f39", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_8uaqob2r", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="8e864d64-6145-4ca4-81ba-cbdbad0523e1", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_nyjtbrz4", name="get_weather", args={"city": "Zürich"}, parsed=true
212
+ MUST calls-reported: CONTRADICTED: tool_calls[0] id="fc700b93-a9ac-4907-8da1-a7b38a28658c", name="get_weather", args={"city": "São Paulo"}, parsed=true; truth id="call_8uaqob2r", name="get_weather", args={"city": "São Paulo"}, parsed=true; tool_calls[1] id="265e0a1f-ef64-4209-b6b9-e3b5c9e3a181", name="get_weather", args={"city": "Zürich"}, parsed=true; truth id="call_nyjtbrz4", name="get_weather", args={"city": "Zürich"}, parsed=true
205
213
 
206
214
  truncation.structured-output gemini
207
215
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
@@ -212,7 +220,7 @@ truncation.structured-output openai-responses
212
220
  SHOULD usage-reported: NOT_OBSERVABLE: usage.input=null (absent); truth.usage.input=68; usage.output=null (absent); truth.usage.output=24
213
221
 
214
222
  truncation.structured-output-forced-tool anthropic
215
- any-llm completion maps typed response_format to native output_config.format and has no structured parser for an explicitly forced tool. (https://github.com/mozilla-ai/any-llm/blob/1.33.0/src/any_llm/providers/anthropic/utils.py#L628-L655)
223
+ any-llm completion maps typed response_format to native output_config.format and has no structured parser for an explicitly forced tool. (https://github.com/mozilla-ai/any-llm/blob/1.40.2/src/any_llm/providers/anthropic/utils.py#L660-L686)
216
224
 
217
225
  truncation.text gemini
218
226
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
@@ -223,27 +231,45 @@ truncation.text-stream gemini
223
231
  truncation.text-stream-usage gemini
224
232
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
225
233
 
234
+ usage.reasoning-tokens anthropic
235
+ SHOULD reasoning-reported: NOT_OBSERVABLE: usage.reasoning=null (absent); truth.usage.reasoning=312
236
+
226
237
  usage.stream-final-chunk gemini
227
238
  shim: Gemini reasoning_effort none is unsupported by any-llm; omitted to use the model default.
228
239
 
229
- any-llm 1.33.0: 95 cells, 78 PASS, 6 LOSSY, 10 WRONG, 1 UNSUPPORTED
230
- stderr: .wiretruth/runs/20261013T134918.993664Z
240
+ any-llm 1.40.2: 100 cells, 87 PASS, 6 LOSSY, 6 WRONG, 1 UNSUPPORTED
241
+ stderr: .wiretruth/runs/20261109T005301.682490Z
231
242
  ```
232
243
 
233
- No API keys are needed. Everything runs against stored responses on `127.0.0.1`. The first run downloads any-llm
234
- into `.wiretruth/envs/`; later runs work offline. `uv run wiretruth run --shim shims/reference` runs the
235
- built-in reference client, which must pass every cell. The scenarios, fixtures and shims live in this repository,
236
- so wiretruth runs from a clone.
244
+ The LiteLLM run grades its 100 cells 86 PASS, 13 WRONG and 1 UNSUPPORTED. Every cell, with its evidence, is in
245
+ [`results/litellm/1.105.5.json`](https://github.com/kartsan03/wiretruth/blob/main/results/litellm/1.105.5.json).
246
+ The pydantic-ai run grades its 100 cells 63 PASS, 16 LOSSY, 6 WRONG and 15 UNSUPPORTED (native Ollama), in
247
+ [`results/pydantic-ai/2.61.0.json`](https://github.com/kartsan03/wiretruth/blob/main/results/pydantic-ai/2.61.0.json).
248
+ The Vercel AI SDK run grades its 100 cells 84 PASS and 16 UNSUPPORTED (native Ollama, and the Anthropic
249
+ forced-tool fixture, since current Claude models get native structured output), in
250
+ [`results/vercel-ai/7.0.153.json`](https://github.com/kartsan03/wiretruth/blob/main/results/vercel-ai/7.0.153.json).
251
+ The TanStack AI run grades its 100 cells 48 PASS, 2 LOSSY, 2 WRONG and 48 UNSUPPORTED, in
252
+ [`results/tanstack-ai/1.0.0.json`](https://github.com/kartsan03/wiretruth/blob/main/results/tanstack-ai/1.0.0.json).
253
+ Its `chat()` always requests a streamed response, so the non-streamed fixtures don't apply to it.
254
+
255
+ No API keys are needed. Everything runs against stored responses on `127.0.0.1`. The first run downloads the
256
+ selected library into `.wiretruth/envs/`; later runs work offline. `uv run wiretruth run --shim shims/reference`
257
+ runs the built-in reference client, which must pass every cell. The scenarios, fixtures and shims live in this
258
+ repository, so wiretruth runs from a clone.
237
259
 
238
260
  Releases are also on [PyPI](https://pypi.org/project/wiretruth/) (`pip install wiretruth`). The package is the
239
- command only, so run it inside a checkout of the matching release tag (for example `v0.2.0`); on `main`, use
261
+ command only, so run it inside a checkout of the matching release tag (for example `v0.3.0`); on `main`, use
240
262
  `uv run` as above.
241
263
 
242
264
  ## Add your library
243
265
 
244
266
  A shim is a small program. It reads a call spec as JSON on stdin, calls your library the way its docs show, and
245
267
  prints what the library reported as JSON on stdout. The
246
- [any-llm shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/any-llm/shim.py) is 150 lines. The
268
+ [any-llm shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/any-llm/shim.py),
269
+ [LiteLLM shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/litellm/shim.py),
270
+ [pydantic-ai shim](https://github.com/kartsan03/wiretruth/blob/main/shims/python/pydantic-ai/shim.py),
271
+ [Vercel AI SDK shim](https://github.com/kartsan03/wiretruth/blob/main/shims/js/vercel-ai/shim.mjs) and
272
+ [TanStack AI shim](https://github.com/kartsan03/wiretruth/blob/main/shims/js/tanstack-ai/shim.mjs) are examples. The
247
273
  protocol is in [shims/README.md](https://github.com/kartsan03/wiretruth/blob/main/shims/README.md); to propose a
248
274
  library, [open a new-library issue](https://github.com/kartsan03/wiretruth/issues/new?template=new-library.yml).
249
275
 
@@ -267,7 +293,8 @@ library, [open a new-library issue](https://github.com/kartsan03/wiretruth/issue
267
293
  ## Author note
268
294
 
269
295
  I've contributed fixes to libraries that wiretruth tests or will test:
270
- [mozilla-ai/any-llm#1389](https://github.com/mozilla-ai/any-llm/pull/1389) and
296
+ [mozilla-ai/any-llm#1389](https://github.com/mozilla-ai/any-llm/pull/1389),
297
+ [mozilla-ai/any-llm#1433](https://github.com/mozilla-ai/any-llm/pull/1433) and
271
298
  [TanStack/ai#1548](https://github.com/TanStack/ai/pull/1548). Grades are computed by the code in this repository.
272
299
  If you think a grade is wrong, please
273
300
  [open a grade dispute](https://github.com/kartsan03/wiretruth/issues/new?template=grade-dispute.yml).
File without changes
File without changes
File without changes
File without changes