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.
- {wiretruth-0.2.0/src/wiretruth.egg-info → wiretruth-0.3.0}/PKG-INFO +63 -36
- {wiretruth-0.2.0 → wiretruth-0.3.0}/README.md +62 -35
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/__init__.py +1 -1
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/checks.py +2 -2
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/cli.py +20 -4
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/envs.py +41 -1
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/formats.py +2 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/grading.py +22 -1
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/lint.py +14 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/recorder.py +12 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/results.py +15 -5
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/runner.py +6 -2
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/scenario.schema.json +12 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0/src/wiretruth.egg-info}/PKG-INFO +63 -36
- {wiretruth-0.2.0 → wiretruth-0.3.0}/LICENSE +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/pyproject.toml +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/setup.cfg +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/__main__.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/catalog.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/__init__.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/anthropic.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/gemini.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/ollama.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/ollama_openai.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/openai_chat.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/providers/openai_responses.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/replay.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/bug-classes.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/call.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/common.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/fixture.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/observation.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/results.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/shim-manifest.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/schemas/upstream.schema.json +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/secret_scan.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth/transforms.py +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/SOURCES.txt +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/dependency_links.txt +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/entry_points.txt +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/requires.txt +0 -0
- {wiretruth-0.2.0 → wiretruth-0.3.0}/src/wiretruth.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/wiretruth/)
|
|
39
39
|
[](https://github.com/kartsan03/wiretruth/blob/main/LICENSE)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
120
|
-
tools.parallel-calls-stream ✓ 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
|
|
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="
|
|
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="
|
|
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.
|
|
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.
|
|
230
|
-
stderr: .wiretruth/runs/
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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)
|
|
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)
|
|
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
|
[](https://pypi.org/project/wiretruth/)
|
|
10
10
|
[](https://github.com/kartsan03/wiretruth/blob/main/LICENSE)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
91
|
-
tools.parallel-calls-stream ✓ 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
|
|
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="
|
|
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="
|
|
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.
|
|
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.
|
|
201
|
-
stderr: .wiretruth/runs/
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
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)
|
|
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)
|
|
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).
|
|
@@ -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
|
|
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
|
|
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(
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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=
|
|
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.
|
|
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
|
[](https://pypi.org/project/wiretruth/)
|
|
39
39
|
[](https://github.com/kartsan03/wiretruth/blob/main/LICENSE)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
and OpenAI-compatible endpoints, Gemini GenerateContent and Anthropic
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
120
|
-
tools.parallel-calls-stream ✓ 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
|
|
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="
|
|
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="
|
|
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.
|
|
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.
|
|
230
|
-
stderr: .wiretruth/runs/
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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)
|
|
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)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|