jevper 0.2.2__tar.gz → 0.2.3__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.
- {jevper-0.2.2 → jevper-0.2.3}/PKG-INFO +2 -1
- {jevper-0.2.2 → jevper-0.2.3}/README.md +1 -0
- {jevper-0.2.2 → jevper-0.2.3}/docs/api.md +12 -1
- {jevper-0.2.2 → jevper-0.2.3}/docs/internals.md +11 -0
- jevper-0.2.3/docs/local-servers.md +94 -0
- {jevper-0.2.2 → jevper-0.2.3}/docs/methods.md +32 -3
- {jevper-0.2.2 → jevper-0.2.3}/pyproject.toml +1 -1
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/__init__.py +1 -1
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/client.py +191 -17
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/errors.py +14 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/methods.py +28 -2
- {jevper-0.2.2 → jevper-0.2.3}/tests/fakes.py +5 -2
- {jevper-0.2.2 → jevper-0.2.3}/tests/test_auto_method.py +246 -0
- {jevper-0.2.2 → jevper-0.2.3}/tests/test_client_behaviour.py +24 -0
- {jevper-0.2.2 → jevper-0.2.3}/tests/test_logprobs_methods.py +97 -0
- {jevper-0.2.2 → jevper-0.2.3}/.github/workflows/release.yml +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/.gitignore +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/LICENSE +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/docs/few-shot.md +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/docs/reasoning.md +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/labels.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/normalize.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/prompts.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/py.typed +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/reasoning.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/transport.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/src/jevper/types.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/tests/conftest.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/tests/test_few_shot.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/tests/test_grammar_structured.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/tests/test_live.py +0 -0
- {jevper-0.2.2 → jevper-0.2.3}/tests/test_reasoning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: jevper
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: The Jev (System One) interface over any OpenAI-compatible model: noul, choice and score questions in one call
|
|
5
5
|
Project-URL: Homepage, https://github.com/zhulinchng/jevper
|
|
6
6
|
Project-URL: Repository, https://github.com/zhulinchng/jevper
|
|
@@ -249,6 +249,7 @@ LLM_MODEL=gpt-5.6-terra OPENAI_API_KEY=... pytest -q tests/test_live.py
|
|
|
249
249
|
|
|
250
250
|
- [docs/api.md](https://github.com/zhulinchng/jevper/blob/main/docs/api.md) — constructor and `system_one` parameters, answer/usage/debug shapes, errors
|
|
251
251
|
- [docs/methods.md](https://github.com/zhulinchng/jevper/blob/main/docs/methods.md) — the four methods, request bodies, readout rules, surface selection
|
|
252
|
+
- [docs/local-servers.md](https://github.com/zhulinchng/jevper/blob/main/docs/local-servers.md) — ollama, llama.cpp, vLLM and SGLang: what to pass, turning thinking off, what fits a small GPU
|
|
252
253
|
- [docs/reasoning.md](https://github.com/zhulinchng/jevper/blob/main/docs/reasoning.md) — native vs two-step reasoning, traces, encrypted content
|
|
253
254
|
- [docs/few-shot.md](https://github.com/zhulinchng/jevper/blob/main/docs/few-shot.md) — example levels, precedence, rendering, structured examples
|
|
254
255
|
- [docs/internals.md](https://github.com/zhulinchng/jevper/blob/main/docs/internals.md) — module map, call flow, concurrency, retries, testing
|
|
@@ -220,6 +220,7 @@ LLM_MODEL=gpt-5.6-terra OPENAI_API_KEY=... pytest -q tests/test_live.py
|
|
|
220
220
|
|
|
221
221
|
- [docs/api.md](https://github.com/zhulinchng/jevper/blob/main/docs/api.md) — constructor and `system_one` parameters, answer/usage/debug shapes, errors
|
|
222
222
|
- [docs/methods.md](https://github.com/zhulinchng/jevper/blob/main/docs/methods.md) — the four methods, request bodies, readout rules, surface selection
|
|
223
|
+
- [docs/local-servers.md](https://github.com/zhulinchng/jevper/blob/main/docs/local-servers.md) — ollama, llama.cpp, vLLM and SGLang: what to pass, turning thinking off, what fits a small GPU
|
|
223
224
|
- [docs/reasoning.md](https://github.com/zhulinchng/jevper/blob/main/docs/reasoning.md) — native vs two-step reasoning, traces, encrypted content
|
|
224
225
|
- [docs/few-shot.md](https://github.com/zhulinchng/jevper/blob/main/docs/few-shot.md) — example levels, precedence, rendering, structured examples
|
|
225
226
|
- [docs/internals.md](https://github.com/zhulinchng/jevper/blob/main/docs/internals.md) — module map, call flow, concurrency, retries, testing
|
|
@@ -26,7 +26,7 @@ caller (`close()` only shuts down jevper's own thread pool).
|
|
|
26
26
|
| --- | --- | --- |
|
|
27
27
|
| `model` | required | Model id sent with every call; overridable per `system_one` call |
|
|
28
28
|
| `method` | `"auto"` | `"auto"`, `"logprobs"`, `"grammar"`, `"structured"` or `"discrete"`; `"auto"` resolves per model and surface — see [methods.md](methods.md#auto) |
|
|
29
|
-
| `api` | `"auto"` | `"auto"`, `"chat_completions"` or `"responses"` |
|
|
29
|
+
| `api` | `"auto"` | `"auto"`, `"chat_completions"` or `"responses"`; `"auto"` prefers `responses` and falls back to `chat_completions` when the server answers 404 for that route — see [methods.md](methods.md#auto) |
|
|
30
30
|
| `reasoning` | `None` | A `ReasoningConfig`; `None` disables reasoning entirely |
|
|
31
31
|
| `examples` | `()` | Default few-shot examples: a sequence for all questions, or a mapping keyed by question id |
|
|
32
32
|
| `structured_outputs` | `True` | Send a strict `json_schema` response format; `False` falls back to `{"type": "json_object"}` with the schema left in the prompt |
|
|
@@ -250,6 +250,17 @@ Catch the public `LabelReadoutError`. `capability=True` from a rejection is reme
|
|
|
250
250
|
read out of an answer that carried no usable logprobs needs a second one, because a single anomalous response is
|
|
251
251
|
not evidence about the provider — [`internals.md`](internals.md#invariants) has the rule.
|
|
252
252
|
|
|
253
|
+
The surface has its own private verdict. An `openai` client object exposes `responses.create` whether or not the
|
|
254
|
+
server behind it implements the route — every local server (ollama, llama.cpp, SGLang, vLLM without the route)
|
|
255
|
+
does not — so under `api="auto"` a 404 that does not name the model is read as *this server has no Responses
|
|
256
|
+
route*: the call is re-issued on `chat_completions` and the verdict is remembered for the rest of the client's
|
|
257
|
+
life. A 404 that names the model is the model, and `api="responses"` asked for explicitly is never overridden.
|
|
258
|
+
|
|
259
|
+
A surface that cannot deliver a distribution — it answered without logprobs, or refused the logprob fields —
|
|
260
|
+
is the same kind of verdict: under `api="auto"` the readout moves to the other surface once, and the surface
|
|
261
|
+
that failed is marked so later calls for that model start where the distribution is. `reasoning="native"` keeps
|
|
262
|
+
the surface it implies, and a provider failure that survived its retries moves nothing.
|
|
263
|
+
|
|
253
264
|
## Constants
|
|
254
265
|
|
|
255
266
|
`Method`, `MethodSelection` and `Api` are `Literal` aliases; the runtime tuples are `jevper.client.METHODS`
|
|
@@ -174,6 +174,17 @@ Worth keeping when editing:
|
|
|
174
174
|
decides whether to retry. A number the provider did not report stays missing (`TokenLogprob.logprob` is
|
|
175
175
|
`float | None`) rather than defaulting to `0.0`, which would read as certainty, and a non-finite logprob
|
|
176
176
|
raises instead of propagating a `nan` distribution into `confidence` and `score`.
|
|
177
|
+
- The first token a label readout reads is the answer's, not the completion's. When the provider separates its
|
|
178
|
+
reasoning from the answer *and* the answer text is exactly the tail of the token stream, the answer's own
|
|
179
|
+
tokens start at that offset (`methods._answer_tokens`); anything else — no trace, no exact tail — is read as
|
|
180
|
+
it arrives, so a stream that cannot be anchored is reported rather than guessed at.
|
|
181
|
+
- `method="auto"` remembers two kinds of verdict per client, and neither changes a pinned method or a pinned
|
|
182
|
+
surface: which method works per `(model, surface)`, and which surfaces answer 404. A surface that cannot
|
|
183
|
+
deliver a distribution — it withheld logprobs, or refused the fields outright — is worth one request on the
|
|
184
|
+
other surface, and is then marked so later calls start there; the mark is per model, survives the call, and
|
|
185
|
+
is cleared by a distribution arriving on that surface. The move happens only while the reasoning plan
|
|
186
|
+
survives it (`native` reasoning exists only on Responses) and never for a provider failure that outlived its
|
|
187
|
+
retries, which says nothing about the surface.
|
|
177
188
|
- An empty assistant turn is never sent. Two-step analysis output that is blank falls back to the call's
|
|
178
189
|
reasoning text, and if there is none the answer call goes from the question block straight to the cue.
|
|
179
190
|
- Content is rendered with `allow_nan=False`: `NaN`/`Infinity` are not valid JSON, so they raise `JevperError`
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Local servers
|
|
2
|
+
|
|
3
|
+
jevper talks to anything that speaks the OpenAI wire format, so a local server is the same client with a
|
|
4
|
+
different `base_url`. Three things differ between servers, and all three are decided by what you pass rather
|
|
5
|
+
than by the library: **which surface carries logprobs**, **how thinking is turned off**, and **which request
|
|
6
|
+
fields the server quietly ignores**. This page is the short version, checked against ollama 0.34, llama.cpp
|
|
7
|
+
b11139, vLLM 0.30 and SGLang 0.5.20 serving `Qwen3.5-9B` at 4-bit on one 12 GB card.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from openai import OpenAI
|
|
11
|
+
from jevper import Choice, SystemOneClient
|
|
12
|
+
|
|
13
|
+
client = SystemOneClient(
|
|
14
|
+
OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="local"),
|
|
15
|
+
model="qwen3.5:9b",
|
|
16
|
+
extra_body={"reasoning_effort": "none"}, # thinking off — see below
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
response = client.system_one(
|
|
20
|
+
state="I was charged twice for the same subscription this month.",
|
|
21
|
+
questions={
|
|
22
|
+
"intent": Choice(
|
|
23
|
+
criteria={
|
|
24
|
+
"billing": "money, invoices, refunds, charges",
|
|
25
|
+
"technical": "errors, crashes, login or performance problems",
|
|
26
|
+
"sales": "pricing, plans, purchasing, upgrades",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
response.answers["intent"].probabilities # a real distribution, read from the server's logprobs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What to pass per server
|
|
36
|
+
|
|
37
|
+
| Server | `base_url` | `model` | Thinking off | Notes |
|
|
38
|
+
| --- | --- | --- | --- | --- |
|
|
39
|
+
| ollama | `http://127.0.0.1:11434/v1` | the tag you pulled, e.g. `qwen3.5:9b` | `extra_body={"reasoning_effort": "none"}` | Chat Completions carries logprobs; the Responses route exists but returns an empty logprob list |
|
|
40
|
+
| llama.cpp | `http://127.0.0.1:8080/v1` | the `--alias` value | `extra_body={"chat_template_kwargs": {"enable_thinking": False}}` | serve with `--jinja` for the model's own template; `grammar` is llama.cpp-only |
|
|
41
|
+
| vLLM | `http://127.0.0.1:8000/v1` | the `--served-model-name` value | `extra_body={"chat_template_kwargs": {"enable_thinking": False}}` | serve with `--reasoning-parser qwen3`; `top_logprobs` is capped by `--max-logprobs` (20) |
|
|
42
|
+
| SGLang | `http://127.0.0.1:30000/v1` | the `--served-model-name` value | `extra_body={"chat_template_kwargs": {"enable_thinking": False}}` | serve with `--reasoning-parser qwen3`; its Responses route needs `top_logprobs` sent explicitly, which jevper always does |
|
|
43
|
+
|
|
44
|
+
`api="auto"` (the default) works against all four: it prefers the Responses surface, and when that route is
|
|
45
|
+
missing — or answers without carrying logprobs through — it re-asks on Chat Completions and remembers the
|
|
46
|
+
verdict. Passing `api="chat_completions"` skips the discovery entirely.
|
|
47
|
+
|
|
48
|
+
## Thinking is the one decision you must make
|
|
49
|
+
|
|
50
|
+
`logprobs`, `grammar` and `discrete` all read a **one-token answer**, and every one of these servers reports
|
|
51
|
+
logprobs for *every* generated token. With thinking on, that is the first token of the reasoning, not the
|
|
52
|
+
label: the readout raises `LabelReadoutError`, spends one corrective retry, and raises again. Turn thinking
|
|
53
|
+
off for classification work — it costs a whole reasoning pass to choose one letter.
|
|
54
|
+
|
|
55
|
+
| Server | Request field that turns it off | Also available |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| ollama | `reasoning_effort: "none"` | `OLLAMA_CONTEXT_LENGTH`, per-model `think` on the native API |
|
|
58
|
+
| llama.cpp | `chat_template_kwargs: {"enable_thinking": false}` | `reasoning_budget: 0`; `--reasoning-format deepseek` splits the trace into `reasoning_content` |
|
|
59
|
+
| vLLM | `chat_template_kwargs: {"enable_thinking": false}` | `reasoning_effort` |
|
|
60
|
+
| SGLang | `chat_template_kwargs: {"enable_thinking": false}` | `reasoning_effort` |
|
|
61
|
+
|
|
62
|
+
If you keep thinking on, `structured` and `discrete` are unaffected — they read the answer text, and the
|
|
63
|
+
trace lands in `response.reasoning` (`message.reasoning` on ollama, `reasoning_content` on the others;
|
|
64
|
+
jevper reads all three). A label readout also survives it when the server separates the trace *and* the token
|
|
65
|
+
stream ends exactly with the answer text: jevper anchors on that tail and reads the answer's own first token.
|
|
66
|
+
That anchor is deliberately strict — anything else is reported rather than guessed.
|
|
67
|
+
|
|
68
|
+
## What each server ignores
|
|
69
|
+
|
|
70
|
+
Unknown fields are accepted and dropped by all four, so a field that does not apply is not an error:
|
|
71
|
+
|
|
72
|
+
- `grammar` (jevper's `method="grammar"`) is a llama.cpp convention. ollama, vLLM and SGLang ignore it, so
|
|
73
|
+
the model answers unconstrained and the label readout reports a non-label first token instead of a grammar
|
|
74
|
+
failure. Use `logprobs` or `structured` there.
|
|
75
|
+
- `strict: true` inside `json_schema` is ignored by ollama and honoured by vLLM and SGLang.
|
|
76
|
+
- `reasoning_effort` is a no-op on llama.cpp (it has no such field) and is accepted by the others.
|
|
77
|
+
|
|
78
|
+
## Sizing a 12 GB card
|
|
79
|
+
|
|
80
|
+
A 9B model at 4-bit is 5.5-8.5 GB of weights, which leaves room for a small KV cache but not much else. What
|
|
81
|
+
worked here, one server at a time (they cannot share the card):
|
|
82
|
+
|
|
83
|
+
| Server | Weights | Serve flags that fit |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| ollama | `qwen3.5:9b` (Q4_K_M, 6.1 GiB) | `OLLAMA_CONTEXT_LENGTH=4096`, `OLLAMA_MAX_LOADED_MODELS=1`, `OLLAMA_NUM_PARALLEL=1` |
|
|
86
|
+
| llama.cpp | Q4_K_M GGUF (5.75 GiB) | `-ngl 99 --ctx-size 4096 -np 1 --jinja` |
|
|
87
|
+
| vLLM | 4-bit compressed-tensors AWQ (8.45 GiB) | `--language-model-only --gpu-memory-utilization 0.80 --max-model-len 1024 --max-num-seqs 1 --enforce-eager` — dropping the vision tower is what makes it fit: without it the engine dies at init, with it the server came up in 43 s and gave 1.88 GiB of KV cache |
|
|
88
|
+
| SGLang | same 8.45 GiB checkpoint | `--mem-fraction-static 0.85 --context-length 4096 --attention-backend triton --sampling-backend pytorch --disable-cuda-graph` — the weights alone exceed 0.80 of the card (SGLang's own guard says so), and FlashInfer's JIT cannot build here, so SGLang's Triton kernels are required; unlike vLLM it has no `--language-model-only` for this architecture, so the vision tower cannot be dropped |
|
|
89
|
+
|
|
90
|
+
Neither builder sends `max_tokens`, so a chatty model can generate far more than the answer needs. Bound it
|
|
91
|
+
per call with `extra_body={"max_tokens": 512}` — the one field worth setting on a small card. With thinking
|
|
92
|
+
left on, that cap is spent on the reasoning first: ollama answered a 512-token cap with an empty `content` and
|
|
93
|
+
`finish_reason: "length"`, which reaches the caller as a malformed answer. Disable thinking *and* bound the
|
|
94
|
+
output.
|
|
@@ -86,8 +86,10 @@ Provider support, as of this release — check your provider's docs, since this
|
|
|
86
86
|
| Gemini native API | yes | not reachable through an OpenAI-compatible client |
|
|
87
87
|
| DeepSeek | yes | `top_logprobs` up to 20 |
|
|
88
88
|
| Together | yes | send `top_logprobs` for alternatives; `logprobs: 1` alone returns the sampled token |
|
|
89
|
-
| llama.cpp
|
|
90
|
-
|
|
|
89
|
+
| llama.cpp | yes | Chat Completions only: its `/v1/responses` shim rejects the logprob fields (`400 top_logprobs requires logprobs to be set to true`), so `auto` re-asks on Chat Completions |
|
|
90
|
+
| vLLM | yes | caps `top_logprobs` at its own `--max-logprobs` (20 by default); `/v1/responses` carries them through `include` |
|
|
91
|
+
| SGLang | yes | its `/v1/responses` needs `top_logprobs` sent explicitly (it defaults to 0) — jevper always sends it |
|
|
92
|
+
| Ollama | partial | local builds since Nov 2025 return logprobs on Chat Completions; its `/v1/responses` returns an empty logprob list, so `auto` re-asks on Chat Completions. Ollama Cloud and older builds report none at all |
|
|
91
93
|
| OpenRouter | per model | it routes by price and, by default, sends your request to an endpoint that may ignore `logprobs` — the answer comes back with none, which `auto` reads as "no logprobs" and falls back on. Add `extra_body={"provider": {"require_parameters": True}}` to route only to endpoints that support every field you send. Its Responses API rejects the logprob includable outright (`400 Invalid option: expected one of …` at `path: ["include", 0]`), so `api="auto"` there always resolves to `structured` |
|
|
92
94
|
| everything else | unknown | reasoning models and thin compatibility layers are the ones that say no |
|
|
93
95
|
|
|
@@ -105,12 +107,33 @@ flowchart TD
|
|
|
105
107
|
E -->|"no"| F{"client has responses.create"}
|
|
106
108
|
F -->|"yes"| C
|
|
107
109
|
F -->|"no"| D
|
|
110
|
+
C -->|"404 that does not name the model"| D
|
|
108
111
|
```
|
|
109
112
|
|
|
110
113
|
`api="auto"` (the default) prefers the Responses surface because it carries native reasoning and encrypted
|
|
111
114
|
content, except for `grammar`, which only Chat Completions can carry. A missing attribute raises
|
|
112
115
|
`ClientCapabilityError` naming the surface to pass explicitly.
|
|
113
116
|
|
|
117
|
+
A client object cannot tell you whether the *server* implements the route: `openai.OpenAI` exposes
|
|
118
|
+
`responses.create` either way, so a server that does not implement it answers 404 for that call. Under `auto`
|
|
119
|
+
that 404 is read as "no Responses surface here" — unless the error names the model, which would fail the same
|
|
120
|
+
way on either surface — and the call is re-issued on `chat_completions` and remembered for the rest of the
|
|
121
|
+
client's life. An explicit `api="responses"` is a decision, not a preference: its 404 reaches you unchanged.
|
|
122
|
+
|
|
123
|
+
The preference has one exception, and it is about the readout rather than the surface. A server can implement
|
|
124
|
+
the Responses route and still not carry logprobs through it: ollama answers it with an empty logprob list,
|
|
125
|
+
llama.cpp refuses the logprob fields there outright (`400 top_logprobs requires logprobs to be set to true`),
|
|
126
|
+
and OpenAI's Responses logprobs hold the sampled token with no alternatives. When the label readout cannot
|
|
127
|
+
produce a distribution on the chosen surface — for any reason other than a provider failure that survived its
|
|
128
|
+
retries — `auto` re-asks on the other surface, marks the one that failed so later calls start where the
|
|
129
|
+
distribution is, and keeps that mark for the model. Two things stop the move: `reasoning="native"`, because
|
|
130
|
+
native reasoning is the reason to prefer Responses and switching would silently turn it into a two-step pass,
|
|
131
|
+
and a server whose other route is missing or already known to be missing. A distribution arriving later on a
|
|
132
|
+
marked surface clears the mark: the verdict moves the readout, it does not condemn the surface.
|
|
133
|
+
|
|
134
|
+
For the four servers this was checked against — what to pass, how to turn thinking off, and what fits a 12 GB
|
|
135
|
+
card — see [local-servers.md](local-servers.md).
|
|
136
|
+
|
|
114
137
|
Request fields per surface:
|
|
115
138
|
|
|
116
139
|
| | Chat Completions | Responses |
|
|
@@ -133,7 +156,13 @@ allowed). On the Responses surface the logprobs arrive through `include=["messag
|
|
|
133
156
|
|
|
134
157
|
Readout:
|
|
135
158
|
|
|
136
|
-
1. The first non-whitespace token of the answer must be a label (compared case-insensitively).
|
|
159
|
+
1. The first non-whitespace token of the answer must be a label (compared case-insensitively). A reasoning
|
|
160
|
+
server reports logprobs for every generated token — vLLM, SGLang and ollama include the thinking span while
|
|
161
|
+
`message.content` holds only the answer — so the answer's own tokens are located first, by matching the
|
|
162
|
+
answer text against the tail of the token stream. The match is strict: without a separated trace, or without
|
|
163
|
+
an exact tail, nothing is skipped and the stream is read as it arrives, so a mismatch is an error rather
|
|
164
|
+
than a guess. Disabling thinking is still the better deployment — a one-token answer does not need a
|
|
165
|
+
reasoning pass.
|
|
137
166
|
2. Its logprob is taken from the token itself, and the rest of the distribution from the token's
|
|
138
167
|
`top_logprobs` entries. A label the provider did not report gets probability exactly `0.0` and is listed in
|
|
139
168
|
`debug["labels_missing"]`. A provider that reports `logprob: null` — some OpenAI-compatible servers do — is
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "jevper"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "The Jev (System One) interface over any OpenAI-compatible model: noul, choice and score questions in one call"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -26,6 +26,7 @@ from .errors import (
|
|
|
26
26
|
MalformedAnswerError,
|
|
27
27
|
ProviderError,
|
|
28
28
|
_LogprobsUnavailable,
|
|
29
|
+
_SurfaceUnavailable,
|
|
29
30
|
)
|
|
30
31
|
from .labels import MAX_LABEL_OPTIONS, labels_for
|
|
31
32
|
from .normalize import (
|
|
@@ -55,10 +56,12 @@ from .reasoning import (
|
|
|
55
56
|
resolve_reasoning_mode,
|
|
56
57
|
)
|
|
57
58
|
from .transport import (
|
|
59
|
+
SURFACES,
|
|
58
60
|
CallResult,
|
|
59
61
|
CallSpec,
|
|
60
62
|
Surface,
|
|
61
63
|
Transport,
|
|
64
|
+
_has_attribute,
|
|
62
65
|
make_transport,
|
|
63
66
|
select_surface,
|
|
64
67
|
)
|
|
@@ -100,6 +103,11 @@ _VALUE_MARKERS = ("must be between", "out of range", "maximum", "max_logprobs",
|
|
|
100
103
|
# expected one of "file_search_call.results"|...`` for ``path: ["include", 0]``. This vocabulary is
|
|
101
104
|
# only read next to ``include``, where it is the refusal of the field rather than of a value.
|
|
102
105
|
_INCLUDE_REJECTION_MARKERS = ("invalid option", "invalid_value", "expected one of")
|
|
106
|
+
# A 404 that names the model *and* talks about a model is about the model: the other surface would
|
|
107
|
+
# answer the same way, so switching would only hide the real problem. Both halves are required —
|
|
108
|
+
# an unrelated message can easily contain a short model name, and a server that does not implement
|
|
109
|
+
# the Responses route answers 404 there with a message about the route.
|
|
110
|
+
_MODEL_404_MARKERS = ("model", "no such", "not exist")
|
|
103
111
|
_TRANSIENT_NAME_MARKERS = ("Connection", "Timeout")
|
|
104
112
|
_TRANSIENT_TRANSPORT_CLASSES = frozenset({"TransportError", "TimeoutException"})
|
|
105
113
|
METHODS: tuple[Method, ...] = ("logprobs", "grammar", "structured", "discrete")
|
|
@@ -175,7 +183,7 @@ def _require_top_logprobs(method: Method, top_logprobs: int) -> None:
|
|
|
175
183
|
)
|
|
176
184
|
|
|
177
185
|
|
|
178
|
-
def
|
|
186
|
+
def _error_evidence(exc: BaseException) -> str:
|
|
179
187
|
"""Everything the provider said about the failure: its message, and the param/code it named."""
|
|
180
188
|
parts = [str(exc)]
|
|
181
189
|
for attr in ("param", "code"):
|
|
@@ -185,6 +193,20 @@ def _logprob_evidence(exc: BaseException) -> str:
|
|
|
185
193
|
return " ".join(parts).lower()
|
|
186
194
|
|
|
187
195
|
|
|
196
|
+
def _route_missing(exc: BaseException, *, surface: Surface, model: str) -> bool:
|
|
197
|
+
"""The server has no route for this surface, rather than a problem with what was sent.
|
|
198
|
+
|
|
199
|
+
An OpenAI-compatible server that does not implement a surface's route answers 404 for it, and the
|
|
200
|
+
``openai`` client object exposes ``responses.create`` either way. A 404 that names the model is
|
|
201
|
+
about the model — the same error would come back from the other surface — so it is left alone.
|
|
202
|
+
"""
|
|
203
|
+
if _status_code(exc) != 404:
|
|
204
|
+
return False
|
|
205
|
+
evidence = _error_evidence(exc)
|
|
206
|
+
names_the_model = model.lower() in evidence
|
|
207
|
+
return not (names_the_model and any(marker in evidence for marker in _MODEL_404_MARKERS))
|
|
208
|
+
|
|
209
|
+
|
|
188
210
|
def _include_refused(evidence: str) -> bool:
|
|
189
211
|
"""The provider refused the Responses surface's logprob carrier, the ``include`` entry.
|
|
190
212
|
|
|
@@ -208,7 +230,7 @@ def _logprobs_rejected(exc: BaseException) -> bool:
|
|
|
208
230
|
"""
|
|
209
231
|
if _status_code(exc) not in _LOGPROB_REJECTION_STATUS_CODES:
|
|
210
232
|
return False
|
|
211
|
-
evidence =
|
|
233
|
+
evidence = _error_evidence(exc)
|
|
212
234
|
return "logprob" in evidence or _include_refused(evidence)
|
|
213
235
|
|
|
214
236
|
|
|
@@ -222,7 +244,7 @@ def _logprobs_unsupported(exc: BaseException) -> bool:
|
|
|
222
244
|
"""
|
|
223
245
|
if not _logprobs_rejected(exc):
|
|
224
246
|
return False
|
|
225
|
-
evidence =
|
|
247
|
+
evidence = _error_evidence(exc)
|
|
226
248
|
if any(marker in evidence for marker in _VALUE_MARKERS):
|
|
227
249
|
return False
|
|
228
250
|
if _include_refused(evidence):
|
|
@@ -231,12 +253,23 @@ def _logprobs_unsupported(exc: BaseException) -> bool:
|
|
|
231
253
|
|
|
232
254
|
|
|
233
255
|
def _dump_model(obj: Any) -> Any:
|
|
256
|
+
"""The provider object as plain data for ``debug``, without the provider's typing noise.
|
|
257
|
+
|
|
258
|
+
A server can put a value the SDK's model does not expect in a field it still has to serialize —
|
|
259
|
+
SGLang returns a list for ``metadata``, which the SDK types as a string — and pydantic answers
|
|
260
|
+
every ``model_dump`` with a ``PydanticSerializationUnexpectedValue`` warning. The value survives
|
|
261
|
+
the dump either way, and the dump exists for ``debug``, so the warning is noise the caller never
|
|
262
|
+
asked for; ``warnings=False`` keeps it out of their logs without touching global warning state.
|
|
263
|
+
"""
|
|
234
264
|
dump = getattr(obj, "model_dump", None)
|
|
235
265
|
if callable(dump):
|
|
236
266
|
try:
|
|
237
|
-
return dump(mode="json")
|
|
238
|
-
except TypeError: # pragma: no cover -
|
|
239
|
-
|
|
267
|
+
return dump(mode="json", warnings=False)
|
|
268
|
+
except TypeError: # pragma: no cover - a model_dump() without those keywords
|
|
269
|
+
try:
|
|
270
|
+
return dump(mode="json")
|
|
271
|
+
except TypeError:
|
|
272
|
+
return dump()
|
|
240
273
|
return obj
|
|
241
274
|
|
|
242
275
|
|
|
@@ -254,6 +287,8 @@ class _CallContext:
|
|
|
254
287
|
model: str
|
|
255
288
|
method: Method
|
|
256
289
|
mode: str
|
|
290
|
+
reasoning: ReasoningConfig | None
|
|
291
|
+
"""The effective config, kept so a surface switch can re-derive ``mode`` for the new surface."""
|
|
257
292
|
temperature: float | None
|
|
258
293
|
examples: Examples
|
|
259
294
|
state_messages: tuple[dict[str, str], ...]
|
|
@@ -262,6 +297,9 @@ class _CallContext:
|
|
|
262
297
|
auto: bool = False
|
|
263
298
|
"""``method="auto"``: choose per question, and fall back when logprobs are unavailable. ``method``
|
|
264
299
|
then holds the resolved default rather than what the caller asked for."""
|
|
300
|
+
api_auto: bool = False
|
|
301
|
+
"""``api="auto"``: the surface was chosen here, so this call may re-choose it. An explicit
|
|
302
|
+
``api="responses"`` is a decision: its 404 belongs to the caller, not to a fallback."""
|
|
265
303
|
|
|
266
304
|
|
|
267
305
|
@dataclass
|
|
@@ -389,6 +427,8 @@ class _BaseClient:
|
|
|
389
427
|
# What method="auto" has learned about this provider, per (model, surface).
|
|
390
428
|
self._auto_methods: dict[tuple[str, Surface], Method] = {}
|
|
391
429
|
self._auto_misses: dict[tuple[str, Surface], int] = {}
|
|
430
|
+
# Surfaces this server has answered 404 for, learned by trying them once each.
|
|
431
|
+
self._missing_surfaces: set[Surface] = set()
|
|
392
432
|
self._auto_lock = threading.Lock()
|
|
393
433
|
|
|
394
434
|
# -- shared helpers ----------------------------------------------------------------
|
|
@@ -430,9 +470,84 @@ class _BaseClient:
|
|
|
430
470
|
return misses
|
|
431
471
|
|
|
432
472
|
def _note_logprobs_present(self, model: str, surface: Surface) -> None:
|
|
433
|
-
"""A readable distribution retires the absences counted so far."""
|
|
473
|
+
"""A readable distribution retires the absences counted so far, and any verdict against it."""
|
|
434
474
|
with self._auto_lock:
|
|
435
475
|
self._auto_misses.pop((model, surface), None)
|
|
476
|
+
# A distribution that arrived here proves this surface can carry one, so a verdict that
|
|
477
|
+
# moved the readout away from it is stale.
|
|
478
|
+
if self._auto_methods.get((model, surface)) == FALLBACK_METHOD:
|
|
479
|
+
self._auto_methods.pop((model, surface), None)
|
|
480
|
+
|
|
481
|
+
def _surface_missing(self, surface: Surface) -> bool:
|
|
482
|
+
"""Whether this server has already answered 404 for a surface's route."""
|
|
483
|
+
with self._auto_lock:
|
|
484
|
+
return surface in self._missing_surfaces
|
|
485
|
+
|
|
486
|
+
def _remember_surface_missing(self, surface: Surface) -> None:
|
|
487
|
+
"""Remember that this server has no route for a surface, for the rest of this client's life."""
|
|
488
|
+
with self._auto_lock:
|
|
489
|
+
self._missing_surfaces.add(surface)
|
|
490
|
+
|
|
491
|
+
def _logprobs_absent_here(self, model: str, surface: Surface) -> bool:
|
|
492
|
+
"""Whether the label readout is already known to have no future on this surface."""
|
|
493
|
+
with self._auto_lock:
|
|
494
|
+
return self._auto_methods.get((model, surface), AUTO_METHOD) == FALLBACK_METHOD
|
|
495
|
+
|
|
496
|
+
def _logprob_surface_alternative(
|
|
497
|
+
self, surface: Surface, reasoning: ReasoningConfig | None
|
|
498
|
+
) -> Surface | None:
|
|
499
|
+
"""The other surface to try for a label readout, when the move is free of side effects.
|
|
500
|
+
|
|
501
|
+
``native`` reasoning exists only on the Responses surface, so a caller who asked for it — or
|
|
502
|
+
whose ``mode="auto"`` resolved to it — keeps the surface that choice implies. Otherwise the
|
|
503
|
+
move costs nothing but a request, and it is worth one: a server can implement a surface
|
|
504
|
+
without carrying logprobs through it — ollama answers the Responses route with an empty
|
|
505
|
+
logprob list, llama.cpp rejects the logprob fields there outright, and OpenAI's Responses
|
|
506
|
+
logprobs hold the sampled token and no alternatives — while Chat Completions on the same
|
|
507
|
+
server carries a full distribution.
|
|
508
|
+
"""
|
|
509
|
+
other: Surface = "chat_completions" if surface == "responses" else "responses"
|
|
510
|
+
if self._surface_missing(other) or resolve_reasoning_mode(reasoning, surface) == "native":
|
|
511
|
+
return None
|
|
512
|
+
return other if _has_attribute(self.client, f"{SURFACES[other][2]}.create") else None
|
|
513
|
+
|
|
514
|
+
def _note_surface_absence(
|
|
515
|
+
self, model: str, surface: Surface, exc: _LogprobsUnavailable
|
|
516
|
+
) -> None:
|
|
517
|
+
"""Count one absence for this surface, remembering the verdict once it is a pattern."""
|
|
518
|
+
if exc.capability and (
|
|
519
|
+
exc.evidence == "provider"
|
|
520
|
+
or self._note_absent_logprobs(model, surface) >= AUTO_ABSENCES_BEFORE_REMEMBERING
|
|
521
|
+
):
|
|
522
|
+
self._remember_logprobs_unavailable(model, surface)
|
|
523
|
+
|
|
524
|
+
def _remember_responses_missing(self) -> None:
|
|
525
|
+
"""Remember that this server has no Responses route, for the rest of this client's life."""
|
|
526
|
+
with self._auto_lock:
|
|
527
|
+
self._responses_missing = True
|
|
528
|
+
|
|
529
|
+
def _switch_surface(self, context: _CallContext, surface: Surface) -> None:
|
|
530
|
+
"""Rebuild the transport and the reasoning plan for another surface, in place.
|
|
531
|
+
|
|
532
|
+
``_assemble`` reports the surface from the context it was handed, and the driver reads the
|
|
533
|
+
transport per call, so the switch has to be visible on the object they already hold.
|
|
534
|
+
"""
|
|
535
|
+
context.transport = make_transport(
|
|
536
|
+
self.client,
|
|
537
|
+
surface,
|
|
538
|
+
structured_outputs=self.structured_outputs,
|
|
539
|
+
extra_body=self.extra_body,
|
|
540
|
+
extra_headers=self.extra_headers,
|
|
541
|
+
)
|
|
542
|
+
if context.auto:
|
|
543
|
+
# The method verdict is keyed by surface, so the surface that just changed has its own:
|
|
544
|
+
# coming back to one that is known to withhold logprobs must not ask for them again.
|
|
545
|
+
context.method = self._auto_method(context.model, surface)
|
|
546
|
+
context.mode = resolve_reasoning_mode(context.reasoning, surface)
|
|
547
|
+
context.answer_reasoning = context.reasoning if context.mode == "native" else None
|
|
548
|
+
context.analysis_reasoning = (
|
|
549
|
+
context.reasoning if context.mode == "two_step" and surface == "responses" else None
|
|
550
|
+
)
|
|
436
551
|
|
|
437
552
|
def _call_failure(
|
|
438
553
|
self, exc: Exception, spec: CallSpec, context: _CallContext, log: _CallLog
|
|
@@ -459,6 +574,9 @@ class _BaseClient:
|
|
|
459
574
|
return _LogprobsUnavailable(
|
|
460
575
|
f"the provider failed every attempt at the logprob request ({failure})",
|
|
461
576
|
capability=False,
|
|
577
|
+
# Not evidence about the surface: another surface would have failed too, so this
|
|
578
|
+
# must not move the label readout anywhere.
|
|
579
|
+
evidence="transient",
|
|
462
580
|
)
|
|
463
581
|
if isinstance(exc, ProviderError):
|
|
464
582
|
# The transport already built the right error — an embedded provider failure — so keep
|
|
@@ -503,18 +621,30 @@ class _BaseClient:
|
|
|
503
621
|
if requested not in METHOD_SELECTIONS:
|
|
504
622
|
raise JevperError(f"method must be one of {METHOD_SELECTIONS!r}, got {requested!r}")
|
|
505
623
|
auto = requested == "auto"
|
|
624
|
+
api_auto = (api or self.api) == "auto"
|
|
625
|
+
effective_model = model or self.model
|
|
626
|
+
effective_reasoning = reasoning if reasoning is not None else self.reasoning
|
|
506
627
|
# The surface is picked for the method auto tries first; the fallback runs on either surface.
|
|
507
628
|
surface = select_surface(self.client, api or self.api, AUTO_METHOD if auto else requested)
|
|
629
|
+
if api_auto and self._surface_missing(surface):
|
|
630
|
+
# This server answered 404 for the route before: do not pay for the discovery again.
|
|
631
|
+
surface = "chat_completions" if surface == "responses" else "responses"
|
|
632
|
+
elif api_auto and auto and self._logprobs_absent_here(effective_model, surface):
|
|
633
|
+
alternative = self._logprob_surface_alternative(surface, effective_reasoning)
|
|
634
|
+
if alternative is not None and not self._logprobs_absent_here(
|
|
635
|
+
effective_model, alternative
|
|
636
|
+
):
|
|
637
|
+
# The label readout has no future on this surface and the other one is unmarked:
|
|
638
|
+
# start there instead of paying for the same discovery on every call.
|
|
639
|
+
surface = alternative
|
|
508
640
|
if requested == "grammar":
|
|
509
641
|
methods.require_grammar_surface(surface)
|
|
510
|
-
effective_model = model or self.model
|
|
511
642
|
effective_method = self._auto_method(effective_model, surface) if auto else requested
|
|
512
643
|
if not auto:
|
|
513
644
|
# auto never reaches the label-readout cap: a wide Choice is answered in JSON.
|
|
514
645
|
for question_id, question in parsed.items():
|
|
515
646
|
methods.require_label_readout(effective_method, question, question_id)
|
|
516
647
|
_require_top_logprobs(effective_method, self.top_logprobs)
|
|
517
|
-
effective_reasoning = reasoning if reasoning is not None else self.reasoning
|
|
518
648
|
mode = resolve_reasoning_mode(effective_reasoning, surface)
|
|
519
649
|
context = _CallContext(
|
|
520
650
|
transport=make_transport(
|
|
@@ -527,6 +657,7 @@ class _BaseClient:
|
|
|
527
657
|
model=effective_model,
|
|
528
658
|
method=effective_method,
|
|
529
659
|
mode=mode,
|
|
660
|
+
reasoning=effective_reasoning,
|
|
530
661
|
temperature=temperature if temperature is not None else self.temperature,
|
|
531
662
|
examples=examples,
|
|
532
663
|
state_messages=state_messages,
|
|
@@ -535,6 +666,7 @@ class _BaseClient:
|
|
|
535
666
|
effective_reasoning if mode == "two_step" and surface == "responses" else None
|
|
536
667
|
),
|
|
537
668
|
auto=auto,
|
|
669
|
+
api_auto=api_auto,
|
|
538
670
|
)
|
|
539
671
|
return context, parsed
|
|
540
672
|
|
|
@@ -580,16 +712,40 @@ class _BaseClient:
|
|
|
580
712
|
except _LogprobsUnavailable as exc:
|
|
581
713
|
if not context.auto or fell_back:
|
|
582
714
|
raise
|
|
715
|
+
if exc.evidence != "transient" and context.api_auto:
|
|
716
|
+
alternative = self._logprob_surface_alternative(
|
|
717
|
+
context.transport.surface, context.reasoning
|
|
718
|
+
)
|
|
719
|
+
if alternative is not None and not self._logprobs_absent_here(
|
|
720
|
+
context.model, alternative
|
|
721
|
+
):
|
|
722
|
+
# The verdict is about this surface — it answered without logprobs, or refused
|
|
723
|
+
# the fields outright, as llama.cpp's Responses shim does. Mark it, so later
|
|
724
|
+
# calls start where the distribution is, and answer this question there.
|
|
725
|
+
self._remember_logprobs_unavailable(context.model, context.transport.surface)
|
|
726
|
+
retry_reasons.append(
|
|
727
|
+
f"{exc} — retrying the label readout on api={alternative!r}"
|
|
728
|
+
)
|
|
729
|
+
self._switch_surface(context, alternative)
|
|
730
|
+
continue
|
|
583
731
|
fell_back = True
|
|
584
732
|
retry_reasons.append(f"{exc} — answering with method={FALLBACK_METHOD!r}")
|
|
585
|
-
|
|
586
|
-
exc.evidence == "provider"
|
|
587
|
-
or self._note_absent_logprobs(context.model, context.transport.surface)
|
|
588
|
-
>= AUTO_ABSENCES_BEFORE_REMEMBERING
|
|
589
|
-
):
|
|
590
|
-
self._remember_logprobs_unavailable(context.model, context.transport.surface)
|
|
733
|
+
self._note_surface_absence(context.model, context.transport.surface, exc)
|
|
591
734
|
# Questions that have not started yet take the fallback without paying for it.
|
|
592
735
|
context.method = FALLBACK_METHOD
|
|
736
|
+
except _SurfaceUnavailable as exc:
|
|
737
|
+
if not context.api_auto:
|
|
738
|
+
raise
|
|
739
|
+
self._remember_surface_missing(context.transport.surface)
|
|
740
|
+
other: Surface = (
|
|
741
|
+
"chat_completions" if context.transport.surface == "responses" else "responses"
|
|
742
|
+
)
|
|
743
|
+
if self._surface_missing(other) or not _has_attribute(
|
|
744
|
+
self.client, f"{SURFACES[other][2]}.create"
|
|
745
|
+
):
|
|
746
|
+
raise # nowhere left to go: this 404 is the answer
|
|
747
|
+
retry_reasons.append(f"{exc} — answering on api={other!r}")
|
|
748
|
+
self._switch_surface(context, other)
|
|
593
749
|
|
|
594
750
|
def _answer_steps(
|
|
595
751
|
self,
|
|
@@ -814,7 +970,7 @@ class SystemOneClient(_BaseClient):
|
|
|
814
970
|
while True:
|
|
815
971
|
try:
|
|
816
972
|
result = self._call(log, question_id, spec, context)
|
|
817
|
-
except _LogprobsUnavailable as exc:
|
|
973
|
+
except (_LogprobsUnavailable, _SurfaceUnavailable) as exc:
|
|
818
974
|
# Hand the verdict back to the generator: it owns the fallback decision.
|
|
819
975
|
spec = steps.throw(exc)
|
|
820
976
|
else:
|
|
@@ -835,6 +991,15 @@ class SystemOneClient(_BaseClient):
|
|
|
835
991
|
):
|
|
836
992
|
if isinstance(exc, ClientCapabilityError):
|
|
837
993
|
raise # the client cannot read this surface; another attempt cannot help
|
|
994
|
+
if context.api_auto and _route_missing(
|
|
995
|
+
exc, surface=context.transport.surface, model=context.model
|
|
996
|
+
):
|
|
997
|
+
# A server with no such route: hand the verdict to the generator, which owns
|
|
998
|
+
# the fallback decision, exactly as with an unavailable logprob readout.
|
|
999
|
+
raise _SurfaceUnavailable(
|
|
1000
|
+
f"the server has no {context.transport.surface!r} route: {exc}",
|
|
1001
|
+
surface=context.transport.surface,
|
|
1002
|
+
) from exc
|
|
838
1003
|
failure = self._call_failure(exc, spec, context, log)
|
|
839
1004
|
# An embedded provider error is the caught exception itself; raising it `from`
|
|
840
1005
|
# itself would print as its own cause.
|
|
@@ -922,7 +1087,7 @@ class AsyncSystemOneClient(_BaseClient):
|
|
|
922
1087
|
while True:
|
|
923
1088
|
try:
|
|
924
1089
|
result = await self._call(log, question_id, spec, context)
|
|
925
|
-
except _LogprobsUnavailable as exc:
|
|
1090
|
+
except (_LogprobsUnavailable, _SurfaceUnavailable) as exc:
|
|
926
1091
|
# Hand the verdict back to the generator: it owns the fallback decision.
|
|
927
1092
|
spec = steps.throw(exc)
|
|
928
1093
|
else:
|
|
@@ -943,6 +1108,15 @@ class AsyncSystemOneClient(_BaseClient):
|
|
|
943
1108
|
):
|
|
944
1109
|
if isinstance(exc, ClientCapabilityError):
|
|
945
1110
|
raise # the client cannot read this surface; another attempt cannot help
|
|
1111
|
+
if context.api_auto and _route_missing(
|
|
1112
|
+
exc, surface=context.transport.surface, model=context.model
|
|
1113
|
+
):
|
|
1114
|
+
# A server with no such route: hand the verdict to the generator, which owns
|
|
1115
|
+
# the fallback decision, exactly as with an unavailable logprob readout.
|
|
1116
|
+
raise _SurfaceUnavailable(
|
|
1117
|
+
f"the server has no {context.transport.surface!r} route: {exc}",
|
|
1118
|
+
surface=context.transport.surface,
|
|
1119
|
+
) from exc
|
|
946
1120
|
failure = self._call_failure(exc, spec, context, log)
|
|
947
1121
|
# An embedded provider error is the caught exception itself; raising it `from`
|
|
948
1122
|
# itself would print as its own cause.
|
|
@@ -56,6 +56,20 @@ class MalformedAnswerError(JevperError):
|
|
|
56
56
|
"""The model's answer had an unusable shape after corrective retries."""
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
class _SurfaceUnavailable(JevperError):
|
|
60
|
+
"""The server does not implement the surface ``api="auto"`` picked.
|
|
61
|
+
|
|
62
|
+
An ``openai`` client object always exposes ``chat.completions.create`` *and* ``responses.create``, so
|
|
63
|
+
client-side detection cannot tell a server that implements the Responses API from one that answers 404
|
|
64
|
+
for the route. The verdict travels to the fallback the way ``_LogprobsUnavailable`` does, and is
|
|
65
|
+
remembered for the rest of the client's life.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str, *, surface: str) -> None:
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.surface = surface
|
|
71
|
+
|
|
72
|
+
|
|
59
73
|
class ProviderError(JevperError):
|
|
60
74
|
"""A provider call failed after transient retries were exhausted.
|
|
61
75
|
|
|
@@ -183,8 +183,34 @@ def softmax_over_labels(values: Mapping[str, float]) -> dict[str, float]:
|
|
|
183
183
|
return {key: weights.get(key, 0.0) / total for key in values}
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def _answer_tokens(result: CallResult) -> tuple[TokenLogprob, ...]:
|
|
187
|
+
"""The tokens of the answer itself, when the provider separated its reasoning from the answer.
|
|
188
|
+
|
|
189
|
+
A reasoning server reports logprobs for every generated token — vLLM, SGLang and ollama include
|
|
190
|
+
the thinking span, while ``message.content`` holds only the answer — so the first token of the
|
|
191
|
+
stream is the first token of the *thinking*, which is never a label. The answer text is the
|
|
192
|
+
anchor: when it is exactly the tail of the token stream, the token covering its first character
|
|
193
|
+
is the answer's first token. The check is strict on purpose — no separated trace, or no exact
|
|
194
|
+
tail, means nothing is skipped and the stream is read as it arrives.
|
|
195
|
+
"""
|
|
196
|
+
tokens = result.token_logprobs
|
|
197
|
+
content = result.text
|
|
198
|
+
if not result.reasoning or not content.strip():
|
|
199
|
+
return tokens
|
|
200
|
+
stream = "".join(token.token for token in tokens)
|
|
201
|
+
if len(content) >= len(stream) or not stream.endswith(content):
|
|
202
|
+
return tokens
|
|
203
|
+
start = len(stream) - len(content)
|
|
204
|
+
offset = 0
|
|
205
|
+
for index, token in enumerate(tokens):
|
|
206
|
+
offset += len(token.token)
|
|
207
|
+
if offset > start:
|
|
208
|
+
return tokens[index:]
|
|
209
|
+
return tokens
|
|
210
|
+
|
|
211
|
+
|
|
186
212
|
def first_answer_token(result: CallResult, labels: Sequence[str], *, method: Method) -> TokenLogprob:
|
|
187
|
-
"""The first non-whitespace token, which must be one of the labels."""
|
|
213
|
+
"""The first non-whitespace token of the answer, which must be one of the labels."""
|
|
188
214
|
if not result.token_logprobs:
|
|
189
215
|
raise _LogprobsUnavailable(
|
|
190
216
|
f"no logprobs returned for the answer token (method={method!r}); this provider does not "
|
|
@@ -192,7 +218,7 @@ def first_answer_token(result: CallResult, labels: Sequence[str], *, method: Met
|
|
|
192
218
|
f"method='discrete' for one label, neither of which needs logprobs",
|
|
193
219
|
evidence="readout",
|
|
194
220
|
)
|
|
195
|
-
for token in result
|
|
221
|
+
for token in _answer_tokens(result):
|
|
196
222
|
if not token.token.strip():
|
|
197
223
|
continue
|
|
198
224
|
if token.token.strip().upper() in labels:
|
|
@@ -21,18 +21,21 @@ def chat_body(
|
|
|
21
21
|
*,
|
|
22
22
|
content: str,
|
|
23
23
|
logprobs: Sequence[tuple[str, float]] | None = None,
|
|
24
|
+
alternatives: Sequence[tuple[str, float]] | None = None,
|
|
24
25
|
reasoning: str | None = None,
|
|
25
26
|
input_tokens: int | None = 10,
|
|
26
27
|
output_tokens: int | None = 3,
|
|
27
28
|
reasoning_tokens: int | None = 0,
|
|
28
29
|
) -> dict[str, Any]:
|
|
29
30
|
"""A ``chat.completion`` body. ``logprobs`` is the generated token stream for the answer, first
|
|
30
|
-
entry first; the answer token's ``top_logprobs`` is the whole sequence
|
|
31
|
+
entry first; the answer token's ``top_logprobs`` is the whole sequence unless ``alternatives``
|
|
32
|
+
says otherwise — a stream that carries reasoning tokens needs the distribution spelled out."""
|
|
31
33
|
message: dict[str, Any] = {"role": "assistant", "content": content}
|
|
32
34
|
if reasoning is not None:
|
|
33
35
|
message["reasoning_content"] = reasoning
|
|
34
36
|
choice: dict[str, Any] = {"index": 0, "finish_reason": "stop", "message": message}
|
|
35
37
|
if logprobs is not None:
|
|
38
|
+
distribution = logprobs if alternatives is None else alternatives
|
|
36
39
|
choice["logprobs"] = {
|
|
37
40
|
"content": [
|
|
38
41
|
{
|
|
@@ -41,7 +44,7 @@ def chat_body(
|
|
|
41
44
|
"bytes": list(token.encode()),
|
|
42
45
|
"top_logprobs": [
|
|
43
46
|
{"token": name, "logprob": value, "bytes": list(name.encode())}
|
|
44
|
-
for name, value in
|
|
47
|
+
for name, value in distribution
|
|
45
48
|
],
|
|
46
49
|
}
|
|
47
50
|
for token, logprob in logprobs
|
|
@@ -266,6 +266,111 @@ def test_auto_falls_back_when_a_provider_refuses_the_include_path(stub_server):
|
|
|
266
266
|
assert stub.bodies("/responses")[2].get("include") is None
|
|
267
267
|
|
|
268
268
|
|
|
269
|
+
def test_a_rejection_moves_the_readout_to_the_other_surface(stub_server):
|
|
270
|
+
"""llama.cpp's Responses shim refuses the logprob fields; Chat Completions carries them.
|
|
271
|
+
|
|
272
|
+
A refusal is about the surface that made it — the same question on the other surface is worth one
|
|
273
|
+
request, because that is where the distribution is. llama.cpp answers
|
|
274
|
+
``400 top_logprobs requires logprobs to be set to true`` on ``/v1/responses`` and a full
|
|
275
|
+
distribution on ``/v1/chat/completions``.
|
|
276
|
+
"""
|
|
277
|
+
stub = stub_server(
|
|
278
|
+
chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)),
|
|
279
|
+
responses=lambda body: (400, INCLUDE_REJECTION)
|
|
280
|
+
if body.get("include")
|
|
281
|
+
else (200, responses_body(text=STRUCTURED)),
|
|
282
|
+
)
|
|
283
|
+
client = SystemOneClient(openai_client(stub), model="stub") # api="auto" prefers responses
|
|
284
|
+
|
|
285
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
286
|
+
|
|
287
|
+
assert response.debug["api"] == "chat_completions"
|
|
288
|
+
assert response.debug["methods"] == {"q": "logprobs"}
|
|
289
|
+
assert stub.paths == ["/v1/responses", "/v1/chat/completions"]
|
|
290
|
+
assert response.answers["q"].probabilities["billing"] == pytest.approx(0.884873983, abs=1e-9)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_a_surface_that_delivers_again_is_not_written_off(stub_server):
|
|
294
|
+
"""The verdict moves the readout away; it does not condemn the surface. A distribution clears it."""
|
|
295
|
+
calls = {"probes": 0}
|
|
296
|
+
|
|
297
|
+
def responses_script(body):
|
|
298
|
+
if body.get("include") or body.get("top_logprobs"):
|
|
299
|
+
calls["probes"] += 1
|
|
300
|
+
if calls["probes"] == 1:
|
|
301
|
+
return 200, responses_body(text="A") # answered, carrying no logprobs
|
|
302
|
+
return 200, responses_body(text="A", logprobs=CHOICE_LOGS) # this time it does
|
|
303
|
+
return 200, responses_body(text=STRUCTURED)
|
|
304
|
+
|
|
305
|
+
stub = stub_server(
|
|
306
|
+
chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)),
|
|
307
|
+
responses=responses_script,
|
|
308
|
+
)
|
|
309
|
+
client = SystemOneClient(openai_client(stub), model="stub")
|
|
310
|
+
question = {"q": Choice(criteria=CRITERIA)}
|
|
311
|
+
|
|
312
|
+
client.system_one(state="s", questions=question) # responses withholds: the readout moves to chat
|
|
313
|
+
assert stub.paths == ["/v1/responses", "/v1/chat/completions"]
|
|
314
|
+
|
|
315
|
+
# A pinned call proves the surface can carry a distribution after all, which clears the verdict.
|
|
316
|
+
client.system_one(state="s", questions=question, api="responses", method="logprobs")
|
|
317
|
+
assert stub.paths[-1] == "/v1/responses"
|
|
318
|
+
|
|
319
|
+
# So the next auto call opens there again, rather than staying away for the client's life — and
|
|
320
|
+
# it needs only that one request, because the surface now carries the distribution.
|
|
321
|
+
client.system_one(state="s", questions=question)
|
|
322
|
+
assert stub.paths[-1] == "/v1/responses"
|
|
323
|
+
assert stub.paths.count("/v1/responses") == 3
|
|
324
|
+
assert stub.paths.count("/v1/chat/completions") == 1
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_a_refusal_is_remembered_even_when_the_surface_cannot_move(stub_server):
|
|
328
|
+
"""Native reasoning keeps the surface, so the only verdict left is the provider's own."""
|
|
329
|
+
|
|
330
|
+
def script(body):
|
|
331
|
+
# Native reasoning also sends an `include` (for encrypted content); only the logprob
|
|
332
|
+
# includable is refused here, which is what the real rejection names.
|
|
333
|
+
if "message.output_text.logprobs" in (body.get("include") or []):
|
|
334
|
+
return 400, INCLUDE_REJECTION
|
|
335
|
+
return 200, responses_body(text=STRUCTURED)
|
|
336
|
+
|
|
337
|
+
stub = stub_server(responses=script)
|
|
338
|
+
client = SystemOneClient(
|
|
339
|
+
openai_client(stub),
|
|
340
|
+
model="stub",
|
|
341
|
+
reasoning=ReasoningConfig(mode="native", effort="low"),
|
|
342
|
+
)
|
|
343
|
+
question = {"q": Choice(criteria=CRITERIA)}
|
|
344
|
+
|
|
345
|
+
client.system_one(state="s", questions=question)
|
|
346
|
+
client.system_one(state="s", questions=question)
|
|
347
|
+
|
|
348
|
+
# The refusal names the surface's carrier, so it is a capability verdict: no second probe.
|
|
349
|
+
assert "message.output_text.logprobs" not in (
|
|
350
|
+
stub.bodies("/responses")[2].get("include") or []
|
|
351
|
+
)
|
|
352
|
+
assert stub.paths.count("/v1/responses") == 3 # probe, structured, structured
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_coming_back_to_a_marked_surface_does_not_ask_for_logprobs_again(stub_server):
|
|
356
|
+
"""The method verdict is keyed by surface: returning to one that withheld logprobs stays in JSON."""
|
|
357
|
+
|
|
358
|
+
def responses_script(body):
|
|
359
|
+
if body.get("include") or body.get("top_logprobs"):
|
|
360
|
+
return 200, responses_body(text="A") # answered, carrying no logprobs
|
|
361
|
+
return 200, responses_body(text=STRUCTURED)
|
|
362
|
+
|
|
363
|
+
stub = stub_server(responses=responses_script) # no chat route: the move cannot land
|
|
364
|
+
client = SystemOneClient(openai_client(stub), model="stub")
|
|
365
|
+
|
|
366
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
367
|
+
|
|
368
|
+
# responses withholds → chat is tried and 404s → back on responses, answered in JSON, not probed.
|
|
369
|
+
assert stub.paths == ["/v1/responses", "/v1/chat/completions", "/v1/responses"]
|
|
370
|
+
assert response.debug["api"] == "responses"
|
|
371
|
+
assert response.answers["q"].choice == "billing"
|
|
372
|
+
|
|
373
|
+
|
|
269
374
|
def test_serial_questions_share_the_discovery(stub_server):
|
|
270
375
|
"""A question that has not started yet takes the verdict: one probe, not one per question."""
|
|
271
376
|
|
|
@@ -498,3 +603,144 @@ def test_a_readable_distribution_retires_earlier_absences(stub_server):
|
|
|
498
603
|
|
|
499
604
|
# Two absences separated by a readable distribution are still only one absence in a row.
|
|
500
605
|
assert stub.bodies("/chat/completions")[5]["logprobs"] is True
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def test_auto_switches_to_chat_when_the_server_has_no_responses_route(stub_server):
|
|
609
|
+
"""A server with no ``/v1/responses`` route: the SDK object exposes ``responses.create`` regardless.
|
|
610
|
+
|
|
611
|
+
The stub answers 404 for a path it has no script for, exactly as such a server does, so the default
|
|
612
|
+
``api="auto"`` must notice and answer on the surface the server actually implements.
|
|
613
|
+
"""
|
|
614
|
+
stub = stub_server(chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)))
|
|
615
|
+
client = SystemOneClient(openai_client(stub), model="stub") # api="auto" prefers responses
|
|
616
|
+
|
|
617
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
618
|
+
|
|
619
|
+
assert stub.paths == ["/v1/responses", "/v1/chat/completions"]
|
|
620
|
+
assert response.debug["api"] == "chat_completions"
|
|
621
|
+
assert response.answers["q"].choice == "billing"
|
|
622
|
+
assert response.usage.n_retries == 0 # a surface switch is not a transient retry
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def test_the_missing_responses_surface_is_remembered(stub_server):
|
|
626
|
+
"""One 404 is enough: later calls on the same client go straight to the surface that works."""
|
|
627
|
+
stub = stub_server(chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)))
|
|
628
|
+
client = SystemOneClient(openai_client(stub), model="stub")
|
|
629
|
+
|
|
630
|
+
client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
631
|
+
client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
632
|
+
|
|
633
|
+
assert stub.paths.count("/v1/responses") == 1
|
|
634
|
+
assert stub.paths.count("/v1/chat/completions") == 2
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def test_a_404_that_names_the_model_does_not_switch_surface(stub_server):
|
|
638
|
+
"""The model is missing on either surface, so the provider's own error must reach the caller."""
|
|
639
|
+
|
|
640
|
+
def script(_):
|
|
641
|
+
return 404, {"error": {"message": "The model 'stub' does not exist", "code": "model_not_found"}}
|
|
642
|
+
|
|
643
|
+
stub = stub_server(responses=script)
|
|
644
|
+
client = SystemOneClient(openai_client(stub), model="stub")
|
|
645
|
+
|
|
646
|
+
with pytest.raises(ProviderError) as error:
|
|
647
|
+
client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
648
|
+
|
|
649
|
+
assert "does not exist" in str(error.value)
|
|
650
|
+
assert stub.paths == ["/v1/responses"]
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def test_an_explicit_responses_surface_reports_the_404(stub_server):
|
|
654
|
+
"""``api="responses"`` is a decision, not a preference: it is never silently overridden."""
|
|
655
|
+
stub = stub_server(chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)))
|
|
656
|
+
client = SystemOneClient(openai_client(stub), model="stub", api="responses")
|
|
657
|
+
|
|
658
|
+
with pytest.raises(ProviderError):
|
|
659
|
+
client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
660
|
+
|
|
661
|
+
assert stub.paths == ["/v1/responses"]
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def test_switching_surface_re_derives_the_reasoning_mode(stub_server):
|
|
665
|
+
"""``mode="auto"`` is native on Responses and two-step on Chat: the switch must follow the surface."""
|
|
666
|
+
stub = stub_server(chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)))
|
|
667
|
+
client = SystemOneClient(
|
|
668
|
+
openai_client(stub),
|
|
669
|
+
model="stub",
|
|
670
|
+
reasoning=ReasoningConfig(effort="medium", mode="auto"),
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
674
|
+
|
|
675
|
+
assert stub.paths[0] == "/v1/responses" # the 404 that teaches the verdict
|
|
676
|
+
assert response.debug["api"] == "chat_completions"
|
|
677
|
+
assert response.debug["reasoning_mode"] == "two_step"
|
|
678
|
+
sent = stub.bodies("/chat/completions")
|
|
679
|
+
assert len(sent) == 2 # analysis pass, then the answer pass
|
|
680
|
+
assert all("reasoning_effort" not in body for body in sent) # two-step asks for no native reasoning
|
|
681
|
+
assert response.answers["q"].choice == "billing"
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def test_auto_answers_from_chat_when_the_responses_surface_carries_no_logprobs(stub_server):
|
|
685
|
+
"""ollama and llama.cpp implement ``/v1/responses`` with an empty logprob list; chat has them.
|
|
686
|
+
|
|
687
|
+
The surface preference exists for native reasoning, but a label readout with nothing to read is
|
|
688
|
+
worth one request on the other surface — which on the same server carries a full distribution.
|
|
689
|
+
"""
|
|
690
|
+
stub = stub_server(
|
|
691
|
+
chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)),
|
|
692
|
+
responses=lambda _: (200, responses_body(text="A")),
|
|
693
|
+
)
|
|
694
|
+
client = SystemOneClient(openai_client(stub), model="stub")
|
|
695
|
+
|
|
696
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
697
|
+
|
|
698
|
+
assert response.debug["api"] == "chat_completions"
|
|
699
|
+
assert response.debug["methods"] == {"q": "logprobs"}
|
|
700
|
+
assert response.answers["q"].probabilities["billing"] == pytest.approx(0.884873983, abs=1e-9)
|
|
701
|
+
assert stub.paths == ["/v1/responses", "/v1/chat/completions"]
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def test_native_reasoning_keeps_the_responses_surface(stub_server):
|
|
705
|
+
"""Switching surfaces would silently turn native reasoning into a two-step pass: it stays put."""
|
|
706
|
+
stub = stub_server(
|
|
707
|
+
chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)),
|
|
708
|
+
responses=lambda _: (200, responses_body(text=STRUCTURED)),
|
|
709
|
+
)
|
|
710
|
+
client = SystemOneClient(
|
|
711
|
+
openai_client(stub), model="stub", reasoning=ReasoningConfig(mode="native", effort="low")
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
715
|
+
|
|
716
|
+
assert response.debug["api"] == "responses"
|
|
717
|
+
assert response.debug["reasoning_mode"] == "native"
|
|
718
|
+
assert response.debug["methods"] == {"q": "structured"}
|
|
719
|
+
assert stub.paths == ["/v1/responses", "/v1/responses"]
|
|
720
|
+
assert response.answers["q"].choice == "billing"
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def test_the_surface_verdict_is_remembered_from_the_first_call(stub_server):
|
|
724
|
+
"""One surface without logprobs is enough to stop opening there: the next call starts elsewhere."""
|
|
725
|
+
stub = stub_server(
|
|
726
|
+
chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)),
|
|
727
|
+
responses=lambda _: (200, responses_body(text="A")),
|
|
728
|
+
)
|
|
729
|
+
client = SystemOneClient(openai_client(stub), model="stub")
|
|
730
|
+
|
|
731
|
+
for _ in range(3):
|
|
732
|
+
client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
733
|
+
|
|
734
|
+
assert stub.paths.count("/v1/responses") == 1 # the discovery, paid once
|
|
735
|
+
assert stub.paths.count("/v1/chat/completions") == 3
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_async_auto_switches_surface_too(stub_server):
|
|
739
|
+
stub = stub_server(chat=lambda _: (200, chat_body(content="A", logprobs=CHOICE_LOGS)))
|
|
740
|
+
client = AsyncSystemOneClient(async_openai_client(stub), model="stub")
|
|
741
|
+
|
|
742
|
+
response = asyncio.run(client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)}))
|
|
743
|
+
|
|
744
|
+
assert stub.paths == ["/v1/responses", "/v1/chat/completions"]
|
|
745
|
+
assert response.debug["api"] == "chat_completions"
|
|
746
|
+
assert response.answers["q"].choice == "billing"
|
|
@@ -6,6 +6,7 @@ import asyncio
|
|
|
6
6
|
import json
|
|
7
7
|
import threading
|
|
8
8
|
import time
|
|
9
|
+
import warnings
|
|
9
10
|
|
|
10
11
|
import pytest
|
|
11
12
|
from fakes import (
|
|
@@ -795,3 +796,26 @@ def test_a_per_call_method_cannot_ask_for_one_alternative(stub_server):
|
|
|
795
796
|
|
|
796
797
|
assert "at least 2" in str(error.value)
|
|
797
798
|
assert stub.requests == []
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def test_a_provider_field_the_sdk_types_differently_is_dumped_without_noise(stub_server):
|
|
802
|
+
"""SGLang returns a list for ``metadata`` where the SDK declares a string.
|
|
803
|
+
|
|
804
|
+
jevper dumps the provider object for ``debug``, so without asking pydantic to keep quiet every
|
|
805
|
+
call against such a server prints a ``PydanticSerializationUnexpectedValue`` warning the caller
|
|
806
|
+
never asked for. The value survives the dump either way: only the noise is removed.
|
|
807
|
+
"""
|
|
808
|
+
body = chat_body(content="A", logprobs=CHOICE_LOGS)
|
|
809
|
+
body["metadata"] = [{"version": "default", "start": 0, "end": 4}]
|
|
810
|
+
stub = stub_server(chat=lambda _: (200, body))
|
|
811
|
+
client = SystemOneClient(openai_client(stub), model="stub", api="chat_completions")
|
|
812
|
+
|
|
813
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
814
|
+
warnings.simplefilter("always")
|
|
815
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria=CRITERIA)})
|
|
816
|
+
|
|
817
|
+
assert [item for item in caught if "PydanticSerializationUnexpectedValue" in str(item.message)] == []
|
|
818
|
+
assert response.debug["llm_attempts"][-1]["response"]["metadata"] == [
|
|
819
|
+
{"version": "default", "start": 0, "end": 4}
|
|
820
|
+
]
|
|
821
|
+
assert response.answers["q"].choice == "billing"
|
|
@@ -119,6 +119,103 @@ def test_non_label_answer_is_retried_then_raises(stub_server):
|
|
|
119
119
|
assert "A, B" in requests[1]["messages"][-1]["content"]
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
def test_a_separated_reasoning_trace_does_not_hide_the_answer_token(stub_server):
|
|
123
|
+
"""A reasoning server reports logprobs for every generated token, the thinking span included.
|
|
124
|
+
|
|
125
|
+
vLLM, SGLang and ollama all do this while separating the trace into ``reasoning_content``, so the
|
|
126
|
+
first token of the stream is the first token of the *thinking*. The answer text is the anchor: the
|
|
127
|
+
token that starts its exact tail is the answer's first token, and the distribution is read there.
|
|
128
|
+
"""
|
|
129
|
+
stream = [
|
|
130
|
+
("The", -0.1),
|
|
131
|
+
(" user", -0.2),
|
|
132
|
+
(" was", -0.3),
|
|
133
|
+
(" charged", -0.4),
|
|
134
|
+
(" twice", -0.5),
|
|
135
|
+
(".", -0.6),
|
|
136
|
+
("A", -0.12),
|
|
137
|
+
]
|
|
138
|
+
body = chat_body(
|
|
139
|
+
content="A",
|
|
140
|
+
logprobs=stream,
|
|
141
|
+
alternatives=[("A", -0.12), ("B", -2.47), ("C", -3.48)],
|
|
142
|
+
reasoning="The user was charged twice, so this is about money.",
|
|
143
|
+
)
|
|
144
|
+
stub = stub_server(chat=lambda _: (200, body))
|
|
145
|
+
client = SystemOneClient(openai_client(stub), model="stub", api="chat_completions", method="logprobs")
|
|
146
|
+
|
|
147
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria={"billing": None, "technical": None, "sales": None})})
|
|
148
|
+
|
|
149
|
+
answer = response.answers["q"]
|
|
150
|
+
assert answer.choice == "billing"
|
|
151
|
+
assert answer.probabilities["billing"] == pytest.approx(0.884873983, abs=1e-9)
|
|
152
|
+
assert response.usage.n_calls == 1 # the readout needed no corrective retry
|
|
153
|
+
assert response.debug["retry_reasons"] == []
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_the_answer_is_the_tail_of_the_stream_not_its_first_mention(stub_server):
|
|
157
|
+
"""The trace weighs the options by name; only the last occurrence of the answer text is the answer."""
|
|
158
|
+
body = chat_body(
|
|
159
|
+
content="B",
|
|
160
|
+
logprobs=[
|
|
161
|
+
("The", -0.2),
|
|
162
|
+
(" user", -0.3),
|
|
163
|
+
(" paid", -0.4),
|
|
164
|
+
(" twice", -0.5),
|
|
165
|
+
(",", -0.6),
|
|
166
|
+
(" so", -0.7),
|
|
167
|
+
("A", -0.8),
|
|
168
|
+
(" is", -0.9),
|
|
169
|
+
(" wrong", -1.0),
|
|
170
|
+
(" and", -1.1),
|
|
171
|
+
(" B", -1.2),
|
|
172
|
+
(" fits", -1.3),
|
|
173
|
+
(".", -1.4),
|
|
174
|
+
("B", -0.05),
|
|
175
|
+
],
|
|
176
|
+
alternatives=[("B", -0.05), ("A", -3.0), ("C", -4.0)],
|
|
177
|
+
reasoning="The user paid twice, so A is wrong and B fits.",
|
|
178
|
+
)
|
|
179
|
+
stub = stub_server(chat=lambda _: (200, body))
|
|
180
|
+
client = SystemOneClient(openai_client(stub), model="stub", api="chat_completions", method="logprobs")
|
|
181
|
+
|
|
182
|
+
response = client.system_one(state="s", questions={"q": Choice(criteria={"billing": None, "technical": None, "sales": None})})
|
|
183
|
+
|
|
184
|
+
answer = response.answers["q"]
|
|
185
|
+
assert answer.choice == "technical" # B, not the A the trace rules out
|
|
186
|
+
# The distribution is the final token's: the trace's mention of B carries logprob -1.2 there.
|
|
187
|
+
assert answer.probabilities["technical"] == pytest.approx(0.933188894, abs=1e-9)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_a_stream_that_does_not_end_with_the_answer_is_read_as_it_arrives(stub_server):
|
|
191
|
+
"""The anchor is exact on purpose: a trailing token after the answer means nothing is skipped.
|
|
192
|
+
|
|
193
|
+
Some servers append an end-of-turn token to the logprobs; guessing where the answer ends there
|
|
194
|
+
would be a guess, so the stream is read as it arrives and the first token is reported as it is.
|
|
195
|
+
"""
|
|
196
|
+
body = chat_body(
|
|
197
|
+
content="A",
|
|
198
|
+
logprobs=[
|
|
199
|
+
("The", -0.2),
|
|
200
|
+
(" user", -0.3),
|
|
201
|
+
(" paid", -0.4),
|
|
202
|
+
(" twice", -0.5),
|
|
203
|
+
(".", -0.6),
|
|
204
|
+
("A", -0.12),
|
|
205
|
+
("<|im_end|>", -0.01),
|
|
206
|
+
],
|
|
207
|
+
alternatives=[("A", -0.12), ("B", -2.47), ("C", -3.48)],
|
|
208
|
+
reasoning="The user paid twice.",
|
|
209
|
+
)
|
|
210
|
+
stub = stub_server(chat=lambda _: (200, body))
|
|
211
|
+
client = SystemOneClient(openai_client(stub), model="stub", api="chat_completions", method="logprobs")
|
|
212
|
+
|
|
213
|
+
with pytest.raises(LabelReadoutError) as error:
|
|
214
|
+
client.system_one(state="s", questions={"q": Choice(criteria={"billing": None, "technical": None, "sales": None})})
|
|
215
|
+
|
|
216
|
+
assert "first non-whitespace token 'The'" in str(error.value)
|
|
217
|
+
|
|
218
|
+
|
|
122
219
|
def test_corrective_retry_succeeds_and_records_reason(stub_server):
|
|
123
220
|
good = chat_body(content="B", logprobs=[("B", -0.05), ("A", -3.0)])
|
|
124
221
|
bad = chat_body(content="maybe", logprobs=[("maybe", -0.1), ("A", -2.0), ("B", -2.5)])
|
|
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
|