Skip to content

osrforge.providers.reading

The reading provider: Claude's role in the tracing stage, and the recordings tests replay.

The tracing stage asks Claude to read a map page whole, to judge the items Clef is unsure of, and to pick the offset between two halves of a map. Each of those is a kind of request (RequestKind): one system prompt, one output schema, one effort, and one max_tokens, fixed for every request of that kind so that requests of a kind share prompt-cache entries. A ReadingRequest is the kind plus its content, arranged for the cache: the shared parts first, each ending at a cache breakpoint, and the crop and the question last.

A recording is one answer filed under its key, the hash of the request's pixels and parameters, so tests replay it without calling the service. RecordedReadingProvider replays a directory of recordings and RecordingReadingProvider writes them. The live adapter is ClaudeProvider. A recording of a private corpus member's map may contain the module's text or picture, so those recordings live in the owner's private corpus directory and never in the repository; only the committed corpus members' and the synthetic maps' recordings are committed.

MAX_SHARED_PARTS module-attribute

MAX_SHARED_PARTS = 3

The most shared parts a request may have.

The API allows four cache breakpoints in one request. The system prompt takes one, so a request has at most three shared parts.

MAX_SHARED_PART_BLOCKS module-attribute

MAX_SHARED_PART_BLOCKS = 20

The most content parts one shared part may have.

The cache looks back twenty blocks from each breakpoint for the previous one, so a shared part is a few blocks: the instructions as one text part, or a page as one image part, never a long run of parts.

THINKING module-attribute

THINKING = 'adaptive'

The one thinking setting every reading request sends.

Every request runs with the API's adaptive thinking, because Claude Opus 5.5 can't turn thinking off, and a change of thinking setting would break the prompt cache. The setting is part of every recording key.

Effort module-attribute

Effort = Literal['low', 'medium', 'high', 'xhigh', 'max']

The API's effort setting: how much the model thinks before it answers.

ReadingProvider

Bases: Protocol

The reading role of the tracing stage, which Claude fills.

Any object with these two methods is a reading provider. A provider owns its transport retries, its ledger, and its schema check: read returns data that fits the kind's schema or raises.

read

read(request: ReadingRequest) -> ReadingResponse

Send one request and return its answer.

Parameters:

Name Type Description Default
request ReadingRequest

The request.

required

Returns:

Type Description
ReadingResponse

The schema-valid answer with its usage.

Raises:

Type Description
ReadingFailure

If the answer stopped at max_tokens or with a refusal.

SchemaValidationError

If the answer doesn't fit the kind's schema.

LedgerStopError

If the request's reservation would pass the ledger's stop.

ProviderError

On transport, auth, or rate-limit exhaustion.

read_all

Send several requests, ordered so each shared part is written to the cache once.

For each distinct cached prefix (ReadingRequest.shared_key), the first request is sent alone, and the others wait until that request's answer starts to arrive. Requests whose every shared part is already in the cache go at the same time. A request that fails doesn't stop the others.

Parameters:

Name Type Description Default
requests Sequence[ReadingRequest]

The requests, in any order.

required

Returns:

Type Description
list[ReadingResponse | Exception]

One item per request, in the requests' order: its answer, or the

list[ReadingResponse | Exception]

exception read would have raised for it.

ReadingRequest dataclass

ReadingRequest(
    kind: RequestKind,
    shared: tuple[tuple[TextPart | ImagePart, ...], ...],
    question: tuple[TextPart | ImagePart, ...],
    retry: Retry | None = None,
)

One request to the reading provider.

The provider renders it as one user message: every shared part in order, then the question, with a cache breakpoint on the last block of each shared part. A retry adds the malformed answer as an assistant message and the fault as a second user message after that.

Attributes:

Name Type Description
kind RequestKind

The kind of request.

shared tuple[tuple[TextPart | ImagePart, ...], ...]

The shared parts, in order: the instructions, the legend, the symbol vocabulary, and any worked examples first, then the page image when several requests are about one page. Each part is one or more content parts, at most MAX_SHARED_PART_BLOCKS, and ends at a cache breakpoint. At most MAX_SHARED_PARTS. The cache stores a prefix of at least 512 tokens: the whole prompt up to a breakpoint, from the system prompt on, not each part on its own. The caller sees what was cached in the response's usage.

question tuple[TextPart | ImagePart, ...]

The crop and the question, after the shared parts. At least one content part.

retry Retry | None

The malformed answer and the fault, when this request is the one retry of an earlier request.

kind instance-attribute

shared instance-attribute

shared: tuple[tuple[TextPart | ImagePart, ...], ...]

question instance-attribute

question: tuple[TextPart | ImagePart, ...]

retry class-attribute instance-attribute

retry: Retry | None = None

key

key(model: str) -> str

Return the recording key: the hash of the request's pixels and parameters.

The key is the sha256 hex of the canonical JSON (sorted keys, compact separators, UTF-8) of the model, the system prompt, each shared part and the question as lists of blocks (a text part as its text, an image part as its pixel_digest), the effort, THINKING, the schema, max_tokens, and the retry's answer and fault when there is one. The shared parts stay grouped in that JSON, so moving a cache breakpoint changes the key.

Parameters:

Name Type Description Default
model str

The model id the request would be sent to.

required

Returns:

Type Description
str

A 64-character sha256 hex digest.

shared_key

shared_key(model: str) -> str

Return the hash of the request's cached prefix: the model, the kind, and the shared parts.

Two requests with the same shared key read the same cache entries, so ReadingProvider.read_all sends one of them first and the rest once that one's answer starts to arrive. The hash covers the model, the system prompt, the effort, THINKING, the schema, max_tokens, and the shared parts the same way key does, and leaves out the question and the retry.

Parameters:

Name Type Description Default
model str

The model id the request would be sent to.

required

Returns:

Type Description
str

A 64-character sha256 hex digest.

ReadingResponse dataclass

ReadingResponse(data: object, usage: ReadingUsage, model_id: str)

One answer that fits its kind's schema.

Attributes:

Name Type Description
data object

The parsed JSON, already validated against the kind's schema.

usage ReadingUsage

The answer's token usage.

model_id str

The model id the service returned.

data instance-attribute

data: object

usage instance-attribute

usage: ReadingUsage

model_id instance-attribute

model_id: str

ReadingUsage

Bases: BaseModel

One answer's token usage, with the cache reads and writes the ledger prices separately.

input_tokens class-attribute instance-attribute

input_tokens: int = Field(default=0, ge=0)

Input tokens charged at the full price.

output_tokens class-attribute instance-attribute

output_tokens: int = Field(default=0, ge=0)

cache_read_tokens class-attribute instance-attribute

cache_read_tokens: int = Field(default=0, ge=0)

Input tokens read from the prompt cache.

cache_write_tokens class-attribute instance-attribute

cache_write_tokens: int = Field(default=0, ge=0)

Input tokens written to the prompt cache.

RecordedReadingProvider

RecordedReadingProvider(recordings: Path, model: str)

Replays recorded answers, so tests and continuous integration never call Claude.

A recording file is JSON with schema_version, key, kind, model, request, and response. request is the exact body the live adapter sent (request_body) with each image block's data replaced by the image's pixel digest, so a reader can see every parameter and the recording stays small. response has data, usage, model_id, and stop_reason. On replay, the provider builds the body the current code would send, in the same digest form, and compares it with the stored one, so a recording whose key still matches but whose body the code no longer builds fails as stale instead of answering.

Bind to a directory of recordings made on one model.

Parameters:

Name Type Description Default
recordings Path

The directory of <kind>.<key[:12]>.json files.

required
model str

The model id the recordings were made on. It is part of every key, so a request is replayed only against recordings of its own model.

required

recordings instance-attribute

recordings = recordings

model instance-attribute

model = model

read

read(request: ReadingRequest) -> ReadingResponse

Replay the recording whose key matches the request.

The replayed data is checked against the kind's schema again, so a changed schema fails as a SchemaValidationError, not as a wrong answer.

Parameters:

Name Type Description Default
request ReadingRequest

The request to replay.

required

Returns:

Type Description
ReadingResponse

The recorded answer.

Raises:

Type Description
FixtureMissError

If no recording has the request's key.

StaleRecordingError

If the stored body, with digests in place of image data, isn't the body the current code builds for the request.

ReadingFailure

If the recording's stop_reason is max_tokens or refusal, as the live answer failed.

SchemaValidationError

If the recorded data doesn't fit the kind's schema.

ProviderError

If the recording's schema version isn't this package's.

read_all

Replay each request in order.

Parameters:

Name Type Description Default
requests Sequence[ReadingRequest]

The requests.

required

Returns:

Type Description
list[ReadingResponse | Exception]

One item per request: its recorded answer, or the exception

list[ReadingResponse | Exception]

read raises for it.

RecordingReadingProvider

RecordingReadingProvider(inner: ReadingProvider, recordings: Path, model: str)

Passes each request to a live provider and writes its answer as a recording.

A failed answer (max_tokens or a refusal) is recorded too, with its stop_reason, so a replay fails the same way. Writing is idempotent by key.

Wrap a live provider.

Parameters:

Name Type Description Default
inner ReadingProvider

The provider that answers.

required
recordings Path

Where recordings land; created if missing.

required
model str

The model id inner sends to, for the keys.

required

inner instance-attribute

inner = inner

recordings instance-attribute

recordings = recordings

model instance-attribute

model = model

read

read(request: ReadingRequest) -> ReadingResponse

Read through the inner provider and record the exchange.

Parameters:

Name Type Description Default
request ReadingRequest

The request.

required

Returns:

Type Description
ReadingResponse

The inner provider's answer, unchanged.

read_all

Read all through the inner provider's read_all and record every exchange.

Parameters:

Name Type Description Default
requests Sequence[ReadingRequest]

The requests.

required

Returns:

Type Description
list[ReadingResponse | Exception]

The inner provider's items, unchanged.

RequestKind dataclass

RequestKind(name: str, system: str, schema: dict[str, object], effort: Effort, max_tokens: int)

One kind of request: what stays the same for every request of that kind.

Attributes:

Name Type Description
name str

A stable identifier like whole_map or items, used in recording filenames and ledger labels, never free prose.

system str

The system prompt. The provider marks it as cached.

schema dict[str, object]

The JSON Schema every answer of this kind must satisfy. The API enforces it (output_config.format), and the provider checks the answer against it again.

effort Effort

The effort for this kind.

max_tokens int

The most output tokens a request of this kind allows, with room above the longest answer measured for the kind.

name instance-attribute

name: str

system instance-attribute

system: str

schema instance-attribute

schema: dict[str, object]

effort instance-attribute

effort: Effort

max_tokens instance-attribute

max_tokens: int

Retry dataclass

Retry(answer: str, fault: str)

The malformed answer and the fault message that a retry adds after the original request.

Attributes:

Name Type Description
answer str

The malformed answer's text, as the service returned it.

fault str

The message that names what was wrong with it.

answer instance-attribute

answer: str

fault instance-attribute

fault: str

recording_filename

recording_filename(kind: str, key: str) -> str

Return the filename a recording is stored under: <kind>.<key[:12]>.json.

Parameters:

Name Type Description Default
kind str

The request kind's name.

required
key str

The recording key.

required

Returns:

Type Description
str

The filename.

request_body

request_body(request: ReadingRequest, model: str) -> dict[str, Any]

Build the exact body the provider sends for a request.

The body has model, max_tokens, system as one text block with cache_control, messages, thinking as {"type": "adaptive"}, and output_config with the kind's effort and its schema as the JSON schema format. The first message is the user message: the blocks of each shared part in order, then the question's blocks. A text part is a text block and an image part is a base64 PNG image block. The last block of each shared part has cache_control: {"type": "ephemeral"}, and no block of the question does. A retry adds an assistant message with the malformed answer's text and a user message with the fault.

Parameters:

Name Type Description Default
request ReadingRequest

The request.

required
model str

The model id.

required

Returns:

Type Description
dict[str, Any]

The body, as the keyword arguments of the SDK's messages.stream.