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.
Files changed (32) hide show
  1. {jevper-0.2.2 → jevper-0.2.3}/PKG-INFO +2 -1
  2. {jevper-0.2.2 → jevper-0.2.3}/README.md +1 -0
  3. {jevper-0.2.2 → jevper-0.2.3}/docs/api.md +12 -1
  4. {jevper-0.2.2 → jevper-0.2.3}/docs/internals.md +11 -0
  5. jevper-0.2.3/docs/local-servers.md +94 -0
  6. {jevper-0.2.2 → jevper-0.2.3}/docs/methods.md +32 -3
  7. {jevper-0.2.2 → jevper-0.2.3}/pyproject.toml +1 -1
  8. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/__init__.py +1 -1
  9. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/client.py +191 -17
  10. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/errors.py +14 -0
  11. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/methods.py +28 -2
  12. {jevper-0.2.2 → jevper-0.2.3}/tests/fakes.py +5 -2
  13. {jevper-0.2.2 → jevper-0.2.3}/tests/test_auto_method.py +246 -0
  14. {jevper-0.2.2 → jevper-0.2.3}/tests/test_client_behaviour.py +24 -0
  15. {jevper-0.2.2 → jevper-0.2.3}/tests/test_logprobs_methods.py +97 -0
  16. {jevper-0.2.2 → jevper-0.2.3}/.github/workflows/release.yml +0 -0
  17. {jevper-0.2.2 → jevper-0.2.3}/.gitignore +0 -0
  18. {jevper-0.2.2 → jevper-0.2.3}/LICENSE +0 -0
  19. {jevper-0.2.2 → jevper-0.2.3}/docs/few-shot.md +0 -0
  20. {jevper-0.2.2 → jevper-0.2.3}/docs/reasoning.md +0 -0
  21. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/labels.py +0 -0
  22. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/normalize.py +0 -0
  23. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/prompts.py +0 -0
  24. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/py.typed +0 -0
  25. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/reasoning.py +0 -0
  26. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/transport.py +0 -0
  27. {jevper-0.2.2 → jevper-0.2.3}/src/jevper/types.py +0 -0
  28. {jevper-0.2.2 → jevper-0.2.3}/tests/conftest.py +0 -0
  29. {jevper-0.2.2 → jevper-0.2.3}/tests/test_few_shot.py +0 -0
  30. {jevper-0.2.2 → jevper-0.2.3}/tests/test_grammar_structured.py +0 -0
  31. {jevper-0.2.2 → jevper-0.2.3}/tests/test_live.py +0 -0
  32. {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.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, vLLM | yes | vLLM caps `top_logprobs` at its own `--max-logprobs` |
90
- | Ollama | partial | local builds since Nov 2025 return logprobs; Ollama Cloud and older builds do not, and the compatibility page still lists Logprobs as unsupported |
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.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"
@@ -42,7 +42,7 @@ from .types import (
42
42
  Usage,
43
43
  )
44
44
 
45
- __version__ = "0.2.2"
45
+ __version__ = "0.2.3"
46
46
 
47
47
  __all__ = [
48
48
  "Answer",
@@ -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 _logprob_evidence(exc: BaseException) -> str:
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 = _logprob_evidence(exc)
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 = _logprob_evidence(exc)
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 - non-pydantic objects with a model_dump()
239
- return dump()
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
- if exc.capability and (
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.token_logprobs:
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 logprobs
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