Internals¶
Independent implementation of the documented System One wire format — not affiliated with TypeSafe.
Module map¶
| Module | Responsibility |
|---|---|
errors.py |
The exception hierarchy; ProviderError.attempts carries the attempt records |
types.py |
Wire-shaped Pydantic models: questions, answers, Usage, SystemOneResponse, question validation |
labels.py |
Label allocation (A–Z, then AA–ZZ) and label → answer-key mapping |
reasoning.py |
Reasoning config and content types, reasoning_text, mode resolution |
prompts.py |
Message rendering: state turns, question blocks, few-shot turns, correction messages |
transport.py |
The three surfaces (chat_completions, responses, and messages): request builders, response normalizers, surface selection |
methods.py |
The four elicitation methods: request spec, schemas, grammar, readout |
normalize.py |
Confidence and probability arithmetic (the reference adapter's formulas) |
client.py |
Sync/async facades: orchestration, concurrency, retries, finalization, debug |
__init__.py |
Public re-exports and __version__ |
The dependency direction is one-way: client → {methods, prompts, transport, types, normalize, labels,
reasoning}, types → {errors, labels, reasoning} and labels → errors. labels.py deliberately never
imports types.py at runtime (it is duck-typed on question.type) so the graph stays acyclic, and
types.validate_question reaches prompts through one function-level import: an example's answer and its
own numbers can only be checked against the question that carries it, and that check lives with the
renderer. It runs after the module is loaded, so nothing changes for an importer.
Call flow¶
flowchart TD
A["system_one(state, questions, ...)"] --> B["_prepare: validate questions, render the state turns, resolve method/api/reasoning/examples"]
B --> B2{"method == auto"}
B2 -->|"yes"| B3["_auto_method(model, surface): what this client learned, else logprobs"]
B2 -->|"no"| C
B3 --> C["select_surface + make_transport"]
C --> D["per question, concurrently"]
D --> E["_question_method + labels_for + _resolve_examples + build_parts"]
E --> F["_answer_steps: assemble each pass from the parts"]
F --> G["analysis CallSpec (two_step only)"]
F --> H["answer CallSpec via methods.build_spec"]
H --> I["transport.call / acall -> CallResult"]
I --> J["methods.readout -> Readout"]
J -->|"unusable answer"| K["correction turn, retry up to n_retry_malformed"]
K --> H
J -->|"logprobs unavailable"| K2["auto only: remember the verdict, re-answer with structured"]
K2 --> E
J --> L["_finalize -> Answer + reasoning + accounting"]
L --> M["_assemble -> SystemOneResponse"]
_prepare validates the questions, renders the state turns once into _CallContext.state_messages (every
question reuses them), resolves method, surface, reasoning mode and examples, and builds the transport. It runs
before any provider call, so a bad question, an empty questions mapping, an unusable state or grammar on
the Responses surface produces zero HTTP requests.
method="auto" is resolved there too: _auto_method(model, surface) returns what this client has learned
about that provider, or logprobs when it has learned nothing. The verdict lives in
_BaseClient._auto_methods, keyed by (model, surface) under a lock, so a second system_one call on the
same client skips the discovery entirely. The surface is picked for logprobs rather than for the resolved
method, because the fallback method runs on either surface.
The per-question generator¶
The sequence "analysis pass → answer pass → corrective retries" is written once, in _answer_steps, as a
generator that yields a CallSpec and receives a CallResult back:
steps = self._question_steps(...)
spec = next(steps)
while True:
try:
result = self._call(log, question_id, spec, context)
except _LogprobsUnavailable as exc:
spec = steps.throw(exc) # the generator owns the fallback decision
else:
spec = steps.send(result)
StopIteration.value is the finished _QuestionOutcome. The sync and async clients differ only in their
driver (_call uses the sync or async transport), which keeps the two implementations from drifting.
_question_steps is a thin wrapper over _answer_steps that exists for one reason: the provider's verdict
arrives in the driver, not inside the generator, so it is thrown back in (steps.throw). Under
method="auto" the wrapper records the verdict, switches _CallContext.method to structured — so questions
that have not started yet take the fallback for free — and re-enters _answer_steps, which re-renders the
prompt because structured uses a different system prompt and different example turns. One fallback per
question, enforced by a local flag; a pinned method re-raises instead.
_answer_steps renders a question's prompt once, as prompts.PromptParts (build_parts), and every pass is
assembled from those parts (assemble). The two-step analysis and answer calls therefore share the rendered
state turns, example turns and question block instead of re-rendering them; the state itself is rendered once
per system_one call, in _prepare. The method it uses comes from _question_method, which is
_CallContext.method unless auto needs a JSON method for a Choice past the 26-label alphabet.
Concurrency¶
- One provider call sequence per question; questions are independent.
- A single question runs inline, without touching the thread pool.
- Multiple questions run on a lazily created
ThreadPoolExecutor(max_workers=max_concurrency)that is reused acrosssystem_onecalls and shut down byclose(). The async client usesasyncio.Semaphore(max_concurrency)andasyncio.gather(..., return_exceptions=True). - Each worker owns its own
_CallLog, so accounting needs no locks;_assemblemerges outcomes in question insertion order. - Failures are collected and the first one in question insertion order is re-raised after every task has
settled — one bad question cannot leave dangling threads, and
answersis never partially returned.
Performance¶
The library is I/O-bound: one provider call costs hundreds of milliseconds, while the local work around it — rendering, request building, readout, normalization — is well under a millisecond per question.
Measured on an 8-question choice call with a 45 KiB JSON state (CPython 3.14, M-series, best of 7):
| Method | Renders of the state | Local CPU |
|---|---|---|
logprobs |
1 (was 9) | 0.5 ms (was 1.5 ms) |
logprobs + two_step |
1 (was 17) | 0.7 ms (was 2.4 ms) |
discrete |
1 (was 9) | 0.5 ms (was 1.3 ms) |
So the whole local path is under 0.2 % of the wall time of a single provider call. That is why there is no
native extension (PyO3, orjson, msgspec): it would trade the universal py3-none-any wheel, a build
toolchain and maturin CI for a fraction of a fraction of a percent. Optimize the prompt and the request count,
not the Python.
Retries¶
| Kind | Trigger | Counted in |
|---|---|---|
| Transient | status_code in {408, 429, 500, 502, 503, 504, 529} (read from the exception or from exc.response.status_code), or Connection/Timeout in the name of the exception class or any base class, or an httpx-family TransportError/TimeoutException in the MRO |
usage.n_retries, with delays min(base_delay · 3ⁿ, max_delay) |
| Corrective | LabelReadoutError or MalformedAnswerError on the answer call |
usage.n_calls (each is a provider call) and debug["retry_reasons"] |
Transient retries wrap the failing call; a non-transient error, or a transient one with the retries exhausted,
becomes a ProviderError with the attempt history attached. A 200 whose body carries the provider's own
error object — OpenRouter reports an overloaded upstream that way, with no choices at all — is read as that
failure rather than as an unreadable surface, and the status inside the body decides whether it is retried.
Corrective retries append a correction turn to the answer conversation — after the ANSWER_CUE turn in
two-step mode — and use the JSON wording for structured/discrete, the label wording otherwise.
Accounting¶
Usage counts are summed across every call of the request, and a count is None when any constituent call
omitted it (a reported 0 is preserved). _CallLog.add_result implements exactly that rule, over
TOKEN_FIELDS — input_tokens, output_tokens, reasoning_tokens and cached_tokens. The last of those is
the only one the provider chooses to send: OpenAI, OpenRouter, ollama and llama.cpp always report it, vLLM
needs --enable-prompt-tokens-details, SGLang needs --enable-cache-report on its Chat Completions route,
and a server that says nothing leaves the total None rather than a flattering 0.
debug is assembled once, in _assemble, with all eight keys always present:
| Key | Filled by |
|---|---|
method, api, reasoning_mode |
_CallContext (the resolved values, not the requested ones) |
llm_attempts |
One record per provider call, including failed ones; the last record for a question carries its readout |
retry_reasons |
Corrective retries and method fallbacks, in order |
probability_errors, original_probabilities |
structured normalization only |
labels_missing |
Labels the provider reported no logprob for |
A ninth key, methods ({question_id: method}), is added only for method="auto", where the method is a
decision per question rather than a pinned fact. Every other key is present either way, which is what keeps a
pinned method's debug byte-identical across releases.
Invariants¶
Worth keeping when editing:
- No request ever carries
max_tokens,max_completion_tokensormax_output_tokens. Reasoning tokens count against those caps, and a small cap truncates a reasoning model. - The builders emit only the fields the surface understands; anything else the provider accepts goes through
extra_body(grammaris merged into it), never intoCallSpec. - Labels are single letters up to 26 options, two letters above that. Only
structuredanddiscretemay use the two-letter range: multi-letter labels break first-token logprob readout, sologprobs/grammarraiseInvalidQuestionErrorpast 26 options (methods.require_label_readout), andChoiceitself stops at the Jev API limit of 255. examplesnever reaches the wire:Field(exclude=True)keeps question dumps andSystemOneResponsedumps at the Jev shape.- The caller's state turns are preserved verbatim, and they come last — after the question block — so the
prefix every call about one rubric shares is the whole prompt except the state. The state is never repeated.
A state's own
system/developerturns are folded into the leading system prompt byprompts.hoist_instructions— llama.cpp's template raisesSystem message must be at the beginning.for one that is not first, and vLLM and SGLang answer400with the same words — so the rest of the state is always last and always cacheable. - The provider client is never closed by jevper.
close()shuts down the thread pool only, andAsyncSystemOneClient.aclose()is a no-op. - Readouts raise
LabelReadoutError/MalformedAnswerErrorfor recoverable shapes and never guess; the client decides whether to retry. A number the provider did not report stays missing (TokenLogprob.logprobisfloat | None) rather than defaulting to0.0, which would read as certainty, and a non-finite logprob raises instead of propagating anandistribution intoconfidenceandscore. - The first token a label readout reads is the answer's, not the completion's. When the provider separates its
reasoning from the answer and the answer text is exactly the tail of the token stream, the answer's own
tokens start at that offset (
methods._answer_tokens); anything else — no trace, no exact tail — is read as it arrives, so a stream that cannot be anchored is reported rather than guessed at. Up to two trailing tokens whose text does not occur in the answer — vLLM's and SGLang's own<|im_end|>— are dropped first, since a token the provider excluded fromcontentcannot be part of the answer; a token that could be is kept, which is what makes this a removal rather than a guess. method="auto"remembers two kinds of verdict per client, and neither changes a pinned method or a pinned surface: which method works per(model, surface), and which surfaces answer 404. A surface that cannot deliver a distribution — it withheld logprobs, or refused the fields outright — is worth one request on the other surface, and is then marked so later calls start there; the mark is per model, survives the call, and is cleared by a distribution arriving on that surface. The move happens only while the reasoning plan survives it (nativereasoning exists only on Responses) and never for a provider failure that outlived its retries, which says nothing about the surface.- An empty assistant turn is never sent. Two-step analysis output that is blank falls back to the call's reasoning text, and if there is none the answer call goes from the question block straight to the cue.
- Content is rendered with
allow_nan=False:NaN/Infinityare not valid JSON, so they raiseJevperErrorinstead of putting an unparseable prompt or example in front of the model. - Probability normalization never raises: out-of-tolerance distributions are recorded in
debugand either rescaled (default) or passed through. method="auto"never changes what a pinned method does. It resolves to a concrete method before any spec is built, so a provider that returns logprobs sees byte-identical requests whether the method was pinned or resolved, and a pinnedlogprobscall still raisesProviderErroron a rejection.- Only evidence about the provider is remembered, and a rejection is stronger evidence than a readout. A 4xx
that refuses the field — naming it in the message,
paramorcodealongside an unsupported/unknown signal — is cached per(model, surface)at once. A response with no logprobs, or one whose only logprob is the sampled token, is the same verdict read from weaker evidence:autocounts it and caches only on the second one (AUTO_ABSENCES_BEFORE_REMEMBERING), and any readable distribution resets the count. A truncated answer, a reasoning-only reply or a provider hiccup must not downgrade a working provider for the life of the client. A 4xx that only complains about the value it was sent (a server whosetop_logprobscap is below the default) and a 5xx that survived the retries arecapability=Falseand are not cached: the question is answered with a logprob-free method, but the next call tries logprobs again. - A request field jevper added for capability is dropped, not fatal. A 4xx that refuses structured output, the
reasoning parameters, the Responses
includelist, the cache key, the Messagesthinkingfield or the Messagesoutput_configmoves that field one step down its ladder —json_schema→json_object→ nothing, then reasoning, then include, then the key, then thinking, then the Messages schema field — and the same call is re-asked, with the limit remembered per surface and reported indebug["server_limits"], which is derived from theLimitsdataclass so a new rung cannot be forgotten there. None of them is needed to answer, so the question is answered instead of failing. The ladder is finite, so a server that refuses everything still ends in aProviderError, and a rejection that names the logprob fields belongs to the readout fallback instead and never reaches it. - A refusal of the value is not a refusal of the field, and only the second moves the ladder.
budget_tokens: must be at least 1024andreasoning_effort must be one of low, medium, highname fields the server knows and numbers it will not take; dropping the field there would answer the question with the caller's reasoning quietly switched off and remember that as the server's limit for a configuration the caller never repeated, so the provider's own error travels back and nothing is cached. A refusal of the schema is deliberately exempt: dropping tojson_objectdoes not lose the schema, which travels in the prompt from then on. The ladder is read against the transport that issued the request, not against the shared call context, which another question's worker may have moved in the meantime. - A capability field the caller put in
extra_bodyis dropped with jevper's own. The SDK mergesextra_bodylast, so a key named there is what reaches the wire; leaving it in place would make the "re-ask without it" the same bytes again. A key the caller names is also the effective one for the builder's own decisions — a caller'sresponse_formatmeans no schema of jevper's is in the request, so the schema goes in the prompt beside it. - The derived cache key is a function of the prefix, not of the request.
prompts.derived_cache_keyhashes the model, the method, the example turns and the question block — everything that determines what a provider can reuse — and never the state, so every state classified with one rubric keys alike. The method belongs in it because its system prompt and answer shape are part of the cached prefix: alogprobsrequest and astructuredone for the same question must not be routed into one bucket. The two reasoning passes of a two-step call still share a key even though their system prompts differ, because the point of the key is to route the answer pass at the analysis pass's cache. A caller's own key replaces the derived one everywhere, including in the re-asks the ladder performs. - An answer that never arrived says why, and a generation the provider itself cut short is never read as one.
CallResult.stopcarries the provider's own reason —finish_reasonon Chat Completions,incomplete_details.reasonon the Responses surface,stop_reasonon the Messages API — normalized to text so a server that sends a list there is reported as a bad stop reason rather than crashing a set lookup.methods.answer_failurereads it before any readout runs, and before the two-step analysis is quoted into the answer prompt: a truncation (length,max_tokens,max_output_tokens,model_context_window_exceeded) or any other non-terminal stop raisesIncompleteAnswerError, and a refusal or a safety filter (refusal,content_filter) raisesModelRefusalError. Both areProviderErrorsubclasses and both are terminal, because a correction turn changes the prompt and not the budget the provider stopped at or the fact that the model declined or withheld the content. The truncation message names the room that ran out and the knob that opens it — the surface's own field,max_output_tokenson the Responses surface andmax_tokenselsewhere — while a spent context window is told to shorten the state rather than to raisemax_tokens, which would lengthen the request. The readouts keep the reason on the errors they raise for an answer that did arrive but could not be read, including the twoparse_json_objectpaths where the answer contained a{but could not be parsed.CallResult.refusalcarries the model's own words, so a refusal reads as a refusal rather than as malformed JSON. - Capability failures are not corrective-retried, and neither are refusals, filters or a spent budget: a correction turn changes the prompt, not what the provider reports. Only the model-side failures (a non-label token, an unusable JSON shape) are worth another call.
- One generation, one answer. A
200that carries a providererroris that error, whatever else the body holds — a body that says both is not an answer jevper can vouch for — and the error'scode, read as a number or a digit string, keeps a transient upstream failure retryable. A sampled token that contradicts an answer text naming a different label is refused rather than read: the two are two views of one generation, and when they disagree neither is trusted. An answer holding more than one JSON object is malformed, because reading the first hides that the model answered twice. A logprob above zero is not a log probability, and alternatives that are all unusable are no distribution at all. - A logprob readout needs at least two candidates, unless the question has only one option.
top_logprobswith nothing but the sampled token is not a distribution over a contest, so it raises instead of reporting the answer as certain; with a single label there is no contest and the sampled token is the answer, reported at probability 1.0. A provider that reports entries but nulls for some of them is the documented partial case and keepslabels_missingsemantics.
Extending¶
- A new method: add the
Literalmember intypes.Method, abuild_specbranch and areadout_*function inmethods.py, and route it inmethods.readout. The client needs no change — it already passes the method through. - A new surface: add a builder and a normalizer in
transport.py, one entry in theSURFACESregistry, and a branch inselect_surface. Everything above the transport is surface-agnostic. - A new answer type: extend the
Answerdiscriminated union intypes.pyand_finalizeinclient.py.
Testing¶
tests/fakes.py runs a stdlib ThreadingHTTPServer on 127.0.0.1:0 that records every request body and
answers with a scripted body — either a fixed (status, body) pair or a callable taking the request body, so
a test can vary the response per call (used for corrective retries and multi-question runs). Tests point a
real openai.OpenAI / AsyncOpenAI at it with max_retries=0, so the SDK's own serialization and parsing
paths are exercised, and wrapper retry assertions measure jevper's retries rather than the SDK's. The stub
bodies carry every field the openai models require, including usage.*_tokens_details.
The suite runs offline. tests/test_live.py is skipped unless both LLM_MODEL and OPENAI_API_KEY
are set, and then runs one choice question with method="structured" and one with method="logprobs"
against the real endpoint.
tests/test_provider_surfaces.py replays what four real servers answered. The bodies in
tests/fixtures/providers/ were recorded with raw HTTP from ollama, llama.cpp, vLLM and SGLang serving
Qwen3.5-9B at 4-bit (recipes in docs/local-servers.md, and each file's _meta says how it was pruned:
opaque fields removed, long generated text truncated, every field jevper reads left as recorded). Each case is
served back through a real openai client, so the shapes those servers actually send — an empty logprob array,
reasoning versus reasoning_content, {"detail": "Not Found"}, a 404 that names the model,
status: "incomplete", SGLang's bare JSON string errors — stay in front of every change without a GPU. To
refresh them, capture again and prune the same way; a case whose shape moved is a test that should move with it.
Several tests pin exact numeric literals (softmax results, confidences, scores) that were computed from the reference adapter's formulas. They are intentional: do not "recompute" them from the implementation, since a changed literal means changed arithmetic.
Lint note: ruff check src tests reports three PYI034 hints on __enter__/__aenter__. They return the
concrete class name instead of typing_extensions.Self on purpose — typing_extensions is not a declared
dependency, and typing.Self requires Python 3.11 while the package supports 3.10. Do not "fix" them by adding
the import.