diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 172ba43..8308bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,11 @@ jobs: ruff format --check . - name: Type-check # mypy strict only on hand-written code; generated _generated/ (if added later) is excluded via pyproject. - run: mypy src/lenz_io + # The examples too: they are what people copy, so a renamed field or a + # wrong type there fails CI like one in the SDK. + run: | + mypy src/lenz_io + mypy examples - name: Test # `--no-cov` because the coverage gate lives in `addopts` and would # otherwise run in all eight matrix cells. Coverage does not vary diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf0c05..915fb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,494 @@ All notable changes to this SDK are documented here. Format follows ## [Unreleased] +> **Upgrading from 2.x.** Must do: (1) upgrade every service that *receives* +> your webhooks to lenz-io 2.21+ before the sending service moves to 3.0 (a +> 2.21 receiver sees the `*.cancelled` events of 3.0-submitted work as a plain +> `WebhookEvent`: branch on `event.event` there); +> (2) code reading raw bodies (`exc.body`, `event.raw`) reads the current +> shape; (3) re-record tests that replay recorded 2.x response bodies; (4) an +> idempotent request first sent before the switch and replayed after it raises +> `LenzApiVersionError`: finish that work with 2.x, never change the key to +> get past it; (5) a hand-written polling loop must treat `cancelled` as +> final: `get_status`, `get_review` and `get_citecheck` return it where 2.21 +> returned `failed`, so a loop waiting for `failed` polls until its own +> timeout. May do: move off the deprecated names (they keep working). +> Details under Migration. + +Major release (3.0.0). The SDK asks for the API's current response shape +(`2026-10-11`) and reads only that shape from its own calls. Every attribute, +exception and CLI rendering keeps the value it had in 2.x (computed from the +current shape), with the exceptions listed under Breaking and "What reads +differently". The raw bodies are the current shape: `exc.body` and a webhook +event's `raw` hold the body as sent; `model_dump()` and the CLI's `--json` +hold the 2.x-compatible fields plus the current-shape keys the server sent +(see Migration). Every 2.x name is kept as a deprecated alias (see +Deprecated). + +### Breaking + +- **3.0 reads only the API's `2026-10-11` response shape for its own calls** + (lenz.io serves it from 2026-10-11). The SDK no longer detects or fills in + a 2.x-shaped response body, a 2.x-shaped error body, or a `/me/usage` body + from before the credit pool. +- **`LenzApiVersionError` (new, a `LenzError`) is raised when a response + names another version** in its `X-Lenz-API-Version` header, in practice + `2026-05-13`: a server still on the older version, or a reply replayed from + an idempotent request stored before the change. It carries `api_version`, + `status_code` and `body` (as sent), applies to success and error responses + of client calls, and never to webhook payloads. A response without the + header is read as usual. If it persists, contact support with the request + id; lenz-io 2.x reads both versions. +- **Values the API no longer sends**, which no client can rebuild: + - `TaskAccepted.chain_id` reads `""` (use `task_id`). + - The `task_id` of a `review.*` or `citecheck.*` webhook event is the + review or citation-check id (use `event_id` to deduplicate, and the + event's `review_id` / `citecheck_id`); these ids were never pollable. + - Some failure sentences and hints are worded anew (listed under "What + reads differently"); compare on `failure.code` / `code`, never on the + text. + - An `/extract` on an input the API first read as not a claim and then + found a claim in says `status == "ready"` (2.x: `"not_a_claim"`); branch + on `claims`. +- **A task cancelled elsewhere (the website's Stop button, another process) + is its own status, `cancelled`**, where 2.x read `failed` with + `failure_class` `cancelled`. + - `TaskStatus.status`, `ReviewFull.status` and `Citecheck.status` can read + `"cancelled"`. A hand-written polling loop must treat `cancelled` as + terminal or it polls until its own timeout. A cancelled `TaskStatus` + (from `get_status`, a batch item's `status_detail`, or a + `verification.cancelled` event's `verification`) reads the failure block + 2.x read for it (`failure.code` and `failure_class` `"cancelled"`, + `detail` `"Cancelled."`, `retryable` `False`) and the 2.x fields (`error` + `"Cancelled."`, `failure_class`, `failure_reason`, `retryable`, + `docs_url`). A cancelled review or citation check has `failure` `None`, as + the API sends it. + - The wait helpers (`wait`, `verify_and_wait`, `verify_batch_and_wait`, + `review_and_wait`, `citecheck_and_wait`) end on it at once instead of + polling to their timeout. They raise the same error class and failure + fields (`failure_class`, `failure_reason`, `retryable`, doc URL) as 2.x did + for the original shape: `LenzPipelineError`, `ReviewFailed`, + `CitecheckFailed`. For a task cancelled while running the message reads + "Cancelled." (2.x said "Pipeline stopped at: cancelled"). A batch item is + a `failed` row; `get_status`, `get_review` and `get_citecheck` return the + cancelled status without raising. + - Webhooks for work submitted with this release arrive as + `verification.cancelled` (`VerificationCancelled`), `review.cancelled` and + `citecheck.cancelled`, not `*.failed`. A receiver that branches only on + `*.failed` misses them. A 2.21 receiver reads them as a plain + `WebhookEvent`: branch on `event.event` there. +- **Webhooks of both shapes are still parsed** (`parse_webhook`, + `LenzWebhooks.parse` and the event models): work submitted by an older + client on the same account is delivered in the 2.x shape. + +### Changed + +- **`verify_signature` refuses an empty secret** with `ValueError`, as + `LenzWebhooks(secret="")` always did. 2.x accepted a body signed with the + empty key. +- **A whitespace-only `webhook_url` is no longer sent** on `verify`, + `verify_batch` (the batch-wide value and each item's) and the `*_and_wait` + helpers that submit them; 2.x sent it as given. It means the key's default + webhook either way, but the request body (and so its idempotency hash) + differs: a request first sent by 2.x with a pinned `Idempotency-Key` and a + whitespace-only `webhook_url`, and resent by 3.0 with the same key, is + refused with a 422 (`idempotency_body_mismatch`). Finish such a request + with 2.x, or resend it with a new key. +- **`TaskAccepted.model_dump()` has no `chain_id` key**: 2.x carried it only + because the API sent it, and the API no longer does. The + `TaskAccepted.chain_id` attribute is kept and reads `""` (deprecated). +- **A `Retry-After` the SDK cannot use is read safely.** A non-finite value + (`inf`, `-inf`, `nan`, `1e999`) reads as no stated wait, so the normal + backoff runs, as in the Node SDK, where it raised `OverflowError`. A huge + finite wait is clamped to 2,147,483 seconds, still past every cap. The + error's `retry_after` follows the same rule: a non-finite or unparseable + value is not a stated wait (`None`; on a `LenzRateLimitError` the next + stated wait, else `0`, as before), and a larger one reads 2,147,483. +- **A cancel's error body is read as sent**: its `code` (a 422's + `validation_error` too), `detail` and `errors`. +- **One rule for every timeout and retry count of a request, checked before + anything is sent.** A timeout must be `None` (no timeout), a finite real + number of seconds greater than 0, httpx's `(connect, read, write, pool)` + tuple of such numbers or `None`, or an `httpx.Timeout`; a retry count a + whole number, 0 or more. A timeout is at most 2,147,483 seconds (about 24.8 + days, the Node SDK's limit): a longer one overflowed in the socket layer on + every request. Anything else raises `ValueError`: in + `Lenz(timeout=..., max_retries=...)` when the client is built, and in the + `timeout=` of `extract` / `assess` before the call mints a key or sends a + request. Newly refused: a timeout of 0 or less, NaN, infinity or past the + limit, a negative + retry count (none of these worked: such a timeout failed every request, a + negative retry count sent none), and booleans: `True` / `False` as a + timeout or a retry count, which 2.21 read as 1 / 0 (`Lenz(timeout=True)` + was a 1-second timeout; pass the number). Real numbers of any type (`Fraction`, numpy + scalars), the tuple form and integer-like retry counts keep working. The + wait helpers' `timeout` (how long to wait) is not affected: `0` or less + still reads once. +- **The private `_request` / `_send` methods take other arguments.** Code that + overrode them to add headers or change timeouts should use the + `extra_headers` / `timeout` / `max_retries` options or `with_options` + instead. +- **An id goes into the URL path as one segment.** Every method that puts an + id in a path (`get_status`, `select`, `get_review`, `get_citecheck`, the + cancels, `verifications.*`, `ask.*`, the waits) percent-encodes it whole, so + an id holding `/`, `?`, `#` or `..` can no longer send the request to another + path. An ordinary id is on the wire byte for byte as before. An empty id, `"."` + and `".."` raise `ValueError` before any request (the methods that took an + empty id now raise as `get_review` and `wait` already did). +- **The SDK asks for the API's current response shape, and reads only that.** Every request sends + `X-Lenz-API-Version: 2026-10-11` (`lenz_io.API_VERSION`). 2.x sent + `2026-05-13` from the client it built, and no version header at all through + an `http_client=` of your own (the server then answered in the account's + version); 3.0 sends it on every request either way. In that shape each field, status and error code has one name + across every endpoint. +- **Every attribute keeps the value it had in 2.x** (except the `status` of a + cancelled task, see Breaking), computed from the + current shape where the server now sends it under another name or not at + all. Among them: + - A failed `/assess` row still reads `verdict == "Error"` and + `confidence == "low"`, with `error_code` and `hint`; a verdict row that + found other claims carries its `hint` again. `AssessResponse.error` reads + `"No verifiable claim detected"` on an input with no claim. + - "Nothing checkable" keeps each field's old spelling: `no_claim` on + `/assess` rows, `AssessResponse.error_code`, review rows and the review's + own failure; `not_a_claim` on `/extract`, a verification + (`TaskStatus.failure_reason` and the `failure_reason` of its `failure` + block, `LenzPipelineError.failure_reason`, the `verification.failed` + webhook's `error` and the `failure_reason` of its `failure` block) and a + deep check inside a review. `failure.code` is `no_checkable_claim` + everywhere. + - `modified_at` (set only when a verification completed on a later UTC + calendar day than it was created), `claim_text` on receipts, `text` on + `needs_input` options, `ExtractedClaims.claim` / `identified_claims` / + `locations`, `claim_limit_reached` / `citation_limit_reached`. + - `Usage.quota_resets_at`, `credits.bonus` and the `verify` / `ask` / + `assess` blocks, which the current `/me/usage` leaves out: projected from + `credits` and `costs` the way the API projected them. + - A `verification.completed` webhook's `result` has every key it had, with + the same defaults, plus `completed_at`. + - A failed `get_status` / `wait`: `error` reads its 2.x sentence, rebuilt + from the failure code (the fixed sentence for `cancelled`, `task_stuck`, + `task_error` and `not_a_claim`, else "Pipeline stopped at: "), so + the `LenzPipelineError` message reads as before; `failure_reason`, + `failure_class`, `retryable`, `docs_url` and `hint` too. + - Errors: every class and attribute as 2.x set it. `code` is `""` where the + 2.x error carried none (the API now sends one on every error: + `not_authenticated`, `not_found`, `validation_error`, `malformed_body`, + `invalid_request`, `internal_error`, `verification_not_ready` from + `ask.send`, ...), per endpoint; a 422 keeps + its 2.x `code` (`blank_item` for a blank `/assess` item, + `validation_error` on `/review`), `message` (the list of field errors + for a request-schema failure; the parameter path on `/review` and + `/citecheck`; `claims[].` before a `verify_batch` item's language + error) and `errors`; `reset_in_seconds` and `retry_after` on a + 429; `LenzQuotaExceededError.credit_balance` on a citation-check 402. +- `map_response_to_error` takes an optional `endpoint=(method, path)`, which + the client passes: an error's original `code` and wording depend on it. +- **Batch submit and `ask.send` send an automatic `Idempotency-Key`.** + `verify_batch`, `verify_batch_and_wait` and `ask.send` generate one random + key per call, reused across that call's own retries (as `verify`, `assess` + and `extract` already did), so a retried batch or question replays the + first answer instead of being charged twice. A key you pass wins; + `idempotency=False` sends none. Asking the same question again is a new + call with a new key, so it is asked again. 2.x sent a key there only when + you passed one. +- **A 409 `idempotency_conflict` is sent again with the same key.** When a + call that sent an `Idempotency-Key` (its own or yours) meets the first + request with that key still running, it sends the same key and body again + after the wait the server states (at most 60s; a longer one falls back to + the usual backoff), within the call's retries. Still conflicting, it raises + the error 2.x raised (same class, `code` and message) with + `retryable=True`. It never mints a second key to get past it. A `review` / + `citecheck` conflict that names its job still returns that job at once. + 2.x raised the first 409. +- **`wait`, `verify_and_wait` and `verify_batch_and_wait` stop at once on a + 401, 403 or 404** (raising `LenzAuthError` / `LenzNotFoundError`; in a + batch, that item is `failed` with no `status_detail` and the others keep + going) instead of polling to a misleading `LenzTimeoutError`. A 5xx, a 429 + or a network failure is still polled again, after the wait it stated. In + `verify_batch_and_wait` a 404, a 410 or an answer in another API version + fails that item only (`failed`, no `status_detail`), while a 401 / 403 + refuses the key itself and raises from the call; a single wait raises all + of them. Each poll is one request bounded by what is left of the deadline + and by the client's own timeout (`Lenz(timeout=)` as a number, `None` or + an `httpx.Timeout`, or an `http_client=`'s own); the client's own retry + ladder no longer runs inside a poll. +- **No poll starts once a wait's deadline is spent** (`wait`, + `verify_and_wait`, `verify_batch_and_wait`, `review_and_wait`, + `citecheck_and_wait`): the ids left are timed out. 2.x polled once more at + the deadline, past it. A `timeout=0` (or below) still reads each status + once, as in 2.x. Each poll keeps the client's connect, read, write and + pool timeouts, each capped by what is left of the deadline. +- **Network failures and transport timeouts raise subclasses of the class + they raised before**: `LenzConnectionError` and `LenzRequestTimeoutError` + (a `LenzConnectionError`), both `LenzAPIError`s, with the same message and + the `httpx` exception as `__cause__`. Every `httpx.TransportError` worth + sending again (a server that hung up mid-response, a proxy failure, a read + or write error) is now retried and raised this way, and a wait polls again + after one; 2.x let these escape as the raw `httpx` exception. For those + (`httpx.RemoteProtocolError`, `httpx.ProxyError`, `httpx.ReadError`, + `httpx.WriteError` and the like) the new `LenzConnectionError` is **not** a + subclass of what 2.21 raised: code that caught `httpx.HTTPError` / + `httpx.TransportError` for them no longer catches them; catch + `LenzConnectionError` (or `LenzAPIError`) instead. A request + that could never be sent (`httpx.UnsupportedProtocol`, + `httpx.LocalProtocolError`) still raises the `httpx` exception. +- **A 404 raises `LenzNotFoundError`** (a `LenzError`, as before) and its + `fix` reads "Check the id or key the call names: nothing with it is + visible to this credential. Retrying will not help." (2.x advised + retrying). The message, the + other fields and every other error's text are unchanged. + +### Added + +- **Per-call request options and `with_options`.** Every method takes three + keyword-only options for that call: `timeout` (one HTTP attempt, seconds or + an `httpx.Timeout`; `None` keeps the client's), `max_retries` and + `extra_headers` (added to every request the call makes; the SDK's own + headers are refused). On the wait helpers `timeout` stays how long to wait, + `max_retries` is the submit's, and `wait` takes `extra_headers` only. + `client.with_options(timeout=..., max_retries=..., extra_headers=...)` + returns a copy with other defaults that shares the connection pool; closing + a copy does nothing. Per option the call wins over the copy and the copy + over the client; headers merge, and `None` removes one a copy added. The + `extract` / `assess` floors (150 s / 100 s) apply to an inherited timeout + only. A call that passes no option sends exactly the request it sent before. + `NOT_GIVEN` / `NotGiven` (the default of `with_options`) are exported for + type annotations. The `timeout=` of `extract` / `assess` now also takes an + `httpx.Timeout`. See "Per-call options" in the README. +- **Stopping a run: `cancel`, `cancel_review` and `cancel_citecheck`.** + `client.cancel(task_id)` stops a verification and returns a `CancelResult` + (`task_id`, `cancelled`, `status`); `client.cancel_review(review_id)` stops a + review, its deep checks and its citation checks, and returns the full + `ReviewFull` (what `get_review` returns); `client.cancel_citecheck(citecheck_id)` + returns the `Citecheck`. All three answer 200 whatever the state of the run: + `cancelled=True` whenever the run is cancelled, by this call or an earlier + one, so a repeated or retried cancel answers True; `cancelled=False` means + the run is not cancelled and `status` is its status, normally `completed` + or `failed`. A task that `select` already resolved answers `cancelled=False` + with `needs_input`: cancel the task ids `select` returned. A review or check + that had ended comes back unchanged. They send no body and no `Idempotency-Key` + (cancelling twice is safe), and are retried on a 5xx or a dropped + connection like any call that is safe to repeat. An unknown id, another + account's, or (for `cancel`) a task started on the website raises + `LenzNotFoundError`. A task that is a review's deep check raises a + `LenzError` with `code == "use_review_cancel"` (409): cancel the review + instead; it is sent once, never waited on or resent. A cancelled + verification is not charged and saves nothing; a cancelled review or citation + check keeps charged what it delivered before the cancel (quick checks served, + deep checks that finished, citations checked) and the rest is refunded or + never charged. A `wait` on a cancelled run raises the failed error with + `failure_class == "cancelled"`. Needs the API to serve version `2026-10-11`. +- **`VerificationCancelled`** (new webhook event class, `verification.cancelled`; + its `verification` property reads the payload as `get_status` returns it, + `status` `"cancelled"`), and `review.cancelled` / `citecheck.cancelled` typed as `ReviewEvent` / + `CitecheckEvent`. They are sent only for work submitted with `2026-10-11`; + a cancellation of older work keeps arriving as `*.failed`. See Breaking. +- `LenzNotFoundError` (404), `LenzConnectionError` and + `LenzRequestTimeoutError` (see Changed). +- **`retryable` on every error**, set when the error is built: whether sending + the same request again can succeed. `True` for a connection failure, a + request timeout, a 429, a 5xx and a 409 `idempotency_conflict` or + `verification_not_ready` (read from the body as sent, so also where the 2.x + `code` is `""`); `False` for any other 4xx and a + `LenzApiVersionError`; `None` when there was no HTTP status (a missing key, + a `*_and_wait` timeout, a needs-input pause, a bad webhook signature). A + failed verification, review or citation check keeps the server's value + (`None` when it sent none), and a boolean `retryable` in a response's + `failure` block, else at its top level, always wins. A + `retryable=` passed to an error's constructor wins too. +- **`idempotency_key` on every error**: the `Idempotency-Key` the failed call + sent (yours or the automatic one), `None` when it sent none; set on every + error of that call, the wait of a `*_and_wait` helper included, and on the + `json.JSONDecodeError` or pydantic `ValidationError` an unreadable answer + raises (their classes unchanged). A resend is safe only + with the same key: pass `idempotency_key=exc.idempotency_key` back and the + server replays the first answer instead of running it again. A plain new + call sends a new key and can run (and charge) the work twice. +- `verify_batch` and `verify_batch_and_wait` take any `Sequence` of claims + (a `list[dict[str, str]]` now type-checks); nothing changes at run time. +- `ReviewFailedError`, `ReviewTimeoutError`, `CitecheckFailedError` and + `CitecheckTimeoutError`: the job errors under the names the Node SDK uses + (the same classes as `ReviewFailed`, `ReviewTimeout`, `CitecheckFailed`, + `CitecheckTimeout`). +- **`verifications.iter()` and `library.iter(**filters)`**: every item, page + after page from `page`, fetched lazily (a page only when its first item is + asked for), the page size read from each response, ending after a short or + empty page, once the pages read reach the response's `total`, when a + response states no positive `page_size`, or (without yielding it) when the + server answers another page than the one asked for. A start page below 1 + raises `ValueError`. `library.iter` takes `list`'s filters and refuses + `sort="random"` (`ValueError`), which is not exhaustive. +- **`.verification` on the `verification.completed`, `verification.failed` + and `verification.needs_input` webhook events**: the verification as + `client.get_status` returns it (a `TaskStatus`; on a completed event the + verdict is `.verification.result`, a typed `Verification`), built from + either payload shape, `None` when a payload cannot be read as one or its + nested status is not the event's. A key a sparse `result` leaves out reads + as `event.result` reads it (`visibility` `"private"`, `depth` `"standard"`, + `created_at` `""`, ...). A + property, so the events' fields and `repr` are unchanged; the dict + `result` stays (prefer `.verification`). A recognised event whose nested + `result` is not an object no longer crashes `parse_webhook`. +- `Verdict`, `VerdictLabel`, `Confidence` and `Depth`: `Literal` aliases of + the accepted values, for comparisons and exhaustive matching. Fields and + arguments stay `str`. +- `verify`, `review_and_wait` and `citecheck_and_wait` list every option they + forward (`source_url` and `webhook_url`; every `review` / `citecheck` + option) as keyword-only parameters with the same defaults, instead of + `**kwargs`, so editors complete them and type checkers check them. Request + bodies are unchanged, and an unknown option is still a `TypeError`. +- A "First call" at the top of the README, and runnable review and + citation-check examples (`examples/core/review_draft.py`, + `examples/core/citecheck_draft.py`); CI type-checks every example. The + quickstarts no longer fail when no claim needed escalating, read failed + rows by `status == "failed"`, and give `depth="low"`'s price (5 credits, not + 10) everywhere. + +### Deprecated + +Kept in 3.x with their 2.x values, so 2.x code runs unchanged; they will be +removed in a future major release. Move to the newer names when convenient. +Reading them emits no new warning (the ones that already warned still do); +the deprecated properties carry a PEP 702 `@deprecated` marker, so editors +strike them through and type checkers report them. `typing_extensions>=4.5` is +now a declared dependency (pydantic already installs it). + +| 2.x name | Use instead | +|---|---| +| `ExtractedClaims.claim` | `claims[0].claim` | +| `ExtractedClaims.identified_claims` | `claims` (each `.claim`) | +| `ExtractedClaims.locations` | `claims` (each `.positions`) | +| `ExtractedClaims.candidate_claims`, `AssessClaim.candidate_claims`, `AssessResponse.candidate_claims`, `TaskStatus.candidates`, `TaskStatus.similar_claims` | none: always empty | +| `AssessClaim.verdict == "Error"` (with `confidence == "low"`) | `status == "failed"` | +| `AssessClaim.error_code` | `failure.code` (`no_checkable_claim` where it reads `no_claim`) | +| `AssessClaim.hint` | on a failed row `failure.hint`; on a completed row that found other claims, `more_claims` (the sentence is no longer sent; the attribute still reads it) | +| `AssessClaim.identified_claims`, `ReviewAssessment.identified_claims` | `more_claims` | +| `ReviewAssessment.error_code`, `ReviewAssessment.hint` | `failure.code`, `failure.hint` on a failed quick check; `more_claims` on a completed one that found other claims (`hint` still reads "This text holds more than one claim.") | +| `AssessResponse.error`, `AssessResponse.error_code` | `status` and `failure` | +| `CandidateClaim.text` | `claim` | +| `TaskAccepted.claim_text`, `BatchItemResult.claim_text` | `claim` | +| `TaskAccepted.chain_id` | none: no longer sent, reads `""` | +| `TaskStatus.error` | `failure.detail` | +| `TaskStatus.failure_reason` | `failure.code` (`no_checkable_claim` where it reads `not_a_claim`) | +| `TaskStatus.failure_class`, `.retryable`, `.docs_url` | `failure.failure_class`, `.retryable`, `.docs_url` | +| `TaskStatus.hint` on a failed run | `failure.hint` | +| `TaskStatus.failure_detail` | none: always `""` | +| `FailureBlock.failure_reason` | `code` | +| `Verification.modified_at`, `VerificationListItem.modified_at`, `ReviewVerification.modified_at` | `completed_at` | +| `ReviewSummary.claim_limit_reached` | `claim_limit_exceeded` (some claims were left out; `reached` also held at exactly the limit) | +| `ReviewSummary.citation_limit_reached`, `CitecheckSummary.citation_limit_reached` | `citation_limit_exceeded` | +| `Usage.verify`, `Usage.ask`, `Usage.assess` | `credits.remaining // costs[]` (and `credits.total`, `credits.extra`) | +| `Usage.quota_resets_at` | `credits.resets_at` | +| `UsageCredits.bonus` | `extra` (warns) | +| `UsageCapacity.credits` | `UsageCapacity.bonus` (warns); the block itself is deprecated, see above | +| `LenzQuotaExceededError.credits_remaining` | `remaining` (warns); `credit_balance` is the credit pool | +| `Progress` mapping access (`p["step"]`, `p.get`, `in`, `keys()`, `values()`, `items()`) | attributes (`p.step`) | +| `ASSESS_LIST_TIMEOUT` | `ASSESS_TIMEOUT` | + +`LenzQuotaExceededError.credits_remaining` and the `Progress` mapping access +were announced for removal in 3.0: they are kept instead, with their warnings +(where they had one), and will now be removed in a future major release, like +the rest of this list. + +The status value `not_a_claim` on `/extract` reads where the API now sends +`no_checkable_claim`; there is no other spelling to read it by. + +### What reads differently + +The API now words or sends a few things differently, which no client can +rebuild: + +- A failed check's `error` (and the `LenzPipelineError` message built from + it) reads "Pipeline stopped at: " or its fixed sentence, as a running + check's failure did; a failure read back from storage said "Pipeline + stopped: ." in 2.x. A `task_error` reads "Pipeline failed." and a + `task_stuck` "The task was never completed and has been marked failed.", + where 2.x said one of several sentences for each (e.g. "Unexpected + result.", "Unexpected pipeline step: ", "We hit a snag finalizing + your result. Please try submitting again.", "The task was never picked up + and has been marked failed."). +- The 409 `verification_failed` from `verifications.get` carries the run's + own `hint` (and so `fix`, and the CLI's message), where 2.x sometimes + carried a generic one; a failed poll read back from storage can carry a + `hint` 2.x left out. +- Some other hints and 4xx messages are worded anew (the `message` / `cause` + of a blank input other than a blank claim or an unparseable body, a failed + review's hint). +- The values of a `failure` block (`TaskStatus.failure`, `AssessClaim.failure`, + `AssessResponse.failure`) are the server's, where 2.21 rebuilt them from the + 2.x fields: `failure.detail` is the API's sentence ("No sources about the + claim were found.", where 2.21 read "Pipeline stopped at: research_empty", + and on an /assess row a sentence where 2.21 read `None`); on a failed + /assess row `failure.docs_url`, `failure.failure_class` and + `failure.retryable` are filled (2.21: `""`, `""`, `None`), and + `failure.hint` too where the API sends one. `failure.code` reads the same. + The 2.x attributes (`error`, `error_code`, `hint`, ...) keep their 2.x + values. +- Reviews: a review row stored without a failure block (a quick-check row or + a `ReviewFailure`) reads one, where 2.x read `failure` `None`; a deep + check's `modified_at` is computed from its completion time by the 2.x rule + instead of read as stored; a completed quick-check row that found other claims reads the fixed 2.x + `hint` ("This text holds more than one claim."); a failed one has none. +- An extraction the API first read as not a claim and then found one in says + `status == "ready"` (2.x: `"not_a_claim"`). +- `verify`'s receipt has no `chain_id` (`TaskAccepted.chain_id` reads `""`); + the `task_id` of a `review.*` / `citecheck.*` webhook event is the review / + citation-check id (deduplicate on `event_id`); a repeated `verify` answered + from the first one is a 202 (the SDK returns the same receipt either way). + +### Migration + +**Required** + +- **Webhook receivers.** Webhooks follow the version of the request that + submitted the work: a `verify`, `verify_batch`, `select`, `review` or + `citecheck` call made with 3.0 gets its webhooks in the current shape (one + envelope: `event`, `event_id`, the work's id, `status`, and the polled body + under `verification` / `review` / `citecheck`). A service that RECEIVES + your webhooks and parses them with lenz-io 2.20 or older, or reads the raw + JSON, must be upgraded to 2.21 or later (which reads both shapes) before + the service that SENDS requests moves to 3.0. Work submitted with 2.x keeps + sending the original shape. +- **The API must answer `2026-10-11`.** 3.0 raises `LenzApiVersionError` + from any call whose response names another version; until the API serves + `2026-10-11` for your account, stay on 2.21. +- **Code that reads raw bodies**: `exc.body` and `event.raw` are the body as + sent, in the current shape (`failure` blocks, `claims` lists, + `completed_at`, `more_claims`, `docs_url`, `retry_after`); read the + attributes instead, or stay on 2.21 until you move. +- **Tests with recorded 2.x response bodies** must be re-recorded: the SDK + reads the current shape only, so a stored 2.x body no longer parses into + the values it did. +- **Replays of requests made before the switch.** An idempotent request + first sent before lenz.io served `2026-10-11` and replayed with the same + `Idempotency-Key` afterwards answers with its stored reply in + `2026-05-13`, which 3.x refuses with `LenzApiVersionError`. Replays are + kept for 24 hours and replies stored since 2026-10-09 are kept in both + versions, so in practice none remain at release. If you meet one, finish + that work with 2.x; never change the key to get past it, which would run + (and charge) the request again. +- **The values the API no longer sends** (see Breaking): `chain_id`, the + review / citecheck webhook `task_id`, reworded failure sentences and hints, + and an `/extract` status on a mixed input. + +**Optional** + +- Move off the deprecated names (see Deprecated): they keep working with + their 2.x values, so nothing breaks if you do not. +- `model_dump()` and the CLI's `--json` return the 2.x-compatible fields + (computed from the response) plus the current-shape keys the server sent; + they are not the wire body. If a script parses `--json` output, it keeps + finding every 2.x key but `chain_id` (see Changed) and gains the new ones. +- `webhook_url` keeps its meaning, though not always its bytes: on `verify` + and `verify_batch` an empty or whitespace-only value (the default is `""`) + means your key's default webhook and is not sent (2.x sent a + whitespace-only one; see Changed for its effect on a pinned + `Idempotency-Key`); on `review` and `citecheck`, `None` means your + key's default and `""` means no webhook. In the current shape the API reads + a missing `webhook_url` as the key's default and `""` as no webhook on + every endpoint. + ## [2.21.0] - 2026-10-09 Minor release. Existing code keeps working unchanged; nothing to do on diff --git a/README.md b/README.md index e0520f4..4069a82 100644 --- a/README.md +++ b/README.md @@ -16,80 +16,30 @@ generators, deep-research products, due-diligence platforms, vertical agents producing structured deliverables. Not chat AI, not voice AI, not real-time copilots — pipeline runs are the wrong shape for those. -```bash -pip install lenz-io -``` +## First call -## Command-line tool - -The same primitives from your terminal — submit, poll, and read full reports. -Ships inside this package behind the `cli` extra (quotes matter — bare brackets -are a glob in zsh): - -```bash -pipx install "lenz-io[cli]" # isolated CLI install (recommended) -pip install "lenz-io[cli]" # or into your current environment -``` +Get a free API key, with free credits to start, at +[lenz.io/api-credentials](https://lenz.io/api-credentials), then: ```bash -lenz login # paste an API key (free — get one at lenz.io/api-credentials) -lenz extract "Einstein won the 1921 Nobel for relativity" # free, 1000/day -lenz extract "$(cat deck.txt)" --focus "market size" # only the claims you want -lenz extract "$(cat draft.txt)" --locate # where the text makes each claim -lenz assess "The Great Wall is visible from space" # fast verdict -lenz assess "" "" "" # one call, one verdict per claim (up to 20) -lenz verify "Water boils at 90C at sea level" # full pipeline (~90s) -lenz verify "" --depth low # shallower, faster, half the credits -lenz verify "" --json | jq .verdict # machine-readable -lenz status # non-blocking: poll a verify task's progress -lenz show # full report — sources, warnings, panel + debate (-c for concise) -lenz ask "Which source is strongest?" -lenz review draft.md # the whole draft: quick verdicts, deep checks, and up to 20 of its sources -lenz review draft.md --issues # only the issues -lenz review draft.md --max-citations 0 # claims only, no source checked -lenz review draft.md --max-assessments 0 # only the sources, no claim -lenz citecheck draft.md # the citation check on its own -lenz citecheck --pairs pairs.json # statement-source pairs, each checked as it is -lenz usage # credits left, what they buy, and when they reset -lenz config # show which key/base URL is in use -``` - -Every command takes `--json` for a clean machine-readable object (also emitted -automatically when stdout is not a TTY, so pipes Just Work). Errors in `--json` -mode are `{"error": {"code", "message", "status"}}` on stdout with a nonzero -exit (an out-of-credits run reports `"code": "no_credits"` and adds -`upgrade_url`). `lenz usage` leads with the balance: - -```text -Lenz usage (Pro plan) - 5070 credits left (≈ 507 verifications · 5070 assessments) - Verify: 507 left (13 / 520 quota + 20 extra · 10 credits each · 5 at depth "low") - Ask: 5070 left (130 / 5200 quota + 200 extra · 1 credit each) - Assess: 5070 left (130 / 5200 quota + 200 extra · 1 credit each) - Extract: 4 / 1000 today (free — no credit charge) - Credits reset in 3 days (Sep 1, 2026) +pip install lenz-io +export LENZ_API_KEY=lenz_... ``` -`verify` blocks with a progress spinner; Ctrl-C prints a -`lenz verify --resume ` handle so a long run isn't lost. Key resolution -order is `--api-key` flag → `LENZ_API_KEY` → `~/.config/lenz/config.json`. - -**Scripting the lifecycle (no blocking).** `verify --detach` returns a -`task_id` immediately; poll it with `status` and read the full report with -`show` once it completes: +```python +from lenz_io import Lenz -```bash -tid=$(lenz verify "" --detach --json | jq -r .task_id) -lenz status "$tid" --json | jq -r .status # processing → completed -lenz show --json # full report once done +client = Lenz() # reads LENZ_API_KEY +row = client.assess(claim="The Great Wall of China is visible from space.").claims[0] +if row.status == "failed": # no verdict for this row (not charged): failure says why + print(row.failure.code, row.failure.hint) +else: + print(row.verdict, row.confidence) # e.g. False high (about 15-20 s, 1 credit) ``` -If the input holds several claims, `status` reports `needs_input` and lists -them; resolve it non-interactively by index (spawns one verification per pick): - -```bash -lenz verify --resume "$tid" --claim 1,3 --detach --json # → spawned task_ids -``` +From there: [the whole ladder](#quickstart--the-canonical-integration) on an +LLM answer, [a review](#review-a-draft) of a draft, or [the command-line +tool](#command-line-tool). ## Review a draft @@ -220,15 +170,19 @@ edited = "".join(chars) **Waiting.** `review_and_wait` polls on the review's own `poll_after_seconds`. Pass `on_update=` to see the quick verdicts as soon as they are in and each deep check as it lands; without it the helper is silent. -It raises `ReviewFailed` when the review fails (`error_code` and `hint` say -why; `review.failure.code` is the cause in the newer spelling) and `ReviewTimeout` after `timeout` seconds (600 by default); the review +It raises `ReviewFailed` when the review fails (`review.failure.code` and +`review.failure.hint` say why; the exception's `error_code` keeps its 2.x spelling) and `ReviewTimeout` after `timeout` seconds (600 by default); the review keeps running, and the error carries its `review_id` and the last body read. To submit without waiting, `client.review(draft)` returns a `review_id`; read it with `client.get_review(review_id)`, or only the issues with `client.get_review(review_id, view="issues")`. +A runnable version, with the errors handled: +[`examples/core/review_draft.py`](examples/core/review_draft.py). + **Credits.** 1 per claim assessed, plus 10 (5 at `depth="low"`) per deep -check; `review.credits.charged` says what the review cost. A resend with the +check, plus 1 per checked citation; `review.credits.charged` says what the +review cost. A resend with the same `Idempotency-Key` within 24 hours returns the same review; a new key is a new review. @@ -279,14 +233,87 @@ The body carries the same rows as a review's: `citations`, `citation_issues`, `citation_failures`, `summary` and `more_citations` (the draft's citations past `max_citations`, found but not checked). `client.citecheck(...)` returns a `citecheck_id` at once; read it with `client.get_citecheck(citecheck_id)`. -`citecheck_and_wait` raises `CitecheckFailed` when the check fails and -`CitecheckTimeout` after `timeout` seconds. The `citecheck.completed` and -`citecheck.failed` webhooks parse into a `CitecheckEvent`. +`citecheck_and_wait` raises `CitecheckFailed` when the check fails (or is +cancelled elsewhere: `failure_class` `cancelled`) and `CitecheckTimeout` after +`timeout` seconds. The `citecheck.completed`, `citecheck.failed` and +`citecheck.cancelled` webhooks parse into a `CitecheckEvent`. A runnable version: +[`examples/core/citecheck_draft.py`](examples/core/citecheck_draft.py). From the terminal: `lenz citecheck draft.md` (or `--pairs pairs.json`, a JSON list of pairs) prints the count, the key numbers and each source issue, and exits `0` clean, `1` issues found, `2` anything else. +## Command-line tool + +The same primitives from your terminal — submit, poll, and read full reports. +Ships inside this package behind the `cli` extra (quotes matter — bare brackets +are a glob in zsh): + +```bash +pipx install "lenz-io[cli]" # isolated CLI install (recommended) +pip install "lenz-io[cli]" # or into your current environment +``` + +```bash +lenz login # paste an API key (free — get one at lenz.io/api-credentials) +lenz extract "Einstein won the 1921 Nobel for relativity" # free, 1000/day +lenz extract "$(cat deck.txt)" --focus "market size" # only the claims you want +lenz extract "$(cat draft.txt)" --locate # where the text makes each claim +lenz assess "The Great Wall is visible from space" # fast verdict +lenz assess "" "" "" # one call, one verdict per claim (up to 20) +lenz verify "Water boils at 90C at sea level" # full pipeline (~90s) +lenz verify "" --depth low # shallower, faster, half the credits +lenz verify "" --json | jq .verdict # machine-readable +lenz status # non-blocking: poll a verify task's progress +lenz show # full report — sources, warnings, panel + debate (-c for concise) +lenz ask "Which source is strongest?" +lenz review draft.md # the whole draft: quick verdicts, deep checks, and up to 20 of its sources +lenz review draft.md --issues # only the issues +lenz review draft.md --max-citations 0 # claims only, no source checked +lenz review draft.md --max-assessments 0 # only the sources, no claim +lenz citecheck draft.md # the citation check on its own +lenz citecheck --pairs pairs.json # statement-source pairs, each checked as it is +lenz usage # credits left, what they buy, and when they reset +lenz config # show which key/base URL is in use +``` + +Every command takes `--json` for a clean machine-readable object (also emitted +automatically when stdout is not a TTY, so pipes Just Work). Errors in `--json` +mode are `{"error": {"code", "message", "status"}}` on stdout with a nonzero +exit (an out-of-credits run reports `"code": "no_credits"` and adds +`upgrade_url`). `lenz usage` leads with the balance: + +```text +Lenz usage (Pro plan) + 5070 credits left (≈ 507 verifications · 5070 assessments) + Verify: 507 left (13 / 520 quota + 20 extra · 10 credits each · 5 at depth "low") + Ask: 5070 left (130 / 5200 quota + 200 extra · 1 credit each) + Assess: 5070 left (130 / 5200 quota + 200 extra · 1 credit each) + Extract: 4 / 1000 today (free — no credit charge) + Credits reset in 3 days (Sep 1, 2026) +``` + +`verify` blocks with a progress spinner; Ctrl-C prints a +`lenz verify --resume ` handle so a long run isn't lost. Key resolution +order is `--api-key` flag → `LENZ_API_KEY` → `~/.config/lenz/config.json`. + +**Scripting the lifecycle (no blocking).** `verify --detach` returns a +`task_id` immediately; poll it with `status` and read the full report with +`show` once it completes: + +```bash +tid=$(lenz verify "" --detach --json | jq -r .task_id) +lenz status "$tid" --json | jq -r .status # processing → completed +lenz show --json # full report once done +``` + +If the input holds several claims, `status` reports `needs_input` and lists +them; resolve it non-interactively by index (spawns one verification per pick): + +```bash +lenz verify --resume "$tid" --claim 1,3 --detach --json # → spawned task_ids +``` + ## Quickstart — the canonical integration ```python @@ -302,6 +329,9 @@ claims = [c.claim for c in out.claims] # 2. assess — one call per 20 claims (extract finds up to 100), one row per claim, same order quick = [row for i in range(0, len(claims), 20) for row in client.assess(claims=claims[i : i + 20]).claims] for c in quick: + if c.status == "failed": # no verdict: failure.code says why, failure.hint what to send next + print("failed", c.failure.code if c.failure else "", c.claim) + continue print(c.verdict, c.confidence, c.claim) if c.rationale: print(" ", c.rationale) @@ -314,10 +344,11 @@ for r in results: if r.verification: print(r.verification.verdict, r.verification.lenz_score, r.verification.executive_summary) -# 4. ask — follow-up grounded on a verification -v = results[0].verification -reply = client.ask.send(v.verification_id, message="Which source is strongest?") -print(reply.content) +# 4. ask — follow-up grounded on a verification (when a claim was escalated) +deep = next((r.verification for r in results if r.verification is not None), None) +if deep is not None: + reply = client.ask.send(deep.verification_id, message="Which source is strongest?") + print(reply.content) ``` `assess(claims=[...])` takes up to 20 claims per call and always answers @@ -395,8 +426,8 @@ your own claims. Use webhooks for production async flows. - **`client.verify_batch(claims=[...])`** → `BatchAccepted`. Fan-out for multi-claim LLM outputs. - **`client.verify_batch_and_wait(claims=[...])`** → `list[BatchItemResult]`. Fan out a batch and poll every item to completion; one result per claim, in input order, never raises on a per-item failure. - **`client.ask.{history,send,reset}(verification_id, ...)`** → Q&A on a verification. `reply.content` uses a small markdown subset (`**bold**`, `*italic*`, `- ` or `* ` bullets, blank-line paragraphs) — render with a minimal markdown library or display verbatim. See [docs/quickstart#ask-reply-format](https://lenz.io/docs/quickstart#ask-reply-format). -- **`client.verifications.{list,get,delete,related}(...)`** → manage past verifications. All API claims are private; reference them by `verification_id`. Cache-hit on another customer's claim is transparent — you always see your own `verification_id`, never another customer's. -- **`client.library.list(...)`** → browse the public catalog (no API key needed). +- **`client.verifications.{list,iter,get,delete,related}(...)`** → manage past verifications. `iter()` walks every page lazily (`for item in client.verifications.iter(): ...`). All API claims are private; reference them by `verification_id`. Cache-hit on another customer's claim is transparent — you always see your own `verification_id`, never another customer's. +- **`client.library.list(...)`** / **`client.library.iter(...)`** → browse the public catalog (no API key needed); `iter` walks every page lazily and refuses `sort="random"`, which is not exhaustive. - **`client.usage()`** → the account's credit balance (`usage.credits`), the price list (`usage.costs` — `verify` 10, `assess` 1, `ask` 1, `extract` 0 — plus `usage.cost_options` for parameter-dependent prices such as `depth`), and per-capability projections of that one pool (`usage.verify.remaining` is how many verifications the balance still buys), plus the daily `extract` rate limit. Also reports `has_webhook_secret` — whether this key can receive signed webhook callbacks (`verify` with a `webhook_url` needs one); the secret value itself is never exposed. ## Polling without webhooks @@ -432,8 +463,10 @@ for r in results: ``` A `failed` item with `status_detail is None` is a verification its account's -retention period has removed (HTTP 410, see [Retention](#retention)); every -other failure carries a `status_detail`. +retention period has removed (HTTP 410, see [Retention](#retention)), a task +id nothing was found under (404) or an answer in another API version; every +other failure carries a `status_detail`. A 401 / 403 on a poll (the key +itself refused) raises from the whole call. A verify takes ~90 seconds, so show your users where it is. `on_progress` fires once per poll while the run is going — it takes the `task_id` as well, because @@ -471,12 +504,28 @@ Every claim-shaped response shares these fields at top level: | `confidence` | `str` | Categorical: `"high"` \| `"medium"` \| `"low"`. | | `lenz_score` | `int \| None` | Integer 1–10 (deep verdicts and list endpoints; `assess` omits it). | -### Field names: both response shapes +The accepted values are exported as `Literal` aliases for comparisons and +exhaustive matching: `Verdict` (the five labels and `"Error"`), `VerdictLabel` +(the five labels), `Confidence` and `Depth` (`"standard"` or `"low"`). The +fields themselves stay `str`, so a value a later API adds still reads. + +```python +from lenz_io import Verdict + +NEEDS_A_LOOK: set[Verdict] = {"False", "Mostly False", "Mixed"} +flagged = [row for row in quick if row.verdict in NEEDS_A_LOOK] +``` + +### Field names: the current names, and the deprecated 2.x ones -The API is adding a newer, dated response shape that gives each field one -name across every endpoint. This SDK still asks for the original shape, and -its models read either one. The newer names are attributes already; the -older ones keep working, with the meaning they always had: +The API's current response shape gives each field one name across every +endpoint. Since 3.0 the SDK asks for it (`X-Lenz-API-Version: 2026-10-11`) and +reads only that shape from its own calls (webhooks of both shapes are still +parsed). The current names are attributes already. The 2.x names are +**deprecated** and still work, with the value they had in 2.x (except the few +values the API no longer sends, listed under "What reads differently" in the +[changelog](CHANGELOG.md)); they will be removed in a future major release. +Move to the current names when convenient: | Read this | Instead of (deprecated, still works) | |---|---| @@ -484,17 +533,24 @@ older ones keep working, with the meaning they always had: | `AssessClaim.status` (`"completed"` / `"failed"`) and `.failure` | `verdict == "Error"`, `error_code`, `hint` | | `AssessClaim.more_claims`, `ReviewAssessment.more_claims` | `identified_claims` | | `AssessResponse.status` and `.failure` | `error`, `error_code` | -| `TaskStatus.failure` (`code`, `detail`, `hint`, `failure_class`, `retryable`, `docs_url`) | `error`, `failure_reason` and the flat fields | +| `TaskStatus.failure` (`code`, `detail`, `hint`, `failure_class`, `retryable`, `docs_url`) | `error`, `failure_reason`, `failure_detail` and the flat fields | | `TaskAccepted.claim`, `BatchItemResult.claim`, `CandidateClaim.claim` | `claim_text`, `text` | | `Verification.completed_at` | `modified_at` | | `ReviewSummary.claim_limit_exceeded`, `citation_limit_exceeded` | `claim_limit_reached`, `citation_limit_reached` | | `FailureBlock.code`, `.detail` | `failure_reason` | | `Usage.credits` and `Usage.costs` | the `verify` / `ask` / `assess` blocks, `quota_resets_at` | -"Nothing checkable" is `no_checkable_claim` in the newer names; the older -fields keep their own spelling (`not_a_claim`, `no_claim`). The newer names -are read-only properties, so an original-shape response parses, dumps and -compares exactly as before. +"Nothing checkable" is `no_checkable_claim` in the current names; the 2.x +fields keep their own spelling (`not_a_claim`, `no_claim`). The current names +are read-only properties. The full list, with the aliases that have no +replacement, is in the [changelog](CHANGELOG.md). + +**What `model_dump()` and `--json` return.** Not the response body as sent: +a model's `model_dump()` (and the CLI's `--json`, which prints it) holds the +2.x-compatible fields, computed from the response, plus the current-shape +keys the server sent (`failure`, `more_claims`, `claims`, `completed_at`, ...). +The body exactly as sent is `exc.body` on an error and `event.raw` on a +webhook event. ### A suggested rewrite (`suggested_rewrite`) @@ -582,20 +638,27 @@ webhooks = LenzWebhooks(secret="whsec_...") # In your web handler: event = webhooks.parse(raw_body=request.body, headers=request.headers) if isinstance(event, VerificationCompleted): - vid, result = event.verification_id, event.result - # result["verdict"], result["lenz_score"], result["confidence"], ... + # event.verification is the verification as client.get_status returns it + # (a TaskStatus); the verdict is under .result, a typed Verification. + v = event.verification.result if event.verification else None + if v is not None: + print(v.verification_id, v.verdict, v.lenz_score, v.confidence) elif isinstance(event, VerificationNeedsInput): - tid, ni = event.task_id, event.needs_input - ... + options = [c.claim for c in event.claims] # pick, then client.select(event.task_id, claims=[...]) elif isinstance(event, VerificationFailed): - # event.error is WHERE the pipeline stopped; event.failure_class is WHY - # (closed set) and event.retryable tells you what to do about it. - if event.retryable: + # failure.code is WHAT stopped it; failure_class is WHY (closed set) and + # retryable tells you what to do about it. + if event.failure and event.failure.retryable: resubmit_later(event.task_id) # transient provider outage else: - log_permanent_failure(event.task_id, event.error) + log_permanent_failure(event.task_id, event.failure.code if event.failure else "") ``` +`.verification` (since 3.0) is built from either payload shape, and is `None` +only when a payload cannot be read as one. The flat `event.result` dict (and +`error`, `failure_class`, `retryable` on a failed event) are kept with their +2.x values; prefer `.verification` and `.failure`. + If you're on Python 3.10+ a `match` statement reads even cleaner — events are plain dataclasses, so structural pattern matching works. @@ -616,6 +679,13 @@ if isinstance(event, CertificateTimestamped): It carries `coverage` instead of `result` — it reports a timestamp landing, not a verdict being produced. +A task cancelled elsewhere (the website's Stop button, another process) sends +`verification.cancelled`, parsed as `VerificationCancelled` +(`event.verification.status` is `"cancelled"`), `review.cancelled` and +`citecheck.cancelled`. These are sent for work submitted with the API version +this SDK uses; a cancellation of work submitted by an older client keeps +arriving as the `*.failed` event with `failure_class` `cancelled`. + A review sends `review.completed` or `review.failed`, parsed as `ReviewEvent` with the final review on `event.review`. Deduplicate on `event.event_id`: it is the same on every retry of one delivery. The deep checks a review runs send no @@ -658,14 +728,15 @@ u = client.usage() print(u.credits.remaining, "credits") # the balance — the authoritative number print(u.costs["verify"], "credits per verification") # the price list print(u.cost_options["verify"]["depth"]["low"], "at depth low") # 5 — half price -print(u.verify.remaining, "verifications left") # a projection of that balance +print(u.credits.remaining // u.costs["verify"], "verifications left") # that balance in verifications print(u.credits.extra, "of them non-expiring") # grants + top-ups ``` -The `verify` / `ask` / `assess` blocks are **projections** of the one balance -into each capability's unit — how many of those calls the remaining credits -would buy — not separate allowances. Spending on any one of them moves all of -them. +The `verify` / `ask` / `assess` blocks on `Usage` are deprecated (kept, with +their 2.x values) **projections** of the one balance into each capability's +unit — how many of those calls the remaining credits would buy — not separate +allowances. Spending on any one of them moves all of them. Derive the same +number from `credits` and `costs`, as above. `credits.extra` is the non-expiring part of the balance. Its old name, `credits.bonus`, is deprecated: the same number, it emits a @@ -715,6 +786,8 @@ support tickets: ```python from lenz_io import ( LenzAuthError, + LenzConnectionError, + LenzNotFoundError, LenzQuotaExceededError, LenzRateLimitError, LenzUpstreamUnavailableError, @@ -754,6 +827,90 @@ except LenzUpstreamUnavailableError as exc: # charged. Waits up to 60s are already slept through by the automatic # retry ladder; reaching here means the server stated a longer one. schedule_retry_in(exc.retry_after) # typically 90-120s +except LenzNotFoundError as exc: + # HTTP 404. The id (or key) the request names finds nothing: retrying + # will not change that. A LenzError, as in 2.x. + print(exc.fix) +except LenzConnectionError as exc: + # No answer at all, after the SDK's own retries: the network, DNS, TLS, + # or (LenzRequestTimeoutError, a subclass) one request's timeout. A + # LenzAPIError, as in 2.x; exc.__cause__ is the httpx exception. The + # request may have reached the server: resend with the SAME key + # (idempotency_key=exc.idempotency_key), never as a plain new call. + schedule_retry_in(30) +``` + +**`retryable`** (since 3.0, on every error): whether sending the same request +again can succeed. `True` for a connection failure, a request timeout, a 429, +a 5xx and a 409 that means "not yet" (`idempotency_conflict`: the first +request with that key is still running; `verification_not_ready`); `False` +for any other 4xx and a version error; `None` when there was no HTTP status +(a missing key, a `*_and_wait` timeout). A failed verification, review or +citation check carries the server's own value (`None` when it sent none). + +**`idempotency_key`** (since 3.0, on every error): the `Idempotency-Key` the +failed call sent, or `None` when it sent none. A resend is safe only with the +same key: pass `idempotency_key=exc.idempotency_key` back, and the server +replays the first answer (or reports the first request still running) +instead of running it again. A plain new call sends a new key, and can run +(and charge) the work twice. + +```python +from lenz_io import LenzError + +try: + client.assess(claim="...") +except LenzError as exc: + if exc.retryable: + # Later, the same request with the same key: + # client.assess(claim="...", idempotency_key=exc.idempotency_key) + schedule_retry_in(getattr(exc, "retry_after", None) or 30) + else: + raise +``` + +Local argument mistakes (an empty id, two exclusive arguments) raise +`ValueError`, never a `LenzError`. + +A `*_and_wait` helper that reaches its own `timeout` raises `LenzTimeoutError` +(`ReviewTimeout`, `CitecheckTimeout`): the job keeps running server-side, so +read it later by its id rather than resubmitting. A poll answered 401, 403, +404 or 410 (or in another API version) ends the wait at once with that error; +in `verify_batch_and_wait` a 404, a 410 or a version error fails that item only (the +others keep going), while a 401 / 403 raises. A 5xx, a 429 or a network +failure is polled again. Each poll is bounded by what is left of the +`timeout` (each phase at most the client's own), and no poll starts once it +is spent; `timeout=0` reads each status once, as in 2.x. `ReviewFailed`, `ReviewTimeout`, `CitecheckFailed` and +`CitecheckTimeout` are also importable as `ReviewFailedError`, +`ReviewTimeoutError`, `CitecheckFailedError` and `CitecheckTimeoutError` (the +same classes). + +`LenzApiVersionError` (a `LenzError`) is raised when a response names an API +version other than the one this SDK reads. Every response carries the version +that served it in `X-Lenz-API-Version`; lenz-io 3.x asks for `2026-10-11` and +reads that version's shape only, so an answer in `2026-05-13` (a server still +on the older version, or a reply replayed from an idempotent request stored +before the change) is refused rather than misread. It carries `api_version` +(what the response named), `status_code` and `body` as sent. If it persists, +contact support with the request id; lenz-io 2.x reads both versions. A response without the header is read as usual, and webhook +payloads are never refused (they are parsed in either shape). + +**Replays of requests made before the switch.** An idempotent request first +sent before lenz.io served `2026-10-11`, and replayed with the same +`Idempotency-Key` afterwards, answers with the stored reply in `2026-05-13`, +which 3.x refuses with `LenzApiVersionError`. Replays are kept for 24 hours +and replies stored since 2026-10-09 are kept in both versions, so in practice +none remain when 3.0 ships. If you meet one, finish that work with lenz-io +2.x; never change the key to get past it, which would run (and charge) the +request a second time. + +```python +from lenz_io import LenzApiVersionError + +try: + client.assess("The Earth is round.") +except LenzApiVersionError as exc: + print(exc.api_version) # "2026-05-13" ``` A failed *verification* (as opposed to a failed HTTP call) raises @@ -761,7 +918,10 @@ A failed *verification* (as opposed to a failed HTTP call) raises `failure_class` (closed set: `upstream_unavailable` | `insufficient_evidence` | `invalid_input` | `cancelled` | `internal`) and `retryable` — `True` means a transient provider-side exhaustion where resubmitting the same claim is the -right move; older servers leave it `None`. +right move; older servers leave it `None`. A verification cancelled elsewhere +(the website's Stop button, another process) raises the same error with +`failure_class == "cancelled"` and `retryable` `False`; `review_and_wait` and +`citecheck_and_wait` do the same with `ReviewFailed` and `CitecheckFailed`. `LenzQuotaExceededError` is a **sibling** of `LenzAuthError`, not a subclass — "fix your key" and "top up your account" are different actions. So if you were @@ -792,17 +952,73 @@ if status.status == "completed": print(status.result.verdict, status.result.lenz_score) ``` -## Idempotency +## Stopping a run + +A verification, a review or a citation check that is still running can be +stopped. Each has its own method, and each answers 200 whatever the state of +the run, so losing a race is not an error: + +```python +result = client.cancel("tsk_abc123") # a verification -> CancelResult +if result.cancelled: + print("stopped:", result.status) # "cancelled" +else: + print("already ended:", result.status) # "completed" or "failed" + +review = client.cancel_review("d6b2bd72") # the full view, like get_review +print(review.status, review.credits.charged) # "cancelled", what it cost -`verify_and_wait` sends an auto-generated `Idempotency-Key` on every call by -default, so a network drop after submit doesn't spawn a duplicate verification -or charge a second credit. Override with `idempotency_key="..."` to pin a -specific key, or `idempotency=False` to opt out. +check = client.cancel_citecheck("12bbbf65") # like get_citecheck +print(check.status, check.credits.charged) +``` + +- `cancel(task_id)` returns a `CancelResult`. `cancelled=True` with + `status == "cancelled"` means the run is cancelled, by this call or an + earlier one, so a repeated or retried cancel answers `True` too. + `cancelled=False` means it is not cancelled, and `status` is the run's + status, normally `"completed"` (the verification exists and was charged as + usual) or `"failed"`. A run waiting on `select` is cancelled too. A task that + `select` already resolved answers `cancelled=False` with `needs_input`: cancel + the task ids `select` returned. +- `cancel_review(review_id)` stops the review and everything in it: its quick + checks, its deep checks and its citation checks. It returns the review as it + stands, `status == "cancelled"`; a review that had already ended is returned + unchanged (`completed` or `failed`). +- `cancel_citecheck(citecheck_id)` returns the check the same way. +- A review's deep checks are cancelled **through the review**. Calling + `cancel(task_id)` with the `task_id` of one raises a `LenzError` with + `code == "use_review_cancel"` (HTTP 409); call `cancel_review` instead. The + SDK sends that request once and does not wait or resend. +- An unknown id, another account's, or (for `cancel`) a task started on the + website raises `LenzNotFoundError`. +- A cancelled verification is not charged and saves nothing. A cancelled + review or citation check is charged only for what it delivered before the + cancel (quick checks served, deep checks that finished, citations checked); + the rest is refunded or never charged. `credits.charged` on the result is + what the review or check cost. +- Cancelling twice is safe, so the calls send no `Idempotency-Key`. They are + retried on a 5xx, a 429 or a dropped connection, like any call that is safe + to repeat. +- Afterwards `get_status`, `get_review` and `get_citecheck` return the + `cancelled` status, and `wait`, `review_and_wait` and `citecheck_and_wait` + raise the failed error with `failure_class == "cancelled"`. A `webhook_url` + receives `verification.cancelled`, `review.cancelled` or `citecheck.cancelled`. + +## Idempotency -`assess` does the same, and `review` always sends one (pin it with `idempotency_key=`). `ask.send` takes an `idempotency_key="..."` too, but -never generates one: re-asking the same question is a normal thing to do, and -a key you did not choose would replay the earlier answer. Pass one when your -retry means "the same question, once" — the reply, the credit and the +Every call that charges or starts work sends an auto-generated +`Idempotency-Key` by default: `verify`, `verify_and_wait`, `verify_batch`, +`verify_batch_and_wait`, `select`, `assess`, `extract` and `ask.send` (one +random key per call, reused across that call's own retries), so a network +drop after submit doesn't spawn a duplicate or charge a second time. Override +with `idempotency_key="..."` to pin a specific key (it also makes a retry +from another process replay), or `idempotency=False` to opt out. `review` and +`citecheck` always send one (pin it with `idempotency_key=`). The batch and +`ask.send` keys are new in 3.0; 2.x sent one there only when you passed it. + +The key is never derived from the request: asking the same question again on +`ask.send` is a new call, with a new key, and is asked again. Pin a key when +your retry means "the same question, once" — the reply, the credit and the conversation history are then all the first call's: ```python @@ -813,9 +1029,14 @@ reply = client.ask.send( ) ``` -A retry that arrives while the first call is still running gets a 409 -(`LenzError`) rather than the reply — there is nothing finished to replay -yet. +A retry that arrives while the first call with that key is still running is +answered 409 `idempotency_conflict`. The SDK sends the same key and body +again inside the same call, after the wait the server states (or its usual +backoff), within the call's retries; if the first call is still running +after them, it raises that `LenzError` with `retryable=True`. It never mints +a second key to get past it. Every error of a call that sent a key carries +it as `exc.idempotency_key`: resend with that key, never as a plain new call, +which would send a new key and could run (and charge) the work twice. ## Steering extract @@ -941,13 +1162,71 @@ batch = client.verify_batch( ) ``` +## Using Lenz from async code + +The client is synchronous. Lenz calls take a while (`assess` about 15 s, a deep check about +90 s, a review a few minutes), so calling one directly inside an `async def` blocks the event +loop for that long: in a FastAPI or aiohttp server, every other request on that worker +waits. Run the call in a worker thread instead: + +```python +import asyncio +from lenz_io import Lenz + +client = Lenz() # one client for the whole app; it is safe to share across threads + + +async def quick_check(claim: str) -> str: + out = await asyncio.to_thread(client.assess, claim=claim) + return out.claims[0].verdict +``` + +The same applies to every method, and above all to the ones that wait (`verify_and_wait`, +`verify_batch_and_wait`, `review_and_wait`, `citecheck_and_wait`, `wait`): never call them +directly inside an async handler. In FastAPI, a plain `def` route runs in the thread pool +already: + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from fastapi import FastAPI +from lenz_io import Lenz + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + app.state.lenz = Lenz() + yield + app.state.lenz.close() + + +app = FastAPI(lifespan=lifespan) + + +@app.post("/check") +def check(claim: str) -> dict[str, object]: # `def`, not `async def`: FastAPI runs it in a thread + v = app.state.lenz.verify_and_wait(claim) + return {"verdict": v.verdict, "score": v.lenz_score} +``` + +Cancelling the asyncio task that awaits `asyncio.to_thread(...)` does not stop the call: the +thread runs on until the call returns. Bound each request with `timeout=` (see +[Per-call options](#per-call-options)), and stop paid work on the server with `cancel()`, +`cancel_review()` or `cancel_citecheck()` (see [Stopping a run](#stopping-a-run)). A native +async client is planned. + +For a long check behind a web request, `verify(..., webhook_url=...)` returns at once and +Lenz posts the result to you when it is done (see [Webhooks](#webhooks)). That ties up no +thread for the wait. To check many claims at once, send them in one call +(`assess(claims=[...])`, `verify_batch`) rather than one thread per claim. + ## Configuration ```python Lenz( api_key="lenz_...", # or set LENZ_API_KEY env var base_url="https://lenz.io/api/v1", # override for staging / local - timeout=30.0, + timeout=30.0, # seconds per request; also None or an httpx.Timeout max_retries=3, ) ``` @@ -959,6 +1238,90 @@ Environment variables: An OAuth access token for the Lenz API works wherever the API key goes: pass it as `api_key` or in `LENZ_API_KEY`. +`timeout` must be a number of seconds greater than 0 and at most 2,147,483, `None` (no timeout), an +`httpx.Timeout` or httpx's `(connect, read, write, pool)` tuple; `max_retries` a +whole number, 0 or more. Anything else raises `ValueError` when the client is +built. + +### Per-call options + +Every method takes three keyword-only request options, for that call only: + +```python +client.assess(claim="...", timeout=20) # one HTTP attempt, in seconds +client.usage(max_retries=0) # no retries for this call +client.verify("...", extra_headers={"X-Trace-Id": trace}) # added to the request +``` + +- `timeout`: one HTTP attempt, a number of seconds greater than 0 or an + `httpx.Timeout`. httpx applies it per phase (connect, read, write, pool), and + the read limit to each chunk of the answer: it limits inactivity, not the + whole call, and each retry gets its own. +- `max_retries`: how often a request that failed in a way worth retrying (a + 5xx, a 429, a dropped connection) is sent again: a whole number, 0 or more. +- `extra_headers`: headers added to the request. Names are header tokens and + values visible ASCII, with spaces and tabs allowed inside but not at either + end (an empty value is fine); anything else raises `ValueError`. The SDK's own headers are + refused (`X-Lenz-API-Version`, `Idempotency-Key`, `Authorization`, + `Content-Type`, `Content-Length`, `Host`, `Transfer-Encoding`): use + `idempotency_key=` and `api_key=` for the first two. A header with the name + of a default one (`User-Agent`, `Accept`), in any casing, replaces the + default instead of being sent next to it. + +What each option reaches: + +| Methods | `timeout` | `max_retries` | `extra_headers` | +|---|---|---|---| +| Plain calls (`verify`, `review`, `get_status`, `cancel`, `usage`, `verifications.*`, `ask.*`, `library.list`, ...) | the attempt | the call's retries | every request | +| `extract`, `assess` | the attempt, used as given | the call's retries | every request | +| Waits (`wait`, `verify_and_wait`, `verify_batch_and_wait`, `review_and_wait`, `citecheck_and_wait`) | **how long to wait** (unchanged) | the submit's retries (`wait` has none: each poll is one request) | the submit and every poll | +| `verifications.iter`, `library.iter` | each page's attempt | each page's retries | every page | +| `with_options` | the default attempt timeout of the copy (also what each poll of a wait uses, capped by what is left of the wait) | the copy's default for plain calls and submits; never a wait's polls, which are one attempt each | added to every request of the copy | + +A bad value raises `ValueError` before anything is sent (for the iterators, +when the iterator is created). Precedence, per option: the call's keyword, then +the copy's (`with_options`), then the client's; headers merge, the call's over +the copy's. + +`extract` and `assess` wait at least 150 s and 100 s when the timeout comes +from a copy or the client, as they always did. A timeout passed to the call is +used as given, even below that: it can time out a call the server is still +running, so retry it with the same `idempotency_key` to get its answer. + +`None` means different things in two places: + +| | `None` | not passed | +|---|---|---| +| `timeout=` on a call | the copy's or the client's timeout | the same | +| `with_options(timeout=...)`, `Lenz(timeout=...)` | no timeout | keep the current one (the client default is 30 s) | +| `extra_headers={"X-A": None}` | removes `X-A` added by a copy | — | + +For no timeout on one call, pass `timeout=httpx.Timeout(None)`. + +### A client with other defaults: `with_options` + +`client.with_options(...)` returns a copy with its own defaults, sharing the +connection pool, key and base URL. It is cheap, so you can make one per request +or per job, and the client it was made from does not change: + +```python +fast = client.with_options(timeout=10, max_retries=0) +fast.assess(claim="...") + +for job in jobs: + scoped = client.with_options(extra_headers={"X-Job-Id": job.id}) + scoped.review_and_wait(job.draft) +``` + +A copy of a copy starts from the copy's options (timeout, retries and +headers), and changes only what it is given. The pool belongs to the +client that created it: `close()` and `with` on a copy do nothing, closing the +original closes the pool for every copy (a copy then raises httpx's +closed-client error), and a client given `http_client=` never closes it. A +copy is as safe to share across threads as the client. The copy is shallow: +attributes a subclass of `Lenz` adds are shared with the client it was made +from. + ## Compatibility - Python 3.10, 3.11, 3.12 diff --git a/examples/core/citecheck_draft.py b/examples/core/citecheck_draft.py new file mode 100644 index 0000000..0c593ec --- /dev/null +++ b/examples/core/citecheck_draft.py @@ -0,0 +1,57 @@ +"""Check a draft's citations on their own: does each source say what the +draft says it does? + +Run: + export LENZ_API_KEY=lenz_... + python examples/core/citecheck_draft.py + +Send a draft (its links and DOIs are read from the text; keep a link as a +markdown link) or the statement-source pairs yourself. 1 credit per checked +citation; a citation that could not be checked is not charged. +""" + +from __future__ import annotations + +import os + +from lenz_io import CitationPair, Citecheck, CitecheckFailedError, CitecheckTimeoutError, Lenz + +DRAFT = ( + "Water boils at 100 degrees Celsius at sea level, according to " + "[the encyclopedia entry](https://en.wikipedia.org/wiki/Boiling_point)." +) + +PAIRS: list[CitationPair] = [ + { + "statement": "Diamond sensors can measure temperature inside a living cell.", + "doi": "10.1038/nature12373", + "cited_year": "2013", + }, +] + + +def report(label: str, check: Citecheck) -> None: + print(f"{label}: {check.outcome}") # clean | issues_found | incomplete | unchecked + for row in check.citations: + finding = row.result.finding if row.result else "pending" + print(f" {finding}: {row.cited_url or row.doi}") + for issue in check.citation_issues: # most serious first + print(f" ISSUE {issue.finding}: {issue.statement}") + if issue.snippet: + print(f" The source says: {issue.snippet}") + + +def main() -> None: + client = Lenz(api_key=os.environ.get("LENZ_API_KEY")) + try: + report("draft", client.citecheck_and_wait(DRAFT, max_citations=10)) + report("pairs", client.citecheck_and_wait(pairs=PAIRS)) + except CitecheckTimeoutError as exc: + # The check keeps running: read it later by its id, never resubmit. + print("Still running; read it later with client.get_citecheck:", exc.citecheck_id) + except CitecheckFailedError as exc: + print("The check failed:", exc.error_code, exc.hint) + + +if __name__ == "__main__": + main() diff --git a/examples/core/fastapi_webhook.py b/examples/core/fastapi_webhook.py index 9454436..446c7c5 100644 --- a/examples/core/fastapi_webhook.py +++ b/examples/core/fastapi_webhook.py @@ -1,7 +1,8 @@ +# mypy: allow-untyped-decorators """Receive Lenz webhook events in a FastAPI app. -Lenz POSTs HMAC-signed payloads to your ``webhook_url`` when the -verification pipeline terminates. This handler verifies the signature, +Lenz POSTs HMAC-signed payloads to your ``webhook_url`` when a +verification, a review or a citation check ends. This handler verifies the signature, parses the payload into a typed event, and dispatches per event type. Run: @@ -22,8 +23,11 @@ from fastapi import FastAPI, HTTPException, Request from lenz_io import ( + CitecheckEvent, LenzWebhooks, LenzWebhookSignatureError, + ReviewEvent, + VerificationCancelled, VerificationCompleted, VerificationFailed, VerificationNeedsInput, @@ -45,23 +49,57 @@ async def lenz_webhook(request: Request) -> dict[str, str]: logger.warning("Rejected webhook: %s", exc.message) raise HTTPException(status_code=400, detail=str(exc)) from exc + # A delivery can be retried: deduplicate on event.event_id (stable across + # attempts) before acting on it. if isinstance(event, VerificationCompleted): - # Verdict block is FLAT on event.result — no nested object. - logger.info( - "Completed: %s -> %s (lenz_score %s, confidence %s)", - event.verification_id, - event.result.get("verdict"), - event.result.get("lenz_score"), - event.result.get("confidence"), - ) + # The verification as client.get_status returns it; the verdict is + # under .result. + result = event.verification.result if event.verification else None + if result is not None: + logger.info( + "Completed: %s -> %s (lenz_score %s, confidence %s)", + result.verification_id, + result.verdict, + result.lenz_score, + result.confidence, + ) # TODO: persist verdict + sources to your DB; ping users; etc. elif isinstance(event, VerificationNeedsInput): - logger.info("Needs input on %s: %s", event.task_id, event.needs_input.get("reason")) + logger.info("Needs input on %s: %s", event.task_id, [c.claim for c in event.claims]) # TODO: surface the candidate claims to the user, then call # client.select(task_id, claims=[...]) to resolve. elif isinstance(event, VerificationFailed): - logger.warning("Pipeline failed: %s (%s)", event.task_id, event.error) + failure = event.failure + logger.warning("Verification failed: %s (%s)", event.task_id, failure.code if failure else "unknown") + elif isinstance(event, VerificationCancelled): + # Stopped elsewhere (the website's Stop button, another process). + # Work submitted by an older client reports this as verification.failed + # with failure_class "cancelled" instead. + logger.info("Verification cancelled: %s", event.task_id) + elif isinstance(event, ReviewEvent): + # review.completed / review.failed / review.cancelled: the final review, + # as get_review returns it (event.status says which). + if event.review is not None: + logger.info( + "Review %s %s: outcome %s, %d issue(s)", + event.review_id, + event.status, + event.review.outcome, + len(event.review.issues), + ) + elif isinstance(event, CitecheckEvent): + # citecheck.completed / citecheck.failed / citecheck.cancelled: the final + # check (event.status says which). + if event.citecheck is not None: + logger.info( + "Citation check %s %s: outcome %s, %d issue(s)", + event.citecheck_id, + event.status, + event.citecheck.outcome, + len(event.citecheck.citation_issues), + ) else: + # A new event type: acknowledge it and move on. logger.info("Unhandled webhook event: %s", event.event) # Always return 2xx fast. Lenz expects an ack within 5s; otherwise the diff --git a/examples/core/quickstart.py b/examples/core/quickstart.py index 73e3b2b..ff2bc42 100644 --- a/examples/core/quickstart.py +++ b/examples/core/quickstart.py @@ -1,4 +1,4 @@ -"""Lenz quickstart — the canonical four-primitive integration. +"""Lenz quickstart — the canonical integration: the four-call ladder. Run: export LENZ_API_KEY=lenz_... @@ -27,7 +27,7 @@ def main() -> None: # 1. extract — pull verifiable claims out of any text (free) out = client.extract(text="Sharks don't get cancer. The Eiffel Tower is 330m tall.") - claims = out.identified_claims or [out.claim] + claims = [c.claim for c in out.claims] print(f"Extracted {len(claims)} claims:") for c in claims: print(f" - {c}") @@ -35,24 +35,28 @@ def main() -> None: # 2. assess — one call over the extracted claims, one row per claim (sync) quick = client.assess(claims=claims).claims - for c in quick: - print(f" {c.verdict:<12} conf={c.confidence:<7} {c.claim}") - if c.verdict == "Error": - # No verdict for this item — error_code says why, hint says what to send next. - print(f" {c.error_code}: {c.hint}") - if c.identified_claims: + for row in quick: + print(f" {row.verdict:<12} conf={row.confidence:<7} {row.claim}") + if row.status == "failed" and row.failure: + # No verdict for this item — failure.code says why, failure.hint says what to send next. + print(f" {row.failure.code}: {row.failure.hint}") + if row.more_claims: # A compound item: only its main claim was assessed. - print(f" also found (not assessed): {c.identified_claims}") + print(f" also found (not assessed): {row.more_claims}") print() # 3. verify — escalate the low-confidence rows to the full multi-model panel # verify_batch_and_wait takes up to 20 claims a call: the first 20 here - doubtful = [{"claim": c.claim} for c in quick if c.verdict != "Error" and c.confidence == "low"][:20] + low = [row for row in quick if row.status == "completed" and row.confidence == "low"] + doubtful = [{"claim": row.claim} for row in low[:20]] # Fall back to the demo claim so the walkthrough always reaches steps 3 # and 4 even when every row came back confident. doubtful = doubtful or [{"claim": "Sharks don't get cancer"}] results = client.verify_batch_and_wait(claims=doubtful) - v = next(r.verification for r in results if r.verification is not None) + v = next((r.verification for r in results if r.verification is not None), None) + if v is None: + print("No verification completed:", [r.status for r in results]) + return print(f"Verdict: {v.verdict} (lenz_score {v.lenz_score}, confidence {v.confidence})") print(f"Summary: {v.executive_summary}") print() @@ -65,7 +69,7 @@ def main() -> None: reply = client.ask.send(v.verification_id, message="Which source is strongest?") print() print("Q: Which source is strongest?") - print(f"A: {reply.reply}") + print(f"A: {reply.content}") if __name__ == "__main__": diff --git a/examples/core/review_draft.py b/examples/core/review_draft.py new file mode 100644 index 0000000..5bd29ec --- /dev/null +++ b/examples/core/review_draft.py @@ -0,0 +1,66 @@ +"""Review a draft: every claim quick-checked, the doubtful ones deep-checked. + +Run: + export LENZ_API_KEY=lenz_... + python examples/core/review_draft.py + +One call pulls the claims out of the draft, gives each a quick verdict, sends +the ones that look wrong or uncertain through the deep check (up to five by +default) and returns the issues with suggested rewrites. It takes two to four +minutes; ``on_update`` shows the quick verdicts as soon as they are in. + +Credits: 1 per claim assessed, plus 10 per deep check (5 at ``depth="low"``), +plus 1 per checked citation; ``review.credits.charged`` says what it cost. +""" + +from __future__ import annotations + +import os + +from lenz_io import Lenz, LenzError, ReviewFailedError, ReviewFull, ReviewTimeoutError + +DRAFT = """ +The EU AI Act entered into force on 1 August 2024, and its obligations for +general-purpose models applied from 2 August 2025. Fines for prohibited +practices reach 7% of global annual turnover, according to +[the regulation](https://eur-lex.europa.eu/eli/reg/2024/1689/oj). +""" + + +def show_progress(review: ReviewFull) -> None: + done = sum(1 for c in review.claims if c.assessment is not None and c.assessment.status != "pending") + print(f" {review.status}: {done}/{len(review.claims)} claims have a quick verdict") + + +def main() -> None: + client = Lenz(api_key=os.environ.get("LENZ_API_KEY")) + try: + review = client.review_and_wait( + DRAFT, + max_citations=5, # also check the draft's first 5 sources (1 credit each) + suggest_edits=True, # the smallest edits that make the draft say what each rewrite says + on_update=show_progress, + ) + except ReviewTimeoutError as exc: + # The review keeps running: read it later by its id, never resubmit. + print("Still running; read it later with client.get_review:", exc.review_id) + return + except ReviewFailedError as exc: + print("The review failed:", exc.error_code, exc.hint) + return + except LenzError as exc: + print("Could not run the review:", exc.message, "(retryable)" if exc.retryable else "") + return + + print(f"\nOutcome: {review.outcome}") # clean | issues_found | incomplete | unchecked + for issue in review.issues: + print(f"- {issue.verdict} ({issue.confidence}): {issue.claim}") + if issue.suggested_rewrite: + print(f" Suggested rewrite: {issue.suggested_rewrite}") + for citation in review.citation_issues: # most serious first + print(f"- Source issue {citation.finding}: {citation.cited_url or citation.doi}") + print(f"Credits charged: {review.credits.charged if review.credits else 'unknown'}") + + +if __name__ == "__main__": + main() diff --git a/examples/core/verify_batch.py b/examples/core/verify_batch.py index 918a6e8..7f89a0d 100644 --- a/examples/core/verify_batch.py +++ b/examples/core/verify_batch.py @@ -31,14 +31,14 @@ def main() -> None: for r in results: if r.status == "completed" and r.verification is not None: - print(f"[completed] {r.claim_text} → {r.verification.verdict} ({r.verification.lenz_score})") + print(f"[completed] {r.claim} → {r.verification.verdict} ({r.verification.lenz_score})") elif r.status == "failed": detail = r.status_detail - reason = (detail.error or detail.failure_detail) if detail else "unknown" - print(f"[failed] {r.claim_text} → {reason}") + reason = detail.failure.detail if detail and detail.failure else "unknown" + print(f"[failed] {r.claim} → {reason}") else: # needs_input (resolve with client.select) or timeout (poll later) - print(f"[{r.status}] {r.claim_text}") + print(f"[{r.status}] {r.claim}") if __name__ == "__main__": diff --git a/examples/core/verify_llm_output.py b/examples/core/verify_llm_output.py index 343ff0f..c8f929f 100644 --- a/examples/core/verify_llm_output.py +++ b/examples/core/verify_llm_output.py @@ -30,23 +30,23 @@ def main() -> None: # Step 1: extract — pull the verifiable claims out of the answer (free). out = client.extract(text=LLM_OUTPUT) - claims = out.identified_claims or [out.claim] + claims = [c.claim for c in out.claims] print(f"Extracted {len(claims)} claims.\n") # Step 2: assess — one call per 20 extracted claims (extract finds up to # 100; one assess call takes 20), one row per claim, same order. A row - # with verdict "Error" got no verdict: ``error_code`` says why - # (``upstream_unavailable`` is worth a retry) and ``hint`` says what to - # send next. A compound item is assessed on its main claim and lists the - # rest in ``identified_claims``. + # with ``status == "failed"`` got no verdict: ``failure.code`` says why + # (``upstream_unavailable`` is worth a retry) and ``failure.hint`` says + # what to send next. A compound item is assessed on its main claim and + # lists the rest in ``more_claims``. quick = [row for i in range(0, len(claims), 20) for row in client.assess(claims=claims[i : i + 20]).claims] print(f"Assessed {len(quick)} claims:\n") for c in quick: print(f" {c.verdict:<12} conf={c.confidence:<7} {c.claim}") - if c.verdict == "Error": - print(f" {c.error_code}: {c.hint}") - elif c.identified_claims: - print(f" also found (not assessed): {c.identified_claims}") + if c.status == "failed" and c.failure: + print(f" {c.failure.code}: {c.failure.hint}") + elif c.more_claims: + print(f" also found (not assessed): {c.more_claims}") print() # Step 3: verify — escalate the low-confidence rows to the full pipeline. @@ -54,15 +54,16 @@ def main() -> None: # claim that already has a deep verification surfaces immediately # via ``verification_url`` and you can skip the escalation. # verify_batch_and_wait takes up to 20 claims a call: the first 20 here - doubtful = [{"claim": c.claim} for c in quick if c.verdict != "Error" and c.confidence == "low"][:20] + low = [c for c in quick if c.status == "completed" and c.confidence == "low"] + doubtful = [{"claim": c.claim} for c in low[:20]] print(f"Escalating {len(doubtful)} low-confidence claims to full verification:\n") results = client.verify_batch_and_wait(claims=doubtful, timeout=180) if doubtful else [] for r in results: v = r.verification if v is None: - print(f"{r.status.upper():<14} {r.claim_text}") + print(f"{r.status.upper():<14} {r.claim}") continue - print(f"{v.verdict.upper():<14} (lenz_score {v.lenz_score}) {r.claim_text}") + print(f"{v.verdict.upper():<14} (lenz_score {v.lenz_score}) {r.claim}") if v.verdict.lower() in ("false", "misleading") and v.sources: print(f" ↳ {v.sources[0].title}") print(f" {v.sources[0].url}") diff --git a/openapi.json b/openapi.json index 8768c4d..bb5bb44 100644 --- a/openapi.json +++ b/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Lenz \u2014 AI Fact-Checking API", "version": "1.0.0", - "description": "# Four API calls and one to run them\n\nA research-depth ladder: find claims, judge fast, prove deep, citecheck; `/review` runs them all.\n\n- `POST /extract` \u2014 enumerate every major verifiable claim in a document (up to 100, most check-worthy first, inputs to 50k chars), or in a public web page given its URL; an optional `focus` narrows the list to the claims you care about, and `locate: true` keeps only the claims traced back to the text, with where each is made. Costs no credits; fair-use cap 1,000/account/day.\n- `POST /assess` \u2014 fast 3-model frontier panel verdict, typically 10-25s. Sync, 1 credit per claim. Send one text in `claim`, or a list of up to 20 in `claims` \u2014 one row per claim, same order, all checked in parallel. A text that makes more claims than one call checks gets the most check-worthy checked and the rest back as `more_claims`, free, to send on.\n- `POST /verify` \u2014 full multi-model pipeline with citations in ~90s. Async, 10 credits \u2014 or 5 with `depth: \"low\"`, a shallower check that comes back sooner.\n- `POST /citecheck` \u2014 does each cited source say what the text attributes to it: send pairs of a statement and its URL or DOI, or a whole text. Async; 1 credit per checked citation, refunded when a citation could not be checked ([guide](https://lenz.io/docs/citations)).\n- `POST /review` \u2014 the extract \u2192 assess \u2192 verify ladder on a whole draft, returning the issues with suggested rewrites in 2-4 min. Async, 1 credit per claim assessed + 10 (5 at `depth: \"low\"`) per claim verified. With `escalate.max_citations` (0-20, default 0) it also checks whether each source the draft cites says what the draft attributes to it, at 1 credit per checked citation (one that could not be checked is refunded) ([guide](https://lenz.io/docs/citations)). `more_claims` and `more_citations` list what the review found and did not check, at no cost; send them to `/assess` and `/citecheck` to check the rest.\n- Also `POST /ask/{id}` \u2014 ask follow-up questions grounded on a verification.\n\n## Built for teams whose AI output is async or document-shaped\n\nLegal-memo generators, AI deep-research, due-diligence platforms, vertical agents producing structured deliverables, \"report-as-a-service\" companies. `/assess` opens the door to sync UX too \u2014 fast enough to gate a chat completion or a UI submit.\n\n## API versions\n\nEvery request is answered in version `2026-05-13` for now. This reference describes the next version, which is not available yet: what `2026-05-13` sends instead is marked here. A field it still sends under an older name is listed `deprecated` beside the current one (\"Sent only to callers on `2026-05-13`\"), and where its shape differs, the operation names the `...Legacy` component it receives (`x-lenz-versions` on the response). Every response carries `X-Lenz-API-Version`, naming the version it is in.\n\n## Install the SDK\n\n```bash\npip install lenz-io # Python\nnpm install lenz-io # Node / TypeScript\n```\n\nSource on GitHub: [lenz-io-python](https://github.com/lenzhq/lenz-io-python) \u00b7 [lenz-io-node](https://github.com/lenzhq/lenz-io-node).\n\n## The canonical integration: one /review call\n\n```python\nfrom lenz_io import Lenz\nclient = Lenz(api_key=\"lenz_...\")\n\n# Extract, assess, and verify the doubtful claims in one call (async, 2-4 min)\ndraft = \"The EU AI Act took effect in March 2024. It sorts AI systems into four risk tiers.\"\nreview = client.review_and_wait(text=draft)\nfor i in review.issues:\n print(i.verdict, i.claim, i.suggested_rewrite)\n```\n\nThe default policy: a quick verdict on every claim (up to 20), and the deep check on the rows whose quick verdict is `False`, `Mostly False` or `Mixed`, or whose confidence is `low`, up to five. Change it in `escalate`. To deep-check the rows the cap left out (`escalation.disposition == \"cap\"`), send them to `/verify/batch`. A resend with the same `Idempotency-Key` within 24 hours returns the same review; a new key is a new review.\n\n## The primitives, call by call: extract \u2192 assess \u2192 conditional verify\n\n```python\nfrom lenz_io import Lenz\nclient = Lenz(api_key=\"lenz_...\")\n\n# 1. Enumerate the factual claims in your model output (free)\n# identified_claims is the complete ordered list when >1 claim is\n# found (top claim included \u2014 iterate it alone); for a single claim\n# it is [] and the claim lives in .claim.\n# Pass focus=\"market size and competitors\" to narrow the list;\n# status is \"no_match\" when the document has claims but none match.\nextracted = client.extract(text=llm_output)\nclaims = extracted.identified_claims or [extracted.claim]\n\n# 2. Fast verdict on every claim in ONE call (typically 10-25s, 3-model panel):\n# a list of up to 20, one row per claim, in the order you sent them.\nquick = client.assess(claims=claims).claims\n\n# 3. Escalate the low-confidence ones to /verify in one batch (~90s, multi-model pipeline)\n# Rows we ran out of time on are free and worth resending; the rest of the\n# Error rows (no_claim, framing_failed) want a different input.\nretry = [c.claim for c in quick if c.error_code == \"timeout\"]\nquick += client.assess(claims=retry).claims if retry else []\ndoubtful = [{\"claim\": c.claim} for c in quick if c.verdict != \"Error\" and c.confidence == \"low\"]\nfor r in client.verify_batch_and_wait(claims=doubtful) if doubtful else []:\n if r.verification:\n print(r.verification.verdict, r.verification.lenz_score, r.verification.key_finding)\n```\n\n## How /verify works under the hood\n\nFrame \u2192 Collect Evidence \u2192 Debate (2 models, 2 rounds) \u2192 Adjudicate (three reviewers on models from different vendors, two more when they disagree) \u2192 Conclude. ~90 seconds wall-clock per claim.\n\n### While /verify is running\n\n`GET /verify/status/{task_id}` answers one of four shapes, keyed on `status`: `processing`, `needs_input`, `completed`, `failed`. Every one echoes `task_id`, and only the fields belonging to that shape are present \u2014 read the ones for the branch you are in, and treat an absent key as absent rather than empty.\n\nA `processing` body carries `progress`:\n\n```json\n{\"status\": \"processing\", \"task_id\": \"\u2026\",\n \"progress\": {\"step\": \"research\", \"index\": 2, \"total\": 5,\n \"elapsed_seconds\": 42, \"poll_after_seconds\": 5}}\n```\n\n`step` is a closed set \u2014 `starting`, `framing`, `research`, `debate`, `adjudication`, `conclusion` \u2014 so it is safe to switch on. `index` is the 1-based stage position (0 while `starting`) out of `total`; read `total` off the response rather than hard-coding it. `poll_after_seconds` is how long to wait before looking again, and `elapsed_seconds` is how long the run has been going \u2014 a measurement, not an estimate of what is left.\n\nNote that `index` is stage **position**, not elapsed work: the stages are uneven, so a progress bar driven by it will sit on `research` for roughly half the run.\n\n`progress` is **advisory**. It says the work is pending and which stage it is on; it is not a place to read results from. The verdict arrives once, under `result`, when `status` is `completed`.\n\n### When /verify asks for input\n\n`/verify` is not only submit-and-poll. `GET /verify/status/{task_id}` can terminate at `needs_input` \u2014 the submitted text held several claims, or was too vague to check as written \u2014 and the task waits rather than guessing which claim you meant. Several claims in one sentence count the same as several sentences.\n\nResolve it with `POST /verify/{task_id}/select`, passing the exact wording of the claim(s) you want from the ones the status offered. Selection is **by text, not index**; anything that was not offered is rejected with a 422. Each selected claim fans out into its own pipeline, so the response carries one `task_id` per claim \u2014 poll each the way you would a fresh `/verify`.\n\nClaims returned by `/extract` skip this entirely: submitted verbatim, they are never bounced back for rephrasing.\n\n### Covered verification\n\nEvery verification a paid Pro or Scale account runs is put in front of a gate. The ones that qualify carry a contractual warranty, and every result tells you whether it qualified \u2014 a `coverage` block on the completed `/verify/status/{task_id}` body, on `GET /verifications/{id}` and on the `verification.completed` webhook.\n\nIt is on by default, with no request field to set; an account on Pro or Scale can turn certificates off on the API credentials page, for checks submitted from then on (reason `account`; a verification that already carries a certificate keeps it).\n\n`status` is `covered`, `pending_timestamp` or `uncovered`; `reasons` says why not, in a closed seven-value vocabulary. **If you publish automatically, key on the `certificate.timestamped` webhook, not `verification.completed`** \u2014 cover requires the qualified timestamp to precede what you publish, and `completed` fires while the qualified timestamp is still pending.\n\nFull wire format, the reason vocabulary and the offline verifier: [lenz.io/docs/coverage](https://lenz.io/docs/coverage). What is covered and what is not: [lenz.io/warranty](https://lenz.io/warranty).\n\n### When /verify fails\n\nA `failed` status (and the `verification.failed` webhook) carries two axes. `failure_reason` says **where** the pipeline stopped (`research_empty`, `conclusion_failed`, `task_stuck`, \u2026); on the webhook the same value rides in `error`. `failure_class` says **why**, and `retryable` is derived from it:\n\n| `failure_class` | Meaning | `retryable` |\n|---|---|---|\n| `upstream_unavailable` | Model or search providers were rate-limited or down, or the task was never run \u2014 resubmit after a short wait | `true` |\n| `insufficient_evidence` | We looked; the sources are not there | `false` |\n| `invalid_input` | Not a checkable claim, an unreadable URL | `false` |\n| `cancelled` | You stopped it | `false` |\n| `internal` | Anything else \u2014 retrying the same input will not help | `false` |\n\nBranch on `retryable`, not on `failure_reason`: the same reason can mean either. `failure_class` is a **closed set** \u2014 these five values are stable identifiers. `failure_reason` is an open, informational set (`task_error`, `task_stuck`, `not_a_claim`, \u2026 will appear). A failed body also carries `docs_url`, pointing at the explanation of its class. A failed `/verify` is not charged.\n\nOne of those reasons comes from framing rather than from a pipeline step and carries `failure_class: invalid_input`: `not_a_claim` (the input is not a checkable statement, or is too vague to have a checkable reading). A vague input that has a likely reading is verified on it, and the result's `claim` is that reading. On the webhook the same value rides in `error`, as an ordinary `verification.failed` delivery; before 2026-09-03 it fired nothing at all, so a task submitted with a `webhook_url` could terminate in silence.\n\n### What may change without notice\n\nNew keys may be added to status and webhook bodies at any time \u2014 ignore keys you do not recognise. Fields documented as **advisory** \u2014 `progress` and everything inside it \u2014 may also change or disappear without a version bump. The verdict fields under `result` may not: those change only with a versioned, announced release.\n\n## Common patterns\n\n- **Runtime sync UX**: `POST /assess` on the user-facing path; escalate `confidence == \"low\"` claims to `/verify` in the background.\n- **Runtime webhook**: `POST /verify` with `webhook_url`. Lenz POSTs the typed payload when done. Best for document pipelines.\n- **Whole drafts**: `POST /review` with `webhook_url`; handle `review.completed` (a `ReviewWebhookPayload`, deduped on `event_id`) and read `review.issues[]` and `review.citation_issues[]`.\n- **CI / pre-release**: `/extract` + batch `/verify` your golden set on every deploy. The free `/extract` tier covers most of this. Claims returned by `/extract` verify cleanly \u2014 submitted verbatim to `/verify`, they are never bounced back for rephrasing.\n- **Incident triage**: paste the offending output into `/extract`, then `/verify` the wrong-looking claims. Get a citation trail to send back to the customer.\n\n## What's in the response\n\nUnified vocabulary across every claim-shaped response:\n\n- **`claim`** \u2014 the framed claim text.\n- **`verdict`** \u2014 `\"True\" | \"Mostly True\" | \"Mixed\" | \"Mostly False\" | \"False\" | \"Error\"`.\n- **`confidence`** \u2014 `\"high\" | \"medium\" | \"low\"`.\n- **`lenz_score`** \u2014 1\u201310 integer score (deep payloads).\n- **`key_finding`** \u2014 one declarative sentence stating the most important fact the analysis established. For a false claim it states the CORRECTED fact rather than restating the claim, so render it next to the verdict label, never as a standalone headline. `\"\"` on older verifications predating the field.\n- **`suggested_rewrite`** \u2014 a suggested rewrite of `claim` that the verification's findings support. It has not been verified itself: before using it, review it or run it through `/verify`. `null` for a true claim, when no correction is established, and on older verifications. On every verification, single or listed (`GET /verifications/{id}`, `/verify/status`, the `verification.completed` webhook, `GET /verifications`, `GET /library`). On `/assess` rows too when the request sets `suggest_rewrite`: written from the quick check's reasoning for a claim it found False or Mostly False with high confidence, at no extra credit; `null` otherwise, and on every row when not requested.\n- **`sources`** \u2014 evidence with title, URL and snippet: the sentence(s) quoted from the page, verbatim and in the page's own language (`/verify` only).\n- **`audit`** \u2014 adjudication reasoning, debate transcript, panel agreement (`/verify` only).\n\n## How the API is organized\n\n- **Extract** \u2014 pull verifiable claims out of text.\n- **Assess** \u2014 fast 3-model verdict.\n- **Verify** \u2014 deep multi-model pipeline: submit one claim or a batch, poll status, and resolve a `needs_input` interrupt by selecting claims.\n- **Verifications** \u2014 list, fetch, delete, and toggle visibility on stored verifications.\n- **Library** \u2014 browse the public catalog. No API key needed.\n- **Ask** \u2014 follow-up questions, 1 credit each.\n- **Account** \u2014 your credit balance and the price list.\n\n## Credits\n\nOne pool per account funds every billable call, at a fixed weight:\n\n| Endpoint | Credits |\n|---|---|\n| `POST /verify` (and `/verify/batch`, `/select`) | 10 per claim |\n| `POST /verify` with `depth: \"low\"` | 5 per claim |\n| `POST /assess` | 1 per claim \u2014 per row that carries a verdict; error rows and rows answered from the cache are free |\n| `POST /ask` | 1 |\n| `POST /extract` | 0 \u2014 free, fair-use capped |\n\n`depth: \"low\"` caps research breadth \u2014 fewer discovery queries, a hard extraction ceiling, no recovery tiers on the happy path \u2014 while every reasoning step runs unchanged. It is not a model downgrade. You are charged for the depth you REQUESTED. A `/verify/batch` may mix depths per item and is billed per item.\n\nAn answer served from the last hour's cache is free: a `/verify`, `/assess` or `/review` claim that gets back a verdict checked in the last hour costs nothing, so a tool that resends a request is not charged twice. The exception is a `/verify` that issues you a new warranty certificate, charged at the depth you requested.\n\n`GET /me/usage` reports the balance under `credits` and the weights under `costs`, with parameter-dependent prices such as `depth` nested under `cost_options`. The per-capability blocks beside them (`verify`, `assess`, `ask`) are **projections** of that one balance into each capability's own unit \u2014 how many of those calls the remaining credits would buy \u2014 not separate allowances. Spending on any one of them moves all of them.\n\n## Authentication\n\n1. [Create a Lenz account](https://lenz.io/auth).\n2. Generate an API key on the [API credentials](https://lenz.io/api-credentials) page.\n3. Send it on every request:\n ```\n Authorization: Bearer lenz_...\n ```\n\nOr skip steps 1\u20132: **[lenz.io/setup](https://lenz.io/setup)** issues a test key and generates setup instructions for your environment \u2014 MCP client, SDK or plain HTTP \u2014 that you paste into your coding agent to do the wiring.\n\n### One balance, whichever door you come through\n\nLenz also runs a remote MCP server at `https://lenz.io/mcp`, so an agent can fact-check inside a conversation. It is **not** part of this surface \u2014 there are no MCP endpoints below, and its tools are documented at [lenz.io/integrations/mcp-server](https://lenz.io/integrations/mcp-server).\n\nIt matters here for one reason: that server is a client of this API. It takes the same `lenz_...` key and forwards it to the endpoints below, so MCP traffic spends the same credits, counts against the same rate limits, and shows up in `GET /me/usage` \u2014 there is no separate MCP quota to check, and a 402 can just as easily be an agent as your own code.\n\n## Idempotency\n\nSend an `Idempotency-Key` header on `POST /extract`, `POST /assess`, `POST /verify`, `POST /verify/batch`, `POST /verify/{task_id}/select`, `POST /review`, `POST /citecheck` and `POST /ask/{verification_id}` so retries after a network drop do not spawn duplicate tasks or double-debit quota. The server caches the response body and returns it on replay; a re-used key with a different body returns 422. The SDKs auto-generate a key for `verify_and_wait`; on other calls pass `idempotency_key` yourself.\n\nOn `/ask` a key also keeps the retry out of the conversation: without one, a repeated question is a second turn, a second credit and a second answer that the next turn reads as context. A retry that lands while the first call is still running gets 409 rather than the reply \u2014 nothing is finalized yet to replay \u2014 so treat it as work already in progress and retry the same key once the first call has had time to finish. `GET /ask/{verification_id}` returns the message history, but carries no request id, so it can only tell you which answer was yours when one question is in flight at a time. The SDKs send the header when you pass a key yourself: `ask.send(..., idempotency_key=...)` in Python, `ask.send(id, { message, idempotencyKey })` in Node; neither generates one for ask, because a repeated question is usually deliberate. The n8n node's Ask operation sends a key scoped to the workflow execution, so a retry replays rather than re-asks. The Zapier app sends none on ask.\n\n## Errors\n\nThe status code says what to do next; the body says what happened. Every response carries an `X-Request-ID` header \u2014 quote it on a support ticket.\n\n| Status | Condition | Retry? |\n|---|---|---|\n| 401 | Missing, malformed or unknown key | After fixing credentials |\n| 402 | Out of credits, or the plan is the limit | No \u2014 billing action |\n| 403 | Authenticated but not allowed (private verification, IP block) | No |\n| 409 | A request with the same `Idempotency-Key` is still running, or the work the call names is not in the state it needs (`no_selection_pending`, `verification_not_ready`, `verification_failed`) | Same-key conflict: yes, with the same key. `verification_not_ready`: poll, or choose the claims when it needs input. `no_selection_pending`: poll the status first |\n| 422 | Malformed input | No |\n| 429 | Rate limit: the per-account `/extract` daily cap, or too many reviews or citation checks running (`review_in_flight`, `citecheck_in_flight`) | Yes, after `Retry-After` |\n| 502 | A sync step (`/assess`, `/extract`, `/ask`) failed deterministically \u2014 a schema or parse failure (`/ask`: `ask_failed`) | No \u2014 same input, same outcome |\n| 503 | Our model providers were rate-limited or down for the step and every fallback was exhausted (`code: upstream_unavailable`), the pipeline has no capacity (`code: capacity`), or citation checking is switched off (`/citecheck`, `code: citations_unavailable`) | Yes, after `Retry-After` (body `retry_after` too) |\n\n**402** carries `detail`, `code` (always `no_credits`), `upgrade_url`, `doc_url`, `wall_id`, `cost` (what the rejected call would have taken), and \u2014 when resolvable \u2014 `remaining`, `credits_remaining` and `resets_at`. `remaining` and `requested` are in the *capability's* unit (verifications, assesses); `credits_remaining` and `cost` are in credits, so a client can tell \"not enough for a 10-credit verify\" from \"empty\". The resolvable fields are omitted rather than sent as `null`, so an absent key means \"unknown\", not \"zero\".\n\n`wall_id` identifies this specific rejection and is already appended to `upgrade_url`, so sending a user to that URL is all that is needed \u2014 it exists as its own field only for clients that build their own upgrade link. Treat `upgrade_url` as opaque and follow it whole rather than matching it against a fixed string.\n\nThe `code` values are deliberately plan-agnostic: they name the condition, never the plan that would fix it. Send users to `upgrade_url` rather than mapping codes to plan names \u2014 the set of plans changes, the conditions do not.\n\nAn empty balance is deliberately **not** a 429: a 429 tells every client to retry, and an empty balance never succeeds on retry. **429** carries `code`, `limit`, `reset_in_seconds`, `upgrade_url` and the `Retry-After` header. The in-flight 429 of `/review` and `/citecheck` (`review_in_flight`, `citecheck_in_flight`) carries `retry_after_seconds` and `Retry-After` instead. The daily cap is the same on every plan; only a key Lenz has marked unlimited for `/extract` bypasses it.\n\n**Migrating (August 2026).** These rejections were 403, which the SDKs map to their auth error; they are now 402, which maps to the quota error \u2014 not a subclass. If you catch the auth error to handle an empty balance, that branch stops firing. This reaches you on every SDK version, including ones released before the change. `detail` strings are unchanged. Full reference: [lenz.io/docs/errors](https://lenz.io/docs/errors#quota).\n\n## Webhooks\n\nOn `POST /verify` and `POST /review` you may supply a `webhook_url`. We POST an HMAC-SHA256-signed JSON payload to it when the pipeline terminates. Signature header is `X-Lenz-Signature: sha256=`. Verify with your webhook secret (generate + rotate on [API credentials](https://lenz.io/api-credentials)). Retries: 10s, 60s, 600s (3 retries after the initial delivery, 4 attempts total). The SDKs ship a `LenzWebhooks` helper that verifies signatures and parses the payload into typed events. Events: `verification.completed`, `verification.failed`, `certificate.timestamped`, and for a review `review.completed` / `review.failed` (switch on `event`; a review's own deep checks fire no `verification.*` events).\n\nFor commercial use, volume pricing, or onboarding support, [get in touch](https://lenz.io/contact).", + "description": "# Four API calls and one to run them\n\nA research-depth ladder: find claims, judge fast, prove deep, citecheck; `/review` runs them all.\n\n- `POST /extract` \u2014 enumerate every major verifiable claim in a document (up to 100, most check-worthy first, inputs to 50k chars), or in a public web page given its URL; an optional `focus` narrows the list to the claims you care about, and `locate: true` keeps only the claims traced back to the text, with where each is made. Costs no credits; fair-use cap 1,000/account/day.\n- `POST /assess` \u2014 fast 3-model frontier panel verdict, typically 10-25s. Sync, 1 credit per claim. Send one text in `claim`, or a list of up to 20 in `claims` \u2014 one row per claim, same order, all checked in parallel. A text that makes more claims than one call checks gets the most check-worthy checked and the rest back as `more_claims`, free, to send on.\n- `POST /verify` \u2014 full multi-model pipeline with citations in ~90s. Async, 10 credits \u2014 or 5 with `depth: \"low\"`, a shallower check that comes back sooner.\n- `POST /citecheck` \u2014 does each cited source say what the text attributes to it: send pairs of a statement and its URL or DOI, or a whole text. Async; 1 credit per checked citation, refunded when a citation could not be checked ([guide](https://lenz.io/docs/citations)).\n- `POST /review` \u2014 the extract \u2192 assess \u2192 verify ladder on a whole draft, returning the issues with suggested rewrites in 2-4 min. Async, 1 credit per claim assessed + 10 (5 at `depth: \"low\"`) per claim verified. With `escalate.max_citations` (0-20, default 0) it also checks whether each source the draft cites says what the draft attributes to it, at 1 credit per checked citation (one that could not be checked is refunded) ([guide](https://lenz.io/docs/citations)). `more_claims` and `more_citations` list what the review found and did not check, at no cost; send them to `/assess` and `/citecheck` to check the rest.\n- Also `POST /ask/{id}` \u2014 ask follow-up questions grounded on a verification.\n\n## Built for teams whose AI output is async or document-shaped\n\nLegal-memo generators, AI deep-research, due-diligence platforms, vertical agents producing structured deliverables, \"report-as-a-service\" companies. `/assess` opens the door to sync UX too \u2014 fast enough to gate a chat completion or a UI submit.\n\n## API versions\n\nLenz versions the shape of its responses by date: send `X-Lenz-API-Version: 2026-10-11` to choose this shape. This reference describes `2026-10-11`. A field `2026-05-13` still sends under an older name is listed `deprecated` beside the current one, and where its shape differs, the operation names the `...Legacy` component it receives (`x-lenz-versions` on the response). Every response carries `X-Lenz-API-Version`, naming the version it is in. Which version a request gets without the header, and what changed: [https://lenz.io/docs/api-versions](https://lenz.io/docs/api-versions).\n\n## Install the SDK\n\n```bash\npip install lenz-io # Python\nnpm install lenz-io # Node / TypeScript\n```\n\nSource on GitHub: [lenz-io-python](https://github.com/lenzhq/lenz-io-python) \u00b7 [lenz-io-node](https://github.com/lenzhq/lenz-io-node).\n\n## The canonical integration: one /review call\n\n```python\nfrom lenz_io import Lenz\nclient = Lenz(api_key=\"lenz_...\")\n\n# Extract, assess, and verify the doubtful claims in one call (async, 2-4 min)\ndraft = \"The EU AI Act took effect in March 2024. It sorts AI systems into four risk tiers.\"\nreview = client.review_and_wait(text=draft)\nfor i in review.issues:\n print(i.verdict, i.claim, i.suggested_rewrite)\n```\n\nThe default policy: a quick verdict on every claim (up to 20), and the deep check on the rows whose quick verdict is `False`, `Mostly False` or `Mixed`, or whose confidence is `low`, up to five. Change it in `escalate`. To deep-check the rows the cap left out (`escalation.disposition == \"cap\"`), send them to `/verify/batch`. A resend with the same `Idempotency-Key` within 24 hours returns the same review; a new key is a new review.\n\n## The primitives, call by call: extract \u2192 assess \u2192 conditional verify\n\n```python\nfrom lenz_io import Lenz\nclient = Lenz(api_key=\"lenz_...\")\n\n# 1. Enumerate the factual claims in your model output (free)\n# extracted.claims lists every claim found, most check-worthy first:\n# always a list (one entry for one claim, [] for none).\n# Pass focus=\"market size and competitors\" to narrow the list;\n# status is \"no_match\" when the document has claims but none match.\nextracted = client.extract(text=llm_output)\nclaims = [c.claim for c in extracted.claims]\n\n# 2. Fast verdict in ONE call (typically 10-25s, 3-model panel): a list of up\n# to 20, one row per claim, in the order you sent them. extract finds up to\n# 100, most check-worthy first, so this checks the first 20.\nquick = client.assess(claims=claims[:20]).claims if claims else []\n\n# 3. Escalate the low-confidence ones to /verify in one batch (~90s, multi-model pipeline)\n# Rows we ran out of time on are free and worth resending; the rest of the\n# failed rows (no_checkable_claim, framing_failed) want a different input.\nretry = [c.claim for c in quick if c.failure and c.failure.code == \"timeout\"]\nquick += client.assess(claims=retry).claims if retry else []\ndoubtful = [{\"claim\": c.claim} for c in quick if c.status == \"completed\" and c.confidence == \"low\"]\nfor r in client.verify_batch_and_wait(claims=doubtful) if doubtful else []:\n if r.verification:\n print(r.verification.verdict, r.verification.lenz_score, r.verification.key_finding)\n```\n\n## How /verify works under the hood\n\nFrame \u2192 Collect Evidence \u2192 Debate (2 models, 2 rounds) \u2192 Adjudicate (three reviewers on models from different vendors, two more when they disagree) \u2192 Conclude. ~90 seconds wall-clock per claim.\n\n### While /verify is running\n\n`GET /verify/status/{task_id}` answers one of five shapes, keyed on `status`: `processing`, `needs_input`, `completed`, `failed`, and, after you cancel the run (see Stopping a run), `cancelled`. Every one echoes `task_id`, and only the fields belonging to that shape are present \u2014 read the ones for the branch you are in, and treat an absent key as absent rather than empty.\n\nA `processing` body carries `progress`:\n\n```json\n{\"status\": \"processing\", \"task_id\": \"\u2026\",\n \"progress\": {\"step\": \"research\", \"index\": 2, \"total\": 5,\n \"elapsed_seconds\": 42, \"poll_after_seconds\": 5}}\n```\n\n`step` is a closed set \u2014 `starting`, `framing`, `research`, `debate`, `adjudication`, `conclusion` \u2014 so it is safe to switch on. `index` is the 1-based stage position (0 while `starting`) out of `total`; read `total` off the response rather than hard-coding it. `poll_after_seconds` is how long to wait before looking again, and `elapsed_seconds` is how long the run has been going \u2014 a measurement, not an estimate of what is left.\n\nNote that `index` is stage **position**, not elapsed work: the stages are uneven, so a progress bar driven by it will sit on `research` for roughly half the run.\n\n`progress` is **advisory**. It says the work is pending and which stage it is on; it is not a place to read results from. The verdict arrives once, under `result`, when `status` is `completed`.\n\n### When /verify asks for input\n\n`/verify` is not only submit-and-poll. `GET /verify/status/{task_id}` can terminate at `needs_input` \u2014 the submitted text held several claims, or was too vague to check as written \u2014 and the task waits rather than guessing which claim you meant. Several claims in one sentence count the same as several sentences.\n\nResolve it with `POST /verify/{task_id}/select`, passing the exact wording of the claim(s) you want from the ones the status offered. Selection is **by text, not index**; anything that was not offered is rejected with a 422. Each selected claim fans out into its own pipeline, so the response carries one `task_id` per claim \u2014 poll each the way you would a fresh `/verify`.\n\nClaims returned by `/extract` skip this entirely: submitted verbatim, they are never bounced back for rephrasing.\n\n### Covered verification\n\nEvery verification a paid Pro or Scale account runs is put in front of a gate. The ones that qualify carry a contractual warranty, and every result tells you whether it qualified \u2014 a `coverage` block on the completed `/verify/status/{task_id}` body, on `GET /verifications/{id}` and on the `verification.completed` webhook.\n\nIt is on by default, with no request field to set; an account on Pro or Scale can turn certificates off on the API credentials page, for checks submitted from then on (reason `account`; a verification that already carries a certificate keeps it).\n\n`status` is `covered`, `pending_timestamp` or `uncovered`; `reasons` says why not, in a closed seven-value vocabulary. **If you publish automatically, key on the `certificate.timestamped` webhook, not `verification.completed`** \u2014 cover requires the qualified timestamp to precede what you publish, and `completed` fires while the qualified timestamp is still pending.\n\nFull wire format, the reason vocabulary and the offline verifier: [lenz.io/docs/coverage](https://lenz.io/docs/coverage). What is covered and what is not: [lenz.io/warranty](https://lenz.io/warranty).\n\n### When /verify fails\n\nA `failed` status (and the `verification.failed` webhook) carries two axes. `failure_reason` says **where** the pipeline stopped (`research_empty`, `conclusion_failed`, `task_stuck`, \u2026); on the webhook the same value rides in `error`. `failure_class` says **why**, and `retryable` is derived from it:\n\n| `failure_class` | Meaning | `retryable` |\n|---|---|---|\n| `upstream_unavailable` | Model or search providers were rate-limited or down, or the task was never run \u2014 resubmit after a short wait | `true` |\n| `insufficient_evidence` | We looked; the sources are not there | `false` |\n| `invalid_input` | Not a checkable claim, an unreadable URL | `false` |\n| `cancelled` | You stopped it with `POST /verify/{task_id}/cancel`; not charged | `false` |\n| `internal` | Anything else \u2014 retrying the same input will not help | `false` |\n\nBranch on `retryable`, not on `failure_reason`: the same reason can mean either. `failure_class` is a **closed set** \u2014 these five values are stable identifiers. `failure_reason` is an open, informational set (`task_error`, `task_stuck`, `not_a_claim`, \u2026 will appear). A failed body also carries `docs_url`, pointing at the explanation of its class. A failed `/verify` is not charged.\n\nIn the next API version a cancelled run is not a failure: `GET /verify/status/{task_id}` answers `{\"status\": \"cancelled\", \"task_id\": \"\u2026\"}`, and a `webhook_url` receives `verification.cancelled`. A request made in 2026-05-13 keeps `status: \"failed\"` with `failure_class: \"cancelled\"` and receives `verification.failed`.\n\n### Stopping a run\n\n`POST /verify/{task_id}/cancel` stops a verification that has not finished. A cancelled run is not charged and saves no verification; model work already under way is not billed to you, and the run stops at its next step. The call is safe to repeat and answers 200 in every state: `cancelled: true` with `status: \"cancelled\"`, or `cancelled: false` with `status: \"completed\"` (it finished first and was charged as usual) or `\"failed\"`. It needs no `Idempotency-Key`. An unknown task, another account's task and a task not submitted through the API answer 404 `not_found`; a task that is a `/review`'s deep check answers 409 `use_review_cancel`. Each item of a `/verify/batch` has its own `task_id`, so cancel per task; there is no call for a whole batch. `POST /reviews/{review_id}/cancel` and `POST /citechecks/{citecheck_id}/cancel` stop everything in a review or a citation check. They refund the quick checks not yet served and the citations not yet checked, and keep what was delivered charged.\n\nOne of those reasons comes from framing rather than from a pipeline step and carries `failure_class: invalid_input`: `not_a_claim` (the input is not a checkable statement, or is too vague to have a checkable reading). A vague input that has a likely reading is verified on it, and the result's `claim` is that reading. On the webhook the same value rides in `error`, as an ordinary `verification.failed` delivery; before 2026-09-03 it fired nothing at all, so a task submitted with a `webhook_url` could terminate in silence.\n\n### What may change without notice\n\nNew keys may be added to status and webhook bodies at any time \u2014 ignore keys you do not recognise. Fields documented as **advisory** \u2014 `progress` and everything inside it \u2014 may also change or disappear without a version bump. The verdict fields under `result` may not: those change only with a versioned, announced release.\n\n## Common patterns\n\n- **Runtime sync UX**: `POST /assess` on the user-facing path; escalate `confidence == \"low\"` claims to `/verify` in the background.\n- **Runtime webhook**: `POST /verify` with `webhook_url`. Lenz POSTs the typed payload when done. Best for document pipelines.\n- **Whole drafts**: `POST /review` with `webhook_url`; handle `review.completed` (a `ReviewWebhookPayload`, deduped on `event_id`) and read `review.issues[]` and `review.citation_issues[]`.\n- **CI / pre-release**: `/extract` + batch `/verify` your golden set on every deploy. The free `/extract` tier covers most of this. Claims returned by `/extract` verify cleanly \u2014 submitted verbatim to `/verify`, they are never bounced back for rephrasing.\n- **Incident triage**: paste the offending output into `/extract`, then `/verify` the wrong-looking claims. Get a citation trail to send back to the customer.\n\n## What's in the response\n\nUnified vocabulary across every claim-shaped response:\n\n- **`claim`** \u2014 the framed claim text.\n- **`verdict`** \u2014 `\"True\" | \"Mostly True\" | \"Mixed\" | \"Mostly False\" | \"False\" | \"Error\"`.\n- **`confidence`** \u2014 `\"high\" | \"medium\" | \"low\"`.\n- **`lenz_score`** \u2014 1\u201310 integer score (deep payloads).\n- **`key_finding`** \u2014 one declarative sentence stating the most important fact the analysis established. For a false claim it states the CORRECTED fact rather than restating the claim, so render it next to the verdict label, never as a standalone headline. `\"\"` on older verifications predating the field.\n- **`suggested_rewrite`** \u2014 a suggested rewrite of `claim` that the verification's findings support. It has not been verified itself: before using it, review it or run it through `/verify`. `null` for a true claim, when no correction is established, and on older verifications. On every verification, single or listed (`GET /verifications/{id}`, `/verify/status`, the `verification.completed` webhook, `GET /verifications`, `GET /library`). On `/assess` rows too when the request sets `suggest_rewrite`: written from the quick check's reasoning for a claim it found False or Mostly False with high confidence, at no extra credit; `null` otherwise, and on every row when not requested.\n- **`sources`** \u2014 evidence with title, URL and snippet: the sentence(s) quoted from the page, verbatim and in the page's own language (`/verify` only).\n- **`audit`** \u2014 adjudication reasoning, debate transcript, panel agreement (`/verify` only).\n\n## How the API is organized\n\n- **Extract** \u2014 pull verifiable claims out of text.\n- **Assess** \u2014 fast 3-model verdict.\n- **Verify** \u2014 deep multi-model pipeline: submit one claim or a batch, poll status, and resolve a `needs_input` interrupt by selecting claims.\n- **Verifications** \u2014 list, fetch, delete, and toggle visibility on stored verifications.\n- **Library** \u2014 browse the public catalog. No API key needed.\n- **Ask** \u2014 follow-up questions, 1 credit each.\n- **Account** \u2014 your credit balance and the price list.\n\n## Credits\n\nOne pool per account funds every billable call, at a fixed weight:\n\n| Endpoint | Credits |\n|---|---|\n| `POST /verify` (and `/verify/batch`, `/select`) | 10 per claim |\n| `POST /verify` with `depth: \"low\"` | 5 per claim |\n| `POST /assess` | 1 per claim \u2014 per row that carries a verdict; error rows and rows answered from the cache are free |\n| `POST /ask` | 1 |\n| `POST /extract` | 0 \u2014 free, fair-use capped |\n\n`depth: \"low\"` caps research breadth \u2014 fewer discovery queries, a hard extraction ceiling, no recovery tiers on the happy path \u2014 while every reasoning step runs unchanged. It is not a model downgrade. You are charged for the depth you REQUESTED. A `/verify/batch` may mix depths per item and is billed per item.\n\nAn answer served from the last hour's cache is free: a `/verify`, `/assess` or `/review` claim that gets back a verdict checked in the last hour costs nothing, so a tool that resends a request is not charged twice. The exception is a `/verify` that issues you a new warranty certificate, charged at the depth you requested.\n\n`GET /me/usage` reports the balance under `credits` and the weights under `costs`, with parameter-dependent prices such as `depth` nested under `cost_options`. In version `2026-05-13` the per-capability blocks beside them (`verify`, `assess`, `ask`) are **projections** of that one balance into each capability's own unit \u2014 how many of those calls the remaining credits would buy \u2014 not separate allowances. Spending on any one of them moves all of them.\n\n## Authentication\n\n1. [Create a Lenz account](https://lenz.io/auth).\n2. Generate an API key on the [API credentials](https://lenz.io/api-credentials) page.\n3. Send it on every request:\n ```\n Authorization: Bearer lenz_...\n ```\n\nOr skip steps 1\u20132: **[lenz.io/setup](https://lenz.io/setup)** issues a test key and generates setup instructions for your environment \u2014 MCP client, SDK or plain HTTP \u2014 that you paste into your coding agent to do the wiring.\n\n### One balance, whichever door you come through\n\nLenz also runs a remote MCP server at `https://lenz.io/mcp`, so an agent can fact-check inside a conversation. It is **not** part of this surface \u2014 there are no MCP endpoints below, and its tools are documented at [lenz.io/integrations/mcp-server](https://lenz.io/integrations/mcp-server).\n\nIt matters here for one reason: that server is a client of this API. It takes the same `lenz_...` key and forwards it to the endpoints below, so MCP traffic spends the same credits, counts against the same rate limits, and shows up in `GET /me/usage` \u2014 there is no separate MCP quota to check, and a 402 can just as easily be an agent as your own code.\n\n## Idempotency\n\nSend an `Idempotency-Key` header on `POST /extract`, `POST /assess`, `POST /verify`, `POST /verify/batch`, `POST /verify/{task_id}/select`, `POST /review`, `POST /citecheck` and `POST /ask/{verification_id}` so retries after a network drop do not spawn duplicate tasks or double-debit quota. The server caches the response body and returns it on replay; a re-used key with a different body returns 422. The SDKs generate a key for `extract`, `assess`, `select`, `verify`, `verify_and_wait`, `verify_batch`, `verify_batch_and_wait`, `ask.send`, `review` and `citecheck` and reuse it across their own retries (lenz-io 3.0; 2.x sent a key on `verify_batch` and `ask.send` only when you passed `idempotency_key` yourself). An error from a keyed call carries the key (`idempotency_key` in Python, `idempotencyKey` in Node): resend with that same key, never as a plain new call, which sends a new key and can run the work twice.\n\nOn `/ask` a key also keeps the retry out of the conversation: without one, a repeated question is a second turn, a second credit and a second answer that the next turn reads as context. A retry that lands while the first call is still running gets 409 rather than the reply \u2014 nothing is finalized yet to replay \u2014 so treat it as work already in progress and retry the same key once the first call has had time to finish. `GET /ask/{verification_id}` returns the message history, but carries no request id, so it can only tell you which answer was yours when one question is in flight at a time. lenz-io 3.0 sends a fresh key on every `ask.send` call and reuses it across the retries inside that call, so a retried question is not asked twice. Asking the same question again is a new call with a new key, and is asked again, because a repeated question is usually deliberate; pin the key to make a retry from another process mean \"the same question, once\": `ask.send(..., idempotency_key=...)` in Python, `ask.send(id, { message, idempotencyKey })` in Node. The n8n node's Ask operation sends a key scoped to the workflow execution, so a retry replays rather than re-asks. The Zapier app sends none on ask.\n\n## Errors\n\nThe status code says what to do next; the body says what happened. Every response carries an `X-Request-ID` header \u2014 quote it on a support ticket. lenz-io 3.0 reads the last column for you: `retryable` is set on every SDK error (true for a 429, a 5xx, a dropped connection, a request timeout and a 409 that means \"not yet\"; false for any other 4xx), so branch on it instead of the status.\n\n| Status | Condition | Retry? |\n|---|---|---|\n| 401 | Missing, malformed or unknown key | After fixing credentials |\n| 402 | Out of credits, or the plan is the limit | No \u2014 billing action |\n| 403 | Authenticated but not allowed (private verification, IP block) | No |\n| 409 | A request with the same `Idempotency-Key` is still running, or the work the call names is not in the state it needs (`no_selection_pending`, `verification_not_ready`, `verification_failed`, `use_review_cancel`) | Same-key conflict: yes, with the same key. `verification_not_ready`: poll, or choose the claims when it needs input. `no_selection_pending`: poll the status first. `use_review_cancel`: cancel the review instead |\n| 422 | Malformed input | No |\n| 429 | Rate limit: the per-account `/extract` daily cap, or too many reviews or citation checks running (`review_in_flight`, `citecheck_in_flight`) | Yes, after `Retry-After` |\n| 502 | A sync step (`/assess`, `/extract`, `/ask`) failed deterministically \u2014 a schema or parse failure (`/ask`: `ask_failed`) | No \u2014 same input, same outcome |\n| 503 | Our model providers were rate-limited or down for the step and every fallback was exhausted (`code: upstream_unavailable`) or the pipeline has no capacity (`code: capacity`) | Yes, after `Retry-After` (body `retry_after` too) |\n\n**402** carries `detail`, `code` (always `no_credits`), `upgrade_url`, `doc_url`, `wall_id`, `cost` (what the rejected call would have taken), and \u2014 when resolvable \u2014 `remaining`, `credits_remaining` and `resets_at`. `remaining` and `requested` are in the *capability's* unit (verifications, assesses); `credits_remaining` and `cost` are in credits, so a client can tell \"not enough for a 10-credit verify\" from \"empty\". The resolvable fields are omitted rather than sent as `null`, so an absent key means \"unknown\", not \"zero\".\n\n`wall_id` identifies this specific rejection and is already appended to `upgrade_url`, so sending a user to that URL is all that is needed \u2014 it exists as its own field only for clients that build their own upgrade link. Treat `upgrade_url` as opaque and follow it whole rather than matching it against a fixed string.\n\nThe `code` values are deliberately plan-agnostic: they name the condition, never the plan that would fix it. Send users to `upgrade_url` rather than mapping codes to plan names \u2014 the set of plans changes, the conditions do not.\n\nAn empty balance is deliberately **not** a 429: a 429 tells every client to retry, and an empty balance never succeeds on retry. **429** carries `code`, `limit`, `reset_in_seconds`, `upgrade_url` and the `Retry-After` header. The in-flight 429 of `/review` and `/citecheck` (`review_in_flight`, `citecheck_in_flight`) carries `retry_after_seconds` and `Retry-After` instead. The daily cap is the same on every plan; only a key Lenz has marked unlimited for `/extract` bypasses it.\n\n**Migrating (August 2026).** These rejections were 403, which the SDKs map to their auth error; they are now 402, which maps to the quota error \u2014 not a subclass. If you catch the auth error to handle an empty balance, that branch stops firing. This reaches you on every SDK version, including ones released before the change. `detail` strings are unchanged. Full reference: [lenz.io/docs/errors](https://lenz.io/docs/errors#quota).\n\n## Webhooks\n\nOn `POST /verify` and `POST /review` you may supply a `webhook_url`. We POST an HMAC-SHA256-signed JSON payload to it when the pipeline terminates. Signature header is `X-Lenz-Signature: sha256=`. Verify with your webhook secret (generate + rotate on [API credentials](https://lenz.io/api-credentials)). Retries: 10s, 60s, 600s (3 retries after the initial delivery, 4 attempts total). The SDKs ship a `LenzWebhooks` helper that verifies signatures and parses the payload into typed events. Events: `verification.completed`, `verification.failed`, `verification.cancelled`, `certificate.timestamped`, and for a review `review.completed` / `review.failed` / `review.cancelled` and for a citation check `citecheck.completed` / `citecheck.failed` / `citecheck.cancelled` (switch on `event`; a review's own deep checks fire no `verification.*` events). The `*.cancelled` events fire once, when you cancel, in the next API version; a submission made in 2026-05-13 receives the matching `*.failed` event with `failure_class: \"cancelled\"` instead.\n\nFor commercial use, volume pricing, or onboarding support, [get in touch](https://lenz.io/contact).", "termsOfService": "https://lenz.io/terms" }, "paths": { @@ -37,12 +37,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -56,7 +56,7 @@ } } }, - "description": "Service discovery root \u2014 links to docs, OpenAPI spec, and endpoints.\n\nReturned at ``GET /api/v1/`` so curious humans and probing clients\nget a useful JSON payload instead of a 404.\n\n**Versions.** default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Service discovery root \u2014 links to docs, OpenAPI spec, and endpoints.\n\nReturned at ``GET /api/v1/`` so curious humans and probing clients\nget a useful JSON payload instead of a 404.\n\n**Versions.** default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Discovery" ] @@ -147,12 +147,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationListOutLegacy" + "$ref": "#/components/schemas/VerificationListOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/VerificationListOut" }, "2026-05-13": { @@ -170,12 +170,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -193,12 +193,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -212,7 +212,7 @@ } } }, - "description": "Search and browse the public catalog of verified claims.\n\nReturns paginated results from the Lenz Library. Only publicly\npublished verifications are included.\n\n**Sort options:** `recent`, `most_true`, `most_untrue`,\n`relevance` (only when `search` is provided), and `random`.\n\n**`curated`** restricts results to one or more named curated collections,\ncomma-separated. Currently supported: `trivia` (the LLM-curated,\ntrivia-worthy pool behind the open-source quiz demo).\n\n**`verdict`** filters to one or more verdict labels, comma-separated\n(e.g. `True,False`). Labels: `True`, `Mostly True`, `Mixed`,\n`Mostly False`, `False`.\n\n**Versions.** 200: `VerificationListOutLegacy` (the next version: `VerificationListOut`); 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Search and browse the public catalog of verified claims.\n\nReturns paginated results from the Lenz Library. Only publicly\npublished verifications are included.\n\n**Sort options:** `recent`, `most_true`, `most_untrue`,\n`relevance` (only when `search` is provided), and `random`.\n\n**`curated`** restricts results to one or more named curated collections,\ncomma-separated. Currently supported: `trivia` (the LLM-curated,\ntrivia-worthy pool behind the open-source quiz demo).\n\n**`verdict`** filters to one or more verdict labels, comma-separated\n(e.g. `True,False`). Labels: `True`, `Mostly True`, `Mixed`,\n`Mostly False`, `False`.\n\n**Versions.** 200: `2026-10-11` receives `VerificationListOut`, `2026-05-13` receives `VerificationListOutLegacy`; 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Library" ], @@ -223,7 +223,7 @@ }, { "lang": "TypeScript", - "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz(); // no api_key needed\nconst page = await client.library.listLibrary({ page: 1, sort: \"recent\" });\npage.items.forEach(v => console.log(v.claim, \"\u2192\", v.verdict));\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz(); // no api_key needed\nconst page = await client.library.list({ page: 1, sort: \"recent\" });\npage.items.forEach(v => console.log(v.claim, \"\u2192\", v.verdict));\n" } ] } @@ -243,12 +243,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerifyAcceptedOutLegacy" + "$ref": "#/components/schemas/VerifyAcceptedOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/VerifyAcceptedOut" }, "2026-05-13": { @@ -286,12 +286,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -309,12 +309,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -328,7 +328,7 @@ } } }, - "description": "Submit a claim for verification.\n\nThe claim goes in ``claim``. (``text`` is accepted as an alias \u2014 a\ndocument is ``text``, a claim is ``claim``; ``POST /extract`` is the\nendpoint that takes a document.)\n\nReturns a ``task_id`` immediately. Poll ``GET /verify/status/{task_id}``\nor supply ``webhook_url`` in the body for an asynchronous callback when\nthe pipeline terminates.\n\n**Duplicate submissions are collapsed.** With no ``Idempotency-Key``\nheader, the request body itself is the key: resubmitting an identical\nbody within 10 minutes returns the original ``task_id`` instead of\nstarting a second run. To run the same text twice on purpose, send your\nown ``Idempotency-Key`` \u2014 a supplied key always wins.\n\n**``depth``** selects how much work the check does. ``standard``\n(default) is the full pipeline. ``low`` searches fewer sources, skips\nthe recovery fetch tiers and stops the debate after the opening\narguments (no rebuttal round), so it comes back sooner \u2014 with less\nevidence behind the verdict. Framing, the adjudication panel and the\nconclusion run unchanged, and every step runs the same models either\nway; ``low`` is a volume lever, not a model downgrade.\n\n``low`` costs **half the credits**, and you are charged for the depth you\nREQUESTED. A claim answered from a check made in the last hour is free\n(unless it issues you a new warranty certificate, which is charged at the\ndepth you requested). The completed result echoes the depth the verdict\nwas actually produced with, which is why that field can read ``standard``\non a ``low`` request: the echo describes the evidence.\n\n**Versions.** 202: `VerifyAcceptedOutLegacy` (the next version: `VerifyAcceptedOut`); 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Submit a claim for verification.\n\nThe claim goes in ``claim``. (``text`` is accepted as an alias \u2014 a\ndocument is ``text``, a claim is ``claim``; ``POST /extract`` is the\nendpoint that takes a document.)\n\nReturns a ``task_id`` immediately. Poll ``GET /verify/status/{task_id}``\nor supply ``webhook_url`` in the body for an asynchronous callback when\nthe pipeline terminates.\n\n**Duplicate submissions are collapsed.** With no ``Idempotency-Key``\nheader, the request body itself is the key: resubmitting an identical\nbody within 10 minutes returns the original ``task_id`` instead of\nstarting a second run. To run the same text twice on purpose, send your\nown ``Idempotency-Key`` \u2014 a supplied key always wins.\n\n**``depth``** selects how much work the check does. ``standard``\n(default) is the full pipeline. ``low`` searches fewer sources, skips\nthe recovery fetch tiers and stops the debate after the opening\narguments (no rebuttal round), so it comes back sooner \u2014 with less\nevidence behind the verdict. Framing, the adjudication panel and the\nconclusion run unchanged, and every step runs the same models either\nway; ``low`` is a volume lever, not a model downgrade.\n\n``low`` costs **half the credits**, and you are charged for the depth you\nREQUESTED. A claim answered from a check made in the last hour is free\n(unless it issues you a new warranty certificate, which is charged at the\ndepth you requested). The completed result echoes the depth the verdict\nwas actually produced with, which is why that field can read ``standard``\non a ``low`` request: the echo describes the evidence.\n\n**Versions.** 202: `2026-10-11` receives `VerifyAcceptedOut`, `2026-05-13` receives `VerifyAcceptedOutLegacy`; 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verify" ], @@ -350,11 +350,11 @@ "x-codeSamples": [ { "lang": "Python", - "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nv = client.verify_and_wait(claim=\"Sharks don't get cancer\")\n# Add language=\"es\" for Spanish output (12 languages supported).\n# Add depth=\"low\" for a shallower check that returns sooner.\nprint(v.verdict, v.lenz_score)\n# false 2.0\n" + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nv = client.verify_and_wait(claim=\"Sharks don't get cancer\")\n# Add language=\"es\" for Spanish output (12 languages supported).\n# Add depth=\"low\" for a shallower check that returns sooner.\nprint(v.verdict, v.lenz_score)\n# False 2\n" }, { "lang": "TypeScript", - "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst v = await client.verifyAndWait({ claim: \"Sharks don't get cancer\" });\n// Add depth: \"low\" for a shallower check that returns sooner.\nconsole.log(v.verdict, v.lenz_score);\n// false 2.0\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst v = await client.verifyAndWait({ claim: \"Sharks don't get cancer\" });\n// Add depth: \"low\" for a shallower check that returns sooner.\nconsole.log(v.verdict, v.lenz_score);\n// False 2\n" } ] } @@ -374,12 +374,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchAcceptedOutLegacy" + "$ref": "#/components/schemas/BatchAcceptedOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/BatchAcceptedOut" }, "2026-05-13": { @@ -397,12 +397,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -420,12 +420,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -439,7 +439,7 @@ } } }, - "description": "Fan-out wrapper around /verify for multi-claim LLM responses.\n\nBody: ``{claims: [{text, source_url?, webhook_url?, visibility?, depth?},\n...]}`` capped at 20 items. Server pre-checks total credits, then fans out N\nindependent pipelines \u2014 each with its own ``task_id`` and lifecycle.\nEach claim's webhook (per-item override, or batch ``webhook_url``, or\nper-key default) fires when its pipeline terminates. ``batch_id`` is\npurely for client-side correlation; the server doesn't gate anything\non it.\n\nOn a mid-fan-out enqueue error, the response includes the partial\n``items`` list and a ``partial: true`` flag. Per-claim credit spend\nhappens at each claim's normal persist time \u2014 claims that never\nenqueue are never charged.\n\n``depth`` works like ``visibility``: a batch-wide default that any item\ncan override. See ``POST /verify`` for what the values mean.\n\nEach item carries its claim in ``claim`` (``text`` is accepted as an\nalias), plus the same optional fields as ``POST /verify``.\n\n**Versions.** 202: `BatchAcceptedOutLegacy` (the next version: `BatchAcceptedOut`); 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Fan-out wrapper around /verify for multi-claim LLM responses.\n\nBody: ``{claims: [{text, source_url?, webhook_url?, visibility?, depth?},\n...]}`` capped at 20 items. Server pre-checks total credits, then fans out N\nindependent pipelines \u2014 each with its own ``task_id`` and lifecycle.\nEach claim's webhook (per-item override, or batch ``webhook_url``, or\nper-key default) fires when its pipeline terminates. ``batch_id`` is\npurely for client-side correlation; the server doesn't gate anything\non it.\n\nOn a mid-fan-out enqueue error, the response includes the partial\n``items`` list and a ``partial: true`` flag. Per-claim credit spend\nhappens at each claim's normal persist time \u2014 claims that never\nenqueue are never charged.\n\n``depth`` works like ``visibility``: a batch-wide default that any item\ncan override. See ``POST /verify`` for what the values mean.\n\nEach item carries its claim in ``claim`` (``text`` is accepted as an\nalias), plus the same optional fields as ``POST /verify``.\n\n**Versions.** 202: `2026-10-11` receives `BatchAcceptedOut`, `2026-05-13` receives `BatchAcceptedOutLegacy`; 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verify" ], @@ -461,11 +461,11 @@ "x-codeSamples": [ { "lang": "Python", - "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nbatch = client.verify_batch(claims=[\n {\"text\": \"The Eiffel Tower is in Berlin.\"},\n {\"text\": \"Water boils at 100\u00b0C at sea level.\"},\n])\nfor item in batch.items:\n print(item.task_id, item.claim_text)\n" + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nbatch = client.verify_batch(claims=[\n {\"claim\": \"The Eiffel Tower is in Berlin.\"},\n {\"claim\": \"Water boils at 100\u00b0C at sea level.\"},\n])\nfor item in batch.items:\n print(item.task_id, item.claim)\n" }, { "lang": "TypeScript", - "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst batch = await client.verifyBatch({\n claims: [\n { text: \"The Eiffel Tower is in Berlin.\" },\n { text: \"Water boils at 100\u00b0C at sea level.\" },\n ],\n});\nbatch.items.forEach(i => console.log(i.taskId, i.claimText));\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst batch = await client.verifyBatch({\n claims: [\n { claim: \"The Eiffel Tower is in Berlin.\" },\n { claim: \"Water boils at 100\u00b0C at sea level.\" },\n ],\n});\nbatch.items.forEach(i => console.log(i.task_id, i.claim));\n" } ] } @@ -485,12 +485,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssessOutLegacy" + "$ref": "#/components/schemas/AssessOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/AssessOut" }, "2026-05-13": { @@ -508,12 +508,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -531,12 +531,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -550,7 +550,7 @@ } } }, - "description": "Fast verdict via a 3-model panel (sync, ~10-25s).\n\nEscalation contract: a ``claims[].claim`` text POSTed back verbatim to\n``/verify`` within 24 hours is verified as-is \u2014 in normal operation it\ndoes not return ``needs_input`` and its wording is not rewritten.\n\nTwo input forms, one response shape::\n\n claim / text \u2500\u25ba ONE text. Lenz reads the claims in it, and each becomes a\n row, up to 20 a call; the rest go back, unchecked and\n free, in ``more_claims``.\n claims[] \u2500\u25ba a LIST of up to 20 items, each one claim. One row per item, same order.\n A compound item is assessed on its primary reading and\n the rest is listed in that row's ``more_claims``.\n\nThe list form is the batch rung of the extract \u2192 assess \u2192 verify ladder:\none ``/assess`` call answers up to 20 of ``/extract``'s claims (it finds up\nto 100) in about the time a single call takes, because every item is\nassessed in parallel.\n\nA verdict can be served from memory: a completed ``/verify`` of the exact\nsame claim text, or a prior ``/assess`` of it, within the last hour,\nwhichever account ran it \u2014 the verdict is a function of the input.\n``verification_url`` is set only when the caller could read that\nverification by id.\n\nBilling: 1 credit per row that carries a verdict; error rows are free; a\nduplicate item is a second row and a second credit (the panel runs once).\n\n**Versions.** 200: `AssessOutLegacy` (the next version: `AssessOut`); 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Fast verdict via a 3-model panel (sync, ~10-25s).\n\nEscalation contract: a ``claims[].claim`` text POSTed back verbatim to\n``/verify`` within 24 hours is verified as-is \u2014 in normal operation it\ndoes not return ``needs_input`` and its wording is not rewritten.\n\nTwo input forms, one response shape::\n\n claim / text \u2500\u25ba ONE text. Lenz reads the claims in it, and each becomes a\n row, up to 20 a call; the rest go back, unchecked and\n free, in ``more_claims``.\n claims[] \u2500\u25ba a LIST of up to 20 items, each one claim. One row per item, same order.\n A compound item is assessed on its primary reading and\n the rest is listed in that row's ``more_claims``.\n\nThe list form is the batch rung of the extract \u2192 assess \u2192 verify ladder:\none ``/assess`` call answers up to 20 of ``/extract``'s claims (it finds up\nto 100) in about the time a single call takes, because every item is\nassessed in parallel.\n\nA verdict can be served from memory: a completed ``/verify`` of the exact\nsame claim text, or a prior ``/assess`` of it, within the last hour,\nwhichever account ran it \u2014 the verdict is a function of the input.\n``verification_url`` is set only when the caller could read that\nverification by id.\n\nBilling: 1 credit per row that carries a verdict; error rows are free; a\nduplicate item is a second row and a second credit (the panel runs once).\n\n**Versions.** 200: `2026-10-11` receives `AssessOut`, `2026-05-13` receives `AssessOutLegacy`; 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Assess" ], @@ -572,11 +572,11 @@ "x-codeSamples": [ { "lang": "Python", - "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nout = client.assess(text=\"Coffee causes cancer and the earth is flat.\")\n# Add language=\"es\" for Spanish verdict text; verdict labels stay English.\nfor c in out.claims:\n print(c.claim, \"\u2192\", c.verdict, c.confidence)\n if c.rationale:\n print(\" \", c.rationale)\n\n# Several claims at once: one row per item, same order (up to 20).\nrows = client.assess(claims=[\"Coffee causes cancer.\", \"The earth is flat.\"]).claims\nfor c in rows:\n print(c.verdict if c.error_code is None else c.hint)\n if c.dissent:\n print(\" One reviewer disagreed:\", c.dissent)\n" + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nout = client.assess(claim=\"Coffee causes cancer and the earth is flat.\")\n# Add language=\"es\" for Spanish verdict text; verdict labels stay English.\nfor c in out.claims:\n print(c.claim, \"\u2192\", c.verdict, c.confidence)\n if c.rationale:\n print(\" \", c.rationale)\n\n# Several claims at once: one row per item, same order (up to 20).\nrows = client.assess(claims=[\"Coffee causes cancer.\", \"The earth is flat.\"]).claims\nfor c in rows:\n if c.failure: # status == \"failed\": no verdict, nothing charged\n print(\"No verdict:\", c.failure.code, c.failure.hint)\n else:\n print(c.verdict, c.confidence)\n if c.dissent:\n print(\" One reviewer disagreed:\", c.dissent)\n" }, { "lang": "TypeScript", - "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst out = await client.assess({\n text: \"Coffee causes cancer and the earth is flat.\",\n});\nout.claims.forEach(c => {\n console.log(c.claim, \"\u2192\", c.verdict, c.confidence);\n if (c.rationale) console.log(\" \", c.rationale);\n});\n\n// Several claims at once: one row per item, same order (up to 20).\nconst rows = (await client.assess({\n claims: [\"Coffee causes cancer.\", \"The earth is flat.\"],\n})).claims;\nrows.forEach(c => {\n console.log(c.error_code ? c.hint : c.verdict);\n if (c.dissent) console.log(\" One reviewer disagreed:\", c.dissent);\n});\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst out = await client.assess({\n claim: \"Coffee causes cancer and the earth is flat.\",\n});\nout.claims.forEach(c => {\n console.log(c.claim, \"\u2192\", c.verdict, c.confidence);\n if (c.rationale) console.log(\" \", c.rationale);\n});\n\n// Several claims at once: one row per item, same order (up to 20).\nconst rows = (await client.assess({\n claims: [\"Coffee causes cancer.\", \"The earth is flat.\"],\n})).claims;\nrows.forEach(c => {\n // status \"failed\": no verdict, nothing charged\n if (c.status === \"failed\") console.log(\"No verdict:\", c.failure?.code, c.failure?.hint);\n else console.log(c.verdict, c.confidence);\n if (c.dissent) console.log(\" One reviewer disagreed:\", c.dissent);\n});\n" } ] } @@ -596,12 +596,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtractOutLegacy" + "$ref": "#/components/schemas/ExtractOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ExtractOut" }, "2026-05-13": { @@ -619,12 +619,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -642,12 +642,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -661,7 +661,7 @@ } } }, - "description": "Extract verifiable claims from arbitrary text.\n\nRuns the extraction step on its own \u2014 no research, no debate, no credit\ncharge. Enumerates every distinct major factual\nclaim in the input (up to 100), ordered most-check-worthy-first, in\n`claims` (one item for a single claim, `[]` for none). Claims returned\nhere verify cleanly when submitted verbatim to `POST /verify`.\n\nPass `focus` to narrow the result to the claims you care about \u2014 a short\ndescription such as `\"market size, growth and competitors\"`, at most 300\ncharacters. A focus selects from the claims the extractor found and\nnothing else: it cannot add a claim, reword one, reorder them, change the\nlanguage, or change what counts as a claim. Ordering stays\nmost-check-worthy-first.\n\n`status` is one of:\n\n- `ready` \u2014 claims were found (and, with a `focus`, at least one matched).\n- `no_checkable_claim` \u2014 no claim in the input could be checked against\n public evidence.\n- `no_match` \u2014 claims were found but none fall within your `focus`. The\n unfocused list is never substituted; widen the focus and call again.\n\n`text` can also be a single public web page URL (http or https, nothing\nelse in the field). Lenz reads the page, or a YouTube video's transcript,\nand extracts the claims from its first 50,000 characters. Pages behind a\nlogin (Facebook, Instagram, Threads, LinkedIn) can't be read. A URL call\ntypically takes 5-40 seconds, so give it a 90-second client timeout (SDK\n2.13 and later do). A page that cannot be read answers 502\n`extraction_failed`; when the services that read pages are down, 503\n`upstream_unavailable` with `Retry-After`. Neither uses a unit of the\ndaily cap.\n\nPass `locate: true` to keep only the claims that can be traced directly\nback to your text, and get each claim's `positions`: the passages that\nmake it, with `start` / `end` offsets into `text` as you\nsent it, in Unicode code points. A claim the text does not make, or makes\nwith a different figure, is left out, so the list says what the author\nwrote and nothing else. Offsets count code points, not UTF-16 units: in\nJavaScript slice with `Array.from(text).slice(start, end).join('')`.\nFor a URL the offsets are `null` and each position carries its passage.\nLocating adds a few seconds, never more than 20. If it cannot run,\nevery `positions` is `null` and the claims are returned unfiltered. `locate`\ndefaults to `false`.\n\nAccepts up to 50,000 characters (longer input is truncated). Free for\nAPI-key holders, capped at 1000 calls per ACCOUNT per day (resets at\n00:00 UTC) regardless of input length \u2014 a focused call costs the same one\nunit as an unfocused one. The cap is shared across all of the account's\nkeys.\n\n**Versions.** 200: `ExtractOutLegacy` (the next version: `ExtractOut`); 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Extract verifiable claims from arbitrary text.\n\nRuns the extraction step on its own \u2014 no research, no debate, no credit\ncharge. Enumerates every distinct major factual\nclaim in the input (up to 100), ordered most-check-worthy-first, in\n`claims` (one item for a single claim, `[]` for none). Claims returned\nhere verify cleanly when submitted verbatim to `POST /verify`.\n\nPass `focus` to narrow the result to the claims you care about \u2014 a short\ndescription such as `\"market size, growth and competitors\"`, at most 300\ncharacters. A focus selects from the claims the extractor found and\nnothing else: it cannot add a claim, reword one, reorder them, change the\nlanguage, or change what counts as a claim. Ordering stays\nmost-check-worthy-first.\n\n`status` is one of:\n\n- `ready` \u2014 claims were found (and, with a `focus`, at least one matched).\n- `no_checkable_claim` \u2014 no claim in the input could be checked against\n public evidence.\n- `no_match` \u2014 claims were found but none fall within your `focus`. The\n unfocused list is never substituted; widen the focus and call again.\n\n`text` can also be a single public web page URL (http or https, nothing\nelse in the field). Lenz reads the page, or a YouTube video's transcript,\nand extracts the claims from its first 50,000 characters. Pages behind a\nlogin (Facebook, Instagram, Threads, LinkedIn) can't be read. A URL call\ntypically takes 5-40 seconds, so give it a 90-second client timeout (SDK\n2.13 and later do). A page that cannot be read answers 502\n`extraction_failed`; when the services that read pages are down, 503\n`upstream_unavailable` with `Retry-After`. Neither uses a unit of the\ndaily cap.\n\nPass `locate: true` to keep only the claims that can be traced directly\nback to your text, and get each claim's `positions`: the passages that\nmake it, with `start` / `end` offsets into `text` as you\nsent it, in Unicode code points. A claim the text does not make, or makes\nwith a different figure, is left out, so the list says what the author\nwrote and nothing else. Offsets count code points, not UTF-16 units: in\nJavaScript slice with `Array.from(text).slice(start, end).join('')`.\nFor a URL the offsets are `null` and each position carries its passage.\nLocating adds a few seconds, never more than 20. If it cannot run,\nevery `positions` is `null` and the claims are returned unfiltered. `locate`\ndefaults to `false`.\n\nAccepts up to 50,000 characters (longer input is truncated). Free for\nAPI-key holders, capped at 1000 calls per ACCOUNT per day (resets at\n00:00 UTC) regardless of input length \u2014 a focused call costs the same one\nunit as an unfocused one. The cap is shared across all of the account's\nkeys.\n\n**Versions.** 200: `2026-10-11` receives `ExtractOut`, `2026-05-13` receives `ExtractOutLegacy`; 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Extract" ], @@ -683,11 +683,11 @@ "x-codeSamples": [ { "lang": "Python", - "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nclaims = client.extract(text=\"Coffee causes cancer and the earth is flat.\")\n# Add language=\"es\" to return extracted claims in Spanish.\nfor c in claims.identified_claims:\n print(c)\n" + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nout = client.extract(text=\"Coffee causes cancer and the earth is flat.\")\n# Add language=\"es\" to return extracted claims in Spanish.\nfor c in out.claims:\n print(c.claim)\n" }, { "lang": "TypeScript", - "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst out = await client.extract({\n text: \"Coffee causes cancer and the earth is flat.\",\n});\nout.identifiedClaims.forEach(c => console.log(c));\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst out = await client.extract({\n text: \"Coffee causes cancer and the earth is flat.\",\n});\n(out.claims ?? []).forEach(c => console.log(c.claim));\n" } ] } @@ -716,12 +716,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchAcceptedOutLegacy" + "$ref": "#/components/schemas/BatchAcceptedOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/BatchAcceptedOut" }, "2026-05-13": { @@ -739,12 +739,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -762,12 +762,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -781,7 +781,7 @@ } } }, - "description": "Resolve a multi-claim interrupt by selecting one or more of the offered\nclaims.\n\nEach selected claim fans out into its own independent pipeline (like\n``/verify/batch``), so the response is batch-shaped:\n``{batch_id, items: [{task_id, claim}], partial?}``. Poll each\n``task_id`` via ``GET /verify/status/{task_id}``.\n\nSend the chosen claims in ``claims`` (``texts`` is accepted as an alias).\nEvery selected claim must match one that was offered in the prior\n``needs_input`` response (server-validated against the recorded offer set);\narbitrary client-asserted text is rejected.\n\n**Versions.** 202: `BatchAcceptedOutLegacy` (the next version: `BatchAcceptedOut`); 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Resolve a multi-claim interrupt by selecting one or more of the offered\nclaims.\n\nEach selected claim fans out into its own independent pipeline (like\n``/verify/batch``), so the response is batch-shaped:\n``{batch_id, items: [{task_id, claim}], partial?}``. Poll each\n``task_id`` via ``GET /verify/status/{task_id}``.\n\nSend the chosen claims in ``claims`` (``texts`` is accepted as an alias).\nEvery selected claim must match one that was offered in the prior\n``needs_input`` response (server-validated against the recorded offer set);\narbitrary client-asserted text is rejected.\n\n**Versions.** 202: `2026-10-11` receives `BatchAcceptedOut`, `2026-05-13` receives `BatchAcceptedOutLegacy`; 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verify" ], @@ -826,12 +826,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StatusOutLegacy" + "$ref": "#/components/schemas/StatusOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/StatusOut" }, "2026-05-13": { @@ -849,12 +849,99 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + } + }, + "description": "Poll processing status for a submitted verification.\n\nFour shapes keyed on ``status`` \u2014 ``processing``, ``needs_input``,\n``completed``, ``failed`` \u2014 and only the fields belonging to the current\none are present (see the response schema). A field that does not belong\nto the current shape is omitted rather than sent as null, so a client may\ntest for presence.\n\n**Versions.** 200: `2026-10-11` receives `StatusOut`, `2026-05-13` receives `StatusOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", + "tags": [ + "Verify" + ], + "security": [ + { + "APIKeyBearerAuth": [] + } + ], + "x-codeSamples": [ + { + "lang": "Python", + "source": "from lenz_io import Lenz\n\n# typed progress needs lenz-io >= 2.11.0\nclient = Lenz(api_key=\"lenz_...\")\nstatus = client.get_status(\"tsk_abc123\")\n\n# \"processing\" | \"needs_input\" | \"completed\" | \"failed\" | \"cancelled\"\nif status.status == \"processing\" and status.progress:\n p = status.progress\n print(f\"{p.step} \u2014 step {p.index} of {p.total}\")\nelif status.status == \"completed\" and status.result:\n print(status.result.verdict, status.result.key_finding)\nelif status.failure: # failed or cancelled\n print(status.status, status.failure.code, status.failure.retryable)\n" + }, + { + "lang": "TypeScript", + "source": "// typed progress needs lenz-io >= 2.11.0\nimport { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst s = await client.getStatus(\"tsk_abc123\");\n\nif (s.status === \"processing\" && s.progress) {\n const { step, index, total } = s.progress;\n console.log(`${step} \u2014 step ${index} of ${total}`);\n} else if (s.status === \"completed\") {\n console.log(s.result!.verdict, s.result!.key_finding);\n} else if (s.failure) {\n // \"failed\" or \"cancelled\"\n console.log(s.status, s.failure.code, s.failure.retryable);\n}\n" + } + ] + } + }, + "/api/v1/verify/{task_id}/cancel": { + "post": { + "operationId": "cancel", + "summary": "Cancel Verification", + "parameters": [ + { + "in": "path", + "name": "task_id", + "schema": { + "title": "Task Id", + "type": "string" + }, + "required": true + }, + { + "$ref": "#/components/parameters/XLenzApiVersion" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/CancelOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/CancelOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "default": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -868,7 +955,7 @@ } } }, - "description": "Poll processing status for a submitted verification.\n\nFour shapes keyed on ``status`` \u2014 ``processing``, ``needs_input``,\n``completed``, ``failed`` \u2014 and only the fields belonging to the current\none are present (see the response schema). A field that does not belong\nto the current shape is omitted rather than sent as null, so a client may\ntest for presence.\n\n**Versions.** 200: `StatusOutLegacy` (the next version: `StatusOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Stop a verification that has not finished.\n\nA cancelled run is not charged and produces no verification: nothing is\nsaved. Work already under way when you cancel (a model call in flight) is\nnot billed to you, and the run itself stops at its next step. A run waiting\non `POST /verify/{task_id}/select` is cancelled too.\n\nThe call is safe to repeat and answers 200 whatever the state, so a client\nthat gives up never has to handle an error for losing a race:\n\n- `cancelled: true`, `status: \"cancelled\"`: the run is cancelled, by this\n call or an earlier one.\n- `cancelled: false`, `status: \"completed\"`: it finished first. The\n verification exists and was charged as usual.\n- `cancelled: false`, `status: \"failed\"`: it had already failed.\n\n`GET /verify/status/{task_id}` answers `cancelled` from then on, and a\n`webhook_url` receives `verification.cancelled` (a `2026-05-13` submission\nreceives `verification.failed` with `failure_class: \"cancelled\"`).\n\nAnother account's task, an unknown one, and a web task answer 404. A task\nthat is a `/review`'s deep check answers 409 `use_review_cancel`: cancel\nthe review (`POST /reviews/{review_id}/cancel`), which cancels everything in\nit.\n\n**Versions.** 200: `2026-10-11` receives `CancelOut`, `2026-05-13` receives `CancelOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verify" ], @@ -880,11 +967,11 @@ "x-codeSamples": [ { "lang": "Python", - "source": "from lenz_io import Lenz\n\n# typed progress needs lenz-io >= 2.11.0\nclient = Lenz(api_key=\"lenz_...\")\nstatus = client.get_status(\"tsk_abc123\")\n\n# \"processing\" | \"needs_input\" | \"completed\" | \"failed\"\nif status.status == \"processing\":\n p = status.progress\n print(f\"{p.step} \u2014 step {p.index} of {p.total}\")\nelif status.status == \"completed\":\n print(status.result.verdict, status.result.key_finding)\n" + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nresult = client.cancel(\"tsk_abc123\") # lenz-io >= 3.0.0\n# cancelled=True: stopped (by this call or an earlier one), not charged.\n# cancelled=False: it had ended; status is \"completed\" or \"failed\",\n# or \"needs_input\" when select already resolved it: cancel each task id\n# select returned (those keep running).\nprint(result.cancelled, result.status)\n" }, { "lang": "TypeScript", - "source": "// typed progress needs lenz-io >= 2.11.0\nimport { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst s = await client.getStatus(\"tsk_abc123\");\n\nif (s.status === \"processing\" && s.progress) {\n const { step, index, total } = s.progress;\n console.log(`${step} \u2014 step ${index} of ${total}`);\n} else if (s.status === \"completed\") {\n console.log(s.result!.verdict, s.result!.key_finding);\n}\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst result = await client.cancel(\"tsk_abc123\"); // lenz-io >= 3.0.0\n// cancelled: true = stopped (by this call or an earlier one), not charged.\n// cancelled: false = it had ended; status is \"completed\" or \"failed\",\n// or \"needs_input\" when select already resolved it: cancel each task id\n// select returned (those keep running).\nconsole.log(result.cancelled, result.status);\n" } ] } @@ -904,12 +991,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MeUsageOutLegacy" + "$ref": "#/components/schemas/MeUsageOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/MeUsageOut" }, "2026-05-13": { @@ -927,12 +1014,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -946,7 +1033,7 @@ } } }, - "description": "Return the calling account's credit balance and what it can still buy.\n\n**``credits`` is the balance.** One pool funds every billable capability,\nat the weights in ``costs`` \u2014 today ``verify`` 10, ``assess`` 1, ``ask`` 1.\nIt has two buckets: the monthly allowance for the current ``plan``, which\nresets at ``resets_at``, and non-expiring ``extra`` credits from grants and\ntop-ups, spent only once the allowance is gone. ``remaining`` is the sum,\nand is what a call is actually checked against.\n\nThe current shape is ``credits``, ``costs``, ``cost_options``, ``extract``,\n``plan``, ``plan_label`` and ``has_webhook_secret``. Callers on API version\n``2026-05-13`` also get the deprecated fields below, which are kept for\nthem: ``credits.bonus``, ``quota_resets_at`` (the same\nas ``credits.resets_at``) and the ``verify`` / ``ask`` / ``assess`` blocks.\n\n``credits.bonus`` (``2026-05-13`` only) is the deprecated old name of\n``credits.extra``, the same number. Read ``extra``.\n\n``plan`` is the stable slug to branch on; ``plan_label`` is the same tier\nas display copy (\"Pro\"). Two fields rather than one humanised\nstring, so a reworded label cannot break a client comparing tiers.\nThe Pro plan's ``plan`` is ``pro``; it read ``developer`` before\n2026-09-15 (#664), while ``plan_label`` already read \"Pro\".\n\n**The per-capability blocks are deprecated** (``2026-05-13`` only). ``verify`` / ``ask`` /\n``assess`` are **projections** of that one pool into each capability's own\nunit \u2014 \"how many of these could I still make\" \u2014 not separate allowances.\nSpending on one moves all of them, and they are floor divisions, so a\nverify block ticks once per 10 credits spent anywhere.\n\nRead ``credits`` and ``costs`` instead; the blocks and the per-block\n``credits`` alias stay for ``2026-05-13`` callers. Derive them::\n\n remaining = credits.remaining // costs[capability]\n total = credits.total // costs[capability]\n used = total - remaining\n\nTwo capabilities at the same price emit identical blocks \u2014 ``ask`` and\n``assess`` are both 1 credit \u2014 which is the clearest sign the shape\ndescribes an allowance that no longer exists.\n\n``bonus`` in each block is ``credits.extra`` in that capability's unit. The\nolder key ``credits`` is its deprecated alias, kept for the same callers.\n\n``costs`` is one entry per capability at its DEFAULT price. Prices that\ndepend on a request parameter live in ``cost_options``, nested capability\n\u2192 parameter \u2192 value \u2014 ``{\"verify\": {\"depth\": {\"standard\": 10, \"low\": 5}}}``.\nTwo maps rather than a flat one with a ``verify_low`` key, so a future\ntuning parameter nests instead of adding an entry that reads like a fifth\ncapability. Every capability in ``cost_options`` also appears in ``costs``\nat its default, so reading only ``costs`` is always correct.\n\n``extract`` is free at the pool (``costs.extract`` is 0) and is bounded by\na per-account daily fair-use cap instead, reported separately here and\nrejected with 429 rather than 402.\n\n**Versions.** 200: `MeUsageOutLegacy` (the next version: `MeUsageOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Return the calling account's credit balance and what it can still buy.\n\n**``credits`` is the balance.** One pool funds every billable capability,\nat the weights in ``costs`` \u2014 today ``verify`` 10, ``assess`` 1, ``ask`` 1.\nIt has two buckets: the monthly allowance for the current ``plan``, which\nresets at ``resets_at``, and non-expiring ``extra`` credits from grants and\ntop-ups, spent only once the allowance is gone. ``remaining`` is the sum,\nand is what a call is actually checked against.\n\nThe current shape is ``credits``, ``costs``, ``cost_options``, ``extract``,\n``plan``, ``plan_label`` and ``has_webhook_secret``. Callers on API version\n``2026-05-13`` also get the deprecated fields below, which are kept for\nthem: ``credits.bonus``, ``quota_resets_at`` (the same\nas ``credits.resets_at``) and the ``verify`` / ``ask`` / ``assess`` blocks.\n\n``credits.bonus`` (``2026-05-13`` only) is the deprecated old name of\n``credits.extra``, the same number. Read ``extra``.\n\n``plan`` is the stable slug to branch on; ``plan_label`` is the same tier\nas display copy (\"Pro\"). Two fields rather than one humanised\nstring, so a reworded label cannot break a client comparing tiers.\nThe Pro plan's ``plan`` is ``pro``; it read ``developer`` before\n2026-09-15, while ``plan_label`` already read \"Pro\".\n\n**The per-capability blocks are deprecated** (``2026-05-13`` only). ``verify`` / ``ask`` /\n``assess`` are **projections** of that one pool into each capability's own\nunit \u2014 \"how many of these could I still make\" \u2014 not separate allowances.\nSpending on one moves all of them, and they are floor divisions, so a\nverify block ticks once per 10 credits spent anywhere.\n\nRead ``credits`` and ``costs`` instead; the blocks and the per-block\n``credits`` alias stay for ``2026-05-13`` callers. Derive them::\n\n remaining = credits.remaining // costs[capability]\n total = credits.total // costs[capability]\n used = total - remaining\n\nTwo capabilities at the same price emit identical blocks \u2014 ``ask`` and\n``assess`` are both 1 credit \u2014 which is the clearest sign the shape\ndescribes an allowance that no longer exists.\n\n``bonus`` in each block is ``credits.extra`` in that capability's unit. The\nolder key ``credits`` is its deprecated alias, kept for the same callers.\n\n``costs`` is one entry per capability at its DEFAULT price. Prices that\ndepend on a request parameter live in ``cost_options``, nested capability\n\u2192 parameter \u2192 value \u2014 ``{\"verify\": {\"depth\": {\"standard\": 10, \"low\": 5}}}``.\nTwo maps rather than a flat one with a ``verify_low`` key, so a future\ntuning parameter nests instead of adding an entry that reads like a fifth\ncapability. Every capability in ``cost_options`` also appears in ``costs``\nat its default, so reading only ``costs`` is always correct.\n\n``extract`` is free at the pool (``costs.extract`` is 0) and is bounded by\na per-account daily fair-use cap instead, reported separately here and\nrejected with 429 rather than 402.\n\n**Versions.** 200: `2026-10-11` receives `MeUsageOut`, `2026-05-13` receives `MeUsageOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Account" ], @@ -958,11 +1045,11 @@ "x-codeSamples": [ { "lang": "Python", - "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nu = client.usage()\n# One credit pool funds every call; `costs` is the price list.\nprint(u.credits.remaining, \"credits left\", u.costs)\n# The same balance, projected into one capability's unit.\nprint(u.verify.remaining, \"verify calls left\")\nprint(u.assess.remaining, \"assess calls left\")\nprint(u.credits.extra, \"of them extra credits (grants and top-ups, never expiring)\")\n" + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\nu = client.usage()\n# One credit pool funds every call; `costs` is the price list.\nprint(u.credits.remaining, \"credits left\", u.costs)\n# The same balance in one capability's unit: credits.remaining // costs[capability].\nprint(u.credits.remaining // u.costs[\"verify\"], \"verify calls left\")\nprint(u.credits.remaining // u.costs[\"assess\"], \"assess calls left\")\nprint(u.credits.extra, \"of them extra credits (grants and top-ups, never expiring)\")\n" }, { "lang": "TypeScript", - "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst u = await client.usage();\nconsole.log(`${u.credits.remaining} credits left`, u.costs);\nconsole.log(`${u.verify.remaining} verify calls left`);\nconsole.log(`${u.assess.remaining} assess calls left`);\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst u = await client.usage();\nconsole.log(`${u.credits.remaining} credits left`, u.costs);\n// The same balance in one capability's unit: credits.remaining / costs[capability].\nconsole.log(`${Math.floor(u.credits.remaining / u.costs[\"verify\"])} verify calls left`);\nconsole.log(`${Math.floor(u.credits.remaining / u.costs[\"assess\"])} assess calls left`);\n" } ] } @@ -1002,12 +1089,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationListOutLegacy" + "$ref": "#/components/schemas/VerificationListOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/VerificationListOut" }, "2026-05-13": { @@ -1025,12 +1112,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -1048,12 +1135,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1067,7 +1154,7 @@ } } }, - "description": "List the authenticated key's verifications (all visibilities).\n\n**Versions.** 200: `VerificationListOutLegacy` (the next version: `VerificationListOut`); 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "List the authenticated key's verifications (all visibilities).\n\n**Versions.** 200: `2026-10-11` receives `VerificationListOut`, `2026-05-13` receives `VerificationListOutLegacy`; 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verifications" ], @@ -1083,7 +1170,7 @@ }, { "lang": "TypeScript", - "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst page = await client.verifications.listVerifications({ page: 1 });\npage.items.forEach(v => console.log(v.verificationId, v.verdict));\n" + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst page = await client.verifications.list({ page: 1 });\npage.items.forEach(v => console.log(v.verification_id, v.verdict));\n" } ] } @@ -1112,12 +1199,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClaimDetailOutLegacy" + "$ref": "#/components/schemas/ClaimDetailOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ClaimDetailOut" }, "2026-05-13": { @@ -1150,12 +1237,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationNotReadyOutLegacy" + "$ref": "#/components/schemas/VerificationNotReadyOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/VerificationNotReadyOut" }, "2026-05-13": { @@ -1188,12 +1275,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1207,7 +1294,7 @@ } } }, - "description": "Retrieve the full verification report for a single claim.\n\nReturns the verdict, citations, and (under ``audit``) the panel\nreasoning, debate transcript, and assessments. Same shape as the\n/verify webhook payload and the deprecated /library/{id} endpoint\nthis merger replaces.\n\nAuthorization model (optional Bearer):\n- Anon callers see any listed public claim (``visibility='public'`` AND\n ``status`` in {pending, published}) PLUS any API ``unlisted`` claim\n (``visibility='public'``, ``status='hidden'``, ``source_channel='api'``):\n unlisted is readable by verification_id, just never surfaced in listings.\n- Authed callers additionally see their own claims regardless of\n visibility or status.\n- Private claims, and non-API hidden public claims (retracted / pre-screen),\n owned by someone else return 404.\n\nAlso accepts the **task_id** ``/verify`` returned, in place of a\nverification_id. A completed run reads exactly as its verification_id\nwould. A run that has not completed answers **409** with ``status``\n(``processing``, ``needs_input`` or ``failed``) and a ``hint``, plus the\nfailure fields when it failed; poll ``GET /verify/status/{task_id}`` for\nprogress. A task_id that is unknown, or not yours, answers 404 with a\n``hint`` at that endpoint.\n\n**Versions.** 200: `ClaimDetailOutLegacy` (the next version: `ClaimDetailOut`); 409: `VerificationNotReadyOutLegacy` (the next version: `VerificationNotReadyOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Retrieve the full verification report for a single claim.\n\nReturns the verdict, citations, and (under ``audit``) the panel\nreasoning, debate transcript, and assessments. Same shape as the\n/verify webhook payload and the deprecated /library/{id} endpoint\nthis merger replaces.\n\nAuthorization model (optional Bearer):\n- Anon callers see any listed public claim (``visibility='public'`` AND\n ``status`` in {pending, published}) PLUS any API ``unlisted`` claim\n (``visibility='public'``, ``status='hidden'``, ``source_channel='api'``):\n unlisted is readable by verification_id, just never surfaced in listings.\n- Authed callers additionally see their own claims regardless of\n visibility or status.\n- Private claims, and non-API hidden public claims (retracted / pre-screen),\n owned by someone else return 404.\n\nAlso accepts the **task_id** ``/verify`` returned, in place of a\nverification_id. A completed run reads exactly as its verification_id\nwould. A run that has not completed answers **409** with ``status``\n(``processing``, ``needs_input`` or ``failed``) and a ``hint``, plus the\nfailure fields when it failed; poll ``GET /verify/status/{task_id}`` for\nprogress. A task_id that is unknown, or not yours, answers 404 with a\n``hint`` at that endpoint.\n\n**Versions.** 200: `2026-10-11` receives `ClaimDetailOut`, `2026-05-13` receives `ClaimDetailOutLegacy`; 409: `2026-10-11` receives `VerificationNotReadyOut`, `2026-05-13` receives `VerificationNotReadyOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verifications" ] @@ -1250,12 +1337,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1269,7 +1356,7 @@ } } }, - "description": "Delete an owned verification.\n\nRemoves the verification, its analysis, evidence and follow-up chat, and\nthe text of each request that submitted it from your credit history and\nfrom our API-call log (on a batch, that item only). Not reached by a\ndeletion: copies in our caches, which expire within 24 hours, and our\nbackups, which age out within 60 days.\n\nDeleting a warranted verification keeps its certificate and warranty; the\ncertificate stays at its `certificate_url`.\n\n**Versions.** default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Delete an owned verification.\n\nRemoves the verification, its analysis, evidence and follow-up chat, and\nthe text of each request that submitted it from your credit history and\nfrom our API-call log (on a batch, that item only). Not reached by a\ndeletion: copies in our caches, which expire within 24 hours, and our\nbackups, which age out within 60 days.\n\nDeleting a warranted verification keeps its certificate and warranty; the\ncertificate stays at its `certificate_url`.\n\n**Versions.** default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verifications" ], @@ -1319,12 +1406,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1338,7 +1425,7 @@ } } }, - "description": "Download the warranty certificate for a covered verification.\n\nResolved by (verification, ACCOUNT), not by verification alone: one cached\nanalysis can have several holders, each with their own certificate, their\nown cap and their own link. Returning \"the certificate for this\nverification\" would hand one customer another customer's document.\n\nByte-identical to ``/certificate/.json``. A withdrawn\ncertificate is still served \u2014 it is the record of what was warranted, and\nthe withdrawal date is on it.\n\nServed whether or not new certificates are currently being issued: an\nissued certificate stands.\n\n**Versions.** default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Download the warranty certificate for a covered verification.\n\nResolved by (verification, ACCOUNT), not by verification alone: one cached\nanalysis can have several holders, each with their own certificate, their\nown cap and their own link. Returning \"the certificate for this\nverification\" would hand one customer another customer's document.\n\nByte-identical to ``/certificate/.json``. A withdrawn\ncertificate is still served \u2014 it is the record of what was warranted, and\nthe withdrawal date is on it.\n\nServed whether or not new certificates are currently being issued: an\nissued certificate stands.\n\n**Versions.** default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verifications" ], @@ -1398,12 +1485,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -1421,12 +1508,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1440,7 +1527,7 @@ } } }, - "description": "List public verifications semantically related to this one.\n\nReturns the closest public-library claims by semantic similarity\n(cosine distance, 0 = identical). Excludes the verification itself,\nnear-duplicates (distance < 0.2), and editorially-hidden claims. Empty\nlist when the verification has not been indexed yet (that happens\nshortly after it completes) or when no claim is close enough.\n\nAuthorization model (optional Bearer) \u2014 identical to\n``GET /verifications/{verification_id}``: anon callers reach listed public claims plus\nAPI-origin unlisted ones; a valid key additionally unlocks the\ncaller's own claims. Results are always drawn from the public\nlibrary only, so keyless access exposes nothing new.\n``limit`` is clamped to [1, 10].\n\n**Versions.** 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "List public verifications semantically related to this one.\n\nReturns the closest public-library claims by semantic similarity\n(cosine distance, 0 = identical). Excludes the verification itself,\nnear-duplicates (distance < 0.2), and editorially-hidden claims. Empty\nlist when the verification has not been indexed yet (that happens\nshortly after it completes) or when no claim is close enough.\n\nAuthorization model (optional Bearer) \u2014 identical to\n``GET /verifications/{verification_id}``: anon callers reach listed public claims plus\nAPI-origin unlisted ones; a valid key additionally unlocks the\ncaller's own claims. Results are always drawn from the public\nlibrary only, so keyless access exposes nothing new.\n``limit`` is clamped to [1, 10].\n\n**Versions.** 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Verifications" ], @@ -1495,12 +1582,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1514,7 +1601,7 @@ } } }, - "description": "Get ask history and remaining quota for a verification.\n\n**Versions.** default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Get ask history and remaining quota for a verification.\n\n**Versions.** default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Ask" ], @@ -1562,12 +1649,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1581,7 +1668,7 @@ } } }, - "description": "Delete all ask messages for this user on a verification.\n\n**Versions.** default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Delete all ask messages for this user on a verification.\n\n**Versions.** default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Ask" ], @@ -1629,12 +1716,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorOutLegacy" + "$ref": "#/components/schemas/ValidationErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ValidationErrorOut" }, "2026-05-13": { @@ -1652,12 +1739,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1671,7 +1758,7 @@ } } }, - "description": "Send a question and get an expert response.\n\nThe reply (``content`` field on the response) is plain text with a\nsmall markdown subset baked in by the chat model:\n\n- ``**bold**`` and ``*italic*``\n- ``- `` or ``* `` bullet lists\n- Blank-line paragraph breaks; single newlines inside a paragraph\n mean line break\n- ``[label](url)`` links, only ever to one of the verification's\n sources: any other link is reduced to its label before the reply\n is returned\n\nThe model only produces these \u2014 no headings, no tables, no code\nblocks. Pass the reply through any markdown library or display it\nverbatim.\n\nDocumented at https://lenz.io/docs/quickstart#ask-reply-format.\n\n**Versions.** 422: `ValidationErrorOutLegacy` (the next version: `ValidationErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Send a question and get an expert response.\n\nThe reply (``content`` field on the response) is plain text with a\nsmall markdown subset baked in by the chat model:\n\n- ``**bold**`` and ``*italic*``\n- ``- `` or ``* `` bullet lists\n- Blank-line paragraph breaks; single newlines inside a paragraph\n mean line break\n- ``[label](url)`` links, only ever to one of the verification's\n sources: any other link is reduced to its label before the reply\n is returned\n\nThe model only produces these \u2014 no headings, no tables, no code\nblocks. Pass the reply through any markdown library or display it\nverbatim.\n\nDocumented at https://lenz.io/docs/quickstart#ask-reply-format.\n\n**Versions.** 422: `2026-10-11` receives `ValidationErrorOut`, `2026-05-13` receives `ValidationErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Ask" ], @@ -1722,12 +1809,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewErrorOutLegacy" + "$ref": "#/components/schemas/ReviewErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewErrorOut" }, "2026-05-13": { @@ -1745,12 +1832,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewErrorOutLegacy" + "$ref": "#/components/schemas/ReviewErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewErrorOut" }, "2026-05-13": { @@ -1798,12 +1885,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewErrorOutLegacy" + "$ref": "#/components/schemas/ReviewErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewErrorOut" }, "2026-05-13": { @@ -1821,12 +1908,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewErrorOutLegacy" + "$ref": "#/components/schemas/ReviewErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewErrorOut" }, "2026-05-13": { @@ -1844,12 +1931,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -1863,7 +1950,7 @@ } } }, - "description": "Read a draft, quick-check every claim, deep-check the ones that look\nwrong or uncertain, and hand back the issues with the fixes.\n\nReturns 202 with a `review_id`; read the review at the `Location`\n(`GET /reviews/{review_id}`), or receive `review.completed` /\n`review.failed` at your webhook. A review takes two to four minutes.\n\nIt spends the credits the primitives spend: 1 per claim assessed, and 10\n(5 at `depth: low`) per deep check it runs. At most\n`min(claims, max_assessments) \u00d7 1 + max_verifications \u00d7 depth cost`;\nthe review reports what it charged.\n\nSend `escalate.max_citations: N` to also check the draft's first N\ncitations (markdown links, bare URLs, DOIs, `[n]` markers with a reference\nlist): for each, whether the source says what the draft attributes to it.\nOff by default; 1 credit per checked citation, and a source that could not\nbe checked is not charged. `POST /citecheck` is the same check alone.\nThe rows are in `citations[]`, the ones that do not hold up in\n`citation_issues[]`, and `outcome` reads `issues_found` for them.\n`escalate.max_assessments: 0` runs no quick check (a review of the\ncitations alone).\n\nOnly claims traced directly back to the draft are checked: a claim found\nnowhere in it is left out (as `/extract` does with `locate: true`). Each\nclaim row's `positions` says where the draft makes it, and\n`more_claim_locations` gives `{claim, positions}` for each of\n`more_claims`. Claim and citation positions share one shape, `{start, end,\ntext}`: `start` / `end` in Unicode code points of `text` as you sent it,\nhalf-open, and `text` the passage (`null` on a citation, whose row carries\nthe statement). For a URL draft `start` / `end` are `null` and `text`\ncarries the passage.\n\nA `text` that is one URL is read like `/extract` reads it and counts as\none `/extract` call: past the daily `/extract` limit it answers 429\n`extract_daily_limit`. The other 429 is `review_in_flight` (three reviews\nrunning on the account).\n\n**Versions.** 402: `ReviewErrorOutLegacy` (the next version: `ReviewErrorOut`); 409: `ReviewErrorOutLegacy` (the next version: `ReviewErrorOut`); 429: `ReviewErrorOutLegacy` (the next version: `ReviewErrorOut`); 503: `ReviewErrorOutLegacy` (the next version: `ReviewErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Read a draft, quick-check every claim, deep-check the ones that look\nwrong or uncertain, and hand back the issues with the fixes.\n\nReturns 202 with a `review_id`; read the review at the `Location`\n(`GET /reviews/{review_id}`), or receive `review.completed` /\n`review.failed` at your webhook. A review takes two to four minutes.\n\nIt spends the credits the primitives spend: 1 per claim assessed, and 10\n(5 at `depth: low`) per deep check it runs. At most\n`min(claims, max_assessments) \u00d7 1 + max_verifications \u00d7 depth cost`;\nthe review reports what it charged.\n\nSend `escalate.max_citations: N` to also check the draft's first N\ncitations (markdown links, bare URLs, DOIs, `[n]` markers with a reference\nlist): for each, whether the source says what the draft attributes to it.\nOff by default; 1 credit per checked citation, and a source that could not\nbe checked is not charged. `POST /citecheck` is the same check alone.\nThe rows are in `citations[]`, the ones that do not hold up in\n`citation_issues[]`, and `outcome` reads `issues_found` for them.\n`escalate.max_assessments: 0` runs no quick check (a review of the\ncitations alone).\n\nOnly claims traced directly back to the draft are checked: a claim found\nnowhere in it is left out (as `/extract` does with `locate: true`). Each\nclaim row's `positions` says where the draft makes it, and\n`more_claim_locations` gives `{claim, positions}` for each of\n`more_claims`. Claim and citation positions share one shape, `{start, end,\ntext}`: `start` / `end` in Unicode code points of `text` as you sent it,\nhalf-open, and `text` the passage (`null` on a citation, whose row carries\nthe statement). For a URL draft `start` / `end` are `null` and `text`\ncarries the passage.\n\nA `text` that is one URL is read like `/extract` reads it and counts as\none `/extract` call: past the daily `/extract` limit it answers 429\n`extract_daily_limit`. The other 429 is `review_in_flight` (three reviews\nrunning on the account).\n\n**Versions.** 402: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; 409: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; 429: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; 503: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Review" ], @@ -1886,7 +1973,7 @@ { "lang": "Shell", "label": "cURL", - "source": "curl -X POST https://lenz.io/api/v1/review \\\n -H \"Authorization: Bearer lenz_...\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Idempotency-Key: draft-42\" \\\n -d '{\"text\": \"The EU AI Act took effect in March 2024. ...\",\n \"escalate\": {\"max_verifications\": 5}}'\n# 202 {\"review_id\": \"a1b2c3d4\", \"status\": \"queued\"}\n# Location: /api/v1/reviews/a1b2c3d4\n" + "source": "curl -X POST https://lenz.io/api/v1/review \\\n -H \"Authorization: Bearer lenz_...\" \\\n -H \"X-Lenz-API-Version: 2026-10-11\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Idempotency-Key: draft-42\" \\\n -d '{\"text\": \"The EU AI Act took effect in March 2024. ...\",\n \"escalate\": {\"max_verifications\": 5}}'\n# 202 {\"review_id\": \"a1b2c3d4\", \"status\": \"queued\"}\n# Location: /api/v1/reviews/a1b2c3d4\n" } ] } @@ -1927,10 +2014,10 @@ "schema": { "anyOf": [ { - "$ref": "#/components/schemas/ReviewFullOutLegacy" + "$ref": "#/components/schemas/ReviewFullOut" }, { - "$ref": "#/components/schemas/ReviewIssuesOutLegacy" + "$ref": "#/components/schemas/ReviewIssuesOut" } ], "title": "Response" @@ -1938,7 +2025,7 @@ } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "anyOf": [ { "$ref": "#/components/schemas/ReviewFullOut" @@ -1972,12 +2059,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewErrorOutLegacy" + "$ref": "#/components/schemas/ReviewErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewErrorOut" }, "2026-05-13": { @@ -1995,12 +2082,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewErrorOutLegacy" + "$ref": "#/components/schemas/ReviewErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewErrorOut" }, "2026-05-13": { @@ -2018,12 +2105,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewErrorOutLegacy" + "$ref": "#/components/schemas/ReviewErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewErrorOut" }, "2026-05-13": { @@ -2056,12 +2143,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -2075,7 +2162,7 @@ } } }, - "description": "The review as it stands: quick verdicts at ~25-40 s, deep checks as each\nlands. `?view=issues` returns the same object without `claims[]`. Poll no\nfaster than `poll_after_seconds`; read `issues[]` at `completed`.\n\n**Versions.** 200: `ReviewFullOutLegacy | ReviewIssuesOutLegacy` (the next version: `ReviewFullOut | ReviewIssuesOut`); 403: `ReviewErrorOutLegacy` (the next version: `ReviewErrorOut`); 404: `ReviewErrorOutLegacy` (the next version: `ReviewErrorOut`); 410: `ReviewErrorOutLegacy` (the next version: `ReviewErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "The review as it stands: quick verdicts at ~25-40 s, deep checks as each\nlands. `?view=issues` returns the same object without `claims[]`. Poll no\nfaster than `poll_after_seconds`; read `issues[]` at `completed`.\n\n**Versions.** 200: `2026-10-11` receives `ReviewFullOut | ReviewIssuesOut`, `2026-05-13` receives `ReviewFullOutLegacy | ReviewIssuesOutLegacy`; 403: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; 404: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; 410: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Review" ], @@ -2088,7 +2175,163 @@ { "lang": "Shell", "label": "cURL", - "source": "curl \"https://lenz.io/api/v1/reviews/a1b2c3d4?view=issues\" \\\n -H \"Authorization: Bearer lenz_...\"\n# Poll every poll_after_seconds until status is completed or failed,\n# then read outcome and issues[] (verdict, key_finding, suggested_rewrite).\n" + "source": "curl \"https://lenz.io/api/v1/reviews/a1b2c3d4?view=issues\" \\\n -H \"X-Lenz-API-Version: 2026-10-11\" \\\n -H \"Authorization: Bearer lenz_...\"\n# Poll every poll_after_seconds until status is completed or failed,\n# then read outcome and issues[] (verdict, key_finding, suggested_rewrite).\n" + } + ] + } + }, + "/api/v1/reviews/{review_id}/cancel": { + "post": { + "operationId": "cancelReview", + "summary": "Cancel a review", + "parameters": [ + { + "in": "path", + "name": "review_id", + "schema": { + "title": "Review Id", + "type": "string" + }, + "required": true + }, + { + "$ref": "#/components/parameters/XLenzApiVersion" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewFullOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ReviewFullOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ReviewFullOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ReviewErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ReviewErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ReviewErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ReviewErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ReviewErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ReviewErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "default": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + } + }, + "description": "Stop a review: its quick checks, its deep checks and its citation\nchecks. Returns the review as it stands afterwards, `status: cancelled`.\n\nYou are charged only for what it delivered before the cancel: quick\nchecks it served, deep checks that finished, citations it checked. The\nrest is refunded or never charged. A review that had already finished is\nreturned unchanged (`completed` or `failed`), so the body always says\nwhat happened; cancelling again is safe. A review's deep check cannot be\ncancelled on its own (`POST /verify/{task_id}/cancel` answers 409\n`use_review_cancel`): cancel the review.\n\n**Versions.** 200: `2026-10-11` receives `ReviewFullOut`, `2026-05-13` receives `ReviewFullOutLegacy`; 403: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; 404: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; 410: `2026-10-11` receives `ReviewErrorOut`, `2026-05-13` receives `ReviewErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", + "tags": [ + "Review" + ], + "security": [ + { + "APIKeyBearerAuth": [] + } + ], + "x-codeSamples": [ + { + "lang": "Python", + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\n# Stops the review, its deep checks and its citation checks (lenz-io >= 3.0.0).\nreview = client.cancel_review(\"a1b2c3d4\") # the full review, as get_review reads it\nprint(review.status, review.credits.charged) # \"cancelled\", what it delivered cost\n" + }, + { + "lang": "TypeScript", + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\n// Stops the review, its deep checks and its citation checks (lenz-io >= 3.0.0).\nconst review = await client.cancelReview(\"a1b2c3d4\"); // as getReview reads it\nconsole.log(review.status, review.credits.charged);\n" } ] } @@ -2123,12 +2366,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + "$ref": "#/components/schemas/CitecheckErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckErrorOut" }, "2026-05-13": { @@ -2146,12 +2389,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + "$ref": "#/components/schemas/CitecheckErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckErrorOut" }, "2026-05-13": { @@ -2184,12 +2427,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + "$ref": "#/components/schemas/CitecheckErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckErrorOut" }, "2026-05-13": { @@ -2207,12 +2450,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + "$ref": "#/components/schemas/CitecheckErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckErrorOut" }, "2026-05-13": { @@ -2230,12 +2473,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -2249,7 +2492,7 @@ } } }, - "description": "Check whether each cited source says what the draft attributes to it.\n\nSend `text` (a draft with its links: markdown links, bare URLs, `doi:` and\n`doi.org` forms, `[n]` markers with a reference list) or `pairs` (up to 20\nstatements, each with the `url` or `doi` it cites). Returns 202 with a\n`citecheck_id`; read the check at the `Location`\n(`GET /citechecks/{citecheck_id}`), or receive `citecheck.completed` /\n`citecheck.failed` at your webhook.\n\nIt costs 1 credit per checked citation: at most 1 credit per citation you\nsend (or `max_citations` for a text), and a citation that could not be\nchecked is refunded. `credits.charged` on the check says what it cost.\n\n**Versions.** 402: `CitecheckErrorOutLegacy` (the next version: `CitecheckErrorOut`); 409: `CitecheckErrorOutLegacy` (the next version: `CitecheckErrorOut`); 429: `CitecheckErrorOutLegacy` (the next version: `CitecheckErrorOut`); 503: `CitecheckErrorOutLegacy` (the next version: `CitecheckErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "Check whether each cited source says what the draft attributes to it.\n\nSend `text` (a draft with its links: markdown links, bare URLs, `doi:` and\n`doi.org` forms, `[n]` markers with a reference list) or `pairs` (up to 20\nstatements, each with the `url` or `doi` it cites). Returns 202 with a\n`citecheck_id`; read the check at the `Location`\n(`GET /citechecks/{citecheck_id}`), or receive `citecheck.completed` /\n`citecheck.failed` at your webhook.\n\nIt costs 1 credit per checked citation: at most 1 credit per citation you\nsend (or `max_citations` for a text), and a citation that could not be\nchecked is refunded. `credits.charged` on the check says what it cost.\n\n**Versions.** 402: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; 409: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; 429: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; 503: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Citecheck" ], @@ -2294,12 +2537,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckOutLegacy" + "$ref": "#/components/schemas/CitecheckOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckOut" }, "2026-05-13": { @@ -2317,12 +2560,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + "$ref": "#/components/schemas/CitecheckErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckErrorOut" }, "2026-05-13": { @@ -2340,12 +2583,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + "$ref": "#/components/schemas/CitecheckErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckErrorOut" }, "2026-05-13": { @@ -2363,12 +2606,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + "$ref": "#/components/schemas/CitecheckErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckErrorOut" }, "2026-05-13": { @@ -2386,12 +2629,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorOutLegacy" + "$ref": "#/components/schemas/ErrorOut" } } }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ErrorOut" }, "2026-05-13": { @@ -2405,7 +2648,7 @@ } } }, - "description": "The check as it stands. Poll no faster than `poll_after_seconds`; read\n`citation_issues[]` at `completed`.\n\n**Versions.** 200: `CitecheckOutLegacy` (the next version: `CitecheckOut`); 403: `CitecheckErrorOutLegacy` (the next version: `CitecheckErrorOut`); 404: `CitecheckErrorOutLegacy` (the next version: `CitecheckErrorOut`); 410: `CitecheckErrorOutLegacy` (the next version: `CitecheckErrorOut`); default: `ErrorOutLegacy` (the next version: `ErrorOut`).", + "description": "The check as it stands. Poll no faster than `poll_after_seconds`; read\n`citation_issues[]` at `completed`.\n\n**Versions.** 200: `2026-10-11` receives `CitecheckOut`, `2026-05-13` receives `CitecheckOutLegacy`; 403: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; 404: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; 410: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", "tags": [ "Citecheck" ], @@ -2415,39 +2658,195 @@ } ] } - } - }, - "components": { - "schemas": { - "EntityRef": { - "description": "An entity (person, place, organization, concept) referenced in the\nsubmitted claim.\n\n``qid`` is the Wikidata Q identifier (e.g. ``Q42`` for Douglas Adams)\nwhen Lenz resolved the entity against its internal catalog; ``null``\notherwise. Customers can join on ``qid`` to their own Wikidata-indexed\ncorpus, or hit the Wikidata API directly for richer metadata.", - "properties": { - "name": { - "title": "Name", - "type": "string" + }, + "/api/v1/citechecks/{citecheck_id}/cancel": { + "post": { + "operationId": "cancelCitecheck", + "summary": "Cancel a citation check", + "parameters": [ + { + "in": "path", + "name": "citecheck_id", + "schema": { + "title": "Citecheck Id", + "type": "string" + }, + "required": true }, - "qid": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Qid" + { + "$ref": "#/components/parameters/XLenzApiVersion" } - }, - "required": [ - "name" ], - "title": "EntityRef", - "type": "object" - }, - "VerificationListItemOut": { - "description": "Single item in a list of verifications: ``GET /verifications``\n(authed-owner listing) and ``GET /library`` (public catalog), one shape.\n\nSlim shape: no ``url`` (customers reference claims by\n``verification_id``), no ``visibility``.", - "properties": { - "verification_id": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CitecheckOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/CitecheckOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/CitecheckOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CitecheckErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/CitecheckErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CitecheckErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/CitecheckErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CitecheckErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/CitecheckErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/CitecheckErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + }, + "default": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOut" + } + } + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ErrorOut" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ErrorOutLegacy" + } + }, + "headers": { + "X-Lenz-API-Version": { + "$ref": "#/components/headers/XLenzApiVersion" + } + } + } + }, + "description": "Stop a citation check. Returns the check as it stands afterwards,\n`status: cancelled`.\n\nYou are charged only for the citations it checked before the cancel; the\nrest are refunded. A check that had already finished is returned\nunchanged (`completed` or `failed`); cancelling again is safe.\n\n**Versions.** 200: `2026-10-11` receives `CitecheckOut`, `2026-05-13` receives `CitecheckOutLegacy`; 403: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; 404: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; 410: `2026-10-11` receives `CitecheckErrorOut`, `2026-05-13` receives `CitecheckErrorOutLegacy`; default: `2026-10-11` receives `ErrorOut`, `2026-05-13` receives `ErrorOutLegacy`.", + "tags": [ + "Citecheck" + ], + "security": [ + { + "APIKeyBearerAuth": [] + } + ], + "x-codeSamples": [ + { + "lang": "Python", + "source": "from lenz_io import Lenz\n\nclient = Lenz(api_key=\"lenz_...\")\ncheck = client.cancel_citecheck(\"12bbbf65\") # lenz-io >= 3.0.0; as get_citecheck reads it\nprint(check.status, check.credits.charged) # citations not checked are refunded\n" + }, + { + "lang": "TypeScript", + "source": "import { Lenz } from \"lenz-io\";\n\nconst client = new Lenz({ apiKey: \"lenz_...\" });\nconst check = await client.cancelCitecheck(\"12bbbf65\"); // lenz-io >= 3.0.0\nconsole.log(check.status, check.credits.charged);\n" + } + ] + } + } + }, + "components": { + "schemas": { + "EntityRef": { + "description": "An entity (person, place, organization, concept) referenced in the\nsubmitted claim.\n\n``qid`` is the Wikidata Q identifier (e.g. ``Q42`` for Douglas Adams)\nwhen Lenz resolved the entity against its own entity catalog; ``null``\notherwise. Customers can join on ``qid`` to their own Wikidata-indexed\ncorpus, or hit the Wikidata API directly for richer metadata.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "qid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Qid" + } + }, + "required": [ + "name" + ], + "title": "EntityRef", + "type": "object" + }, + "VerificationListItemOut": { + "description": "Single item in a list of verifications: ``GET /verifications``\n(authed-owner listing) and ``GET /library`` (public catalog), one shape.\n\nSlim shape: no ``url`` (customers reference claims by\n``verification_id``), no ``visibility``.", + "properties": { + "verification_id": { "title": "Verification Id", "type": "string" }, @@ -3526,7 +3925,7 @@ "type": "object" }, "CoverageOut": { - "description": "Whether this verdict carries a warranty, and if not, why not.\n\nPresent only when covered verification is enabled. It describes the pair\n(this account, this analysis), not the analysis alone: two accounts can\nhold two certificates over one cached verdict, so the block one customer\nsees is never the block another sees.\n\n``reasons`` is empty exactly when ``status`` is not ``uncovered``. The\nvocabulary is closed and deliberately smaller than the internal gate's:\n``plan`` and ``depth`` are actionable, ``account`` means the account\nturned certificates off (Pro and Scale), ``verdict`` is a product rule\ncallers design around, ``quality`` covers everything a caller can neither\nact on nor define, and ``withdrawn`` / ``issue_failed`` are statements\nabout Lenz rather than about the caller's claim.", + "description": "Whether this verdict carries a warranty, and if not, why not.\n\nPresent only when covered verification is enabled. It describes the pair\n(this account, this analysis), not the analysis alone: two accounts can\nhold two certificates over one cached verdict, so the block one customer\nsees is never the block another sees.\n\n``reasons`` is empty exactly when ``status`` is not ``uncovered``. The\nvocabulary is closed and deliberately smaller than the set of rules\nbehind the decision: ``plan`` and ``depth`` are actionable, ``account``\nmeans the account turned certificates off (Pro and Scale), ``verdict`` is\na product rule callers design around, ``quality`` covers everything a\ncaller can neither act on nor define, and ``withdrawn`` / ``issue_failed``\nare statements about Lenz rather than about the caller's claim.", "properties": { "status": { "default": "uncovered", @@ -3710,14 +4109,15 @@ "type": "object" }, "StatusOut": { - "description": "Body of ``GET /verify/status/{task_id}``.\n\nFour shapes keyed on ``status``, and only the fields belonging to the\ncurrent one are present \u2014 ``processing`` carries ``progress``,\n``needs_input`` carries ``reason``, ``claims`` and ``hint``,\n``completed`` carries ``result``, and ``failed`` carries ``failure``.\nBranch on ``status`` and read the fields for that branch; absent is\nmeaningful, so do not treat a missing key as an empty one.\n\n``task_id`` is echoed on every shape, so a caller polling several\nverifications in one loop can tell the replies apart without threading\nthe id through their own call site.", + "description": "Body of ``GET /verify/status/{task_id}``.\n\nFive shapes keyed on ``status``, and only the fields belonging to the\ncurrent one are present \u2014``processing`` carries ``progress``,\n``needs_input`` carries ``reason``, ``claims`` and ``hint``,\n``completed`` carries ``result``, ``failed`` carries ``failure``, and\n``cancelled`` (the run was stopped with\n``POST /verify/{task_id}/cancel``) carries nothing more: it was not\ncharged and has no verification.\nBranch on ``status`` and read the fields for that branch; absent is\nmeaningful, so do not treat a missing key as an empty one.\n\n``task_id`` is echoed on every shape, so a caller polling several\nverifications in one loop can tell the replies apart without threading\nthe id through their own call site.", "properties": { "status": { "enum": [ "processing", "needs_input", "completed", - "failed" + "failed", + "cancelled" ], "title": "Status", "type": "string" @@ -3801,6 +4201,37 @@ "title": "StatusOut", "type": "object" }, + "CancelOut": { + "description": "Body of ``POST /verify/{task_id}/cancel``.\n\n``cancelled`` is true when the run is cancelled after this call, whether\nthis call stopped it or an earlier one did (a repeat is safe). A run that\nhad already finished is not touched: ``cancelled`` is false and ``status``\nsays how it ended (``completed``: the verification was saved and charged\nas usual). ``status`` is the same word ``GET /verify/status/{task_id}``\nanswers at that moment (version ``2026-05-13`` reads a cancelled run as\n``failed``, with ``cancelled: true``).\n\nA cancelled run is not charged and has no verification; model work already\nunder way when it stopped is not billed to you. The run itself stops at its\nnext step.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + }, + "cancelled": { + "title": "Cancelled", + "type": "boolean" + }, + "status": { + "enum": [ + "processing", + "needs_input", + "completed", + "failed", + "cancelled" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "task_id", + "cancelled", + "status" + ], + "title": "CancelOut", + "type": "object" + }, "MeUsageCreditsOut": { "description": "The account's credit balance \u2014 the one pool every capability spends.\n\nTwo buckets: the monthly allowance for the current plan (resets at\n``resets_at``) and non-expiring ``extra`` credits from grants and top-ups,\nwhich are spent only once the allowance is gone. ``remaining`` is the sum\nand is what a call is checked against.\n\n``bonus`` is a DEPRECATED alias of ``extra`` (the balance's name until\n2026-09-15); read ``extra``. It stays for existing callers. It was renamed because the balance holds top-ups you paid for as\nwell as grants.", "properties": { @@ -3869,7 +4300,7 @@ "type": "object" }, "MeUsageOut": { - "description": "The calling account's credit balance, and what it can still buy.\n\n``credits`` is the balance and ``costs`` is the price list. Those two,\nplus ``cost_options``, are the whole contract for a new integration. The\ncurrent shape is ``credits``, ``costs``, ``cost_options``, ``extract``,\n``plan``, ``plan_label`` and ``has_webhook_secret``.\n\nCallers on API version ``2026-05-13`` also get the DEPRECATED fields, kept\nfor them: the ``verify`` / ``ask`` / ``assess`` blocks\n(projections of that same pool into each capability's unit) with their\n``credits`` alias, ``credits.bonus`` and ``quota_resets_at``.\n\n``extract`` is free at the pool (``costs.extract`` is 0) and carries a\nper-account daily fair-use cap instead; it rejects with 429, not 402.\n\nThe Pro plan's ``plan`` is ``pro`` since 2026-09-15; it was ``developer``\nbefore (#664). ``plan_label`` reads \"Pro\" either way.", + "description": "The calling account's credit balance, and what it can still buy.\n\n``credits`` is the balance and ``costs`` is the price list. Those two,\nplus ``cost_options``, are the whole contract for a new integration. The\ncurrent shape is ``credits``, ``costs``, ``cost_options``, ``extract``,\n``plan``, ``plan_label`` and ``has_webhook_secret``.\n\nCallers on API version ``2026-05-13`` also get the DEPRECATED fields, kept\nfor them: the ``verify`` / ``ask`` / ``assess`` blocks\n(projections of that same pool into each capability's unit) with their\n``credits`` alias, ``credits.bonus`` and ``quota_resets_at``.\n\n``extract`` is free at the pool (``costs.extract`` is 0) and carries a\nper-account daily fair-use cap instead; it rejects with 429, not 402.\n\nThe Pro plan's ``plan`` is ``pro`` since 2026-09-15; it was ``developer``\nbefore. ``plan_label`` reads \"Pro\" either way.", "properties": { "plan": { "default": "free", @@ -5681,13 +6112,14 @@ "type": "string" }, "status": { - "description": "verifying: deep checks or citation checks are running.", + "description": "verifying: deep checks or citation checks are running. cancelled: you cancelled it (POST /reviews/{review_id}/cancel); only what it delivered before that is charged.", "enum": [ "queued", "assessing", "verifying", "completed", - "failed" + "failed", + "cancelled" ], "title": "Status", "type": "string" @@ -6081,13 +6513,14 @@ "type": "string" }, "status": { - "description": "verifying: deep checks or citation checks are running.", + "description": "verifying: deep checks or citation checks are running. cancelled: you cancelled it (POST /reviews/{review_id}/cancel); only what it delivered before that is charged.", "enum": [ "queued", "assessing", "verifying", "completed", - "failed" + "failed", + "cancelled" ], "title": "Status", "type": "string" @@ -7432,11 +7865,13 @@ "type": "string" }, "status": { + "description": "cancelled: you cancelled it (POST /citechecks/{citecheck_id}/cancel). Only citations it checked before that are charged.", "enum": [ "queued", "checking", "completed", - "failed" + "failed", + "cancelled" ], "title": "Status", "type": "string" @@ -7667,7 +8102,7 @@ "type": "object" }, "WebhookPayload": { - "description": "Body the customer's endpoint receives for a verification event.\n\nOne envelope for every event (plan no-claim-unification vote 20):\n``event``, ``event_id`` (the same on every retry: deduplicate on it),\n``task_id`` (pollable on ``GET /verify/status/{task_id}``),\n``verification_id`` once the verification exists, ``status``, and the\nbody polling returns at that moment under ``verification``. Keys a payload\ndoes not carry are absent, not null.\n\nAlways signed with HMAC-SHA256 over the raw body bytes; signature\nrides in the ``X-Lenz-Signature: sha256=`` header, and the version\nthe body was built in in ``X-Lenz-API-Version``. A webhook follows the\nversion of the request that asked for it; version ``2026-05-13`` sends\nthe earlier flat body (``result`` / ``needs_input`` / ``error`` beside\n``task_id``, no ``event_id``).\n\nThe ``attempt`` field is 1-indexed and includes the initial delivery,\nso values run 1 through 4 (initial + 3 retries).", + "description": "Body the customer's endpoint receives for a verification event.\n\nOne envelope for every event: ``event``, ``event_id`` (the same on every\nretry: deduplicate on it), ``task_id`` (pollable on\n``GET /verify/status/{task_id}``), ``verification_id`` once the\nverification exists, ``status``, and the body polling returns at that\nmoment under ``verification``. Keys a payload does not carry are absent,\nnot null.\n\nAlways signed with HMAC-SHA256 over the raw body bytes; signature\nrides in the ``X-Lenz-Signature: sha256=`` header, and the version\nthe body was built in in ``X-Lenz-API-Version``. A webhook follows the\nversion of the request that asked for it; version ``2026-05-13`` sends\nthe earlier flat body (``result`` / ``needs_input`` / ``error`` beside\n``task_id``, no ``event_id``).\n\nThe ``attempt`` field is 1-indexed and includes the initial delivery,\nso values run 1 through 4 (initial + 3 retries).", "properties": { "event": { "title": "Event", @@ -7764,12 +8199,13 @@ "type": "object" }, "ReviewWebhookPayload": { - "description": "Body of ``review.completed`` / ``review.failed``.\n\n``event_id`` is stable per (review, event): dedupe on it (``attempt``\nchanges on retries). ``delivered_at`` is the attempt time. Version\n``2026-05-13`` also sends ``task_id``, an internal delivery identity that\nis not pollable on ``/verify/status``.", + "description": "Body of ``review.completed`` / ``review.failed`` / ``review.cancelled``.\n\n``event_id`` is stable per (review, event): dedupe on it (``attempt``\nchanges on retries). ``delivered_at`` is the attempt time. Version\n``2026-05-13`` also sends ``task_id``, an internal delivery identity that\nis not pollable on ``/verify/status``.", "properties": { "event": { "enum": [ "review.completed", - "review.failed" + "review.failed", + "review.cancelled" ], "title": "Event", "type": "string" @@ -7785,7 +8221,8 @@ "status": { "enum": [ "completed", - "failed" + "failed", + "cancelled" ], "title": "Status", "type": "string" @@ -7815,12 +8252,13 @@ "type": "object" }, "CitecheckWebhookPayload": { - "description": "Body of ``citecheck.completed`` / ``citecheck.failed``.\n\n``event_id`` is stable per (check, event): dedupe on it. Version\n``2026-05-13`` also sends ``task_id``, an internal delivery identity that\nis not pollable on ``/verify/status``.", + "description": "Body of ``citecheck.completed`` / ``citecheck.failed`` / ``citecheck.cancelled``.\n\n``event_id`` is stable per (check, event): dedupe on it. Version\n``2026-05-13`` also sends ``task_id``, an internal delivery identity that\nis not pollable on ``/verify/status``.", "properties": { "event": { "enum": [ "citecheck.completed", - "citecheck.failed" + "citecheck.failed", + "citecheck.cancelled" ], "title": "Event", "type": "string" @@ -7836,7 +8274,8 @@ "status": { "enum": [ "completed", - "failed" + "failed", + "cancelled" ], "title": "Status", "type": "string" @@ -8222,7 +8661,7 @@ "description": "The signed warranty certificate of a covered verification, as issued. Its format and the offline check are at https://lenz.io/docs/coverage." }, "VerificationListItemOutLegacy": { - "description": "The ``2026-05-13`` list item: ``modified_at`` (set only when completed\non a later UTC day) where ``VerificationListItemOut`` has\n``completed_at``. Served by no route schema; it documents the shape\n``lenz.api.downgrade.verify`` restores for a legacy caller.", + "description": "The ``2026-05-13`` list item: ``modified_at`` (set only when completed\non a later UTC day) where ``VerificationListItemOut`` has\n``completed_at``. It is the shape a caller on that version receives.", "properties": { "verification_id": { "title": "Verification Id", @@ -8565,7 +9004,7 @@ "type": "object" }, "WebhookPayloadLegacy": { - "description": "The ``2026-05-13`` body of a verification event, sent for a submission\nmade in that version (K5): flat, no ``event_id``, the failure code in\n``error``. Produced by ``lenz.api.downgrade.webhooks`` from the canonical\n``WebhookPayload``; never used to build one (kept to describe the older\nversion's component).", + "description": "The ``2026-05-13`` body of a verification event, sent for a submission\nmade in that version: flat, no ``event_id``, the failure code in\n``error``. The canonical ``WebhookPayload`` carries the same facts in\nits own shape.", "properties": { "event": { "title": "Event", @@ -9508,17 +9947,16 @@ "type": "object" }, "StatusOutLegacy": { - "description": "The poll as `2026-05-13` sends it: a failed run carries `error`, `failure_reason` (`not_a_claim` where the current version says `no_checkable_claim`), `failure_class`, `retryable`, `docs_url` and `hint` at the top level instead of `failure`; `needs_input` options carry `text`; a result carries `modified_at`.", + "description": "The poll as `2026-05-13` sends it: a failed run carries `error`, `failure_reason` (`not_a_claim` where the current version says `no_checkable_claim`), `failure_class`, `retryable`, `docs_url` and `hint` at the top level instead of `failure`; `needs_input` options carry `text`; a result carries `modified_at`; a cancelled run is `failed` with `failure_class: \"cancelled\"`.", "properties": { "status": { + "type": "string", "enum": [ "processing", "needs_input", "completed", "failed" - ], - "title": "Status", - "type": "string" + ] }, "task_id": { "title": "Task Id", @@ -9619,6 +10057,35 @@ "title": "StatusOutLegacy", "type": "object" }, + "CancelOutLegacy": { + "description": "The cancel response as `2026-05-13` sends it: a cancelled run is `status: \"failed\"` (with `cancelled: true`), the word its poll says.", + "properties": { + "task_id": { + "title": "Task Id", + "type": "string" + }, + "cancelled": { + "title": "Cancelled", + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "processing", + "needs_input", + "completed", + "failed" + ] + } + }, + "required": [ + "task_id", + "cancelled", + "status" + ], + "title": "CancelOutLegacy", + "type": "object" + }, "VerificationNotReadyOutLegacy": { "description": "The 409 as `2026-05-13` sends it: `failure_reason`, `failure_class`, `retryable` and `docs_url` at the top level instead of `failure`.", "properties": { @@ -10300,226 +10767,222 @@ "type": "object", "description": "A review's deep check as `2026-05-13` sends it: `modified_at` (the completion time, set only when it falls on a later UTC day than `created_at`) instead of `completed_at`." }, - "ReviewWebhookPayloadLegacy": { - "description": "A `review.*` webhook as `2026-05-13` sends it: `task_id` (an internal delivery id, not pollable) beside `review_id`, and the review in its older shape.", + "ReviewFullOutLegacy": { "properties": { - "event": { - "enum": [ - "review.completed", - "review.failed" - ], - "title": "Event", - "type": "string" - }, - "event_id": { - "title": "Event Id", - "type": "string" - }, "review_id": { "title": "Review Id", "type": "string" }, "status": { + "type": "string", "enum": [ + "queued", + "assessing", + "verifying", "completed", "failed" + ] + }, + "outcome": { + "anyOf": [ + { + "enum": [ + "clean", + "issues_found", + "incomplete", + "unchecked" + ], + "type": "string" + }, + { + "type": "null" + } ], - "title": "Status", + "description": "Null until terminal. clean covers the selected claims at the policy's chosen depth. issues_found with an empty issues[] means the rows are in citation_issues[].", + "title": "Outcome" + }, + "created_at": { + "title": "Created At", "type": "string" }, - "review": { - "$ref": "#/components/schemas/ReviewFullOutLegacy" - }, - "attempt": { - "title": "Attempt", - "type": "integer" - }, - "delivered_at": { - "title": "Delivered At", - "type": "string" - }, - "task_id": { - "type": "string" - } - }, - "required": [ - "event", - "event_id", - "review_id", - "status", - "review", - "attempt", - "delivered_at" - ], - "title": "ReviewWebhookPayloadLegacy", - "type": "object" - }, - "CitecheckWebhookPayloadLegacy": { - "description": "A `citecheck.*` webhook as `2026-05-13` sends it: `task_id` (an internal delivery id, not pollable) beside `citecheck_id`, and the check in its older shape.", - "properties": { - "event": { - "enum": [ - "citecheck.completed", - "citecheck.failed" + "completed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "title": "Event", - "type": "string" - }, - "event_id": { - "title": "Event Id", - "type": "string" - }, - "citecheck_id": { - "title": "Citecheck Id", - "type": "string" + "title": "Completed At" }, - "status": { - "enum": [ - "completed", - "failed" - ], - "title": "Status", + "language": { + "title": "Language", "type": "string" }, - "citecheck": { - "$ref": "#/components/schemas/CitecheckOutLegacy" - }, - "attempt": { - "title": "Attempt", - "type": "integer" - }, - "delivered_at": { - "title": "Delivered At", - "type": "string" + "policy": { + "$ref": "#/components/schemas/ReviewPolicyOut" }, - "task_id": { - "type": "string" - } - }, - "required": [ - "event", - "event_id", - "citecheck_id", - "status", - "citecheck", - "attempt", - "delivered_at" - ], - "title": "CitecheckWebhookPayloadLegacy", - "type": "object" - }, - "MeUsageOutLegacy": { - "description": "`/me/usage` as `2026-05-13` sends it: the current body plus `quota_resets_at`, `credits.bonus` and the `verify` / `ask` / `assess` blocks, every one always present.", - "properties": { - "plan": { - "default": "free", - "title": "Plan", - "type": "string" + "summary": { + "$ref": "#/components/schemas/ReviewSummaryOutLegacy" }, - "plan_label": { - "default": "Free", - "title": "Plan Label", - "type": "string" + "credits": { + "$ref": "#/components/schemas/ReviewCreditsOut" }, - "quota_resets_at": { + "poll_after_seconds": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Quota Resets At" + "title": "Poll After Seconds" }, - "credits": { - "$ref": "#/components/schemas/MeUsageCreditsOut" + "issues": { + "items": { + "$ref": "#/components/schemas/ReviewIssueOutLegacy" + }, + "title": "Issues", + "type": "array" }, - "costs": { - "additionalProperties": { - "type": "integer" + "failures": { + "items": { + "$ref": "#/components/schemas/ReviewFailedRowOutLegacy" }, - "title": "Costs", - "type": "object" + "title": "Failures", + "type": "array" }, - "cost_options": { - "additionalProperties": { - "additionalProperties": { - "additionalProperties": { - "type": "integer" + "citation_issues": { + "default": [], + "description": "Citations whose source does not say what the draft says, the most serious first; partly_supported rows are not issues and are only in citations[]. [] when the check was not asked.", + "items": { + "$ref": "#/components/schemas/ReviewCitationIssueOutLegacy" + }, + "title": "Citation Issues", + "type": "array" + }, + "citation_failures": { + "default": [], + "items": { + "$ref": "#/components/schemas/ReviewCitationFailedRowOutLegacy" + }, + "title": "Citation Failures", + "type": "array" + }, + "more_claims": { + "anyOf": [ + { + "items": { + "type": "string" }, - "type": "object" + "type": "array" }, - "type": "object" - }, - "default": {}, - "title": "Cost Options", - "type": "object" + { + "type": "null" + } + ], + "description": "Claims found past max_assessments, in the draft's order: found but not checked; send them in a later request to check them. Null until the draft is read.", + "title": "More Claims" }, - "verify": { + "more_claim_locations": { "anyOf": [ { - "$ref": "#/components/schemas/MeUsageQuotaOut" + "items": { + "$ref": "#/components/schemas/ClaimLocationOut" + }, + "type": "array" }, { "type": "null" } - ] + ], + "description": "Where the draft makes each of `more_claims`, in the same order: `{claim, positions}`, the shape `/extract` returns with `locate: true` (see a claim row's `positions`). Null until the draft is read.", + "title": "More Claim Locations" }, - "ask": { + "more_citations": { "anyOf": [ { - "$ref": "#/components/schemas/MeUsageQuotaOut" + "items": { + "$ref": "#/components/schemas/ReviewMoreCitationOut" + }, + "type": "array" }, { "type": "null" } - ] + ], + "description": "Citations found past the ones selected (up to 100): found but not checked; send them in a later request to check them. Null until the draft is read.", + "title": "More Citations" }, - "assess": { + "failure": { "anyOf": [ { - "$ref": "#/components/schemas/MeUsageQuotaOut" + "$ref": "#/components/schemas/FailureOutLegacy" }, { "type": "null" } ] }, - "extract": { - "$ref": "#/components/schemas/MeUsageExtractOut" + "view": { + "const": "full", + "title": "View", + "type": "string" }, - "has_webhook_secret": { - "default": false, - "title": "Has Webhook Secret", - "type": "boolean" + "claims": { + "items": { + "$ref": "#/components/schemas/ReviewClaimOutLegacy" + }, + "title": "Claims", + "type": "array" + }, + "citations": { + "default": [], + "items": { + "$ref": "#/components/schemas/ReviewCitationOutLegacy" + }, + "title": "Citations", + "type": "array" } }, "required": [ + "review_id", + "status", + "outcome", + "created_at", + "completed_at", + "language", + "policy", + "summary", "credits", - "costs", - "extract" + "poll_after_seconds", + "issues", + "failures", + "failure", + "view", + "claims" ], - "title": "MeUsageOutLegacy", - "type": "object" + "title": "ReviewFullOutLegacy", + "type": "object", + "description": "A review as `2026-05-13` sends it: a cancelled review is `failed` with a `cancelled` failure." }, - "ReviewFullOutLegacy": { + "ReviewIssuesOutLegacy": { "properties": { "review_id": { "title": "Review Id", "type": "string" }, "status": { - "description": "verifying: deep checks or citation checks are running.", + "type": "string", "enum": [ "queued", "assessing", "verifying", "completed", "failed" - ], - "title": "Status", - "type": "string" + ] }, "outcome": { "anyOf": [ @@ -10665,46 +11128,370 @@ ] }, "view": { - "const": "full", + "const": "issues", "title": "View", "type": "string" - }, - "claims": { - "items": { - "$ref": "#/components/schemas/ReviewClaimOutLegacy" - }, - "title": "Claims", - "type": "array" - }, - "citations": { - "default": [], - "items": { + } + }, + "required": [ + "review_id", + "status", + "outcome", + "created_at", + "completed_at", + "language", + "policy", + "summary", + "credits", + "poll_after_seconds", + "issues", + "failures", + "failure", + "view" + ], + "title": "ReviewIssuesOutLegacy", + "type": "object", + "description": "A review as `2026-05-13` sends it: a cancelled review is `failed` with a `cancelled` failure." + }, + "CitecheckOutLegacy": { + "properties": { + "citecheck_id": { + "title": "Citecheck Id", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "checking", + "completed", + "failed" + ] + }, + "outcome": { + "anyOf": [ + { + "enum": [ + "clean", + "issues_found", + "incomplete", + "unchecked" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Null until terminal. incomplete: a citation could not be checked for a reason of ours.", + "title": "Outcome" + }, + "created_at": { + "title": "Created At", + "type": "string" + }, + "completed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completed At" + }, + "language": { + "description": "The language the reasoning is written in, as the review envelope echoes it.", + "title": "Language", + "type": "string" + }, + "poll_after_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Poll After Seconds" + }, + "policy": { + "$ref": "#/components/schemas/CitecheckPolicyOut" + }, + "summary": { + "$ref": "#/components/schemas/CitecheckSummaryOutLegacy" + }, + "credits": { + "$ref": "#/components/schemas/ReviewCreditsOut" + }, + "citations": { + "default": [], + "items": { "$ref": "#/components/schemas/ReviewCitationOutLegacy" }, "title": "Citations", "type": "array" + }, + "citation_issues": { + "default": [], + "description": "Citations whose source does not say what the draft says, the most serious first; partly_supported rows are not issues and are only in citations[].", + "items": { + "$ref": "#/components/schemas/ReviewCitationIssueOutLegacy" + }, + "title": "Citation Issues", + "type": "array" + }, + "citation_failures": { + "default": [], + "items": { + "$ref": "#/components/schemas/ReviewCitationFailedRowOutLegacy" + }, + "title": "Citation Failures", + "type": "array" + }, + "more_citations": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ReviewMoreCitationOut" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Citations found in the text past the ones selected (up to 100): found but not checked; send them in a later request to check them. Null until the text is read; [] for pairs.", + "title": "More Citations" + }, + "failure": { + "anyOf": [ + { + "$ref": "#/components/schemas/FailureOutLegacy" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "citecheck_id", + "status", + "outcome", + "created_at", + "completed_at", + "language", + "poll_after_seconds", + "policy", + "summary", + "credits", + "failure" + ], + "title": "CitecheckOutLegacy", + "type": "object", + "description": "A citation check as `2026-05-13` sends it: a cancelled check is `failed` with a `cancelled` failure." + }, + "ReviewWebhookPayloadLegacy": { + "description": "A `review.*` webhook as `2026-05-13` sends it: `task_id` (an internal delivery id, not pollable) beside `review_id`, and the review in its older shape; a cancelled review is `review.failed`.", + "properties": { + "event": { + "type": "string", + "enum": [ + "review.completed", + "review.failed" + ] + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "review_id": { + "title": "Review Id", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "completed", + "failed" + ] + }, + "review": { + "$ref": "#/components/schemas/ReviewFullOutLegacy" + }, + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "delivered_at": { + "title": "Delivered At", + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "event", + "event_id", + "review_id", + "status", + "review", + "attempt", + "delivered_at" + ], + "title": "ReviewWebhookPayloadLegacy", + "type": "object" + }, + "CitecheckWebhookPayloadLegacy": { + "description": "A `citecheck.*` webhook as `2026-05-13` sends it: `task_id` (an internal delivery id, not pollable) beside `citecheck_id`, and the check in its older shape; a cancelled check is `citecheck.failed`.", + "properties": { + "event": { + "type": "string", + "enum": [ + "citecheck.completed", + "citecheck.failed" + ] + }, + "event_id": { + "title": "Event Id", + "type": "string" + }, + "citecheck_id": { + "title": "Citecheck Id", + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "completed", + "failed" + ] + }, + "citecheck": { + "$ref": "#/components/schemas/CitecheckOutLegacy" + }, + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "delivered_at": { + "title": "Delivered At", + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "event", + "event_id", + "citecheck_id", + "status", + "citecheck", + "attempt", + "delivered_at" + ], + "title": "CitecheckWebhookPayloadLegacy", + "type": "object" + }, + "MeUsageOutLegacy": { + "description": "`/me/usage` as `2026-05-13` sends it: the current body plus `quota_resets_at`, `credits.bonus` and the `verify` / `ask` / `assess` blocks, every one always present.", + "properties": { + "plan": { + "default": "free", + "title": "Plan", + "type": "string" + }, + "plan_label": { + "default": "Free", + "title": "Plan Label", + "type": "string" + }, + "quota_resets_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Quota Resets At" + }, + "credits": { + "$ref": "#/components/schemas/MeUsageCreditsOut" + }, + "costs": { + "additionalProperties": { + "type": "integer" + }, + "title": "Costs", + "type": "object" + }, + "cost_options": { + "additionalProperties": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "type": "object" + }, + "default": {}, + "title": "Cost Options", + "type": "object" + }, + "verify": { + "anyOf": [ + { + "$ref": "#/components/schemas/MeUsageQuotaOut" + }, + { + "type": "null" + } + ] + }, + "ask": { + "anyOf": [ + { + "$ref": "#/components/schemas/MeUsageQuotaOut" + }, + { + "type": "null" + } + ] + }, + "assess": { + "anyOf": [ + { + "$ref": "#/components/schemas/MeUsageQuotaOut" + }, + { + "type": "null" + } + ] + }, + "extract": { + "$ref": "#/components/schemas/MeUsageExtractOut" + }, + "has_webhook_secret": { + "default": false, + "title": "Has Webhook Secret", + "type": "boolean" } }, "required": [ - "review_id", - "status", - "outcome", - "created_at", - "completed_at", - "language", - "policy", - "summary", "credits", - "poll_after_seconds", - "issues", - "failures", - "failure", - "view", - "claims" + "costs", + "extract" ], - "title": "ReviewFullOutLegacy", - "type": "object", - "description": "`ReviewFullOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains." + "title": "MeUsageOutLegacy", + "type": "object" }, "ReviewIssueOutLegacy": { "properties": { @@ -11291,247 +12078,19 @@ "index": { "title": "Index", "type": "integer" - }, - "reference": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reference" - }, - "cited_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Cited Url" - }, - "doi": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Doi" - }, - "statement": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Statement" - }, - "quotes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Quotes", - "type": "array" - }, - "position": { - "anyOf": [ - { - "$ref": "#/components/schemas/PositionOut" - }, - { - "type": "null" - } - ] - }, - "result": { - "anyOf": [ - { - "$ref": "#/components/schemas/ReviewCitationResultOut" - }, - { - "type": "null" - } - ] - }, - "check": { - "$ref": "#/components/schemas/ReviewCitationCheckOutLegacy" - } - }, - "required": [ - "index", - "reference", - "cited_url", - "doi", - "statement", - "position", - "result", - "check" - ], - "title": "ReviewCitationOutLegacy", - "type": "object", - "description": "`ReviewCitationOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains." - }, - "ReviewCitationCheckOutLegacy": { - "properties": { - "status": { - "enum": [ - "pending", - "running", - "completed", - "failed" - ], - "title": "Status", - "type": "string" - }, - "page_read": { - "anyOf": [ - { - "enum": [ - "full", - "partial", - "not_found", - "none" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "description": "What reading the source gave: full, partial (cut short, a teaser or an abstract alone), not_found, none.", - "title": "Page Read" - }, - "page_title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Page Title" - }, - "page_published_date": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Page Published Date" - }, - "page_language": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Page Language" - }, - "source_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Where the text was actually read from.", - "title": "Source Url" - }, - "source_version": { - "anyOf": [ - { - "enum": [ - "published", - "accepted", - "submitted" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Source Version" - }, - "support": { - "anyOf": [ - { - "enum": [ - "supported", - "partly_supported", - "contradicted", - "not_in_source", - "unchecked" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Support" - }, - "snippet": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "The passage from the source the support answer rests on, checked against the page text.", - "title": "Snippet" - }, - "rationale": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "The reviewer's sentence: reasoning, not a checked source.", - "title": "Rationale" - }, - "quote": { - "anyOf": [ - { - "enum": [ - "matched", - "not_in_source", - "unchecked" - ], + }, + "reference": { + "anyOf": [ + { "type": "string" }, { "type": "null" } ], - "title": "Quote" + "title": "Reference" }, - "missing_quote": { + "cited_url": { "anyOf": [ { "type": "string" @@ -11540,119 +12099,96 @@ "type": "null" } ], - "description": "The first of quotes the source does not contain; null unless quote is not_in_source.", - "title": "Missing Quote" + "title": "Cited Url" }, - "doi_registered": { + "doi": { "anyOf": [ { - "type": "boolean" + "type": "string" }, { "type": "null" } ], - "title": "Doi Registered" + "title": "Doi" }, - "metadata": { + "statement": { "anyOf": [ { - "enum": [ - "consistent", - "mismatch", - "unchecked" - ], "type": "string" }, { "type": "null" } ], - "title": "Metadata" + "title": "Statement" }, - "metadata_differences": { + "quotes": { "default": [], "items": { - "$ref": "#/components/schemas/ReviewMetadataDifferenceOut" + "type": "string" }, - "title": "Metadata Differences", + "title": "Quotes", "type": "array" }, - "registered": { + "position": { "anyOf": [ { - "$ref": "#/components/schemas/ReviewRegisteredOut" + "$ref": "#/components/schemas/PositionOut" }, { "type": "null" } ] }, - "unchecked_reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Why the finding is unchecked; an extensible string.", - "title": "Unchecked Reason" - }, - "hint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Hint" - }, - "failure": { + "result": { "anyOf": [ { - "$ref": "#/components/schemas/FailureOutLegacy" + "$ref": "#/components/schemas/ReviewCitationResultOut" }, { "type": "null" } ] + }, + "check": { + "$ref": "#/components/schemas/ReviewCitationCheckOutLegacy" } }, "required": [ - "status" + "index", + "reference", + "cited_url", + "doi", + "statement", + "position", + "result", + "check" ], - "title": "ReviewCitationCheckOutLegacy", + "title": "ReviewCitationOutLegacy", "type": "object", - "description": "`ReviewCitationCheckOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains." + "description": "`ReviewCitationOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains." }, - "CitecheckOutLegacy": { + "ReviewCitationCheckOutLegacy": { "properties": { - "citecheck_id": { - "title": "Citecheck Id", - "type": "string" - }, "status": { "enum": [ - "queued", - "checking", + "pending", + "running", "completed", "failed" ], "title": "Status", "type": "string" }, - "outcome": { + "page_read": { "anyOf": [ { "enum": [ - "clean", - "issues_found", - "incomplete", - "unchecked" + "full", + "partial", + "not_found", + "none" ], "type": "string" }, @@ -11660,14 +12196,10 @@ "type": "null" } ], - "description": "Null until terminal. incomplete: a citation could not be checked for a reason of ours.", - "title": "Outcome" - }, - "created_at": { - "title": "Created At", - "type": "string" + "description": "What reading the source gave: full, partial (cut short, a teaser or an abstract alone), not_found, none.", + "title": "Page Read" }, - "completed_at": { + "page_title": { "anyOf": [ { "type": "string" @@ -11676,150 +12208,106 @@ "type": "null" } ], - "title": "Completed At" - }, - "language": { - "description": "The language the reasoning is written in, as the review envelope echoes it.", - "title": "Language", - "type": "string" + "title": "Page Title" }, - "poll_after_seconds": { + "page_published_date": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Poll After Seconds" - }, - "policy": { - "$ref": "#/components/schemas/CitecheckPolicyOut" - }, - "summary": { - "$ref": "#/components/schemas/CitecheckSummaryOutLegacy" - }, - "credits": { - "$ref": "#/components/schemas/ReviewCreditsOut" - }, - "citations": { - "default": [], - "items": { - "$ref": "#/components/schemas/ReviewCitationOutLegacy" - }, - "title": "Citations", - "type": "array" - }, - "citation_issues": { - "default": [], - "description": "Citations whose source does not say what the draft says, the most serious first; partly_supported rows are not issues and are only in citations[].", - "items": { - "$ref": "#/components/schemas/ReviewCitationIssueOutLegacy" - }, - "title": "Citation Issues", - "type": "array" + "title": "Page Published Date" }, - "citation_failures": { - "default": [], - "items": { - "$ref": "#/components/schemas/ReviewCitationFailedRowOutLegacy" - }, - "title": "Citation Failures", - "type": "array" + "page_language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Page Language" }, - "more_citations": { + "source_url": { "anyOf": [ { - "items": { - "$ref": "#/components/schemas/ReviewMoreCitationOut" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], - "description": "Citations found in the text past the ones selected (up to 100): found but not checked; send them in a later request to check them. Null until the text is read; [] for pairs.", - "title": "More Citations" + "description": "Where the text was actually read from.", + "title": "Source Url" }, - "failure": { + "source_version": { "anyOf": [ { - "$ref": "#/components/schemas/FailureOutLegacy" + "enum": [ + "published", + "accepted", + "submitted" + ], + "type": "string" }, { "type": "null" } - ] - } - }, - "required": [ - "citecheck_id", - "status", - "outcome", - "created_at", - "completed_at", - "language", - "poll_after_seconds", - "policy", - "summary", - "credits", - "failure" - ], - "title": "CitecheckOutLegacy", - "type": "object", - "description": "`CitecheckOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains." - }, - "BatchAcceptedOutLegacy": { - "title": "BatchAcceptedOutLegacy", - "type": "object", - "description": "`BatchAcceptedOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains.", - "properties": { - "batch_id": { - "type": "string" + ], + "title": "Source Version" }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BatchAcceptedItemOutLegacy" - } + "support": { + "anyOf": [ + { + "enum": [ + "supported", + "partly_supported", + "contradicted", + "not_in_source", + "unchecked" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Support" }, - "partial": { - "type": "boolean", - "description": "True when only some of the claims were accepted (the balance covered the first ones)." - } - }, - "required": [ - "batch_id", - "items" - ] - }, - "ReviewIssuesOutLegacy": { - "properties": { - "review_id": { - "title": "Review Id", - "type": "string" + "snippet": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The passage from the source the support answer rests on, checked against the page text.", + "title": "Snippet" }, - "status": { - "description": "verifying: deep checks or citation checks are running.", - "enum": [ - "queued", - "assessing", - "verifying", - "completed", - "failed" + "rationale": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "title": "Status", - "type": "string" + "description": "The reviewer's sentence: reasoning, not a checked source.", + "title": "Rationale" }, - "outcome": { + "quote": { "anyOf": [ { "enum": [ - "clean", - "issues_found", - "incomplete", + "matched", + "not_in_source", "unchecked" ], "type": "string" @@ -11828,14 +12316,9 @@ "type": "null" } ], - "description": "Null until terminal. clean covers the selected claims at the policy's chosen depth. issues_found with an empty issues[] means the rows are in citation_issues[].", - "title": "Outcome" - }, - "created_at": { - "title": "Created At", - "type": "string" + "title": "Quote" }, - "completed_at": { + "missing_quote": { "anyOf": [ { "type": "string" @@ -11844,107 +12327,76 @@ "type": "null" } ], - "title": "Completed At" - }, - "language": { - "title": "Language", - "type": "string" - }, - "policy": { - "$ref": "#/components/schemas/ReviewPolicyOut" - }, - "summary": { - "$ref": "#/components/schemas/ReviewSummaryOutLegacy" - }, - "credits": { - "$ref": "#/components/schemas/ReviewCreditsOut" + "description": "The first of quotes the source does not contain; null unless quote is not_in_source.", + "title": "Missing Quote" }, - "poll_after_seconds": { + "doi_registered": { "anyOf": [ { - "type": "integer" + "type": "boolean" }, { "type": "null" } ], - "title": "Poll After Seconds" - }, - "issues": { - "items": { - "$ref": "#/components/schemas/ReviewIssueOutLegacy" - }, - "title": "Issues", - "type": "array" - }, - "failures": { - "items": { - "$ref": "#/components/schemas/ReviewFailedRowOutLegacy" - }, - "title": "Failures", - "type": "array" + "title": "Doi Registered" }, - "citation_issues": { - "default": [], - "description": "Citations whose source does not say what the draft says, the most serious first; partly_supported rows are not issues and are only in citations[]. [] when the check was not asked.", - "items": { - "$ref": "#/components/schemas/ReviewCitationIssueOutLegacy" - }, - "title": "Citation Issues", - "type": "array" + "metadata": { + "anyOf": [ + { + "enum": [ + "consistent", + "mismatch", + "unchecked" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metadata" }, - "citation_failures": { + "metadata_differences": { "default": [], "items": { - "$ref": "#/components/schemas/ReviewCitationFailedRowOutLegacy" + "$ref": "#/components/schemas/ReviewMetadataDifferenceOut" }, - "title": "Citation Failures", + "title": "Metadata Differences", "type": "array" }, - "more_claims": { + "registered": { "anyOf": [ { - "items": { - "type": "string" - }, - "type": "array" + "$ref": "#/components/schemas/ReviewRegisteredOut" }, { "type": "null" } - ], - "description": "Claims found past max_assessments, in the draft's order: found but not checked; send them in a later request to check them. Null until the draft is read.", - "title": "More Claims" + ] }, - "more_claim_locations": { + "unchecked_reason": { "anyOf": [ { - "items": { - "$ref": "#/components/schemas/ClaimLocationOut" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], - "description": "Where the draft makes each of `more_claims`, in the same order: `{claim, positions}`, the shape `/extract` returns with `locate: true` (see a claim row's `positions`). Null until the draft is read.", - "title": "More Claim Locations" + "description": "Why the finding is unchecked; an extensible string.", + "title": "Unchecked Reason" }, - "more_citations": { + "hint": { "anyOf": [ { - "items": { - "$ref": "#/components/schemas/ReviewMoreCitationOut" - }, - "type": "array" + "type": "string" }, { "type": "null" } ], - "description": "Citations found past the ones selected (up to 100): found but not checked; send them in a later request to check them. Null until the draft is read.", - "title": "More Citations" + "title": "Hint" }, "failure": { "anyOf": [ @@ -11955,32 +12407,38 @@ "type": "null" } ] - }, - "view": { - "const": "issues", - "title": "View", - "type": "string" } }, "required": [ - "review_id", - "status", - "outcome", - "created_at", - "completed_at", - "language", - "policy", - "summary", - "credits", - "poll_after_seconds", - "issues", - "failures", - "failure", - "view" + "status" ], - "title": "ReviewIssuesOutLegacy", + "title": "ReviewCitationCheckOutLegacy", + "type": "object", + "description": "`ReviewCitationCheckOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains." + }, + "BatchAcceptedOutLegacy": { + "title": "BatchAcceptedOutLegacy", "type": "object", - "description": "`ReviewIssuesOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains." + "description": "`BatchAcceptedOut` as `2026-05-13` sends it: the same fields, with the older shape of what it contains.", + "properties": { + "batch_id": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchAcceptedItemOutLegacy" + } + }, + "partial": { + "type": "boolean", + "description": "True when only some of the claims were accepted (the balance covered the first ones)." + } + }, + "required": [ + "batch_id", + "items" + ] } }, "securitySchemes": { @@ -12000,7 +12458,7 @@ "2026-05-13" ] }, - "description": "Read, but every request is answered in `2026-05-13` for now." + "description": "The API version to answer in, as a date. A date before `2026-10-11` (or a value that is not a date) gets `2026-05-13`. See https://lenz.io/docs/api-versions." } }, "headers": { @@ -12070,10 +12528,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookPayloadLegacy" + "$ref": "#/components/schemas/WebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/WebhookPayload" }, "2026-05-13": { @@ -12099,10 +12557,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookPayloadLegacy" + "$ref": "#/components/schemas/WebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/WebhookPayload" }, "2026-05-13": { @@ -12128,10 +12586,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookPayloadLegacy" + "$ref": "#/components/schemas/WebhookPayload" + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/WebhookPayload" + }, + "2026-05-13": { + "$ref": "#/components/schemas/WebhookPayloadLegacy" + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Your endpoint should return 2xx within 5 seconds. Non-2xx triggers retries at 10s / 60s / 600s (3 retries after the initial delivery, 4 attempts total)." + } + } + } + }, + "verification.cancelled": { + "post": { + "summary": "verification.cancelled", + "description": "Fires when POST /verify/{task_id}/cancel stopped the run: nothing was saved or charged. A 2026-05-13 submission receives verification.failed with failure_class \"cancelled\" instead.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/WebhookPayload" }, "2026-05-13": { @@ -12157,10 +12644,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookPayloadLegacy" + "$ref": "#/components/schemas/WebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/WebhookPayload" }, "2026-05-13": { @@ -12186,10 +12673,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewWebhookPayloadLegacy" + "$ref": "#/components/schemas/ReviewWebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewWebhookPayload" }, "2026-05-13": { @@ -12215,10 +12702,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewWebhookPayloadLegacy" + "$ref": "#/components/schemas/ReviewWebhookPayload" + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/ReviewWebhookPayload" + }, + "2026-05-13": { + "$ref": "#/components/schemas/ReviewWebhookPayloadLegacy" + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Your endpoint should return 2xx within 5 seconds. Non-2xx triggers retries at 10s / 60s / 600s (3 retries after the initial delivery, 4 attempts total)." + } + } + } + }, + "review.cancelled": { + "post": { + "summary": "review.cancelled", + "description": "Fires once when POST /reviews/{review_id}/cancel stopped the review. What was not delivered was not charged. A 2026-05-13 submission receives review.failed instead.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewWebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/ReviewWebhookPayload" }, "2026-05-13": { @@ -12244,10 +12760,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckWebhookPayloadLegacy" + "$ref": "#/components/schemas/CitecheckWebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckWebhookPayload" }, "2026-05-13": { @@ -12273,10 +12789,39 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CitecheckWebhookPayloadLegacy" + "$ref": "#/components/schemas/CitecheckWebhookPayload" + }, + "x-lenz-versions": { + "2026-10-11": { + "$ref": "#/components/schemas/CitecheckWebhookPayload" + }, + "2026-05-13": { + "$ref": "#/components/schemas/CitecheckWebhookPayloadLegacy" + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Your endpoint should return 2xx within 5 seconds. Non-2xx triggers retries at 10s / 60s / 600s (3 retries after the initial delivery, 4 attempts total)." + } + } + } + }, + "citecheck.cancelled": { + "post": { + "summary": "citecheck.cancelled", + "description": "Fires once when POST /citechecks/{citecheck_id}/cancel stopped the check. Citations not checked were not charged. A 2026-05-13 submission receives citecheck.failed instead.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CitecheckWebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/CitecheckWebhookPayload" }, "2026-05-13": { @@ -12302,10 +12847,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookPayloadLegacy" + "$ref": "#/components/schemas/WebhookPayload" }, "x-lenz-versions": { - "next": { + "2026-10-11": { "$ref": "#/components/schemas/WebhookPayload" }, "2026-05-13": { diff --git a/pyproject.toml b/pyproject.toml index 0249ca7..2c011f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ dependencies = [ # unknown Field kwarg is swallowed into json_schema_extra with a warning of # its own and the deprecation never fires, which is worse than the bump. "pydantic>=2.7", + # `@deprecated(..., category=None)` marks the deprecated property aliases for + # editors and type checkers without a runtime warning. Pydantic needs it too. + "typing_extensions>=4.5", ] [project.optional-dependencies] diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index b93799c..64f3caf 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -9,7 +9,7 @@ # Runs (~10s total on a warm cache): # 1. ruff check — lint # 2. ruff format — formatting -# 3. mypy — strict type-check on src/lenz_io +# 3. mypy — strict type-check on src/lenz_io and examples/ # 4. pytest — unit suite (skips live-API smoke; those run in CI / on release) # # Skip on a specific commit with `git commit --no-verify`. Don't habituate. @@ -43,6 +43,7 @@ done_ok "ruff format" step "mypy (strict)" uv run mypy src/lenz_io || fail "mypy" +uv run mypy examples || fail "mypy (examples)" done_ok "mypy" step "pytest (unit; skips live-API smoke)" diff --git a/src/lenz_io/__init__.py b/src/lenz_io/__init__.py index 3fe51ec..8703e84 100644 --- a/src/lenz_io/__init__.py +++ b/src/lenz_io/__init__.py @@ -2,9 +2,16 @@ pip install lenz-io -The fact-check API for AI products. Four primitives form a research-depth -ladder — find claims, judge them fast, prove them deep, follow up — and a -fifth call runs the ladder on a whole draft: +The fact-check API for AI products, in six calls: four form a research-depth +ladder (find claims, judge them fast, prove them deep, follow up), ``review`` +runs the ladder on a whole draft, and ``citecheck`` checks a draft's sources +on its own: + + from lenz_io import Lenz + client = Lenz() # reads LENZ_API_KEY + + row = client.assess(claim="The Great Wall of China is visible from space.").claims[0] + print(row.verdict, row.confidence) # /review — every claim quick-checked, the doubtful ones deep-checked (2-4 min) review = client.review_and_wait(text=draft) @@ -30,9 +37,10 @@ doubtful = [{"claim": c.claim} for c in quick if c.status == "completed" and c.confidence == "low"][:20] results = client.verify_batch_and_wait(claims=doubtful) if doubtful else [] - # 4. /ask — follow-up questions grounded on a verification - deep = results[0].verification - reply = client.ask.send(deep.verification_id, message="Which source is strongest?") + # 4. /ask — follow-up questions grounded on a verification (when one was escalated) + deep = next((r.verification for r in results if r.verification is not None), None) + if deep is not None: + reply = client.ask.send(deep.verification_id, message="Which source is strongest?") See https://lenz.io/api/v1/docs/ for the full API reference. """ @@ -46,26 +54,34 @@ __version__ = "0.0.0+local" # Public surface -from .client import API_VERSION, DEFAULT_BASE_URL, CitationPair, Lenz, VerifyBatchItem +from .client import API_VERSION, DEFAULT_BASE_URL, NOT_GIVEN, CitationPair, Lenz, NotGiven, VerifyBatchItem from .errors import ( MAX_RETRY_AFTER_SLEEP, CitecheckFailed, + CitecheckFailedError, CitecheckTimeout, + CitecheckTimeoutError, LenzAPIError, + LenzApiVersionError, LenzAuthError, + LenzConnectionError, LenzError, LenzGoneError, LenzNeedsInputError, + LenzNotFoundError, LenzPipelineError, LenzQuotaExceededError, LenzRateLimitError, + LenzRequestTimeoutError, LenzTimeoutError, LenzUpstreamUnavailableError, LenzValidationError, LenzVerificationNotReadyError, LenzWebhookSignatureError, ReviewFailed, + ReviewFailedError, ReviewTimeout, + ReviewTimeoutError, ) from .models import ( AskHistory, @@ -78,15 +94,18 @@ Audit, BatchAccepted, BatchItemResult, + CancelResult, CandidateClaim, Certificate, Citecheck, CitecheckStarted, ClaimLocation, + Confidence, Coverage, CoverageReason, CoverageStatus, DebateSide, + Depth, EntityRef, Escalation, EscalationPolicy, @@ -120,6 +139,8 @@ UsageCapacity, UsageCredits, UsageExtract, + Verdict, + VerdictLabel, Verification, VerificationList, VerificationListItem, @@ -129,6 +150,7 @@ CitecheckEvent, LenzWebhooks, ReviewEvent, + VerificationCancelled, VerificationCompleted, VerificationFailed, VerificationNeedsInput, @@ -141,6 +163,7 @@ "API_VERSION", "DEFAULT_BASE_URL", "MAX_RETRY_AFTER_SLEEP", + "NOT_GIVEN", "AskHistory", "AskMessage", "AskReply", @@ -151,6 +174,7 @@ "Audit", "BatchAccepted", "BatchItemResult", + "CancelResult", "CandidateClaim", "Certificate", "CertificateTimestamped", @@ -158,13 +182,17 @@ "Citecheck", "CitecheckEvent", "CitecheckFailed", + "CitecheckFailedError", "CitecheckStarted", "CitecheckTimeout", + "CitecheckTimeoutError", "ClaimLocation", + "Confidence", "Coverage", "CoverageReason", "CoverageStatus", "DebateSide", + "Depth", "EntityRef", "Escalation", "EscalationPolicy", @@ -176,13 +204,17 @@ "FailureClass", "Lenz", "LenzAPIError", + "LenzApiVersionError", "LenzAuthError", + "LenzConnectionError", "LenzError", "LenzGoneError", "LenzNeedsInputError", + "LenzNotFoundError", "LenzPipelineError", "LenzQuotaExceededError", "LenzRateLimitError", + "LenzRequestTimeoutError", "LenzTimeoutError", "LenzUpstreamUnavailableError", "LenzValidationError", @@ -191,6 +223,7 @@ "LenzWebhooks", "LibraryItem", "LibraryList", + "NotGiven", "Position", "Progress", "RelatedVerifications", @@ -200,12 +233,14 @@ "ReviewClaim", "ReviewEvent", "ReviewFailed", + "ReviewFailedError", "ReviewFailure", "ReviewFull", "ReviewIssue", "ReviewIssues", "ReviewStarted", "ReviewTimeout", + "ReviewTimeoutError", "SimilarVerification", "Source", "SuggestedEdit", @@ -216,7 +251,10 @@ "UsageCapacity", "UsageCredits", "UsageExtract", + "Verdict", + "VerdictLabel", "Verification", + "VerificationCancelled", "VerificationCompleted", "VerificationFailed", "VerificationList", diff --git a/src/lenz_io/cli/citecheck.py b/src/lenz_io/cli/citecheck.py index 07e7f22..71c63d3 100644 --- a/src/lenz_io/cli/citecheck.py +++ b/src/lenz_io/cli/citecheck.py @@ -236,8 +236,10 @@ def render_citecheck(out: Output, check: Citecheck) -> None: f"Citation check {escape(check.citecheck_id)}: {outcome} — {_count(check.credits.charged, 'credit')} charged" ) if check.failure is not None: - hint = check.failure.hint or check.failure.failure_reason or "no reason given" + hint = check.failure.hint or check.failure.code or "no reason given" out.console.print(f"[red]Failed:[/red] {escape(hint)}") + elif check.status == "cancelled": + out.console.print("[red]Cancelled.[/red]") render_citations(out, check) if check.status == "completed" and not check.citation_issues and not check.citation_failures: out.console.print("\n[green]No issues.[/green]") diff --git a/src/lenz_io/cli/commands.py b/src/lenz_io/cli/commands.py index 6f4b109..095c360 100644 --- a/src/lenz_io/cli/commands.py +++ b/src/lenz_io/cli/commands.py @@ -12,7 +12,7 @@ import typer from lenz_io import Lenz -from lenz_io.errors import LenzError, LenzVerificationNotReadyError +from lenz_io.errors import LenzApiVersionError, LenzError, LenzVerificationNotReadyError from ._run import execute, read_text_arg from .config import ENV_API_KEY, clear_api_key, config_path, mask_key, save_api_key @@ -100,7 +100,7 @@ def work(client: Lenz) -> None: with out.working("Thinking…"): reply = client.ask.send(verification_id, message=message) except LenzError as exc: - if exc.status_code == 404: + if exc.status_code == 404 and not isinstance(exc, LenzApiVersionError): raise CLIError( f"No verification found for id {verification_id!r}. " "A verification_id is the 8-character id printed by `lenz verify` " diff --git a/src/lenz_io/cli/render.py b/src/lenz_io/cli/render.py index 80e7e05..ee7c803 100644 --- a/src/lenz_io/cli/render.py +++ b/src/lenz_io/cli/render.py @@ -12,7 +12,7 @@ import json import sys from datetime import datetime, timezone -from typing import Any, NoReturn +from typing import Any, NamedTuple, NoReturn from rich.console import Console from rich.markdown import Markdown @@ -26,7 +26,6 @@ Source, TaskStatus, Usage, - UsageCapacity, Verification, ) @@ -109,15 +108,11 @@ def render_extract(out: Output, result: ExtractedClaims) -> None: if out.json_mode: out.emit_json(_model_json(result)) return - # The server splits a claim set across two fields: the primary claim lands - # in ``claim`` and any extras in ``identified_claims``. Neither alone is the - # full list — the primary is usually NOT echoed into ``identified_claims`` — - # so render the union so the primary is never dropped (and the count is - # right). Single-claim input → just ``claim``. - primary = (getattr(result, "claim", "") or "").strip() - claims = [primary] if primary else [] - for c in result.identified_claims or []: - c = (c or "").strip() + # ``claims`` is the whole list, most check-worthy first (one entry for one + # claim); blank and repeated entries are skipped. + claims: list[str] = [] + for found in result.claims: + c = (found.claim or "").strip() if c and c not in claims: claims.append(c) if len(claims) > 1: @@ -151,7 +146,7 @@ def _render_positions(out: Output, result: ExtractedClaims, claim: str, *, inden input, where there is nothing to index) and the passage itself. The passage is the user's own text, so it prints with ``markup=False``. """ - for loc in result.locations or []: + for loc in result.claims: if (loc.claim or "").strip() != claim: continue for pos in loc.positions or []: @@ -173,7 +168,7 @@ def render_assess(out: Output, result: AssessResponse) -> None: color = _VERDICT_COLOR.get(c.verdict, "white") # An Error row (list form) names its cause in place of the confidence, # which is meaningless there. - detail = c.error_code if c.verdict == "Error" and c.error_code else c.confidence + detail = c.error_code if c.status == "failed" and c.error_code else c.confidence out.console.print(f"[{color}]{c.verdict or '?'}[/{color}] ({detail}) — {c.claim}") # The reviewers' notes, as plain text: `markup=False` because the # words are a model's, and a stray "[bold]" in them is not ours. @@ -181,9 +176,9 @@ def render_assess(out: Output, result: AssessResponse) -> None: out.console.print(f" {c.rationale}", markup=False, highlight=False) if c.dissent: out.console.print(f" One reviewer disagreed: {c.dissent}", markup=False, highlight=False) - if c.identified_claims: + if c.more_claims: out.console.print(" [dim]also found:[/dim]") - for other in c.identified_claims: + for other in c.more_claims: out.console.print(f" • {other}") if c.hint: out.console.print(f" [dim]{c.hint}[/dim]") @@ -395,12 +390,14 @@ def render_task_status(out: Output, st: TaskStatus, *, task_id: str = "") -> Non if st.claims: out.console.print("[dim]claims found:[/dim]") for i, claim in enumerate(st.claims, 1): - out.console.print(f" {i}. {claim.text}") + out.console.print(f" {i}. {claim.claim}") ref = task_id or "" # Non-interactive resolution (agents/scripts): `--claim` picks by index # and `--detach` returns the spawned task_id(s) without blocking. Drop # both flags for the interactive picker. out.console.print(f"[dim]resolve it:[/dim] lenz verify --resume {ref} --claim --detach") + elif state == "cancelled": + out.console.print("[red]cancelled[/red]") elif state == "failed": err = st.error or st.failure_detail or st.failure_reason or "Verification failed." out.console.print(f"[red]failed[/red] [dim]— {err}[/dim]") @@ -433,6 +430,8 @@ def _batch_status_cell(st: Any) -> Any: return Text(f"{v.verdict or '?'} ({v.confidence}){score}", style=f"bold {color}") if st.status == "failed": return Text("failed", style="red") + if st.status == "cancelled": + return Text("cancelled", style="red") return Text(st.status or "?", style="yellow") @@ -486,6 +485,8 @@ def render_batch_details(out: Output, picks: list[tuple[str, str]], statuses: di st = statuses.get(tid) if st is not None and st.status == "completed" and st.result is not None: _batch_verdict_block(out, st.result) + elif st is not None and st.status == "cancelled": + out.console.print("[red]Cancelled.[/red]") elif st is not None and st.status == "failed": out.console.print(f"[red]Failed:[/red] {st.error or st.failure_detail or 'pipeline error'}") else: @@ -510,7 +511,7 @@ def render_ask(out: Output, reply: Any) -> None: def _capacity_row( out: Output, label: str, - cap: UsageCapacity, + cap: _Projection, cost: int | None = None, *, price_note: str = "", @@ -527,9 +528,9 @@ def _capacity_row( getting a row of its own because it is a PRICE, not a capability — there is no separate low-depth allowance, and printing one would imply a second balance.""" - detail = f"{cap.quota_used} / {cap.quota_total} quota" - if cap.bonus: - detail += f" + {cap.bonus} extra" + detail = f"{cap.used} / {cap.total} quota" + if cap.extra: + detail += f" + {cap.extra} extra" if cost: detail += f" · {_count(cost, 'credit')} each" if price_note: @@ -594,16 +595,16 @@ def render_usage(out: Output, u: Usage) -> None: assessments = _count(_equivalent(u, "assess"), "assessment") equivalents = f"≈ {verifications} · {assessments}" out.console.print(f" [bold]{u.credits.remaining} credits left[/bold] [dim]({equivalents})[/dim]") - _capacity_row(out, "Verify", u.verify, u.costs.get("verify"), price_note=_low_depth_note(u)) - _capacity_row(out, "Ask", u.ask, u.costs.get("ask")) - _capacity_row(out, "Assess", u.assess, u.costs.get("assess")) + _capacity_row(out, "Verify", _projection(u, "verify"), u.costs.get("verify"), price_note=_low_depth_note(u)) + _capacity_row(out, "Ask", _projection(u, "ask"), u.costs.get("ask")) + _capacity_row(out, "Assess", _projection(u, "assess"), u.costs.get("assess")) ex = u.extract label = f"{'Extract:':<9}" if ex.unlimited: out.console.print(f" {label} [dim]unlimited[/dim]") else: out.console.print(f" {label} {ex.calls_today} / {ex.daily_limit} today [dim](free — no credit charge)[/dim]") - resets_at = u.credits.resets_at or u.quota_resets_at + resets_at = u.credits.resets_at if resets_at: out.console.print(f" [dim]Credits reset {_humanize_reset(resets_at)}[/dim]") @@ -631,17 +632,29 @@ def _has_credit_pool(u: Usage) -> bool: return bool(c.total or c.remaining or c.used or c.extra or u.costs) +class _Projection(NamedTuple): + """The credit pool in one capability's unit (calls it would buy).""" + + used: int + total: int + remaining: int + extra: int + + +#: The price each capability's row is projected at when the price list leaves it out. +_DEFAULT_COSTS = {"verify": 10, "ask": 1, "assess": 1} + + +def _projection(u: Usage, capability: str) -> _Projection: + """``u.credits`` divided by the capability's price (``u.costs``).""" + cost = u.costs.get(capability) or _DEFAULT_COSTS.get(capability, 1) + total, left = u.credits.total // cost, u.credits.remaining // cost + return _Projection(max(0, total - left), total, left, u.credits.extra // cost) + + def _equivalent(u: Usage, capability: str) -> int: - """How many ``capability`` calls the remaining balance buys. - - The server already projects this per capability, so use its number; derive - from the price list only when the block is empty (a capability the server - reports a cost for but no projection — a new endpoint, say).""" - cap: UsageCapacity = getattr(u, capability, None) or UsageCapacity() - if cap.remaining or cap.quota_total: - return cap.remaining - cost = u.costs.get(capability) or 0 - return u.credits.remaining // cost if cost > 0 else 0 + """How many ``capability`` calls the remaining balance buys.""" + return _projection(u, capability).remaining def render_config(out: Output, payload: dict[str, Any]) -> None: diff --git a/src/lenz_io/cli/review.py b/src/lenz_io/cli/review.py index b605568..a616595 100644 --- a/src/lenz_io/cli/review.py +++ b/src/lenz_io/cli/review.py @@ -274,7 +274,7 @@ def _progress_cell(c: ReviewClaim) -> Any: if a.status in ("pending", "running", ""): return Spinner("dots", text=Text("quick check…", style="dim")) if a.status == "failed": - return Text(f"failed ({a.error_code or 'error'})", style="red") + return Text(f"failed ({(a.failure.code if a.failure else None) or 'error'})", style="red") quick = Text.from_markup(_verdict_text(a.verdict, a.confidence)) if v is None: return quick @@ -385,7 +385,7 @@ def _render_claim(out: Output, c: ReviewClaim, n: int) -> None: out.console.print(f"[bold]\\[{c.index + 1}/{n}][/bold] {escape(c.claim or '')}{_position_suffix(c)}") a, v = c.assessment, c.verification if a.status == "failed": - hint = (a.failure.hint if a.failure else None) or a.hint or a.error_code or "failed" + hint = (a.failure.hint if a.failure else None) or (a.failure.code if a.failure else None) or "failed" out.console.print(f" [red]Quick check failed:[/red] {escape(hint)}") return if v is not None and v.status == "completed": @@ -418,9 +418,7 @@ def _render_issue(out: Output, i: Any, n: int) -> None: if i.suggested_rewrite: out.console.print(f" [dim]Suggested rewrite:[/dim] {escape(i.suggested_rewrite)}") if i.failure is not None: - out.console.print( - f" [red]Deep check failed:[/red] {escape(i.failure.hint or i.failure.failure_reason or 'failed')}" - ) + out.console.print(f" [red]Deep check failed:[/red] {escape(i.failure.hint or i.failure.code or 'failed')}") elif i.source != "verification" and i.escalation is not None and i.escalation.disposition != "planned": out.console.print(f" [dim]Not deep-checked ({escape(i.escalation.disposition)}).[/dim]") if i.url: @@ -433,8 +431,10 @@ def render_review(out: Output, review: ReviewFull, *, issues_only: bool = False) or the reviewers' note (quick), and the suggested rewrite.""" out.console.print(_summary_line(review)) if review.failure is not None: - hint = review.failure.hint or review.failure.failure_reason or "no reason given" + hint = review.failure.hint or review.failure.code or "no reason given" out.console.print(f"[red]Failed:[/red] {escape(hint)}") + elif review.status == "cancelled": + out.console.print("[red]Cancelled.[/red]") n = review.summary.claims_selected or len(review.claims) if issues_only: for i in review.issues: @@ -621,7 +621,7 @@ def _render_citation_issue(out: Output, i: ReviewCitationIssue, total: int) -> N for d in i.metadata_differences: _print_text(out, f"Your reference says {d.cited or '(none)'}; the record says {d.registered or '(none)'}.") if i.failure is not None: - hint = i.failure.hint or i.failure.failure_reason or "failed" + hint = i.failure.hint or i.failure.code or "failed" out.console.print(f" [yellow]Part of the check failed:[/yellow] {escape(hint)}") diff --git a/src/lenz_io/cli/verify.py b/src/lenz_io/cli/verify.py index 8373375..aa613f7 100644 --- a/src/lenz_io/cli/verify.py +++ b/src/lenz_io/cli/verify.py @@ -27,7 +27,7 @@ import typer from lenz_io import Lenz -from lenz_io.errors import LenzError, LenzGoneError +from lenz_io.errors import LenzApiVersionError, LenzError, LenzGoneError from lenz_io.models import TaskStatus from ._run import execute, read_text_arg @@ -188,7 +188,7 @@ def _poll( if st.status == "completed": render_verification(out, st.result) return - if st.status == "failed": + if st.status in ("failed", "cancelled"): # cancelled elsewhere: the same exit as a failure raise CLIError(st.error or st.failure_detail or "Verification failed.", code="pipeline_failed") if st.status == "needs_input": if st.reason == "multi_claim": @@ -198,7 +198,7 @@ def _poll( # Sub-claims inherit the parent submission's depth server-side # (/select reads it off the task meta), so nothing to send here. items = client.select(task_id, claims=texts).items - picks = [(it.task_id, it.claim_text or txt) for it, txt in zip(items, texts, strict=True)] + picks = [(it.task_id, it.claim or txt) for it, txt in zip(items, texts, strict=True)] # detach, or >1 claim → batch path; exactly one → keep the # single-verdict flow (nicer than a 1-row table). if detach or len(picks) > 1: @@ -256,7 +256,7 @@ def _resolve_multi_claim(out: Output, task_id: str, st: TaskStatus, selection: l Otherwise json mode emits the needs_input object and exits 3 (never hangs on a prompt that can't happen); a TTY shows the checkbox picker. """ - options = [c.text for c in st.claims] + options = [c.claim for c in st.claims] if selection is not None: return [options[i] for i in _selection_to_indices(selection, len(options))] if out.json_mode: @@ -371,7 +371,7 @@ def _batch_item_json(task_id: str, claim_text: str, st: TaskStatus | None) -> di "status": "completed", "verification": st.result.model_dump(mode="json"), } - if st.status == "failed": + if st.status in ("failed", "cancelled"): # cancelled elsewhere: a failed row, as in the original shape return {"task_id": task_id, "claim": claim_text, "status": "failed", "error": st.error or st.failure_detail} return {"task_id": task_id, "claim": claim_text, "status": st.status or "unknown"} @@ -413,14 +413,14 @@ def _resume( try: st = client.get_status(ident) except LenzError as exc: - if exc.status_code == 404: + if exc.status_code == 404 and not isinstance(exc, LenzApiVersionError): _resume_as_verification(client, out, ident) return raise if st.status == "completed": render_verification(out, st.result) - elif st.status == "failed": - raise CLIError(st.error or "Verification failed.", code="pipeline_failed") + elif st.status in ("failed", "cancelled"): + raise CLIError(st.error or st.failure_detail or "Verification failed.", code="pipeline_failed") else: # processing / needs_input → keep polling from here, honoring --claim/--detach _poll(client, out, ident, timeout, selection=selection, detach=detach) diff --git a/src/lenz_io/client.py b/src/lenz_io/client.py index 7d0c05a..94abb14 100644 --- a/src/lenz_io/client.py +++ b/src/lenz_io/client.py @@ -18,12 +18,13 @@ byte-identical English path. The empty-default-then-omit convention exists precisely to avoid that. -Shape (four-primitive ladder + the supporting reads): +Shape (six calls: the four-call ladder, ``review`` and ``citecheck``, plus +the supporting reads): from lenz_io import Lenz client = Lenz(api_key="lenz_...") - # Marquee verbs — top-level (the four-primitive ladder) + # Marquee verbs — top-level (the four-call ladder) out = client.extract(text="...") # find claims in a document r = client.assess(claims=[...]) # one fast verdict per claim, up to 20 r = client.assess(claim="...") # ...or a single claim, ~15s @@ -63,27 +64,38 @@ * Exponential backoff on transient errors (5xx, 429). 3 retry attempts by default. ``Retry-After`` honored on 429s. * ``Idempotency-Key`` auto-generated per call for ``verify``, - ``verify_and_wait``, ``select``, ``assess`` and ``extract`` (random, reused + ``verify_and_wait``, ``verify_batch``, ``verify_batch_and_wait``, + ``select``, ``assess``, ``extract`` and ``ask.send`` (random, reused across that call's own retries) so a network drop doesn't run the request twice. Customer can override with explicit ``idempotency_key=...`` or opt - out with ``idempotency=False``. ``ask.send`` takes the same argument but - never generates one — see its docstring. -* ``X-Lenz-API-Version`` header pinned at SDK release date so the server - can route old clients to v1 handlers when v2 ships. + out with ``idempotency=False``. (2.x sent a key on ``verify_batch``, + ``verify_batch_and_wait`` and ``ask.send`` only when you passed one.) +* ``X-Lenz-API-Version`` header pinned per SDK release (``API_VERSION``), so + the server answers every release in the response shape it was built for. * ``X-Request-ID`` is captured from every response onto the typed error for support escalation. """ from __future__ import annotations +import builtins +import copy import logging +import math +import numbers +import operator import os +import re import time import uuid -from collections.abc import Callable -from typing import Any, Literal, TypedDict, TypeVar, overload +from collections.abc import Callable, Iterator, Mapping, Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Final, Literal, TypedDict, TypeVar, overload +from urllib.parse import quote import httpx +from typing_extensions import Self from . import __version__ from .errors import ( @@ -93,11 +105,16 @@ CitecheckFailed, CitecheckTimeout, LenzAPIError, + LenzApiVersionError, + LenzAuthError, + LenzConnectionError, LenzError, LenzGoneError, LenzNeedsInputError, + LenzNotFoundError, LenzPipelineError, LenzRateLimitError, + LenzRequestTimeoutError, LenzTimeoutError, ReviewFailed, ReviewTimeout, @@ -109,10 +126,13 @@ AssessResponse, BatchAccepted, BatchItemResult, + CancelResult, Certificate, Citecheck, CitecheckStarted, ExtractedClaims, + FailureBlock, + LibraryItem, LibraryList, Progress, RelatedVerifications, @@ -124,13 +144,20 @@ Usage, Verification, VerificationList, + VerificationListItem, ) logger = logging.getLogger("lenz_io") -# Pin the API version the SDK was built against. The server logs it on -# every request; when v2 ships, old SDKs keep getting v1 behavior. -API_VERSION = "2026-05-13" +# The API version this SDK asks for, sent as ``X-Lenz-API-Version`` on every +# request: the server answers in that version's response shape, whatever the +# account. Since 3.0.0 this is the current shape (one name for each field, +# status and error code), and the SDK reads only that shape from its own +# calls (2.x asked for ``2026-05-13``). Every attribute keeps the meaning it +# had in 2.x; the old names are deprecated aliases. Webhooks are the one +# place both shapes are still parsed. +API_VERSION = "2026-10-11" +_VERSION_HEADER = "X-Lenz-API-Version" DEFAULT_BASE_URL = "https://lenz.io/api/v1" DEFAULT_TIMEOUT = 30.0 @@ -147,7 +174,7 @@ # charged it — and a retry with no idempotency key charged again. ASSESS_TIMEOUT = 100.0 #: Deprecated alias for :data:`ASSESS_TIMEOUT`, kept for callers that imported -#: it. Same value; removed no earlier than 3.0. +#: it. Same value; to be removed in a future major release. ASSESS_LIST_TIMEOUT = ASSESS_TIMEOUT # ``extract`` reads the whole input and enumerates its claims inside one # synchronous request. Most calls answer in seconds, but a long input can take @@ -165,6 +192,10 @@ RETRY_BACKOFF = (1.0, 2.0, 4.0) POLL_BACKOFF = (2.0, 4.0, 8.0) POLL_BACKOFF_CAP = 10.0 +# The statuses a verification poll ends on. ``cancelled`` is a task stopped +# elsewhere (the website's Stop button, another process): its own status in +# API version 2026-10-11, where 2026-05-13 said ``failed``. +_TERMINAL_STATUSES = ("completed", "needs_input", "failed", "cancelled") # Bounds on the server's ``progress.poll_after_seconds``. A hint outside # them is treated as garbage and the local ladder is used instead — the # floor stops a bad value turning the poll loop into a hot loop, the @@ -178,6 +209,248 @@ REVIEW_POLL_DEFAULT = 10.0 +class NotGiven: + """The type of :data:`NOT_GIVEN`: "this option was not passed", as opposed + to ``None``, which ``Lenz.with_options(timeout=None)`` reads as "no + timeout". Only for type annotations; callers never need to pass it.""" + + _instance: NotGiven | None = None + + def __new__(cls) -> NotGiven: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __bool__(self) -> Literal[False]: + return False + + def __repr__(self) -> str: + return "NOT_GIVEN" + + def __copy__(self) -> NotGiven: + return self + + def __deepcopy__(self, memo: Any) -> NotGiven: + return self + + +#: The default of ``Lenz.with_options``' parameters: keep what the client has. +NOT_GIVEN: Final = NotGiven() + +#: Headers the request options refuse, in any casing: the SDK sets them +#: itself (``idempotency_key=`` and ``api_key=`` are the way to choose the +#: first two), or httpx computes them. +_RESERVED_HEADERS = frozenset( + { + "x-lenz-api-version", + "idempotency-key", + "authorization", + "content-type", + "content-length", + "host", + "transfer-encoding", + } +) + + +#: The longest timeout a request can take, in seconds (the Node SDK's limit, +#: 2^31 - 1 ms): past it the socket layer overflows on every request. Also the +#: longest finite wait a ``Retry-After`` is read as. +MAX_TIMEOUT_SECONDS = 2_147_483 + + +def _seconds(value: Any) -> bool: + """A finite real number of seconds greater than 0 and at most + :data:`MAX_TIMEOUT_SECONDS` (``bool`` is not one).""" + return ( + not isinstance(value, bool) + and isinstance(value, numbers.Real) + and math.isfinite(value) + and 0 < float(value) <= MAX_TIMEOUT_SECONDS + ) + + +def _snapshot(value: Any) -> Any: + """A copy of an ``httpx.Timeout`` (they are mutable); anything else as is.""" + return httpx.Timeout(value) if isinstance(value, httpx.Timeout) else value + + +def _check_timeout(value: Any, where: str) -> float | httpx.Timeout | None: + """A per-request timeout, checked and snapshotted: ``None``, a finite real + number of seconds greater than 0, httpx's 4-tuple ``(connect, read, write, + pool)`` whose parts are each ``None`` or such a number, or an + ``httpx.Timeout`` (taken as given). An ``httpx.Timeout`` or a tuple comes + back as a new ``httpx.Timeout``, so changing the caller's object later + changes nothing here. ``ValueError`` otherwise, before any request.""" + if value is None or _seconds(value): + return value # type: ignore[no-any-return] + if isinstance(value, httpx.Timeout): + parts = (value.connect, value.read, value.write, value.pool) + if not any(isinstance(p, (int, float)) and p > MAX_TIMEOUT_SECONDS for p in parts): + return httpx.Timeout(value) + elif isinstance(value, tuple) and len(value) == 4 and all(part is None or _seconds(part) for part in value): + return httpx.Timeout(value) + raise ValueError( + f"{where}: timeout must be a number of seconds greater than 0 and at most 2,147,483, None, an " + f"httpx.Timeout or a (connect, read, write, pool) tuple of such numbers or None (got {value!r})." + ) + + +def _check_retries(value: Any, where: str) -> int: + """A retry count: a whole number (anything ``operator.index`` takes, but + not ``bool``), 0 or more, returned as an ``int``. ``ValueError`` otherwise.""" + count: int | None = None + if not isinstance(value, bool): + try: + count = operator.index(value) + except TypeError: + count = None + if count is None or count < 0: + raise ValueError(f"{where}: max_retries must be a whole number, 0 or more (got {value!r}).") + return count + + +#: A header name is an RFC 7230 token; a value is visible ASCII, spaces and +#: tabs (no CR, LF or NUL: those would end the header or the request), or empty. +_HEADER_NAME = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") +#: Spaces and tabs only inside the value: at either end httpx refuses it. +_HEADER_VALUE = re.compile(r"(?:[\x21-\x7e](?:[\t\x20-\x7e]*[\x21-\x7e])?)?") + + +def _check_headers(value: Any, where: str) -> tuple[tuple[str, str | None], ...]: + """``extra_headers`` as (name, value) pairs in the order given: names + RFC 7230 tokens outside ``_RESERVED_HEADERS``, values visible-ASCII + strings or ``None`` (removes the header a ``with_options`` copy added). + ``ValueError`` otherwise. The pairs are a snapshot: changing the caller's + mapping later changes nothing here.""" + if value is None: + return () + if not isinstance(value, Mapping): + raise ValueError(f"{where}: extra_headers must be a mapping of header names to strings (got {value!r}).") + pairs: list[tuple[str, str | None]] = [] + for name, header in value.items(): + if not isinstance(name, str) or not _HEADER_NAME.fullmatch(name): + raise ValueError( + f"{where}: a header name must be a non-empty token of ASCII letters, digits and " + f"!#$%&'*+-.^_`|~ (got {name!r})." + ) + if name.lower() in _RESERVED_HEADERS: + raise ValueError(f"{where}: the {name} header is set by the SDK and cannot be passed in extra_headers.") + if header is not None and (not isinstance(header, str) or not _HEADER_VALUE.fullmatch(header)): + raise ValueError( + f"{where}: the value of header {name} must be a string of visible ASCII characters, with " + f"spaces and tabs only between them (not at either end), or None." + ) + pairs.append((name, header)) + return tuple(pairs) + + +def _merge_headers( + lower: Sequence[tuple[str, str]], upper: Sequence[tuple[str, str | None]] +) -> tuple[tuple[str, str], ...]: + """``upper`` over ``lower``, names compared case-insensitively: the last + spelling of a name and its value win, and a ``None`` value removes the + name from ``lower``.""" + merged: dict[str, tuple[str, str]] = {name.lower(): (name, value) for name, value in lower} + for name, value in upper: + if value is None: + merged.pop(name.lower(), None) + else: + merged[name.lower()] = (name, value) + return tuple(merged.values()) + + +@dataclass(frozen=True) +class _CallOptions: + """The request options one call was given (its ``timeout``, + ``max_retries`` and ``extra_headers`` keywords); ``None`` inherits.""" + + timeout: float | httpx.Timeout | None = None + max_retries: int | None = None + headers: tuple[tuple[str, str | None], ...] = () + + +_NO_OPTIONS = _CallOptions() + + +def _call_options( + timeout: float | httpx.Timeout | None, + max_retries: int | None, + extra_headers: Mapping[str, str | None] | None, + where: str, +) -> _CallOptions: + """One call's request options, checked and snapshotted: raises + ``ValueError`` before the call mints a key or makes a request.""" + return _CallOptions( + timeout=_check_timeout(timeout, where), + max_retries=None if max_retries is None else _check_retries(max_retries, where), + headers=_check_headers(extra_headers, where), + ) + + +def _given(options: _CallOptions) -> dict[str, Any]: + """``options`` as keywords for another public method, only those that were + given: a method overridden with a 2.21 signature (which knows none of + them) is still called the way 2.21 called it when there are none.""" + out: dict[str, Any] = {} + if options.timeout is not None: + out["timeout"] = options.timeout + if options.max_retries is not None: + out["max_retries"] = options.max_retries + if options.headers: + out["extra_headers"] = dict(options.headers) + return out + + +@dataclass(frozen=True) +class _ClientOptions: + """What ``Lenz.with_options`` set on a copy, already flattened over the + copy it was made from. ``NOT_GIVEN`` inherits from the ``httpx.Client`` in + use (timeout) or the constructor (retries).""" + + timeout: float | httpx.Timeout | NotGiven | None = NOT_GIVEN + max_retries: int | NotGiven = NOT_GIVEN + headers: tuple[tuple[str, str], ...] = () + + +def _resolve( + call: _CallOptions, + layer: _ClientOptions, + client_timeout: httpx.Timeout, + client_retries: int, + floor: float | None, +) -> tuple[httpx.Timeout | None, int, tuple[tuple[str, str], ...]]: + """The timeout, retry count and option headers of one request. Pure: no + I/O, no clock. + + Per field, the first that is set wins: the call's option, the copy's + (``with_options``), the client's (the ``httpx.Client`` in use for the + timeout, the constructor for retries). Headers merge, the call's over the + copy's. A ``floor`` (``extract`` / ``assess``) applies to an inherited + timeout only, as it always did: when its ``read`` is bounded and shorter + than the floor, the floor replaces the whole timeout; otherwise the + inherited timeout is kept as it is. A timeout of ``None`` means "the + ``httpx.Client``'s own" (httpx's ``USE_CLIENT_DEFAULT``). + """ + timeout: httpx.Timeout | None + if call.timeout is not None: + timeout = httpx.Timeout(call.timeout) + else: + own = None if isinstance(layer.timeout, NotGiven) else httpx.Timeout(layer.timeout) + inherited = client_timeout if own is None else own + if floor is not None and inherited.read is not None and inherited.read < floor: + timeout = httpx.Timeout(floor) + else: + timeout = own + if call.max_retries is not None: + retries = call.max_retries + elif not isinstance(layer.max_retries, NotGiven): + retries = layer.max_retries + else: + retries = client_retries + return timeout, retries, _merge_headers(layer.headers, call.headers) + + def _poll_hint(progress: Any) -> float | None: """The server's suggested wait before the next poll, or None. @@ -199,6 +472,15 @@ def _user_agent() -> str: return f"lenz-io-python/{__version__} (httpx {httpx.__version__})" +def _blank_webhook_url(value: Any) -> bool: + """An empty or whitespace-only ``webhook_url`` on ``verify`` / + ``verify_batch``: it always meant the key's default webhook (the API read + it as left out), so it is left out, which keeps that meaning on every API + version (the current one reads a blank value as "no webhook"). ``None`` + is left out too.""" + return value is None or (isinstance(value, str) and not value.strip()) + + def _batch_item_body(item: Any) -> Any: """The wire shape of one batch item. @@ -209,7 +491,7 @@ def _batch_item_body(item: Any) -> Any: the key's default, and leaving it out keeps that meaning on every API version. """ - if isinstance(item, dict) and item.get("webhook_url") == "": + if isinstance(item, dict) and _blank_webhook_url(item.get("webhook_url")): item = {k: v for k, v in item.items() if k != "webhook_url"} if "claim" not in item: return item @@ -219,17 +501,19 @@ def _batch_item_body(item: Any) -> Any: def _extracted(body: Any, *, locate: bool | None) -> ExtractedClaims: - """``/extract``'s answer. A newer-shape body (``claims``, no - ``identified_claims``) that located every claim away answers ``claims: - []``; the original field for that was ``locations=[]``, which only the + """``/extract``'s answer. A body that located every claim away answers + ``claims: []``; the 2.x field for that was ``locations=[]``, which only the request (``locate=True``) can tell apart from "nothing found".""" out = ExtractedClaims.model_validate(body) - newer = isinstance(body, dict) and "claims" in body and "identified_claims" not in body - if newer and locate and not body["claims"] and out.status == "not_a_claim" and out.locations is None: + answered = isinstance(body, dict) and body.get("claims") == [] + if answered and locate and out.status == "not_a_claim" and out.locations is None: out.locations = [] return out +#: ``Lenz`` or a subclass, for ``with_options``' return type. +_Client = TypeVar("_Client", bound="Lenz") + #: An async job the poll loop waits on: a review or a citation check. _Job = TypeVar("_Job", ReviewFull, Citecheck) @@ -282,8 +566,9 @@ class VerifyBatchItem(TypedDict, total=False): # 'private' (default) or 'unlisted' (link-readable, never listed). # Per-item value overrides the batch-wide ``visibility`` default. visibility: str - # 'standard' (default) or 'low' (shallower check — fewer sources, - # faster). Per-item value overrides the batch-wide ``depth`` default. + # 'standard' (default, 10 credits) or 'low' (shallower check — fewer + # sources, faster, 5 credits). Per-item value overrides the batch-wide + # ``depth`` default. depth: str @@ -293,11 +578,61 @@ class _VerificationsNamespace: def __init__(self, parent: Lenz) -> None: self._p = parent - def list(self, *, page: int = 1) -> VerificationList: - body = self._p._request("GET", "/verifications", params={"page": page}) + def list( + self, + *, + page: int = 1, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> VerificationList: + """One page of your verifications, newest first. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + """ + options = _call_options(timeout, max_retries, extra_headers, "verifications.list()") + body = self._p._request("GET", "/verifications", params={"page": page}, options=options) return VerificationList.model_validate(body) - def get(self, verification_id: str) -> Verification: + def iter( + self, + *, + page: int = 1, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> Iterator[VerificationListItem]: + """Every verification on your account, newest first, page after page + from ``page`` (default 1). + + Fetches a page only when the items before it have been consumed, reads + the page size from each response, and stops after a short or empty + page, once the pages read reach the response's ``total``, or when the + server answers another page than the one asked for. ``page`` must be + 1 or more (``ValueError``):: + + for item in client.verifications.iter(): + print(item.verification_id, item.verdict) + + Since 3.0. ``list(page=...)`` reads one page. + + Request options (``timeout``, ``max_retries``, ``extra_headers``) + apply to every page request, and a bad one raises here, before the + first page: see :meth:`Lenz.with_options`. + """ + # Checked and snapshotted now: every page is read with these. + options = _call_options(timeout, max_retries, extra_headers, "verifications.iter()") + return _walk(lambda n: self.list(page=n, **_given(options)), _first_page(page)) + + def get( + self, + verification_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> Verification: """Fetch a single verification by id. Works without an API key — the server accepts optional Bearer: @@ -311,16 +646,29 @@ def get(self, verification_id: str) -> Verification: wait for a run, use ``client.wait(task_id)``. Raises :class:`LenzGoneError` (HTTP 410) when the account's retention period has removed the verification. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + options = _call_options(timeout, max_retries, extra_headers, "verifications.get()") + vid = _segment(verification_id, "verifications.get() needs a verification_id.") body = self._p._request( "GET", - f"/verifications/{verification_id}", + f"/verifications/{vid}", auth_required=False, auth_optional=True, # send the key if we have one → owner sees private rows + options=options, ) return Verification.model_validate(body) - def get_certificate(self, verification_id: str) -> Certificate: + def get_certificate( + self, + verification_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> Certificate: """Download the warranty certificate for a covered verification. Resolved by (verification, ACCOUNT), not by verification alone: one @@ -328,8 +676,8 @@ def get_certificate(self, verification_id: str) -> Certificate: certificate and their own cap, so this returns YOUR certificate over this analysis and never another customer's. - Raises ``LenzError`` with status 404 when this verification carries no - certificate for your account — which is also what an uncovered verdict + Raises :class:`LenzNotFoundError` (a ``LenzError``, status 404) when + this verification carries no certificate for your account — which is also what an uncovered verdict returns, so check ``verification.coverage.status`` first rather than using a 404 here to mean "not covered". @@ -337,24 +685,51 @@ def get_certificate(self, verification_id: str) -> Certificate: ``/certificate/.json``, so it verifies with the published open-source checker without involving Lenz. A withdrawn certificate is still served — it is the record of what was warranted. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ - body = self._p._request("GET", f"/verifications/{verification_id}/certificate") + options = _call_options(timeout, max_retries, extra_headers, "verifications.get_certificate()") + vid = _segment(verification_id, "verifications.get_certificate() needs a verification_id.") + body = self._p._request("GET", f"/verifications/{vid}/certificate", options=options) return Certificate.model_validate(body) - def delete(self, verification_id: str) -> bool: - """Idempotent. Retry-on-404 returns True ("already deleted").""" + def delete( + self, + verification_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> bool: + """Idempotent. Retry-on-404 returns True ("already deleted"). + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + """ + options = _call_options(timeout, max_retries, extra_headers, "verifications.delete()") + vid = _segment(verification_id, "verifications.delete() needs a verification_id.") try: - self._p._request("DELETE", f"/verifications/{verification_id}") + self._p._request("DELETE", f"/verifications/{vid}", options=options) return True except LenzError as exc: # Idempotent DELETE: if the row was already gone (e.g. previous # request succeeded but the network reply was lost), treat as - # success rather than surfacing a confusing 404. - if exc.status_code == 404: + # success rather than surfacing a confusing 404. A 404 in another + # API version is not read as this one's. + if exc.status_code == 404 and not isinstance(exc, LenzApiVersionError): return True raise - def related(self, verification_id: str, *, limit: int = 5) -> RelatedVerifications: + def related( + self, + verification_id: str, + *, + limit: int = 5, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> RelatedVerifications: """Return public verifications semantically related to this one. Server caps ``limit`` to 10. Empty list when the verification has @@ -366,13 +741,19 @@ def related(self, verification_id: str, *, limit: int = 5) -> RelatedVerificatio library item. Raises :class:`LenzGoneError` (HTTP 410) when the account's retention period has removed the verification. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + options = _call_options(timeout, max_retries, extra_headers, "verifications.related()") + vid = _segment(verification_id, "verifications.related() needs a verification_id.") body = self._p._request( "GET", - f"/verifications/{verification_id}/related", + f"/verifications/{vid}/related", params={"limit": limit}, auth_required=False, auth_optional=True, # send the key if we have one → owner sees own rows + options=options, ) return RelatedVerifications.model_validate(body) @@ -387,12 +768,24 @@ class _AskNamespace: def __init__(self, parent: Lenz) -> None: self._p = parent - def history(self, verification_id: str) -> AskHistory: + def history( + self, + verification_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> AskHistory: """The follow-up conversation on a verification. Raises :class:`LenzGoneError` (HTTP 410) when the account's retention period has removed the verification. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ - body = self._p._request("GET", f"/ask/{verification_id}") + options = _call_options(timeout, max_retries, extra_headers, "ask.history()") + vid = _segment(verification_id, "ask.history() needs a verification_id.") + body = self._p._request("GET", f"/ask/{vid}", options=options) return AskHistory.model_validate(body) def send( @@ -402,6 +795,10 @@ def send( message: str, language: str = "", idempotency_key: str | None = None, + idempotency: bool = True, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> AskReply: """Send a follow-up question on an existing verification. @@ -410,39 +807,68 @@ def send( ``language`` as default — that's the typical case. ``'auto'`` also answers in the language of the claim being discussed. - ``idempotency_key`` (optional): send an ``Idempotency-Key`` so a - retry of *this* question replays the first reply instead of asking — - and paying for — it twice, and without appending the question and a - second answer to the conversation. A retry that arrives while the - first call is still running gets a 409 instead: there is no reply to - replay yet. - - Unlike ``assess``, no key is generated for you, and none is derived - from the message: asking the same thing again is a normal thing to do - here, and each turn also reads the history the previous one wrote, so - an implied key would replay a stale answer. Pass your own key when - your retry means "the same question, once". + ``idempotency`` (default ``True``): send an ``Idempotency-Key`` so the + SDK's own retry after a timeout or network drop replays the first + reply instead of asking, and paying for, the question twice, and + without appending the question and a second answer to the + conversation. The key is random per call and reused across that + call's retries; pin your own with ``idempotency_key=`` (it wins) to + make a retry from another process replay too, or pass + ``idempotency=False`` to send none. A retry that arrives while the + first call is still running is answered 409 ``idempotency_conflict``: + the SDK sends the same key again within the call's retries, and if it + still conflicts raises it with ``retryable=True``. Every error of the + call carries the key (``exc.idempotency_key``): resend with it, never + as a plain new call, which would ask (and charge) again. (Since 3.0; + 2.x sent a key only when you passed one.) + + Never derived from the message: asking the same thing again is a + normal thing to do here, and each turn also reads the history the + previous one wrote, so a key derived from the text would replay a + stale answer. A new call is a new key, so it is asked again. Paid — see ``client.usage()``. Raises :class:`LenzGoneError` (HTTP 410) when the account's retention period has removed the verification. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + options = _call_options(timeout, max_retries, extra_headers, "ask.send()") + vid = _segment(verification_id, "ask.send() needs a verification_id.") payload: dict[str, Any] = {"message": message} if language: payload["language"] = language headers = {} - if idempotency_key: - headers["Idempotency-Key"] = idempotency_key - body = self._p._request( - "POST", - f"/ask/{verification_id}", - json=payload, - headers=headers, - ) - return AskReply.model_validate(body) + key = _call_key(idempotency_key, idempotency) + if key: + headers["Idempotency-Key"] = key + with _carrying_key(key, unreadable=True): + body = self._p._request( + "POST", + f"/ask/{vid}", + json=payload, + headers=headers, + options=options, + ) + return AskReply.model_validate(body) - def reset(self, verification_id: str) -> bool: - self._p._request("DELETE", f"/ask/{verification_id}") + def reset( + self, + verification_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> bool: + """Clear the follow-up conversation on a verification. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + """ + options = _call_options(timeout, max_retries, extra_headers, "ask.reset()") + vid = _segment(verification_id, "ask.reset() needs a verification_id.") + self._p._request("DELETE", f"/ask/{vid}", options=options) return True @@ -469,6 +895,9 @@ def list( entity: str = "", curated: list[str] | None = None, verdict: str = "", + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> LibraryList: """List the public verification catalog. Works without an API key. @@ -477,7 +906,11 @@ def list( filters by comma-separated labels, e.g. ``"True,False"``. ``sort`` also accepts ``"random"`` alongside ``recent`` / ``most_true`` / ``most_untrue`` / ``relevance``. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + options = _call_options(timeout, max_retries, extra_headers, "library.list()") params: dict[str, Any] = { "page": page, "sort": sort, @@ -495,9 +928,99 @@ def list( "/library", params=params, auth_required=False, + options=options, ) return LibraryList.model_validate(body) + def iter( + self, + *, + page: int = 1, + sort: str = "recent", + search: str = "", + domain: str = "", + entity: str = "", + # ``builtins.list``: in this class body ``list`` is the method above. + curated: builtins.list[str] | None = None, + verdict: str = "", + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> Iterator[LibraryItem]: + """Every item of the public catalog that matches the filters (the + ones ``list`` takes), page after page from ``page``. Works without an + API key. + + Fetches a page only when the items before it have been consumed, reads + the page size from each response, and stops after a short or empty + page, once the pages read reach the response's ``total``, or when the + server answers another page than the one asked for. ``page`` must be + 1 or more, and ``sort="random"`` raises ``ValueError``: a random order is drawn + anew for every page, so walking it neither reaches every item nor + avoids repeats (use ``list(sort="random")`` for a sample). Since 3.0. + + Request options (``timeout``, ``max_retries``, ``extra_headers``) + apply to every page request, and a bad one raises here, before the + first page: see :meth:`Lenz.with_options`. + """ + # Checked and snapshotted now: every page is read with these. + options = _call_options(timeout, max_retries, extra_headers, "library.iter()") + if sort == "random": + raise ValueError('iter cannot walk sort="random" (each page is a fresh sample); call library.list instead.') + page = _first_page(page) + return _walk( + lambda n: self.list( + page=n, + sort=sort, + search=search, + domain=domain, + entity=entity, + curated=curated, + verdict=verdict, + **_given(options), + ), + page, + ) + + +def _walk(fetch: Callable[[int], VerificationList | LibraryList], page: int) -> Iterator[Any]: + """The items of ``fetch(page)``, ``fetch(page + 1)``, ... A generator: no + page is fetched before its first item is asked for. + + Stops after a page that is short or empty, once the pages read reach the + response's ``total``, when a response states no usable ``page_size``, and + (without yielding it) when the server answers another page than the one + asked for, which a server clamping a page past the end would repeat.""" + while True: + current = fetch(page) + sent = current.model_fields_set + if "page" in sent and current.page != page: + return + yield from current.items + size = current.page_size if "page_size" in sent else 0 + if not current.items or size <= 0 or len(current.items) < size: + return + if "total" in sent and page * size >= current.total: + return + page += 1 + + +def _first_page(page: int) -> int: + """``page`` for an iterator, refused unless it is 1 or more.""" + if isinstance(page, bool) or not isinstance(page, int) or page < 1: + raise ValueError(f"iter starts at page 1 or later (got page={page!r}).") + return page + + +def _segment(value: Any, message: str) -> str: + """An id as ONE path segment: percent-encoded whole, so a ``/``, ``?``, ``#`` + or ``%`` in it cannot leave the intended path (httpx would otherwise cut the + URL there). ``""``, ``"."`` and ``".."`` raise ``ValueError(message)``: a + path normaliser eats the dots, and an empty id names the collection.""" + if not isinstance(value, str) or value in ("", ".", ".."): + raise ValueError(message) + return quote(value, safe="") + def _call_key(idempotency_key: str | None, idempotency: bool) -> str | None: """The ``Idempotency-Key`` for one call: the caller's own, else a random @@ -512,6 +1035,37 @@ def _call_key(idempotency_key: str | None, idempotency: bool) -> str | None: return uuid.uuid4().hex if idempotency else None +@contextmanager +def _carrying_key(key: str | None, *, unreadable: bool = False) -> Iterator[None]: + """Put ``key`` on any ``LenzError`` raised inside (``exc.idempotency_key``) + that does not carry one yet: every error of a call that sent a key says + which, so a resend can reuse it. With ``unreadable``, also on a + ``ValueError`` (an answer whose body is not JSON), whose class stays the + one 2.x raised.""" + try: + yield + except LenzError as exc: + if key and exc.idempotency_key is None: + exc.idempotency_key = key + raise + except ValueError as exc: + if unreadable and key and getattr(exc, "idempotency_key", None) is None: + exc.idempotency_key = key # type: ignore[attr-defined] + raise + + +def _names_the_job(field: str) -> Callable[[Any], bool]: + """Whether a 409 ``idempotency_conflict`` body names the job the first + request created (``review_id`` / ``citecheck_id``): that answer settles the + call, so it is not sent again.""" + + def check(body: Any) -> bool: + value = body.get(field) if isinstance(body, dict) else None + return isinstance(value, str) and bool(value) + + return check + + class Lenz: """Top-level client. @@ -528,16 +1082,21 @@ def __init__( *, api_key: str | None = None, base_url: str | None = None, - timeout: float = DEFAULT_TIMEOUT, + timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, http_client: httpx.Client | None = None, user_agent: str | None = None, ) -> None: + # The same rule as every request option: refused here, before the + # client exists, rather than failing (or retrying forever) later. + timeout = _check_timeout(timeout, "Lenz()") + max_retries = _check_retries(max_retries, "Lenz()") self._api_key = api_key or os.environ.get("LENZ_API_KEY") or "" self._base_url = (base_url or os.environ.get("LENZ_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") self._timeout = timeout self._max_retries = max_retries self._owns_client = http_client is None + self._options = _ClientOptions() # ``user_agent`` lets a wrapper (e.g. the CLI) override just the UA while # the SDK keeps ownership of every other default header — so a new # default header can't be silently dropped by a hand-copied client. @@ -559,10 +1118,83 @@ def __init__( # ── lifecycle ── def close(self) -> None: + """Close the connection pool, if this client created it. A client + given ``http_client=`` leaves it open (it is yours), and a copy made + by :meth:`with_options` never closes the pool it shares: close the + client you made the copies from.""" if self._owns_client: self._client.close() - def __enter__(self) -> Lenz: + def with_options( + self: _Client, + *, + timeout: float | httpx.Timeout | NotGiven | None = NOT_GIVEN, + max_retries: int | NotGiven = NOT_GIVEN, + extra_headers: Mapping[str, str | None] | None = None, + ) -> _Client: + """A copy of this client with other request options, sharing its + connection pool, key and base URL. Cheap: make one per request if you + like. The client it was made from is not changed:: + + fast = client.with_options(timeout=10, max_retries=0) + fast.usage() + + traced = client.with_options(extra_headers={"X-Trace-Id": trace_id}) + traced.assess(claim="...") + + Every method also takes the same three options as keywords, for one + call: ``client.assess(claim="...", timeout=20)``. + + * ``timeout``: one HTTP attempt, in seconds (a number greater than 0) + or an ``httpx.Timeout``. httpx applies it per phase (connect, read, + write, pool) and the read limit to each chunk, so it is an + inactivity limit, not a bound on the whole call, and retries each get + their own. Here ``None`` means no timeout, as on ``Lenz(timeout=None)``; + on a single call ``timeout=None`` keeps the client's (pass + ``httpx.Timeout(None)`` for no timeout on one call). ``extract`` and + ``assess`` take at least 150 s / 100 s when the timeout is inherited + from a copy or the client, as they always did; a timeout passed to the + call itself is used as given, even below that (a shorter one can time + out a call the server is still running: retry it with the same + ``idempotency_key`` to get the answer). + * ``max_retries``: how often a request that failed in a way worth + retrying (a 5xx, a 429, a dropped connection) is sent again: a whole + number, 0 or more. + * ``extra_headers``: headers added to every request, merged over the + copy's own by name, case-insensitively. ``None`` as a value removes a + header a copy added. The SDK's own headers (``X-Lenz-API-Version``, + ``Idempotency-Key``, ``Authorization``, ``Content-Type``, + ``Content-Length``, ``Host``, ``Transfer-Encoding``) are refused. + + Per option, a call's keyword wins over the copy, and the copy over the + client. A bad value raises ``ValueError`` here, before any request. + The wait helpers keep their own ``timeout`` (how long to wait); the + timeout of each of their requests comes from the copy or the client. + + The copy shares the pool: ``close()`` and ``with`` on a copy do + nothing, closing the original closes the pool for every copy, and a + copy is as safe to share across threads as the client. + """ + if not isinstance(timeout, NotGiven): + timeout = _check_timeout(timeout, "with_options()") + if not isinstance(max_retries, NotGiven): + max_retries = _check_retries(max_retries, "with_options()") + headers = _check_headers(extra_headers, "with_options()") + layer = self._options + clone = copy.copy(self) + clone._owns_client = False + clone._options = _ClientOptions( + # A snapshot: each copy holds its own ``httpx.Timeout``. + timeout=_snapshot(layer.timeout) if isinstance(timeout, NotGiven) else timeout, + max_retries=layer.max_retries if isinstance(max_retries, NotGiven) else max_retries, + headers=_merge_headers(layer.headers, headers), + ) + clone.verifications = _VerificationsNamespace(clone) + clone.ask = _AskNamespace(clone) + clone.library = _LibraryNamespace(clone) + return clone + + def __enter__(self) -> Self: return self def __exit__(self, *exc_info: Any) -> None: @@ -580,7 +1212,11 @@ def verify( depth: str = "", idempotency: bool = True, idempotency_key: str | None = None, - **kwargs: Any, + source_url: str = "", + webhook_url: str = "", + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> TaskAccepted: """Submit a claim for verification. Returns a ``task_id``; the pipeline runs async. For sync ergonomics use ``verify_and_wait``. @@ -598,8 +1234,8 @@ def verify( never surfaced in the Library or search). Omit for private. ``depth`` (optional): ``'standard'`` (default) or ``'low'``. ``'low'`` - runs a shallower check — fewer sources, faster. Same models, same - quota cost. The completed ``Verification.depth`` echoes the depth the + runs a shallower check — fewer sources, faster, same models — for half + the credits (5 instead of 10). The completed ``Verification.depth`` echoes the depth the verdict was actually produced with, which can be ``'standard'`` for a ``'low'`` request served from cache. @@ -610,26 +1246,43 @@ def verify( ``idempotency_key=`` to make a retry from a different process replay too, or pass ``idempotency=False`` to send none. Never derived from the claim: the same claim sent again later is a new verification. + + ``source_url`` (optional): where the claim was found, kept with the + verification. ``webhook_url`` (optional): where to send this + verification's ``verification.*`` events; leave it out (or blank) for + your key's default webhook URL. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + _call_options(timeout, max_retries, extra_headers, "verify()") return self._verify_submit( claim=claim, text=text, + source_url=source_url, + webhook_url=webhook_url, language=language, visibility=visibility, depth=depth, idempotency_key=_call_key(idempotency_key, idempotency), - **kwargs, + timeout=timeout, + max_retries=max_retries, + extra_headers=extra_headers, ) def verify_batch( self, *, - claims: list[VerifyBatchItem | dict[str, Any]], + claims: Sequence[VerifyBatchItem | dict[str, Any]], webhook_url: str = "", language: str = "", visibility: str = "", depth: str = "", idempotency_key: str | None = None, + idempotency: bool = True, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> BatchAccepted: """Submit multiple claims in one call. Returns a ``batch_id`` and per-claim ``task_id``s. Each item has its own lifecycle and webhook. @@ -643,16 +1296,32 @@ def verify_batch( to override the batch-wide value. ``depth`` (optional): batch-wide default, ``'standard'`` or ``'low'`` - (shallower check — fewer sources, faster). Each item dict may set its - own ``depth`` key to override the batch-wide value. + (shallower check — fewer sources, faster, 5 credits instead of 10). + Each item dict may set its own ``depth`` key to override the + batch-wide value. + + ``idempotency`` (default ``True``): send an ``Idempotency-Key`` for + the whole batch, so a retry after a network drop returns the tasks the + first attempt started instead of starting, and paying for, them again. + The key is random per call and reused across this SDK's own retries; + pin your own with ``idempotency_key=`` (it wins), or pass + ``idempotency=False`` to send none. (Since 3.0; 2.x sent a key only + when you passed one.) + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + _call_options(timeout, max_retries, extra_headers, "verify_batch()") return self._verify_batch( claims=claims, webhook_url=webhook_url, language=language, visibility=visibility, depth=depth, - idempotency_key=idempotency_key, + idempotency_key=_call_key(idempotency_key, idempotency), + timeout=timeout, + max_retries=max_retries, + extra_headers=extra_headers, ) def extract( @@ -662,9 +1331,11 @@ def extract( language: str = "", focus: str = "", locate: bool | None = None, - timeout: float | None = None, + timeout: float | httpx.Timeout | None = None, idempotency: bool = True, idempotency_key: str | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> ExtractedClaims: """Pull the verifiable claims out of any text. Sync, free, capped at 1000 calls/account/day (shared across your API keys). @@ -697,10 +1368,12 @@ def extract( unfiltered. Leave it ``None`` to use the server default (currently off); an explicit ``False`` is sent as such. - ``timeout`` (optional): per-call HTTP timeout in seconds, overriding - the client default for this one request. Otherwise ``extract`` uses + ``timeout`` (optional): per-call HTTP timeout in seconds (or an + ``httpx.Timeout``), overriding the client default for this one + request, used as given. Otherwise ``extract`` uses ``EXTRACT_TIMEOUT`` (150s), or your client timeout when you configured a longer one: a long input can take more than a minute to extract. + ``max_retries`` and ``extra_headers``: see :meth:`Lenz.with_options`. ``idempotency`` (default ``True``): send an ``Idempotency-Key`` so the SDK's own retry after a timeout or network drop replays the first @@ -709,6 +1382,7 @@ def extract( retries; pin your own with ``idempotency_key=``, or pass ``idempotency=False`` to send none. Never derived from the text. """ + _call_options(timeout, max_retries, extra_headers, "extract()") return self._extract( text=text, language=language, @@ -716,6 +1390,8 @@ def extract( locate=locate, timeout=timeout, idempotency_key=_call_key(idempotency_key, idempotency), + max_retries=max_retries, + extra_headers=extra_headers, ) def assess( @@ -726,9 +1402,11 @@ def assess( claims: list[str] | None = None, language: str = "", suggest_rewrite: bool = False, - timeout: float | None = None, + timeout: float | httpx.Timeout | None = None, idempotency: bool = True, idempotency_key: str | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> AssessResponse: """Fast verdict via a 3-model frontier panel. Sync, typically ~15s. @@ -789,11 +1467,13 @@ def assess( and is not itself verified: review it, or run it through ``verify``, before using it. ``False`` sends nothing. - ``timeout`` (optional): per-call HTTP timeout in seconds, overriding - the client default for this one request. Both forms otherwise use + ``timeout`` (optional): per-call HTTP timeout in seconds (or an + ``httpx.Timeout``), overriding the client default for this one + request, used as given. Both forms otherwise use ``ASSESS_TIMEOUT`` (100s), or your client timeout when you configured a longer one — the server runs framing and a 3-model panel inside one request, and a long text can use the server's whole 90s budget. + ``max_retries`` and ``extra_headers``: see :meth:`Lenz.with_options`. ``idempotency`` (default ``True``): send an ``Idempotency-Key`` so a retry after a network drop replays the first response instead of @@ -811,16 +1491,19 @@ def assess( single-string input read as N claims (up to 20) costs N; ``Error`` rows are free. """ + _call_options(timeout, max_retries, extra_headers, "assess()") + if claims is not None and (claim or text): + raise ValueError("assess takes either one claim (claim=) or a list (claims=), not both") key = _call_key(idempotency_key, idempotency) if claims is not None: - if claim or text: - raise ValueError("assess takes either one claim (claim=) or a list (claims=), not both") return self._assess( claims=claims, language=language, suggest_rewrite=suggest_rewrite, timeout=timeout, idempotency_key=key, + max_retries=max_retries, + extra_headers=extra_headers, ) return self._assess( text=claim or text, @@ -828,6 +1511,8 @@ def assess( suggest_rewrite=suggest_rewrite, timeout=timeout, idempotency_key=key, + max_retries=max_retries, + extra_headers=extra_headers, ) def select( @@ -838,6 +1523,9 @@ def select( texts: list[str] | None = None, idempotency: bool = True, idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> BatchAccepted: """Resolve a needs-input interrupt by selecting one or more claims. @@ -857,20 +1545,92 @@ def select( instead of starting a second set. Random per call and reused across this SDK's own retries; pin your own with ``idempotency_key=``, or pass ``idempotency=False`` to send none. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + _call_options(timeout, max_retries, extra_headers, "select()") chosen = claims or texts if not chosen: raise ValueError("select requires a non-empty claims=[...]") - return self._select(task_id, texts=chosen, idempotency_key=_call_key(idempotency_key, idempotency)) + return self._select( + task_id, + texts=chosen, + idempotency_key=_call_key(idempotency_key, idempotency), + timeout=timeout, + max_retries=max_retries, + extra_headers=extra_headers, + ) - def get_status(self, task_id: str) -> TaskStatus: + def get_status( + self, + task_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> TaskStatus: """Poll the pipeline status. Use ``verify_and_wait`` for sync ergonomics. Raises :class:`LenzGoneError` (HTTP 410) when the task completed and the account's retention period has since removed its verification; a task that is still running never answers 410. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + """ + return self._get_status(task_id, timeout=timeout, max_retries=max_retries, extra_headers=extra_headers) + + def cancel( + self, + task_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> CancelResult: + """Stop a verification that has not finished (``POST /verify/{task_id}/cancel``). + + A cancelled run is not charged and saves nothing; work already under + way (a model call in flight) is not billed to you, and the run stops + at its next step. A run waiting for ``select`` is cancelled too. + + The call is safe to repeat and answers 200 whatever the state of the + run, so losing a race is not an error. Read the result: + + - ``cancelled`` is ``True`` and ``status`` is ``"cancelled"``: the run + is cancelled, by this call or an earlier one, so a repeated or + retried cancel answers ``True`` too. + - ``cancelled`` is ``False``: the run is not cancelled, and ``status`` + is its status, normally ``"completed"`` (the verification exists and + was charged as usual) or ``"failed"``. A task that ``select`` + already resolved answers ``False`` with ``"needs_input"``: cancel + the task ids ``select`` returned. + + ``client.wait(task_id)`` and ``get_status`` then see ``cancelled``; + ``wait`` raises :class:`LenzPipelineError` with + ``failure_class == "cancelled"``. + + Raises :class:`LenzNotFoundError` (404) for an unknown task, another + account's, or a task started on the website. A task that is a + review's deep check raises :class:`LenzError` with + ``code == "use_review_cancel"`` (409): cancel the review with + :meth:`cancel_review`, which stops everything in it. A server error or + a dropped connection is retried, as for every call that is safe to + send twice. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + + Since 3.0. """ - return self._get_status(task_id) + options = _call_options(timeout, max_retries, extra_headers, "cancel()") + tid = _segment(task_id, "cancel() needs a task_id.") + path = f"/verify/{tid}/cancel" + body = self._request("POST", path, options=options) + if not _is_cancel_body(body, task_id): + raise _unexpected_answer("POST", path) + return CancelResult.model_validate(body) # ── headline ergonomic ── @@ -888,6 +1648,8 @@ def verify_and_wait( idempotency: bool = True, idempotency_key: str | None = None, on_progress: Callable[[str, Progress], None] | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> Verification: """Submit + poll until the pipeline terminates. @@ -917,21 +1679,38 @@ def verify_and_wait( Equivalent to ``wait(verify(claim, ...))``, with the same idempotency-key handling (auto-generate / pin / disable). - ``timeout`` defaults to ``WAIT_TIMEOUT`` (300s). + ``timeout`` defaults to ``WAIT_TIMEOUT`` (300s). The clock starts + once the submit is accepted. + + ``max_retries``: the submit's retries (each poll is one request and a + failed poll is read again on the next round). ``extra_headers``: added + to the submit and to every poll. See :meth:`Lenz.with_options`; the + timeout of each request comes from the copy or the client, since + ``timeout`` here is how long to wait. """ + # Checked and snapshotted once: the submit and every poll use these. + options = _call_options(None, max_retries, extra_headers, "verify_and_wait()") key = _call_key(idempotency_key, idempotency) - accepted = self._verify_submit( - claim=claim, - text=text, - source_url=source_url, - webhook_url=webhook_url, - language=language, - visibility=visibility, - depth=depth, - idempotency_key=key, - ) - logger.info("Submitted task: %s", accepted.task_id) - return self.wait(accepted, timeout=timeout, on_progress=on_progress) + with _carrying_key(key): + accepted = self._verify_submit( + claim=claim, + text=text, + source_url=source_url, + webhook_url=webhook_url, + language=language, + visibility=visibility, + depth=depth, + idempotency_key=key, + max_retries=options.max_retries, + extra_headers=dict(options.headers), + ) + logger.info("Submitted task: %s", accepted.task_id) + # Only the headers reach the polls, and only when there are some, so + # a subclass overriding ``wait`` with the 2.21 signature is still + # called the way 2.21 called it. + return self.wait( + accepted, timeout=timeout, on_progress=on_progress, **_given(_CallOptions(headers=options.headers)) + ) def wait( self, @@ -939,6 +1718,7 @@ def wait( *, timeout: float = WAIT_TIMEOUT, on_progress: Callable[[str, Progress], None] | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> Verification: """Block until an already-submitted task terminates, then return its ``Verification``. @@ -947,17 +1727,31 @@ def wait( ``verify`` / ``select`` — so ``client.wait(client.verify(claim=...))`` reads naturally. Raises ``ValueError`` for an empty id, ``LenzNeedsInputError`` / ``LenzPipelineError`` on terminal - non-success, ``LenzGoneError`` if its account's retention period has + non-success (a verification cancelled elsewhere, such as the + website's Stop button, raises the same ``LenzPipelineError`` as a + failed one, with ``failure_class == "cancelled"``), + ``LenzGoneError`` if its account's retention period has removed the verification, and ``LenzTimeoutError`` if ``timeout`` (default ``WAIT_TIMEOUT``, 300s) elapses (the task may still finish server-side — resume via ``get_status``). + + A poll answered 401 / 403 (``LenzAuthError``) or 404 + (``LenzNotFoundError``) ends the wait at once with that error: no + later poll would answer otherwise. A 5xx, a 429 or a network failure + is polled again on the next round. (Since 3.0; 2.x polled through + every error until the timeout.) + + ``extra_headers``: added to every poll (see :meth:`Lenz.with_options`). + Each poll is one request, so there is no ``max_retries`` here; its + timeout comes from the copy or the client, capped by what is left of + the wait. """ + options = _call_options(None, None, extra_headers, "wait()") task_id = task if isinstance(task, str) else task.task_id - if not task_id: - raise ValueError("wait() requires a non-empty task_id (got an empty TaskAccepted.task_id).") - terminal, timed_out, gone = self._poll_to_terminal([task_id], timeout, on_progress) - if task_id in gone: - raise gone[task_id] + _segment(task_id, "wait() requires a non-empty task_id (got an empty TaskAccepted.task_id).") + terminal, timed_out, stopped = self._poll_to_terminal([task_id], timeout, on_progress, options=options) + if task_id in stopped: + raise stopped[task_id] if task_id in timed_out: raise LenzTimeoutError( message=f"wait timed out after {timeout}s", @@ -971,7 +1765,7 @@ def wait( def verify_batch_and_wait( self, *, - claims: list[VerifyBatchItem | dict[str, Any]], + claims: Sequence[VerifyBatchItem | dict[str, Any]], webhook_url: str = "", language: str = "", visibility: str = "", @@ -979,6 +1773,9 @@ def verify_batch_and_wait( idempotency_key: str | None = None, timeout: float = WAIT_TIMEOUT, on_progress: Callable[[str, Progress], None] | None = None, + idempotency: bool = True, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> list[BatchItemResult]: """Submit a batch and poll every item to a terminal state. @@ -986,7 +1783,11 @@ def verify_batch_and_wait( order**. Never raises on a per-item outcome — a claim that fails, pauses for input, or times out becomes a ``BatchItemResult`` with the matching ``status`` rather than an exception. (Transport/auth errors on the - initial submit still raise.) + initial submit still raise.) An item whose poll is answered 404 or 410, + or in another API version (``LenzApiVersionError``), is ``failed`` at + once, with no ``status_detail``; the other items keep being polled. A + poll answered 401 / 403 refuses the key itself, so the whole call + raises ``LenzAuthError``. ``on_progress(task_id, progress)`` fires per still-running item per round; the ``task_id`` is what tells you which claim moved. @@ -994,24 +1795,41 @@ def verify_batch_and_wait( ``timeout`` defaults to ``WAIT_TIMEOUT`` (300s); items still running then come back with ``status="timeout"`` and stay resumable by ``task_id``. + + ``idempotency`` / ``idempotency_key``: as on ``verify_batch``. + + ``max_retries``: the submit's retries (each poll is one request and a + failed poll is read again on the next round). ``extra_headers``: added + to the submit and to every poll. See :meth:`Lenz.with_options`; the + timeout of each request comes from the copy or the client, since + ``timeout`` here is how long to wait. """ - accepted = self._verify_batch( - claims=claims, - webhook_url=webhook_url, - language=language, - visibility=visibility, - depth=depth, - idempotency_key=idempotency_key, - ) - ids = [it.task_id for it in accepted.items if it.task_id] - terminal, timed_out, gone = self._poll_to_terminal(ids, timeout, on_progress) + # Checked and snapshotted once: the submit and every poll use these. + options = _call_options(None, max_retries, extra_headers, "verify_batch_and_wait()") + key = _call_key(idempotency_key, idempotency) + with _carrying_key(key): + accepted = self._verify_batch( + claims=claims, + webhook_url=webhook_url, + language=language, + visibility=visibility, + depth=depth, + idempotency_key=key, + max_retries=options.max_retries, + extra_headers=dict(options.headers), + ) + ids = [it.task_id for it in accepted.items if it.task_id] + terminal, timed_out, stopped = self._poll_to_terminal( + ids, timeout, on_progress, options=_CallOptions(headers=options.headers) + ) results: list[BatchItemResult] = [] for it in accepted.items: # preserve input order status = terminal.get(it.task_id) - if it.task_id in gone: - # Removed by the account's retention period (410): terminal, - # with no status to carry. + if it.task_id in stopped: + # An error no later poll could change: removed by the + # account's retention period (410), an unknown task (404) or + # an answer in another API version. Terminal, with no status. results.append(BatchItemResult(task_id=it.task_id, claim_text=it.claim_text, status="failed")) elif not it.task_id or it.task_id in timed_out or status is None: results.append(BatchItemResult(task_id=it.task_id, claim_text=it.claim_text, status="timeout")) @@ -1055,6 +1873,9 @@ def review( webhook_url: str | None = None, visibility: str = "private", idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> ReviewStarted: """Start a review of a draft. Returns at once with a ``review_id``; the review takes two to four minutes. Use ``review_and_wait`` to @@ -1107,7 +1928,11 @@ def review( submit cannot start a second review; a resend with the same key within 24 hours returns the same review, and a new key is a new review. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + options = _call_options(timeout, max_retries, extra_headers, "review()") if not text or not text.strip(): raise ValueError("review() needs the draft text, or one public http(s) URL.") payload: dict[str, Any] = {"text": text} @@ -1142,7 +1967,14 @@ def review( payload["escalate"] = escalate headers = {"Idempotency-Key": idempotency_key or uuid.uuid4().hex} try: - body = self._request("POST", "/review", json=payload, headers=headers) + body = self._request( + "POST", + "/review", + json=payload, + headers=headers, + conflict_settles=_names_the_job("review_id"), + options=options, + ) except LenzError as exc: # A retried submit (same key) that lands while the first attempt's # review is still being created answers 409 with that review's id: @@ -1157,33 +1989,102 @@ def review( ): return ReviewStarted(review_id=review_id, status="queued") raise - return ReviewStarted.model_validate(body) + with _carrying_key(headers["Idempotency-Key"], unreadable=True): + return ReviewStarted.model_validate(body) @overload - def get_review(self, review_id: str) -> ReviewFull: ... + def get_review( + self, + review_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> ReviewFull: ... @overload - def get_review(self, review_id: str, *, view: Literal["full"]) -> ReviewFull: ... + def get_review( + self, + review_id: str, + *, + view: Literal["full"], + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> ReviewFull: ... @overload - def get_review(self, review_id: str, *, view: Literal["issues"]) -> ReviewIssues: ... + def get_review( + self, + review_id: str, + *, + view: Literal["issues"], + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> ReviewIssues: ... - def get_review(self, review_id: str, *, view: str = "full") -> ReviewFull | ReviewIssues: + def get_review( + self, + review_id: str, + *, + view: str = "full", + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> ReviewFull | ReviewIssues: """Read a review. ``view="issues"`` returns just the issues and the failures (``ReviewIssues``, no ``claims``); the default returns every claim (``ReviewFull``). Raises :class:`LenzGoneError` (410) once the account's retention period has removed the review. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ - if not review_id: - raise ValueError("get_review() needs a review_id.") + options = _call_options(timeout, max_retries, extra_headers, "get_review()") + rid = _segment(review_id, "get_review() needs a review_id.") if view == "issues": - body = self._request("GET", f"/reviews/{review_id}", params={"view": "issues"}) + body = self._request("GET", f"/reviews/{rid}", params={"view": "issues"}, options=options) return ReviewIssues.model_validate(body) if view != "full": raise ValueError(f"view must be 'full' or 'issues' (got {view!r}).") - return ReviewFull.model_validate(self._request("GET", f"/reviews/{review_id}")) + return ReviewFull.model_validate(self._request("GET", f"/reviews/{rid}", options=options)) + + def cancel_review( + self, + review_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> ReviewFull: + """Stop a review (``POST /reviews/{review_id}/cancel``), including the + deep checks it started. Returns the review as it stands afterwards, the + full view: ``status`` is ``"cancelled"``. + + A review that had already ended is returned unchanged (``completed`` or + ``failed``), and cancelling again is safe. What was not delivered is + not charged: see ``credits.charged`` on the result for what the review + cost. + + Raises :class:`LenzNotFoundError` (404) for an unknown review or + another account's, and :class:`LenzGoneError` (410) once the account's + retention period has removed it. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + + Since 3.0. + """ + options = _call_options(timeout, max_retries, extra_headers, "cancel_review()") + rid = _segment(review_id, "cancel_review() needs a review_id.") + path = f"/reviews/{rid}/cancel" + body = self._request("POST", path, options=options) + if not _is_full_review_body(body, review_id): + raise _unexpected_answer("POST", path) + return ReviewFull.model_validate(body) def review_and_wait( self, @@ -1191,9 +2092,22 @@ def review_and_wait( *, timeout: float = 600.0, on_update: Callable[[ReviewFull], None] | None = None, - **kw: Any, + verdicts: list[str] | None = None, + confidence: list[str] | None = None, + max_assessments: int | None = None, + max_verifications: int | None = None, + depth: str | None = None, + max_citations: int | None = None, + suggest_edits: bool = False, + language: str = "", + webhook_url: str | None = None, + visibility: str = "private", + idempotency_key: str | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> ReviewFull: - """Start a review (``review(text, **kw)``) and poll it until it ends. + """Start a review (``review(text, ...)``, which documents every option + but ``timeout`` and ``on_update``) and poll it until it ends. Returns the completed ``ReviewFull``: read ``outcome``, then ``issues``. Polls on the review's own ``poll_after_seconds`` (never @@ -1203,13 +2117,42 @@ def review_and_wait( silent. Raises :class:`ReviewFailed` when the review ends ``failed`` (its - ``error_code`` and ``hint`` say why), and :class:`ReviewTimeout` when + ``failure`` says why), and :class:`ReviewTimeout` when ``timeout`` seconds pass first: the review keeps running, and the - error carries its ``review_id`` and the last body read. + error carries its ``review_id`` and the last body read. The clock + starts once the submit is accepted. + + ``max_retries``: the submit's retries (each poll is one request and a + failed poll is read again on the next round). ``extra_headers``: added + to the submit and to every poll. See :meth:`Lenz.with_options`; the + timeout of each request comes from the copy or the client, since + ``timeout`` here is how long to wait. """ - started = self.review(text, **kw) - logger.info("Submitted review: %s", started.review_id) - return self._wait_review(started.review_id, timeout=timeout, on_update=on_update) + # Checked and snapshotted once: the submit and every poll use these. + options = _call_options(None, max_retries, extra_headers, "review_and_wait()") + # The key is minted here (as ``review`` would) so the wait's errors carry it too. + key = idempotency_key or uuid.uuid4().hex + with _carrying_key(key): + started = self.review( + text, + verdicts=verdicts, + confidence=confidence, + max_assessments=max_assessments, + max_verifications=max_verifications, + depth=depth, + max_citations=max_citations, + suggest_edits=suggest_edits, + language=language, + webhook_url=webhook_url, + visibility=visibility, + idempotency_key=key, + # Only the options given, so a 2.21 ``review`` override is still called. + **_given(options), + ) + logger.info("Submitted review: %s", started.review_id) + return self._wait_review( + started.review_id, timeout=timeout, on_update=on_update, extra_headers=dict(options.headers) + ) # ── /citecheck: the citation check on its own ── @@ -1222,6 +2165,9 @@ def citecheck( language: str = "", webhook_url: str | None = None, idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> CitecheckStarted: """Start a citation check. Returns at once with a ``citecheck_id``; use ``citecheck_and_wait`` to block until it ends, or @@ -1246,7 +2192,11 @@ def citecheck( default webhook URL, ``""`` sends none, a URL sends them there. An ``Idempotency-Key`` is generated when you pass none: a resend with the same key within 24 hours returns the same check. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. """ + options = _call_options(timeout, max_retries, extra_headers, "citecheck()") has_text = bool(text and text.strip()) if has_text == (pairs is not None): raise ValueError("citecheck() needs exactly one of text and pairs.") @@ -1261,7 +2211,14 @@ def citecheck( payload["webhook_url"] = webhook_url headers = {"Idempotency-Key": idempotency_key or uuid.uuid4().hex} try: - body = self._request("POST", "/citecheck", json=payload, headers=headers) + body = self._request( + "POST", + "/citecheck", + json=payload, + headers=headers, + conflict_settles=_names_the_job("citecheck_id"), + options=options, + ) except LenzError as exc: # A retried submit (same key) that lands while the first attempt's # check is still being created answers 409 naming that check: it @@ -1271,14 +2228,60 @@ def citecheck( if exc.status_code == 409 and exc.code == "idempotency_conflict" and isinstance(existing, str) and existing: return CitecheckStarted(citecheck_id=existing, status="queued") raise - return CitecheckStarted.model_validate(body) + with _carrying_key(headers["Idempotency-Key"], unreadable=True): + return CitecheckStarted.model_validate(body) - def get_citecheck(self, citecheck_id: str) -> Citecheck: + def get_citecheck( + self, + citecheck_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> Citecheck: """Read a citation check. Raises :class:`LenzGoneError` (410) once - the account's retention period has removed it.""" - if not citecheck_id: - raise ValueError("get_citecheck() needs a citecheck_id.") - return Citecheck.model_validate(self._request("GET", f"/citechecks/{citecheck_id}")) + the account's retention period has removed it. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + """ + options = _call_options(timeout, max_retries, extra_headers, "get_citecheck()") + cid = _segment(citecheck_id, "get_citecheck() needs a citecheck_id.") + return Citecheck.model_validate(self._request("GET", f"/citechecks/{cid}", options=options)) + + def cancel_citecheck( + self, + citecheck_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> Citecheck: + """Stop a citation check (``POST /citechecks/{citecheck_id}/cancel``). + Returns the check as it stands afterwards: ``status`` is + ``"cancelled"``. + + You are charged only for the citations it checked before the cancel; + the rest are refunded (``credits.charged``). A check that had already + ended is returned unchanged (``completed`` or ``failed``), and + cancelling again is safe. + + Raises :class:`LenzNotFoundError` (404) for an unknown check, another + account's, or the id of a review, and :class:`LenzGoneError` (410) once + the account's retention period has removed it. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + + Since 3.0. + """ + options = _call_options(timeout, max_retries, extra_headers, "cancel_citecheck()") + cid = _segment(citecheck_id, "cancel_citecheck() needs a citecheck_id.") + path = f"/citechecks/{cid}/cancel" + body = self._request("POST", path, options=options) + if not _is_citecheck_body(body, citecheck_id): + raise _unexpected_answer("POST", path) + return Citecheck.model_validate(body) def citecheck_and_wait( self, @@ -1286,9 +2289,16 @@ def citecheck_and_wait( *, timeout: float = 600.0, on_update: Callable[[Citecheck], None] | None = None, - **kw: Any, + pairs: list[CitationPair] | None = None, + max_citations: int | None = None, + language: str = "", + webhook_url: str | None = None, + idempotency_key: str | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> Citecheck: - """Start a citation check (``citecheck(text, **kw)``) and poll it until + """Start a citation check (``citecheck(text, ...)``, which documents + every option but ``timeout`` and ``on_update``) and poll it until it ends. Returns the completed :class:`Citecheck`: read ``outcome``, then ``citation_issues``. @@ -1297,11 +2307,34 @@ def citecheck_and_wait( changed. Raises :class:`CitecheckFailed` when the check ends ``failed``, and :class:`CitecheckTimeout` when ``timeout`` seconds pass first: the check keeps running, and the error carries its - ``citecheck_id`` and the last body read. + ``citecheck_id`` and the last body read. The clock starts once the + submit is accepted. + + ``max_retries``: the submit's retries (each poll is one request and a + failed poll is read again on the next round). ``extra_headers``: added + to the submit and to every poll. See :meth:`Lenz.with_options`; the + timeout of each request comes from the copy or the client, since + ``timeout`` here is how long to wait. """ - started = self.citecheck(text, **kw) - logger.info("Submitted citation check: %s", started.citecheck_id) - return self._wait_citecheck(started.citecheck_id, timeout=timeout, on_update=on_update) + # Checked and snapshotted once: the submit and every poll use these. + options = _call_options(None, max_retries, extra_headers, "citecheck_and_wait()") + # The key is minted here (as ``citecheck`` would) so the wait's errors carry it too. + key = idempotency_key or uuid.uuid4().hex + with _carrying_key(key): + started = self.citecheck( + text, + pairs=pairs, + max_citations=max_citations, + language=language, + webhook_url=webhook_url, + idempotency_key=key, + # Only the options given, so a 2.21 ``citecheck`` override is still called. + **_given(options), + ) + logger.info("Submitted citation check: %s", started.citecheck_id) + return self._wait_citecheck( + started.citecheck_id, timeout=timeout, on_update=on_update, extra_headers=dict(options.headers) + ) def _wait_citecheck( self, @@ -1309,6 +2342,7 @@ def _wait_citecheck( *, timeout: float, on_update: Callable[[Citecheck], None] | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> Citecheck: """The poll loop behind ``citecheck_and_wait`` (and ``lenz citecheck --resume``).""" @@ -1323,12 +2357,13 @@ def timed_out(last: Citecheck | None) -> Exception: ) return self._wait_job( - f"/citechecks/{citecheck_id}", + f"/citechecks/{_segment(citecheck_id, '_wait_citecheck() needs a citecheck_id.')}", timeout=timeout, on_update=on_update, parse=lambda body: Citecheck.model_validate(body) if _is_citecheck_body(body, citecheck_id) else None, failed=_citecheck_failed, timed_out=timed_out, + options=_call_options(None, None, extra_headers, "citecheck_and_wait()"), ) def _wait_review( @@ -1337,6 +2372,7 @@ def _wait_review( *, timeout: float, on_update: Callable[[ReviewFull], None] | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> ReviewFull: """The poll loop behind ``review_and_wait`` (and ``lenz review --resume``).""" @@ -1351,12 +2387,13 @@ def timed_out(last: ReviewFull | None) -> Exception: ) return self._wait_job( - f"/reviews/{review_id}", + f"/reviews/{_segment(review_id, '_wait_review() needs a review_id.')}", timeout=timeout, on_update=on_update, parse=lambda body: ReviewFull.model_validate(body) if _is_full_review_body(body, review_id) else None, failed=_review_failed, timed_out=timed_out, + options=_call_options(None, None, extra_headers, "review_and_wait()"), ) def _wait_job( @@ -1368,6 +2405,7 @@ def _wait_job( parse: Callable[[Any], _Job | None], failed: Callable[[_Job], Exception], timed_out: Callable[[_Job | None], Exception], + options: _CallOptions = _NO_OPTIONS, ) -> _Job: """The poll loop behind every ``*_and_wait`` of an async job (a review, a citation check). @@ -1382,18 +2420,28 @@ def _wait_job( deadline = time.monotonic() + timeout last: _Job | None = None last_dump: dict[str, Any] | None = None + first = timeout <= 0 # ``timeout <= 0`` reads once, as in 2.x while True: # One request per poll, bounded by what is left of the deadline: # the client's own retry ladder inside a poll could run minutes # past it. A failed poll is retried on the next round instead. + # Once the deadline is spent no poll starts (``timeout <= 0`` + # still reads once, as in 2.x). stated_wait: float | None = None job: _Job | None = None remaining = deadline - time.monotonic() + if remaining <= 0 and not first: + raise timed_out(last) + first = False try: - body = self._request("GET", path, max_retries=0, timeout=max(1.0, min(remaining, self._timeout))) + body = self._request("GET", path, max_retries=0, timeout=self._poll_timeout(remaining), options=options) job = parse(body) if job is None: raise ValueError("not this job's body") + except UnicodeEncodeError: + # The request could not be built (nothing was sent): no later + # poll would do better, so it is not read as an unreadable body. + raise except ValueError: # A body this release cannot read (pydantic's ValidationError # is a ValueError): read again next round, as for a 5xx. @@ -1416,7 +2464,7 @@ def _wait_job( last = job if job.status == "completed": return job - if job.status == "failed": + if job.status in ("failed", "cancelled"): raise failed(job) remaining = deadline - time.monotonic() if remaining <= 0: @@ -1435,29 +2483,35 @@ def _poll_to_terminal( task_ids: list[str], timeout: float, on_progress: Callable[[str, Progress], None] | None = None, - ) -> tuple[dict[str, TaskStatus], set[str], dict[str, LenzGoneError]]: + *, + options: _CallOptions = _NO_OPTIONS, + ) -> tuple[dict[str, TaskStatus], set[str], dict[str, LenzError]]: """Round-robin poll ``task_ids`` until each reaches a terminal state - (completed / needs_input / failed) or the deadline elapses. - - Returns ``(terminal_by_id, timed_out_ids, gone_by_id)``. A timed-out task has no - ``TaskStatus`` — ``"timeout"`` is a client-side concept, never a wire - status — so it lands in the second set, not the dict. - - Ordering preserves the legacy ``verify_and_wait`` behavior: each round - polls every still-pending id once *before* the deadline check, so after - sleeping the remaining time we always poll once more and can succeed - just past the nominal deadline. The timeout is therefore approximate: - the final round finishes polling every pending id (bounded by the - per-request timeout + retries) before any still-pending ids are marked - timed out. Backoff reuses the existing 2/4/8/8…s sequence; the 10s cap + (completed / needs_input / failed / cancelled) or the deadline elapses. + + Returns ``(terminal_by_id, timed_out_ids, stopped_by_id)``. A timed-out + task has no ``TaskStatus`` — ``"timeout"`` is a client-side concept, + never a wire status — so it lands in the second set, not the dict. + + No poll starts once the deadline is spent, not even within a round: + the ids left are timed out. The one exception is ``timeout <= 0``, + which reads every status once, as in 2.x. Each poll is + ONE request whose timeout is what is left of the deadline, at most the + client's own timeout (``_poll_timeout``): the client's own retry + ladder inside a poll could run minutes past it, so a failed poll is + retried on the next round instead. Backoff reuses the existing 2/4/8/8…s sequence; the 10s cap is currently unreachable and kept only to preserve identical timing. - A per-id poll that raises ``LenzError`` (e.g. a transport blip that - outlived ``_request``'s own retries) does not abort the other ids: that - id stays pending and is retried next round. A persistent error - therefore surfaces as a timeout once the deadline passes. The one - exception is ``LenzGoneError`` (410): it is terminal, so that id stops - being polled and its error lands in ``gone_by_id``. + A per-id poll that fails with a 5xx, a 429, a network failure or any + other error a later poll can change does not abort the other ids: that + id stays pending and is polled next round (after the wait the server + stated, if it stated one), so a persistent one surfaces as a timeout + once the deadline passes. An error no later poll can change for that + id stops it at once and lands in ``stopped_by_id``: a 410 + (``LenzGoneError``, retention removed it), a 404 + (``LenzNotFoundError``) and a version error (``LenzApiVersionError``). + A 401 / 403 (``LenzAuthError``) refuses the key itself, so it is raised + for the whole wait. ``wait`` raises a stopped id's error. ``on_progress(task_id, progress)`` fires once per still-running poll. It takes the id as well as the object because this loop round-robins a @@ -1471,29 +2525,55 @@ def _poll_to_terminal( ladder when it is present and sane; garbage falls back to the ladder. """ pending = list(task_ids) - gone: dict[str, LenzGoneError] = {} + stopped: dict[str, LenzError] = {} terminal: dict[str, TaskStatus] = {} timed_out: set[str] = set() deadline = time.monotonic() + timeout backoff_idx = 0 + # ``timeout <= 0`` reads every id once, as in 2.x; otherwise no poll + # starts once the deadline is spent. + one_shot = timeout <= 0 + first_round = True while pending: still_pending: list[str] = [] server_hint: float | None = None + stated_wait: float | None = None for task_id in pending: + remaining = deadline - time.monotonic() + if remaining <= 0 and not (one_shot and first_round): + # The deadline is spent: no poll starts past it. + still_pending.append(task_id) + continue try: - status = self._get_status(task_id) - except LenzGoneError as exc: - # Terminal: retention removed it, and no later poll will - # say otherwise. The API never answers 410 for a task - # that is still running. - gone[task_id] = exc + body = self._request( + "GET", + f"/verify/status/{quote(task_id, safe='')}", + max_retries=0, + timeout=self._poll_timeout(remaining), + options=options, + ) + status = TaskStatus.model_validate(body) + except LenzAuthError: + # The key itself is refused: every other id would answer + # the same, so the whole wait ends with it. + raise + except (LenzGoneError, LenzNotFoundError, LenzApiVersionError) as exc: + # Terminal for this id: no later poll will say otherwise. + # (The API never answers 410 for a task that is still + # running; a version error answers the same again.) + stopped[task_id] = exc continue - except LenzError: + except LenzError as exc: # Don't let one id's poll failure abort the rest — retry it - # next round (bounded by the deadline below). + # next round (bounded by the deadline below), after the + # wait it stated, capped like the retry ladder caps it. + wait = getattr(exc, "retry_after", None) + if isinstance(wait, int) and not isinstance(wait, bool) and wait > 0: + wait_s = float(min(wait, MAX_RETRY_AFTER_SLEEP)) + stated_wait = wait_s if stated_wait is None else max(stated_wait, wait_s) still_pending.append(task_id) continue - if status.status in ("completed", "needs_input", "failed"): + if status.status in _TERMINAL_STATUSES: terminal[task_id] = status else: still_pending.append(task_id) @@ -1509,6 +2589,7 @@ def _poll_to_terminal( except Exception: logger.debug("on_progress callback raised for task %s", task_id, exc_info=True) pending = still_pending + first_round = False if not pending: break remaining = deadline - time.monotonic() @@ -1519,10 +2600,12 @@ def _poll_to_terminal( sleep_for = server_hint else: sleep_for = min(POLL_BACKOFF[min(backoff_idx, len(POLL_BACKOFF) - 1)], POLL_BACKOFF_CAP) + if stated_wait is not None: + sleep_for = max(sleep_for, stated_wait) sleep_for = min(sleep_for, remaining) time.sleep(sleep_for) backoff_idx += 1 - return terminal, timed_out, gone + return terminal, timed_out, stopped def _verification_from_terminal(self, status: TaskStatus, task_id: str) -> Verification: """Map a terminal ``TaskStatus`` to a ``Verification`` or raise the @@ -1548,8 +2631,9 @@ def _verification_from_terminal(self, status: TaskStatus, task_id: str) -> Verif hint=status.hint, payload=status.model_dump(), ) - # failed. Server sends the diagnostic under ``error``; fall back to the - # legacy fields for resilience. + # failed, or cancelled elsewhere (the same outcome, as in 2.x, where the + # original shape said ``failed`` with ``failure_class`` ``cancelled``). + # ``error`` is the 2.x sentence, rebuilt from ``failure``. detail = status.error or status.failure_detail or status.failure_reason or "unknown" if status.retryable: fix = "Transient provider outage — retry the same request after a short wait." @@ -1572,8 +2656,20 @@ def _verification_from_terminal(self, status: TaskStatus, task_id: str) -> Verif # ── account ── - def usage(self) -> Usage: - body = self._request("GET", "/me/usage") + def usage( + self, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> Usage: + """The account's plan, credits and per-capability usage. + + Request options (``timeout``, ``max_retries``, ``extra_headers``): + see :meth:`Lenz.with_options`. + """ + options = _call_options(timeout, max_retries, extra_headers, "usage()") + body = self._request("GET", "/me/usage", options=options) return Usage.model_validate(body) # ── verb-level submit helpers (used by the verify namespace) ── @@ -1589,7 +2685,11 @@ def _verify_submit( visibility: str = "", depth: str = "", idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> TaskAccepted: + options = _call_options(timeout, max_retries, extra_headers, "verify()") payload: dict[str, Any] = { "text": claim or text, "source_url": source_url, @@ -1597,7 +2697,7 @@ def _verify_submit( # Omit-when-empty: no ``webhook_url`` means the key's default webhook. # An empty string is never sent, so a request keeps that meaning on # every API version (a newer one reads ``""`` as "no webhook"). - if webhook_url: + if not _blank_webhook_url(webhook_url): payload["webhook_url"] = webhook_url # Omit-when-empty so existing English callers keep byte-identical # request bodies (no extra "language": "" key). @@ -1612,19 +2712,24 @@ def _verify_submit( headers = {} if idempotency_key: headers["Idempotency-Key"] = idempotency_key - body = self._request("POST", "/verify", json=payload, headers=headers) - return TaskAccepted.model_validate(body) + with _carrying_key(idempotency_key, unreadable=True): + body = self._request("POST", "/verify", json=payload, headers=headers, options=options) + return TaskAccepted.model_validate(body) def _verify_batch( self, *, - claims: list[VerifyBatchItem | dict[str, Any]], + claims: Sequence[VerifyBatchItem | dict[str, Any]], webhook_url: str = "", language: str = "", visibility: str = "", depth: str = "", idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> BatchAccepted: + options = _call_options(timeout, max_retries, extra_headers, "verify_batch()") # ``webhook_url`` and ``language`` are batch-wide defaults; any # per-item value on a claim dict overrides them server-side. # Per-item items are validated as plain dicts at runtime — the @@ -1633,7 +2738,7 @@ def _verify_batch( # runtime contract a plain dict). payload: dict[str, Any] = {"claims": [_batch_item_body(c) for c in claims]} # Omit-when-empty, as on ``verify``: no ``webhook_url`` means the key's default. - if webhook_url: + if not _blank_webhook_url(webhook_url): payload["webhook_url"] = webhook_url if language: payload["language"] = language @@ -1644,22 +2749,28 @@ def _verify_batch( headers = {} if idempotency_key: headers["Idempotency-Key"] = idempotency_key - body = self._request("POST", "/verify/batch", json=payload, headers=headers) - return BatchAccepted.model_validate(body) + with _carrying_key(idempotency_key, unreadable=True): + body = self._request("POST", "/verify/batch", json=payload, headers=headers, options=options) + return BatchAccepted.model_validate(body) + + def _poll_timeout(self, remaining: float) -> httpx.Timeout | None: + """The timeouts of one poll request: each phase (connect, read, write, + pool) the copy or the client in use configured, capped by what is left of the + wait's deadline (an unbounded phase gets just that). Past the deadline + (only a ``timeout <= 0`` wait polls then) ``None``: the copy's or the client's own. + + Reads the client actually in use, so ``Lenz(timeout=None)``, an + ``httpx.Timeout`` and an ``httpx.Client`` passed as ``http_client=`` + all work, and so does a copy's timeout (``with_options``).""" + if remaining <= 0: + return None + layer = self._options.timeout + own = self._client.timeout if isinstance(layer, NotGiven) else httpx.Timeout(layer) - def _timeout_at_least(self, floor: float) -> float | None: - """``floor`` seconds, or ``None`` (the client's own timeout) when that - is already at least as long, or unbounded. + def cap(phase: float | None) -> float: + return remaining if phase is None else min(phase, remaining) - Never a flat assignment: a caller who configured a longer timeout asked - for it, and shortening it would be this SDK quietly overruling them. It - reads the client actually in use, so an ``httpx.Client`` passed as - ``http_client=`` keeps its own setting too. - """ - current = self._client.timeout.read - if current is None or current >= floor: - return None - return floor + return httpx.Timeout(connect=cap(own.connect), read=cap(own.read), write=cap(own.write), pool=cap(own.pool)) def _extract( self, @@ -1668,9 +2779,12 @@ def _extract( language: str = "", focus: str = "", locate: bool | None = None, - timeout: float | None = None, + timeout: float | httpx.Timeout | None = None, idempotency_key: str | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> ExtractedClaims: + options = _call_options(timeout, max_retries, extra_headers, "extract()") payload: dict[str, Any] = {"text": text} if language: payload["language"] = language @@ -1682,13 +2796,14 @@ def _extract( # omitted value leaves the server default in charge. if locate is not None: payload["locate"] = locate - if timeout is None: - timeout = self._timeout_at_least(EXTRACT_TIMEOUT) headers = {} if idempotency_key: headers["Idempotency-Key"] = idempotency_key - body = self._request("POST", "/extract", json=payload, timeout=timeout, headers=headers) - return _extracted(body, locate=locate) + with _carrying_key(idempotency_key, unreadable=True): + body = self._request( + "POST", "/extract", json=payload, headers=headers, options=options, floor=EXTRACT_TIMEOUT + ) + return _extracted(body, locate=locate) def _assess( self, @@ -1697,9 +2812,12 @@ def _assess( claims: list[str] | None = None, language: str = "", suggest_rewrite: bool = False, - timeout: float | None = None, + timeout: float | httpx.Timeout | None = None, idempotency_key: str | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, ) -> AssessResponse: + options = _call_options(timeout, max_retries, extra_headers, "assess()") # The single form keeps its historical wire body (`text`). The list # form sends `claims` and never `text` — the server rejects a body # carrying both with a 422. No client-side count / length checks on @@ -1716,23 +2834,47 @@ def _assess( # idempotency body) is exactly what it was before. if suggest_rewrite: payload["suggest_rewrite"] = True - if timeout is None: - timeout = self._timeout_at_least(ASSESS_TIMEOUT) headers = {} if idempotency_key: headers["Idempotency-Key"] = idempotency_key - body = self._request("POST", "/assess", json=payload, timeout=timeout, headers=headers) - return AssessResponse.model_validate(body) + with _carrying_key(idempotency_key, unreadable=True): + body = self._request( + "POST", "/assess", json=payload, headers=headers, options=options, floor=ASSESS_TIMEOUT + ) + return AssessResponse.model_validate(body) - def _select(self, task_id: str, *, texts: list[str], idempotency_key: str | None = None) -> BatchAccepted: + def _select( + self, + task_id: str, + *, + texts: list[str], + idempotency_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> BatchAccepted: + options = _call_options(timeout, max_retries, extra_headers, "select()") + tid = _segment(task_id, "select() needs a task_id.") headers = {} if idempotency_key: headers["Idempotency-Key"] = idempotency_key - body = self._request("POST", f"/verify/{task_id}/select", json={"texts": texts}, headers=headers) - return BatchAccepted.model_validate(body) + with _carrying_key(idempotency_key, unreadable=True): + body = self._request( + "POST", f"/verify/{tid}/select", json={"texts": texts}, headers=headers, options=options + ) + return BatchAccepted.model_validate(body) - def _get_status(self, task_id: str) -> TaskStatus: - body = self._request("GET", f"/verify/status/{task_id}") + def _get_status( + self, + task_id: str, + *, + timeout: float | httpx.Timeout | None = None, + max_retries: int | None = None, + extra_headers: Mapping[str, str | None] | None = None, + ) -> TaskStatus: + options = _call_options(timeout, max_retries, extra_headers, "get_status()") + tid = _segment(task_id, "get_status() needs a task_id.") + body = self._request("GET", f"/verify/status/{tid}", options=options) return TaskStatus.model_validate(body) # ── HTTP plumbing ── @@ -1747,15 +2889,58 @@ def _request( headers: dict[str, str] | None = None, auth_required: bool = True, auth_optional: bool = False, - timeout: float | None = None, + timeout: float | httpx.Timeout | None = None, max_retries: int | None = None, + conflict_settles: Callable[[Any], bool] | None = None, + options: _CallOptions = _NO_OPTIONS, + floor: float | None = None, ) -> dict[str, Any]: - # ``max_retries`` overrides the client's retry count for this request - # only (the review wait polls with 0 and does its own pacing). - retries = self._max_retries if max_retries is None else max_retries - if auth_required and not self._api_key: - from .errors import LenzAuthError + """One API call, with the retry ladder. Every ``LenzError`` it raises + carries the ``Idempotency-Key`` it sent (``exc.idempotency_key``). + + ``options`` are the call's request options and ``floor`` the minimum + an inherited timeout gets (``_resolve``). ``timeout`` and + ``max_retries`` are the SDK's own settings for one request (a wait's + polls): when set they win over every option.""" + key = (headers or {}).get("Idempotency-Key") or None + resolved, retries, option_headers = _resolve( + options, self._options, self._client.timeout, self._max_retries, floor + ) + if timeout is not None: + resolved = httpx.Timeout(timeout) + if max_retries is not None: + retries = max_retries + with _carrying_key(key, unreadable=True): + return self._send( + method, + path, + json=json, + params=params, + headers=headers, + option_headers=option_headers, + auth_required=auth_required, + auth_optional=auth_optional, + timeout=resolved, + retries=retries, + conflict_settles=conflict_settles, + ) + def _send( + self, + method: str, + path: str, + *, + json: dict[str, Any] | None, + params: dict[str, Any] | None, + headers: dict[str, str] | None, + option_headers: Sequence[tuple[str, str]], + auth_required: bool, + auth_optional: bool, + timeout: httpx.Timeout | None, + retries: int, + conflict_settles: Callable[[Any], bool] | None, + ) -> dict[str, Any]: + if auth_required and not self._api_key: raise LenzAuthError( message="API key required", cause="This method requires authentication; no API key was provided.", @@ -1768,6 +2953,14 @@ def _request( url = f"{self._base_url}{path}" req_headers = dict(headers or {}) + # The request options' headers, over the method's own (case-insensitive; + # none is added when no option was given). httpx then puts the + # ``httpx.Client``'s own headers under them, so an option header also + # replaces a default one like ``User-Agent``. + for name, value in option_headers: + for same in [k for k in req_headers if k.lower() == name.lower()]: + del req_headers[same] + req_headers[name] = value # Attach the bearer on authed endpoints AND on opt-in optional-auth ones # (`auth_optional=True`). The server returns a caller's own private/hidden # rows only to the owning bearer, so `verifications.get` must send a key it @@ -1777,9 +2970,11 @@ def _request( if self._api_key and (auth_required or auth_optional): req_headers["Authorization"] = f"Bearer {self._api_key}" req_headers.setdefault("Content-Type", "application/json") - # A per-call ``timeout`` overrides the client-wide one for this request - # only; ``None`` keeps httpx on the client default. - req_timeout = httpx.USE_CLIENT_DEFAULT if timeout is None else httpx.Timeout(timeout) + # The version is part of the request, not of the HTTP client: a client + # passed as ``http_client=`` may carry no version header or a stale one. + req_headers[_VERSION_HEADER] = API_VERSION + # ``None``: the ``httpx.Client``'s own timeout. + req_timeout = httpx.USE_CLIENT_DEFAULT if timeout is None else timeout last_exc: Exception | None = None for attempt in range(retries + 1): @@ -1787,10 +2982,19 @@ def _request( response = self._client.request( method, url, json=json, params=params, headers=req_headers, timeout=req_timeout ) - except (httpx.TimeoutException, httpx.NetworkError) as exc: + except (httpx.UnsupportedProtocol, httpx.LocalProtocolError): + # The request could never be sent (a bad URL scheme, a request + # httpx refuses to write): a programming error, not a network one. + raise + except httpx.TransportError as exc: + # Connect / read / write failures, timeouts, a server that + # hung up (RemoteProtocolError), a proxy failure: worth sending + # again. last_exc = exc if attempt >= retries: - raise LenzAPIError( + # Subclasses of LenzAPIError, which is what 2.x raised. + cls = LenzRequestTimeoutError if isinstance(exc, httpx.TimeoutException) else LenzConnectionError + raise cls( message=f"{method} {path} failed after {attempt + 1} attempts: {exc}", cause=str(exc), fix="Check your network connection; verify base_url is reachable.", @@ -1799,9 +3003,27 @@ def _request( time.sleep(_retry_sleep(attempt)) continue + _check_served_version(response) + if response.status_code < 400: return response.json() if response.content else {} + # A 409 ``idempotency_conflict``: the first request with this key is + # still running. Send the SAME key and body again after the stated + # wait (capped) or the backoff, inside this call's retry budget; a + # new key would run the work twice. A conflict that names the job + # the first request created settles the call (``conflict_settles``). + if ( + attempt < retries + and response.status_code == 409 + and req_headers.get("Idempotency-Key") + and _body_error_code(response) == "idempotency_conflict" + and not (conflict_settles is not None and conflict_settles(_json_or_none(response))) + ): + stated = _stated_retry_after(response) + time.sleep(stated if stated is not None and stated <= MAX_RETRY_AFTER_SLEEP else _retry_sleep(attempt)) + continue + # Error path. Retry on 5xx and 429; otherwise raise immediately. # # A stated wait is honored only up to MAX_RETRY_AFTER_SLEEP. Past @@ -1844,6 +3066,7 @@ def _request( response.status_code, response.content, dict(response.headers), + endpoint=(method, path), ) # Shouldn't reach here, but guard. @@ -1852,6 +3075,28 @@ def _request( raise LenzAPIError(message=f"{method} {path} failed without diagnostic") +def _unexpected_answer(method: str, path: str) -> LenzAPIError: + """A 200 whose body is not the thing asked for (a proxy page, another + task's body): an error, never a default-valued result.""" + return LenzAPIError( + message=f"{method} {path} returned an unexpected response body.", + cause="The answer is not the shape the API documents for this call.", + fix="Retry; if it persists, contact support (https://lenz.io/contact) with the request.", + doc_url="https://lenz.io/docs/errors", + ) + + +def _is_cancel_body(body: Any, task_id: str) -> bool: + """Whether a 200 is this task's cancel result: its id, a boolean + ``cancelled`` and a status.""" + return ( + isinstance(body, dict) + and body.get("task_id") == task_id + and isinstance(body.get("cancelled"), bool) + and isinstance(body.get("status"), str) + ) + + def _is_full_review_body(body: Any, review_id: str) -> bool: """Whether a 200 is this review's full view: its id, a status, and the three lists. A proxy page or another review's body is a failed poll, never @@ -1876,8 +3121,26 @@ def _is_citecheck_body(body: Any, citecheck_id: str) -> bool: ) +#: What the original response shape said of a task cancelled elsewhere, which +#: the current one states as the status ``cancelled`` and no failure block. +_CANCELLED_FAILURE = { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": False, + "docs_url": "https://lenz.io/docs/errors#cancelled", +} + + +def _failure_of(job: Citecheck | ReviewFull) -> FailureBlock | None: + """Why a job ended without a result: its failure block, or the cancelled + one for a job cancelled elsewhere.""" + if job.failure is None and job.status == "cancelled": + return FailureBlock.model_validate(_CANCELLED_FAILURE) + return job.failure + + def _citecheck_failed(check: Citecheck) -> CitecheckFailed: - failure = check.failure + failure = _failure_of(check) reason = (failure.failure_reason if failure else None) or "" hint = (failure.hint if failure else None) or "" retryable = failure.retryable if failure is not None and isinstance(failure.retryable, bool) else None @@ -1897,7 +3160,7 @@ def _citecheck_failed(check: Citecheck) -> CitecheckFailed: def _review_failed(review: ReviewFull) -> ReviewFailed: - failure = review.failure + failure = _failure_of(review) reason = (failure.failure_reason if failure else None) or "" hint = (failure.hint if failure else None) or "" retryable = failure.retryable if failure is not None and isinstance(failure.retryable, bool) else None @@ -1916,6 +3179,34 @@ def _review_failed(review: ReviewFull) -> ReviewFailed: ) +def _check_served_version(response: httpx.Response) -> None: + """Refuse an answer in an API version this SDK does not read. + + A response without the header proceeds (a proxy or an old server may not + send it). Webhook payloads never pass through here. + """ + served = (response.headers.get(_VERSION_HEADER) or "").strip() + if not served or served == API_VERSION: + return + try: + parsed = response.json() if response.content else None + except ValueError: + parsed = None + raise LenzApiVersionError( + message=f"The API answered in version {served}; lenz-io 3.x reads {API_VERSION} only.", + cause=f"The response carries {_VERSION_HEADER}: {served}.", + fix=( + "If this persists, contact support (https://lenz.io/contact) with the request id; " + "lenz-io 2.x reads both versions." + ), + doc_url="https://lenz.io/docs/errors", + request_id=response.headers.get("X-Request-ID") or "", + status_code=response.status_code, + body=parsed if isinstance(parsed, dict) else None, + api_version=served, + ) + + def _stated_retry_after(response: httpx.Response) -> int | None: """Seconds the server says to wait, or None if it didn't say. @@ -1946,11 +3237,24 @@ def _stated_retry_after(response: httpx.Response) -> int | None: # Floored at zero: `Retry-After: -5` is malformed, and time.sleep() # raises ValueError on a negative — which would escape the retry # ladder as a bare ValueError, defeating the typed-exception contract. - return max(0, int(float(raw))) + seconds = float(raw) + if not math.isfinite(seconds): + # ``inf``, ``nan``, ``1e999``: no stated wait (the backoff ladder + # runs), as in the Node SDK; never an OverflowError. + return None + # A huge finite wait is clamped (still past every cap). + return int(min(max(seconds, 0.0), MAX_TIMEOUT_SECONDS)) except (TypeError, ValueError): return None +def _json_or_none(response: httpx.Response) -> Any: + try: + return response.json() + except Exception: + return None + + def _body_error_code(response: httpx.Response) -> str: """The server's machine-readable ``code`` from the body, or ``""``. @@ -1986,4 +3290,4 @@ def _retry_sleep(attempt: int) -> float: return RETRY_BACKOFF[-1] -__all__ = ["API_VERSION", "DEFAULT_BASE_URL", "Lenz", "VerifyBatchItem"] +__all__ = ["API_VERSION", "DEFAULT_BASE_URL", "NOT_GIVEN", "Lenz", "NotGiven", "VerifyBatchItem"] diff --git a/src/lenz_io/errors.py b/src/lenz_io/errors.py index 2f6579e..c241b25 100644 --- a/src/lenz_io/errors.py +++ b/src/lenz_io/errors.py @@ -20,9 +20,12 @@ from __future__ import annotations import json +import re import warnings from typing import TYPE_CHECKING, Any +from typing_extensions import deprecated + if TYPE_CHECKING: from .models import Citecheck, ReviewFull @@ -46,8 +49,27 @@ class LenzError(Exception): ``"no_credits"``. Present on 402, 403 and 429; ``""`` when the server sent none. Branch on this rather than on message text. * ``body`` — parsed JSON response body if available + * ``retryable`` — whether sending the same request again can succeed: + ``True`` for a network failure, a transport timeout, a 429, a 5xx and + a 409 ``idempotency_conflict`` (the first request with that key is + still running) or ``verification_not_ready``; ``False`` for any other + 4xx and a version error; ``None`` when there was no HTTP status (a + missing key, a ``*_and_wait`` timeout, a needs-input pause, a bad + webhook signature). A failed + verification, review or citation check carries the server's value + (``None`` when it sent none), and a boolean ``retryable`` in the + response's ``failure`` block always wins. Since 3.0; set on every + instance at construction, and a ``retryable=`` passed in wins. """ + retryable: bool | None + #: The ``Idempotency-Key`` the failed call sent, or ``None`` when it sent + #: none. To resend safely, pass it back (``idempotency_key=exc.idempotency_key``): + #: the server then replays the first answer, or reports the first request + #: still running, instead of running it twice. A plain new call sends a + #: new key, and can run (and charge) the work a second time. Since 3.0. + idempotency_key: str | None = None + def __init__( self, *, @@ -70,12 +92,25 @@ def __init__( self.status_code = status_code self.code = code self.body = body + self.retryable = self._derived_retryable() # Per-subclass enrichment (retry_after, task_id, etc.). Set on the # instance so they're accessible as ``exc.task_id`` regardless of # which subclass raised. for k, v in extra.items(): setattr(self, k, v) + def _derived_retryable(self) -> bool | None: + """``retryable`` when nothing more specific says (see the class + docstring). A class with its own rule overrides this.""" + status = self.status_code + if status == 429 or 500 <= status < 600: + return True + if status == 409 and _resendable_409(self): + return True + if 400 <= status < 500: + return False + return None + def __str__(self) -> str: # pragma: no cover - trivial lines = [self.message or self.__class__.__name__] if self.cause: @@ -89,6 +124,18 @@ def __str__(self) -> str: # pragma: no cover - trivial return "\n".join(lines) +#: 409 codes that mean "not yet": the same request, sent again later (with the +#: same ``Idempotency-Key``), can succeed. +_RESENDABLE_409_CODES = ("idempotency_conflict", "verification_not_ready") + + +def _resendable_409(err: LenzError) -> bool: + """Whether a 409 says "not yet". Reads the body as sent too: the 2.x + attributes leave ``code`` empty on endpoints whose original body had none.""" + sent = err.body.get("code") if isinstance(err.body, dict) else None + return err.code in _RESENDABLE_409_CODES or sent in _RESENDABLE_409_CODES + + class LenzAuthError(LenzError): """401 / 403 — the credential is missing, invalid, expired, or revoked. @@ -145,8 +192,9 @@ class LenzQuotaExceededError(LenzError): cost: int | None = None @property + @deprecated("Use `remaining`.", category=None) def credits_remaining(self) -> int: - """Deprecated alias for ``remaining``. Removed in 3.0. + """Deprecated: use `remaining`. To be removed in a future major release. Returns 0 when ``remaining`` is unknown, which is exactly the ambiguity ``remaining`` exists to fix — migrate to ``remaining``. @@ -178,7 +226,7 @@ def credits_remaining(self, value: int | None) -> None: @staticmethod def _warn_credits_remaining() -> None: warnings.warn( - "credits_remaining is deprecated and will be removed in 3.0; " + "credits_remaining is deprecated and will be removed in a future major release; " "use `remaining`, which is None when the server didn't report a " "balance (credits_remaining reports that as 0). It is NOT the " "API's `credits_remaining` body field — that is the credit pool, " @@ -228,10 +276,53 @@ class LenzAPIError(LenzError): ``retry_after`` is the wait the response stated (``Retry-After``), or ``None`` when it stated none. + + Network failures are :class:`LenzConnectionError`, a subclass. """ retry_after: int | None = None + def _derived_retryable(self) -> bool | None: + # Any status this class is raised for is a 5xx; without one (built by + # hand, or a request that failed with no diagnostic) it is unknown. + return None if self.status_code == 0 else True + + +class LenzConnectionError(LenzAPIError): + """The request never got an answer: the connection failed or broke + (DNS, refused, reset, TLS, a server or proxy that hung up), after the + SDK's own retries. + + A subclass of :class:`LenzAPIError`, which is what 2.x raised, so an + existing ``except LenzAPIError`` keeps catching it. ``__cause__`` is the + underlying ``httpx`` exception and ``status_code`` is 0. ``retryable`` is + ``True``: the same request can succeed once the network is back (calls + that charge send an ``Idempotency-Key``). Resend with the same key: pass + ``idempotency_key=exc.idempotency_key`` back, and the server replays the + first answer if the first request did arrive. A plain new call sends a new + key, and can run (and charge) the work twice. + """ + + def _derived_retryable(self) -> bool | None: + return True + + +class LenzRequestTimeoutError(LenzConnectionError): + """One HTTP request got no answer within its timeout (``Lenz(timeout=)`` + or the call's ``timeout=``), after the SDK's own retries. + + Not :class:`LenzTimeoutError`, which is a ``*_and_wait`` helper reaching + its own deadline while the job keeps running. A subclass of + :class:`LenzConnectionError` and so of :class:`LenzAPIError`, which is + what 2.x raised. + + The request may have reached the server and be running. Resend only with + the same key (``idempotency_key=exc.idempotency_key``): the server then + replays the first answer (or answers 409 ``idempotency_conflict`` while it + still runs) instead of running it again. A plain new call sends a new key, + and can run (and charge) the work twice. + """ + class LenzUpstreamUnavailableError(LenzAPIError): """503 with ``code`` ``upstream_unavailable`` or ``capacity``. @@ -255,6 +346,9 @@ class LenzTimeoutError(LenzError): """``verify_and_wait`` exceeded the configured timeout. ``task_id`` is set so callers can resume via ``client.get_status(task_id)``. + The job keeps running server-side: read it later by its id rather than + resubmit (``retryable`` is ``None``: no request failed). A single HTTP request that timed out is + :class:`LenzRequestTimeoutError` instead. """ task_id: str = "" @@ -297,6 +391,10 @@ class LenzPipelineError(LenzError): retryable: bool | None = None hint: str = "" + def _derived_retryable(self) -> bool | None: + # The server's value or nothing: never guessed from a status. + return None + class LenzVerificationNotReadyError(LenzError): """409 — ``verifications.get`` was handed the ``task_id`` of a run that is @@ -317,6 +415,17 @@ class LenzVerificationNotReadyError(LenzError): hint: str = "" +class LenzNotFoundError(LenzError): + """404 — nothing was found under the id (or path) the request names, for + the API key it was sent with. + + Check the id and the key: resending the same request will not find it + (``retryable`` is ``False``). A subclass of :class:`LenzError`, which is + what 2.x raised for a 404. An id whose verification its account's + retention period removed answers 410 instead (:class:`LenzGoneError`). + """ + + class LenzGoneError(LenzError): """410 — the verification existed, and its account's retention period has since removed it. @@ -328,8 +437,8 @@ class LenzGoneError(LenzError): Raised by ``verifications.get``, ``get_status`` on a completed task, ``verifications.related``, ``ask.send`` and ``ask.history``. ``wait`` raises it at once instead - of polling to the deadline. A 404 stays a plain :class:`LenzError`: only - an id you could read before answers 410. + of polling to the deadline. A 404 is :class:`LenzNotFoundError`: only an + id you could read before answers 410. """ purged_at: str | None = None @@ -398,6 +507,41 @@ class LenzWebhookSignatureError(LenzError): """ +class LenzApiVersionError(LenzError): + """A response named an API version this SDK does not read. + + Every API response names the version that served it in the + ``X-Lenz-API-Version`` header. lenz-io 3.x asks for ``2026-10-11`` and reads + that version's response shape only, so an answer in another version (in + practice ``2026-05-13``, which a reply replayed from before the account's + version changed, or a server pinned to the older version, still sends) is + refused instead of being misread. Applies to every response of a client + call, success or error; never to webhook payloads. + + Fields: + * ``api_version`` — the version the response named. + * ``status_code`` — the response's HTTP status. + * ``body`` — the response body as sent (parsed JSON), or ``None`` + when it was not a JSON object. + """ + + def __init__(self, *, api_version: str = "", **kwargs: Any) -> None: + super().__init__(api_version=api_version, **kwargs) + self.api_version = api_version + + def _derived_retryable(self) -> bool | None: + # The same request answers in the same version again. + return False + + +#: The job errors under the names the other error classes follow (``...Error``), +#: the names the Node SDK uses. The same classes: catch either name. +ReviewFailedError = ReviewFailed +ReviewTimeoutError = ReviewTimeout +CitecheckFailedError = CitecheckFailed +CitecheckTimeoutError = CitecheckTimeout + + # ── Mapping table ──────────────────────────────────────────────────────── # # Single source of truth for HTTP status -> exception class + default @@ -514,17 +658,195 @@ def _parse_body(raw: bytes | str | None) -> dict[str, Any]: return parsed if isinstance(parsed, dict) else {} +# ── The original error bodies, read from a newer-shape body ───────────── +# +# The API's current response shape (what 3.0 asks for) gives every error one +# envelope: ``detail`` is a sentence, ``code`` is always set, a 422 lists its +# fields in ``errors``, and waits and links have one name each. The 2.x shape +# differed by endpoint. ``_original_error`` rebuilds the 2.x body by endpoint +# (the same rules as the Node SDK), so every attribute of the exception keeps +# its 2.x value; ``exc.body`` is the body as sent. + +#: /review and /citecheck (submit and read) kept their own error envelope. +_REVIEW_FAMILY = re.compile(r"^/(?:review|reviews/[^/]+|citecheck|citechecks/[^/]+)$") + +#: The cancel calls are new in 3.0: no earlier shape to read, so the server's +#: ``code`` stays on the error (``not_found``, ``use_review_cancel``, ...). +_CANCEL_PATH = re.compile(r"^/(?:verify|reviews|citechecks)/[^/]+/cancel$") +_SELECT_PATH = re.compile(r"^/verify/[^/]+/select$") + +#: Codes the newer shape sends where the original error carried no ``code`` +#: (outside /review and /citecheck, which always sent one). +_CODELESS = frozenset( + { + "not_authenticated", + "not_found", + "idempotency_body_mismatch", + "idempotency_conflict", + "malformed_body", + "method_not_allowed", + "validation_error", + "blank_input", + "unsupported_language", + "too_many_items", + # The fallback codes: any 4xx without its own, and a 500. + "invalid_request", + "internal_error", + } +) + + +def _rename(o: dict[str, Any], old: str, new: str) -> None: + """``old`` renamed to ``new`` when only ``old`` is there (in place).""" + if old in o and new not in o: + o[new] = o.pop(old) + + +def _original_wait_and_link(o: dict[str, Any], status: int) -> None: + """A wait and a docs link under their original names.""" + code = o.get("code") + if status == 429 and code == "extract_daily_limit": + _rename(o, "retry_after", "reset_in_seconds") + if status == 429 and code in ("review_in_flight", "citecheck_in_flight"): + _rename(o, "retry_after", "retry_after_seconds") + if status in (402, 429, 503): + _rename(o, "docs_url", "doc_url") + + +def _original_error(status: int, parsed: dict[str, Any], method: str, path: str) -> dict[str, Any]: + """``parsed`` as the original response shape sent it, as far as the newer + body and the request's endpoint tell.""" + out = dict(parsed) + code = out["code"] if isinstance(out.get("code"), str) else "" + errors = out["errors"] if isinstance(out.get("errors"), list) else None + path = path.split("?", 1)[0] + if method.upper() == "POST" and _CANCEL_PATH.match(path): + # The three cancel calls are new in 3.0: there is no 2.x reading of + # their errors, so the body is read as sent (code, detail and errors), + # as the Node SDK reads it. + return out + if _REVIEW_FAMILY.match(path): + # A missing or unknown credential is refused before the endpoint runs. + if code == "not_authenticated": + del out["code"] + if status == 422: + detail = out.get("detail") + if method.upper() == "POST" and path == "/review" and isinstance(detail, str): + if code in ("blank_input", "unsupported_language"): + out["code"] = "validation_error" + if code == "unsupported_language" and not detail.startswith("language: "): + out["detail"] = f"language: {detail}" + if errors is not None: + first: dict[str, Any] = next((e for e in errors if isinstance(e, dict)), {}) + loc, msg = first.get("loc"), first.get("msg") + if isinstance(loc, list) and len(loc) > 1 and loc[1] == "payload" and isinstance(msg, str): + # The original named the body parameter: ``payload.text: ...``. + out["detail"] = ".".join(str(p) for p in loc[1:]) + f": {msg}" + items: list[Any] = [] + for item in errors: + if not isinstance(item, dict): + items.append(item) + continue + o: dict[str, Any] = {} + if "loc" in item: + o["loc"] = item["loc"] + if "msg" in item: + renamed = item["msg"] == detail and out.get("detail") != detail + o["msg"] = out["detail"] if renamed else item["msg"] + items.append(o) + out["errors"] = items + elif code == "idempotency_body_mismatch": + out["errors"] = [{"loc": ["header"], "msg": out.get("detail")}] + if ( + status == 402 + and method.upper() == "POST" + and path == "/citecheck" + and "credits_remaining" not in out + and isinstance(out.get("remaining"), int) + ): + # One credit per citation: the pool equals ``remaining``. + out["credits_remaining"] = out["remaining"] + _original_wait_and_link(out, status) + return out + if status == 422 and code == "blank_input" and isinstance(out.get("detail"), str) and method.upper() == "POST": + # A blank input said "Text is required." (or named its item, or + # ``texts``), where the newer sentence names ``claim`` / ``claims``. + loc = errors[0].get("loc") if errors and isinstance(errors[0], dict) else None + if isinstance(loc, list) and loc and loc[0] == "body": + if path in ("/verify", "/assess"): + if len(loc) == 2 and loc[1] == "claim": + out["detail"] = "Text is required." + elif path == "/verify/batch": + if len(loc) == 4 and loc[1] == "claims" and isinstance(loc[2], int) and not isinstance(loc[2], bool): + out["detail"] = f"claims[{loc[2]}].text is required." + elif _SELECT_PATH.match(path): + if len(loc) == 2 and loc[1] == "claims": + out["detail"] = "texts is required and must be non-empty." + if status == 422 and code == "blank_input" and path == "/assess": + loc = errors[0].get("loc") if errors and isinstance(errors[0], dict) else None + if isinstance(loc, list) and "claims" in loc: + # A blank item in ``claims``: the original said ``blank_item``. + out["code"] = "blank_item" + out.pop("errors", None) + return out + if status == 422 and code == "unsupported_language" and path == "/verify/batch" and errors: + loc = errors[0].get("loc") if isinstance(errors[0], dict) else None + detail = out.get("detail") + if isinstance(loc, list) and len(loc) > 2 and loc[1] == "claims" and isinstance(loc[2], int): + if isinstance(detail, str) and not detail.startswith("claims["): + # The original named the item: ``claims[1].Unsupported language ...``. + out["detail"] = f"claims[{loc[2]}].{detail}" + if ( + status == 422 + and code == "validation_error" + and errors + and all(isinstance(e, dict) and isinstance(e.get("type"), str) and e["type"] != code for e in errors) + ): + # Request-schema validation: the original ``detail`` was the list of + # field errors itself, each ``{type, loc, msg, ...}``, with no ``code``. + listed = [] + for item in errors: + ordered = {k: item[k] for k in ("type", "loc", "msg") if k in item} + ordered.update({k: v for k, v in item.items() if k not in ordered}) + listed.append(ordered) + original: dict[str, Any] = {"detail": listed} + for key, value in out.items(): + if key not in ("detail", "code", "errors"): + original["doc_url" if key == "docs_url" else key] = value + return original + # /assess sent ``too_many_items``; /ask sent no code for an unfinished + # verification (``GET /verifications/{id}`` still sends ``verification_not_ready``). + codeless = (code in _CODELESS and not (code == "too_many_items" and path == "/assess")) or ( + code == "verification_not_ready" and path.startswith("/ask/") + ) + if codeless: + del out["code"] + out.pop("errors", None) + _original_wait_and_link(out, status) + return out + + def map_response_to_error( status_code: int, body: bytes | str | None, headers: dict[str, str] | None = None, + *, + endpoint: tuple[str, str] | None = None, ) -> LenzError: """Translate an HTTP error response into the right typed exception. Returns an *instance* (not raised) so callers can decide whether to raise, log, or surface. Keep this pure — no I/O. + + ``endpoint`` is the request's ``(method, path)``: the client passes it, and + the exception gets the attributes 2.x gave (``code``, ``message``, + ``errors``, waits), which differed by endpoint; ``exc.body`` is the body + as sent. Without it the body is read as it stands. """ - parsed = _parse_body(body) + raw = _parse_body(body) + parsed = raw + if endpoint is not None: + parsed = _original_error(status_code, raw, *endpoint) headers = headers or {} request_id = headers.get("X-Request-ID") or headers.get("x-request-id") or "" @@ -536,6 +858,8 @@ def map_response_to_error( if status_code in _STATUS_MAP: cls, default_msg, doc_url = _STATUS_MAP[status_code] + elif status_code == 404: + cls, default_msg, doc_url = LenzNotFoundError, f"HTTP {status_code}", f"{_DOCS_BASE}/errors" elif status_code == 410 and code in _GONE_410_CODES: cls, default_msg, doc_url = _GONE_410_CODES[code] elif status_code == 409 and code in _VERIFICATION_409_CODES: @@ -560,7 +884,7 @@ def map_response_to_error( request_id=request_id, status_code=status_code, code=code, - body=parsed, + body=raw, ) # Class-specific enrichment from the response body. Each is set on the @@ -568,12 +892,9 @@ def map_response_to_error( if status_code == 409 and isinstance(err, (LenzVerificationNotReadyError, LenzPipelineError)): # The generic 4xx advice ("retry; file an issue") is wrong for both: # the server's own hint says what to do, with a class default behind it. - # A failed run states its cause flat in the original response shape - # and in one ``failure`` block in the newer one. The block is read only - # for a newer-shape body, and only where the flat key is absent. + # A failed run states its cause in one ``failure`` block. raw_failure = parsed.get("failure") - newer = isinstance(raw_failure, dict) and "failure_reason" not in parsed - failure: dict[str, Any] = raw_failure if newer and isinstance(raw_failure, dict) else {} + failure: dict[str, Any] = raw_failure if isinstance(raw_failure, dict) else {} def _read(key: str, block_key: str) -> Any: return parsed[key] if key in parsed else failure.get(block_key) @@ -586,7 +907,7 @@ def _read(key: str, block_key: str) -> Any: else: reason = _opt_str(_read("failure_reason", "code")) # The original spelling of "nothing checkable" on a verification. - err.failure_reason = "not_a_claim" if newer and reason == "no_checkable_claim" else reason + err.failure_reason = "not_a_claim" if reason == "no_checkable_claim" else reason err.failure_class = _opt_str(_read("failure_class", "failure_class")) # Only a real boolean is a retry signal, as in the wait path. retryable = _read("retryable", "retryable") @@ -600,23 +921,29 @@ def _read(key: str, block_key: str) -> Any: else: err.fix = "This run will not produce a result. Resubmit with a different claim." + if status_code == 409 and code == "use_review_cancel": + # Not "not yet": the task belongs to a review, and the review is what + # to cancel. Sending the same request again can never succeed. + err.fix = "Cancel the review that started this task instead: client.cancel_review(review_id)." + err.retryable = False + if isinstance(err, LenzGoneError): err.purged_at = _opt_str(parsed.get("purged_at")) or None # Retrying cannot bring it back, so not the generic 4xx advice. err.fix = "Its account's retention period removed it. A certificate issued for it is still available." if isinstance(err, LenzAPIError) and not isinstance(err, LenzUpstreamUnavailableError): - err.retry_after = _opt_int(headers.get("Retry-After") or headers.get("retry-after")) + err.retry_after = _opt_wait(headers.get("Retry-After") or headers.get("retry-after")) if isinstance(err, LenzUpstreamUnavailableError): # Body ``retry_after`` first (both 503 shapes carry it), header as # the fallback for any proxy that strips the body. - stated = _opt_int(parsed.get("retry_after")) + stated = _opt_wait(parsed.get("retry_after")) if stated is None: # The /review error body states its wait under this name. - stated = _opt_int(parsed.get("retry_after_seconds")) + stated = _opt_wait(parsed.get("retry_after_seconds")) if stated is None: - stated = _opt_int(headers.get("Retry-After") or headers.get("retry-after")) + stated = _opt_wait(headers.get("Retry-After") or headers.get("retry-after")) err.retry_after = stated if isinstance(err, LenzQuotaExceededError): @@ -628,9 +955,9 @@ def _read(key: str, block_key: str) -> Any: # sending null precisely so "unknown" stays distinguishable from # "zero". Collapsing that here would throw the distinction away. err.remaining = _opt_int(parsed.get("remaining")) - if "remaining" not in parsed and "requested" not in parsed and "doc_url" not in parsed and "docs_url" in parsed: - # A newer-shape body (``docs_url``) without the capability figure: - # the pool divided by this one call's price. + if "remaining" not in parsed and "requested" not in parsed: + # A body without the capability figure: the pool divided by this + # one call's price. pool, price = _opt_int(parsed.get("credits_remaining")), _opt_int(parsed.get("cost")) if pool is not None and price: err.remaining = pool // price @@ -639,6 +966,9 @@ def _read(key: str, block_key: str) -> Any: # credits. The body's ``credits_remaining`` lands on ``credit_balance``, # NOT on the same-named deprecated property — that one aliases # ``remaining`` and means a different quantity (see the class docstring). + # (A citation check's current-shape 402 leaves the pool out because it + # equals ``remaining``; ``_original_error`` restores it for that + # endpoint only.) err.credit_balance = _opt_int(parsed.get("credits_remaining")) err.cost = _opt_int(parsed.get("cost")) resets_at = parsed.get("resets_at") @@ -659,13 +989,10 @@ def _read(key: str, block_key: str) -> Any: "reset_in_seconds" not in parsed and "retry_after_seconds" not in parsed and "retry_after" in parsed - and "docs_url" in parsed - and "doc_url" not in parsed and code not in _IN_FLIGHT_429_CODES ): - # A newer-shape body (``docs_url``) names every wait - # ``retry_after``. The in-flight refusals never carried - # ``reset_in_seconds``. + # The current shape names every wait ``retry_after``. The + # in-flight refusals never carried ``reset_in_seconds``. err.reset_in_seconds = _opt_int(parsed.get("retry_after")) rl_upgrade_url = parsed.get("upgrade_url") err.upgrade_url = rl_upgrade_url if isinstance(rl_upgrade_url, str) else "" @@ -687,16 +1014,40 @@ def _read(key: str, block_key: str) -> Any: parsed.get("retry_after_seconds"), parsed.get("retry_after"), ): - resolved = _opt_int(candidate) + resolved = _opt_wait(candidate) if resolved is not None: err.retry_after = resolved break else: err.retry_after = 0 + # A boolean ``retryable`` in the body's failure block, else at its top + # level, is the server's own word and wins over the status. (A failed + # verification's 409 read it above.) + if not isinstance(err, LenzPipelineError): + candidates = [ + block.get("retryable") for block in (raw.get("failure"), parsed.get("failure")) if isinstance(block, dict) + ] + candidates += [raw.get("retryable"), parsed.get("retryable")] + stated = next((c for c in candidates if isinstance(c, bool)), None) + if stated is not None: + err.retryable = stated + return err +#: The longest wait ``retry_after`` reports, in seconds (the client's +#: ``MAX_TIMEOUT_SECONDS``): a larger stated wait reads as this. +_MAX_STATED_WAIT = 2_147_483 + + +def _opt_wait(value: Any) -> int | None: + """A stated wait in seconds, as ``_opt_int`` reads it (a non-finite or + unparseable value is ``None``), at most ``_MAX_STATED_WAIT``.""" + seconds = _opt_int(value) + return None if seconds is None else min(seconds, _MAX_STATED_WAIT) + + def _opt_int(value: Any) -> int | None: """Coerce to int, or None when absent/unparseable. @@ -718,7 +1069,8 @@ def _opt_int(value: Any) -> int | None: return None try: return int(float(value)) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): + # OverflowError: an infinite value (``"1e999"``) is unknown, as in Node. return None @@ -727,6 +1079,9 @@ def _fix_hint_for(status_code: int) -> str: 401: "Your credential is missing, invalid or expired. Check the key you passed, or get a new one at https://lenz.io/api-credentials.", 403: "This key doesn't have access to that resource.", 402: "Top up or upgrade at https://lenz.io/plans, or wait for the period reset.", + 404: ( + "Check the id or key the call names: nothing with it is visible to this credential. Retrying will not help." + ), 422: "Check the request body against the OpenAPI spec.", 429: "Wait Retry-After seconds and retry.", }.get(status_code, "Retry; if the error persists, file an issue with the Request ID.") @@ -737,21 +1092,29 @@ def _fix_hint_for(status_code: int) -> str: "NO_RETRY_429_CODES", "UPSTREAM_503_CODES", "CitecheckFailed", + "CitecheckFailedError", "CitecheckTimeout", + "CitecheckTimeoutError", "LenzAPIError", + "LenzApiVersionError", "LenzAuthError", + "LenzConnectionError", "LenzError", "LenzGoneError", "LenzNeedsInputError", + "LenzNotFoundError", "LenzPipelineError", "LenzQuotaExceededError", "LenzRateLimitError", + "LenzRequestTimeoutError", "LenzTimeoutError", "LenzUpstreamUnavailableError", "LenzValidationError", "LenzVerificationNotReadyError", "LenzWebhookSignatureError", "ReviewFailed", + "ReviewFailedError", "ReviewTimeout", + "ReviewTimeoutError", "map_response_to_error", ] diff --git a/src/lenz_io/models.py b/src/lenz_io/models.py index c9fc1a9..4364dcc 100644 --- a/src/lenz_io/models.py +++ b/src/lenz_io/models.py @@ -23,6 +23,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing_extensions import deprecated class _Lax(BaseModel): @@ -37,24 +38,27 @@ class _Lax(BaseModel): model_config = ConfigDict(extra="allow") -# ── Reading both response shapes ───────────────────────────────────────── +# ── Reading the current response shape ─────────────────────────────────── # -# The API is gaining a second, dated response shape that renames a handful of +# Since 3.0 the SDK asks for the API's 2026-10-11 response shape and reads +# only that shape from its own calls (webhooks of both shapes are still +# parsed; see ``webhooks.py``). The current shape renames a handful of 2.x # fields (``claim_text`` -> ``claim``, ``modified_at`` -> ``completed_at``, a # failed item's ``error`` / ``error_code`` / ``failure_reason`` -> one -# ``failure`` block, ...). This release still asks for the original shape. +# ``failure`` block, ...). # -# Two rules keep existing code exactly as it was: +# Every 2.x name stays as a DEPRECATED alias with its 2.x value, so 2.x code +# runs unchanged: # -# * A body is treated as the newer shape only when it carries something only -# that shape has. Every other body is parsed exactly as before: same fields, -# same values, same dump, same ``exclude_unset``. -# * A newer-shape body gets the original fields filled in, with their original -# meaning, from the newer ones (only where the body does not carry them). +# * The 2.x fields are model fields, filled from the current ones in a +# ``before`` validator (only where the body does not carry them). +# * The current names are read-only properties over what the server sent. +# They are not model fields, so ``model_dump()`` shows the body as sent plus +# the 2.x fields. # -# The newer names are read-only properties, computed from either shape: they -# are not model fields, so ``repr``, ``model_dump()``, equality and pickling of -# an original-shape object are unchanged. +# A few models are also built from webhook payloads of the original shape +# (a review, a citation check, a failure block, a needs-input option); those +# keep reading it. #: The one code for "the input holds nothing that can be checked", in the #: newer response shape. The original shape spells it per endpoint: @@ -73,10 +77,9 @@ def _old_code(code: Any, old: str) -> Any: return old if code in (NO_CHECKABLE_CLAIM, *_OLD_NO_CLAIM_CODES) else code -def _is_newer(data: Any, has: str, lacks: str) -> bool: - """A dict carrying ``has`` (a key only the newer shape sends) and not - ``lacks`` (its original-shape counterpart).""" - return isinstance(data, dict) and has in data and lacks not in data +def _is_newer(data: Any, key: str) -> bool: + """A dict carrying ``key`` (a key only the current response shape sends).""" + return isinstance(data, dict) and key in data def _utc_day(iso: Any) -> Any: @@ -104,8 +107,8 @@ def _modified_at_from(created_at: Any, completed_at: Any) -> str | None: def _fill_modified_at(data: Any) -> Any: """A newer-shape verification (``completed_at``, no ``modified_at``): add the original ``modified_at``.""" - if _is_newer(data, "completed_at", "modified_at"): - return {**data, "modified_at": _modified_at_from(data.get("created_at"), data.get("completed_at"))} + if _is_newer(data, "completed_at"): + return _fill(data, modified_at=_modified_at_from(data.get("created_at"), data.get("completed_at"))) return data @@ -138,6 +141,28 @@ def _sent(model: BaseModel, key: str) -> Any: ] +#: The five verdict labels a check can return. A documentation and comparison +#: alias, like ``FailureClass``: the ``verdict`` fields stay ``str``, so a label +#: the API adds later still reads. +VerdictLabel = Literal["True", "Mostly True", "Mixed", "Mostly False", "False"] + +#: A verdict as read on a verification or an assess row: one of the five +#: labels, or ``"Error"`` on a failed assess row (deprecated there: read +#: ``status == "failed"``). Fields stay ``str``:: +#: +#: from lenz_io import Verdict +#: FALSE_ISH: set[Verdict] = {"False", "Mostly False"} +#: if row.verdict in FALSE_ISH: ... +Verdict = Literal["True", "Mostly True", "Mixed", "Mostly False", "False", "Error"] + +#: A confidence band, as read on a verification or an assess row. +Confidence = Literal["low", "medium", "high"] + +#: A check's depth: ``"standard"`` (10 credits) or ``"low"`` (5 credits, +#: fewer sources). The ``depth=`` arguments stay ``str``. +Depth = Literal["standard", "low"] + + class Source(_Lax): """A single citation backing a verification.""" @@ -199,19 +224,20 @@ class Audit(_Lax): class CandidateClaim(_Lax): """One of multiple distinct claims framing found in the submitted text.""" - text: str = "" + #: **Deprecated**, use :attr:`claim` (the same string). + text: str = Field(default="", json_schema_extra={"deprecated": True}) domain: str = "" @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if _is_newer(data, "claim", "text"): + if _is_newer(data, "claim"): return _fill(data, text=data["claim"]) return data @property def claim(self) -> str: - """The option's claim (``text`` is its older name, the same string).""" + """The option's claim. Replaces ``text``, which is deprecated and kept.""" sent = _sent(self, "claim") return sent if isinstance(sent, str) else self.text @@ -334,8 +360,9 @@ class Verification(_Lax): endpoint, and the webhook payload. The verdict block is FLAT at top level (was nested ``Verdict`` object - pre-unify). ``created_at`` + ``modified_at`` are the only timestamp - fields on the API surface — editorial ``published_at`` is internal-only. + pre-unify). ``created_at`` + ``completed_at`` are the only timestamp + fields on the API surface (``modified_at`` is the deprecated 2.x name of + the second); a claim's ``published_at`` is not part of the API. 1.1.0: dropped ``url`` and ``visibility``. API claims are private by default and referenced by ``verification_id`` only. Cache-hit on @@ -384,7 +411,9 @@ class Verification(_Lax): sources: list[Source] = Field(default_factory=list) audit: Audit = Field(default_factory=Audit) created_at: str | None = None - modified_at: str | None = None + # Deprecated: use ``completed_at``. Set only when the verification + # completed on a later UTC day than ``created_at``. + modified_at: str | None = Field(default=None, json_schema_extra={"deprecated": True}) # Output language (ISO 639-1). Always populated by the server when # the SDK is fresh; defaulted to ``'en'`` for resilience against # older cached payloads that lack the field. @@ -402,15 +431,15 @@ class Verification(_Lax): @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - return _fill_modified_at(data) + return _deep_check_failure(_fill_modified_at(data)) @property def completed_at(self) -> str | None: - """When the verification completed. From the original response shape it - is known only through ``modified_at``: set when that is, ``None`` on a - same-day completion.""" + """When the verification completed. Replaces ``modified_at``, which is + deprecated and kept (set only when the completion fell on a later UTC + day than ``created_at``).""" sent = _sent(self, "completed_at") - return sent if isinstance(sent, str) else self.modified_at + return sent if isinstance(sent, str) else None class VerificationListItem(_Lax): @@ -432,22 +461,23 @@ class VerificationListItem(_Lax): # A suggested rewrite of ``claim``. See ``Verification.suggested_rewrite``. suggested_rewrite: str | None = None created_at: str | None = None - modified_at: str | None = None + # Deprecated: use ``completed_at``. See ``Verification.modified_at``. + modified_at: str | None = Field(default=None, json_schema_extra={"deprecated": True}) # Output language (ISO 639-1). See ``Verification.language``. language: str = "en" @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - return _fill_modified_at(data) + return _deep_check_failure(_fill_modified_at(data)) @property def completed_at(self) -> str | None: - """When the verification completed. From the original response shape it - is known only through ``modified_at``: set when that is, ``None`` on a - same-day completion.""" + """When the verification completed. Replaces ``modified_at``, which is + deprecated and kept (set only when the completion fell on a later UTC + day than ``created_at``).""" sent = _sent(self, "completed_at") - return sent if isinstance(sent, str) else self.modified_at + return sent if isinstance(sent, str) else None class VerificationList(_Lax): @@ -505,8 +535,7 @@ class ClaimLocation(_Lax): """Where the submitted text makes one claim. ``claim`` is exactly as in the claim list it belongs to - (``ExtractedClaims.claim`` / ``identified_claims``, or a review's - ``more_claims``). ``positions`` lists every place the text makes it, in + (``ExtractedClaims.claims``, or a review's ``more_claims``). ``positions`` lists every place the text makes it, in text order, at most 10. It is ``None`` only when that claim could not be placed; on ``/extract`` every returned claim is placed. """ @@ -550,22 +579,26 @@ class ExtractedClaims(_Lax): ``status`` is one of ``ExtractStatus``: ``ready``, ``not_a_claim``, or — when a ``focus`` was given and no claim fell within it — ``no_match``. - ``no_match`` is a successful answer, not an error: ``identified_claims`` - is empty and the unfocused list is never substituted for it. - - ``locations`` is set only on a call made with ``locate=True``: one - ``ClaimLocation`` per returned claim, in the order of - ``identified_claims`` (one entry for a single ``claim``). It is ``[]`` - when every claim was left out (``status`` is then ``"not_a_claim"``), - and ``None`` when ``locate`` was not set, when the extraction found no - claims, or when the - claims could not be located (the list is then returned unfiltered). - Older servers omit the key; it parses as ``None``. + ``no_match`` is a successful answer, not an error: ``claims`` is empty + and the unfocused list is never substituted for it. + + ``claims`` lists every claim found, most check-worthy first (one entry + for one claim). On a call made with ``locate=True`` each carries its + ``positions`` (the claims that could not be placed are left out, and + ``status`` is ``"not_a_claim"`` when none is left); they are ``None`` + when ``locate`` was not set or the claims could not be located (the list + is then returned unfiltered). + + ``claim``, ``identified_claims`` and ``locations`` are the deprecated 2.x + fields, kept with their 2.x values: ``claims`` is their replacement. """ + # ``"not_a_claim"`` is the 2.x spelling of the API's ``no_checkable_claim``. status: str = "" - claim: str = "" - identified_claims: list[str] = Field(default_factory=list) + # Deprecated: use ``claims[0].claim``. + claim: str = Field(default="", json_schema_extra={"deprecated": True}) + # Deprecated: use ``claims`` (this is its names, and ``[]`` for one claim). + identified_claims: list[str] = Field(default_factory=list, json_schema_extra={"deprecated": True}) # Deprecated: always empty since 2026-09-12. Kept because the server # still sends the key. candidate_claims: list[str] = Field(default_factory=list, json_schema_extra={"deprecated": True}) @@ -573,12 +606,13 @@ class ExtractedClaims(_Lax): key_entities: list[ExtractedEntity] = Field(default_factory=list) presumed_intent: str = "" original_input: str = "" - locations: list[ClaimLocation] | None = None + # Deprecated: use ``claims`` (each with its ``positions``). + locations: list[ClaimLocation] | None = Field(default=None, json_schema_extra={"deprecated": True}) @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if not (_is_newer(data, "claims", "identified_claims") and isinstance(data["claims"], list)): + if not (_is_newer(data, "claims") and isinstance(data["claims"], list)): return data items = [c for c in data["claims"] if isinstance(c, dict)] names = [c.get("claim") if isinstance(c.get("claim"), str) else "" for c in items] @@ -599,17 +633,19 @@ def _read_newer_shape(cls, data: Any) -> Any: def claims(self) -> list[ExtractedClaim]: """Every claim found, most check-worthy first: always a list (one entry for one claim, ``[]`` for none), each with its ``positions`` when the - call located them. Read from either response shape.""" + call located them. Replaces ``claim``, ``identified_claims`` and + ``locations``, which are deprecated and kept.""" sent = _sent(self, "claims") - if isinstance(sent, list): - return [ExtractedClaim.model_validate(c) for c in sent if isinstance(c, dict)] - names = list(self.identified_claims) or ([self.claim] if self.claim else []) - if self.claim and self.claim not in names: - names.insert(0, self.claim) - positions: dict[str, Any] = {} - for loc in self.locations or []: - positions.setdefault(loc.claim, loc.positions) - return [ExtractedClaim(claim=n, positions=positions.get(n)) for n in names] + return [ExtractedClaim.model_validate(c) for c in sent if isinstance(c, dict)] if isinstance(sent, list) else [] + + +#: The original shape's ``hint`` on an /assess verdict row that found other +#: claims in its input, and its ``error`` on an input holding no claim. +#: A completed quick check inside a review that found other claims: the one +#: sentence 2.x put in ``hint`` (a failed quick check had none). +_REVIEW_COMPOUND_HINT = "This text holds more than one claim." +_COMPOUND_HINT = "Assessed the main claim only. Send identified_claims as their own items to check the rest." +_NO_CLAIM_ERROR = "No verifiable claim detected" class AssessClaim(_Lax): @@ -620,16 +656,19 @@ class AssessClaim(_Lax): ``ClaimDetailOut`` payload at ``GET /api/v1/verifications/{id}`` for callers that want citations and the full audit trail. - A row with ``verdict == "Error"`` could not be given a verdict. On a + A row with ``status == "failed"`` could not be given a verdict. On a list call it stays in position (one row per item sent), is not charged, - and says why: ``error_code`` names the cause and ``hint`` is one - sentence on what to send next. ``hint`` is the field to surface to a - human — it is written per cause and stays correct as causes are added. + and says why: ``failure.code`` names the cause and ``failure.hint`` is + one sentence on what to send next (``error_code`` and ``hint`` are their + 2.x names, deprecated and kept; so is reading ``verdict == "Error"`` + instead of ``status == "failed"``). ``failure.hint`` is the field to + surface to a human — it is written per cause and stays correct as causes + are added. + A vague input is assessed on its most likely reading, which ``claim`` carries. A compound input is assessed on its main claim; the other - claims found in it are listed in ``identified_claims`` (also with a - ``hint``). These fields default empty so older servers that don't send - them still parse. + claims found in it are listed in ``more_claims`` (``identified_claims``, + deprecated and kept). """ claim: str = "" @@ -655,8 +694,9 @@ class AssessClaim(_Lax): # check's reasoning and not itself verified: review it, or run it through # ``verify``, before using it. suggested_rewrite: str | None = None - # Only on ``verdict == "Error"`` rows: 'no_claim' | 'framing_failed' | - # 'upstream_unavailable' | 'timeout'. + # Deprecated: use ``failure.code`` (``"no_checkable_claim"`` where this + # reads ``"no_claim"``). Only on ``verdict == "Error"`` rows: + # 'no_claim' | 'framing_failed' | 'upstream_unavailable' | 'timeout'. # # An OPEN set, deliberately typed ``str`` rather than a Literal: the API # may add a cause in a minor version, so branch on the ones you know and @@ -668,20 +708,23 @@ class AssessClaim(_Lax): # 'framing_failed' is deterministic, so retrying the same text will not # help (a provider outage comes back as 'upstream_unavailable' instead). # 'no_claim' wants a different input; read ``hint``. - error_code: str | None = None + error_code: str | None = Field(default=None, json_schema_extra={"deprecated": True}) # Deprecated: always empty since 2026-09-12, when the ``ambiguous`` cause # that filled it was retired. Kept because the server still sends the key. candidate_claims: list[str] = Field(default_factory=list, json_schema_extra={"deprecated": True}) - # Other claims found in the input that were NOT assessed; else empty. - identified_claims: list[str] = Field(default_factory=list) - # One sentence on what to send next. Set on every Error row and on a row - # with a non-empty ``identified_claims``; ``None`` on a plain verdict row. - hint: str | None = None + # Deprecated: use ``more_claims``. Other claims found in the input that + # were NOT assessed; else empty. + identified_claims: list[str] = Field(default_factory=list, json_schema_extra={"deprecated": True}) + # Deprecated: use ``failure.hint`` (on a failed row; the sentence on a + # verdict row that found other claims has no replacement). One sentence on + # what to send next. Set on every Error row and on a row with a non-empty + # ``identified_claims``; ``None`` on a plain verdict row. + hint: str | None = Field(default=None, json_schema_extra={"deprecated": True}) @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if not _is_newer(data, "failure", "error_code"): + if not _is_newer(data, "failure"): return data out = dict(data) failed = out.get("status") == "failed" @@ -690,41 +733,42 @@ def _read_newer_shape(cls, data: Any) -> Any: if out.get("confidence") is None: out["confidence"] = "low" failure = out.get("failure") if isinstance(out.get("failure"), dict) else {} + more = out.get("more_claims") or [] + if failure: + hint = failure.get("hint") + else: + # A verdict row with other claims found carried this one sentence. + hint = _COMPOUND_HINT if more else None return _fill( out, error_code=_old_code(failure.get("code"), "no_claim") if failure else None, - hint=failure.get("hint") if failure else None, - identified_claims=out.get("more_claims") or [], + hint=hint, + identified_claims=more, ) @property def status(self) -> str: - """``"completed"`` (a verdict) or ``"failed"`` (none; see ``failure``).""" + """``"completed"`` (a verdict) or ``"failed"`` (none; see ``failure``). + Replaces ``verdict == "Error"``, which is deprecated and kept.""" sent = _sent(self, "status") - if isinstance(sent, str): - return sent - if self.verdict == "Error": - return "failed" - return "completed" if self.verdict else "" + return sent if isinstance(sent, str) else "" @property def failure(self) -> FailureBlock | None: """Why a failed row has no verdict (``None`` on a completed row): ``code`` (``no_checkable_claim`` | ``framing_failed`` | ``upstream_unavailable`` | ``timeout`` today, an open set) and ``hint``, - one sentence on what to send next.""" + one sentence on what to send next. Replaces ``error_code`` and + ``hint``, which are deprecated and kept.""" sent = _sent(self, "failure") - if isinstance(sent, dict): - return FailureBlock.model_validate(sent) - if self.status != "failed": - return None - return FailureBlock(failure_reason=self.error_code or "", hint=self.hint) + return FailureBlock.model_validate(sent) if isinstance(sent, dict) else None @property def more_claims(self) -> list[str]: - """Other claims found in the input that were not assessed.""" + """Other claims found in the input that were not assessed. Replaces + ``identified_claims``, which is deprecated and kept.""" sent = _sent(self, "more_claims") - return list(sent) if isinstance(sent, list) else list(self.identified_claims) + return list(sent) if isinstance(sent, list) else [] #: The ``AssessResponse.status`` values this release knows about. A @@ -752,15 +796,19 @@ class AssessResponse(_Lax): ``"Error"`` row in position (see ``AssessClaim``), never a missing one; the top-level ``error`` stays ``None``. - When ``claims`` is empty (single form), ``error_code`` is ``'no_claim'``: - the input holds no checkable claim (a vague input is assessed on its - most likely reading instead). It defaults empty, so older servers that - don't send it degrade to the plain ``error`` message. + When ``claims`` is empty (single form), ``status`` is + ``"no_checkable_claim"`` and ``failure`` says why: the input holds no + checkable claim (a vague input is assessed on its most likely reading + instead). The deprecated ``error`` and ``error_code`` (``'no_claim'``) + keep their 2.x values. """ claims: list[AssessClaim] = Field(default_factory=list) - error: str | None = None - error_code: str = "" # '' | 'no_claim' + # Deprecated: use ``failure`` / ``status``. The 2.x sentence for an input + # with no claim. + error: str | None = Field(default=None, json_schema_extra={"deprecated": True}) + # Deprecated: use ``failure.code`` (``"no_checkable_claim"``). + error_code: str = Field(default="", json_schema_extra={"deprecated": True}) # '' | 'no_claim' # Deprecated: always empty since 2026-09-12. Kept because the server # still sends the key. candidate_claims: list[str] = Field(default_factory=list, json_schema_extra={"deprecated": True}) @@ -771,14 +819,15 @@ class AssessResponse(_Lax): @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if not _is_newer(data, "failure", "error"): + if not _is_newer(data, "failure"): return data failure = data.get("failure") if isinstance(data.get("failure"), dict) else None if failure is None: return data return _fill( data, - error=failure.get("detail"), + # The original shape's one sentence for an input with no claim. + error=_NO_CLAIM_ERROR, error_code=_old_code(failure.get("code"), "no_claim") or "", ) @@ -786,48 +835,49 @@ def _read_newer_shape(cls, data: Any) -> Any: def status(self) -> str: """``ok`` (some row has a verdict), ``no_checkable_claim`` (the input, or every item, holds nothing checkable) or ``error``. See - ``AssessStatus``; computed from the rows when a server does not send it.""" + ``AssessStatus``. Replaces ``error`` and ``error_code``, which are + deprecated and kept.""" sent = _sent(self, "status") - if isinstance(sent, str): - return sent - rows = self.claims - if any(r.status == "completed" for r in rows): - return "ok" - if not rows: - return NO_CHECKABLE_CLAIM if _new_code(self.error_code) == NO_CHECKABLE_CLAIM else "error" - codes = {r.failure.code if r.failure else None for r in rows} - return NO_CHECKABLE_CLAIM if codes == {NO_CHECKABLE_CLAIM} else "error" + return sent if isinstance(sent, str) else "" @property def failure(self) -> FailureBlock | None: - """Why the single form has no rows; ``None`` otherwise.""" + """Why the single form has no rows; ``None`` otherwise. Replaces + ``error`` and ``error_code``, which are deprecated and kept.""" sent = _sent(self, "failure") - if isinstance(sent, dict): - return FailureBlock.model_validate(sent) - if not (self.error or self.error_code): - return None - return FailureBlock.model_validate({"failure_reason": self.error_code, "detail": self.error}) + return FailureBlock.model_validate(sent) if isinstance(sent, dict) else None class TaskAccepted(_Lax): """Returned by ``POST /verify`` and per item of ``POST /verify/batch``.""" task_id: str = "" - claim_text: str = "" + #: **Deprecated**, use :attr:`claim`. + claim_text: str = Field(default="", json_schema_extra={"deprecated": True}) @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if _is_newer(data, "claim", "claim_text"): + if _is_newer(data, "claim"): return _fill(data, claim_text=data["claim"]) return data @property def claim(self) -> str: """The item's claim on a batch or select receipt (``""`` on a single - ``verify`` receipt); ``claim_text`` is its older name.""" + ``verify`` receipt). Replaces ``claim_text``, which is deprecated and + kept.""" sent = _sent(self, "claim") - return sent if isinstance(sent, str) else self.claim_text + return sent if isinstance(sent, str) else "" + + @property + @deprecated("It is no longer sent; use `task_id` to poll.", category=None) + def chain_id(self) -> str: + """Deprecated: no longer sent. An internal correlation id the 2.x + response shape sent on a ``verify`` receipt; ``""`` now. It cannot be + polled: use ``task_id``.""" + sent = _sent(self, "chain_id") + return sent if isinstance(sent, str) else "" class BatchAccepted(_Lax): @@ -835,6 +885,23 @@ class BatchAccepted(_Lax): items: list[TaskAccepted] = Field(default_factory=list) +class CancelResult(_Lax): + """Returned by ``POST /verify/{task_id}/cancel`` (``Lenz.cancel``). + + ``cancelled`` is ``True`` whenever the run is cancelled, by this call or an + earlier one (so a repeated or retried cancel answers ``True``); ``status`` + is then ``"cancelled"``. It is ``False`` when the run is not cancelled: + ``status`` is its status, normally ``"completed"`` (the verification + exists and was charged as usual) or ``"failed"``; a task that ``select`` + already resolved answers ``False`` with ``"needs_input"``. The call answers + 200 either way. + """ + + task_id: str = "" + cancelled: bool = False + status: str = "" + + class Progress(_Lax): """Where a running verification has got to. Advisory — never results. @@ -854,8 +921,9 @@ class Progress(_Lax): passes it through. This was a plain ``dict`` up to 2.10.0. The mapping methods below keep - ``p["step"]``, ``"step" in p``, ``p.keys()`` and ``dict(p)`` working on a - 2.x minor; they are deprecated and go in 3.0.0. Use attributes. + ``p["step"]``, ``"step" in p``, ``p.keys()`` and ``dict(p)`` working; they + are deprecated and will be removed in a future major release. Use + attributes. """ step: str = "" @@ -864,7 +932,7 @@ class Progress(_Lax): elapsed_seconds: int | None = None poll_after_seconds: int | None = None - # ── dict compatibility shim (deprecated, removed in 3.0.0) ── + # ── dict compatibility shim (deprecated, to be removed in a future major) ── # # `.get()` alone would have been the worst option available: the # changelog would say "minor", the shim would look like it handled @@ -894,10 +962,46 @@ def items(self) -> ItemsView[str, Any]: return self.model_dump().items() +#: A failed poll's original ``error`` sentence, for the codes that had a fixed +#: one. +_STATUS_ERRORS = { + "cancelled": "Cancelled.", + "task_stuck": "The task was never completed and has been marked failed.", + "task_error": "Pipeline failed.", + "not_a_claim": "Not a verifiable claim.", +} + + +def _original_status_error(code: Any, detail: Any) -> Any: + """A failed poll's original ``error``: the code's fixed sentence, else + "Pipeline stopped at: " (a running check's form; one read back from + storage said "Pipeline stopped: ."). The newer shape's sentence when + there is no code.""" + if isinstance(code, str) and code: + return _STATUS_ERRORS.get(code, f"Pipeline stopped at: {code}") + return detail + + +#: The failure block of a task cancelled elsewhere, which the API sends with +#: no block: what 2.x read for a run cancelled while running. +_CANCELLED_TASK_FAILURE: dict[str, Any] = { + "code": "cancelled", + "detail": "Cancelled.", + "hint": None, + "failure_class": "cancelled", + "retryable": False, + "docs_url": "https://lenz.io/docs/errors#cancelled", +} + + class TaskStatus(_Lax): """Returned by ``GET /verify/status/{task_id}``.""" - status: str = "" # processing | needs_input | completed | failed + # processing | needs_input | completed | failed | cancelled. ``cancelled`` + # is a task stopped elsewhere (the website's Stop button, another + # process): its own status since API version 2026-10-11, which the + # original shape said as ``failed`` with ``failure_class`` ``cancelled``. + status: str = "" # Echoed on every shape since 2026-09, so a caller polling several # verifications in one loop can tell the replies apart. ``""`` from # older servers. @@ -921,34 +1025,47 @@ class TaskStatus(_Lax): # the JSON schema only, so reading them does not warn. candidates: list[str] = Field(default_factory=list, json_schema_extra={"deprecated": True}) similar_claims: list[SimilarVerification] = Field(default_factory=list, json_schema_extra={"deprecated": True}) - # failure branches. The server's failed response is - # ``{"status": "failed", "error": "..."}`` — ``error`` is the live wire - # field. ``failure_reason`` / ``failure_detail`` are kept for forward/back - # compatibility and other channels; read precedence is - # ``error or failure_detail or failure_reason``. - error: str = "" - failure_reason: str = "" - failure_detail: str = "" + # failure branches. Deprecated: ``error``, ``failure_reason``, + # ``failure_detail``, ``failure_class``, ``retryable`` and ``docs_url`` + # below are the 2.x flat fields of a failed run; read ``failure`` instead + # (``failure.detail``, ``.code``, ``.failure_class``, ...). ``error`` keeps + # its 2.x sentence; ``failure_detail`` is always empty, with no + # replacement. + error: str = Field(default="", json_schema_extra={"deprecated": True}) + failure_reason: str = Field(default="", json_schema_extra={"deprecated": True}) + failure_detail: str = Field(default="", json_schema_extra={"deprecated": True}) # WHY it failed — the closed set is ``FailureClass`` (import it for # exhaustive matching). The annotation stays ``str`` on purpose: a # ``Literal`` here would make an unknown class the server adds later a # hard ValidationError, and every other field on this model is lax. # Rows predating 2026-08 omit this and ``retryable`` (the derived retry # signal — true iff ``upstream_unavailable``). - failure_class: str = "" - retryable: bool | None = None + failure_class: str = Field(default="", json_schema_extra={"deprecated": True}) + retryable: bool | None = Field(default=None, json_schema_extra={"deprecated": True}) # Where this ``failure_class`` is explained. ``""`` from older servers. - docs_url: str = "" + docs_url: str = Field(default="", json_schema_extra={"deprecated": True}) @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if not _is_newer(data, "failure", "failure_reason") or not isinstance(data["failure"], dict): + if isinstance(data, dict) and data.get("status") == "cancelled" and not isinstance(data.get("failure"), dict): + # A task cancelled elsewhere: its own status, with no failure + # block. The 2.x fields read what the original shape said of it. + return _fill( + data, + error=_STATUS_ERRORS["cancelled"], + failure_reason="cancelled", + failure_class="cancelled", + retryable=False, + docs_url="https://lenz.io/docs/errors#cancelled", + ) + if not _is_newer(data, "failure") or not isinstance(data["failure"], dict): return data failure = data["failure"] + reason = _old_code(failure.get("code"), "not_a_claim") values = { - "error": failure.get("detail"), - "failure_reason": _old_code(failure.get("code"), "not_a_claim"), + "error": _original_status_error(reason, failure.get("detail")), + "failure_reason": reason, "failure_class": failure.get("failure_class"), "retryable": failure.get("retryable"), "docs_url": failure.get("docs_url"), @@ -962,22 +1079,21 @@ def failure(self) -> FailureBlock | None: """On ``failed``: why. ``code`` is the cause (an open set, e.g. ``research_empty``, ``no_checkable_claim``), ``detail`` one sentence on what happened, ``failure_class`` / ``retryable`` / ``hint`` / - ``docs_url`` as on the original fields. ``None`` on other statuses.""" + ``docs_url``. ``None`` on other statuses. Replaces ``error``, + ``failure_reason``, ``failure_detail``, ``failure_class``, + ``retryable``, ``docs_url`` and ``hint`` on a failure, which are + deprecated and kept. + + On ``cancelled`` with no block sent: the block 2.x read for a run + cancelled while running (``code`` and ``failure_class`` + ``cancelled``, ``detail`` "Cancelled.", not retryable).""" sent = _sent(self, "failure") - if isinstance(sent, dict): - return FailureBlock.model_validate(sent) - if self.status != "failed": + if not isinstance(sent, dict) and self.status == "cancelled": + sent = _CANCELLED_TASK_FAILURE + if not isinstance(sent, dict): return None - return FailureBlock.model_validate( - { - "failure_reason": self.failure_reason, - "detail": self.error or self.failure_detail or None, - "failure_class": self.failure_class, - "retryable": self.retryable, - "hint": self.hint or None, - "docs_url": self.docs_url, - } - ) + # A verification spelled "nothing checkable" ``not_a_claim``. + return FailureBlock.model_validate(_verification_failure(sent)) class BatchItemResult(_Lax): @@ -991,20 +1107,25 @@ class BatchItemResult(_Lax): - ``completed`` — ``verification`` is set (and ``status_detail`` carries the raw poll). - ``needs_input`` — paused for caller input; inspect ``status_detail`` (reason / claims). - - ``failed`` — terminal failure (or completed-without-result); ``status_detail`` carries the diagnostic. - A verification removed by its account's retention period (HTTP 410) is ``failed``, ``status_detail`` ``None``. + - ``failed`` — terminal failure (or completed-without-result), or cancelled elsewhere + (``status_detail.status`` is then ``cancelled``); ``status_detail`` carries the diagnostic. + An item whose poll could never succeed is ``failed`` with ``status_detail`` ``None``: a verification + removed by its account's retention period (HTTP 410), a task id nothing was found under (404), or an + answer in another API version. - ``timeout`` — the deadline elapsed before this task reached a terminal state; ``status_detail`` is ``None``. """ task_id: str = "" - claim_text: str = "" + #: **Deprecated**, use :attr:`claim`. + claim_text: str = Field(default="", json_schema_extra={"deprecated": True}) status: Literal["completed", "needs_input", "failed", "timeout"] verification: Verification | None = None status_detail: TaskStatus | None = None @property def claim(self) -> str: - """The item's claim (``claim_text`` is its older name).""" + """The item's claim. Replaces ``claim_text``, which is deprecated and + kept.""" return self.claim_text @@ -1045,18 +1166,9 @@ class UsageCredits(_Lax): @model_validator(mode="before") @classmethod def _mirror_extra_and_bonus(cls, data: Any) -> Any: - """Keep ``extra`` and its deprecated old name in step, in both directions. - - A server that has not started sending ``extra`` sends only ``bonus``; - the newer response shape sends only ``extra``. Mirroring - here means both attributes read correctly either way. - """ - if not isinstance(data, dict): - return data - has_extra, has_bonus = data.get("extra") is not None, data.get("bonus") is not None - if has_bonus and not has_extra: - return {**data, "extra": data["bonus"]} - if has_extra and not has_bonus: + """Fill the deprecated ``bonus`` from ``extra``, the one number the + current response shape sends.""" + if isinstance(data, dict) and data.get("extra") is not None and data.get("bonus") is None: return {**data, "bonus": data["extra"]} return data @@ -1118,20 +1230,10 @@ class UsageCapacity(_Lax): @model_validator(mode="before") @classmethod def _mirror_bonus_and_credits(cls, data: Any) -> Any: - """Keep ``bonus`` and its deprecated alias in step, in both directions. - - A server predating the credit pool sends only ``credits``; a block - computed from the pool has only ``bonus``. Mirroring here means - both attributes read correctly either way, so the SDK never depends on - which side of an API deploy it is talking to. - """ - if not isinstance(data, dict): - return data - has_bonus, has_credits = data.get("bonus") is not None, data.get("credits") is not None - if has_bonus and not has_credits: + """Fill the deprecated ``credits`` alias from ``bonus``: the blocks are + computed from the pool, so they carry only ``bonus``.""" + if isinstance(data, dict) and data.get("bonus") is not None and data.get("credits") is None: return {**data, "credits": data["bonus"]} - if has_credits and not has_bonus: - return {**data, "bonus": data["credits"]} return data @@ -1143,6 +1245,11 @@ class UsageExtract(_Lax): unlimited: bool = False +#: The per-capability blocks the original /me/usage carried, with the price +#: each was projected at when a response publishes none. +_BLOCK_COSTS = (("verify", 10), ("ask", 1), ("assess", 1)) + + class Usage(_Lax): """Returned by ``GET /me/usage`` — the account's balance and what it buys. @@ -1172,7 +1279,8 @@ class Usage(_Lax): #: comparing against it will break on a rename that ought to be free. #: Empty on servers predating this field — fall back to :attr:`plan`. plan_label: str = "" - quota_resets_at: str | None = None + #: **Deprecated**, use :attr:`UsageCredits.resets_at` (``credits.resets_at``). + quota_resets_at: str | None = Field(default=None, json_schema_extra={"deprecated": True}) #: The credit balance — the authoritative number. Empty on older servers. credits: UsageCredits = Field(default_factory=UsageCredits) #: Credits per call, keyed by CAPABILITY, at its default price: @@ -1212,11 +1320,11 @@ class Usage(_Lax): #: :attr:`costs` instead:: #: #: left = u.credits.remaining // u.costs["verify"] - verify: UsageCapacity = Field(default_factory=UsageCapacity) + verify: UsageCapacity = Field(default_factory=UsageCapacity, json_schema_extra={"deprecated": True}) #: DEPRECATED, kept for existing code. See :attr:`verify`. - ask: UsageCapacity = Field(default_factory=UsageCapacity) + ask: UsageCapacity = Field(default_factory=UsageCapacity, json_schema_extra={"deprecated": True}) #: DEPRECATED, kept for existing code. See :attr:`verify`. - assess: UsageCapacity = Field(default_factory=UsageCapacity) + assess: UsageCapacity = Field(default_factory=UsageCapacity, json_schema_extra={"deprecated": True}) extract: UsageExtract = Field(default_factory=UsageExtract) # Whether this key has a webhook signing secret provisioned. ``POST /verify`` # with a ``webhook_url`` is rejected without one, so callers that rely on @@ -1233,8 +1341,6 @@ def _read_newer_shape(cls, data: Any) -> Any: did, and ``quota_resets_at`` from ``credits.resets_at``.""" if not (isinstance(data, dict) and isinstance(data.get("credits"), dict)): return data - if any(k in data for k in ("verify", "ask", "assess", "quota_resets_at")): - return data credits, costs = data["credits"], data.get("costs") def _int(value: Any) -> int: @@ -1242,20 +1348,23 @@ def _int(value: Any) -> int: out = _fill(data, quota_resets_at=credits.get("resets_at")) if not isinstance(costs, dict): - return out + costs = {} total, remaining = _int(credits.get("total")), _int(credits.get("remaining")) - extra = _int(credits.get("extra", credits.get("bonus"))) - for capability in ("verify", "ask", "assess"): - cost = _int(costs.get(capability)) - if cost > 0: - quota_total, left = total // cost, remaining // cost - out[capability] = { - "quota_used": quota_total - left, - "quota_total": quota_total, - "quota_remaining": left, - "bonus": extra // cost, - "remaining": left, - } + extra = _int(credits.get("extra")) + for capability, default_cost in _BLOCK_COSTS: + if capability in out: + continue + # At the price the body publishes; a capability it leaves out (or + # prices at 0) at the price the blocks always used. + cost = _int(costs.get(capability)) or default_cost + quota_total, left = total // cost, remaining // cost + out[capability] = { + "quota_used": max(0, quota_total - left), + "quota_total": quota_total, + "quota_remaining": left, + "bonus": extra // cost, + "remaining": left, + } return out @@ -1328,15 +1437,18 @@ class ReviewStarted(_Lax): class FailureBlock(_Lax): """Why a review, or one claim's work inside it, failed. - The same fields a failed ``GET /verify/status`` carries: ``failure_reason`` - is the specific cause (an open set, e.g. ``no_claim``, - ``insufficient_credits``, ``timeout``), ``failure_class`` the closed + The same fields a failed ``GET /verify/status`` carries: ``code`` + is the specific cause (an open set, e.g. ``no_checkable_claim``, + ``insufficient_credits``, ``timeout``; ``failure_reason`` is its 2.x name, + deprecated and kept, spelling "nothing checkable" ``no_claim`` or + ``not_a_claim``), ``failure_class`` the closed :data:`FailureClass`, ``retryable`` whether resending the same request can help, ``hint`` one sentence on what to do next and ``docs_url`` where the class is explained. """ - failure_reason: str | None = "" + #: **Deprecated**, use :attr:`code`. + failure_reason: str | None = Field(default="", json_schema_extra={"deprecated": True}) failure_class: str | None = "" retryable: bool | None = None hint: str | None = None @@ -1345,15 +1457,15 @@ class is explained. @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if _is_newer(data, "code", "failure_reason"): + if _is_newer(data, "code"): return _fill(data, failure_reason=_old_code(data["code"], "no_claim")) return data @property def code(self) -> str | None: """The cause, an open set (``no_checkable_claim``, ``timeout``, ...). - ``failure_reason`` is its older name, spelling "nothing checkable" - ``no_claim``.""" + Replaces ``failure_reason``, which is deprecated and kept (it spells + "nothing checkable" ``no_claim``).""" sent = _sent(self, "code") return sent if isinstance(sent, str) else _new_code(self.failure_reason) @@ -1364,6 +1476,24 @@ def detail(self) -> str | None: return sent if isinstance(sent, str) else None +def _verification_failure(failure: Any) -> Any: + """A newer-shape ``failure`` block of a verification, with the original + spelling of "nothing checkable" (``not_a_claim``) in ``failure_reason``.""" + if isinstance(failure, dict) and _is_newer(failure, "code"): + return _fill(failure, failure_reason=_old_code(failure["code"], "not_a_claim")) + return failure + + +def _deep_check_failure(data: Any) -> Any: + """A newer-shape ``failure`` block of a deep check inside a review: its + "nothing checkable" was ``not_a_claim`` (a quick check's and the review's + own, ``no_claim``, which ``FailureBlock`` reads by default).""" + failure = data.get("failure") if isinstance(data, dict) else None + if isinstance(failure, dict) and _is_newer(failure, "code"): + return {**data, "failure": _fill(failure, failure_reason=_old_code(failure["code"], "not_a_claim"))} + return data + + class EscalationPolicy(_Lax): """Which quick-checked claims get a deep check, as the review RESOLVED it. @@ -1434,8 +1564,9 @@ class ReviewSummary(_Lax): claims_selected: int | None = None #: The resolved ``max_assessments``. claim_limit: int = 20 - #: The draft held at least ``claim_limit`` claims: more MAY exist. - claim_limit_reached: bool | None = None + #: **Deprecated**, use :attr:`claim_limit_exceeded`. The draft held at + #: least ``claim_limit`` claims: more MAY exist. + claim_limit_reached: bool | None = Field(default=None, json_schema_extra={"deprecated": True}) #: The text was cut at 50,000 characters. input_truncated: bool = False assessments: ReviewAssessmentCounts | None = None @@ -1447,8 +1578,9 @@ class ReviewSummary(_Lax): citations_selected: int | None = None #: The resolved ``max_citations``. citation_limit: int | None = None + #: **Deprecated**, use :attr:`citation_limit_exceeded`. #: ``citations_found`` is over ``citation_limit``. - citation_limit_reached: bool | None = None + citation_limit_reached: bool | None = Field(default=None, json_schema_extra={"deprecated": True}) citation_checks: ReviewCitationCheckCounts | None = None #: ``len(citation_issues)``. citation_issues: int = 0 @@ -1460,7 +1592,7 @@ class ReviewSummary(_Lax): @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if not _is_newer(data, "claim_limit_exceeded", "claim_limit_reached"): + if not _is_newer(data, "claim_limit_exceeded"): return data found, limit = data.get("claims_found"), data.get("claim_limit", 20) reached = found >= limit if isinstance(found, int) and isinstance(limit, int) else None @@ -1510,8 +1642,9 @@ class ReviewResult(_Lax): class ReviewAssessment(_Lax): """A claim's quick check. ``status``: ``pending`` | ``running`` | ``completed`` | ``failed``. On ``completed`` the fields mean what they - mean on an ``/assess`` row; on ``failed`` see ``error_code`` (an open - set, as on ``/assess``), ``hint`` and ``failure``.""" + mean on an ``/assess`` row; on ``failed`` see ``failure`` (``code`` is an + open set, as on ``/assess``; ``error_code`` and ``hint`` are its 2.x + names, deprecated and kept).""" status: str = "" verdict: str | None = None @@ -1519,9 +1652,13 @@ class ReviewAssessment(_Lax): rationale: str | None = None dissent: str | None = None verification_url: str | None = None - error_code: str | None = None - identified_claims: list[str] = Field(default_factory=list) - hint: str | None = None + #: **Deprecated**, use ``failure.code`` (``no_checkable_claim`` where this + #: reads ``no_claim``). + error_code: str | None = Field(default=None, json_schema_extra={"deprecated": True}) + #: **Deprecated**, use :attr:`more_claims`. + identified_claims: list[str] = Field(default_factory=list, json_schema_extra={"deprecated": True}) + #: **Deprecated**, use ``failure.hint`` (on a failed quick check). + hint: str | None = Field(default=None, json_schema_extra={"deprecated": True}) #: With ``suggest_edits=True``: the claim with its wrong part corrected, #: from the quick check's reasoning, when it found the claim "False" or #: "Mostly False" with high confidence. ``None`` otherwise, and from @@ -1533,18 +1670,22 @@ class ReviewAssessment(_Lax): @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if not _is_newer(data, "more_claims", "identified_claims"): + if not _is_newer(data, "more_claims"): return data failure = data.get("failure") if isinstance(data.get("failure"), dict) else {} + more = data.get("more_claims") or [] + completed = not failure and data.get("verdict") is not None return _fill( data, - identified_claims=data.get("more_claims") or [], + identified_claims=more, error_code=_old_code(failure.get("code"), "no_claim") if failure else None, + hint=_REVIEW_COMPOUND_HINT if completed and more else None, ) @property def more_claims(self) -> list[str]: - """Other claims found in the passage that were not checked.""" + """Other claims found in the passage that were not checked. Replaces + ``identified_claims``, which is deprecated and kept.""" sent = _sent(self, "more_claims") return list(sent) if isinstance(sent, list) else list(self.identified_claims) @@ -1588,7 +1729,9 @@ class ReviewVerification(_Lax): suggested_rewrite: str | None = None warnings: list[str] = Field(default_factory=list) created_at: str | None = None - modified_at: str | None = None + #: **Deprecated**, use :attr:`completed_at`. Set only when the check + #: completed on a later UTC day than ``created_at``. + modified_at: str | None = Field(default=None, json_schema_extra={"deprecated": True}) verification_url: str | None = None url: str | None = None failure: FailureBlock | None = None @@ -1596,7 +1739,7 @@ class ReviewVerification(_Lax): @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - return _fill_modified_at(data) + return _deep_check_failure(_fill_modified_at(data)) @property def completed_at(self) -> str | None: @@ -1709,6 +1852,12 @@ class ReviewIssue(_Lax): #: A copy of its claim row's ``suggested_edits``. suggested_edits: SuggestedEdits | None = None + @model_validator(mode="before") + @classmethod + def _read_newer_shape(cls, data: Any) -> Any: + # An issue's failure is its deep check's. + return _deep_check_failure(data) + class ReviewFailure(_Lax): """A claim outside the issues whose work failed. ``stage`` is @@ -1719,6 +1868,13 @@ class ReviewFailure(_Lax): stage: str = "" failure: FailureBlock | None = None + @model_validator(mode="before") + @classmethod + def _read_newer_shape(cls, data: Any) -> Any: + if isinstance(data, dict) and data.get("stage") == "verification": + return _deep_check_failure(data) + return data + class ReviewCitationResult(_Lax): """The one answer to read for a citation, derived from its ``check``. @@ -1906,14 +2062,15 @@ class CitecheckSummary(_Lax): citations_found: int | None = None citations_selected: int | None = None citation_limit: int | None = None - citation_limit_reached: bool | None = None + #: **Deprecated**, use :attr:`citation_limit_exceeded`. + citation_limit_reached: bool | None = Field(default=None, json_schema_extra={"deprecated": True}) citation_checks: ReviewCitationCheckCounts | None = None citation_issues: int = 0 @model_validator(mode="before") @classmethod def _read_newer_shape(cls, data: Any) -> Any: - if _is_newer(data, "citation_limit_exceeded", "citation_limit_reached"): + if _is_newer(data, "citation_limit_exceeded"): return _fill(data, citation_limit_reached=data["citation_limit_exceeded"]) return data @@ -1927,7 +2084,9 @@ def citation_limit_exceeded(self) -> bool | None: class Citecheck(_Lax): """``GET /citechecks/{citecheck_id}``: a citation check as it stands. - ``status``: ``queued`` → ``checking`` → ``completed``, or ``failed``. + ``status``: ``queued`` → ``checking`` → ``completed``, or ``failed``, or + ``cancelled`` (stopped elsewhere; ``citecheck_and_wait`` raises the failed + error for it, ``failure_class`` ``cancelled``). ``outcome`` is ``None`` until it ends, then ``clean``, ``issues_found``, ``incomplete`` (a citation could not be checked for a reason of ours) or ``unchecked``. ``citations``, ``citation_issues`` and ``citation_failures`` @@ -1955,7 +2114,9 @@ class ReviewEnvelope(_Lax): """What both views of ``GET /reviews/{review_id}`` carry. ``status`` is the lifecycle: ``queued`` → ``assessing`` → ``verifying`` - → ``completed``, or ``failed``. ``outcome`` is ``None`` until the review + → ``completed``, or ``failed``, or ``cancelled`` (stopped elsewhere; + ``review_and_wait`` raises the failed error for it, ``failure_class`` + ``cancelled``). ``outcome`` is ``None`` until the review ends, then one of ``clean`` (every selected claim checked, no issue), ``issues_found``, ``incomplete`` (some work failed) or ``unchecked`` (it failed before any claim was checked; ``failure`` says why). diff --git a/src/lenz_io/webhooks.py b/src/lenz_io/webhooks.py index 8d6bb54..2cc5f5d 100644 --- a/src/lenz_io/webhooks.py +++ b/src/lenz_io/webhooks.py @@ -27,6 +27,7 @@ from __future__ import annotations +import copy import hashlib import hmac import json @@ -42,8 +43,11 @@ Citecheck, FailureBlock, ReviewFull, + TaskStatus, _fill_modified_at, + _new_code, _old_code, + _verification_failure, ) SIGNATURE_HEADER = "X-Lenz-Signature" @@ -68,7 +72,12 @@ def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool: Returns rather than raising on success makes ``if verify_signature(...)`` idioms work; the raise-on-bad path means a silent ``False`` can't accidentally pass through. + + An empty ``secret`` raises ``ValueError``, as ``LenzWebhooks(secret="")`` + does: a body signed with the empty key proves nothing. """ + if not secret: + raise ValueError("verify_signature requires a non-empty secret. Get it from /api-credentials.") if not signature: raise LenzWebhookSignatureError( message="Missing webhook signature", @@ -120,12 +129,91 @@ def event_id(self) -> str: return value if isinstance(value, str) else "" +#: The run status each ``verification.*`` event reports. +_EVENT_STATUS = { + "verification.completed": "completed", + "verification.failed": "failed", + "verification.cancelled": "cancelled", + "verification.needs_input": "needs_input", +} + + +def _status_envelope(event: WebhookEvent) -> TaskStatus | None: + """The verification of a ``verification.*`` event as ``client.get_status`` + returns it: the newer payload's ``verification``, else one built from the + original flat fields. ``None`` when the payload cannot be read as one, + or its status is not the event's (``completed`` / ``failed`` / + ``cancelled`` / ``needs_input``).""" + raw = event.raw + nested = raw.get("verification") + body: dict[str, Any] + if isinstance(nested, dict): + # A ``null`` reads as the field left out (``hint: null`` on a pause). + body = {k: v for k, v in nested.items() if v is not None} + # Never presented as this event's kind when it says it is another + # (a ``verification.completed`` carrying a failed run). + if body.get("status") != _EVENT_STATUS.get(event.event): + return None + elif event.event == "verification.completed": + body = {"status": "completed", "task_id": raw.get("task_id") or ""} + if raw.get("result") is not None: + body["result"] = raw["result"] + elif event.event == "verification.failed": + error = raw.get("error") + failure: dict[str, Any] = { + "code": _new_code(error) if isinstance(error, str) else "", + # The original payload's ``error`` is the code, never a sentence. + "detail": None, + "failure_class": raw.get("failure_class"), + "retryable": raw.get("retryable") if isinstance(raw.get("retryable"), bool) else None, + } + body = { + "status": "failed", + "task_id": raw.get("task_id") or "", + "error": "", + "failure_reason": error if isinstance(error, str) else "", + "failure": failure, + } + elif event.event == "verification.cancelled": + body = {"status": "cancelled", "task_id": raw.get("task_id") or ""} + elif event.event == "verification.needs_input": + needs_input = raw.get("needs_input") + given = needs_input if isinstance(needs_input, dict) else {} + body = {"status": "needs_input", "task_id": raw.get("task_id") or ""} + body.update({k: v for k, v in given.items() if v is not None}) + else: + return None + if isinstance(body.get("result"), dict): + # A key the payload left out reads as ``event.result`` reads it (the + # original payload's default: ``visibility`` "private", ``depth`` + # "standard", ``created_at`` "", ...), never the model's own default. + body["result"] = _original_result(body["result"]) + try: + return TaskStatus.model_validate(body) + except ValidationError: + return None + + @dataclass class VerificationCompleted(WebhookEvent): - """``event=verification.completed`` — the pipeline produced a verdict.""" + """``event=verification.completed`` — the pipeline produced a verdict. + + Read :attr:`verification`: the verification as ``client.get_status`` + returns it, the verdict under ``verification.result`` (a typed + :class:`Verification`). ``result`` (the original payload's flat dict) is + deprecated and kept. + """ result: dict[str, Any] = field(default_factory=dict) + @property + def verification(self) -> TaskStatus | None: + """The verification as ``client.get_status`` returns it, from either + payload shape: ``status``, ``task_id`` and ``result`` (the verdict, + a typed :class:`Verification`; ``None`` when the payload carried + none). ``None`` when the payload cannot be read as one. Since 3.0.""" + return _status_envelope(self) + @dataclass class VerificationFailed(WebhookEvent): @@ -141,6 +229,13 @@ class VerificationFailed(WebhookEvent): failure_class: str = "" retryable: bool | None = None + @property + def verification(self) -> TaskStatus | None: + """The verification as ``client.get_status`` returns it, from either + payload shape: ``status``, ``task_id`` and ``failure``. ``None`` when + the payload cannot be read as one. Since 3.0.""" + return _status_envelope(self) + @property def failure(self) -> FailureBlock | None: """Why the run failed, from either payload shape: ``code`` (the cause, @@ -155,11 +250,31 @@ def failure(self) -> FailureBlock | None: return None block = {"failure_reason": self.error, "failure_class": self.failure_class, "retryable": self.retryable} try: - return FailureBlock.model_validate(block) + return FailureBlock.model_validate(_verification_failure(block)) except ValidationError: return None +@dataclass +class VerificationCancelled(WebhookEvent): + """``event=verification.cancelled`` — the verification was cancelled (from + the website, or by another process) before it finished. + + Sent only for work submitted with API version 2026-10-11 (this SDK's); + work submitted by an older client reports a cancellation as + ``verification.failed`` with ``failure_class`` ``cancelled`` + (:class:`VerificationFailed`). Nothing was charged. Read + :attr:`verification`: ``status`` is ``"cancelled"``. + """ + + @property + def verification(self) -> TaskStatus | None: + """The verification as ``client.get_status`` returns it: ``status`` + (``"cancelled"``) and ``task_id``. ``None`` when the payload cannot be + read as one.""" + return _status_envelope(self) + + @dataclass class VerificationNeedsInput(WebhookEvent): """``event=verification.needs_input`` — pipeline paused for caller input. @@ -176,6 +291,13 @@ class VerificationNeedsInput(WebhookEvent): needs_input: dict[str, Any] = field(default_factory=dict) hint: str = "" + @property + def verification(self) -> TaskStatus | None: + """The verification as ``client.get_status`` returns it, from either + payload shape: ``status``, ``task_id``, ``reason``, ``claims`` and + ``hint``. ``None`` when the payload cannot be read as one. Since 3.0.""" + return _status_envelope(self) + @property def reason(self) -> str: """Why the run paused (``multi_claim``).""" @@ -219,12 +341,15 @@ class CertificateTimestamped(WebhookEvent): @dataclass class ReviewEvent(WebhookEvent): - """``event=review.completed`` or ``review.failed`` — a review ended. + """``event=review.completed``, ``review.failed`` or ``review.cancelled`` — + a review ended. ``review`` is the final review (the same body ``get_review`` returns), or ``None`` if it could not be parsed (``raw["review"]`` still has it). - ``status`` is ``completed`` or ``failed``; read ``review.outcome`` and - ``review.issues``. + ``status`` is ``completed``, ``failed`` or ``cancelled``; read + ``review.outcome`` and ``review.issues``. ``review.cancelled`` is sent + only for work submitted with API version 2026-10-11; an older client's + cancelled review arrives as ``review.failed``. Deduplicate on ``event_id``: it is the same on every delivery attempt of one event, while ``attempt`` counts up. ``task_id`` identifies the @@ -240,8 +365,10 @@ class ReviewEvent(WebhookEvent): @dataclass class CitecheckEvent(WebhookEvent): - """``event=citecheck.completed`` or ``citecheck.failed`` — a citation - check ended. + """``event=citecheck.completed``, ``citecheck.failed`` or + ``citecheck.cancelled`` — a citation check ended. ``citecheck.cancelled`` + is sent only for work submitted with API version 2026-10-11; an older + client's cancelled check arrives as ``citecheck.failed``. ``citecheck`` is the final check (the same body ``get_citecheck`` returns), or ``None`` if it could not be parsed (``raw["citecheck"]`` @@ -256,6 +383,62 @@ class CitecheckEvent(WebhookEvent): citecheck: Citecheck | None = None +#: The original ``verification.completed`` ``result``: every key, in order, +#: with the value it took when the verification left it out. A newer payload +#: sends only the keys it has. +_RESULT_DEFAULTS: tuple[tuple[str, Any], ...] = ( + ("verification_id", ""), + ("claim", ""), + ("visibility", "private"), + ("depth", "standard"), + ("domain", ""), + ("entities", []), + ("presumed_intent", ""), + ("verdict", ""), + ("confidence", "low"), + ("lenz_score", None), + ("key_finding", ""), + ("executive_summary", ""), + ("warnings", []), + ("suggested_rewrite", None), + ("created_at", ""), + ("modified_at", None), + ("sources", []), + ("audit", None), + ("language", "en"), + ("coverage", None), +) +_AUDIT_DEFAULTS: tuple[tuple[str, Any], ...] = ( + ("adjudication_summary", ""), + ("assessments", []), + ("debate_pro", None), + ("debate_con", None), + ("panel_agreement", ""), +) +_SIDE_DEFAULTS: tuple[tuple[str, Any], ...] = (("role", ""), ("argument", ""), ("rebuttal", "")) + + +def _with_defaults(value: Any, defaults: tuple[tuple[str, Any], ...]) -> dict[str, Any]: + """``value`` (a dict, else empty) with every key of ``defaults`` in that + order, then any key it carries beyond them.""" + given = value if isinstance(value, dict) else {} + out = {key: given.get(key, copy.deepcopy(default)) for key, default in defaults} + out.update({k: v for k, v in given.items() if k not in out}) + return out + + +def _original_result(result: dict[str, Any]) -> dict[str, Any]: + """A newer payload's ``result`` with the original's keys and defaults + (``modified_at`` computed from ``completed_at``); keys the original did + not have (``completed_at``) follow them.""" + out = _with_defaults(_fill_modified_at(result), _RESULT_DEFAULTS) + audit = _with_defaults(out["audit"], _AUDIT_DEFAULTS) + audit["debate_pro"] = _with_defaults(audit["debate_pro"], _SIDE_DEFAULTS) + audit["debate_con"] = _with_defaults(audit["debate_con"], _SIDE_DEFAULTS) + out["audit"] = audit + return out + + def _original_view(payload: dict[str, Any]) -> dict[str, Any]: """The original flat ``verification.*`` fields, for a payload in the newer envelope (the polled body nested under ``verification``). Any other @@ -267,7 +450,7 @@ def _original_view(payload: dict[str, Any]) -> dict[str, Any]: view.setdefault("task_id", body.get("task_id")) result = body.get("result") if isinstance(result, dict): - view.setdefault("result", _fill_modified_at(result)) + view.setdefault("result", _original_result(result)) view.setdefault("verification_id", result.get("verification_id")) failure = body.get("failure") if isinstance(failure, dict): @@ -283,7 +466,7 @@ def _original_view(payload: dict[str, Any]) -> dict[str, Any]: view.setdefault( "needs_input", {"reason": body.get("reason", ""), "claims": options, "hint": body.get("hint", "")} ) - view.setdefault("coverage", body.get("coverage") or (result or {}).get("coverage")) + view.setdefault("coverage", body.get("coverage") or (result if isinstance(result, dict) else {}).get("coverage")) return view @@ -326,6 +509,17 @@ def _build_event(raw: dict[str, Any]) -> WebhookEvent: failure_class=str(payload.get("failure_class") or ""), retryable=payload.get("retryable") if isinstance(payload.get("retryable"), bool) else None, ) + if event == "verification.cancelled": + return VerificationCancelled( + event=event, + task_id=task_id, + attempt=attempt, + delivered_at=delivered_at, + verification_id=verification_id, + batch_id=batch_id, + status=status, + raw=raw, + ) if event == "verification.needs_input": needs_input = payload.get("needs_input") or {} return VerificationNeedsInput( @@ -352,7 +546,7 @@ def _build_event(raw: dict[str, Any]) -> WebhookEvent: raw=raw, coverage=payload.get("coverage") or {}, ) - if event in ("review.completed", "review.failed"): + if event in ("review.completed", "review.failed", "review.cancelled"): body = payload.get("review") try: review = ReviewFull.model_validate(body) if isinstance(body, dict) else None @@ -373,7 +567,7 @@ def _build_event(raw: dict[str, Any]) -> WebhookEvent: review_id=str(payload.get("review_id") or ""), review=review, ) - if event in ("citecheck.completed", "citecheck.failed"): + if event in ("citecheck.completed", "citecheck.failed", "citecheck.cancelled"): body = payload.get("citecheck") try: check = Citecheck.model_validate(body) if isinstance(body, dict) else None @@ -415,8 +609,8 @@ def parse_webhook(body: bytes | str | dict[str, Any]) -> WebhookEvent: use :meth:`LenzWebhooks.parse`, which checks the signature and the replay window first. - Returns a :class:`ReviewEvent` for ``review.*``, a :class:`CitecheckEvent` - for ``citecheck.*``, the matching verification + Returns a :class:`ReviewEvent` for ``review.*`` (``completed``, ``failed``, + ``cancelled``), a :class:`CitecheckEvent` for ``citecheck.*``, the matching verification or certificate event otherwise, and a plain :class:`WebhookEvent` for an event type this release does not know. Branch with ``isinstance`` and ignore events you do not handle. @@ -542,6 +736,7 @@ def _check_replay(self, payload: dict[str, Any]) -> None: "CitecheckEvent", "LenzWebhooks", "ReviewEvent", + "VerificationCancelled", "VerificationCompleted", "VerificationFailed", "VerificationNeedsInput", diff --git a/tests/fixtures/contract/cancel_citecheck_already_cancelled.json b/tests/fixtures/contract/cancel_citecheck_already_cancelled.json new file mode 100644 index 0000000..eb3eb92 --- /dev/null +++ b/tests/fixtures/contract/cancel_citecheck_already_cancelled.json @@ -0,0 +1,138 @@ +{ + "citecheck_id": "12bbbf65", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "poll_after_seconds": null, + "policy": { + "max_citations": 2 + }, + "summary": { + "citations_found": 2, + "citations_selected": 2, + "citation_limit": 2, + "citation_limit_exceeded": false, + "citation_checks": { + "checked": 0, + "unchecked": 0, + "failed": 2 + }, + "citation_issues": 0 + }, + "credits": { + "charged": 0 + }, + "citations": [ + { + "index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "statement": "Unemployment fell to 4.1% in 2024.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + }, + { + "index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "statement": "The agency said so in a statement.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + } + ], + "citation_issues": [], + "citation_failures": [ + { + "citation_index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + }, + { + "citation_index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + } + ], + "failure": null, + "more_citations": [] +} diff --git a/tests/fixtures/contract/cancel_citecheck_cancelled.json b/tests/fixtures/contract/cancel_citecheck_cancelled.json new file mode 100644 index 0000000..eb3eb92 --- /dev/null +++ b/tests/fixtures/contract/cancel_citecheck_cancelled.json @@ -0,0 +1,138 @@ +{ + "citecheck_id": "12bbbf65", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "poll_after_seconds": null, + "policy": { + "max_citations": 2 + }, + "summary": { + "citations_found": 2, + "citations_selected": 2, + "citation_limit": 2, + "citation_limit_exceeded": false, + "citation_checks": { + "checked": 0, + "unchecked": 0, + "failed": 2 + }, + "citation_issues": 0 + }, + "credits": { + "charged": 0 + }, + "citations": [ + { + "index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "statement": "Unemployment fell to 4.1% in 2024.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + }, + { + "index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "statement": "The agency said so in a statement.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + } + ], + "citation_issues": [], + "citation_failures": [ + { + "citation_index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + }, + { + "citation_index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + } + ], + "failure": null, + "more_citations": [] +} diff --git a/tests/fixtures/contract/cancel_citecheck_completed.json b/tests/fixtures/contract/cancel_citecheck_completed.json new file mode 100644 index 0000000..69837e2 --- /dev/null +++ b/tests/fixtures/contract/cancel_citecheck_completed.json @@ -0,0 +1,103 @@ +{ + "citecheck_id": "12bbbf65", + "status": "completed", + "outcome": "clean", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "poll_after_seconds": null, + "policy": { + "max_citations": 2 + }, + "summary": { + "citations_found": 2, + "citations_selected": 2, + "citation_limit": 2, + "citation_limit_exceeded": false, + "citation_checks": { + "checked": 2, + "unchecked": 0, + "failed": 0 + }, + "citation_issues": 0 + }, + "credits": { + "charged": 2 + }, + "citations": [ + { + "index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "statement": "Unemployment fell to 4.1% in 2024.", + "quotes": [], + "position": null, + "result": { + "finding": "supported", + "source": "support", + "is_issue": false + }, + "check": { + "status": "completed", + "page_read": "full", + "page_title": "Page at https://example.gov/report-2024", + "page_published_date": null, + "page_language": "en", + "source_url": "https://example.gov/report-2024", + "source_version": null, + "support": "supported", + "snippet": "The source says so.", + "rationale": "The page states it.", + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": null, + "failure": null, + "missing_quote": null + } + }, + { + "index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "statement": "The agency said so in a statement.", + "quotes": [], + "position": null, + "result": { + "finding": "supported", + "source": "support", + "is_issue": false + }, + "check": { + "status": "completed", + "page_read": "full", + "page_title": "Page at https://example.org/statement", + "page_published_date": null, + "page_language": "en", + "source_url": "https://example.org/statement", + "source_version": null, + "support": "supported", + "snippet": "The source says so.", + "rationale": "The page states it.", + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": null, + "failure": null, + "missing_quote": null + } + } + ], + "citation_issues": [], + "citation_failures": [], + "failure": null, + "more_citations": [] +} diff --git a/tests/fixtures/contract/cancel_review_already_cancelled.json b/tests/fixtures/contract/cancel_review_already_cancelled.json new file mode 100644 index 0000000..55e47f2 --- /dev/null +++ b/tests/fixtures/contract/cancel_review_already_cancelled.json @@ -0,0 +1,182 @@ +{ + "review_id": "d6b2bd72", + "view": "full", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "policy": { + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ], + "confidence": [ + "low" + ], + "max_assessments": 20, + "max_verifications": 5, + "depth": "standard", + "max_citations": 0, + "suggest_edits": false + }, + "summary": { + "claims_found": 2, + "claims_selected": 2, + "claim_limit": 20, + "claim_limit_exceeded": false, + "input_truncated": false, + "assessments": { + "completed": 2, + "failed": 0 + }, + "verifications": { + "planned": 1, + "completed": 0, + "failed": 1 + }, + "issues": 1, + "citations_found": null, + "citations_selected": null, + "citation_limit": null, + "citation_limit_exceeded": null, + "citation_checks": null, + "citation_issues": 0, + "citations_skipped": null + }, + "credits": { + "charged": 2 + }, + "poll_after_seconds": null, + "issues": [ + { + "claim_index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "verified_claim": null, + "verdict": "False", + "confidence": "high", + "source": "assessment", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "url": null, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "key_finding": null, + "rationale": null, + "suggested_rewrite": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "suggested_edits": null + } + ], + "failures": [], + "citation_issues": [], + "citation_failures": [], + "claims": [ + { + "index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "positions": null, + "result": { + "verdict": "False", + "confidence": "high", + "source": "assessment", + "is_issue": true + }, + "assessment": { + "status": "completed", + "verdict": "False", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "verification": { + "verification_id": null, + "task_id": "9e8d7c6b5a49382716f5e4d3c2b1a098", + "claim": null, + "language": null, + "visibility": null, + "depth": null, + "domain": null, + "entities": [], + "verdict": null, + "confidence": null, + "lenz_score": null, + "key_finding": null, + "executive_summary": null, + "suggested_rewrite": null, + "warnings": [], + "created_at": null, + "completed_at": null, + "verification_url": null, + "url": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "status": "failed", + "content_status": "available" + }, + "suggested_edits": null + }, + { + "index": 1, + "claim": "Paris is the capital of France.", + "positions": null, + "result": { + "verdict": "True", + "confidence": "high", + "source": "assessment", + "is_issue": false + }, + "assessment": { + "status": "completed", + "verdict": "True", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [], + "disposition": "not_selected" + }, + "verification": null, + "suggested_edits": null + } + ], + "citations": [], + "failure": null, + "more_claims": [], + "more_claim_locations": [], + "more_citations": [] +} diff --git a/tests/fixtures/contract/cancel_review_cancelled.json b/tests/fixtures/contract/cancel_review_cancelled.json new file mode 100644 index 0000000..55e47f2 --- /dev/null +++ b/tests/fixtures/contract/cancel_review_cancelled.json @@ -0,0 +1,182 @@ +{ + "review_id": "d6b2bd72", + "view": "full", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "policy": { + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ], + "confidence": [ + "low" + ], + "max_assessments": 20, + "max_verifications": 5, + "depth": "standard", + "max_citations": 0, + "suggest_edits": false + }, + "summary": { + "claims_found": 2, + "claims_selected": 2, + "claim_limit": 20, + "claim_limit_exceeded": false, + "input_truncated": false, + "assessments": { + "completed": 2, + "failed": 0 + }, + "verifications": { + "planned": 1, + "completed": 0, + "failed": 1 + }, + "issues": 1, + "citations_found": null, + "citations_selected": null, + "citation_limit": null, + "citation_limit_exceeded": null, + "citation_checks": null, + "citation_issues": 0, + "citations_skipped": null + }, + "credits": { + "charged": 2 + }, + "poll_after_seconds": null, + "issues": [ + { + "claim_index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "verified_claim": null, + "verdict": "False", + "confidence": "high", + "source": "assessment", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "url": null, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "key_finding": null, + "rationale": null, + "suggested_rewrite": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "suggested_edits": null + } + ], + "failures": [], + "citation_issues": [], + "citation_failures": [], + "claims": [ + { + "index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "positions": null, + "result": { + "verdict": "False", + "confidence": "high", + "source": "assessment", + "is_issue": true + }, + "assessment": { + "status": "completed", + "verdict": "False", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "verification": { + "verification_id": null, + "task_id": "9e8d7c6b5a49382716f5e4d3c2b1a098", + "claim": null, + "language": null, + "visibility": null, + "depth": null, + "domain": null, + "entities": [], + "verdict": null, + "confidence": null, + "lenz_score": null, + "key_finding": null, + "executive_summary": null, + "suggested_rewrite": null, + "warnings": [], + "created_at": null, + "completed_at": null, + "verification_url": null, + "url": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "status": "failed", + "content_status": "available" + }, + "suggested_edits": null + }, + { + "index": 1, + "claim": "Paris is the capital of France.", + "positions": null, + "result": { + "verdict": "True", + "confidence": "high", + "source": "assessment", + "is_issue": false + }, + "assessment": { + "status": "completed", + "verdict": "True", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [], + "disposition": "not_selected" + }, + "verification": null, + "suggested_edits": null + } + ], + "citations": [], + "failure": null, + "more_claims": [], + "more_claim_locations": [], + "more_citations": [] +} diff --git a/tests/fixtures/contract/cancel_review_completed.json b/tests/fixtures/contract/cancel_review_completed.json new file mode 100644 index 0000000..278eb24 --- /dev/null +++ b/tests/fixtures/contract/cancel_review_completed.json @@ -0,0 +1,91 @@ +{ + "review_id": "d6b2bd72", + "view": "full", + "status": "completed", + "outcome": "clean", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "policy": { + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ], + "confidence": [ + "low" + ], + "max_assessments": 20, + "max_verifications": 5, + "depth": "standard", + "max_citations": 0, + "suggest_edits": false + }, + "summary": { + "claims_found": 1, + "claims_selected": 1, + "claim_limit": 20, + "claim_limit_exceeded": false, + "input_truncated": false, + "assessments": { + "completed": 1, + "failed": 0 + }, + "verifications": { + "planned": 0, + "completed": 0, + "failed": 0 + }, + "issues": 0, + "citations_found": null, + "citations_selected": null, + "citation_limit": null, + "citation_limit_exceeded": null, + "citation_checks": null, + "citation_issues": 0, + "citations_skipped": null + }, + "credits": { + "charged": 1 + }, + "poll_after_seconds": null, + "issues": [], + "failures": [], + "citation_issues": [], + "citation_failures": [], + "claims": [ + { + "index": 0, + "claim": "Paris is the capital of France.", + "positions": null, + "result": { + "verdict": "True", + "confidence": "high", + "source": "assessment", + "is_issue": false + }, + "assessment": { + "status": "completed", + "verdict": "True", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [], + "disposition": "not_selected" + }, + "verification": null, + "suggested_edits": null + } + ], + "citations": [], + "failure": null, + "more_claims": [], + "more_claim_locations": [], + "more_citations": [] +} diff --git a/tests/fixtures/contract/cancel_verify_cancelled.json b/tests/fixtures/contract/cancel_verify_cancelled.json new file mode 100644 index 0000000..b918523 --- /dev/null +++ b/tests/fixtures/contract/cancel_verify_cancelled.json @@ -0,0 +1,5 @@ +{ + "task_id": "3f2a9c1e5b7d4a608c1d2e3f4a5b6c7d", + "cancelled": true, + "status": "cancelled" +} diff --git a/tests/fixtures/contract/cancel_verify_completed.json b/tests/fixtures/contract/cancel_verify_completed.json new file mode 100644 index 0000000..bdf5d94 --- /dev/null +++ b/tests/fixtures/contract/cancel_verify_completed.json @@ -0,0 +1,5 @@ +{ + "task_id": "3f2a9c1e5b7d4a608c1d2e3f4a5b6c7d", + "cancelled": false, + "status": "completed" +} diff --git a/tests/fixtures/contract/error_cancel_citecheck_404.json b/tests/fixtures/contract/error_cancel_citecheck_404.json new file mode 100644 index 0000000..fa2174f --- /dev/null +++ b/tests/fixtures/contract/error_cancel_citecheck_404.json @@ -0,0 +1,4 @@ +{ + "detail": "Citation check not found.", + "code": "not_found" +} diff --git a/tests/fixtures/contract/error_cancel_review_404.json b/tests/fixtures/contract/error_cancel_review_404.json new file mode 100644 index 0000000..d27beb2 --- /dev/null +++ b/tests/fixtures/contract/error_cancel_review_404.json @@ -0,0 +1,4 @@ +{ + "detail": "Review not found.", + "code": "not_found" +} diff --git a/tests/fixtures/contract/error_cancel_task_404.json b/tests/fixtures/contract/error_cancel_task_404.json new file mode 100644 index 0000000..0f2d164 --- /dev/null +++ b/tests/fixtures/contract/error_cancel_task_404.json @@ -0,0 +1,4 @@ +{ + "detail": "Task not found.", + "code": "not_found" +} diff --git a/tests/fixtures/contract/error_cancel_use_review_cancel_409.json b/tests/fixtures/contract/error_cancel_use_review_cancel_409.json new file mode 100644 index 0000000..2dd1243 --- /dev/null +++ b/tests/fixtures/contract/error_cancel_use_review_cancel_409.json @@ -0,0 +1,4 @@ +{ + "detail": "This task is a review's deep check. Cancel the review to cancel it.", + "code": "use_review_cancel" +} diff --git a/tests/fixtures/freeze/requests.json b/tests/fixtures/freeze/requests.json new file mode 100644 index 0000000..59303a0 --- /dev/null +++ b/tests/fixtures/freeze/requests.json @@ -0,0 +1,8839 @@ +{ + "ask_history": { + "outcome": { + "result": "AskHistory" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/ask/v1" + } + ], + "sleeps": [] + }, + "ask_reset": { + "outcome": { + "result": "bool" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "DELETE", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/ask/v1" + } + ], + "sleeps": [] + }, + "ask_send": { + "outcome": { + "result": "AskReply" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"message\":\"Why?\",\"language\":\"auto\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "36" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/ask/v1" + } + ], + "sleeps": [] + }, + "assess_claim": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "assess_claims": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"claims\":[\"A.\",\"B.\"],\"language\":\"auto\",\"suggest_rewrite\":true}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "63" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "assess_timeout_20": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 20, + "pool": 20, + "read": 20, + "write": 20 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "borrowed_10:assess": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "borrowed_10:extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 150.0, + "pool": 150.0, + "read": 150.0, + "write": 150.0 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "borrowed_10:extract_timeout_9": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 9, + "pool": 9, + "read": 9, + "write": 9 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "borrowed_10:review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 2.0, + "pool": 2.0, + "read": 2.0, + "write": 2.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0 + ] + }, + "borrowed_10:usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "borrowed_10:wait": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 12s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 6.0, + "pool": 6.0, + "read": 6.0, + "write": 6.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 6.0 + ] + }, + "borrowed_300:assess": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 300.0, + "pool": 300.0, + "read": 300.0, + "write": 300.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "borrowed_300:extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 300.0, + "pool": 300.0, + "read": 300.0, + "write": 300.0 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "borrowed_300:extract_timeout_9": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 9, + "pool": 9, + "read": 9, + "write": 9 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "borrowed_300:review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 300.0, + "pool": 300.0, + "read": 300.0, + "write": 300.0 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 2.0, + "pool": 2.0, + "read": 2.0, + "write": 2.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0 + ] + }, + "borrowed_300:usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 300.0, + "pool": 300.0, + "read": 300.0, + "write": 300.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "borrowed_300:wait": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 12s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept", + "*/*" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "python-httpx/0.28.1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 6.0, + "pool": 6.0, + "read": 6.0, + "write": 6.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 6.0 + ] + }, + "cancel": { + "outcome": { + "result": "CancelResult" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Content-Length", + "0" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/3f2a9c1e5b7d4a608c1d2e3f4a5b6c7d/cancel" + } + ], + "sleeps": [] + }, + "cancel_citecheck": { + "outcome": { + "result": "Citecheck" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Content-Length", + "0" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/citechecks/12bbbf65/cancel" + } + ], + "sleeps": [] + }, + "cancel_review": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Content-Length", + "0" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/reviews/d6b2bd72/cancel" + } + ], + "sleeps": [] + }, + "citecheck_and_wait": { + "outcome": { + "result": "Citecheck" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "22" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/citecheck" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/citechecks/12bbbf65" + } + ], + "sleeps": [] + }, + "citecheck_pairs": { + "outcome": { + "result": "CitecheckStarted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"pairs\":[{\"statement\":\"S.\",\"url\":\"https://e.x/s\"}]}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "52" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/citecheck" + } + ], + "sleeps": [] + }, + "citecheck_text": { + "outcome": { + "result": "CitecheckStarted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"max_citations\":2}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "40" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/citecheck" + } + ], + "sleeps": [] + }, + "error_404": { + "outcome": { + "code": "", + "error": "LenzNotFoundError", + "idempotency_key": null, + "message": "nope", + "status_code": 404 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [] + }, + "extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 150.0, + "pool": 150.0, + "read": 150.0, + "write": 150.0 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "extract_options": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\",\"language\":\"it\",\"focus\":\"figures\",\"locate\":true}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "63" + ] + ], + "method": "POST", + "timeout": { + "connect": 150.0, + "pool": 150.0, + "read": 150.0, + "write": 150.0 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "extract_timeout_5": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 5, + "pool": 5, + "read": 5, + "write": 5 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "extract_timeout_obj": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 7, + "pool": 7, + "read": 300, + "write": 7 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "get_citecheck": { + "outcome": { + "result": "Citecheck" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/citechecks/12bbbf65" + } + ], + "sleeps": [] + }, + "get_review": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [] + }, + "get_review_issues": { + "outcome": { + "result": "ReviewIssues" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9?view=issues" + } + ], + "sleeps": [] + }, + "get_status": { + "outcome": { + "result": "TaskStatus" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [] + }, + "keyless_refused": { + "outcome": { + "code": "", + "error": "LenzAuthError", + "idempotency_key": null, + "message": "API key required", + "status_code": 0 + }, + "requests": [], + "sleeps": [] + }, + "library_iter": { + "outcome": { + "result": [ + "LibraryItem", + "LibraryItem" + ] + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/library?page=1&sort=recent&search=x&domain=&entity=" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/library?page=2&sort=recent&search=x&domain=&entity=" + } + ], + "sleeps": [] + }, + "library_list": { + "outcome": { + "result": "LibraryList" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/library?page=1&sort=recent&search=&domain=&entity=" + } + ], + "sleeps": [] + }, + "library_list_filters": { + "outcome": { + "result": "LibraryList" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/library?page=2&sort=most_true&search=x&domain=&entity=&curated=trivia&verdict=True" + } + ], + "sleeps": [] + }, + "max_retries_0_503": { + "outcome": { + "code": "", + "error": "LenzAPIError", + "idempotency_key": null, + "message": "busy", + "status_code": 503 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "max_retries_1_503": { + "outcome": { + "code": "", + "error": "LenzAPIError", + "idempotency_key": "", + "message": "busy", + "status_code": 503 + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + }, + { + "at": 1.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [ + 1.0 + ] + }, + "retries_exhausted_503": { + "outcome": { + "code": "", + "error": "LenzAPIError", + "idempotency_key": null, + "message": "busy", + "status_code": 503 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + }, + { + "at": 1.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + }, + { + "at": 3.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + }, + { + "at": 7.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [ + 1.0, + 2.0, + 4.0 + ] + }, + "retries_exhausted_transport": { + "outcome": { + "code": "", + "error": "LenzConnectionError", + "idempotency_key": null, + "message": "GET /ask/v1 failed after 4 attempts: connection refused", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/ask/v1" + }, + { + "at": 1.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/ask/v1" + }, + { + "at": 3.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/ask/v1" + }, + { + "at": 7.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/ask/v1" + } + ], + "sleeps": [ + 1.0, + 2.0, + 4.0 + ] + }, + "retry_409_conflict": { + "outcome": { + "result": "TaskAccepted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\",\"source_url\":\"\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "29" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + }, + { + "at": 2.0, + "body": "{\"text\":\"A.\",\"source_url\":\"\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "29" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + } + ], + "sleeps": [ + 2 + ] + }, + "retry_429_stated": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [ + 2 + ] + }, + "retry_503": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + }, + { + "at": 1.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [ + 1.0 + ] + }, + "retry_transport": { + "outcome": { + "result": "TaskStatus" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 1.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 1.0 + ] + }, + "review": { + "outcome": { + "result": "ReviewStarted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/review" + } + ], + "sleeps": [] + }, + "review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 20.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0, + 10.0 + ] + }, + "review_and_wait_times_out": { + "outcome": { + "code": "", + "error": "ReviewTimeout", + "idempotency_key": "", + "message": "review_and_wait timed out after 25s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 25.0, + "pool": 25.0, + "read": 25.0, + "write": 25.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 15.0, + "pool": 15.0, + "read": 15.0, + "write": 15.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 20.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 5.0, + "pool": 5.0, + "read": 5.0, + "write": 5.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0, + 10.0, + 5.0 + ] + }, + "review_and_wait_zero": { + "outcome": { + "code": "", + "error": "ReviewTimeout", + "idempotency_key": "", + "message": "review_and_wait timed out after 0s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [] + }, + "review_options": { + "outcome": { + "result": "ReviewStarted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\",\"escalate\":{\"verdicts\":[\"False\"],\"max_citations\":3,\"suggest_edits\":true}}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "118" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/review" + } + ], + "sleeps": [] + }, + "select": { + "outcome": { + "result": "BatchAccepted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"texts\":[\"A.\",\"B.\"]}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "21" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/t1/select" + } + ], + "sleeps": [] + }, + "timeout_120:assess": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 120.0, + "pool": 120.0, + "read": 120.0, + "write": 120.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "timeout_120:extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 150.0, + "pool": 150.0, + "read": 150.0, + "write": 150.0 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_120:extract_timeout_9": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 9, + "pool": 9, + "read": 9, + "write": 9 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_120:review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 120.0, + "pool": 120.0, + "read": 120.0, + "write": 120.0 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 2.0, + "pool": 2.0, + "read": 2.0, + "write": 2.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0 + ] + }, + "timeout_120:usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 120.0, + "pool": 120.0, + "read": 120.0, + "write": 120.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "timeout_120:wait": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 12s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 6.0, + "pool": 6.0, + "read": 6.0, + "write": 6.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 6.0 + ] + }, + "timeout_200_read_5:assess": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 100.0, + "pool": 100.0, + "read": 100.0, + "write": 100.0 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "timeout_200_read_5:extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 150.0, + "pool": 150.0, + "read": 150.0, + "write": 150.0 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_200_read_5:extract_timeout_9": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 9, + "pool": 9, + "read": 9, + "write": 9 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_200_read_5:review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 200, + "pool": 200, + "read": 5, + "write": 200 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 5, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 2.0, + "pool": 2.0, + "read": 2.0, + "write": 2.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0 + ] + }, + "timeout_200_read_5:usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 200, + "pool": 200, + "read": 5, + "write": 200 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "timeout_200_read_5:wait": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 12s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 5, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 5, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 6.0, + "pool": 6.0, + "read": 5, + "write": 6.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 6.0 + ] + }, + "timeout_30_read_none:assess": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 30, + "pool": 30, + "read": null, + "write": 30 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "timeout_30_read_none:extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 30, + "pool": 30, + "read": null, + "write": 30 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_30_read_none:extract_timeout_9": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 9, + "pool": 9, + "read": 9, + "write": 9 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_30_read_none:review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 30, + "pool": 30, + "read": null, + "write": 30 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 2.0, + "pool": 2.0, + "read": 2.0, + "write": 2.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0 + ] + }, + "timeout_30_read_none:usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30, + "pool": 30, + "read": null, + "write": 30 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "timeout_30_read_none:wait": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 12s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 6.0, + "pool": 6.0, + "read": 6.0, + "write": 6.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 6.0 + ] + }, + "timeout_5_read_200:assess": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": 5, + "pool": 5, + "read": 200, + "write": 5 + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "timeout_5_read_200:extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 5, + "pool": 5, + "read": 200, + "write": 5 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_5_read_200:extract_timeout_9": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 9, + "pool": 9, + "read": 9, + "write": 9 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_5_read_200:review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": 5, + "pool": 5, + "read": 200, + "write": 5 + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 5, + "pool": 5, + "read": 12.0, + "write": 5 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 2.0, + "pool": 2.0, + "read": 2.0, + "write": 2.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0 + ] + }, + "timeout_5_read_200:usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 5, + "pool": 5, + "read": 200, + "write": 5 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "timeout_5_read_200:wait": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 12s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 5, + "pool": 5, + "read": 12.0, + "write": 5 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 5, + "pool": 5, + "read": 10.0, + "write": 5 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 5, + "pool": 5, + "read": 6.0, + "write": 5 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 6.0 + ] + }, + "timeout_none:assess": { + "outcome": { + "result": "AssessResponse" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "13" + ] + ], + "method": "POST", + "timeout": { + "connect": null, + "pool": null, + "read": null, + "write": null + }, + "url": "https://lenz.io/api/v1/assess" + } + ], + "sleeps": [] + }, + "timeout_none:extract": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": null, + "pool": null, + "read": null, + "write": null + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_none:extract_timeout_9": { + "outcome": { + "result": "ExtractedClaims" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Doc.\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "15" + ] + ], + "method": "POST", + "timeout": { + "connect": 9, + "pool": 9, + "read": 9, + "write": 9 + }, + "url": "https://lenz.io/api/v1/extract" + } + ], + "sleeps": [] + }, + "timeout_none:review_and_wait": { + "outcome": { + "result": "ReviewFull" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"Draft text.\",\"visibility\":\"private\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "45" + ] + ], + "method": "POST", + "timeout": { + "connect": null, + "pool": null, + "read": null, + "write": null + }, + "url": "https://lenz.io/api/v1/review" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + }, + { + "at": 10.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 2.0, + "pool": 2.0, + "read": 2.0, + "write": 2.0 + }, + "url": "https://lenz.io/api/v1/reviews/442b6aa9" + } + ], + "sleeps": [ + 10.0 + ] + }, + "timeout_none:usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": null, + "pool": null, + "read": null, + "write": null + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "timeout_none:wait": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 12s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 12.0, + "pool": 12.0, + "read": 12.0, + "write": 12.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 10.0, + "pool": 10.0, + "read": 10.0, + "write": 10.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 6.0, + "pool": 6.0, + "read": 6.0, + "write": 6.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 6.0 + ] + }, + "usage": { + "outcome": { + "result": "Usage" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/me/usage" + } + ], + "sleeps": [] + }, + "verifications_delete": { + "outcome": { + "result": "bool" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "DELETE", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications/v1" + } + ], + "sleeps": [] + }, + "verifications_delete_404": { + "outcome": { + "result": "bool" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "DELETE", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications/v1" + } + ], + "sleeps": [] + }, + "verifications_get": { + "outcome": { + "result": "Verification" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications/v1" + } + ], + "sleeps": [] + }, + "verifications_get_certificate": { + "outcome": { + "result": "Certificate" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications/v1/certificate" + } + ], + "sleeps": [] + }, + "verifications_get_keyless": { + "outcome": { + "result": "Verification" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications/v1" + } + ], + "sleeps": [] + }, + "verifications_iter": { + "outcome": { + "result": [ + "VerificationListItem", + "VerificationListItem" + ] + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications?page=1" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications?page=2" + } + ], + "sleeps": [] + }, + "verifications_list": { + "outcome": { + "result": "VerificationList" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications?page=1" + } + ], + "sleeps": [] + }, + "verifications_list_p2": { + "outcome": { + "result": "VerificationList" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications?page=2" + } + ], + "sleeps": [] + }, + "verifications_related": { + "outcome": { + "result": "RelatedVerifications" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verifications/v1/related?limit=3" + } + ], + "sleeps": [] + }, + "verify_and_wait": { + "outcome": { + "result": "Verification" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\",\"source_url\":\"\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "29" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 3.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 7.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 3.0, + 4.0 + ] + }, + "verify_and_wait_times_out": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": "", + "message": "wait timed out after 5s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\",\"source_url\":\"\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "29" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 5.0, + "pool": 5.0, + "read": 5.0, + "write": 5.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 3.0, + "pool": 3.0, + "read": 3.0, + "write": 3.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 3.0 + ] + }, + "verify_batch": { + "outcome": { + "result": "BatchAccepted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"claims\":[{\"text\":\"A.\"},{\"text\":\"B.\"}],\"language\":\"de\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "56" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/batch" + } + ], + "sleeps": [] + }, + "verify_batch_and_wait": { + "outcome": { + "result": [ + "BatchItemResult", + "BatchItemResult" + ] + }, + "requests": [ + { + "at": 0.0, + "body": "{\"claims\":[{\"text\":\"A.\"},{\"text\":\"B.\"}]}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "40" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/batch" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t2" + }, + { + "at": 3.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 3.0 + ] + }, + "verify_no_key": { + "outcome": { + "result": "TaskAccepted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\",\"source_url\":\"\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "29" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + } + ], + "sleeps": [] + }, + "verify_options": { + "outcome": { + "result": "TaskAccepted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\",\"source_url\":\"https://e.x/a\",\"webhook_url\":\"https://e.x/h\",\"language\":\"es\",\"visibility\":\"unlisted\",\"depth\":\"low\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "126" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + } + ], + "sleeps": [] + }, + "verify_pinned": { + "outcome": { + "result": "TaskAccepted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\",\"source_url\":\"\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "pinned-key-1" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "29" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + } + ], + "sleeps": [] + }, + "verify_random_key": { + "outcome": { + "result": "TaskAccepted" + }, + "requests": [ + { + "at": 0.0, + "body": "{\"text\":\"A.\",\"source_url\":\"\"}", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Idempotency-Key", + "" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ], + [ + "Content-Length", + "29" + ] + ], + "method": "POST", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify" + } + ], + "sleeps": [] + }, + "wait_negative": { + "outcome": { + "result": "Verification" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [] + }, + "wait_poll_errors": { + "outcome": { + "result": "Verification" + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 2.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 6.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + }, + { + "at": 14.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [ + 2.0, + 4.0, + 8.0 + ] + }, + "wait_zero": { + "outcome": { + "code": "", + "error": "LenzTimeoutError", + "idempotency_key": null, + "message": "wait timed out after 0s", + "status_code": 0 + }, + "requests": [ + { + "at": 0.0, + "body": "", + "headers": [ + [ + "Host", + "lenz.io" + ], + [ + "Accept-Encoding", + "" + ], + [ + "Connection", + "keep-alive" + ], + [ + "User-Agent", + "" + ], + [ + "Accept", + "application/json" + ], + [ + "Authorization", + "Bearer lenz_00000000000000000000000000000000" + ], + [ + "Content-Type", + "application/json" + ], + [ + "X-Lenz-API-Version", + "2026-10-11" + ] + ], + "method": "GET", + "timeout": { + "connect": 30.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0 + }, + "url": "https://lenz.io/api/v1/verify/status/t1" + } + ], + "sleeps": [] + } +} diff --git a/tests/fixtures/parity/legacy/verify__replay_later_202.json b/tests/fixtures/parity/canonical/account__ask_send_idempotent_replay.json similarity index 51% rename from tests/fixtures/parity/legacy/verify__replay_later_202.json rename to tests/fixtures/parity/canonical/account__ask_send_idempotent_replay.json index 1991e8a..b387511 100644 --- a/tests/fixtures/parity/legacy/verify__replay_later_202.json +++ b/tests/fixtures/parity/canonical/account__ask_send_idempotent_replay.json @@ -1,9 +1,9 @@ { - "status": 202, + "status": 200, "body": { - "task_id": "7cff18da2d979d6370e54a160be121ad", - "status": "queued", - "chain_id": "99a0a9ca89e1e72c" + "role": "expert", + "content": "Because.", + "created_at": "2026-10-01T09:07:00.123456+00:00" }, "headers": { "Content-Type": "application/json", diff --git a/tests/fixtures/parity/canonical/account__oauth_revoke_token.json b/tests/fixtures/parity/canonical/account__oauth_revoke_live_token.json similarity index 100% rename from tests/fixtures/parity/canonical/account__oauth_revoke_token.json rename to tests/fixtures/parity/canonical/account__oauth_revoke_live_token.json diff --git a/tests/fixtures/parity/canonical/citecheck__402_no_credits.json b/tests/fixtures/parity/canonical/citecheck__402_no_credits.json index 5d36059..69c2002 100644 --- a/tests/fixtures/parity/canonical/citecheck__402_no_credits.json +++ b/tests/fixtures/parity/canonical/citecheck__402_no_credits.json @@ -6,8 +6,8 @@ "docs_url": "https://lenz.io/docs/errors#quota", "upgrade_url": "https://lenz.io/plans?wall=6b86b273-ff34-4ce1-8d6b-804eff5a3f57", "wall_id": "6b86b273-ff34-4ce1-8d6b-804eff5a3f57", + "remaining": 100, "resets_at": "2026-10-01T14:07:00+00:00", - "credits_remaining": 100, "cost": 1 }, "headers": { diff --git a/tests/fixtures/parity/canonical/citecheck__get_cancelled.json b/tests/fixtures/parity/canonical/citecheck__get_cancelled.json new file mode 100644 index 0000000..320730c --- /dev/null +++ b/tests/fixtures/parity/canonical/citecheck__get_cancelled.json @@ -0,0 +1,146 @@ +{ + "status": 200, + "body": { + "citecheck_id": "8eb49302", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "poll_after_seconds": null, + "policy": { + "max_citations": 2 + }, + "summary": { + "citations_found": 2, + "citations_selected": 2, + "citation_limit": 2, + "citation_limit_exceeded": false, + "citation_checks": { + "checked": 0, + "unchecked": 0, + "failed": 2 + }, + "citation_issues": 0 + }, + "credits": { + "charged": 0 + }, + "citations": [ + { + "index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "statement": "Unemployment fell to 4.1% in 2024.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + }, + { + "index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "statement": "The agency said so in a statement.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + } + ], + "citation_issues": [], + "citation_failures": [ + { + "citation_index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + }, + { + "citation_index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + } + ], + "failure": null, + "more_citations": [] + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/canonical/review__get_cancelled.json b/tests/fixtures/parity/canonical/review__get_cancelled.json new file mode 100644 index 0000000..9ec4e20 --- /dev/null +++ b/tests/fixtures/parity/canonical/review__get_cancelled.json @@ -0,0 +1,190 @@ +{ + "status": 200, + "body": { + "review_id": "d6b2bd72", + "view": "full", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "policy": { + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ], + "confidence": [ + "low" + ], + "max_assessments": 20, + "max_verifications": 5, + "depth": "standard", + "max_citations": 0, + "suggest_edits": false + }, + "summary": { + "claims_found": 2, + "claims_selected": 2, + "claim_limit": 20, + "claim_limit_exceeded": false, + "input_truncated": false, + "assessments": { + "completed": 2, + "failed": 0 + }, + "verifications": { + "planned": 1, + "completed": 0, + "failed": 1 + }, + "issues": 1, + "citations_found": null, + "citations_selected": null, + "citation_limit": null, + "citation_limit_exceeded": null, + "citation_checks": null, + "citation_issues": 0, + "citations_skipped": null + }, + "credits": { + "charged": 2 + }, + "poll_after_seconds": null, + "issues": [ + { + "claim_index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "verified_claim": null, + "verdict": "False", + "confidence": "high", + "source": "assessment", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "url": null, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "key_finding": null, + "rationale": null, + "suggested_rewrite": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "suggested_edits": null + } + ], + "failures": [], + "citation_issues": [], + "citation_failures": [], + "claims": [ + { + "index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "positions": null, + "result": { + "verdict": "False", + "confidence": "high", + "source": "assessment", + "is_issue": true + }, + "assessment": { + "status": "completed", + "verdict": "False", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "verification": { + "verification_id": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "claim": null, + "language": null, + "visibility": null, + "depth": null, + "domain": null, + "entities": [], + "verdict": null, + "confidence": null, + "lenz_score": null, + "key_finding": null, + "executive_summary": null, + "suggested_rewrite": null, + "warnings": [], + "created_at": null, + "completed_at": null, + "verification_url": null, + "url": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "status": "failed", + "content_status": "available" + }, + "suggested_edits": null + }, + { + "index": 1, + "claim": "Paris is the capital of France.", + "positions": null, + "result": { + "verdict": "True", + "confidence": "high", + "source": "assessment", + "is_issue": false + }, + "assessment": { + "status": "completed", + "verdict": "True", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [], + "disposition": "not_selected" + }, + "verification": null, + "suggested_edits": null + } + ], + "citations": [], + "failure": null, + "more_claims": [], + "more_claim_locations": [], + "more_citations": [] + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/canonical/verify__status_cancelled_durable.json b/tests/fixtures/parity/canonical/verify__status_cancelled_durable.json new file mode 100644 index 0000000..2699174 --- /dev/null +++ b/tests/fixtures/parity/canonical/verify__status_cancelled_durable.json @@ -0,0 +1,12 @@ +{ + "status": 200, + "body": { + "status": "cancelled", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/canonical/verify__status_cancelled_live.json b/tests/fixtures/parity/canonical/verify__status_cancelled_live.json new file mode 100644 index 0000000..2699174 --- /dev/null +++ b/tests/fixtures/parity/canonical/verify__status_cancelled_live.json @@ -0,0 +1,12 @@ +{ + "status": 200, + "body": { + "status": "cancelled", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/canonical/verify__status_needs_input_hint_names.json b/tests/fixtures/parity/canonical/verify__status_needs_input_hint_names.json new file mode 100644 index 0000000..ee572f4 --- /dev/null +++ b/tests/fixtures/parity/canonical/verify__status_needs_input_hint_names.json @@ -0,0 +1,28 @@ +{ + "status": 200, + "body": { + "status": "needs_input", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "reason": "multi_claim", + "claims": [ + { + "claim": "A.", + "domain": "" + }, + { + "claim": "A happened.", + "domain": "" + }, + { + "claim": "B happened.", + "domain": "" + } + ], + "hint": "The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select." + }, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/canonical/verify__status_older_run_completed.json b/tests/fixtures/parity/canonical/verify__stored_progress_completed.json similarity index 100% rename from tests/fixtures/parity/canonical/verify__status_older_run_completed.json rename to tests/fixtures/parity/canonical/verify__stored_progress_completed.json diff --git a/tests/fixtures/parity/canonical/verify__status_older_run_failed_crashed.json b/tests/fixtures/parity/canonical/verify__stored_progress_failed_crashed.json similarity index 100% rename from tests/fixtures/parity/canonical/verify__status_older_run_failed_crashed.json rename to tests/fixtures/parity/canonical/verify__stored_progress_failed_crashed.json diff --git a/tests/fixtures/parity/canonical/verify__status_older_run_failed_insufficient_evidence.json b/tests/fixtures/parity/canonical/verify__stored_progress_failed_insufficient_evidence.json similarity index 100% rename from tests/fixtures/parity/canonical/verify__status_older_run_failed_insufficient_evidence.json rename to tests/fixtures/parity/canonical/verify__stored_progress_failed_insufficient_evidence.json diff --git a/tests/fixtures/parity/canonical/verify__status_older_run_in_progress.json b/tests/fixtures/parity/canonical/verify__stored_progress_in_progress.json similarity index 100% rename from tests/fixtures/parity/canonical/verify__status_older_run_in_progress.json rename to tests/fixtures/parity/canonical/verify__stored_progress_in_progress.json diff --git a/tests/fixtures/parity/canonical/verify__verification_not_ready_409_needs_input.json b/tests/fixtures/parity/canonical/verify__verification_not_ready_409_needs_input.json new file mode 100644 index 0000000..efbdf4f --- /dev/null +++ b/tests/fixtures/parity/canonical/verify__verification_not_ready_409_needs_input.json @@ -0,0 +1,15 @@ +{ + "status": 409, + "body": { + "detail": "This check is waiting for your input.", + "code": "verification_not_ready", + "status": "needs_input", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "hint": "GET /verify/status/7cff18da2d979d6370e54a160be121ad lists the options and how to resolve them." + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/canonical/verify__verification_purged_410.json b/tests/fixtures/parity/canonical/verify__verification_purged_410.json new file mode 100644 index 0000000..924d468 --- /dev/null +++ b/tests/fixtures/parity/canonical/verify__verification_purged_410.json @@ -0,0 +1,13 @@ +{ + "status": 410, + "body": { + "detail": "This verification is no longer available: its account removes verifications after a set period.", + "code": "purged", + "purged_at": "2026-10-01T09:07:00.123456+00:00" + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/canonical/webhook__citecheck_cancelled.json b/tests/fixtures/parity/canonical/webhook__citecheck_cancelled.json new file mode 100644 index 0000000..78dc010 --- /dev/null +++ b/tests/fixtures/parity/canonical/webhook__citecheck_cancelled.json @@ -0,0 +1,150 @@ +{ + "status": 200, + "body": { + "event": "citecheck.cancelled", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "citecheck_id": "8eb49302", + "status": "cancelled", + "citecheck": { + "citecheck_id": "8eb49302", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "poll_after_seconds": null, + "policy": { + "max_citations": 2 + }, + "summary": { + "citations_found": 2, + "citations_selected": 2, + "citation_limit": 2, + "citation_limit_exceeded": false, + "citation_checks": { + "checked": 0, + "unchecked": 0, + "failed": 2 + }, + "citation_issues": 0 + }, + "credits": { + "charged": 0 + }, + "citations": [ + { + "index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "statement": "Unemployment fell to 4.1% in 2024.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + }, + { + "index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "statement": "The agency said so in a statement.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "missing_quote": null + } + } + ], + "citation_issues": [], + "citation_failures": [ + { + "citation_index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + }, + { + "citation_index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": "This source could not be checked this time. Try again later.", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + } + } + ], + "failure": null, + "more_citations": [] + }, + "attempt": 1, + "delivered_at": "2026-10-01T11:07:00.123456+00:00" + }, + "headers": {} +} diff --git a/tests/fixtures/parity/canonical/webhook__delivery_retry_restamps.json b/tests/fixtures/parity/canonical/webhook__delivery_retry_restamps.json new file mode 100644 index 0000000..14a10f3 --- /dev/null +++ b/tests/fixtures/parity/canonical/webhook__delivery_retry_restamps.json @@ -0,0 +1,24 @@ +{ + "status": 200, + "body": { + "attempt": 2, + "delivered_at": "2026-10-01T09:07:00.123456+00:00", + "event": "verification.failed", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "verification": { + "failure": { + "code": "conclusion_failed", + "detail": "The check stopped while writing the conclusion.", + "docs_url": "https://lenz.io/docs/errors#internal", + "failure_class": "internal", + "hint": null, + "retryable": false + }, + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad" + } + }, + "headers": {} +} diff --git a/tests/fixtures/parity/canonical/webhook__review_cancelled.json b/tests/fixtures/parity/canonical/webhook__review_cancelled.json new file mode 100644 index 0000000..4a440a8 --- /dev/null +++ b/tests/fixtures/parity/canonical/webhook__review_cancelled.json @@ -0,0 +1,166 @@ +{ + "status": 200, + "body": { + "event": "review.cancelled", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "review_id": "d6b2bd72", + "status": "cancelled", + "review": { + "review_id": "d6b2bd72", + "view": "full", + "status": "cancelled", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "policy": { + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ], + "confidence": [ + "low" + ], + "max_assessments": 20, + "max_verifications": 5, + "depth": "standard", + "max_citations": 0, + "suggest_edits": false + }, + "summary": { + "claims_found": 1, + "claims_selected": 1, + "claim_limit": 20, + "claim_limit_exceeded": false, + "input_truncated": false, + "assessments": { + "completed": 1, + "failed": 0 + }, + "verifications": { + "planned": 1, + "completed": 0, + "failed": 1 + }, + "issues": 1, + "citations_found": null, + "citations_selected": null, + "citation_limit": null, + "citation_limit_exceeded": null, + "citation_checks": null, + "citation_issues": 0, + "citations_skipped": null + }, + "credits": { + "charged": 1 + }, + "poll_after_seconds": null, + "issues": [ + { + "claim_index": 0, + "claim": "The Eiffel Tower is in Berlin.", + "verified_claim": null, + "verdict": "False", + "confidence": "high", + "source": "assessment", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "url": null, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "key_finding": null, + "rationale": null, + "suggested_rewrite": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "suggested_edits": null + } + ], + "failures": [], + "citation_issues": [], + "citation_failures": [], + "claims": [ + { + "index": 0, + "claim": "The Eiffel Tower is in Berlin.", + "positions": null, + "result": { + "verdict": "False", + "confidence": "high", + "source": "assessment", + "is_issue": true + }, + "assessment": { + "status": "completed", + "verdict": "False", + "confidence": "high", + "verification_url": null, + "rationale": null, + "dissent": null, + "suggested_rewrite": null, + "more_claims": [], + "failure": null + }, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "verification": { + "verification_id": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "claim": null, + "language": null, + "visibility": null, + "depth": null, + "domain": null, + "entities": [], + "verdict": null, + "confidence": null, + "lenz_score": null, + "key_finding": null, + "executive_summary": null, + "suggested_rewrite": null, + "warnings": [], + "created_at": null, + "completed_at": null, + "verification_url": null, + "url": null, + "failure": { + "code": "cancelled", + "detail": "The check was cancelled.", + "hint": null, + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "status": "failed", + "content_status": "available" + }, + "suggested_edits": null + } + ], + "citations": [], + "failure": null, + "more_claims": [], + "more_claim_locations": [], + "more_citations": [] + }, + "attempt": 1, + "delivered_at": "2026-10-01T11:07:00.123456+00:00" + }, + "headers": {} +} diff --git a/tests/fixtures/parity/canonical/webhook__verification_cancelled.json b/tests/fixtures/parity/canonical/webhook__verification_cancelled.json new file mode 100644 index 0000000..89f5704 --- /dev/null +++ b/tests/fixtures/parity/canonical/webhook__verification_cancelled.json @@ -0,0 +1,16 @@ +{ + "status": 200, + "body": { + "event": "verification.cancelled", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "status": "cancelled", + "verification": { + "status": "cancelled", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "attempt": 1, + "delivered_at": "2026-10-01T11:07:00.123456+00:00" + }, + "headers": {} +} diff --git a/tests/fixtures/parity/expected/account__oauth_revoke_token.json b/tests/fixtures/parity/expected/account__ask_send_idempotent_replay.json similarity index 100% rename from tests/fixtures/parity/expected/account__oauth_revoke_token.json rename to tests/fixtures/parity/expected/account__ask_send_idempotent_replay.json diff --git a/tests/fixtures/parity/expected/account__oauth_revoke_live_token.json b/tests/fixtures/parity/expected/account__oauth_revoke_live_token.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/parity/expected/account__oauth_revoke_live_token.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/parity/expected/assess__replay_later_200.json b/tests/fixtures/parity/expected/assess__stored_replay_200.json similarity index 100% rename from tests/fixtures/parity/expected/assess__replay_later_200.json rename to tests/fixtures/parity/expected/assess__stored_replay_200.json diff --git a/tests/fixtures/parity/expected/citecheck__get_cancelled.json b/tests/fixtures/parity/expected/citecheck__get_cancelled.json new file mode 100644 index 0000000..095ba90 --- /dev/null +++ b/tests/fixtures/parity/expected/citecheck__get_cancelled.json @@ -0,0 +1,286 @@ +{ + "dump": { + "citation_failures": [ + { + "citation_index": 0, + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "reference": "https://example.gov/report-2024" + }, + { + "citation_index": 1, + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "reference": "https://example.org/statement" + } + ], + "citation_issues": [], + "citations": [ + { + "check": { + "doi_registered": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "hint": "This source could not be checked this time. Try again later.", + "metadata": null, + "metadata_differences": [], + "missing_quote": null, + "page_language": null, + "page_published_date": null, + "page_read": null, + "page_title": null, + "quote": null, + "rationale": null, + "registered": null, + "snippet": null, + "source_url": null, + "source_version": null, + "status": "failed", + "support": null, + "unchecked_reason": null + }, + "cited_url": "https://example.gov/report-2024", + "doi": null, + "index": 0, + "position": null, + "quotes": [], + "reference": "https://example.gov/report-2024", + "result": null, + "statement": "Unemployment fell to 4.1% in 2024." + }, + { + "check": { + "doi_registered": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "hint": "This source could not be checked this time. Try again later.", + "metadata": null, + "metadata_differences": [], + "missing_quote": null, + "page_language": null, + "page_published_date": null, + "page_read": null, + "page_title": null, + "quote": null, + "rationale": null, + "registered": null, + "snippet": null, + "source_url": null, + "source_version": null, + "status": "failed", + "support": null, + "unchecked_reason": null + }, + "cited_url": "https://example.org/statement", + "doi": null, + "index": 1, + "position": null, + "quotes": [], + "reference": "https://example.org/statement", + "result": null, + "statement": "The agency said so in a statement." + } + ], + "citecheck_id": "8eb49302", + "completed_at": "2026-10-01T10:07:00.123456Z", + "created_at": "2026-10-01T09:07:00.123456Z", + "credits": { + "charged": 0 + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "language": "en", + "more_citations": [], + "outcome": "incomplete", + "policy": { + "max_citations": 2 + }, + "poll_after_seconds": null, + "status": "failed", + "summary": { + "citation_checks": { + "checked": 0, + "failed": 2, + "unchecked": 0 + }, + "citation_issues": 0, + "citation_limit": 2, + "citation_limit_reached": false, + "citations_found": 2, + "citations_selected": 2 + } + }, + "dump_unset": { + "citation_failures": [ + { + "citation_index": 0, + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "reference": "https://example.gov/report-2024" + }, + { + "citation_index": 1, + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "reference": "https://example.org/statement" + } + ], + "citation_issues": [], + "citations": [ + { + "check": { + "doi_registered": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "hint": "This source could not be checked this time. Try again later.", + "metadata": null, + "metadata_differences": [], + "missing_quote": null, + "page_language": null, + "page_published_date": null, + "page_read": null, + "page_title": null, + "quote": null, + "rationale": null, + "registered": null, + "snippet": null, + "source_url": null, + "source_version": null, + "status": "failed", + "support": null, + "unchecked_reason": null + }, + "cited_url": "https://example.gov/report-2024", + "doi": null, + "index": 0, + "position": null, + "quotes": [], + "reference": "https://example.gov/report-2024", + "result": null, + "statement": "Unemployment fell to 4.1% in 2024." + }, + { + "check": { + "doi_registered": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "hint": "This source could not be checked this time. Try again later.", + "metadata": null, + "metadata_differences": [], + "missing_quote": null, + "page_language": null, + "page_published_date": null, + "page_read": null, + "page_title": null, + "quote": null, + "rationale": null, + "registered": null, + "snippet": null, + "source_url": null, + "source_version": null, + "status": "failed", + "support": null, + "unchecked_reason": null + }, + "cited_url": "https://example.org/statement", + "doi": null, + "index": 1, + "position": null, + "quotes": [], + "reference": "https://example.org/statement", + "result": null, + "statement": "The agency said so in a statement." + } + ], + "citecheck_id": "8eb49302", + "completed_at": "2026-10-01T10:07:00.123456Z", + "created_at": "2026-10-01T09:07:00.123456Z", + "credits": { + "charged": 0 + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "language": "en", + "more_citations": [], + "outcome": "incomplete", + "policy": { + "max_citations": 2 + }, + "poll_after_seconds": null, + "status": "failed", + "summary": { + "citation_checks": { + "checked": 0, + "failed": 2, + "unchecked": 0 + }, + "citation_issues": 0, + "citation_limit": 2, + "citation_limit_reached": false, + "citations_found": 2, + "citations_selected": 2 + } + }, + "exit_code": 2, + "render": "Citation check 8eb49302: incomplete — 0 credits charged\nFailed: cancelled\n\n2 sources cited in your draft\n2 could not be checked.\n\n[source 1/2] Could not be checked this time.\n https://example.gov/report-2024\n This source could not be checked this time. Try again later.\n\n[source 2/2] Could not be checked this time.\n https://example.org/statement\n This source could not be checked this time. Try again later.\n", + "render_json": null, + "repr": "Citecheck(citecheck_id='8eb49302', status='failed', outcome='incomplete', created_at='2026-10-01T09:07:00.123456Z', completed_at='2026-10-01T10:07:00.123456Z', poll_after_seconds=None, policy=CitecheckPolicy(max_citations=2), summary=CitecheckSummary(citations_found=2, citations_selected=2, citation_limit=2, citation_limit_reached=False, citation_checks=ReviewCitationCheckCounts(checked=0, unchecked=0, failed=2), citation_issues=0), credits=ReviewCredits(charged=0), citations=[ReviewCitation(index=0, reference='https://example.gov/report-2024', cited_url='https://example.gov/report-2024', doi=None, statement='Unemployment fell to 4.1% in 2024.', quotes=[], position=None, result=None, check=ReviewCitationCheck(status='failed', page_read=None, page_title=None, page_published_date=None, page_language=None, source_url=None, source_version=None, support=None, snippet=None, rationale=None, quote=None, missing_quote=None, doi_registered=None, metadata=None, metadata_differences=[], registered=None, unchecked_reason=None, hint='This source could not be checked this time. Try again later.', failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint='This source could not be checked this time. Try again later.', docs_url='https://lenz.io/docs/errors#cancelled'))), ReviewCitation(index=1, reference='https://example.org/statement', cited_url='https://example.org/statement', doi=None, statement='The agency said so in a statement.', quotes=[], position=None, result=None, check=ReviewCitationCheck(status='failed', page_read=None, page_title=None, page_published_date=None, page_language=None, source_url=None, source_version=None, support=None, snippet=None, rationale=None, quote=None, missing_quote=None, doi_registered=None, metadata=None, metadata_differences=[], registered=None, unchecked_reason=None, hint='This source could not be checked this time. Try again later.', failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint='This source could not be checked this time. Try again later.', docs_url='https://lenz.io/docs/errors#cancelled')))], citation_issues=[], citation_failures=[ReviewCitationFailure(citation_index=0, reference='https://example.gov/report-2024', cited_url='https://example.gov/report-2024', doi=None, failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint='This source could not be checked this time. Try again later.', docs_url='https://lenz.io/docs/errors#cancelled')), ReviewCitationFailure(citation_index=1, reference='https://example.org/statement', cited_url='https://example.org/statement', doi=None, failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint='This source could not be checked this time. Try again later.', docs_url='https://lenz.io/docs/errors#cancelled'))], more_citations=[], failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint=None, docs_url='https://lenz.io/docs/errors#cancelled'), language='en')" +} diff --git a/tests/fixtures/parity/expected/extract__replay_later_200.json b/tests/fixtures/parity/expected/extract__stored_replay_200.json similarity index 100% rename from tests/fixtures/parity/expected/extract__replay_later_200.json rename to tests/fixtures/parity/expected/extract__stored_replay_200.json diff --git a/tests/fixtures/parity/expected/review__get_cancelled.json b/tests/fixtures/parity/expected/review__get_cancelled.json new file mode 100644 index 0000000..4694bbd --- /dev/null +++ b/tests/fixtures/parity/expected/review__get_cancelled.json @@ -0,0 +1,385 @@ +{ + "dump": { + "citation_failures": [], + "citation_issues": [], + "citations": [], + "claims": [ + { + "assessment": { + "confidence": "high", + "dissent": null, + "error_code": null, + "failure": null, + "hint": null, + "identified_claims": [], + "rationale": null, + "status": "completed", + "suggested_rewrite": null, + "verdict": "False", + "verification_url": null + }, + "claim": "The EU AI Act took effect in March 2024.", + "escalation": { + "disposition": "planned", + "matched_rules": [ + "verdict" + ] + }, + "index": 0, + "positions": null, + "result": { + "confidence": "high", + "is_issue": true, + "source": "assessment", + "verdict": "False" + }, + "suggested_edits": null, + "verification": { + "claim": null, + "confidence": null, + "content_status": "available", + "created_at": null, + "depth": null, + "domain": null, + "entities": [], + "executive_summary": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "key_finding": null, + "language": null, + "lenz_score": null, + "modified_at": null, + "status": "failed", + "suggested_rewrite": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "url": null, + "verdict": null, + "verification_id": null, + "verification_url": null, + "visibility": null, + "warnings": [] + } + }, + { + "assessment": { + "confidence": "high", + "dissent": null, + "error_code": null, + "failure": null, + "hint": null, + "identified_claims": [], + "rationale": null, + "status": "completed", + "suggested_rewrite": null, + "verdict": "True", + "verification_url": null + }, + "claim": "Paris is the capital of France.", + "escalation": { + "disposition": "not_selected", + "matched_rules": [] + }, + "index": 1, + "positions": null, + "result": { + "confidence": "high", + "is_issue": false, + "source": "assessment", + "verdict": "True" + }, + "suggested_edits": null, + "verification": null + } + ], + "completed_at": "2026-10-01T10:07:00.123456Z", + "created_at": "2026-10-01T09:07:00.123456Z", + "credits": { + "charged": 2 + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "failures": [], + "issues": [ + { + "claim": "The EU AI Act took effect in March 2024.", + "claim_index": 0, + "confidence": "high", + "escalation": { + "disposition": "planned", + "matched_rules": [ + "verdict" + ] + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "key_finding": null, + "rationale": null, + "source": "assessment", + "suggested_edits": null, + "suggested_rewrite": null, + "url": null, + "verdict": "False", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "verified_claim": null + } + ], + "language": "en", + "more_citations": [], + "more_claim_locations": [], + "more_claims": [], + "outcome": "incomplete", + "policy": { + "confidence": [ + "low" + ], + "depth": "standard", + "max_assessments": 20, + "max_citations": 0, + "max_verifications": 5, + "suggest_edits": false, + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ] + }, + "poll_after_seconds": null, + "review_id": "d6b2bd72", + "status": "failed", + "summary": { + "assessments": { + "completed": 2, + "failed": 0 + }, + "citation_checks": null, + "citation_issues": 0, + "citation_limit": null, + "citation_limit_reached": null, + "citations_found": null, + "citations_selected": null, + "citations_skipped": null, + "claim_limit": 20, + "claim_limit_reached": false, + "claims_selected": 2, + "input_truncated": false, + "issues": 1, + "verifications": { + "completed": 0, + "failed": 1, + "planned": 1 + } + }, + "view": "full" + }, + "dump_unset": { + "citation_failures": [], + "citation_issues": [], + "citations": [], + "claims": [ + { + "assessment": { + "confidence": "high", + "dissent": null, + "error_code": null, + "failure": null, + "hint": null, + "identified_claims": [], + "rationale": null, + "status": "completed", + "suggested_rewrite": null, + "verdict": "False", + "verification_url": null + }, + "claim": "The EU AI Act took effect in March 2024.", + "escalation": { + "disposition": "planned", + "matched_rules": [ + "verdict" + ] + }, + "index": 0, + "positions": null, + "result": { + "confidence": "high", + "is_issue": true, + "source": "assessment", + "verdict": "False" + }, + "suggested_edits": null, + "verification": { + "claim": null, + "confidence": null, + "content_status": "available", + "created_at": null, + "depth": null, + "domain": null, + "entities": [], + "executive_summary": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "key_finding": null, + "language": null, + "lenz_score": null, + "modified_at": null, + "status": "failed", + "suggested_rewrite": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "url": null, + "verdict": null, + "verification_id": null, + "verification_url": null, + "visibility": null, + "warnings": [] + } + }, + { + "assessment": { + "confidence": "high", + "dissent": null, + "error_code": null, + "failure": null, + "hint": null, + "identified_claims": [], + "rationale": null, + "status": "completed", + "suggested_rewrite": null, + "verdict": "True", + "verification_url": null + }, + "claim": "Paris is the capital of France.", + "escalation": { + "disposition": "not_selected", + "matched_rules": [] + }, + "index": 1, + "positions": null, + "result": { + "confidence": "high", + "is_issue": false, + "source": "assessment", + "verdict": "True" + }, + "suggested_edits": null, + "verification": null + } + ], + "completed_at": "2026-10-01T10:07:00.123456Z", + "created_at": "2026-10-01T09:07:00.123456Z", + "credits": { + "charged": 2 + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "failures": [], + "issues": [ + { + "claim": "The EU AI Act took effect in March 2024.", + "claim_index": 0, + "confidence": "high", + "escalation": { + "disposition": "planned", + "matched_rules": [ + "verdict" + ] + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "key_finding": null, + "rationale": null, + "source": "assessment", + "suggested_edits": null, + "suggested_rewrite": null, + "url": null, + "verdict": "False", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "verified_claim": null + } + ], + "language": "en", + "more_citations": [], + "more_claim_locations": [], + "more_claims": [], + "outcome": "incomplete", + "policy": { + "confidence": [ + "low" + ], + "depth": "standard", + "max_assessments": 20, + "max_citations": 0, + "max_verifications": 5, + "suggest_edits": false, + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ] + }, + "poll_after_seconds": null, + "review_id": "d6b2bd72", + "status": "failed", + "summary": { + "assessments": { + "completed": 2, + "failed": 0 + }, + "citation_checks": null, + "citation_issues": 0, + "citation_limit": null, + "citation_limit_reached": null, + "citations_found": null, + "citations_selected": null, + "citations_skipped": null, + "claim_limit": 20, + "claim_limit_reached": false, + "claims_selected": 2, + "input_truncated": false, + "issues": 1, + "verifications": { + "completed": 0, + "failed": 1, + "planned": 1 + } + }, + "view": "full" + }, + "exit_code": 2, + "render": "Review d6b2bd72: incomplete — 2 claims: 1 issue, 1 true or mostly true · 0 of 1 deep-checked · 2 credits charged\nFailed: cancelled\n\n[1/2] The EU AI Act took effect in March 2024.\n False (high) · quick check\n Deep check failed: The deep check failed.\n\n[2/2] Paris is the capital of France.\n True (high) · quick check\n", + "render_issues": "Review d6b2bd72: incomplete — 2 claims: 1 issue, 1 true or mostly true · 0 of 1 deep-checked · 2 credits charged\nFailed: cancelled\n\n[1/2] The EU AI Act took effect in March 2024.\n False (high) · quick check\n Deep check failed: cancelled\n", + "render_json": null, + "repr": "ReviewFull(review_id='d6b2bd72', view='full', status='failed', outcome='incomplete', created_at='2026-10-01T09:07:00.123456Z', completed_at='2026-10-01T10:07:00.123456Z', language='en', policy=EscalationPolicy(verdicts=['False', 'Mostly False', 'Mixed'], confidence=['low'], max_assessments=20, max_verifications=5, depth='standard', max_citations=0, suggest_edits=False), summary=ReviewSummary(claims_selected=2, claim_limit=20, claim_limit_reached=False, input_truncated=False, assessments=ReviewAssessmentCounts(completed=2, failed=0), verifications=ReviewVerificationCounts(planned=1, completed=0, failed=1), issues=1, citations_found=None, citations_selected=None, citation_limit=None, citation_limit_reached=None, citation_checks=None, citation_issues=0, citations_skipped=None), credits=ReviewCredits(charged=2), poll_after_seconds=None, issues=[ReviewIssue(claim_index=0, claim='The EU AI Act took effect in March 2024.', verified_claim=None, verdict='False', confidence='high', source='assessment', verification_id=None, verification_status='failed', verification_url=None, url=None, escalation=Escalation(matched_rules=['verdict'], disposition='planned'), key_finding=None, rationale=None, suggested_rewrite=None, failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint=None, docs_url='https://lenz.io/docs/errors#cancelled'), suggested_edits=None)], failures=[], citation_issues=[], citation_failures=[], more_claims=[], more_claim_locations=[], more_citations=[], failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint=None, docs_url='https://lenz.io/docs/errors#cancelled'), claims=[ReviewClaim(index=0, claim='The EU AI Act took effect in March 2024.', positions=None, result=ReviewResult(verdict='False', confidence='high', source='assessment', is_issue=True), assessment=ReviewAssessment(status='completed', verdict='False', confidence='high', rationale=None, dissent=None, verification_url=None, error_code=None, identified_claims=[], hint=None, suggested_rewrite=None, failure=None), escalation=Escalation(matched_rules=['verdict'], disposition='planned'), verification=ReviewVerification(status='failed', content_status='available', verification_id=None, task_id='7cff18da2d979d6370e54a160be121ad', claim=None, language=None, visibility=None, depth=None, domain=None, entities=[], verdict=None, confidence=None, lenz_score=None, key_finding=None, executive_summary=None, suggested_rewrite=None, warnings=[], created_at=None, modified_at=None, verification_url=None, url=None, failure=FailureBlock(failure_reason='cancelled', failure_class='cancelled', retryable=False, hint=None, docs_url='https://lenz.io/docs/errors#cancelled')), suggested_edits=None), ReviewClaim(index=1, claim='Paris is the capital of France.', positions=None, result=ReviewResult(verdict='True', confidence='high', source='assessment', is_issue=False), assessment=ReviewAssessment(status='completed', verdict='True', confidence='high', rationale=None, dissent=None, verification_url=None, error_code=None, identified_claims=[], hint=None, suggested_rewrite=None, failure=None), escalation=Escalation(matched_rules=[], disposition='not_selected'), verification=None, suggested_edits=None)], citations=[])" +} diff --git a/tests/fixtures/parity/expected/review__replay_later_202.json b/tests/fixtures/parity/expected/review__stored_replay_202.json similarity index 100% rename from tests/fixtures/parity/expected/review__replay_later_202.json rename to tests/fixtures/parity/expected/review__stored_replay_202.json diff --git a/tests/fixtures/parity/expected/verify__status_cancelled_after_a_while.json b/tests/fixtures/parity/expected/verify__status_cancelled_durable.json similarity index 100% rename from tests/fixtures/parity/expected/verify__status_cancelled_after_a_while.json rename to tests/fixtures/parity/expected/verify__status_cancelled_durable.json diff --git a/tests/fixtures/parity/expected/verify__status_cancelled_live.json b/tests/fixtures/parity/expected/verify__status_cancelled_live.json new file mode 100644 index 0000000..b957ea0 --- /dev/null +++ b/tests/fixtures/parity/expected/verify__status_cancelled_live.json @@ -0,0 +1,61 @@ +{ + "dump": { + "candidates": [], + "claims": [], + "docs_url": "https://lenz.io/docs/errors#cancelled", + "error": "Pipeline stopped at: cancelled", + "failure_class": "cancelled", + "failure_detail": "", + "failure_reason": "cancelled", + "hint": "", + "progress": { + "elapsed_seconds": null, + "index": null, + "poll_after_seconds": null, + "step": "", + "total": null + }, + "reason": "", + "result": null, + "retryable": false, + "similar_claims": [], + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "dump_unset": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "error": "Pipeline stopped at: cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "retryable": false, + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "render": "failed — Pipeline stopped at: cancelled\n", + "render_json": "{\n \"status\": \"failed\",\n \"task_id\": \"7cff18da2d979d6370e54a160be121ad\",\n \"reason\": \"\",\n \"hint\": \"\",\n \"progress\": {\n \"step\": \"\",\n \"index\": null,\n \"total\": null,\n \"elapsed_seconds\": null,\n \"poll_after_seconds\": null\n },\n \"result\": null,\n \"claims\": [],\n \"candidates\": [],\n \"similar_claims\": [],\n \"error\": \"Pipeline stopped at: cancelled\",\n \"failure_reason\": \"cancelled\",\n \"failure_detail\": \"\",\n \"failure_class\": \"cancelled\",\n \"retryable\": false,\n \"docs_url\": \"https://lenz.io/docs/errors#cancelled\"\n}\n", + "repr": "TaskStatus(status='failed', task_id='7cff18da2d979d6370e54a160be121ad', reason='', hint='', progress=Progress(step='', index=None, total=None, elapsed_seconds=None, poll_after_seconds=None), result=None, claims=[], candidates=[], similar_claims=[], error='Pipeline stopped at: cancelled', failure_reason='cancelled', failure_detail='', failure_class='cancelled', retryable=False, docs_url='https://lenz.io/docs/errors#cancelled')", + "wait": { + "cause": "Pipeline stopped at: cancelled", + "code": "", + "doc_url": "https://lenz.io/docs/errors", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "fix": "Retry with a different claim, or check status.error for the diagnostic.", + "friendly_text": "Pipeline failed: Pipeline stopped at: cancelled\n Fix: Retry with a different claim, or check status.error for the diagnostic.", + "hint": "", + "message": "Pipeline failed: Pipeline stopped at: cancelled", + "payload_json": { + "error": { + "code": "pipeline_failed", + "fix": "Retry with a different claim, or check status.error for the diagnostic.", + "message": "Pipeline failed: Pipeline stopped at: cancelled", + "status": 0 + } + }, + "request_id": "", + "retryable": false, + "status_code": 0, + "task_id": "t", + "type": "LenzPipelineError" + } +} diff --git a/tests/fixtures/parity/expected/verify__status_needs_input_hint_names.json b/tests/fixtures/parity/expected/verify__status_needs_input_hint_names.json new file mode 100644 index 0000000..713def0 --- /dev/null +++ b/tests/fixtures/parity/expected/verify__status_needs_input_hint_names.json @@ -0,0 +1,119 @@ +{ + "dump": { + "candidates": [], + "claims": [ + { + "domain": "", + "text": "A." + }, + { + "domain": "", + "text": "A happened." + }, + { + "domain": "", + "text": "B happened." + } + ], + "docs_url": "", + "error": "", + "failure_class": "", + "failure_detail": "", + "failure_reason": "", + "hint": "The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select.", + "progress": { + "elapsed_seconds": null, + "index": null, + "poll_after_seconds": null, + "step": "", + "total": null + }, + "reason": "multi_claim", + "result": null, + "retryable": null, + "similar_claims": [], + "status": "needs_input", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "dump_unset": { + "claims": [ + { + "domain": "", + "text": "A." + }, + { + "domain": "", + "text": "A happened." + }, + { + "domain": "", + "text": "B happened." + } + ], + "hint": "The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select.", + "reason": "multi_claim", + "status": "needs_input", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "render": "needs input (multi_claim)\nclaims found:\n 1. A.\n 2. A happened.\n 3. B happened.\nresolve it: lenz verify --resume t --claim --detach\n", + "render_json": "{\n \"status\": \"needs_input\",\n \"task_id\": \"7cff18da2d979d6370e54a160be121ad\",\n \"reason\": \"multi_claim\",\n \"hint\": \"The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select.\",\n \"progress\": {\n \"step\": \"\",\n \"index\": null,\n \"total\": null,\n \"elapsed_seconds\": null,\n \"poll_after_seconds\": null\n },\n \"result\": null,\n \"claims\": [\n {\n \"text\": \"A.\",\n \"domain\": \"\"\n },\n {\n \"text\": \"A happened.\",\n \"domain\": \"\"\n },\n {\n \"text\": \"B happened.\",\n \"domain\": \"\"\n }\n ],\n \"candidates\": [],\n \"similar_claims\": [],\n \"error\": \"\",\n \"failure_reason\": \"\",\n \"failure_detail\": \"\",\n \"failure_class\": \"\",\n \"retryable\": null,\n \"docs_url\": \"\"\n}\n", + "repr": "TaskStatus(status='needs_input', task_id='7cff18da2d979d6370e54a160be121ad', reason='multi_claim', hint='The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select.', progress=Progress(step='', index=None, total=None, elapsed_seconds=None, poll_after_seconds=None), result=None, claims=[CandidateClaim(text='A.', domain=''), CandidateClaim(text='A happened.', domain=''), CandidateClaim(text='B happened.', domain='')], candidates=[], similar_claims=[], error='', failure_reason='', failure_detail='', failure_class='', retryable=None, docs_url='')", + "wait": { + "cause": "The verification needs caller input to proceed.", + "code": "", + "doc_url": "https://lenz.io/docs/verify#needs-input", + "fix": "Inspect the payload, then call client.select(task_id, texts=[...]) with the chosen claim(s).", + "friendly_text": "Pipeline paused: multi_claim\n Fix: Inspect the payload, then call client.select(task_id, texts=[...]) with the chosen claim(s).", + "hint": "The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select.", + "kind": "multi_claim", + "message": "Pipeline paused: multi_claim", + "payload": { + "candidates": [], + "claims": [ + { + "domain": "", + "text": "A." + }, + { + "domain": "", + "text": "A happened." + }, + { + "domain": "", + "text": "B happened." + } + ], + "docs_url": "", + "error": "", + "failure_class": "", + "failure_detail": "", + "failure_reason": "", + "hint": "The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select.", + "progress": { + "elapsed_seconds": null, + "index": null, + "poll_after_seconds": null, + "step": "", + "total": null + }, + "reason": "multi_claim", + "result": null, + "retryable": null, + "similar_claims": [], + "status": "needs_input", + "task_id": "7cff18da2d979d6370e54a160be121ad" + }, + "payload_json": { + "error": { + "code": "needs_input", + "fix": "Inspect the payload, then call client.select(task_id, texts=[...]) with the chosen claim(s).", + "message": "Pipeline paused: multi_claim", + "status": 0 + } + }, + "request_id": "", + "status_code": 0, + "task_id": "t", + "type": "LenzNeedsInputError" + } +} diff --git a/tests/fixtures/parity/expected/verify__status_older_run_completed.json b/tests/fixtures/parity/expected/verify__stored_progress_completed.json similarity index 100% rename from tests/fixtures/parity/expected/verify__status_older_run_completed.json rename to tests/fixtures/parity/expected/verify__stored_progress_completed.json diff --git a/tests/fixtures/parity/expected/verify__status_older_run_failed_crashed.json b/tests/fixtures/parity/expected/verify__stored_progress_failed_crashed.json similarity index 100% rename from tests/fixtures/parity/expected/verify__status_older_run_failed_crashed.json rename to tests/fixtures/parity/expected/verify__stored_progress_failed_crashed.json diff --git a/tests/fixtures/parity/expected/verify__status_older_run_failed_insufficient_evidence.json b/tests/fixtures/parity/expected/verify__stored_progress_failed_insufficient_evidence.json similarity index 100% rename from tests/fixtures/parity/expected/verify__status_older_run_failed_insufficient_evidence.json rename to tests/fixtures/parity/expected/verify__stored_progress_failed_insufficient_evidence.json diff --git a/tests/fixtures/parity/expected/verify__status_older_run_in_progress.json b/tests/fixtures/parity/expected/verify__stored_progress_in_progress.json similarity index 100% rename from tests/fixtures/parity/expected/verify__status_older_run_in_progress.json rename to tests/fixtures/parity/expected/verify__stored_progress_in_progress.json diff --git a/tests/fixtures/parity/expected/verify__replay_later_202.json b/tests/fixtures/parity/expected/verify__stored_replay_202.json similarity index 100% rename from tests/fixtures/parity/expected/verify__replay_later_202.json rename to tests/fixtures/parity/expected/verify__stored_replay_202.json diff --git a/tests/fixtures/parity/expected/verify__verification_not_ready_409_needs_input.json b/tests/fixtures/parity/expected/verify__verification_not_ready_409_needs_input.json new file mode 100644 index 0000000..ae73788 --- /dev/null +++ b/tests/fixtures/parity/expected/verify__verification_not_ready_409_needs_input.json @@ -0,0 +1,24 @@ +{ + "error": { + "cause": "This check is waiting for your input.", + "code": "verification_not_ready", + "doc_url": "https://lenz.io/docs/verify", + "fix": "GET /verify/status/7cff18da2d979d6370e54a160be121ad lists the options and how to resolve them.", + "friendly_text": "This check is waiting for your input.\n Fix: GET /verify/status/7cff18da2d979d6370e54a160be121ad lists the options and how to resolve them.", + "hint": "GET /verify/status/7cff18da2d979d6370e54a160be121ad lists the options and how to resolve them.", + "message": "This check is waiting for your input.", + "payload_json": { + "error": { + "code": "not_ready", + "fix": "GET /verify/status/7cff18da2d979d6370e54a160be121ad lists the options and how to resolve them.", + "message": "This check is waiting for your input.", + "status": 409 + } + }, + "request_id": "", + "status": "needs_input", + "status_code": 409, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "type": "LenzVerificationNotReadyError" + } +} diff --git a/tests/fixtures/parity/expected/verify__verification_purged_410.json b/tests/fixtures/parity/expected/verify__verification_purged_410.json new file mode 100644 index 0000000..99f659b --- /dev/null +++ b/tests/fixtures/parity/expected/verify__verification_purged_410.json @@ -0,0 +1,22 @@ +{ + "error": { + "cause": "This verification is no longer available: its account removes verifications after a set period.", + "code": "purged", + "doc_url": "https://lenz.io/docs/errors", + "fix": "Its account's retention period removed it. A certificate issued for it is still available.", + "friendly_text": "This verification is no longer available: its account removes verifications after a set period.\n Fix: Its account's retention period removed it. A certificate issued for it is still available.", + "message": "This verification is no longer available: its account removes verifications after a set period.", + "payload_json": { + "error": { + "code": "gone", + "fix": "Its account's retention period removed it. A certificate issued for it is still available.", + "message": "This verification is no longer available: its account removes verifications after a set period.", + "status": 410 + } + }, + "purged_at": "2026-10-01T09:07:00.123456+00:00", + "request_id": "", + "status_code": 410, + "type": "LenzGoneError" + } +} diff --git a/tests/fixtures/parity/expected/webhook__citecheck_cancelled.json b/tests/fixtures/parity/expected/webhook__citecheck_cancelled.json new file mode 100644 index 0000000..e736524 --- /dev/null +++ b/tests/fixtures/parity/expected/webhook__citecheck_cancelled.json @@ -0,0 +1,154 @@ +{ + "event": { + "attempt": 1, + "batch_id": null, + "citecheck": { + "citation_failures": [ + { + "citation_index": 0, + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "reference": "https://example.gov/report-2024" + }, + { + "citation_index": 1, + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "reference": "https://example.org/statement" + } + ], + "citation_issues": [], + "citations": [ + { + "check": { + "doi_registered": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "hint": "This source could not be checked this time. Try again later.", + "metadata": null, + "metadata_differences": [], + "missing_quote": null, + "page_language": null, + "page_published_date": null, + "page_read": null, + "page_title": null, + "quote": null, + "rationale": null, + "registered": null, + "snippet": null, + "source_url": null, + "source_version": null, + "status": "failed", + "support": null, + "unchecked_reason": null + }, + "cited_url": "https://example.gov/report-2024", + "doi": null, + "index": 0, + "position": null, + "quotes": [], + "reference": "https://example.gov/report-2024", + "result": null, + "statement": "Unemployment fell to 4.1% in 2024." + }, + { + "check": { + "doi_registered": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": "This source could not be checked this time. Try again later.", + "retryable": false + }, + "hint": "This source could not be checked this time. Try again later.", + "metadata": null, + "metadata_differences": [], + "missing_quote": null, + "page_language": null, + "page_published_date": null, + "page_read": null, + "page_title": null, + "quote": null, + "rationale": null, + "registered": null, + "snippet": null, + "source_url": null, + "source_version": null, + "status": "failed", + "support": null, + "unchecked_reason": null + }, + "cited_url": "https://example.org/statement", + "doi": null, + "index": 1, + "position": null, + "quotes": [], + "reference": "https://example.org/statement", + "result": null, + "statement": "The agency said so in a statement." + } + ], + "citecheck_id": "8eb49302", + "completed_at": "2026-10-01T10:07:00.123456Z", + "created_at": "2026-10-01T09:07:00.123456Z", + "credits": { + "charged": 0 + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "language": "en", + "more_citations": [], + "outcome": "incomplete", + "policy": { + "max_citations": 2 + }, + "poll_after_seconds": null, + "status": "failed", + "summary": { + "citation_checks": { + "checked": 0, + "failed": 2, + "unchecked": 0 + }, + "citation_issues": 0, + "citation_limit": 2, + "citation_limit_reached": false, + "citations_found": 2, + "citations_selected": 2 + } + }, + "citecheck_id": "8eb49302", + "delivered_at": "2026-10-01T11:07:00.123456+00:00", + "event": "citecheck.failed", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "type": "CitecheckEvent", + "verification_id": null + } +} diff --git a/tests/fixtures/parity/expected/webhook__delivery_retry_restamps.json b/tests/fixtures/parity/expected/webhook__delivery_retry_restamps.json new file mode 100644 index 0000000..fa818f6 --- /dev/null +++ b/tests/fixtures/parity/expected/webhook__delivery_retry_restamps.json @@ -0,0 +1,15 @@ +{ + "event": { + "attempt": 2, + "batch_id": null, + "delivered_at": "2026-10-01T09:07:00.123456+00:00", + "error": "conclusion_failed", + "event": "verification.failed", + "failure_class": "internal", + "retryable": false, + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "type": "VerificationFailed", + "verification_id": null + } +} diff --git a/tests/fixtures/parity/expected/webhook__review_cancelled.json b/tests/fixtures/parity/expected/webhook__review_cancelled.json new file mode 100644 index 0000000..62cb25f --- /dev/null +++ b/tests/fixtures/parity/expected/webhook__review_cancelled.json @@ -0,0 +1,173 @@ +{ + "event": { + "attempt": 1, + "batch_id": null, + "delivered_at": "2026-10-01T11:07:00.123456+00:00", + "event": "review.failed", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "review": { + "citation_failures": [], + "citation_issues": [], + "citations": [], + "claims": [ + { + "assessment": { + "confidence": "high", + "dissent": null, + "error_code": null, + "failure": null, + "hint": null, + "identified_claims": [], + "rationale": null, + "status": "completed", + "suggested_rewrite": null, + "verdict": "False", + "verification_url": null + }, + "claim": "The Eiffel Tower is in Berlin.", + "escalation": { + "disposition": "planned", + "matched_rules": [ + "verdict" + ] + }, + "index": 0, + "positions": null, + "result": { + "confidence": "high", + "is_issue": true, + "source": "assessment", + "verdict": "False" + }, + "suggested_edits": null, + "verification": { + "claim": null, + "confidence": null, + "content_status": "available", + "created_at": null, + "depth": null, + "domain": null, + "entities": [], + "executive_summary": null, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "key_finding": null, + "language": null, + "lenz_score": null, + "modified_at": null, + "status": "failed", + "suggested_rewrite": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "url": null, + "verdict": null, + "verification_id": null, + "verification_url": null, + "visibility": null, + "warnings": [] + } + } + ], + "completed_at": "2026-10-01T10:07:00.123456Z", + "created_at": "2026-10-01T09:07:00.123456Z", + "credits": { + "charged": 1 + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "failures": [], + "issues": [ + { + "claim": "The Eiffel Tower is in Berlin.", + "claim_index": 0, + "confidence": "high", + "escalation": { + "disposition": "planned", + "matched_rules": [ + "verdict" + ] + }, + "failure": { + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_class": "cancelled", + "failure_reason": "cancelled", + "hint": null, + "retryable": false + }, + "key_finding": null, + "rationale": null, + "source": "assessment", + "suggested_edits": null, + "suggested_rewrite": null, + "url": null, + "verdict": "False", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "verified_claim": null + } + ], + "language": "en", + "more_citations": [], + "more_claim_locations": [], + "more_claims": [], + "outcome": "incomplete", + "policy": { + "confidence": [ + "low" + ], + "depth": "standard", + "max_assessments": 20, + "max_citations": 0, + "max_verifications": 5, + "suggest_edits": false, + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ] + }, + "poll_after_seconds": null, + "review_id": "d6b2bd72", + "status": "failed", + "summary": { + "assessments": { + "completed": 1, + "failed": 0 + }, + "citation_checks": null, + "citation_issues": 0, + "citation_limit": null, + "citation_limit_reached": null, + "citations_found": null, + "citations_selected": null, + "citations_skipped": null, + "claim_limit": 20, + "claim_limit_reached": false, + "claims_selected": 1, + "input_truncated": false, + "issues": 1, + "verifications": { + "completed": 0, + "failed": 1, + "planned": 1 + } + }, + "view": "full" + }, + "review_id": "d6b2bd72", + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "type": "ReviewEvent", + "verification_id": null + } +} diff --git a/tests/fixtures/parity/expected/webhook__verification_cancelled.json b/tests/fixtures/parity/expected/webhook__verification_cancelled.json new file mode 100644 index 0000000..aa51cf6 --- /dev/null +++ b/tests/fixtures/parity/expected/webhook__verification_cancelled.json @@ -0,0 +1,15 @@ +{ + "event": { + "attempt": 1, + "batch_id": null, + "delivered_at": "2026-10-01T11:07:00.123456+00:00", + "error": "cancelled", + "event": "verification.failed", + "failure_class": "cancelled", + "retryable": false, + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "type": "VerificationFailed", + "verification_id": null + } +} diff --git a/tests/fixtures/parity/legacy/account__ask_send_idempotent_replay.json b/tests/fixtures/parity/legacy/account__ask_send_idempotent_replay.json new file mode 100644 index 0000000..b387511 --- /dev/null +++ b/tests/fixtures/parity/legacy/account__ask_send_idempotent_replay.json @@ -0,0 +1,13 @@ +{ + "status": 200, + "body": { + "role": "expert", + "content": "Because.", + "created_at": "2026-10-01T09:07:00.123456+00:00" + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/legacy/account__oauth_revoke_token.json b/tests/fixtures/parity/legacy/account__oauth_revoke_live_token.json similarity index 100% rename from tests/fixtures/parity/legacy/account__oauth_revoke_token.json rename to tests/fixtures/parity/legacy/account__oauth_revoke_live_token.json diff --git a/tests/fixtures/parity/legacy/assess__replay_later_200.json b/tests/fixtures/parity/legacy/assess__replay_later_200.json deleted file mode 100644 index fedc9c5..0000000 --- a/tests/fixtures/parity/legacy/assess__replay_later_200.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "status": 200, - "body": { - "claims": [ - { - "claim": "The Earth orbits the Sun.", - "language": "en", - "verdict": "True", - "confidence": "high", - "verification_url": null, - "rationale": null, - "dissent": null, - "suggested_rewrite": null, - "error_code": null, - "candidate_claims": [], - "identified_claims": [], - "hint": null - } - ], - "error": null, - "more_claims": [] - }, - "headers": { - "Content-Type": "application/json", - "Cache-Control": "no-store", - "Vary": "origin" - } -} diff --git a/tests/fixtures/parity/canonical/assess__replay_later_200.json b/tests/fixtures/parity/legacy/assess__stored_replay_200.json similarity index 100% rename from tests/fixtures/parity/canonical/assess__replay_later_200.json rename to tests/fixtures/parity/legacy/assess__stored_replay_200.json diff --git a/tests/fixtures/parity/legacy/citecheck__get_cancelled.json b/tests/fixtures/parity/legacy/citecheck__get_cancelled.json new file mode 100644 index 0000000..aa6e757 --- /dev/null +++ b/tests/fixtures/parity/legacy/citecheck__get_cancelled.json @@ -0,0 +1,148 @@ +{ + "status": 200, + "body": { + "citecheck_id": "8eb49302", + "status": "failed", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "poll_after_seconds": null, + "policy": { + "max_citations": 2 + }, + "summary": { + "citations_found": 2, + "citations_selected": 2, + "citation_limit": 2, + "citation_limit_reached": false, + "citation_checks": { + "checked": 0, + "unchecked": 0, + "failed": 2 + }, + "citation_issues": 0 + }, + "credits": { + "charged": 0 + }, + "citations": [ + { + "index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "statement": "Unemployment fell to 4.1% in 2024.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + }, + "missing_quote": null + } + }, + { + "index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "statement": "The agency said so in a statement.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + }, + "missing_quote": null + } + } + ], + "citation_issues": [], + "citation_failures": [ + { + "citation_index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + } + }, + { + "citation_index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + } + } + ], + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "more_citations": [] + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/legacy/extract__replay_later_200.json b/tests/fixtures/parity/legacy/extract__replay_later_200.json deleted file mode 100644 index 8f23ae8..0000000 --- a/tests/fixtures/parity/legacy/extract__replay_later_200.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "status": 200, - "body": { - "status": "ready", - "claim": "Alpha rose 5% in 2024.", - "identified_claims": [ - "Alpha rose 5% in 2024.", - "Beta fell 3% last year." - ], - "candidate_claims": [], - "domain": "Economics", - "key_entities": [ - { - "name": "Alpha", - "type": "organization" - }, - { - "name": "Beta", - "type": "organization" - } - ], - "presumed_intent": "Verify reported figures", - "original_input": "Alpha rose 5% in 2024. Beta fell 3% last year.", - "locations": null - }, - "headers": { - "Content-Type": "application/json", - "Cache-Control": "no-store", - "Vary": "origin" - } -} diff --git a/tests/fixtures/parity/canonical/extract__replay_later_200.json b/tests/fixtures/parity/legacy/extract__stored_replay_200.json similarity index 100% rename from tests/fixtures/parity/canonical/extract__replay_later_200.json rename to tests/fixtures/parity/legacy/extract__stored_replay_200.json diff --git a/tests/fixtures/parity/legacy/review__get_cancelled.json b/tests/fixtures/parity/legacy/review__get_cancelled.json new file mode 100644 index 0000000..084ce4d --- /dev/null +++ b/tests/fixtures/parity/legacy/review__get_cancelled.json @@ -0,0 +1,197 @@ +{ + "status": 200, + "body": { + "review_id": "d6b2bd72", + "view": "full", + "status": "failed", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "policy": { + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ], + "confidence": [ + "low" + ], + "max_assessments": 20, + "max_verifications": 5, + "depth": "standard", + "max_citations": 0, + "suggest_edits": false + }, + "summary": { + "claims_selected": 2, + "claim_limit": 20, + "claim_limit_reached": false, + "input_truncated": false, + "assessments": { + "completed": 2, + "failed": 0 + }, + "verifications": { + "planned": 1, + "completed": 0, + "failed": 1 + }, + "issues": 1, + "citations_found": null, + "citations_selected": null, + "citation_limit": null, + "citation_limit_reached": null, + "citation_checks": null, + "citation_issues": 0, + "citations_skipped": null + }, + "credits": { + "charged": 2 + }, + "poll_after_seconds": null, + "issues": [ + { + "claim_index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "verified_claim": null, + "verdict": "False", + "confidence": "high", + "source": "assessment", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "url": null, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "key_finding": null, + "rationale": null, + "suggested_rewrite": null, + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "suggested_edits": null + } + ], + "failures": [], + "citation_issues": [], + "citation_failures": [], + "claims": [ + { + "index": 0, + "claim": "The EU AI Act took effect in March 2024.", + "positions": null, + "result": { + "verdict": "False", + "confidence": "high", + "source": "assessment", + "is_issue": true + }, + "assessment": { + "status": "completed", + "verdict": "False", + "confidence": "high", + "rationale": null, + "dissent": null, + "verification_url": null, + "error_code": null, + "identified_claims": [], + "hint": null, + "suggested_rewrite": null, + "failure": null + }, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "verification": { + "verification_id": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "claim": null, + "language": null, + "visibility": null, + "depth": null, + "domain": null, + "entities": [], + "verdict": null, + "confidence": null, + "lenz_score": null, + "key_finding": null, + "executive_summary": null, + "suggested_rewrite": null, + "warnings": [], + "created_at": null, + "modified_at": null, + "verification_url": null, + "url": null, + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "status": "failed", + "content_status": "available" + }, + "suggested_edits": null + }, + { + "index": 1, + "claim": "Paris is the capital of France.", + "positions": null, + "result": { + "verdict": "True", + "confidence": "high", + "source": "assessment", + "is_issue": false + }, + "assessment": { + "status": "completed", + "verdict": "True", + "confidence": "high", + "rationale": null, + "dissent": null, + "verification_url": null, + "error_code": null, + "identified_claims": [], + "hint": null, + "suggested_rewrite": null, + "failure": null + }, + "escalation": { + "matched_rules": [], + "disposition": "not_selected" + }, + "verification": null, + "suggested_edits": null + } + ], + "citations": [], + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "more_claims": [], + "more_claim_locations": [], + "more_citations": [] + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/legacy/review__replay_later_202.json b/tests/fixtures/parity/legacy/review__replay_later_202.json deleted file mode 100644 index ea99cf8..0000000 --- a/tests/fixtures/parity/legacy/review__replay_later_202.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "status": 202, - "body": { - "review_id": "d6b2bd72", - "status": "queued" - }, - "headers": { - "Content-Type": "application/json", - "Retry-After": "20", - "Location": "/api/v1/reviews/d6b2bd72", - "Cache-Control": "no-store", - "Vary": "origin" - } -} diff --git a/tests/fixtures/parity/canonical/review__replay_later_202.json b/tests/fixtures/parity/legacy/review__stored_replay_202.json similarity index 100% rename from tests/fixtures/parity/canonical/review__replay_later_202.json rename to tests/fixtures/parity/legacy/review__stored_replay_202.json diff --git a/tests/fixtures/parity/legacy/verify__status_cancelled_after_a_while.json b/tests/fixtures/parity/legacy/verify__status_cancelled_durable.json similarity index 100% rename from tests/fixtures/parity/legacy/verify__status_cancelled_after_a_while.json rename to tests/fixtures/parity/legacy/verify__status_cancelled_durable.json diff --git a/tests/fixtures/parity/canonical/verify__status_cancelled_after_a_while.json b/tests/fixtures/parity/legacy/verify__status_cancelled_live.json similarity index 50% rename from tests/fixtures/parity/canonical/verify__status_cancelled_after_a_while.json rename to tests/fixtures/parity/legacy/verify__status_cancelled_live.json index 40ec53e..2d5625b 100644 --- a/tests/fixtures/parity/canonical/verify__status_cancelled_after_a_while.json +++ b/tests/fixtures/parity/legacy/verify__status_cancelled_live.json @@ -3,14 +3,11 @@ "body": { "status": "failed", "task_id": "7cff18da2d979d6370e54a160be121ad", - "failure": { - "code": "cancelled", - "detail": "The check was cancelled.", - "hint": null, - "failure_class": "cancelled", - "retryable": false, - "docs_url": "https://lenz.io/docs/errors#cancelled" - } + "error": "Pipeline stopped at: cancelled", + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "docs_url": "https://lenz.io/docs/errors#cancelled" }, "headers": { "Content-Type": "application/json; charset=utf-8", diff --git a/tests/fixtures/parity/legacy/verify__status_needs_input_hint_names.json b/tests/fixtures/parity/legacy/verify__status_needs_input_hint_names.json new file mode 100644 index 0000000..09d3e99 --- /dev/null +++ b/tests/fixtures/parity/legacy/verify__status_needs_input_hint_names.json @@ -0,0 +1,28 @@ +{ + "status": 200, + "body": { + "status": "needs_input", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "reason": "multi_claim", + "claims": [ + { + "text": "A.", + "domain": "" + }, + { + "text": "A happened.", + "domain": "" + }, + { + "text": "B happened.", + "domain": "" + } + ], + "hint": "The text holds several distinct claims. Send the ones to check to POST /verify/{task_id}/select." + }, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/legacy/verify__status_older_run_completed.json b/tests/fixtures/parity/legacy/verify__stored_progress_completed.json similarity index 100% rename from tests/fixtures/parity/legacy/verify__status_older_run_completed.json rename to tests/fixtures/parity/legacy/verify__stored_progress_completed.json diff --git a/tests/fixtures/parity/legacy/verify__status_older_run_failed_crashed.json b/tests/fixtures/parity/legacy/verify__stored_progress_failed_crashed.json similarity index 100% rename from tests/fixtures/parity/legacy/verify__status_older_run_failed_crashed.json rename to tests/fixtures/parity/legacy/verify__stored_progress_failed_crashed.json diff --git a/tests/fixtures/parity/legacy/verify__status_older_run_failed_insufficient_evidence.json b/tests/fixtures/parity/legacy/verify__stored_progress_failed_insufficient_evidence.json similarity index 100% rename from tests/fixtures/parity/legacy/verify__status_older_run_failed_insufficient_evidence.json rename to tests/fixtures/parity/legacy/verify__stored_progress_failed_insufficient_evidence.json diff --git a/tests/fixtures/parity/legacy/verify__status_older_run_in_progress.json b/tests/fixtures/parity/legacy/verify__stored_progress_in_progress.json similarity index 100% rename from tests/fixtures/parity/legacy/verify__status_older_run_in_progress.json rename to tests/fixtures/parity/legacy/verify__stored_progress_in_progress.json diff --git a/tests/fixtures/parity/canonical/verify__replay_later_202.json b/tests/fixtures/parity/legacy/verify__stored_replay_202.json similarity index 100% rename from tests/fixtures/parity/canonical/verify__replay_later_202.json rename to tests/fixtures/parity/legacy/verify__stored_replay_202.json diff --git a/tests/fixtures/parity/legacy/verify__verification_not_ready_409_needs_input.json b/tests/fixtures/parity/legacy/verify__verification_not_ready_409_needs_input.json new file mode 100644 index 0000000..efbdf4f --- /dev/null +++ b/tests/fixtures/parity/legacy/verify__verification_not_ready_409_needs_input.json @@ -0,0 +1,15 @@ +{ + "status": 409, + "body": { + "detail": "This check is waiting for your input.", + "code": "verification_not_ready", + "status": "needs_input", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "hint": "GET /verify/status/7cff18da2d979d6370e54a160be121ad lists the options and how to resolve them." + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/legacy/verify__verification_purged_410.json b/tests/fixtures/parity/legacy/verify__verification_purged_410.json new file mode 100644 index 0000000..924d468 --- /dev/null +++ b/tests/fixtures/parity/legacy/verify__verification_purged_410.json @@ -0,0 +1,13 @@ +{ + "status": 410, + "body": { + "detail": "This verification is no longer available: its account removes verifications after a set period.", + "code": "purged", + "purged_at": "2026-10-01T09:07:00.123456+00:00" + }, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Vary": "origin" + } +} diff --git a/tests/fixtures/parity/legacy/webhook__citecheck_cancelled.json b/tests/fixtures/parity/legacy/webhook__citecheck_cancelled.json new file mode 100644 index 0000000..e1ca5d9 --- /dev/null +++ b/tests/fixtures/parity/legacy/webhook__citecheck_cancelled.json @@ -0,0 +1,153 @@ +{ + "status": 200, + "body": { + "event": "citecheck.failed", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "citecheck_id": "8eb49302", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "status": "failed", + "citecheck": { + "citecheck_id": "8eb49302", + "status": "failed", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "poll_after_seconds": null, + "policy": { + "max_citations": 2 + }, + "summary": { + "citations_found": 2, + "citations_selected": 2, + "citation_limit": 2, + "citation_limit_reached": false, + "citation_checks": { + "checked": 0, + "unchecked": 0, + "failed": 2 + }, + "citation_issues": 0 + }, + "credits": { + "charged": 0 + }, + "citations": [ + { + "index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "statement": "Unemployment fell to 4.1% in 2024.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + }, + "missing_quote": null + } + }, + { + "index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "statement": "The agency said so in a statement.", + "quotes": [], + "position": null, + "result": null, + "check": { + "status": "failed", + "page_read": null, + "page_title": null, + "page_published_date": null, + "page_language": null, + "source_url": null, + "source_version": null, + "support": null, + "snippet": null, + "rationale": null, + "quote": null, + "doi_registered": null, + "metadata": null, + "metadata_differences": [], + "registered": null, + "unchecked_reason": null, + "hint": "This source could not be checked this time. Try again later.", + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + }, + "missing_quote": null + } + } + ], + "citation_issues": [], + "citation_failures": [ + { + "citation_index": 0, + "reference": "https://example.gov/report-2024", + "cited_url": "https://example.gov/report-2024", + "doi": null, + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + } + }, + { + "citation_index": 1, + "reference": "https://example.org/statement", + "cited_url": "https://example.org/statement", + "doi": null, + "failure": { + "hint": "This source could not be checked this time. Try again later.", + "docs_url": "https://lenz.io/docs/errors#cancelled", + "retryable": false, + "failure_class": "cancelled", + "failure_reason": "cancelled" + } + } + ], + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "more_citations": [] + }, + "attempt": 1, + "delivered_at": "2026-10-01T11:07:00.123456+00:00" + }, + "headers": {} +} diff --git a/tests/fixtures/parity/legacy/webhook__delivery_retry_restamps.json b/tests/fixtures/parity/legacy/webhook__delivery_retry_restamps.json new file mode 100644 index 0000000..36f3541 --- /dev/null +++ b/tests/fixtures/parity/legacy/webhook__delivery_retry_restamps.json @@ -0,0 +1,19 @@ +{ + "status": 200, + "body": { + "attempt": 2, + "batch_id": null, + "coverage": null, + "delivered_at": "2026-10-01T09:07:00.123456+00:00", + "error": "conclusion_failed", + "event": "verification.failed", + "failure_class": "internal", + "needs_input": null, + "result": null, + "retryable": false, + "status": "failed", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "verification_id": null + }, + "headers": {} +} diff --git a/tests/fixtures/parity/legacy/webhook__review_cancelled.json b/tests/fixtures/parity/legacy/webhook__review_cancelled.json new file mode 100644 index 0000000..0d8dc08 --- /dev/null +++ b/tests/fixtures/parity/legacy/webhook__review_cancelled.json @@ -0,0 +1,172 @@ +{ + "status": 200, + "body": { + "event": "review.failed", + "event_id": "evt_09301870aba4e8f6ae33ff60", + "review_id": "d6b2bd72", + "task_id": "7cff18da2d979d6370e54a160be121ad", + "status": "failed", + "review": { + "review_id": "d6b2bd72", + "view": "full", + "status": "failed", + "outcome": "incomplete", + "created_at": "2026-10-01T09:07:00.123456Z", + "completed_at": "2026-10-01T10:07:00.123456Z", + "language": "en", + "policy": { + "verdicts": [ + "False", + "Mostly False", + "Mixed" + ], + "confidence": [ + "low" + ], + "max_assessments": 20, + "max_verifications": 5, + "depth": "standard", + "max_citations": 0, + "suggest_edits": false + }, + "summary": { + "claims_selected": 1, + "claim_limit": 20, + "claim_limit_reached": false, + "input_truncated": false, + "assessments": { + "completed": 1, + "failed": 0 + }, + "verifications": { + "planned": 1, + "completed": 0, + "failed": 1 + }, + "issues": 1, + "citations_found": null, + "citations_selected": null, + "citation_limit": null, + "citation_limit_reached": null, + "citation_checks": null, + "citation_issues": 0, + "citations_skipped": null + }, + "credits": { + "charged": 1 + }, + "poll_after_seconds": null, + "issues": [ + { + "claim_index": 0, + "claim": "The Eiffel Tower is in Berlin.", + "verified_claim": null, + "verdict": "False", + "confidence": "high", + "source": "assessment", + "verification_id": null, + "verification_status": "failed", + "verification_url": null, + "url": null, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "key_finding": null, + "rationale": null, + "suggested_rewrite": null, + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "suggested_edits": null + } + ], + "failures": [], + "citation_issues": [], + "citation_failures": [], + "claims": [ + { + "index": 0, + "claim": "The Eiffel Tower is in Berlin.", + "positions": null, + "result": { + "verdict": "False", + "confidence": "high", + "source": "assessment", + "is_issue": true + }, + "assessment": { + "status": "completed", + "verdict": "False", + "confidence": "high", + "rationale": null, + "dissent": null, + "verification_url": null, + "error_code": null, + "identified_claims": [], + "hint": null, + "suggested_rewrite": null, + "failure": null + }, + "escalation": { + "matched_rules": [ + "verdict" + ], + "disposition": "planned" + }, + "verification": { + "verification_id": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "claim": null, + "language": null, + "visibility": null, + "depth": null, + "domain": null, + "entities": [], + "verdict": null, + "confidence": null, + "lenz_score": null, + "key_finding": null, + "executive_summary": null, + "suggested_rewrite": null, + "warnings": [], + "created_at": null, + "modified_at": null, + "verification_url": null, + "url": null, + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "status": "failed", + "content_status": "available" + }, + "suggested_edits": null + } + ], + "citations": [], + "failure": { + "failure_reason": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "hint": null, + "docs_url": "https://lenz.io/docs/errors#cancelled" + }, + "more_claims": [], + "more_claim_locations": [], + "more_citations": [] + }, + "attempt": 1, + "delivered_at": "2026-10-01T11:07:00.123456+00:00" + }, + "headers": {} +} diff --git a/tests/fixtures/parity/legacy/webhook__verification_cancelled.json b/tests/fixtures/parity/legacy/webhook__verification_cancelled.json new file mode 100644 index 0000000..41c557d --- /dev/null +++ b/tests/fixtures/parity/legacy/webhook__verification_cancelled.json @@ -0,0 +1,19 @@ +{ + "status": 200, + "body": { + "event": "verification.failed", + "verification_id": null, + "task_id": "7cff18da2d979d6370e54a160be121ad", + "batch_id": null, + "status": "failed", + "result": null, + "needs_input": null, + "coverage": null, + "error": "cancelled", + "failure_class": "cancelled", + "retryable": false, + "attempt": 1, + "delivered_at": "2026-10-01T11:07:00.123456+00:00" + }, + "headers": {} +} diff --git a/tests/fixtures/parity/pickles.json b/tests/fixtures/parity/pickles.json index cf1551e..9035ab2 100644 --- a/tests/fixtures/parity/pickles.json +++ b/tests/fixtures/parity/pickles.json @@ -23,11 +23,11 @@ "assess__list_compound_item.json": "gASV2wIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UKGgAjAtBc3Nlc3NDbGFpbZSTlCmBlH2UKGgFfZQojAVjbGFpbZSMDlByaW1hcnkgY2xhaW0ulIwIbGFuZ3VhZ2WUjAJlbpSMB3ZlcmRpY3SUjAxNb3N0bHkgRmFsc2WUjApjb25maWRlbmNllIwEaGlnaJSMEHZlcmlmaWNhdGlvbl91cmyUTowJcmF0aW9uYWxllE6MB2Rpc3NlbnSUTowRc3VnZ2VzdGVkX3Jld3JpdGWUTowKZXJyb3JfY29kZZROjBBjYW5kaWRhdGVfY2xhaW1zlF2UjBFpZGVudGlmaWVkX2NsYWltc5RdlCiMDVNlY29uZCBjbGFpbS6UjAxUaGlyZCBjbGFpbS6UZYwEaGludJSMWkFzc2Vzc2VkIHRoZSBtYWluIGNsYWltIG9ubHkuIFNlbmQgaWRlbnRpZmllZF9jbGFpbXMgYXMgdGhlaXIgb3duIGl0ZW1zIHRvIGNoZWNrIHRoZSByZXN0LpR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgOaCFoGGgbaBZoEGgUaB1oEmgXaBpoGZCMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmgKKYGUfZQoaAV9lChoDowOQSBwbGFpbiBjbGFpbS6UaBCMAmVulGgSjAxNb3N0bHkgRmFsc2WUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoIU51aCN9lGglj5QoaA5oIWgYaBtoFmgQaBRoHWgSaBdoGmgZkGgnTnViZYwFZXJyb3KUTmgajACUaBtdlIwLbW9yZV9jbGFpbXOUXZR1aCN9lGglj5QoaAdoNmgzkGgnTnViLg==", "assess__list_mixed_rows.json": "gASVxAQAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UKGgAjAtBc3Nlc3NDbGFpbZSTlCmBlH2UKGgFfZQojAVjbGFpbZSMIldhdGVyIGJvaWxzIGF0IDEwMCBDIGF0IHNlYSBsZXZlbC6UjAhsYW5ndWFnZZSMAmVulIwHdmVyZGljdJSMBFRydWWUjApjb25maWRlbmNllIwEaGlnaJSMEHZlcmlmaWNhdGlvbl91cmyUTowJcmF0aW9uYWxllE6MB2Rpc3NlbnSUTowRc3VnZ2VzdGVkX3Jld3JpdGWUTowKZXJyb3JfY29kZZROjBBjYW5kaWRhdGVfY2xhaW1zlF2UjBFpZGVudGlmaWVkX2NsYWltc5RdlIwEaGludJROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlChoDmgfaBhoG2gWaBBoFGgdaBJoF2gaaBmQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWJoCimBlH2UKGgFfZQoaA6MC2hlbGxvIHRoZXJllGgQjAJlbpRoEowFRXJyb3KUaBSMA2xvd5RoFk5oF05oGE5oGU5oGowIbm9fY2xhaW2UaBtdlGgdXZRoH4xqVGhlIGlucHV0IGlzIGEgZ3JlZXRpbmcuIFNlbmQgb25lIGZhY3R1YWwgY2xhaW0sIG9yIHJ1biB0aGUgdGV4dCB0aHJvdWdoIC9leHRyYWN0IHRvIGVudW1lcmF0ZSBpdHMgY2xhaW1zLpR1aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjA52ZW5kb3IgaXMgZG93bpRoEIwCZW6UaBKMBUVycm9ylGgUjANsb3eUaBZOaBdOaBhOaBlOaBqMFHVwc3RyZWFtX3VuYXZhaWxhYmxllGgbXZRoHV2UaB+MTkEgbW9kZWwgcHJvdmlkZXIgd2FzIHVuYXZhaWxhYmxlIGZvciB0aGlzIGl0ZW0uIFJldHJ5IGl0OyBub3RoaW5nIHdhcyBjaGFyZ2VkLpR1aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjAxjYW5ub3QgZnJhbWWUaBCMAmVulGgSjAVFcnJvcpRoFIwDbG93lGgWTmgXTmgYTmgZTmgajA5mcmFtaW5nX2ZhaWxlZJRoG12UaB1dlGgfjINUaGlzIGl0ZW0gY291bGQgbm90IGJlIHByb2Nlc3NlZDsgcmV0cnlpbmcgaXQgYXMtaXMgd2lsbCBub3QgaGVscC4gU2VuZCBpdCByZXBocmFzZWQgYXMgb25lIGZhY3R1YWwgc3RhdGVtZW50OyBub3RoaW5nIHdhcyBjaGFyZ2VkLpR1aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViZYwFZXJyb3KUTmgajACUaBtdlIwLbW9yZV9jbGFpbXOUXZR1aCB9lGgij5QoaAdoT2hMkGgkTnViLg==", "assess__memo_hit.json": "gASVBgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UaACMC0Fzc2Vzc0NsYWltlJOUKYGUfZQoaAV9lCiMBWNsYWltlIwgVGhlIERhbnViZSBmbG93cyB0aHJvdWdoIFZpZW5uYS6UjAhsYW5ndWFnZZSMAmVulIwHdmVyZGljdJSMBFRydWWUjApjb25maWRlbmNllIwEaGlnaJSMEHZlcmlmaWNhdGlvbl91cmyUTowJcmF0aW9uYWxllIwgVGhlIHJpdmVyIHJ1bnMgdGhyb3VnaCB0aGUgY2l0eS6UjAdkaXNzZW50lE6MEXN1Z2dlc3RlZF9yZXdyaXRllE6MCmVycm9yX2NvZGWUTowQY2FuZGlkYXRlX2NsYWltc5RdlIwRaWRlbnRpZmllZF9jbGFpbXOUXZSMBGhpbnSUTnWMEl9fcHlkYW50aWNfZXh0cmFfX5R9lIwXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaA5oIGgZaBxoFmgQaBRoHmgSaBdoG2gakIwUX19weWRhbnRpY19wcml2YXRlX1+UTnViYYwFZXJyb3KUTmgbjACUaBxdlIwLbW9yZV9jbGFpbXOUXZR1aCF9lGgjj5QoaAdoKWgmkGglTnViLg==", - "assess__replay_later_200.json": "gASV3QEAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UaACMC0Fzc2Vzc0NsYWltlJOUKYGUfZQoaAV9lCiMBWNsYWltlIwZVGhlIEVhcnRoIG9yYml0cyB0aGUgU3VuLpSMCGxhbmd1YWdllIwCZW6UjAd2ZXJkaWN0lIwEVHJ1ZZSMCmNvbmZpZGVuY2WUjARoaWdolIwQdmVyaWZpY2F0aW9uX3VybJROjAlyYXRpb25hbGWUTowHZGlzc2VudJROjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAplcnJvcl9jb2RllE6MEGNhbmRpZGF0ZV9jbGFpbXOUXZSMEWlkZW50aWZpZWRfY2xhaW1zlF2UjARoaW50lE51jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgOaB9oGGgbaBZoEGgUaB1oEmgXaBpoGZCMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmGMBWVycm9ylE5oGowAlGgbXZSMC21vcmVfY2xhaW1zlF2UdWggfZRoIo+UKGgHaChoJZBoJE51Yi4=", "assess__single_no_claim.json": "gASVAgEAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UjAVlcnJvcpSMHE5vIHZlcmlmaWFibGUgY2xhaW0gZGV0ZWN0ZWSUjAplcnJvcl9jb2RllIwIbm9fY2xhaW2UjBBjYW5kaWRhdGVfY2xhaW1zlF2UjAttb3JlX2NsYWltc5RdlHWMEl9fcHlkYW50aWNfZXh0cmFfX5R9lIwXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaA1oD2gLaAdoCZCMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51Yi4=", "assess__single_one_claim.json": "gASViwIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UaACMC0Fzc2Vzc0NsYWltlJOUKYGUfZQoaAV9lCiMBWNsYWltlIwsVGhlIHJlZ2lzdHJ5IHJlcG9ydGVkIDQsMjAwIGZpbGluZ3MgaW4gMjAyNC6UjAhsYW5ndWFnZZSMAmVulIwHdmVyZGljdJSMBFRydWWUjApjb25maWRlbmNllIwEaGlnaJSMEHZlcmlmaWNhdGlvbl91cmyUTowJcmF0aW9uYWxllIxJVGhlIHJlZ2lzdHJ5IGxpc3RzIDQsMjAwIGZpbGluZ3MgZm9yIDIwMjQgYW5kIGhhcyBub3QgcmV2aXNlZCB0aGUgZmlndXJlLpSMB2Rpc3NlbnSUjE5UaGUgcmVnaXN0cnkgZmlndXJlIGlzIHByb3Zpc2lvbmFsLCBzbyB0aGUgMjAyNCB0b3RhbCBjYW5ub3QgeWV0IGJlIGNvbmZpcm1lZC6UjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAplcnJvcl9jb2RllE6MEGNhbmRpZGF0ZV9jbGFpbXOUXZSMEWlkZW50aWZpZWRfY2xhaW1zlF2UjARoaW50lE51jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgOaCFoGWgdaBZoEGgUaB9oEmgXaBxoG5CMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmGMBWVycm9ylE5oHIwAlGgdXZSMC21vcmVfY2xhaW1zlF2UdWgifZRoJI+UKGgHaCpoJ5BoJk51Yi4=", "assess__single_text_over_wave_more_claims.json": "gASVbgwAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UKGgAjAtBc3Nlc3NDbGFpbZSTlCmBlH2UKGgFfZQojAVjbGFpbZSMHUNsYWltIG51bWJlciAwIGlzIGRvY3VtZW50ZWQulIwIbGFuZ3VhZ2WUjAJlbpSMB3ZlcmRpY3SUjARUcnVllIwKY29uZmlkZW5jZZSMBGhpZ2iUjBB2ZXJpZmljYXRpb25fdXJslE6MCXJhdGlvbmFsZZROjAdkaXNzZW50lE6MEXN1Z2dlc3RlZF9yZXdyaXRllE6MCmVycm9yX2NvZGWUTowQY2FuZGlkYXRlX2NsYWltc5RdlIwRaWRlbnRpZmllZF9jbGFpbXOUXZSMBGhpbnSUTnWMEl9fcHlkYW50aWNfZXh0cmFfX5R9lIwXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkIwUX19weWRhbnRpY19wcml2YXRlX1+UTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgMSBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgMiBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgMyBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgNCBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgNSBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgNiBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgNyBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgOCBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB1DbGFpbSBudW1iZXIgOSBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB5DbGFpbSBudW1iZXIgMTAgaXMgZG9jdW1lbnRlZC6UaBCMAmVulGgSjARUcnVllGgUjARoaWdolGgWTmgXTmgYTmgZTmgaTmgbXZRoHV2UaB9OdWggfZRoIo+UKGgOaB9oGGgbaBZoEGgUaB1oEmgXaBpoGZBoJE51YmgKKYGUfZQoaAV9lChoDoweQ2xhaW0gbnVtYmVyIDExIGlzIGRvY3VtZW50ZWQulGgQjAJlbpRoEowEVHJ1ZZRoFIwEaGlnaJRoFk5oF05oGE5oGU5oGk5oG12UaB1dlGgfTnVoIH2UaCKPlChoDmgfaBhoG2gWaBBoFGgdaBJoF2gaaBmQaCROdWJoCimBlH2UKGgFfZQoaA6MHkNsYWltIG51bWJlciAxMiBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB5DbGFpbSBudW1iZXIgMTMgaXMgZG9jdW1lbnRlZC6UaBCMAmVulGgSjARUcnVllGgUjARoaWdolGgWTmgXTmgYTmgZTmgaTmgbXZRoHV2UaB9OdWggfZRoIo+UKGgOaB9oGGgbaBZoEGgUaB1oEmgXaBpoGZBoJE51YmgKKYGUfZQoaAV9lChoDoweQ2xhaW0gbnVtYmVyIDE0IGlzIGRvY3VtZW50ZWQulGgQjAJlbpRoEowEVHJ1ZZRoFIwEaGlnaJRoFk5oF05oGE5oGU5oGk5oG12UaB1dlGgfTnVoIH2UaCKPlChoDmgfaBhoG2gWaBBoFGgdaBJoF2gaaBmQaCROdWJoCimBlH2UKGgFfZQoaA6MHkNsYWltIG51bWJlciAxNSBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB5DbGFpbSBudW1iZXIgMTYgaXMgZG9jdW1lbnRlZC6UaBCMAmVulGgSjARUcnVllGgUjARoaWdolGgWTmgXTmgYTmgZTmgaTmgbXZRoHV2UaB9OdWggfZRoIo+UKGgOaB9oGGgbaBZoEGgUaB1oEmgXaBpoGZBoJE51YmgKKYGUfZQoaAV9lChoDoweQ2xhaW0gbnVtYmVyIDE3IGlzIGRvY3VtZW50ZWQulGgQjAJlbpRoEowEVHJ1ZZRoFIwEaGlnaJRoFk5oF05oGE5oGU5oGk5oG12UaB1dlGgfTnVoIH2UaCKPlChoDmgfaBhoG2gWaBBoFGgdaBJoF2gaaBmQaCROdWJoCimBlH2UKGgFfZQoaA6MHkNsYWltIG51bWJlciAxOCBpcyBkb2N1bWVudGVkLpRoEIwCZW6UaBKMBFRydWWUaBSMBGhpZ2iUaBZOaBdOaBhOaBlOaBpOaBtdlGgdXZRoH051aCB9lGgij5QoaA5oH2gYaBtoFmgQaBRoHWgSaBdoGmgZkGgkTnViaAopgZR9lChoBX2UKGgOjB5DbGFpbSBudW1iZXIgMTkgaXMgZG9jdW1lbnRlZC6UaBCMAmVulGgSjARUcnVllGgUjARoaWdolGgWTmgXTmgYTmgZTmgaTmgbXZRoHV2UaB9OdWggfZRoIo+UKGgOaB9oGGgbaBZoEGgUaB1oEmgXaBpoGZBoJE51YmWMBWVycm9ylE5oGowAlGgbXZSMC21vcmVfY2xhaW1zlF2UKIweQ2xhaW0gbnVtYmVyIDIwIGlzIGRvY3VtZW50ZWQulIweQ2xhaW0gbnVtYmVyIDIxIGlzIGRvY3VtZW50ZWQulGV1aCB9lGgij5QoaAdo+Wj2kGgkTnViLg==", "assess__single_text_several_claims.json": "gASVBwMAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UKGgAjAtBc3Nlc3NDbGFpbZSTlCmBlH2UKGgFfZQojAVjbGFpbZSMIldhdGVyIGJvaWxzIGF0IDEwMCBDIGF0IHNlYSBsZXZlbC6UjAhsYW5ndWFnZZSMAmVulIwHdmVyZGljdJSMBFRydWWUjApjb25maWRlbmNllIwEaGlnaJSMEHZlcmlmaWNhdGlvbl91cmyUTowJcmF0aW9uYWxllE6MB2Rpc3NlbnSUTowRc3VnZ2VzdGVkX3Jld3JpdGWUTowKZXJyb3JfY29kZZROjBBjYW5kaWRhdGVfY2xhaW1zlF2UjBFpZGVudGlmaWVkX2NsYWltc5RdlIwEaGludJROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlChoDmgfaBhoG2gWaBBoFGgdaBJoF2gaaBmQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWJoCimBlH2UKGgFfZQoaA6MG1RoZSBNb29uIGlzIG1hZGUgb2YgY2hlZXNlLpRoEIwCZW6UaBKMBUZhbHNllGgUjARoaWdolGgWTmgXTmgYTmgZTmgaTmgbXZRoHV2UaB9OdWggfZRoIo+UKGgOaB9oGGgbaBZoEGgUaB1oEmgXaBpoGZBoJE51YmgKKYGUfZQoaAV9lChoDowhVGhlIFBhY2lmaWMgaXMgdGhlIGRlZXBlc3Qgb2NlYW4ulGgQjAJlbpRoEowLTW9zdGx5IFRydWWUaBSMBm1lZGl1bZRoFk5oF05oGE5oGU5oGk5oG12UaB1dlGgfTnVoIH2UaCKPlChoDmgfaBhoG2gWaBBoFGgdaBJoF2gaaBmQaCROdWJljAVlcnJvcpROaBqMAJRoG12UjAttb3JlX2NsYWltc5RdlHVoIH2UaCKPlChoB2g+aDuQaCROdWIu", + "assess__stored_replay_200.json": "gASV3QEAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UaACMC0Fzc2Vzc0NsYWltlJOUKYGUfZQoaAV9lCiMBWNsYWltlIwZVGhlIEVhcnRoIG9yYml0cyB0aGUgU3VuLpSMCGxhbmd1YWdllIwCZW6UjAd2ZXJkaWN0lIwEVHJ1ZZSMCmNvbmZpZGVuY2WUjARoaWdolIwQdmVyaWZpY2F0aW9uX3VybJROjAlyYXRpb25hbGWUTowHZGlzc2VudJROjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAplcnJvcl9jb2RllE6MEGNhbmRpZGF0ZV9jbGFpbXOUXZSMEWlkZW50aWZpZWRfY2xhaW1zlF2UjARoaW50lE51jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgOaB9oGGgbaBZoEGgUaB1oEmgXaBpoGZCMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmGMBWVycm9ylE5oGowAlGgbXZSMC21vcmVfY2xhaW1zlF2UdWggfZRoIo+UKGgHaChoJZBoJE51Yi4=", "assess__suggest_rewrite.json": "gASVBwMAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwOQXNzZXNzUmVzcG9uc2WUk5QpgZR9lCiMCF9fZGljdF9flH2UKIwGY2xhaW1zlF2UKGgAjAtBc3Nlc3NDbGFpbZSTlCmBlH2UKGgFfZQojAVjbGFpbZSMJ1ZlbnVzIGlzIHRoZSBjbG9zZXN0IHBsYW5ldCB0byB0aGUgU3VuLpSMCGxhbmd1YWdllIwCZW6UjAd2ZXJkaWN0lIwFRmFsc2WUjApjb25maWRlbmNllIwEaGlnaJSMEHZlcmlmaWNhdGlvbl91cmyUTowJcmF0aW9uYWxllIw1TWVyY3VyeSwgbm90IFZlbnVzLCBpcyB0aGUgY2xvc2VzdCBwbGFuZXQgdG8gdGhlIFN1bi6UjAdkaXNzZW50lE6MEXN1Z2dlc3RlZF9yZXdyaXRllIwpTWVyY3VyeSBpcyB0aGUgY2xvc2VzdCBwbGFuZXQgdG8gdGhlIFN1bi6UjAplcnJvcl9jb2RllE6MEGNhbmRpZGF0ZV9jbGFpbXOUXZSMEWlkZW50aWZpZWRfY2xhaW1zlF2UjARoaW50lE51jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgOaCFoGWgdaBZoEGgUaB9oEmgXaBxoGpCMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmgKKYGUfZQoaAV9lChoDowpTWVyY3VyeSBpcyB0aGUgY2xvc2VzdCBwbGFuZXQgdG8gdGhlIFN1bi6UaBCMAmVulGgSjARUcnVllGgUjARoaWdolGgWTmgXjCBNZXJjdXJ5IGlzIHRoZSBpbm5lcm1vc3QgcGxhbmV0LpRoGU5oGk5oHE5oHV2UaB9dlGghTnVoIn2UaCSPlChoDmghaBloHWgWaBBoFGgfaBJoF2gcaBqQaCZOdWJljAVlcnJvcpROaByMAJRoHV2UjAttb3JlX2NsYWltc5RdlHVoIn2UaCSPlChoB2g2aDOQaCZOdWIu", "citecheck__get_checking.json": "gASV7AYAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwJQ2l0ZWNoZWNrlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMDGNpdGVjaGVja19pZJSMCDhlYjQ5MzAylIwGc3RhdHVzlIwIY2hlY2tpbmeUjAdvdXRjb21llE6MCmNyZWF0ZWRfYXSUjBsyMDI2LTEwLTAxVDA5OjA3OjAwLjEyMzQ1NlqUjAxjb21wbGV0ZWRfYXSUTowScG9sbF9hZnRlcl9zZWNvbmRzlEsKjAZwb2xpY3mUaACMD0NpdGVjaGVja1BvbGljeZSTlCmBlH2UKGgFfZSMDW1heF9jaXRhdGlvbnOUSwJzjBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgWkIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAdzdW1tYXJ5lGgAjBBDaXRlY2hlY2tTdW1tYXJ5lJOUKYGUfZQoaAV9lCiMD2NpdGF0aW9uc19mb3VuZJRLAowSY2l0YXRpb25zX3NlbGVjdGVklEsCjA5jaXRhdGlvbl9saW1pdJRLAowWY2l0YXRpb25fbGltaXRfcmVhY2hlZJSJjA9jaXRhdGlvbl9jaGVja3OUaACMGVJldmlld0NpdGF0aW9uQ2hlY2tDb3VudHOUk5QpgZR9lChoBX2UKIwHY2hlY2tlZJRLAIwJdW5jaGVja2VklEsAjAZmYWlsZWSUSwB1aBd9lGgZj5QoaCxoLWgukGgbTnVijA9jaXRhdGlvbl9pc3N1ZXOUSwB1aBd9lGgZj5QoaCNoJWgxaCRoJmgikGgbTnVijAdjcmVkaXRzlGgAjA1SZXZpZXdDcmVkaXRzlJOUKYGUfZQoaAV9lIwHY2hhcmdlZJRLAHNoF32UaBmPlChoOpBoG051YowJY2l0YXRpb25zlF2UKGgAjA5SZXZpZXdDaXRhdGlvbpSTlCmBlH2UKGgFfZQojAVpbmRleJRLAIwJcmVmZXJlbmNllIwfaHR0cHM6Ly9leGFtcGxlLmdvdi9yZXBvcnQtMjAyNJSMCWNpdGVkX3VybJSMH2h0dHBzOi8vZXhhbXBsZS5nb3YvcmVwb3J0LTIwMjSUjANkb2mUTowJc3RhdGVtZW50lIwiVW5lbXBsb3ltZW50IGZlbGwgdG8gNC4xJSBpbiAyMDI0LpSMBnF1b3Rlc5RdlIwIcG9zaXRpb26UTowGcmVzdWx0lE6MBWNoZWNrlGgAjBNSZXZpZXdDaXRhdGlvbkNoZWNrlJOUKYGUfZQoaAV9lChoCYwHcGVuZGluZ5SMCXBhZ2VfcmVhZJROjApwYWdlX3RpdGxllE6ME3BhZ2VfcHVibGlzaGVkX2RhdGWUTowNcGFnZV9sYW5ndWFnZZROjApzb3VyY2VfdXJslE6MDnNvdXJjZV92ZXJzaW9ulE6MB3N1cHBvcnSUTowHc25pcHBldJROjAlyYXRpb25hbGWUTowFcXVvdGWUTowNbWlzc2luZ19xdW90ZZROjA5kb2lfcmVnaXN0ZXJlZJROjAhtZXRhZGF0YZROjBRtZXRhZGF0YV9kaWZmZXJlbmNlc5RdlIwKcmVnaXN0ZXJlZJROjBB1bmNoZWNrZWRfcmVhc29ulE6MBGhpbnSUTowHZmFpbHVyZZROdWgXfZRoGY+UKGhZaGdoX2haaFtoaGhiaGNoCWhhaGRoYGhYaGZoXWhXaFxoXmhpkGgbTnVidWgXfZRoGY+UKGhKaE9oRWhQaERoSWhMaEdoTpBoG051YmhAKYGUfZQoaAV9lChoREsBaEWMHWh0dHBzOi8vZXhhbXBsZS5vcmcvc3RhdGVtZW50lGhHjB1odHRwczovL2V4YW1wbGUub3JnL3N0YXRlbWVudJRoSU5oSowiVGhlIGFnZW5jeSBzYWlkIHNvIGluIGEgc3RhdGVtZW50LpRoTF2UaE5OaE9OaFBoUimBlH2UKGgFfZQoaAmMB3BlbmRpbmeUaFdOaFhOaFlOaFpOaFtOaFxOaF1OaF5OaF9OaGBOaGFOaGJOaGNOaGRdlGhmTmhnTmhoTmhpTnVoF32UaBmPlChoWWhnaF9oWmhbaGhoYmhjaAloYWhkaGBoWGhmaF1oV2hcaF5oaZBoG051YnVoF32UaBmPlChoSmhPaEVoUGhEaEloTGhHaE6QaBtOdWJlaDFdlIwRY2l0YXRpb25fZmFpbHVyZXOUXZSMDm1vcmVfY2l0YXRpb25zlF2UaGlOdWgXfZSMCGxhbmd1YWdllIwCZW6Uc2gZj5QoaAtoNGgOaAxohGgJaBBoPWgxaH9oD2gHaBxogWhpkGgbTnViLg==", "citecheck__get_completed_clean.json": "gASV0QgAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwJQ2l0ZWNoZWNrlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMDGNpdGVjaGVja19pZJSMCDhlYjQ5MzAylIwGc3RhdHVzlIwJY29tcGxldGVklIwHb3V0Y29tZZSMBWNsZWFulIwKY3JlYXRlZF9hdJSMGzIwMjYtMTAtMDFUMDk6MDc6MDAuMTIzNDU2WpSMDGNvbXBsZXRlZF9hdJSMGzIwMjYtMTAtMDFUMTA6MDc6MDAuMTIzNDU2WpSMEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROjAZwb2xpY3mUaACMD0NpdGVjaGVja1BvbGljeZSTlCmBlH2UKGgFfZSMDW1heF9jaXRhdGlvbnOUSwJzjBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgYkIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAdzdW1tYXJ5lGgAjBBDaXRlY2hlY2tTdW1tYXJ5lJOUKYGUfZQoaAV9lCiMD2NpdGF0aW9uc19mb3VuZJRLAowSY2l0YXRpb25zX3NlbGVjdGVklEsCjA5jaXRhdGlvbl9saW1pdJRLAowWY2l0YXRpb25fbGltaXRfcmVhY2hlZJSJjA9jaXRhdGlvbl9jaGVja3OUaACMGVJldmlld0NpdGF0aW9uQ2hlY2tDb3VudHOUk5QpgZR9lChoBX2UKIwHY2hlY2tlZJRLAowJdW5jaGVja2VklEsAjAZmYWlsZWSUSwB1aBl9lGgbj5QoaC5oL2gwkGgdTnVijA9jaXRhdGlvbl9pc3N1ZXOUSwB1aBl9lGgbj5QoaCVoJ2gzaCZoKGgkkGgdTnVijAdjcmVkaXRzlGgAjA1SZXZpZXdDcmVkaXRzlJOUKYGUfZQoaAV9lIwHY2hhcmdlZJRLAnNoGX2UaBuPlChoPJBoHU51YowJY2l0YXRpb25zlF2UKGgAjA5SZXZpZXdDaXRhdGlvbpSTlCmBlH2UKGgFfZQojAVpbmRleJRLAIwJcmVmZXJlbmNllIwfaHR0cHM6Ly9leGFtcGxlLmdvdi9yZXBvcnQtMjAyNJSMCWNpdGVkX3VybJSMH2h0dHBzOi8vZXhhbXBsZS5nb3YvcmVwb3J0LTIwMjSUjANkb2mUTowJc3RhdGVtZW50lIwiVW5lbXBsb3ltZW50IGZlbGwgdG8gNC4xJSBpbiAyMDI0LpSMBnF1b3Rlc5RdlIwIcG9zaXRpb26UTowGcmVzdWx0lGgAjBRSZXZpZXdDaXRhdGlvblJlc3VsdJSTlCmBlH2UKGgFfZQojAdmaW5kaW5nlIwJc3VwcG9ydGVklIwGc291cmNllIwHc3VwcG9ydJSMCGlzX2lzc3VllIl1aBl9lGgbj5QoaFdoW2hZkGgdTnVijAVjaGVja5RoAIwTUmV2aWV3Q2l0YXRpb25DaGVja5STlCmBlH2UKGgFfZQoaAmMCWNvbXBsZXRlZJSMCXBhZ2VfcmVhZJSMBGZ1bGyUjApwYWdlX3RpdGxllIwnUGFnZSBhdCBodHRwczovL2V4YW1wbGUuZ292L3JlcG9ydC0yMDI0lIwTcGFnZV9wdWJsaXNoZWRfZGF0ZZROjA1wYWdlX2xhbmd1YWdllIwCZW6UjApzb3VyY2VfdXJslIwfaHR0cHM6Ly9leGFtcGxlLmdvdi9yZXBvcnQtMjAyNJSMDnNvdXJjZV92ZXJzaW9ulE6MB3N1cHBvcnSUjAlzdXBwb3J0ZWSUjAdzbmlwcGV0lIwTVGhlIHNvdXJjZSBzYXlzIHNvLpSMCXJhdGlvbmFsZZSME1RoZSBwYWdlIHN0YXRlcyBpdC6UjAVxdW90ZZROjA1taXNzaW5nX3F1b3RllE6MDmRvaV9yZWdpc3RlcmVklE6MCG1ldGFkYXRhlE6MFG1ldGFkYXRhX2RpZmZlcmVuY2VzlF2UjApyZWdpc3RlcmVklE6MEHVuY2hlY2tlZF9yZWFzb26UTowEaGludJROjAdmYWlsdXJllE51aBl9lGgbj5QoaGlofGhzaGpobGh9aHdoeGgJaHZoeWh1aGdoe2hvaGVobmhxaH6QaB1OdWJ1aBl9lGgbj5QoaExoUWhHaF5oRmhLaE5oSWhQkGgdTnViaEIpgZR9lChoBX2UKGhGSwFoR4wdaHR0cHM6Ly9leGFtcGxlLm9yZy9zdGF0ZW1lbnSUaEmMHWh0dHBzOi8vZXhhbXBsZS5vcmcvc3RhdGVtZW50lGhLTmhMjCJUaGUgYWdlbmN5IHNhaWQgc28gaW4gYSBzdGF0ZW1lbnQulGhOXZRoUE5oUWhTKYGUfZQoaAV9lChoV4wJc3VwcG9ydGVklGhZjAdzdXBwb3J0lGhbiXVoGX2UaBuPlChoV2hbaFmQaB1OdWJoXmhgKYGUfZQoaAV9lChoCYwJY29tcGxldGVklGhljARmdWxslGhnjCVQYWdlIGF0IGh0dHBzOi8vZXhhbXBsZS5vcmcvc3RhdGVtZW50lGhpTmhqjAJlbpRobIwdaHR0cHM6Ly9leGFtcGxlLm9yZy9zdGF0ZW1lbnSUaG5OaG+MCXN1cHBvcnRlZJRocYwTVGhlIHNvdXJjZSBzYXlzIHNvLpRoc4wTVGhlIHBhZ2Ugc3RhdGVzIGl0LpRodU5odk5od05oeE5oeV2UaHtOaHxOaH1OaH5OdWgZfZRoG4+UKGhpaHxoc2hqaGxofWh3aHhoCWh2aHlodWhnaHtob2hlaG5ocWh+kGgdTnVidWgZfZRoG4+UKGhMaFFoR2heaEZoS2hOaEloUJBoHU51YmVoM12UjBFjaXRhdGlvbl9mYWlsdXJlc5RdlIwObW9yZV9jaXRhdGlvbnOUXZRofk51aBl9lIwIbGFuZ3VhZ2WUjAJlbpRzaBuPlChoC2g2aA9oDWinaAloEmg/aDNoomgRaAdoHmikaH6QaB1OdWIu", @@ -56,7 +56,7 @@ "extract__not_a_claim_beside_claims.json": "gASVHwIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwPRXh0cmFjdGVkQ2xhaW1zlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMBnN0YXR1c5SMC25vdF9hX2NsYWltlIwFY2xhaW2UjBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwRaWRlbnRpZmllZF9jbGFpbXOUXZSMEGNhbmRpZGF0ZV9jbGFpbXOUXZSMBmRvbWFpbpSMCUVjb25vbWljc5SMDGtleV9lbnRpdGllc5RdlChoAIwPRXh0cmFjdGVkRW50aXR5lJOUKYGUfZQoaAV9lCiMBG5hbWWUjAVBbHBoYZSMBHR5cGWUjAxvcmdhbml6YXRpb26UdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlChoGGgakIwUX19weWRhbnRpY19wcml2YXRlX1+UTnViaBQpgZR9lChoBX2UKGgYjARCZXRhlGgajAxvcmdhbml6YXRpb26UdWgcfZRoHo+UKGgYaBqQaCBOdWJljA9wcmVzdW1lZF9pbnRlbnSUjBdWZXJpZnkgcmVwb3J0ZWQgZmlndXJlc5SMDm9yaWdpbmFsX2lucHV0lIwWQWxwaGEgcm9zZSA1JSBpbiAyMDI0LpSMCWxvY2F0aW9uc5ROdWgcfZRoHo+UKGgRaAloDWgLaAdoD2gsaCpoKJBoIE51Yi4=", "extract__ready_one_claim.json": "gASVGQIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwPRXh0cmFjdGVkQ2xhaW1zlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMBnN0YXR1c5SMBXJlYWR5lIwFY2xhaW2UjBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwRaWRlbnRpZmllZF9jbGFpbXOUXZSMEGNhbmRpZGF0ZV9jbGFpbXOUXZSMBmRvbWFpbpSMCUVjb25vbWljc5SMDGtleV9lbnRpdGllc5RdlChoAIwPRXh0cmFjdGVkRW50aXR5lJOUKYGUfZQoaAV9lCiMBG5hbWWUjAVBbHBoYZSMBHR5cGWUjAxvcmdhbml6YXRpb26UdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlChoGGgakIwUX19weWRhbnRpY19wcml2YXRlX1+UTnViaBQpgZR9lChoBX2UKGgYjARCZXRhlGgajAxvcmdhbml6YXRpb26UdWgcfZRoHo+UKGgYaBqQaCBOdWJljA9wcmVzdW1lZF9pbnRlbnSUjBdWZXJpZnkgcmVwb3J0ZWQgZmlndXJlc5SMDm9yaWdpbmFsX2lucHV0lIwWQWxwaGEgcm9zZSA1JSBpbiAyMDI0LpSMCWxvY2F0aW9uc5ROdWgcfZRoHo+UKGgRaAloDWgLaAdoD2gsaCpoKJBoIE51Yi4=", "extract__ready_several_claims.json": "gASVZgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwPRXh0cmFjdGVkQ2xhaW1zlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMBnN0YXR1c5SMBXJlYWR5lIwFY2xhaW2UjBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwRaWRlbnRpZmllZF9jbGFpbXOUXZQojBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwXQmV0YSBmZWxsIDMlIGxhc3QgeWVhci6UZYwQY2FuZGlkYXRlX2NsYWltc5RdlIwGZG9tYWlulIwJRWNvbm9taWNzlIwMa2V5X2VudGl0aWVzlF2UKGgAjA9FeHRyYWN0ZWRFbnRpdHmUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUFscGhhlIwEdHlwZZSMDG9yZ2FuaXphdGlvbpR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgaaByQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWJoFimBlH2UKGgFfZQoaBqMBEJldGGUaByMDG9yZ2FuaXphdGlvbpR1aB59lGggj5QoaBpoHJBoIk51YmWMD3ByZXN1bWVkX2ludGVudJSMF1ZlcmlmeSByZXBvcnRlZCBmaWd1cmVzlIwOb3JpZ2luYWxfaW5wdXSUjC5BbHBoYSByb3NlIDUlIGluIDIwMjQuIEJldGEgZmVsbCAzJSBsYXN0IHllYXIulIwJbG9jYXRpb25zlE51aB59lGggj5QoaBNoCWgPaAtoB2gRaC5oLGgqkGgiTnViLg==", - "extract__replay_later_200.json": "gASVZgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwPRXh0cmFjdGVkQ2xhaW1zlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMBnN0YXR1c5SMBXJlYWR5lIwFY2xhaW2UjBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwRaWRlbnRpZmllZF9jbGFpbXOUXZQojBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwXQmV0YSBmZWxsIDMlIGxhc3QgeWVhci6UZYwQY2FuZGlkYXRlX2NsYWltc5RdlIwGZG9tYWlulIwJRWNvbm9taWNzlIwMa2V5X2VudGl0aWVzlF2UKGgAjA9FeHRyYWN0ZWRFbnRpdHmUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUFscGhhlIwEdHlwZZSMDG9yZ2FuaXphdGlvbpR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgaaByQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWJoFimBlH2UKGgFfZQoaBqMBEJldGGUaByMDG9yZ2FuaXphdGlvbpR1aB59lGggj5QoaBpoHJBoIk51YmWMD3ByZXN1bWVkX2ludGVudJSMF1ZlcmlmeSByZXBvcnRlZCBmaWd1cmVzlIwOb3JpZ2luYWxfaW5wdXSUjC5BbHBoYSByb3NlIDUlIGluIDIwMjQuIEJldGEgZmVsbCAzJSBsYXN0IHllYXIulIwJbG9jYXRpb25zlE51aB59lGggj5QoaBNoCWgPaAtoB2gRaC5oLGgqkGgiTnViLg==", + "extract__stored_replay_200.json": "gASVZgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwPRXh0cmFjdGVkQ2xhaW1zlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMBnN0YXR1c5SMBXJlYWR5lIwFY2xhaW2UjBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwRaWRlbnRpZmllZF9jbGFpbXOUXZQojBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwXQmV0YSBmZWxsIDMlIGxhc3QgeWVhci6UZYwQY2FuZGlkYXRlX2NsYWltc5RdlIwGZG9tYWlulIwJRWNvbm9taWNzlIwMa2V5X2VudGl0aWVzlF2UKGgAjA9FeHRyYWN0ZWRFbnRpdHmUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUFscGhhlIwEdHlwZZSMDG9yZ2FuaXphdGlvbpR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgaaByQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWJoFimBlH2UKGgFfZQoaBqMBEJldGGUaByMDG9yZ2FuaXphdGlvbpR1aB59lGggj5QoaBpoHJBoIk51YmWMD3ByZXN1bWVkX2ludGVudJSMF1ZlcmlmeSByZXBvcnRlZCBmaWd1cmVzlIwOb3JpZ2luYWxfaW5wdXSUjC5BbHBoYSByb3NlIDUlIGluIDIwMjQuIEJldGEgZmVsbCAzJSBsYXN0IHllYXIulIwJbG9jYXRpb25zlE51aB59lGggj5QoaBNoCWgPaAtoB2gRaC5oLGgqkGgiTnViLg==", "extract__url_input_located.json": "gASV4AIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwPRXh0cmFjdGVkQ2xhaW1zlJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMBnN0YXR1c5SMBXJlYWR5lIwFY2xhaW2UjBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwRaWRlbnRpZmllZF9jbGFpbXOUXZSMEGNhbmRpZGF0ZV9jbGFpbXOUXZSMBmRvbWFpbpSMCUVjb25vbWljc5SMDGtleV9lbnRpdGllc5RdlChoAIwPRXh0cmFjdGVkRW50aXR5lJOUKYGUfZQoaAV9lCiMBG5hbWWUjAVBbHBoYZSMBHR5cGWUjAxvcmdhbml6YXRpb26UdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlChoGGgakIwUX19weWRhbnRpY19wcml2YXRlX1+UTnViaBQpgZR9lChoBX2UKGgYjARCZXRhlGgajAxvcmdhbml6YXRpb26UdWgcfZRoHo+UKGgYaBqQaCBOdWJljA9wcmVzdW1lZF9pbnRlbnSUjBdWZXJpZnkgcmVwb3J0ZWQgZmlndXJlc5SMDm9yaWdpbmFsX2lucHV0lIweaHR0cHM6Ly9leGFtcGxlLmNvbS9hbi1hcnRpY2xllIwJbG9jYXRpb25zlF2UaACMDUNsYWltTG9jYXRpb26Uk5QpgZR9lChoBX2UKGgJjBZBbHBoYSByb3NlIDUlIGluIDIwMjQulIwJcG9zaXRpb25zlF2UaACMCFBvc2l0aW9ulJOUKYGUfZQoaAV9lCiMBXN0YXJ0lE6MA2VuZJROjAR0ZXh0lIwWQWxwaGEgcm9zZSA1JSBpbiAyMDI0LpR1aBx9lGgej5QoaDtoPGg9kGggTnViYXVoHH2UaB6PlChoCWg0kGggTnViYXVoHH2UaB6PlChoEWgJaA1oC2gHaA9oLGgqaCiQaCBOdWIu", "review__get_assessing.json": "gASVTgcAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKUmV2aWV3RnVsbJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAlyZXZpZXdfaWSUjAhkNmIyYmQ3MpSMBHZpZXeUjARmdWxslIwGc3RhdHVzlIwJYXNzZXNzaW5nlIwHb3V0Y29tZZROjApjcmVhdGVkX2F0lIwbMjAyNi0xMC0wMVQwOTowNzowMC4xMjM0NTZalIwMY29tcGxldGVkX2F0lIwbMjAyNi0xMC0wMVQxMDowNzowMC4xMjM0NTZalIwIbGFuZ3VhZ2WUjAJlbpSMBnBvbGljeZRoAIwQRXNjYWxhdGlvblBvbGljeZSTlCmBlH2UKGgFfZQojAh2ZXJkaWN0c5RdlCiMBUZhbHNllIwMTW9zdGx5IEZhbHNllIwFTWl4ZWSUZYwKY29uZmlkZW5jZZRdlIwDbG93lGGMD21heF9hc3Nlc3NtZW50c5RLFIwRbWF4X3ZlcmlmaWNhdGlvbnOUSwWMBWRlcHRolIwIc3RhbmRhcmSUjA1tYXhfY2l0YXRpb25zlEsAjA1zdWdnZXN0X2VkaXRzlIl1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgaaCRoH2gmaCdoI2gikIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAdzdW1tYXJ5lGgAjA1SZXZpZXdTdW1tYXJ5lJOUKYGUfZQoaAV9lCiMD2NsYWltc19zZWxlY3RlZJRLAowLY2xhaW1fbGltaXSUSxSME2NsYWltX2xpbWl0X3JlYWNoZWSUiYwPaW5wdXRfdHJ1bmNhdGVklImMC2Fzc2Vzc21lbnRzlGgAjBZSZXZpZXdBc3Nlc3NtZW50Q291bnRzlJOUKYGUfZQoaAV9lCiMCWNvbXBsZXRlZJRLAIwGZmFpbGVklEsAdWgofZRoKo+UKGg9aD6QaCxOdWKMDXZlcmlmaWNhdGlvbnOUTowGaXNzdWVzlEsAjA9jaXRhdGlvbnNfZm91bmSUTowSY2l0YXRpb25zX3NlbGVjdGVklE6MDmNpdGF0aW9uX2xpbWl0lE6MFmNpdGF0aW9uX2xpbWl0X3JlYWNoZWSUTowPY2l0YXRpb25fY2hlY2tzlE6MD2NpdGF0aW9uX2lzc3Vlc5RLAIwRY2l0YXRpb25zX3NraXBwZWSUTnVoKH2UaCqPlChoM2g3aDZoRGhBaEloRmhCaDRoRWhIaEdoQ2g1kGgsTnVijAdjcmVkaXRzlGgAjA1SZXZpZXdDcmVkaXRzlJOUKYGUfZQoaAV9lIwHY2hhcmdlZJRLAnNoKH2UaCqPlChoUpBoLE51YowScG9sbF9hZnRlcl9zZWNvbmRzlEsKaEJdlIwIZmFpbHVyZXOUXZRoSF2UjBFjaXRhdGlvbl9mYWlsdXJlc5RdlIwLbW9yZV9jbGFpbXOUXZSMFG1vcmVfY2xhaW1fbG9jYXRpb25zlF2UjA5tb3JlX2NpdGF0aW9uc5RdlIwHZmFpbHVyZZROjAZjbGFpbXOUXZQoaACMC1Jldmlld0NsYWltlJOUKYGUfZQoaAV9lCiMBWluZGV4lEsAjAVjbGFpbZSMKFRoZSBFVSBBSSBBY3QgdG9vayBlZmZlY3QgaW4gTWFyY2ggMjAyNC6UjAlwb3NpdGlvbnOUTowGcmVzdWx0lE6MCmFzc2Vzc21lbnSUaACMEFJldmlld0Fzc2Vzc21lbnSUk5QpgZR9lChoBX2UKGgLjAdydW5uaW5nlIwHdmVyZGljdJROaB9OjAlyYXRpb25hbGWUTowHZGlzc2VudJROjBB2ZXJpZmljYXRpb25fdXJslE6MCmVycm9yX2NvZGWUTowRaWRlbnRpZmllZF9jbGFpbXOUXZSMBGhpbnSUTowRc3VnZ2VzdGVkX3Jld3JpdGWUTmhiTnVoKH2UaCqPlChofWh4aHloH2gLaHZoe2h3aHpofmhikGgsTnVijAplc2NhbGF0aW9ulE6MDHZlcmlmaWNhdGlvbpROjA9zdWdnZXN0ZWRfZWRpdHOUTnVoKH2UaCqPlChoa2hvaIFoamiCaINobmhtkGgsTnViaGYpgZR9lChoBX2UKGhqSwFoa4wiV2F0ZXIgYm9pbHMgYXQgMTAwIEMgYXQgc2VhIGxldmVsLpRobU5obk5ob2hxKYGUfZQoaAV9lChoC4wHcnVubmluZ5Rodk5oH05od05oeE5oeU5oek5oe12UaH1OaH5OaGJOdWgofZRoKo+UKGh9aHhoeWgfaAtodmh7aHdoemh+aGKQaCxOdWJogU5ogk5og051aCh9lGgqj5QoaGtob2iBaGpogmiDaG5obZBoLE51YmWMCWNpdGF0aW9uc5RdlHVoKH2UaCqPlChoXGhXaBBoC2gUaFpoVWgtaGBoTGgOaEhoY2gNaF5oEmgJaJNoQmgHaGKQaCxOdWIu", "review__get_assessment_rows_full_fields.json": "gASV+QwAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKUmV2aWV3RnVsbJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAlyZXZpZXdfaWSUjAhkNmIyYmQ3MpSMBHZpZXeUjARmdWxslIwGc3RhdHVzlIwJY29tcGxldGVklIwHb3V0Y29tZZSMCmluY29tcGxldGWUjApjcmVhdGVkX2F0lIwbMjAyNi0xMC0wMVQwOTowNzowMC4xMjM0NTZalIwMY29tcGxldGVkX2F0lIwbMjAyNi0xMC0wMVQxMDowNzowMC4xMjM0NTZalIwIbGFuZ3VhZ2WUjAJlbpSMBnBvbGljeZRoAIwQRXNjYWxhdGlvblBvbGljeZSTlCmBlH2UKGgFfZQojAh2ZXJkaWN0c5RdlCiMBUZhbHNllIwMTW9zdGx5IEZhbHNllIwFTWl4ZWSUZYwKY29uZmlkZW5jZZRdlIwDbG93lGGMD21heF9hc3Nlc3NtZW50c5RLFIwRbWF4X3ZlcmlmaWNhdGlvbnOUSwCMBWRlcHRolIwIc3RhbmRhcmSUjA1tYXhfY2l0YXRpb25zlEsAjA1zdWdnZXN0X2VkaXRzlIl1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgbaCVoIGgnaChoJGgjkIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAdzdW1tYXJ5lGgAjA1SZXZpZXdTdW1tYXJ5lJOUKYGUfZQoaAV9lCiMD2NsYWltc19zZWxlY3RlZJRLAowLY2xhaW1fbGltaXSUSxSME2NsYWltX2xpbWl0X3JlYWNoZWSUiYwPaW5wdXRfdHJ1bmNhdGVklImMC2Fzc2Vzc21lbnRzlGgAjBZSZXZpZXdBc3Nlc3NtZW50Q291bnRzlJOUKYGUfZQoaAV9lCiMCWNvbXBsZXRlZJRLAYwGZmFpbGVklEsBdWgpfZRoK4+UKGg+aD+QaC1OdWKMDXZlcmlmaWNhdGlvbnOUaACMGFJldmlld1ZlcmlmaWNhdGlvbkNvdW50c5STlCmBlH2UKGgFfZQojAdwbGFubmVklEsAaD5LAGg/SwB1aCl9lGgrj5QoaD5oP2hIkGgtTnVijAZpc3N1ZXOUSwGMD2NpdGF0aW9uc19mb3VuZJROjBJjaXRhdGlvbnNfc2VsZWN0ZWSUTowOY2l0YXRpb25fbGltaXSUTowWY2l0YXRpb25fbGltaXRfcmVhY2hlZJROjA9jaXRhdGlvbl9jaGVja3OUTowPY2l0YXRpb25faXNzdWVzlEsAjBFjaXRhdGlvbnNfc2tpcHBlZJROdWgpfZRoK4+UKGg0aDhoN2hNaEJoUmhPaEtoNWhOaFFoUGhMaDaQaC1OdWKMB2NyZWRpdHOUaACMDVJldmlld0NyZWRpdHOUk5QpgZR9lChoBX2UjAdjaGFyZ2VklEsCc2gpfZRoK4+UKGhbkGgtTnVijBJwb2xsX2FmdGVyX3NlY29uZHOUTmhLXZRoAIwLUmV2aWV3SXNzdWWUk5QpgZR9lChoBX2UKIwLY2xhaW1faW5kZXiUSwCMBWNsYWltlIwoVGhlIEVVIEFJIEFjdCB0b29rIGVmZmVjdCBpbiBNYXJjaCAyMDI0LpSMDnZlcmlmaWVkX2NsYWltlE6MB3ZlcmRpY3SUjAVGYWxzZZRoIIwEaGlnaJSMBnNvdXJjZZSMCmFzc2Vzc21lbnSUjA92ZXJpZmljYXRpb25faWSUTowTdmVyaWZpY2F0aW9uX3N0YXR1c5ROjBB2ZXJpZmljYXRpb25fdXJslE6MA3VybJROjAplc2NhbGF0aW9ulGgAjApFc2NhbGF0aW9ulJOUKYGUfZQoaAV9lCiMDW1hdGNoZWRfcnVsZXOUXZSMB3ZlcmRpY3SUYYwLZGlzcG9zaXRpb26UjANjYXCUdWgpfZRoK4+UKGh7aHiQaC1OdWKMC2tleV9maW5kaW5nlE6MCXJhdGlvbmFsZZSMI1R3byByZXZpZXdlcnMgZm91bmQgdGhlIGRhdGUgd3JvbmculIwRc3VnZ2VzdGVkX3Jld3JpdGWUjDBUaGUgRVUgQUkgQWN0IGVudGVyZWQgaW50byBmb3JjZSBpbiBBdWd1c3QgMjAyNC6UjAdmYWlsdXJllE6MD3N1Z2dlc3RlZF9lZGl0c5ROdWgpfZRoK4+UKGhmaG5ocGggaGlob2hyaIBohWhxaGxoaGhlaH9ogmiEkGgtTnViYYwIZmFpbHVyZXOUXZRoAIwNUmV2aWV3RmFpbHVyZZSTlCmBlH2UKGgFfZQoaGVLAWhmjB5UaGUgRWlmZmVsIFRvd2VyIGlzIGluIEJlcmxpbi6UjAVzdGFnZZSMCmFzc2Vzc21lbnSUaIRoAIwMRmFpbHVyZUJsb2NrlJOUKYGUfZQoaAV9lCiMDmZhaWx1cmVfcmVhc29ulIwHdGltZW91dJSMDWZhaWx1cmVfY2xhc3OUjBR1cHN0cmVhbV91bmF2YWlsYWJsZZSMCXJldHJ5YWJsZZSIjARoaW50lIw5VGhlIGNoZWNrIHJhbiBvdXQgb2YgdGltZS4gUmV0cnkgaXQ7IG5vdGhpbmcgd2FzIGNoYXJnZWQulIwIZG9jc191cmyUTnVoKX2UaCuPlChonGiXaJ5omWibkGgtTnVidWgpfZRoK4+UKGhmaGVokGiEkGgtTnViYWhRXZSMEWNpdGF0aW9uX2ZhaWx1cmVzlF2UjAttb3JlX2NsYWltc5RdlIwUbW9yZV9jbGFpbV9sb2NhdGlvbnOUXZSMDm1vcmVfY2l0YXRpb25zlF2UaIROjAZjbGFpbXOUXZQoaACMC1Jldmlld0NsYWltlJOUKYGUfZQoaAV9lCiMBWluZGV4lEsAaGaMKFRoZSBFVSBBSSBBY3QgdG9vayBlZmZlY3QgaW4gTWFyY2ggMjAyNC6UjAlwb3NpdGlvbnOUTowGcmVzdWx0lGgAjAxSZXZpZXdSZXN1bHSUk5QpgZR9lChoBX2UKGhpjAVGYWxzZZRoIIwEaGlnaJRobIwKYXNzZXNzbWVudJSMCGlzX2lzc3VllIh1aCl9lGgrj5QoaCBoaWhsaL+QaC1OdWKMCmFzc2Vzc21lbnSUaACMEFJldmlld0Fzc2Vzc21lbnSUk5QpgZR9lChoBX2UKGgLjAljb21wbGV0ZWSUaGmMBUZhbHNllGggjARoaWdolGiAjCNUd28gcmV2aWV3ZXJzIGZvdW5kIHRoZSBkYXRlIHdyb25nLpSMB2Rpc3NlbnSUjDpPbmUgcmV2aWV3ZXIgcmVhZCB0aGUgY2xhaW0gYXMgYWJvdXQgdGhlIGVudHJ5IGludG8gZm9yY2UulGhwTowKZXJyb3JfY29kZZROjBFpZGVudGlmaWVkX2NsYWltc5RdlCiMIlRoZSBFVSBBSSBBY3QgdG9vayBlZmZlY3QgaW4gMjAyNC6UjBhJdCB0b29rIGVmZmVjdCBpbiBNYXJjaC6UZWicjCRUaGlzIHRleHQgaG9sZHMgbW9yZSB0aGFuIG9uZSBjbGFpbS6UaIKMMFRoZSBFVSBBSSBBY3QgZW50ZXJlZCBpbnRvIGZvcmNlIGluIEF1Z3VzdCAyMDI0LpRohE51aCl9lGgrj5QoaJxozGhwaCBoC2hpaM9ogGjOaIJohJBoLU51YmhyaHQpgZR9lChoBX2UKGh4XZSMB3ZlcmRpY3SUYWh7jANjYXCUdWgpfZRoK4+UKGh7aHiQaC1OdWKMDHZlcmlmaWNhdGlvbpROaIVOdWgpfZRoK4+UKGhmaMJocmizaN9ohWi2aLWQaC1OdWJorymBlH2UKGgFfZQoaLNLAWhmjB5UaGUgRWlmZmVsIFRvd2VyIGlzIGluIEJlcmxpbi6UaLVOaLZOaMJoxCmBlH2UKGgFfZQoaAuMBmZhaWxlZJRoaU5oIE5ogE5ozE5ocE5ozowHdGltZW91dJRoz12UaJxOaIJOaIRokymBlH2UKGgFfZQoaJeMB3RpbWVvdXSUaJmMFHVwc3RyZWFtX3VuYXZhaWxhYmxllGibiGicjDlUaGUgY2hlY2sgcmFuIG91dCBvZiB0aW1lLiBSZXRyeSBpdDsgbm90aGluZyB3YXMgY2hhcmdlZC6UaJ5OdWgpfZRoK4+UKGicaJdonmiZaJuQaC1OdWJ1aCl9lGgrj5QoaJxozGhwaCBoC2hpaM9ogGjOaIJohJBoLU51YmhyTmjfTmiFTnVoKX2UaCuPlChoZmjCaHJos2jfaIVotmi1kGgtTnViZYwJY2l0YXRpb25zlF2UdWgpfZRoK4+UKGimaIhoEWgLaBVopGheaC5oqmhVaA9oUWisaA1oqGgTaAlo+GhLaAdohJBoLU51Yi4=", @@ -101,7 +101,7 @@ "review__receipt_202.json": "gASVtQAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNUmV2aWV3U3RhcnRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAlyZXZpZXdfaWSUjAhkNmIyYmQ3MpSMBnN0YXR1c5SMBnF1ZXVlZJR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgJaAeQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", "review__receipt_202_empty_webhook_url.json": "gASVtQAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNUmV2aWV3U3RhcnRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAlyZXZpZXdfaWSUjAhkNmIyYmQ3MpSMBnN0YXR1c5SMBnF1ZXVlZJR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgJaAeQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", "review__receipt_202_webhook_url.json": "gASVtQAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNUmV2aWV3U3RhcnRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAlyZXZpZXdfaWSUjAhkNmIyYmQ3MpSMBnN0YXR1c5SMBnF1ZXVlZJR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgJaAeQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", - "review__replay_later_202.json": "gASVtQAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNUmV2aWV3U3RhcnRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAlyZXZpZXdfaWSUjAhkNmIyYmQ3MpSMBnN0YXR1c5SMBnF1ZXVlZJR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgJaAeQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", + "review__stored_replay_202.json": "gASVtQAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNUmV2aWV3U3RhcnRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAlyZXZpZXdfaWSUjAhkNmIyYmQ3MpSMBnN0YXR1c5SMBnF1ZXVlZJR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgJaAeQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", "verify__batch_202.json": "gASVpAEAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNQmF0Y2hBY2NlcHRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAhiYXRjaF9pZJSMEDdlYTljZGJkNDA2NGUzY2aUjAVpdGVtc5RdlChoAIwMVGFza0FjY2VwdGVklJOUKYGUfZQoaAV9lCiMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMCmNsYWltX3RleHSUjBNUaGUgRWFydGggaXMgcm91bmQulHWMEl9fcHlkYW50aWNfZXh0cmFfX5R9lIwXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaBJoEJCMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmgMKYGUfZQoaAV9lChoEIwgMGI5MmI0NDNlYjY5YjljNzE0MzRmZGM2NGYyY2E4YmWUaBKMIVdhdGVyIGJvaWxzIGF0IDEwMEMgYXQgc2VhIGxldmVsLpR1aBR9lGgWj5QoaBJoEJBoGE51YmV1aBR9lGgWj5QoaAdoCZBoGE51Yi4=", "verify__batch_partial_202.json": "gASVPgEAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNQmF0Y2hBY2NlcHRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAhiYXRjaF9pZJSMEDdlYTljZGJkNDA2NGUzY2aUjAVpdGVtc5RdlGgAjAxUYXNrQWNjZXB0ZWSUk5QpgZR9lChoBX2UKIwHdGFza19pZJSMIDdjZmYxOGRhMmQ5NzlkNjM3MGU1NGExNjBiZTEyMWFklIwKY2xhaW1fdGV4dJSMDEZpcnN0IGNsYWltLpR1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgSaBCQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWJhdWgUfZSMB3BhcnRpYWyUiHNoFo+UKGgHaBpoCZBoGE51Yi4=", "verify__delete_200.json": "gASVrAAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwMVGFza0FjY2VwdGVklJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMB3Rhc2tfaWSUjACUjApjbGFpbV90ZXh0lGgIdYwSX19weWRhbnRpY19leHRyYV9flH2UjAJva5SIc4wXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaAyQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", @@ -114,9 +114,8 @@ "verify__list_clamped_200.json": "gASVvgAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwQVmVyaWZpY2F0aW9uTGlzdJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAVpdGVtc5RdlIwFdG90YWyUSwCMBHBhZ2WUSwGMCXBhZ2Vfc2l6ZZRLZHWMEl9fcHlkYW50aWNfZXh0cmFfX5R9lIwXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaApoC2gJaAeQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", "verify__list_empty_200.json": "gASVvgAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwQVmVyaWZpY2F0aW9uTGlzdJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAVpdGVtc5RdlIwFdG90YWyUSwCMBHBhZ2WUSwGMCXBhZ2Vfc2l6ZZRLFHWMEl9fcHlkYW50aWNfZXh0cmFfX5R9lIwXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaApoC2gJaAeQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWIu", "verify__list_page2_200.json": "gASVbgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwQVmVyaWZpY2F0aW9uTGlzdJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAVpdGVtc5RdlGgAjBRWZXJpZmljYXRpb25MaXN0SXRlbZSTlCmBlH2UKGgFfZQojA92ZXJpZmljYXRpb25faWSUjAg2OGNhNGFhZpSMBWNsYWltlIwTVGhlIEVhcnRoIGlzIHJvdW5kLpSMBmRvbWFpbpSMB1NjaWVuY2WUjAhlbnRpdGllc5RdlIwHdmVyZGljdJSMBFRydWWUjApjb25maWRlbmNllIwEaGlnaJSMCmxlbnpfc2NvcmWUSwmMC2tleV9maW5kaW5nlIwuVGhlIEVhcnRoIGlzIGFwcHJveGltYXRlbHkgc3BoZXJpY2FsIGluIHNoYXBlLpSMEWV4ZWN1dGl2ZV9zdW1tYXJ5lIwWVGhlIGNsYWltIGlzIHZlcmlmaWVkLpSMEXN1Z2dlc3RlZF9yZXdyaXRllE6MCmNyZWF0ZWRfYXSUjCAyMDI2LTEwLTAxVDA5OjA3OjAwLjEyMzQ1NiswMDowMJSMC21vZGlmaWVkX2F0lE6MCGxhbmd1YWdllIwCZW6UdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlChoEGgOaBRoGGggaBJoFmgjaCJoHWgaaBtoH5CMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmGMBXRvdGFslEsDjARwYWdllEsCjAlwYWdlX3NpemWUSwJ1aCV9lGgnj5QoaCtoLGgqaAeQaClOdWIu", - "verify__replay_later_202.json": "gASV/AAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwMVGFza0FjY2VwdGVklJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMCmNsYWltX3RleHSUjACUdYwSX19weWRhbnRpY19leHRyYV9flH2UKIwGc3RhdHVzlIwGcXVldWVklIwIY2hhaW5faWSUjBA5OWEwYTljYTg5ZTFlNzJjlHWMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgPaA1oB5CMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51Yi4=", "verify__select_202.json": "gASVpAEAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwNQmF0Y2hBY2NlcHRlZJSTlCmBlH2UKIwIX19kaWN0X1+UfZQojAhiYXRjaF9pZJSMEDdlYTljZGJkNDA2NGUzY2aUjAVpdGVtc5RdlChoAIwMVGFza0FjY2VwdGVklJOUKYGUfZQoaAV9lCiMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMCmNsYWltX3RleHSUjBNUaGUgRWFydGggaXMgcm91bmQulHWMEl9fcHlkYW50aWNfZXh0cmFfX5R9lIwXX19weWRhbnRpY19maWVsZHNfc2V0X1+Uj5QoaBJoEJCMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51YmgMKYGUfZQoaAV9lChoEIwgMGI5MmI0NDNlYjY5YjljNzE0MzRmZGM2NGYyY2E4YmWUaBKMIVdhdGVyIGJvaWxzIGF0IDEwMEMgYXQgc2VhIGxldmVsLpR1aBR9lGgWj5QoaBJoEJBoGE51YmV1aBR9lGgWj5QoaAdoCZBoGE51Yi4=", - "verify__status_cancelled_after_a_while.json": "gASVNgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjApDYW5jZWxsZWQulIwOZmFpbHVyZV9yZWFzb26UjAljYW5jZWxsZWSUjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5SMCWNhbmNlbGxlZJSMCXJldHJ5YWJsZZSJjAhkb2NzX3VybJSMJWh0dHBzOi8vbGVuei5pby9kb2NzL2Vycm9ycyNjYW5jZWxsZWSUdWgZfZRoG4+UKGgJaAdoJ2gtaCpoLGglkGgdTnViLg==", + "verify__status_cancelled_durable.json": "gASVNgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjApDYW5jZWxsZWQulIwOZmFpbHVyZV9yZWFzb26UjAljYW5jZWxsZWSUjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5SMCWNhbmNlbGxlZJSMCXJldHJ5YWJsZZSJjAhkb2NzX3VybJSMJWh0dHBzOi8vbGVuei5pby9kb2NzL2Vycm9ycyNjYW5jZWxsZWSUdWgZfZRoG4+UKGgJaAdoJ2gtaCpoLGglkGgdTnViLg==", "verify__status_completed.json": "gASV5wkAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAljb21wbGV0ZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUaACMDFZlcmlmaWNhdGlvbpSTlCmBlH2UKGgFfZQojA92ZXJpZmljYXRpb25faWSUjAg2OGNhNGFhZpSMBWNsYWltlIwTVGhlIEVhcnRoIGlzIHJvdW5kLpSMCnZpc2liaWxpdHmUjAdwcml2YXRllIwFZGVwdGiUjAhzdGFuZGFyZJSMBmRvbWFpbpSMB1NjaWVuY2WUjAhlbnRpdGllc5RdlGgAjAlFbnRpdHlSZWaUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUVhcnRolIwDcWlklE51aBl9lGgbj5QoaDVoN5BoHU51YmGMD3ByZXN1bWVkX2ludGVudJSMHlZlcmlmeSBhIGJhc2ljIHNjaWVudGlmaWMgZmFjdJSMB3ZlcmRpY3SUjARUcnVllIwKY29uZmlkZW5jZZSMBGhpZ2iUjApsZW56X3Njb3JllEsJjAtrZXlfZmluZGluZ5SMLlRoZSBFYXJ0aCBpcyBhcHByb3hpbWF0ZWx5IHNwaGVyaWNhbCBpbiBzaGFwZS6UjBFleGVjdXRpdmVfc3VtbWFyeZSMFlRoZSBjbGFpbSBpcyB2ZXJpZmllZC6UjAh3YXJuaW5nc5RdlIwZUmVsaWVzIG9uIGxpbWl0ZWQgc291cmNlc5RhjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAdzb3VyY2VzlF2UKGgAjAZTb3VyY2WUk5QpgZR9lChoBX2UKIwLc291cmNlX25hbWWUjAROQVNBlIwFdGl0bGWUjBZUaGUgc2hhcGUgb2YgdGhlIEVhcnRolIwDdXJslIwaaHR0cHM6Ly9uYXNhLmV4YW1wbGUvc2hhcGWUjAdzbmlwcGV0lIwrU2F0ZWxsaXRlIGltYWdlcnkgc2hvd3MgYW4gb2JsYXRlIHNwaGVyb2lkLpSMBGRhdGWUjAoyMDI1LTAxLTE1lHVoGX2UaBuPlChoUGhYaFRoUmhWkGgdTnViaEwpgZR9lChoBX2UKGhQjANFU0GUaFKMEEVhcnRoIGZyb20gc3BhY2WUaFSMGWh0dHBzOi8vZXNhLmV4YW1wbGUvZWFydGiUaFaMK09yYml0YWwgbWVhc3VyZW1lbnRzIGNvbmZpcm0gdGhlIGN1cnZhdHVyZS6UaFiMCjIwMjUtMDEtMTaUdWgZfZRoG4+UKGhQaFhoVGhSaFaQaB1OdWJljAVhdWRpdJRoAIwFQXVkaXSUk5QpgZR9lChoBX2UKIwUYWRqdWRpY2F0aW9uX3N1bW1hcnmUjBZCb3RoIHJldmlld2VycyBhZ3JlZWQulIwLYXNzZXNzbWVudHOUXZQoaACMCkFzc2Vzc21lbnSUk5QpgZR9lChoBX2UKIwNcGFuZWxpc3RfbmFtZZSMClJldmlld2VyIEGUjApmb2N1c19hcmVhlIwnQ2xhaW0gUHJlY2lzaW9uICYgUXVhbnRpdGF0aXZlIEFjY3VyYWN5lIwFc2NvcmWUR0AiAAAAAAAAjAlyZWFzb25pbmeUjB1QcmVjaXNpb24gYW5hbHlzaXMgcmVhc29uaW5nLpRoRV2UdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJocSmBlH2UKGgFfZQoaHWMClJldmlld2VyIEKUaHeMB1NvdXJjZXOUaHlHQCIAAAAAAABoeowXU291cmNlIGF1ZGl0IHJlYXNvbmluZy6UaEVdlIwNd2VhayBzb3VyY2UgQZRhdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJljApkZWJhdGVfcHJvlGgAjApEZWJhdGVTaWRllJOUKYGUfZQoaAV9lCiMBHJvbGWUjAhBZHZvY2F0ZZSMCGFyZ3VtZW50lIwkU2F0ZWxsaXRlIGltYWdlcnkgYW5kIGdlb2Rlc3kgYWdyZWUulIwIcmVidXR0YWyUjCJGbGF0LUVhcnRoIG9iamVjdGlvbnMgZG8gbm90IGhvbGQulHVoGX2UaBuPlChokWiTaI+QaB1OdWKMCmRlYmF0ZV9jb26UaIspgZR9lChoBX2UKGiPjAdTa2VwdGljlGiRjCJUaGUgRWFydGggaXMgbm90IGEgcGVyZmVjdCBzcGhlcmUulGiTjCBPYmxhdGUsIGJ1dCByb3VuZCBmb3IgdGhlIGNsYWltLpR1aBl9lGgbj5QoaJFok2iPkGgdTnVijA9wYW5lbF9hZ3JlZW1lbnSUjAl1bmFuaW1vdXOUdWgZfZRoG4+UKGhuaJdobGigaImQaB1OdWKMCmNyZWF0ZWRfYXSUjCAyMDI2LTEwLTAxVDA5OjA3OjAwLjEyMzQ1NiswMDowMJSMC21vZGlmaWVkX2F0lE6MCGxhbmd1YWdllIwCZW6UjAhjb3ZlcmFnZZRoAIwIQ292ZXJhZ2WUk5QpgZR9lChoBX2UKGgHjAl1bmNvdmVyZWSUjAdyZWFzb25zlF2UjARwbGFulGGMDmNlcnRpZmljYXRlX2lklE6MD2NlcnRpZmljYXRlX3VybJROjAVhc19vZpROjAhjdXJyZW5jeZSMA0VVUpSMA2NhcJRNECeMCWFnZ3JlZ2F0ZZRKIKEHAIwNdGVybXNfdmVyc2lvbpSMAnYxlHVoGX2UaBuPlChouWi0aLhotWgHaLZoumizaLCQaB1OdWJ1aBl9lGgbj5QoaCRoLmg+aKZoQ2goaCxoQWgmaCpopGg8aDpoSGhFaKdoqWhJaGZoQJBoHU51YowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUaAyMDmZhaWx1cmVfcmVhc29ulGgMjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5RoDIwJcmV0cnlhYmxllE6MCGRvY3NfdXJslGgMdWgZfZRoG4+UKGgeaAdoCZBoHU51Yi4=", "verify__status_completed_after_a_while.json": "gASV5wkAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAljb21wbGV0ZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUaACMDFZlcmlmaWNhdGlvbpSTlCmBlH2UKGgFfZQojA92ZXJpZmljYXRpb25faWSUjAg2OGNhNGFhZpSMBWNsYWltlIwTVGhlIEVhcnRoIGlzIHJvdW5kLpSMCnZpc2liaWxpdHmUjAdwcml2YXRllIwFZGVwdGiUjAhzdGFuZGFyZJSMBmRvbWFpbpSMB1NjaWVuY2WUjAhlbnRpdGllc5RdlGgAjAlFbnRpdHlSZWaUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUVhcnRolIwDcWlklE51aBl9lGgbj5QoaDVoN5BoHU51YmGMD3ByZXN1bWVkX2ludGVudJSMHlZlcmlmeSBhIGJhc2ljIHNjaWVudGlmaWMgZmFjdJSMB3ZlcmRpY3SUjARUcnVllIwKY29uZmlkZW5jZZSMBGhpZ2iUjApsZW56X3Njb3JllEsJjAtrZXlfZmluZGluZ5SMLlRoZSBFYXJ0aCBpcyBhcHByb3hpbWF0ZWx5IHNwaGVyaWNhbCBpbiBzaGFwZS6UjBFleGVjdXRpdmVfc3VtbWFyeZSMFlRoZSBjbGFpbSBpcyB2ZXJpZmllZC6UjAh3YXJuaW5nc5RdlIwZUmVsaWVzIG9uIGxpbWl0ZWQgc291cmNlc5RhjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAdzb3VyY2VzlF2UKGgAjAZTb3VyY2WUk5QpgZR9lChoBX2UKIwLc291cmNlX25hbWWUjAROQVNBlIwFdGl0bGWUjBZUaGUgc2hhcGUgb2YgdGhlIEVhcnRolIwDdXJslIwaaHR0cHM6Ly9uYXNhLmV4YW1wbGUvc2hhcGWUjAdzbmlwcGV0lIwrU2F0ZWxsaXRlIGltYWdlcnkgc2hvd3MgYW4gb2JsYXRlIHNwaGVyb2lkLpSMBGRhdGWUjAoyMDI1LTAxLTE1lHVoGX2UaBuPlChoUGhYaFRoUmhWkGgdTnViaEwpgZR9lChoBX2UKGhQjANFU0GUaFKMEEVhcnRoIGZyb20gc3BhY2WUaFSMGWh0dHBzOi8vZXNhLmV4YW1wbGUvZWFydGiUaFaMK09yYml0YWwgbWVhc3VyZW1lbnRzIGNvbmZpcm0gdGhlIGN1cnZhdHVyZS6UaFiMCjIwMjUtMDEtMTaUdWgZfZRoG4+UKGhQaFhoVGhSaFaQaB1OdWJljAVhdWRpdJRoAIwFQXVkaXSUk5QpgZR9lChoBX2UKIwUYWRqdWRpY2F0aW9uX3N1bW1hcnmUjBZCb3RoIHJldmlld2VycyBhZ3JlZWQulIwLYXNzZXNzbWVudHOUXZQoaACMCkFzc2Vzc21lbnSUk5QpgZR9lChoBX2UKIwNcGFuZWxpc3RfbmFtZZSMClJldmlld2VyIEGUjApmb2N1c19hcmVhlIwnQ2xhaW0gUHJlY2lzaW9uICYgUXVhbnRpdGF0aXZlIEFjY3VyYWN5lIwFc2NvcmWUR0AiAAAAAAAAjAlyZWFzb25pbmeUjB1QcmVjaXNpb24gYW5hbHlzaXMgcmVhc29uaW5nLpRoRV2UdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJocSmBlH2UKGgFfZQoaHWMClJldmlld2VyIEKUaHeMB1NvdXJjZXOUaHlHQCIAAAAAAABoeowXU291cmNlIGF1ZGl0IHJlYXNvbmluZy6UaEVdlIwNd2VhayBzb3VyY2UgQZRhdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJljApkZWJhdGVfcHJvlGgAjApEZWJhdGVTaWRllJOUKYGUfZQoaAV9lCiMBHJvbGWUjAhBZHZvY2F0ZZSMCGFyZ3VtZW50lIwkU2F0ZWxsaXRlIGltYWdlcnkgYW5kIGdlb2Rlc3kgYWdyZWUulIwIcmVidXR0YWyUjCJGbGF0LUVhcnRoIG9iamVjdGlvbnMgZG8gbm90IGhvbGQulHVoGX2UaBuPlChokWiTaI+QaB1OdWKMCmRlYmF0ZV9jb26UaIspgZR9lChoBX2UKGiPjAdTa2VwdGljlGiRjCJUaGUgRWFydGggaXMgbm90IGEgcGVyZmVjdCBzcGhlcmUulGiTjCBPYmxhdGUsIGJ1dCByb3VuZCBmb3IgdGhlIGNsYWltLpR1aBl9lGgbj5QoaJFok2iPkGgdTnVijA9wYW5lbF9hZ3JlZW1lbnSUjAl1bmFuaW1vdXOUdWgZfZRoG4+UKGhuaJdobGigaImQaB1OdWKMCmNyZWF0ZWRfYXSUjCAyMDI2LTEwLTAxVDA5OjA3OjAwLjEyMzQ1NiswMDowMJSMC21vZGlmaWVkX2F0lE6MCGxhbmd1YWdllIwCZW6UjAhjb3ZlcmFnZZRoAIwIQ292ZXJhZ2WUk5QpgZR9lChoBX2UKGgHjAl1bmNvdmVyZWSUjAdyZWFzb25zlF2UjARwbGFulGGMDmNlcnRpZmljYXRlX2lklE6MD2NlcnRpZmljYXRlX3VybJROjAVhc19vZpROjAhjdXJyZW5jeZSMA0VVUpSMA2NhcJRNECeMCWFnZ3JlZ2F0ZZRKIKEHAIwNdGVybXNfdmVyc2lvbpSMAnYxlHVoGX2UaBuPlChouWi0aLhotWgHaLZoumizaLCQaB1OdWJ1aBl9lGgbj5QoaCRoLmg+aKZoQ2goaCxoQWgmaCpopGg8aDpoSGhFaKdoqWhJaGZoQJBoHU51YowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUaAyMDmZhaWx1cmVfcmVhc29ulGgMjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5RoDIwJcmV0cnlhYmxllE6MCGRvY3NfdXJslGgMdWgZfZRoG4+UKGgeaAdoCZBoHU51Yi4=", "verify__status_completed_after_a_while_modified_at_crosses_midnight_by_minutes.json": "gASVCQoAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAljb21wbGV0ZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUaACMDFZlcmlmaWNhdGlvbpSTlCmBlH2UKGgFfZQojA92ZXJpZmljYXRpb25faWSUjAg2OGNhNGFhZpSMBWNsYWltlIwTVGhlIEVhcnRoIGlzIHJvdW5kLpSMCnZpc2liaWxpdHmUjAdwcml2YXRllIwFZGVwdGiUjAhzdGFuZGFyZJSMBmRvbWFpbpSMB1NjaWVuY2WUjAhlbnRpdGllc5RdlGgAjAlFbnRpdHlSZWaUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUVhcnRolIwDcWlklE51aBl9lGgbj5QoaDVoN5BoHU51YmGMD3ByZXN1bWVkX2ludGVudJSMHlZlcmlmeSBhIGJhc2ljIHNjaWVudGlmaWMgZmFjdJSMB3ZlcmRpY3SUjARUcnVllIwKY29uZmlkZW5jZZSMBGhpZ2iUjApsZW56X3Njb3JllEsJjAtrZXlfZmluZGluZ5SMLlRoZSBFYXJ0aCBpcyBhcHByb3hpbWF0ZWx5IHNwaGVyaWNhbCBpbiBzaGFwZS6UjBFleGVjdXRpdmVfc3VtbWFyeZSMFlRoZSBjbGFpbSBpcyB2ZXJpZmllZC6UjAh3YXJuaW5nc5RdlIwZUmVsaWVzIG9uIGxpbWl0ZWQgc291cmNlc5RhjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAdzb3VyY2VzlF2UKGgAjAZTb3VyY2WUk5QpgZR9lChoBX2UKIwLc291cmNlX25hbWWUjAROQVNBlIwFdGl0bGWUjBZUaGUgc2hhcGUgb2YgdGhlIEVhcnRolIwDdXJslIwaaHR0cHM6Ly9uYXNhLmV4YW1wbGUvc2hhcGWUjAdzbmlwcGV0lIwrU2F0ZWxsaXRlIGltYWdlcnkgc2hvd3MgYW4gb2JsYXRlIHNwaGVyb2lkLpSMBGRhdGWUjAoyMDI1LTAxLTE1lHVoGX2UaBuPlChoUGhYaFRoUmhWkGgdTnViaEwpgZR9lChoBX2UKGhQjANFU0GUaFKMEEVhcnRoIGZyb20gc3BhY2WUaFSMGWh0dHBzOi8vZXNhLmV4YW1wbGUvZWFydGiUaFaMK09yYml0YWwgbWVhc3VyZW1lbnRzIGNvbmZpcm0gdGhlIGN1cnZhdHVyZS6UaFiMCjIwMjUtMDEtMTaUdWgZfZRoG4+UKGhQaFhoVGhSaFaQaB1OdWJljAVhdWRpdJRoAIwFQXVkaXSUk5QpgZR9lChoBX2UKIwUYWRqdWRpY2F0aW9uX3N1bW1hcnmUjBZCb3RoIHJldmlld2VycyBhZ3JlZWQulIwLYXNzZXNzbWVudHOUXZQoaACMCkFzc2Vzc21lbnSUk5QpgZR9lChoBX2UKIwNcGFuZWxpc3RfbmFtZZSMClJldmlld2VyIEGUjApmb2N1c19hcmVhlIwnQ2xhaW0gUHJlY2lzaW9uICYgUXVhbnRpdGF0aXZlIEFjY3VyYWN5lIwFc2NvcmWUR0AiAAAAAAAAjAlyZWFzb25pbmeUjB1QcmVjaXNpb24gYW5hbHlzaXMgcmVhc29uaW5nLpRoRV2UdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJocSmBlH2UKGgFfZQoaHWMClJldmlld2VyIEKUaHeMB1NvdXJjZXOUaHlHQCIAAAAAAABoeowXU291cmNlIGF1ZGl0IHJlYXNvbmluZy6UaEVdlIwNd2VhayBzb3VyY2UgQZRhdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJljApkZWJhdGVfcHJvlGgAjApEZWJhdGVTaWRllJOUKYGUfZQoaAV9lCiMBHJvbGWUjAhBZHZvY2F0ZZSMCGFyZ3VtZW50lIwkU2F0ZWxsaXRlIGltYWdlcnkgYW5kIGdlb2Rlc3kgYWdyZWUulIwIcmVidXR0YWyUjCJGbGF0LUVhcnRoIG9iamVjdGlvbnMgZG8gbm90IGhvbGQulHVoGX2UaBuPlChokWiTaI+QaB1OdWKMCmRlYmF0ZV9jb26UaIspgZR9lChoBX2UKGiPjAdTa2VwdGljlGiRjCJUaGUgRWFydGggaXMgbm90IGEgcGVyZmVjdCBzcGhlcmUulGiTjCBPYmxhdGUsIGJ1dCByb3VuZCBmb3IgdGhlIGNsYWltLpR1aBl9lGgbj5QoaJFok2iPkGgdTnVijA9wYW5lbF9hZ3JlZW1lbnSUjAl1bmFuaW1vdXOUdWgZfZRoG4+UKGhuaJdobGigaImQaB1OdWKMCmNyZWF0ZWRfYXSUjCAyMDI2LTEwLTAxVDIzOjU1OjAwLjEyMzQ1NiswMDowMJSMC21vZGlmaWVkX2F0lIwgMjAyNi0xMC0wMlQwMDowMzowMC4xMjM0NTYrMDA6MDCUjAhsYW5ndWFnZZSMAmVulIwIY292ZXJhZ2WUaACMCENvdmVyYWdllJOUKYGUfZQoaAV9lChoB4wJdW5jb3ZlcmVklIwHcmVhc29uc5RdlIwEcGxhbpRhjA5jZXJ0aWZpY2F0ZV9pZJROjA9jZXJ0aWZpY2F0ZV91cmyUTowFYXNfb2aUTowIY3VycmVuY3mUjANFVVKUjANjYXCUTRAnjAlhZ2dyZWdhdGWUSiChBwCMDXRlcm1zX3ZlcnNpb26UjAJ2MZR1aBl9lGgbj5QoaLpotWi5aLZoB2i3aLtotGixkGgdTnVidWgZfZRoG4+UKGgkaC5oPmimaENoKGgsaEFoJmgqaKRoPGg6aEhoRWioaKpoSWhmaECQaB1OdWKMBmNsYWltc5RdlIwKY2FuZGlkYXRlc5RdlIwOc2ltaWxhcl9jbGFpbXOUXZSMBWVycm9ylGgMjA5mYWlsdXJlX3JlYXNvbpRoDIwOZmFpbHVyZV9kZXRhaWyUaAyMDWZhaWx1cmVfY2xhc3OUaAyMCXJldHJ5YWJsZZROjAhkb2NzX3VybJRoDHVoGX2UaBuPlChoHmgHaAmQaB1OdWIu", @@ -130,13 +129,14 @@ "verify__status_needs_input.json": "gASVGAMAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAtuZWVkc19pbnB1dJSMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMBnJlYXNvbpSMC211bHRpX2NsYWltlIwEaGludJSMYFRoZSB0ZXh0IGhvbGRzIHNldmVyYWwgZGlzdGluY3QgY2xhaW1zLiBTZW5kIHRoZSBvbmVzIHRvIGNoZWNrIHRvIFBPU1QgL3ZlcmlmeS97dGFza19pZH0vc2VsZWN0LpSMCHByb2dyZXNzlGgAjAhQcm9ncmVzc5STlCmBlH2UKGgFfZQojARzdGVwlIwAlIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UKGgAjA5DYW5kaWRhdGVDbGFpbZSTlCmBlH2UKGgFfZQojAR0ZXh0lIwTVGhlIEVhcnRoIGlzIHJvdW5kLpSMBmRvbWFpbpSMB1NjaWVuY2WUdWgbfZRoHY+UKGgqaCiQaB9OdWJoJCmBlH2UKGgFfZQoaCiMIVdhdGVyIGJvaWxzIGF0IDEwMEMgYXQgc2VhIGxldmVsLpRoKowHU2NpZW5jZZR1aBt9lGgdj5QoaCpoKJBoH051YmWMCmNhbmRpZGF0ZXOUXZSMDnNpbWlsYXJfY2xhaW1zlF2UjAVlcnJvcpRoFowOZmFpbHVyZV9yZWFzb26UaBaMDmZhaWx1cmVfZGV0YWlslGgWjA1mYWlsdXJlX2NsYXNzlGgWjAlyZXRyeWFibGWUTowIZG9jc191cmyUaBZ1aBt9lGgdj5QoaA1oC2gJaAdoIZBoH051Yi4=", "verify__status_not_a_claim.json": "gASV3wIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lIyPVGhlIGlucHV0IGlzIGEgZ3JlZXRpbmcsIG5vdCBhIHN0YXRlbWVudCB0aGF0IGNhbiBiZSBjaGVja2VkLiBTZW5kIG9uZSBmYWN0dWFsIGNsYWltLCBvciBydW4gdGhlIHRleHQgdGhyb3VnaCAvZXh0cmFjdCB0byBlbnVtZXJhdGUgaXRzIGNsYWltcy6UjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjBdOb3QgYSB2ZXJpZmlhYmxlIGNsYWltLpSMDmZhaWx1cmVfcmVhc29ulIwLbm90X2FfY2xhaW2UjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5SMDWludmFsaWRfaW5wdXSUjAlyZXRyeWFibGWUiYwIZG9jc191cmyUjClodHRwczovL2xlbnouaW8vZG9jcy9lcnJvcnMjaW52YWxpZC1pbnB1dJR1aBp9lGgcj5QoaA1oCWgHaChoLmgraC1oJpBoHk51Yi4=", "verify__status_not_a_claim_after_a_while.json": "gASVVAIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjB5QaXBlbGluZSBzdG9wcGVkOiBub3RfYV9jbGFpbS6UjA5mYWlsdXJlX3JlYXNvbpSMC25vdF9hX2NsYWltlIwOZmFpbHVyZV9kZXRhaWyUaAyMDWZhaWx1cmVfY2xhc3OUjA1pbnZhbGlkX2lucHV0lIwJcmV0cnlhYmxllImMCGRvY3NfdXJslIwpaHR0cHM6Ly9sZW56LmlvL2RvY3MvZXJyb3JzI2ludmFsaWQtaW5wdXSUdWgZfZRoG4+UKGgJaAdoJ2gtaCpoLGglkGgdTnViLg==", - "verify__status_older_run_completed.json": "gASV5wkAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAljb21wbGV0ZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUaACMDFZlcmlmaWNhdGlvbpSTlCmBlH2UKGgFfZQojA92ZXJpZmljYXRpb25faWSUjAg2OGNhNGFhZpSMBWNsYWltlIwTVGhlIEVhcnRoIGlzIHJvdW5kLpSMCnZpc2liaWxpdHmUjAdwcml2YXRllIwFZGVwdGiUjAhzdGFuZGFyZJSMBmRvbWFpbpSMB1NjaWVuY2WUjAhlbnRpdGllc5RdlGgAjAlFbnRpdHlSZWaUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUVhcnRolIwDcWlklE51aBl9lGgbj5QoaDVoN5BoHU51YmGMD3ByZXN1bWVkX2ludGVudJSMHlZlcmlmeSBhIGJhc2ljIHNjaWVudGlmaWMgZmFjdJSMB3ZlcmRpY3SUjARUcnVllIwKY29uZmlkZW5jZZSMBGhpZ2iUjApsZW56X3Njb3JllEsJjAtrZXlfZmluZGluZ5SMLlRoZSBFYXJ0aCBpcyBhcHByb3hpbWF0ZWx5IHNwaGVyaWNhbCBpbiBzaGFwZS6UjBFleGVjdXRpdmVfc3VtbWFyeZSMFlRoZSBjbGFpbSBpcyB2ZXJpZmllZC6UjAh3YXJuaW5nc5RdlIwZUmVsaWVzIG9uIGxpbWl0ZWQgc291cmNlc5RhjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAdzb3VyY2VzlF2UKGgAjAZTb3VyY2WUk5QpgZR9lChoBX2UKIwLc291cmNlX25hbWWUjAROQVNBlIwFdGl0bGWUjBZUaGUgc2hhcGUgb2YgdGhlIEVhcnRolIwDdXJslIwaaHR0cHM6Ly9uYXNhLmV4YW1wbGUvc2hhcGWUjAdzbmlwcGV0lIwrU2F0ZWxsaXRlIGltYWdlcnkgc2hvd3MgYW4gb2JsYXRlIHNwaGVyb2lkLpSMBGRhdGWUjAoyMDI1LTAxLTE1lHVoGX2UaBuPlChoUGhYaFRoUmhWkGgdTnViaEwpgZR9lChoBX2UKGhQjANFU0GUaFKMEEVhcnRoIGZyb20gc3BhY2WUaFSMGWh0dHBzOi8vZXNhLmV4YW1wbGUvZWFydGiUaFaMK09yYml0YWwgbWVhc3VyZW1lbnRzIGNvbmZpcm0gdGhlIGN1cnZhdHVyZS6UaFiMCjIwMjUtMDEtMTaUdWgZfZRoG4+UKGhQaFhoVGhSaFaQaB1OdWJljAVhdWRpdJRoAIwFQXVkaXSUk5QpgZR9lChoBX2UKIwUYWRqdWRpY2F0aW9uX3N1bW1hcnmUjBZCb3RoIHJldmlld2VycyBhZ3JlZWQulIwLYXNzZXNzbWVudHOUXZQoaACMCkFzc2Vzc21lbnSUk5QpgZR9lChoBX2UKIwNcGFuZWxpc3RfbmFtZZSMClJldmlld2VyIEGUjApmb2N1c19hcmVhlIwnQ2xhaW0gUHJlY2lzaW9uICYgUXVhbnRpdGF0aXZlIEFjY3VyYWN5lIwFc2NvcmWUR0AiAAAAAAAAjAlyZWFzb25pbmeUjB1QcmVjaXNpb24gYW5hbHlzaXMgcmVhc29uaW5nLpRoRV2UdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJocSmBlH2UKGgFfZQoaHWMClJldmlld2VyIEKUaHeMB1NvdXJjZXOUaHlHQCIAAAAAAABoeowXU291cmNlIGF1ZGl0IHJlYXNvbmluZy6UaEVdlIwNd2VhayBzb3VyY2UgQZRhdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJljApkZWJhdGVfcHJvlGgAjApEZWJhdGVTaWRllJOUKYGUfZQoaAV9lCiMBHJvbGWUjAhBZHZvY2F0ZZSMCGFyZ3VtZW50lIwkU2F0ZWxsaXRlIGltYWdlcnkgYW5kIGdlb2Rlc3kgYWdyZWUulIwIcmVidXR0YWyUjCJGbGF0LUVhcnRoIG9iamVjdGlvbnMgZG8gbm90IGhvbGQulHVoGX2UaBuPlChokWiTaI+QaB1OdWKMCmRlYmF0ZV9jb26UaIspgZR9lChoBX2UKGiPjAdTa2VwdGljlGiRjCJUaGUgRWFydGggaXMgbm90IGEgcGVyZmVjdCBzcGhlcmUulGiTjCBPYmxhdGUsIGJ1dCByb3VuZCBmb3IgdGhlIGNsYWltLpR1aBl9lGgbj5QoaJFok2iPkGgdTnVijA9wYW5lbF9hZ3JlZW1lbnSUjAl1bmFuaW1vdXOUdWgZfZRoG4+UKGhuaJdobGigaImQaB1OdWKMCmNyZWF0ZWRfYXSUjCAyMDI2LTEwLTAxVDA5OjA3OjAwLjEyMzQ1NiswMDowMJSMC21vZGlmaWVkX2F0lE6MCGxhbmd1YWdllIwCZW6UjAhjb3ZlcmFnZZRoAIwIQ292ZXJhZ2WUk5QpgZR9lChoBX2UKGgHjAl1bmNvdmVyZWSUjAdyZWFzb25zlF2UjARwbGFulGGMDmNlcnRpZmljYXRlX2lklE6MD2NlcnRpZmljYXRlX3VybJROjAVhc19vZpROjAhjdXJyZW5jeZSMA0VVUpSMA2NhcJRNECeMCWFnZ3JlZ2F0ZZRKIKEHAIwNdGVybXNfdmVyc2lvbpSMAnYxlHVoGX2UaBuPlChouWi0aLhotWgHaLZoumizaLCQaB1OdWJ1aBl9lGgbj5QoaCRoLmg+aKZoQ2goaCxoQWgmaCpopGg8aDpoSGhFaKdoqWhJaGZoQJBoHU51YowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUaAyMDmZhaWx1cmVfcmVhc29ulGgMjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5RoDIwJcmV0cnlhYmxllE6MCGRvY3NfdXJslGgMdWgZfZRoG4+UKGgeaAdoCZBoHU51Yi4=", - "verify__status_older_run_failed_crashed.json": "gASVOwIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjBBQaXBlbGluZSBmYWlsZWQulIwOZmFpbHVyZV9yZWFzb26UjAp0YXNrX2Vycm9ylIwOZmFpbHVyZV9kZXRhaWyUaAyMDWZhaWx1cmVfY2xhc3OUjAhpbnRlcm5hbJSMCXJldHJ5YWJsZZSJjAhkb2NzX3VybJSMJGh0dHBzOi8vbGVuei5pby9kb2NzL2Vycm9ycyNpbnRlcm5hbJR1aBl9lGgbj5QoaAloB2gnaC1oKmgsaCWQaB1OdWIu", - "verify__status_older_run_failed_insufficient_evidence.json": "gASVbAIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjCNQaXBlbGluZSBzdG9wcGVkIGF0OiByZXNlYXJjaF9lbXB0eZSMDmZhaWx1cmVfcmVhc29ulIwOcmVzZWFyY2hfZW1wdHmUjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5SMFWluc3VmZmljaWVudF9ldmlkZW5jZZSMCXJldHJ5YWJsZZSJjAhkb2NzX3VybJSMMWh0dHBzOi8vbGVuei5pby9kb2NzL2Vycm9ycyNpbnN1ZmZpY2llbnQtZXZpZGVuY2WUdWgZfZRoG4+UKGgJaAdoJ2gtaCpoLGglkGgdTnViLg==", - "verify__status_older_run_in_progress.json": "gASVBgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjApwcm9jZXNzaW5nlIwHdGFza19pZJSMIDdjZmYxOGRhMmQ5NzlkNjM3MGU1NGExNjBiZTEyMWFklIwGcmVhc29ulIwAlIwEaGludJRoDIwIcHJvZ3Jlc3OUaACMCFByb2dyZXNzlJOUKYGUfZQoaAV9lCiMBHN0ZXCUjAhyZXNlYXJjaJSMBWluZGV4lEsCjAV0b3RhbJRLBYwPZWxhcHNlZF9zZWNvbmRzlEsBjBJwb2xsX2FmdGVyX3NlY29uZHOUSwV1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgYaBdoFmgUaBmQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWKMBnJlc3VsdJROjAZjbGFpbXOUXZSMCmNhbmRpZGF0ZXOUXZSMDnNpbWlsYXJfY2xhaW1zlF2UjAVlcnJvcpRoDIwOZmFpbHVyZV9yZWFzb26UaAyMDmZhaWx1cmVfZGV0YWlslGgMjA1mYWlsdXJlX2NsYXNzlGgMjAlyZXRyeWFibGWUTowIZG9jc191cmyUaAx1aBp9lGgcj5QoaA5oB2gJkGgeTnViLg==", "verify__status_processing.json": "gASVBgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjApwcm9jZXNzaW5nlIwHdGFza19pZJSMIDdjZmYxOGRhMmQ5NzlkNjM3MGU1NGExNjBiZTEyMWFklIwGcmVhc29ulIwAlIwEaGludJRoDIwIcHJvZ3Jlc3OUaACMCFByb2dyZXNzlJOUKYGUfZQoaAV9lCiMBHN0ZXCUjAhyZXNlYXJjaJSMBWluZGV4lEsCjAV0b3RhbJRLBYwPZWxhcHNlZF9zZWNvbmRzlEsBjBJwb2xsX2FmdGVyX3NlY29uZHOUSwV1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgYaBdoFmgUaBmQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWKMBnJlc3VsdJROjAZjbGFpbXOUXZSMCmNhbmRpZGF0ZXOUXZSMDnNpbWlsYXJfY2xhaW1zlF2UjAVlcnJvcpRoDIwOZmFpbHVyZV9yZWFzb26UaAyMDmZhaWx1cmVfZGV0YWlslGgMjA1mYWlsdXJlX2NsYXNzlGgMjAlyZXRyeWFibGWUTowIZG9jc191cmyUaAx1aBp9lGgcj5QoaA5oB2gJkGgeTnViLg==", "verify__status_processing_after_a_while.json": "gASVCgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjApwcm9jZXNzaW5nlIwHdGFza19pZJSMIDdjZmYxOGRhMmQ5NzlkNjM3MGU1NGExNjBiZTEyMWFklIwGcmVhc29ulIwAlIwEaGludJRoDIwIcHJvZ3Jlc3OUaACMCFByb2dyZXNzlJOUKYGUfZQoaAV9lCiMBHN0ZXCUjAxhZGp1ZGljYXRpb26UjAVpbmRleJRLBIwFdG90YWyUSwWMD2VsYXBzZWRfc2Vjb25kc5RLAYwScG9sbF9hZnRlcl9zZWNvbmRzlEsFdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlChoGGgXaBZoFGgZkIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUaAyMDmZhaWx1cmVfcmVhc29ulGgMjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5RoDIwJcmV0cnlhYmxllE6MCGRvY3NfdXJslGgMdWgafZRoHI+UKGgOaAdoCZBoHk51Yi4=", "verify__status_task_stuck.json": "gASVewIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjDhUaGUgdGFzayB3YXMgbmV2ZXIgY29tcGxldGVkIGFuZCBoYXMgYmVlbiBtYXJrZWQgZmFpbGVkLpSMDmZhaWx1cmVfcmVhc29ulIwKdGFza19zdHVja5SMDmZhaWx1cmVfZGV0YWlslGgMjA1mYWlsdXJlX2NsYXNzlIwUdXBzdHJlYW1fdW5hdmFpbGFibGWUjAlyZXRyeWFibGWUiIwIZG9jc191cmyUjDBodHRwczovL2xlbnouaW8vZG9jcy9lcnJvcnMjdXBzdHJlYW0tdW5hdmFpbGFibGWUdWgZfZRoG4+UKGgJaAdoJ2gtaCpoLGglkGgdTnViLg==", + "verify__stored_progress_completed.json": "gASV5wkAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAljb21wbGV0ZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUaACMDFZlcmlmaWNhdGlvbpSTlCmBlH2UKGgFfZQojA92ZXJpZmljYXRpb25faWSUjAg2OGNhNGFhZpSMBWNsYWltlIwTVGhlIEVhcnRoIGlzIHJvdW5kLpSMCnZpc2liaWxpdHmUjAdwcml2YXRllIwFZGVwdGiUjAhzdGFuZGFyZJSMBmRvbWFpbpSMB1NjaWVuY2WUjAhlbnRpdGllc5RdlGgAjAlFbnRpdHlSZWaUk5QpgZR9lChoBX2UKIwEbmFtZZSMBUVhcnRolIwDcWlklE51aBl9lGgbj5QoaDVoN5BoHU51YmGMD3ByZXN1bWVkX2ludGVudJSMHlZlcmlmeSBhIGJhc2ljIHNjaWVudGlmaWMgZmFjdJSMB3ZlcmRpY3SUjARUcnVllIwKY29uZmlkZW5jZZSMBGhpZ2iUjApsZW56X3Njb3JllEsJjAtrZXlfZmluZGluZ5SMLlRoZSBFYXJ0aCBpcyBhcHByb3hpbWF0ZWx5IHNwaGVyaWNhbCBpbiBzaGFwZS6UjBFleGVjdXRpdmVfc3VtbWFyeZSMFlRoZSBjbGFpbSBpcyB2ZXJpZmllZC6UjAh3YXJuaW5nc5RdlIwZUmVsaWVzIG9uIGxpbWl0ZWQgc291cmNlc5RhjBFzdWdnZXN0ZWRfcmV3cml0ZZROjAdzb3VyY2VzlF2UKGgAjAZTb3VyY2WUk5QpgZR9lChoBX2UKIwLc291cmNlX25hbWWUjAROQVNBlIwFdGl0bGWUjBZUaGUgc2hhcGUgb2YgdGhlIEVhcnRolIwDdXJslIwaaHR0cHM6Ly9uYXNhLmV4YW1wbGUvc2hhcGWUjAdzbmlwcGV0lIwrU2F0ZWxsaXRlIGltYWdlcnkgc2hvd3MgYW4gb2JsYXRlIHNwaGVyb2lkLpSMBGRhdGWUjAoyMDI1LTAxLTE1lHVoGX2UaBuPlChoUGhYaFRoUmhWkGgdTnViaEwpgZR9lChoBX2UKGhQjANFU0GUaFKMEEVhcnRoIGZyb20gc3BhY2WUaFSMGWh0dHBzOi8vZXNhLmV4YW1wbGUvZWFydGiUaFaMK09yYml0YWwgbWVhc3VyZW1lbnRzIGNvbmZpcm0gdGhlIGN1cnZhdHVyZS6UaFiMCjIwMjUtMDEtMTaUdWgZfZRoG4+UKGhQaFhoVGhSaFaQaB1OdWJljAVhdWRpdJRoAIwFQXVkaXSUk5QpgZR9lChoBX2UKIwUYWRqdWRpY2F0aW9uX3N1bW1hcnmUjBZCb3RoIHJldmlld2VycyBhZ3JlZWQulIwLYXNzZXNzbWVudHOUXZQoaACMCkFzc2Vzc21lbnSUk5QpgZR9lChoBX2UKIwNcGFuZWxpc3RfbmFtZZSMClJldmlld2VyIEGUjApmb2N1c19hcmVhlIwnQ2xhaW0gUHJlY2lzaW9uICYgUXVhbnRpdGF0aXZlIEFjY3VyYWN5lIwFc2NvcmWUR0AiAAAAAAAAjAlyZWFzb25pbmeUjB1QcmVjaXNpb24gYW5hbHlzaXMgcmVhc29uaW5nLpRoRV2UdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJocSmBlH2UKGgFfZQoaHWMClJldmlld2VyIEKUaHeMB1NvdXJjZXOUaHlHQCIAAAAAAABoeowXU291cmNlIGF1ZGl0IHJlYXNvbmluZy6UaEVdlIwNd2VhayBzb3VyY2UgQZRhdWgZfZRoG4+UKGhFaHdodWh5aHqQaB1OdWJljApkZWJhdGVfcHJvlGgAjApEZWJhdGVTaWRllJOUKYGUfZQoaAV9lCiMBHJvbGWUjAhBZHZvY2F0ZZSMCGFyZ3VtZW50lIwkU2F0ZWxsaXRlIGltYWdlcnkgYW5kIGdlb2Rlc3kgYWdyZWUulIwIcmVidXR0YWyUjCJGbGF0LUVhcnRoIG9iamVjdGlvbnMgZG8gbm90IGhvbGQulHVoGX2UaBuPlChokWiTaI+QaB1OdWKMCmRlYmF0ZV9jb26UaIspgZR9lChoBX2UKGiPjAdTa2VwdGljlGiRjCJUaGUgRWFydGggaXMgbm90IGEgcGVyZmVjdCBzcGhlcmUulGiTjCBPYmxhdGUsIGJ1dCByb3VuZCBmb3IgdGhlIGNsYWltLpR1aBl9lGgbj5QoaJFok2iPkGgdTnVijA9wYW5lbF9hZ3JlZW1lbnSUjAl1bmFuaW1vdXOUdWgZfZRoG4+UKGhuaJdobGigaImQaB1OdWKMCmNyZWF0ZWRfYXSUjCAyMDI2LTEwLTAxVDA5OjA3OjAwLjEyMzQ1NiswMDowMJSMC21vZGlmaWVkX2F0lE6MCGxhbmd1YWdllIwCZW6UjAhjb3ZlcmFnZZRoAIwIQ292ZXJhZ2WUk5QpgZR9lChoBX2UKGgHjAl1bmNvdmVyZWSUjAdyZWFzb25zlF2UjARwbGFulGGMDmNlcnRpZmljYXRlX2lklE6MD2NlcnRpZmljYXRlX3VybJROjAVhc19vZpROjAhjdXJyZW5jeZSMA0VVUpSMA2NhcJRNECeMCWFnZ3JlZ2F0ZZRKIKEHAIwNdGVybXNfdmVyc2lvbpSMAnYxlHVoGX2UaBuPlChouWi0aLhotWgHaLZoumizaLCQaB1OdWJ1aBl9lGgbj5QoaCRoLmg+aKZoQ2goaCxoQWgmaCpopGg8aDpoSGhFaKdoqWhJaGZoQJBoHU51YowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUaAyMDmZhaWx1cmVfcmVhc29ulGgMjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5RoDIwJcmV0cnlhYmxllE6MCGRvY3NfdXJslGgMdWgZfZRoG4+UKGgeaAdoCZBoHU51Yi4=", + "verify__stored_progress_failed_crashed.json": "gASVOwIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjBBQaXBlbGluZSBmYWlsZWQulIwOZmFpbHVyZV9yZWFzb26UjAp0YXNrX2Vycm9ylIwOZmFpbHVyZV9kZXRhaWyUaAyMDWZhaWx1cmVfY2xhc3OUjAhpbnRlcm5hbJSMCXJldHJ5YWJsZZSJjAhkb2NzX3VybJSMJGh0dHBzOi8vbGVuei5pby9kb2NzL2Vycm9ycyNpbnRlcm5hbJR1aBl9lGgbj5QoaAloB2gnaC1oKmgsaCWQaB1OdWIu", + "verify__stored_progress_failed_insufficient_evidence.json": "gASVbAIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjAZmYWlsZWSUjAd0YXNrX2lklIwgN2NmZjE4ZGEyZDk3OWQ2MzcwZTU0YTE2MGJlMTIxYWSUjAZyZWFzb26UjACUjARoaW50lGgMjAhwcm9ncmVzc5RoAIwIUHJvZ3Jlc3OUk5QpgZR9lChoBX2UKIwEc3RlcJRoDIwFaW5kZXiUTowFdG90YWyUTowPZWxhcHNlZF9zZWNvbmRzlE6MEnBvbGxfYWZ0ZXJfc2Vjb25kc5ROdYwSX19weWRhbnRpY19leHRyYV9flH2UjBdfX3B5ZGFudGljX2ZpZWxkc19zZXRfX5SPlIwUX19weWRhbnRpY19wcml2YXRlX1+UTnVijAZyZXN1bHSUTowGY2xhaW1zlF2UjApjYW5kaWRhdGVzlF2UjA5zaW1pbGFyX2NsYWltc5RdlIwFZXJyb3KUjCNQaXBlbGluZSBzdG9wcGVkIGF0OiByZXNlYXJjaF9lbXB0eZSMDmZhaWx1cmVfcmVhc29ulIwOcmVzZWFyY2hfZW1wdHmUjA5mYWlsdXJlX2RldGFpbJRoDIwNZmFpbHVyZV9jbGFzc5SMFWluc3VmZmljaWVudF9ldmlkZW5jZZSMCXJldHJ5YWJsZZSJjAhkb2NzX3VybJSMMWh0dHBzOi8vbGVuei5pby9kb2NzL2Vycm9ycyNpbnN1ZmZpY2llbnQtZXZpZGVuY2WUdWgZfZRoG4+UKGgJaAdoJ2gtaCpoLGglkGgdTnViLg==", + "verify__stored_progress_in_progress.json": "gASVBgIAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwKVGFza1N0YXR1c5STlCmBlH2UKIwIX19kaWN0X1+UfZQojAZzdGF0dXOUjApwcm9jZXNzaW5nlIwHdGFza19pZJSMIDdjZmYxOGRhMmQ5NzlkNjM3MGU1NGExNjBiZTEyMWFklIwGcmVhc29ulIwAlIwEaGludJRoDIwIcHJvZ3Jlc3OUaACMCFByb2dyZXNzlJOUKYGUfZQoaAV9lCiMBHN0ZXCUjAhyZXNlYXJjaJSMBWluZGV4lEsCjAV0b3RhbJRLBYwPZWxhcHNlZF9zZWNvbmRzlEsBjBJwb2xsX2FmdGVyX3NlY29uZHOUSwV1jBJfX3B5ZGFudGljX2V4dHJhX1+UfZSMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgYaBdoFmgUaBmQjBRfX3B5ZGFudGljX3ByaXZhdGVfX5ROdWKMBnJlc3VsdJROjAZjbGFpbXOUXZSMCmNhbmRpZGF0ZXOUXZSMDnNpbWlsYXJfY2xhaW1zlF2UjAVlcnJvcpRoDIwOZmFpbHVyZV9yZWFzb26UaAyMDmZhaWx1cmVfZGV0YWlslGgMjA1mYWlsdXJlX2NsYXNzlGgMjAlyZXRyeWFibGWUTowIZG9jc191cmyUaAx1aBp9lGgcj5QoaA5oB2gJkGgeTnViLg==", + "verify__stored_replay_202.json": "gASV/AAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwMVGFza0FjY2VwdGVklJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMCmNsYWltX3RleHSUjACUdYwSX19weWRhbnRpY19leHRyYV9flH2UKIwGc3RhdHVzlIwGcXVldWVklIwIY2hhaW5faWSUjBA5OWEwYTljYTg5ZTFlNzJjlHWMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgPaA1oB5CMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51Yi4=", "verify__submit_202.json": "gASV/AAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwMVGFza0FjY2VwdGVklJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMCmNsYWltX3RleHSUjACUdYwSX19weWRhbnRpY19leHRyYV9flH2UKIwGc3RhdHVzlIwGcXVldWVklIwIY2hhaW5faWSUjBA5OWEwYTljYTg5ZTFlNzJjlHWMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgPaA1oB5CMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51Yi4=", "verify__submit_202_options.json": "gASV/AAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwMVGFza0FjY2VwdGVklJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMCmNsYWltX3RleHSUjACUdYwSX19weWRhbnRpY19leHRyYV9flH2UKIwGc3RhdHVzlIwGcXVldWVklIwIY2hhaW5faWSUjBA5OWEwYTljYTg5ZTFlNzJjlHWMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgPaA1oB5CMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51Yi4=", "verify__submit_202_text_alias.json": "gASV/AAAAAAAAACMDmxlbnpfaW8ubW9kZWxzlIwMVGFza0FjY2VwdGVklJOUKYGUfZQojAhfX2RpY3RfX5R9lCiMB3Rhc2tfaWSUjCA3Y2ZmMThkYTJkOTc5ZDYzNzBlNTRhMTYwYmUxMjFhZJSMCmNsYWltX3RleHSUjACUdYwSX19weWRhbnRpY19leHRyYV9flH2UKIwGc3RhdHVzlIwGcXVldWVklIwIY2hhaW5faWSUjBA5OWEwYTljYTg5ZTFlNzJjlHWMF19fcHlkYW50aWNfZmllbGRzX3NldF9flI+UKGgPaA1oB5CMFF9fcHlkYW50aWNfcHJpdmF0ZV9flE51Yi4=", diff --git a/tests/freeze_cases.py b/tests/freeze_cases.py new file mode 100644 index 0000000..67c48f6 --- /dev/null +++ b/tests/freeze_cases.py @@ -0,0 +1,350 @@ +"""Every public call form, with scripted answers, for the request freeze. + +Each case is ``(name, client, call, answers)``: ``client`` names a factory in +``freeze_harness.CLIENTS``, ``call`` runs the public method, ``answers`` maps +``(method, path)`` to the scripted answers. ``test_request_freeze.py`` records +what each case sends (URL, raw body, ordered headers, the four timeout +components, sleeps) and compares it with ``fixtures/freeze/requests.json``. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import httpx +from freeze_harness import PINNED, Answer + +from lenz_io import Lenz + +_CONTRACT = Path(__file__).parent / "fixtures" / "contract" + + +def _load(name: str) -> Any: + return json.loads((_CONTRACT / name).read_text()) + + +TASK = "t1" +ACCEPTED = (200, {"task_id": TASK, "claim_text": "A."}) +DONE = (200, {"status": "completed", "task_id": TASK, "result": {"verification_id": "v1", "claim": "A."}}) +DONE2 = (200, {"status": "completed", "task_id": "t2", "result": {"verification_id": "v2", "claim": "B."}}) +RUNNING = (200, {"status": "processing", "task_id": TASK, "progress": {"step": "research", "poll_after_seconds": 3}}) +RUNNING_NO_HINT = (200, {"status": "processing", "task_id": TASK, "progress": {"step": "research"}}) +BATCH = (200, {"batch_id": "b1", "items": [{"task_id": TASK, "claim": "A."}, {"task_id": "t2", "claim": "B."}]}) +EXTRACTED = (200, {"claims": [], "status": "ok"}) +ASSESSED = (200, {"claims": []}) +SELECTED = (200, {"items": []}) +REVIEW_DONE = _load("review_completed.json") +REVIEW_RUNNING = _load("review_assessing.json") +RID = REVIEW_DONE["review_id"] +CHECK_DONE = _load("citecheck_completed.json") +CID = CHECK_DONE["citecheck_id"] +CANCEL_TASK = _load("cancel_verify_cancelled.json") +CANCEL_REVIEW = _load("cancel_review_cancelled.json") +CANCEL_CHECK = _load("cancel_citecheck_cancelled.json") +_ITEM = _load("verifications_list.json")["items"][0] +PAGE_1 = (200, {"items": [_ITEM], "total": 2, "page": 1, "page_size": 1}) +PAGE_2 = (200, {"items": [_ITEM], "total": 2, "page": 2, "page_size": 1}) +LIB_ITEM = {"verification_id": "lib1", "claim": "A."} +LIB_1 = (200, {"items": [LIB_ITEM], "total": 2, "page": 1, "page_size": 1}) +LIB_2 = (200, {"items": [LIB_ITEM], "total": 2, "page": 2, "page_size": 1}) +DETAIL = (200, _load("verifications_detail.json")) +CERT = (200, _load("certificate.json")) +USAGE = (200, _load("usage.json")) +ASK_REPLY = (200, {"role": "expert", "content": "Because."}) +ASK_HISTORY = (200, {"messages": []}) +RELATED = (200, {"items": []}) +DRAFT = "Draft text." + +S503 = (503, {"detail": "busy"}) +CONFLICT = (409, {"code": "idempotency_conflict", "detail": "in flight"}, {"Retry-After": "2"}) +LIMITED = (429, {"code": "rate_limited", "detail": "slow down"}, {"Retry-After": "2"}) +NOT_FOUND = (404, {"code": "not_found", "detail": "nope"}) + + +def _drop() -> Exception: + return httpx.ConnectError("connection refused") + + +Case = tuple[str, str, Callable[[Lenz], Any], dict[tuple[str, str], list[Answer]]] + +CASES: list[Case] = [ + # ── submits ── + ("verify_pinned", "default", lambda c: c.verify("A.", idempotency_key=PINNED), {("POST", "/verify"): [ACCEPTED]}), + ("verify_random_key", "default", lambda c: c.verify(claim="A."), {("POST", "/verify"): [ACCEPTED]}), + ("verify_no_key", "default", lambda c: c.verify("A.", idempotency=False), {("POST", "/verify"): [ACCEPTED]}), + ( + "verify_options", + "default", + lambda c: c.verify( + "A.", + language="es", + visibility="unlisted", + depth="low", + source_url="https://e.x/a", + webhook_url="https://e.x/h", + ), + {("POST", "/verify"): [ACCEPTED]}, + ), + ( + "verify_batch", + "default", + lambda c: c.verify_batch(claims=[{"claim": "A."}, {"text": "B."}], idempotency_key=PINNED, language="de"), + {("POST", "/verify/batch"): [BATCH]}, + ), + ("extract", "default", lambda c: c.extract(text="Doc."), {("POST", "/extract"): [EXTRACTED]}), + ( + "extract_options", + "default", + lambda c: c.extract(text="Doc.", language="it", focus="figures", locate=True, idempotency_key=PINNED), + {("POST", "/extract"): [EXTRACTED]}, + ), + ("extract_timeout_5", "default", lambda c: c.extract(text="Doc.", timeout=5), {("POST", "/extract"): [EXTRACTED]}), + ( + "extract_timeout_obj", + "default", + lambda c: c.extract(text="Doc.", timeout=httpx.Timeout(7, read=300)), # type: ignore[arg-type] + {("POST", "/extract"): [EXTRACTED]}, + ), + ("assess_claim", "default", lambda c: c.assess("A."), {("POST", "/assess"): [ASSESSED]}), + ( + "assess_claims", + "default", + lambda c: c.assess(claims=["A.", "B."], language="auto", suggest_rewrite=True, idempotency_key=PINNED), + {("POST", "/assess"): [ASSESSED]}, + ), + ("assess_timeout_20", "default", lambda c: c.assess("A.", timeout=20), {("POST", "/assess"): [ASSESSED]}), + ( + "select", + "default", + lambda c: c.select(TASK, claims=["A.", "B."]), + {("POST", f"/verify/{TASK}/select"): [SELECTED]}, + ), + ("get_status", "default", lambda c: c.get_status(TASK), {("GET", f"/verify/status/{TASK}"): [DONE]}), + ( + "cancel", + "default", + lambda c: c.cancel(CANCEL_TASK["task_id"]), + {("POST", f"/verify/{CANCEL_TASK['task_id']}/cancel"): [(200, CANCEL_TASK)]}, + ), + ( + "review", + "default", + lambda c: c.review(DRAFT), + {("POST", "/review"): [(202, {"review_id": RID, "status": "queued"})]}, + ), + ( + "review_options", + "default", + lambda c: c.review(DRAFT, verdicts=["False"], max_citations=3, suggest_edits=True, idempotency_key=PINNED), + {("POST", "/review"): [(202, {"review_id": RID, "status": "queued"})]}, + ), + ("get_review", "default", lambda c: c.get_review(RID), {("GET", f"/reviews/{RID}"): [(200, REVIEW_DONE)]}), + ( + "get_review_issues", + "default", + lambda c: c.get_review(RID, view="issues"), + {("GET", f"/reviews/{RID}"): [(200, _load("review_completed_issues.json"))]}, + ), + ( + "cancel_review", + "default", + lambda c: c.cancel_review(CANCEL_REVIEW["review_id"]), + {("POST", f"/reviews/{CANCEL_REVIEW['review_id']}/cancel"): [(200, CANCEL_REVIEW)]}, + ), + ( + "citecheck_text", + "default", + lambda c: c.citecheck(DRAFT, max_citations=2), + {("POST", "/citecheck"): [(202, {"citecheck_id": CID, "status": "queued"})]}, + ), + ( + "citecheck_pairs", + "default", + lambda c: c.citecheck(pairs=[{"statement": "S.", "url": "https://e.x/s"}], idempotency_key=PINNED), + {("POST", "/citecheck"): [(202, {"citecheck_id": CID, "status": "queued"})]}, + ), + ("get_citecheck", "default", lambda c: c.get_citecheck(CID), {("GET", f"/citechecks/{CID}"): [(200, CHECK_DONE)]}), + ( + "cancel_citecheck", + "default", + lambda c: c.cancel_citecheck(CID), + {("POST", f"/citechecks/{CID}/cancel"): [(200, CANCEL_CHECK)]}, + ), + ("usage", "default", lambda c: c.usage(), {("GET", "/me/usage"): [USAGE]}), + # ── namespaces ── + ("verifications_list", "default", lambda c: c.verifications.list(), {("GET", "/verifications"): [PAGE_1]}), + ("verifications_list_p2", "default", lambda c: c.verifications.list(page=2), {("GET", "/verifications"): [PAGE_2]}), + ("verifications_iter", "default", lambda c: c.verifications.iter(), {("GET", "/verifications"): [PAGE_1, PAGE_2]}), + ("verifications_get", "default", lambda c: c.verifications.get("v1"), {("GET", "/verifications/v1"): [DETAIL]}), + ( + "verifications_get_keyless", + "keyless", + lambda c: c.verifications.get("v1"), + {("GET", "/verifications/v1"): [DETAIL]}, + ), + ( + "verifications_get_certificate", + "default", + lambda c: c.verifications.get_certificate("v1"), + {("GET", "/verifications/v1/certificate"): [CERT]}, + ), + ( + "verifications_delete", + "default", + lambda c: c.verifications.delete("v1"), + {("DELETE", "/verifications/v1"): [(204, None)]}, + ), + ( + "verifications_delete_404", + "default", + lambda c: c.verifications.delete("v1"), + {("DELETE", "/verifications/v1"): [NOT_FOUND]}, + ), + ( + "verifications_related", + "default", + lambda c: c.verifications.related("v1", limit=3), + {("GET", "/verifications/v1/related"): [RELATED]}, + ), + ("ask_history", "default", lambda c: c.ask.history("v1"), {("GET", "/ask/v1"): [ASK_HISTORY]}), + ( + "ask_send", + "default", + lambda c: c.ask.send("v1", message="Why?", language="auto"), + {("POST", "/ask/v1"): [ASK_REPLY]}, + ), + ("ask_reset", "default", lambda c: c.ask.reset("v1"), {("DELETE", "/ask/v1"): [(204, None)]}), + ("library_list", "default", lambda c: c.library.list(), {("GET", "/library"): [LIB_1]}), + ( + "library_list_filters", + "keyless", + lambda c: c.library.list(page=2, sort="most_true", search="x", curated=["trivia"], verdict="True"), + {("GET", "/library"): [LIB_2]}, + ), + ("library_iter", "default", lambda c: c.library.iter(search="x"), {("GET", "/library"): [LIB_1, LIB_2]}), + # ── waits ── + ( + "verify_and_wait", + "default", + lambda c: c.verify_and_wait("A.", idempotency_key=PINNED), + {("POST", "/verify"): [ACCEPTED], ("GET", f"/verify/status/{TASK}"): [RUNNING, RUNNING_NO_HINT, DONE]}, + ), + ( + "verify_and_wait_times_out", + "default", + lambda c: c.verify_and_wait("A.", timeout=5), + {("POST", "/verify"): [ACCEPTED], ("GET", f"/verify/status/{TASK}"): [RUNNING_NO_HINT]}, + ), + ("wait_zero", "default", lambda c: c.wait(TASK, timeout=0), {("GET", f"/verify/status/{TASK}"): [RUNNING]}), + ("wait_negative", "default", lambda c: c.wait(TASK, timeout=-1), {("GET", f"/verify/status/{TASK}"): [DONE]}), + ( + "wait_poll_errors", + "default", + lambda c: c.wait(TASK, timeout=60), + {("GET", f"/verify/status/{TASK}"): [S503, LIMITED, _drop(), DONE]}, + ), + ( + "verify_batch_and_wait", + "default", + lambda c: c.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], idempotency_key=PINNED), + { + ("POST", "/verify/batch"): [BATCH], + ("GET", f"/verify/status/{TASK}"): [RUNNING, DONE], + ("GET", "/verify/status/t2"): [NOT_FOUND], + }, + ), + ( + "review_and_wait", + "default", + lambda c: c.review_and_wait(DRAFT, idempotency_key=PINNED), + { + ("POST", "/review"): [(202, {"review_id": RID, "status": "queued"})], + ("GET", f"/reviews/{RID}"): [(200, REVIEW_RUNNING), S503, (200, REVIEW_DONE)], + }, + ), + ( + "review_and_wait_zero", + "default", + lambda c: c.review_and_wait(DRAFT, timeout=0), + { + ("POST", "/review"): [(202, {"review_id": RID, "status": "queued"})], + ("GET", f"/reviews/{RID}"): [(200, REVIEW_RUNNING)], + }, + ), + ( + "review_and_wait_times_out", + "default", + lambda c: c.review_and_wait(DRAFT, timeout=25), + { + ("POST", "/review"): [(202, {"review_id": RID, "status": "queued"})], + ("GET", f"/reviews/{RID}"): [(200, REVIEW_RUNNING)], + }, + ), + ( + "citecheck_and_wait", + "default", + lambda c: c.citecheck_and_wait(DRAFT), + { + ("POST", "/citecheck"): [(202, {"citecheck_id": CID, "status": "queued"})], + ("GET", f"/citechecks/{CID}"): [(200, CHECK_DONE)], + }, + ), + # ── retries and errors ── + ("retry_503", "default", lambda c: c.assess("A.", idempotency_key=PINNED), {("POST", "/assess"): [S503, ASSESSED]}), + ("retry_429_stated", "default", lambda c: c.usage(), {("GET", "/me/usage"): [LIMITED, USAGE]}), + ( + "retry_409_conflict", + "default", + lambda c: c.verify("A.", idempotency_key=PINNED), + {("POST", "/verify"): [CONFLICT, ACCEPTED]}, + ), + ("retry_transport", "default", lambda c: c.get_status(TASK), {("GET", f"/verify/status/{TASK}"): [_drop(), DONE]}), + ("retries_exhausted_503", "default", lambda c: c.usage(), {("GET", "/me/usage"): [S503]}), + ("retries_exhausted_transport", "default", lambda c: c.ask.history("v1"), {("GET", "/ask/v1"): [_drop()]}), + ("max_retries_0_503", "max_retries_0", lambda c: c.usage(), {("GET", "/me/usage"): [S503]}), + ("max_retries_1_503", "max_retries_1", lambda c: c.assess("A."), {("POST", "/assess"): [S503]}), + ("error_404", "default", lambda c: c.get_status(TASK), {("GET", f"/verify/status/{TASK}"): [NOT_FOUND]}), + ("keyless_refused", "keyless", lambda c: c.usage(), {}), +] + +# The per-attempt timeout under every client configuration: the floored calls, +# a plain call, and a wait's polls (capped by what is left of the wait). +_TIMEOUT_CLIENTS = [ + "timeout_none", + "timeout_5_read_200", + "timeout_200_read_5", + "timeout_30_read_none", + "timeout_120", + "borrowed_10", + "borrowed_300", +] +for _client in _TIMEOUT_CLIENTS: + CASES += [ + (f"{_client}:extract", _client, lambda c: c.extract(text="Doc."), {("POST", "/extract"): [EXTRACTED]}), + (f"{_client}:assess", _client, lambda c: c.assess("A."), {("POST", "/assess"): [ASSESSED]}), + ( + f"{_client}:extract_timeout_9", + _client, + lambda c: c.extract(text="Doc.", timeout=9), + {("POST", "/extract"): [EXTRACTED]}, + ), + (f"{_client}:usage", _client, lambda c: c.usage(), {("GET", "/me/usage"): [USAGE]}), + ( + f"{_client}:wait", + _client, + lambda c: c.wait(TASK, timeout=12), + {("GET", f"/verify/status/{TASK}"): [RUNNING_NO_HINT, RUNNING_NO_HINT, RUNNING_NO_HINT, DONE]}, + ), + ( + f"{_client}:review_and_wait", + _client, + lambda c: c.review_and_wait(DRAFT, timeout=12, idempotency_key=PINNED), + { + ("POST", "/review"): [(202, {"review_id": RID, "status": "queued"})], + ("GET", f"/reviews/{RID}"): [(200, REVIEW_RUNNING), (200, REVIEW_DONE)], + }, + ), + ] diff --git a/tests/freeze_harness.py b/tests/freeze_harness.py new file mode 100644 index 0000000..54e9301 --- /dev/null +++ b/tests/freeze_harness.py @@ -0,0 +1,160 @@ +"""A recorder for what the client puts on the wire, for the request freeze +(``test_request_freeze.py``) and the request-option tests. + +Every request is recorded with its method, URL (query string as sent), raw +body, ordered header pairs and the four ``httpx.Timeout`` components the +client passed to httpx; every sleep with its length, under a fake clock that +only moves when the client sleeps. Responses are scripted per (method, path). +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any + +import httpx +import pytest +import respx + +from lenz_io import Lenz +from lenz_io import client as client_module + +BASE = "https://lenz.io/api/v1" +API_KEY = "lenz_" + "0" * 32 +PINNED = "pinned-key-1" +_RANDOM_KEY = re.compile(r"^[0-9a-f]{32}$") + +#: A scripted answer: ``(status, json_body)``, ``(status, json_body, headers)`` +#: or an exception instance to raise from the transport. +Answer = Any + + +@dataclass +class FakeClock: + now: float = 1000.0 + sleeps: list[float] = field(default_factory=list) + + def monotonic(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +class _FakeTime: + """Stands in for the ``time`` module inside ``lenz_io.client`` only.""" + + def __init__(self, clock: FakeClock) -> None: + self.monotonic = clock.monotonic + self.sleep = clock.sleep + + +def _mask_headers(raw: list[tuple[bytes, bytes]]) -> list[list[str]]: + out = [] + for name_b, value_b in raw: + name, value = name_b.decode("latin-1"), value_b.decode("latin-1") + lower = name.lower() + if lower == "user-agent" and value.startswith("lenz-io-python/"): + value = "" + elif lower == "accept-encoding": + value = "" # depends on which decoders are installed + elif lower == "idempotency-key" and value != PINNED and _RANDOM_KEY.match(value): + value = "" + out.append([name, value]) + return out + + +@dataclass +class Recording: + requests: list[dict[str, Any]] = field(default_factory=list) + clock: FakeClock = field(default_factory=FakeClock) + + def summary(self) -> list[dict[str, Any]]: + return self.requests + + +class Script: + """Scripted answers per (method, path below the base URL). The last answer + of a list repeats.""" + + def __init__(self, answers: dict[tuple[str, str], list[Answer]], recording: Recording) -> None: + self._answers = {k: list(v) for k, v in answers.items()} + self._rec = recording + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.startswith("/api/v1"): + path = path[len("/api/v1") :] + timeout = request.extensions.get("timeout") or {} + self._rec.requests.append( + { + "method": request.method, + "url": str(request.url), + "headers": _mask_headers(request.headers.raw), + "body": request.content.decode("utf-8"), + "timeout": {k: timeout.get(k) for k in ("connect", "read", "write", "pool")}, + "at": round(self._rec.clock.now - 1000.0, 6), + } + ) + queue = self._answers.get((request.method, path)) + if not queue: + raise AssertionError(f"unscripted request {request.method} {path}") + answer = queue.pop(0) if len(queue) > 1 else queue[0] + if isinstance(answer, Exception): + raise answer + status, body, *rest = answer + headers = rest[0] if rest else {} + if body is None: + return httpx.Response(status, headers=headers) + return httpx.Response(status, json=body, headers=headers) + + +@contextmanager +def recording(monkeypatch: pytest.MonkeyPatch, answers: dict[tuple[str, str], list[Answer]]) -> Iterator[Recording]: + rec = Recording() + monkeypatch.setattr(client_module, "time", _FakeTime(rec.clock)) + with respx.mock(assert_all_called=False) as router: + router.route().mock(side_effect=Script(answers, rec)) + yield rec + + +def outcome(call: Callable[[], Any]) -> dict[str, Any]: + """What a call ended with: the result's type, or the error's class and + the fields a caller reads.""" + try: + result = call() + except Exception as exc: # every outcome is recorded + key = getattr(exc, "idempotency_key", None) + if isinstance(key, str) and key != PINNED and _RANDOM_KEY.match(key): + key = "" + return { + "error": type(exc).__name__, + "status_code": getattr(exc, "status_code", None), + "code": getattr(exc, "code", None), + "message": str(getattr(exc, "message", exc)), + "idempotency_key": key, + } + if isinstance(result, list): + return {"result": [type(r).__name__ for r in result]} + if isinstance(result, Iterator) or hasattr(result, "__next__"): + return {"result": [type(r).__name__ for r in result]} + return {"result": type(result).__name__} + + +CLIENTS: dict[str, Callable[[], Lenz]] = { + "default": lambda: Lenz(api_key=API_KEY), + "keyless": lambda: Lenz(api_key="", base_url=BASE), + "timeout_none": lambda: Lenz(api_key=API_KEY, timeout=None), + "timeout_5_read_200": lambda: Lenz(api_key=API_KEY, timeout=httpx.Timeout(5, read=200)), + "timeout_200_read_5": lambda: Lenz(api_key=API_KEY, timeout=httpx.Timeout(200, read=5)), + "timeout_30_read_none": lambda: Lenz(api_key=API_KEY, timeout=httpx.Timeout(30, read=None)), + "timeout_120": lambda: Lenz(api_key=API_KEY, timeout=120.0), + "borrowed_10": lambda: Lenz(api_key=API_KEY, http_client=httpx.Client(timeout=10.0)), + "borrowed_300": lambda: Lenz(api_key=API_KEY, http_client=httpx.Client(timeout=300.0)), + "max_retries_0": lambda: Lenz(api_key=API_KEY, max_retries=0), + "max_retries_1": lambda: Lenz(api_key=API_KEY, max_retries=1), +} diff --git a/tests/parity_observe.py b/tests/parity_observe.py index 0ed28d7..8211522 100644 --- a/tests/parity_observe.py +++ b/tests/parity_observe.py @@ -12,6 +12,7 @@ from __future__ import annotations import contextlib +import inspect import io import json import warnings @@ -136,6 +137,23 @@ def _error_view(err: BaseException) -> dict[str, Any]: return view +def endpoint_for(name: str) -> tuple[str, str]: + """The ``(method, path)`` a recorded error answered, as precisely as the + error mapping needs it: /review and /citecheck, /assess, or any other.""" + area, case = name.split("__", 1) + if area == "review": + return ("GET", "/reviews/r1") if case.startswith("get_") else ("POST", "/review") + if area == "citecheck": + return ("GET", "/citechecks/c1") if case.startswith("get_") else ("POST", "/citecheck") + if area == "assess": + return ("POST", "/assess") + if area == "extract": + return ("POST", "/extract") + if area == "errors" and case.startswith("ask_"): + return ("POST", "/ask/v1") + return ("POST", "/verify") + + def _model_for(name: str) -> Any: area = name.split("__", 1)[0] case = name.split("__", 1)[1] @@ -148,11 +166,15 @@ def _model_for(name: str) -> Any: if area == "account" and case.startswith("library"): return models.LibraryList if area == "review": - return models.ReviewStarted if case.startswith(("receipt", "idempotent", "replay")) else models.ReviewFull + return ( + models.ReviewStarted + if case.startswith(("receipt", "idempotent", "replay", "stored_replay")) + else models.ReviewFull + ) if area == "citecheck": return models.CitecheckStarted if case.startswith(("receipt", "idempotent")) else models.Citecheck if area == "verify": - if case.startswith("status_"): + if case.startswith(("status_", "stored_progress")): return models.TaskStatus if case.startswith(("batch_", "select_")): return models.BatchAccepted @@ -180,7 +202,12 @@ def observe(name: str, fixture: dict[str, Any]) -> dict[str, Any]: result["event"] = view return result if status >= 400: - err = errors_mod.map_response_to_error(status, json.dumps(body).encode(), headers) + kwargs: dict[str, Any] = {} + if "endpoint" in inspect.signature(errors_mod.map_response_to_error).parameters: + # The request the client sends, as far as the mapping reads it + # (releases before the newer shape take no endpoint). + kwargs["endpoint"] = endpoint_for(name) + err = errors_mod.map_response_to_error(status, json.dumps(body).encode(), headers, **kwargs) result["error"] = _error_view(err) return result cls = _model_for(name) diff --git a/tests/test_api_version_guard.py b/tests/test_api_version_guard.py new file mode 100644 index 0000000..faaed4f --- /dev/null +++ b/tests/test_api_version_guard.py @@ -0,0 +1,159 @@ +"""The version header goes on every request, and an answer in another version +is refused with a typed error instead of being misread.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +import respx +from parity_observe import load + +import lenz_io +from lenz_io import Lenz, LenzApiVersionError, LenzError +from lenz_io.client import API_VERSION, DEFAULT_BASE_URL +from lenz_io.webhooks import parse_webhook + +KEY = "lenz_" + "0" * 32 +OLD = "2026-05-13" + + +def _seen_versions(headers_on_client: dict[str, str] | None) -> list[str | None]: + seen: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("X-Lenz-API-Version")) + return httpx.Response(200, json={"api": "ok"}) + + http = httpx.Client(transport=httpx.MockTransport(handler), headers=headers_on_client or {}) + with Lenz(api_key=KEY, http_client=http) as c: + c._request("GET", "/") + c._request("POST", "/verify", json={"claim": "x"}, headers={"X-Lenz-API-Version": OLD}) + return seen + + +def test_a_custom_http_client_without_the_header_still_sends_it() -> None: + assert _seen_versions(None) == [API_VERSION, API_VERSION] + + +def test_a_custom_http_client_with_a_stale_default_is_overridden() -> None: + assert _seen_versions({"X-Lenz-API-Version": OLD}) == [API_VERSION, API_VERSION] + + +def test_the_default_client_sends_it() -> None: + with Lenz(api_key=KEY, max_retries=0) as c, respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/me/usage").respond(200, json={}) + c._request("GET", "/me/usage") + assert route.calls.last.request.headers["X-Lenz-API-Version"] == "2026-10-11" + + +# ── The answer's version ── + + +@pytest.fixture +def client() -> Any: + c = Lenz(api_key=KEY, max_retries=0) + yield c + c.close() + + +def test_an_answer_in_another_version_raises(client: Lenz) -> None: + body = {"status": "ready", "claim": "A.", "identified_claims": []} + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.post("/extract").respond(200, json=body, headers={"X-Lenz-API-Version": OLD}) + with pytest.raises(LenzApiVersionError) as info: + client.extract(text="A.") + exc = info.value + assert isinstance(exc, LenzError) + assert exc.api_version == OLD + assert exc.status_code == 200 + assert exc.body == body + assert OLD in exc.message and "2026-10-11" in exc.message + assert "lenz-io 2.x reads both versions" in exc.fix + + +def test_an_error_answered_in_another_version_raises_the_version_error(client: Lenz) -> None: + body = {"detail": "No remaining credits.", "code": "no_credits"} + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.post("/verify").respond(402, json=body, headers={"X-Lenz-API-Version": OLD}) + with pytest.raises(LenzApiVersionError) as info: + client.verify("A.") + assert info.value.status_code == 402 + assert info.value.body == body + + +def test_a_404_delete_in_another_version_is_not_read_as_already_deleted(client: Lenz) -> None: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.delete("/verifications/v1").respond( + 404, json={"detail": "Not found."}, headers={"X-Lenz-API-Version": OLD} + ) + with pytest.raises(LenzApiVersionError): + client.verifications.delete("v1") + + +def test_a_non_json_answer_in_another_version_still_raises(client: Lenz) -> None: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/me/usage").respond(200, content=b"", headers={"X-Lenz-API-Version": OLD}) + with pytest.raises(LenzApiVersionError) as info: + client.usage() + assert info.value.body is None + + +def test_the_current_version_and_a_missing_header_proceed(client: Lenz) -> None: + usage = load("canonical", "account__me_usage_free.json")["body"] + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/me/usage").respond(200, json=usage, headers={"X-Lenz-API-Version": API_VERSION}) + assert client.usage().plan + mock.get("/me/usage").respond(200, json=usage) + assert client.usage().plan + + +@pytest.mark.parametrize( + ("call", "name"), + [ + (lambda c: c.verify("A.", idempotency_key="k"), "verify__stored_replay_202.json"), + (lambda c: c.assess("A.", idempotency_key="k"), "assess__stored_replay_200.json"), + (lambda c: c.extract(text="A.", idempotency_key="k"), "extract__stored_replay_200.json"), + (lambda c: c.review("A.", idempotency_key="k"), "review__stored_replay_202.json"), + ], +) +def test_a_stored_replay_in_the_old_shape_is_refused(client: Lenz, call: Any, name: str) -> None: + """The API answers a replay of an idempotent request stored by an older + release in the old shape, and says so in the header.""" + fixture = load("legacy", name) + path = "/" + name.split("__", 1)[0] + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.post(path).respond(fixture["status"], json=fixture["body"], headers={"X-Lenz-API-Version": OLD}) + with pytest.raises(LenzApiVersionError) as info: + call(client) + assert info.value.api_version == OLD + assert info.value.body == fixture["body"] + + +def test_the_guard_does_not_apply_to_webhook_parsing() -> None: + event = load("legacy", "webhook__verification_completed.json")["body"] + assert parse_webhook(event).event == "verification.completed" + + +def test_the_error_is_exported_and_pickles_its_fields() -> None: + assert "LenzApiVersionError" in lenz_io.__all__ + exc = LenzApiVersionError(message="m", status_code=200, body={"a": 1}, api_version=OLD) + assert (exc.api_version, exc.body, exc.status_code) == (OLD, {"a": 1}, 200) + assert json.dumps(exc.body) + + +def test_waiting_never_swallows_the_version_error(client: Lenz) -> None: + # A version error is never read as a slow poll: it stops that id at once + # (``wait`` raises it; a batch marks that item failed). + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get(url__regex=r".*/verify/.*").respond( + 200, json={"status": "processing"}, headers={"X-Lenz-API-Version": OLD} + ) + terminal, timed_out, stopped = client._poll_to_terminal(["a" * 32], 5) + with pytest.raises(LenzApiVersionError): + client.wait("a" * 32, timeout=5) + assert isinstance(stopped["a" * 32], LenzApiVersionError) + assert terminal == {} and timed_out == set() + assert route.call_count == 2 diff --git a/tests/test_cancel.py b/tests/test_cancel.py new file mode 100644 index 0000000..90c1b92 --- /dev/null +++ b/tests/test_cancel.py @@ -0,0 +1,460 @@ +"""Stopping a run: ``cancel``, ``cancel_review`` and ``cancel_citecheck``. + +The bodies are the API's own (``tests/fixtures/contract/cancel_*.json`` and +``error_cancel_*.json``). A cancel is safe to repeat, so it carries no +``Idempotency-Key`` and no body; it answers 200 whatever the state of the run. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest +import respx + +from lenz_io import ( + CancelResult, + Citecheck, + Lenz, + LenzApiVersionError, + LenzAuthError, + LenzError, + LenzNotFoundError, + LenzPipelineError, + ReviewFull, +) + +BASE = "https://lenz.io/api/v1" +FIXTURES = Path(__file__).parent / "fixtures" / "contract" +TASK = "3f2a9c1e5b7d4a608c1d2e3f4a5b6c7d" +REVIEW = "d6b2bd72" +CHECK = "12bbbf65" + + +def _load(name: str) -> dict[str, Any]: + return json.loads((FIXTURES / name).read_text()) + + +@pytest.fixture() +def slept(monkeypatch: pytest.MonkeyPatch) -> list[float]: + calls: list[float] = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: calls.append(s)) + return calls + + +# ── cancel(task_id) ── + + +class TestCancel: + def test_a_run_it_stopped(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel").respond(200, json=_load("cancel_verify_cancelled.json")) + result = client.cancel(TASK) + assert isinstance(result, CancelResult) + assert (result.task_id, result.cancelled, result.status) == (TASK, True, "cancelled") + assert route.call_count == 1 + + def test_a_run_that_finished_first_is_not_an_error(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.post(f"/verify/{TASK}/cancel").respond(200, json=_load("cancel_verify_completed.json")) + result = client.cancel(TASK) + assert (result.task_id, result.cancelled, result.status) == (TASK, False, "completed") + + def test_the_request_is_a_bare_post_with_no_key(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel").respond(200, json=_load("cancel_verify_cancelled.json")) + client.cancel(TASK) + request = route.calls.last.request + assert request.method == "POST" + assert request.content == b"" + assert "Idempotency-Key" not in request.headers + assert request.headers["X-Lenz-API-Version"] == "2026-10-11" + assert request.headers["Authorization"].startswith("Bearer ") + + def test_a_field_the_server_adds_is_kept(self, client: Lenz) -> None: + body = _load("cancel_verify_cancelled.json") | {"refunded": 10} + with respx.mock(base_url=BASE) as r: + r.post(f"/verify/{TASK}/cancel").respond(200, json=body) + result = client.cancel(TASK) + assert result.model_dump()["refunded"] == 10 + + @pytest.mark.parametrize("task_id", ["", None]) + def test_an_empty_id_sends_nothing(self, client: Lenz, task_id: Any) -> None: + with respx.mock(base_url=BASE, assert_all_called=False) as r: + route = r.post(url__regex=r".*").respond(200, json={}) + with pytest.raises(ValueError, match="cancel"): + client.cancel(task_id) + assert route.call_count == 0 + + def test_a_wait_after_a_cancel_raises_the_failed_error(self, client: Lenz, slept: list[float]) -> None: + status = {"status": "cancelled", "task_id": TASK} + with respx.mock(base_url=BASE) as r: + r.post(f"/verify/{TASK}/cancel").respond(200, json=_load("cancel_verify_cancelled.json")) + r.get(f"/verify/status/{TASK}").respond(200, json=status) + assert client.cancel(TASK).cancelled is True + with pytest.raises(LenzPipelineError) as ei: + client.wait(TASK, timeout=30) + assert (ei.value.failure_class, ei.value.retryable) == ("cancelled", False) + + @pytest.mark.parametrize("name", ["error_cancel_task_404.json"]) + def test_an_unknown_other_account_or_web_task_is_a_not_found( + self, client: Lenz, slept: list[float], name: str + ) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel").respond(404, json=_load(name)) + with pytest.raises(LenzNotFoundError) as ei: + client.cancel(TASK) + assert (ei.value.status_code, ei.value.code, ei.value.retryable) == (404, "not_found", False) + assert ei.value.body["code"] == "not_found" + assert route.call_count == 1 and slept == [] + + def test_a_reviews_deep_check_is_a_409_that_names_the_review_door(self, client: Lenz, slept: list[float]) -> None: + body = _load("error_cancel_use_review_cancel_409.json") + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel").respond(409, json=body) + with pytest.raises(LenzError) as ei: + client.cancel(TASK) + err = ei.value + assert (err.status_code, err.code) == (409, "use_review_cancel") + assert err.body == body + assert err.retryable is False + assert err.idempotency_key is None + assert "cancel_review" in err.fix + # Not the in-flight 409: one request, no wait, no resend. + assert route.call_count == 1 + assert slept == [] + + def test_the_review_409_is_not_resent_even_when_it_states_a_wait(self, client: Lenz, slept: list[float]) -> None: + body = _load("error_cancel_use_review_cancel_409.json") | {"retry_after": 1} + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel").respond(409, json=body, headers={"Retry-After": "1"}) + with pytest.raises(LenzError) as ei: + client.cancel(TASK) + assert ei.value.code == "use_review_cancel" + assert route.call_count == 1 and slept == [] + + def test_an_in_flight_409_is_not_a_reason_to_resend_either(self, client: Lenz, slept: list[float]) -> None: + # The resend rule is for a request that carries a key; a cancel has none. + body = {"detail": "busy", "code": "idempotency_conflict"} + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel").respond(409, json=body) + with pytest.raises(LenzError): + client.cancel(TASK) + assert route.call_count == 1 and slept == [] + + def test_a_server_error_is_retried_like_any_safe_call(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel") + route.side_effect = [ + httpx.Response(502, json={"detail": "bad gateway"}), + httpx.Response(200, json=_load("cancel_verify_cancelled.json")), + ] + result = client.cancel(TASK) + assert result.cancelled is True + assert route.call_count == 2 + assert len(slept) == 1 + + def test_a_dropped_connection_is_retried(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/verify/{TASK}/cancel") + route.side_effect = [ + httpx.ConnectError("reset"), + httpx.Response(200, json=_load("cancel_verify_cancelled.json")), + ] + assert client.cancel(TASK).cancelled is True + assert route.call_count == 2 + + def test_a_bad_key_is_an_auth_error(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.post(f"/verify/{TASK}/cancel").respond(401, json={"detail": "Invalid API key.", "code": "invalid_key"}) + with pytest.raises(LenzAuthError): + client.cancel(TASK) + + def test_it_needs_a_key(self, unauth_client: Lenz) -> None: + with respx.mock(base_url=BASE, assert_all_called=False) as r: + route = r.post(url__regex=r".*").respond(200, json={}) + with pytest.raises(LenzAuthError): + unauth_client.cancel(TASK) + assert route.call_count == 0 + + def test_an_answer_in_another_version_is_refused(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.post(f"/verify/{TASK}/cancel").respond( + 200, + json={"task_id": TASK, "cancelled": True, "status": "failed"}, + headers={"X-Lenz-API-Version": "2026-05-13"}, + ) + with pytest.raises(LenzApiVersionError): + client.cancel(TASK) + + +# ── cancel_review(review_id) ── + + +class TestCancelReview: + def test_a_review_it_stopped_is_the_full_view(self, client: Lenz) -> None: + body = _load("cancel_review_cancelled.json") + with respx.mock(base_url=BASE) as r: + route = r.post(f"/reviews/{REVIEW}/cancel").respond(200, json=body) + review = client.cancel_review(REVIEW) + assert isinstance(review, ReviewFull) + assert (review.review_id, review.status, review.outcome) == (REVIEW, "cancelled", "incomplete") + assert review.credits.charged == 2 + assert len(review.claims) == 2 + assert route.call_count == 1 + + def test_it_is_the_model_get_review_returns(self, client: Lenz) -> None: + body = _load("cancel_review_cancelled.json") + with respx.mock(base_url=BASE) as r: + r.post(f"/reviews/{REVIEW}/cancel").respond(200, json=body) + r.get(f"/reviews/{REVIEW}").respond(200, json=body) + cancelled = client.cancel_review(REVIEW) + read = client.get_review(REVIEW) + assert type(cancelled) is type(read) + assert cancelled.model_dump() == read.model_dump() + + @pytest.mark.parametrize( + ("name", "status"), + [ + ("cancel_review_completed.json", "completed"), + ("cancel_review_already_cancelled.json", "cancelled"), + ], + ) + def test_a_review_that_ended_is_returned_as_it_stands(self, client: Lenz, name: str, status: str) -> None: + with respx.mock(base_url=BASE) as r: + r.post(f"/reviews/{REVIEW}/cancel").respond(200, json=_load(name)) + assert client.cancel_review(REVIEW).status == status + + def test_the_request_is_a_bare_post_with_no_key(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/reviews/{REVIEW}/cancel").respond(200, json=_load("cancel_review_cancelled.json")) + client.cancel_review(REVIEW) + request = route.calls.last.request + assert request.method == "POST" + assert request.content == b"" + assert "Idempotency-Key" not in request.headers + assert request.url.params.get("view") is None + + @pytest.mark.parametrize("review_id", ["", None]) + def test_an_empty_id_sends_nothing(self, client: Lenz, review_id: Any) -> None: + with respx.mock(base_url=BASE, assert_all_called=False) as r: + route = r.post(url__regex=r".*").respond(200, json={}) + with pytest.raises(ValueError, match="cancel_review"): + client.cancel_review(review_id) + assert route.call_count == 0 + + def test_an_unknown_or_other_accounts_review_is_a_not_found(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/reviews/{REVIEW}/cancel").respond(404, json=_load("error_cancel_review_404.json")) + with pytest.raises(LenzNotFoundError) as ei: + client.cancel_review(REVIEW) + assert (ei.value.status_code, ei.value.code) == (404, "not_found") + assert ei.value.body["code"] == "not_found" + assert route.call_count == 1 and slept == [] + + def test_a_server_error_is_retried(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/reviews/{REVIEW}/cancel") + route.side_effect = [ + httpx.Response(503, json={"detail": "down"}), + httpx.Response(200, json=_load("cancel_review_cancelled.json")), + ] + assert client.cancel_review(REVIEW).status == "cancelled" + assert route.call_count == 2 + + def test_an_answer_in_another_version_is_refused(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.post(f"/reviews/{REVIEW}/cancel").respond( + 200, json=_load("cancel_review_cancelled.json"), headers={"X-Lenz-API-Version": "2026-05-13"} + ) + with pytest.raises(LenzApiVersionError): + client.cancel_review(REVIEW) + + def test_it_needs_a_key(self, unauth_client: Lenz) -> None: + with pytest.raises(LenzAuthError): + unauth_client.cancel_review(REVIEW) + + +# ── cancel_citecheck(citecheck_id) ── + + +class TestCancelCitecheck: + def test_a_check_it_stopped_is_the_citecheck_model(self, client: Lenz) -> None: + body = _load("cancel_citecheck_cancelled.json") + with respx.mock(base_url=BASE) as r: + route = r.post(f"/citechecks/{CHECK}/cancel").respond(200, json=body) + check = client.cancel_citecheck(CHECK) + assert isinstance(check, Citecheck) + assert (check.citecheck_id, check.status, check.outcome) == (CHECK, "cancelled", "incomplete") + assert check.credits.charged == 0 + assert route.call_count == 1 + + def test_it_is_the_model_get_citecheck_returns(self, client: Lenz) -> None: + body = _load("cancel_citecheck_cancelled.json") + with respx.mock(base_url=BASE) as r: + r.post(f"/citechecks/{CHECK}/cancel").respond(200, json=body) + r.get(f"/citechecks/{CHECK}").respond(200, json=body) + cancelled = client.cancel_citecheck(CHECK) + read = client.get_citecheck(CHECK) + assert type(cancelled) is type(read) + assert cancelled.model_dump() == read.model_dump() + + @pytest.mark.parametrize( + ("name", "status"), + [ + ("cancel_citecheck_completed.json", "completed"), + ("cancel_citecheck_already_cancelled.json", "cancelled"), + ], + ) + def test_a_check_that_ended_is_returned_as_it_stands(self, client: Lenz, name: str, status: str) -> None: + with respx.mock(base_url=BASE) as r: + r.post(f"/citechecks/{CHECK}/cancel").respond(200, json=_load(name)) + assert client.cancel_citecheck(CHECK).status == status + + def test_the_request_is_a_bare_post_with_no_key(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/citechecks/{CHECK}/cancel").respond(200, json=_load("cancel_citecheck_cancelled.json")) + client.cancel_citecheck(CHECK) + request = route.calls.last.request + assert request.method == "POST" + assert request.content == b"" + assert "Idempotency-Key" not in request.headers + + @pytest.mark.parametrize("citecheck_id", ["", None]) + def test_an_empty_id_sends_nothing(self, client: Lenz, citecheck_id: Any) -> None: + with respx.mock(base_url=BASE, assert_all_called=False) as r: + route = r.post(url__regex=r".*").respond(200, json={}) + with pytest.raises(ValueError, match="cancel_citecheck"): + client.cancel_citecheck(citecheck_id) + assert route.call_count == 0 + + @pytest.mark.parametrize("name", ["error_cancel_citecheck_404.json"]) + def test_an_unknown_check_or_a_review_id_is_a_not_found(self, client: Lenz, slept: list[float], name: str) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/citechecks/{CHECK}/cancel").respond(404, json=_load(name)) + with pytest.raises(LenzNotFoundError) as ei: + client.cancel_citecheck(CHECK) + assert (ei.value.status_code, ei.value.code) == (404, "not_found") + assert ei.value.body["code"] == "not_found" + assert route.call_count == 1 and slept == [] + + def test_a_server_error_is_retried(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(f"/citechecks/{CHECK}/cancel") + route.side_effect = [ + httpx.Response(500, json={"detail": "oops"}), + httpx.Response(200, json=_load("cancel_citecheck_cancelled.json")), + ] + assert client.cancel_citecheck(CHECK).status == "cancelled" + assert route.call_count == 2 + + def test_an_answer_in_another_version_is_refused(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.post(f"/citechecks/{CHECK}/cancel").respond( + 200, json=_load("cancel_citecheck_cancelled.json"), headers={"X-Lenz-API-Version": "2026-05-13"} + ) + with pytest.raises(LenzApiVersionError): + client.cancel_citecheck(CHECK) + + def test_it_needs_a_key(self, unauth_client: Lenz) -> None: + with pytest.raises(LenzAuthError): + unauth_client.cancel_citecheck(CHECK) + + +def test_the_cancel_result_is_lax_like_the_other_models() -> None: + result = CancelResult.model_validate({}) + assert (result.task_id, result.cancelled, result.status) == ("", False, "") + + +@pytest.mark.parametrize("path", ["/verify/t1/cancel", "/reviews/r1/cancel", "/citechecks/c1/cancel"]) +@pytest.mark.parametrize( + ("status", "code"), [(401, "not_authenticated"), (404, "not_found"), (422, "validation_error")] +) +def test_a_cancel_error_keeps_the_servers_code(path: str, status: int, code: str) -> None: + from lenz_io.errors import map_response_to_error + + err = map_response_to_error(status, json.dumps({"detail": "x", "code": code}).encode(), {}, endpoint=("POST", path)) + assert err.code == code + + +def test_another_endpoints_404_keeps_the_empty_code_2x_had() -> None: + from lenz_io.errors import map_response_to_error + + err = map_response_to_error( + 404, json.dumps({"detail": "x", "code": "not_found"}).encode(), {}, endpoint=("GET", "/verify/status/t1") + ) + assert err.code == "" + + +# ── an answer that is not the cancel's ── + + +@pytest.mark.parametrize( + "body", + [ + {}, + [], + "ok", + {"task_id": TASK}, + {"cancelled": True, "status": "cancelled"}, + {"task_id": "", "cancelled": True, "status": "cancelled"}, + {"task_id": "someone-else", "cancelled": True, "status": "cancelled"}, + {"task_id": TASK, "cancelled": "yes", "status": "cancelled"}, + {"task_id": TASK, "cancelled": True}, + ], +) +def test_a_cancel_answer_that_is_not_a_cancel_result_is_an_error(client: Lenz, body: Any) -> None: + from lenz_io import LenzAPIError + + with respx.mock(base_url=BASE) as r: + r.post(f"/verify/{TASK}/cancel").respond(200, json=body) + with pytest.raises(LenzAPIError) as ei: + client.cancel(TASK) + assert "unexpected" in ei.value.message.lower() + + +@pytest.mark.parametrize( + "body", + [ + {}, + {"review_id": REVIEW}, + {"status": "cancelled"}, + [], + {"review_id": "other", "status": "cancelled", "issues": [], "failures": [], "claims": []}, + ], +) +def test_a_review_cancel_answer_that_is_not_the_review_is_an_error(client: Lenz, body: Any) -> None: + from lenz_io import LenzAPIError + + with respx.mock(base_url=BASE) as r: + r.post(f"/reviews/{REVIEW}/cancel").respond(200, json=body) + with pytest.raises(LenzAPIError): + client.cancel_review(REVIEW) + + +@pytest.mark.parametrize( + "body", + [ + {}, + {"citecheck_id": CHECK}, + {"status": "cancelled"}, + [], + { + "citecheck_id": "other", + "status": "cancelled", + "citations": [], + "citation_issues": [], + "citation_failures": [], + }, + ], +) +def test_a_citecheck_cancel_answer_that_is_not_the_check_is_an_error(client: Lenz, body: Any) -> None: + from lenz_io import LenzAPIError + + with respx.mock(base_url=BASE) as r: + r.post(f"/citechecks/{CHECK}/cancel").respond(200, json=body) + with pytest.raises(LenzAPIError): + client.cancel_citecheck(CHECK) diff --git a/tests/test_cancelled.py b/tests/test_cancelled.py new file mode 100644 index 0000000..f94ac7d --- /dev/null +++ b/tests/test_cancelled.py @@ -0,0 +1,441 @@ +"""A task cancelled elsewhere (the website's Stop button, another process) is +a terminal answer. + +In API version 2026-10-11 ``cancelled`` is a status of its own. In 2026-05-13 +the same thing read ``failed`` with ``failure_class: "cancelled"``, and 2.x +raised the failed error for it. A 3.x wait must end the same way, at once, +instead of polling to its timeout. The recorded bodies are the API's own +(``tests/fixtures/parity/``).""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +import respx +from parity_observe import load + +from lenz_io import ( + CitecheckEvent, + CitecheckFailed, + Lenz, + LenzPipelineError, + LenzTimeoutError, + ReviewEvent, + ReviewFailed, + TaskStatus, + VerificationCancelled, + WebhookEvent, + parse_webhook, +) +from lenz_io.models import Citecheck, ReviewFull + +BASE = "https://lenz.io/api/v1" +_RUNNING = {"status": "processing", "task_id": "t", "progress": {"step": "research"}} +_DONE = {"status": "completed", "task_id": "u", "result": {"verification_id": "v1", "claim": "A."}} + + +def _body(name: str, shape: str = "canonical") -> dict[str, Any]: + return load(shape, name)["body"] + + +@pytest.fixture() +def slept(monkeypatch: pytest.MonkeyPatch) -> list[float]: + calls: list[float] = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: calls.append(s)) + return calls + + +# ── /verify/status ── + + +@pytest.mark.parametrize("name", ["verify__status_cancelled_live.json", "verify__status_cancelled_durable.json"]) +class TestAVerificationCancelledElsewhere: + def test_get_status_returns_it_and_does_not_raise(self, client: Lenz, name: str) -> None: + body = _body(name) + with respx.mock(base_url=BASE) as r: + r.get(f"/verify/status/{body['task_id']}").respond(200, json=body) + status = client.get_status(body["task_id"]) + assert isinstance(status, TaskStatus) + assert status.status == "cancelled" + assert status.task_id == body["task_id"] + assert status.result is None + # The block 2.x read for a run cancelled while running, as the Node SDK fills it. + assert status.failure is not None and status.failure.code == "cancelled" + + def test_wait_raises_the_failed_error_at_once(self, client: Lenz, slept: list[float], name: str) -> None: + body = _body(name) + with respx.mock(base_url=BASE) as r: + poll = r.get(f"/verify/status/{body['task_id']}") + poll.side_effect = [ + httpx.Response(200, json=_RUNNING | {"task_id": body["task_id"]}), + httpx.Response(200, json=body), + ] + with pytest.raises(LenzPipelineError) as ei: + client.wait(body["task_id"], timeout=300) + err = ei.value + assert not isinstance(err, LenzTimeoutError) + assert poll.call_count == 2 + assert (err.failure_class, err.retryable, err.failure_reason) == ("cancelled", False, "cancelled") + assert err.task_id == body["task_id"] + assert "cancelled" in err.message.lower() + + def test_verify_and_wait_raises_it(self, client: Lenz, slept: list[float], name: str) -> None: + body = _body(name) + with respx.mock(base_url=BASE) as r: + r.post("/verify").respond(200, json={"task_id": body["task_id"]}) + poll = r.get(f"/verify/status/{body['task_id']}").respond(200, json=body) + with pytest.raises(LenzPipelineError) as ei: + client.verify_and_wait("A.", timeout=300) + assert poll.call_count == 1 + assert ei.value.failure_class == "cancelled" and ei.value.retryable is False + + def test_it_is_the_error_2x_raised_for_the_original_shape( + self, client: Lenz, slept: list[float], name: str + ) -> None: + new, old = _body(name), _body(name, "legacy") + raised = [] + for body in (new, old): + with respx.mock(base_url=BASE) as r: + r.get(f"/verify/status/{body['task_id']}").respond(200, json=body) + with pytest.raises(LenzPipelineError) as ei: + client.wait(body["task_id"], timeout=300) + raised.append(ei.value) + a, b = raised + for attr in ("failure_class", "retryable", "failure_reason", "task_id", "doc_url", "hint"): + assert getattr(a, attr) == getattr(b, attr), attr + + +def test_a_batch_item_cancelled_elsewhere_is_a_failed_item(client: Lenz, slept: list[float]) -> None: + gone = _body("verify__status_cancelled_live.json") + with respx.mock(base_url=BASE) as r: + r.post("/verify/batch").respond( + 200, + json={ + "batch_id": "b", + "items": [{"task_id": gone["task_id"], "claim": "A."}, {"task_id": "u", "claim": "B."}], + }, + ) + cancelled = r.get(f"/verify/status/{gone['task_id']}").respond(200, json=gone) + done = r.get("/verify/status/u") + done.side_effect = [httpx.Response(200, json=_RUNNING | {"task_id": "u"}), httpx.Response(200, json=_DONE)] + results = client.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], timeout=300) + assert [x.status for x in results] == ["failed", "completed"] + assert results[0].verification is None + assert results[0].status_detail is not None and results[0].status_detail.status == "cancelled" + assert cancelled.call_count == 1, "a cancelled item is not polled again" + assert done.call_count == 2 + + +def test_the_batch_waits_for_nothing_but_the_running_ones(client: Lenz, slept: list[float]) -> None: + """A batch whose only item is cancelled returns without sleeping to a timeout.""" + gone = _body("verify__status_cancelled_durable.json") + with respx.mock(base_url=BASE) as r: + r.post("/verify/batch").respond( + 200, json={"batch_id": "b", "items": [{"task_id": gone["task_id"], "claim": "A."}]} + ) + r.get(f"/verify/status/{gone['task_id']}").respond(200, json=gone) + results = client.verify_batch_and_wait(claims=[{"claim": "A."}], timeout=300) + assert [x.status for x in results] == ["failed"] + assert slept == [] + + +# ── /review ── + + +class TestAReviewCancelledElsewhere: + def test_get_review_returns_it_and_does_not_raise(self, client: Lenz) -> None: + body = _body("review__get_cancelled.json") + with respx.mock(base_url=BASE) as r: + r.get(f"/reviews/{body['review_id']}").respond(200, json=body) + review = client.get_review(body["review_id"], view="full") + assert isinstance(review, ReviewFull) + assert review.status == "cancelled" and review.outcome == "incomplete" + assert review.failure is None + + def test_review_and_wait_raises_the_failed_error_at_once(self, client: Lenz, slept: list[float]) -> None: + body = _body("review__get_cancelled.json") + rid = body["review_id"] + running = {**body, "status": "verifying", "outcome": None, "completed_at": None, "poll_after_seconds": 5} + with respx.mock(base_url=BASE) as r: + r.post("/review").respond(202, json={"review_id": rid, "status": "queued"}) + poll = r.get(f"/reviews/{rid}") + poll.side_effect = [httpx.Response(200, json=running), httpx.Response(200, json=body)] + with pytest.raises(ReviewFailed) as ei: + client.review_and_wait("draft", timeout=300) + err = ei.value + assert poll.call_count == 2 + assert (err.failure_class, err.retryable, err.failure_reason, err.error_code) == ( + "cancelled", + False, + "cancelled", + "cancelled", + ) + assert err.review_id == rid and err.review is not None and err.review.status == "cancelled" + assert "cancelled" in err.message.lower() + + def test_it_is_the_error_2x_raised_for_the_original_shape(self, client: Lenz, slept: list[float]) -> None: + raised = [] + for shape in ("canonical", "legacy"): + body = _body("review__get_cancelled.json", shape) + with respx.mock(base_url=BASE) as r: + r.get(f"/reviews/{body['review_id']}").respond(200, json=body) + with pytest.raises(ReviewFailed) as ei: + client._wait_review(body["review_id"], timeout=300) + raised.append(ei.value) + a, b = raised + for attr in ( + "failure_class", + "retryable", + "failure_reason", + "error_code", + "doc_url", + "hint", + "message", + "cause", + "fix", + ): + assert getattr(a, attr) == getattr(b, attr), attr + + +# ── /citecheck ── + + +class TestACitecheckCancelledElsewhere: + def test_get_citecheck_returns_it_and_does_not_raise(self, client: Lenz) -> None: + body = _body("citecheck__get_cancelled.json") + with respx.mock(base_url=BASE) as r: + r.get(f"/citechecks/{body['citecheck_id']}").respond(200, json=body) + check = client.get_citecheck(body["citecheck_id"]) + assert isinstance(check, Citecheck) + assert check.status == "cancelled" and check.failure is None + + def test_citecheck_and_wait_raises_the_failed_error_at_once(self, client: Lenz, slept: list[float]) -> None: + body = _body("citecheck__get_cancelled.json") + cid = body["citecheck_id"] + running = {**body, "status": "checking", "outcome": None, "completed_at": None, "poll_after_seconds": 5} + with respx.mock(base_url=BASE) as r: + r.post("/citecheck").respond(202, json={"citecheck_id": cid, "status": "queued"}) + poll = r.get(f"/citechecks/{cid}") + poll.side_effect = [httpx.Response(200, json=running), httpx.Response(200, json=body)] + with pytest.raises(CitecheckFailed) as ei: + client.citecheck_and_wait(text="See https://example.gov/report-2024.", timeout=300) + err = ei.value + assert poll.call_count == 2 + assert (err.failure_class, err.retryable, err.failure_reason, err.error_code) == ( + "cancelled", + False, + "cancelled", + "cancelled", + ) + assert err.citecheck_id == cid and err.citecheck is not None and err.citecheck.status == "cancelled" + assert "cancelled" in err.message.lower() + + def test_it_is_the_error_2x_raised_for_the_original_shape(self, client: Lenz, slept: list[float]) -> None: + raised = [] + for shape in ("canonical", "legacy"): + body = _body("citecheck__get_cancelled.json", shape) + with respx.mock(base_url=BASE) as r: + r.get(f"/citechecks/{body['citecheck_id']}").respond(200, json=body) + with pytest.raises(CitecheckFailed) as ei: + client._wait_citecheck(body["citecheck_id"], timeout=300) + raised.append(ei.value) + a, b = raised + for attr in ( + "failure_class", + "retryable", + "failure_reason", + "error_code", + "doc_url", + "hint", + "message", + "cause", + "fix", + ): + assert getattr(a, attr) == getattr(b, attr), attr + + +# ── webhooks ── + + +class TestCancelledWebhooks: + def test_verification_cancelled(self) -> None: + raw = _body("webhook__verification_cancelled.json") + event = parse_webhook(json.dumps(raw)) + assert isinstance(event, VerificationCancelled) + assert (event.event, event.status, event.task_id) == ("verification.cancelled", "cancelled", raw["task_id"]) + assert event.event_id == raw["event_id"] and event.attempt == 1 + assert event.delivered_at == raw["delivered_at"] + assert isinstance(event.verification, TaskStatus) + assert event.verification.status == "cancelled" and event.verification.task_id == raw["task_id"] + + def test_verification_cancelled_is_not_a_failed_event(self) -> None: + from lenz_io import VerificationFailed + + assert not issubclass(VerificationCancelled, VerificationFailed) + assert issubclass(VerificationCancelled, WebhookEvent) + + def test_a_cancelled_envelope_on_another_event_is_not_read_as_that_kind(self) -> None: + raw = _body("webhook__verification_cancelled.json") | {"event": "verification.failed"} + event = parse_webhook(raw) + assert event.verification is None # a failed event carrying a cancelled run + + @pytest.mark.parametrize("nested", [None, "absent"]) + def test_a_cancelled_event_without_a_nested_verification_is_a_cancelled_status(self, nested: Any) -> None: + raw = {k: v for k, v in _body("webhook__verification_cancelled.json").items() if k != "verification"} + if nested is None: + raw["verification"] = None + event = parse_webhook(raw) + assert isinstance(event, VerificationCancelled) + assert event.verification is not None + assert (event.verification.status, event.verification.task_id) == ("cancelled", raw["task_id"]) + + def test_an_event_that_names_no_known_kind_has_no_verification(self) -> None: + from lenz_io.webhooks import VerificationFailed + + event = VerificationFailed(event="verification.paused", task_id="t", raw={"event": "verification.paused"}) + assert event.verification is None + + def test_review_cancelled(self) -> None: + raw = _body("webhook__review_cancelled.json") + event = parse_webhook(raw) + assert isinstance(event, ReviewEvent) + assert (event.event, event.status, event.review_id, event.event_id) == ( + "review.cancelled", + "cancelled", + raw["review_id"], + raw["event_id"], + ) + assert event.task_id == raw["review_id"] + assert event.review is not None and event.review.status == "cancelled" + + def test_citecheck_cancelled(self) -> None: + raw = _body("webhook__citecheck_cancelled.json") + event = parse_webhook(raw) + assert isinstance(event, CitecheckEvent) + assert (event.event, event.status, event.citecheck_id, event.event_id) == ( + "citecheck.cancelled", + "cancelled", + raw["citecheck_id"], + raw["event_id"], + ) + assert event.task_id == raw["citecheck_id"] + assert event.citecheck is not None and event.citecheck.status == "cancelled" + + @pytest.mark.parametrize( + ("name", "cls"), + [ + ("webhook__verification_cancelled.json", "VerificationFailed"), + ("webhook__review_cancelled.json", "ReviewEvent"), + ("webhook__citecheck_cancelled.json", "CitecheckEvent"), + ], + ) + def test_a_cancellation_of_an_original_shape_submission_keeps_arriving_as_failed(self, name: str, cls: str) -> None: + raw = _body(name, "legacy") + event = parse_webhook(raw) + assert type(event).__name__ == cls + assert event.event.endswith(".failed") and event.status == "failed" + if cls == "VerificationFailed": + assert (event.error, event.failure_class, event.retryable) == ("cancelled", "cancelled", False) + + def test_an_unknown_event_still_parses_generically(self) -> None: + event = parse_webhook({"event": "verification.paused", "task_id": "t", "status": "paused"}) + assert type(event) is WebhookEvent + assert event.event == "verification.paused" + + +# ── the command line ── + + +class TestTheCli: + def _run(self, monkeypatch: pytest.MonkeyPatch, args: list[str], routes: dict[str, dict[str, Any]]): + from typer.testing import CliRunner + + from lenz_io.cli.app import app + + monkeypatch.setenv("LENZ_API_KEY", "lenz_" + "0" * 32) + with respx.mock(base_url=BASE) as r: + for path, body in routes.items(): + r.get(path).respond(200, json=body) + return CliRunner().invoke(app, args) + + def test_verify_resume_ends_like_a_failed_run(self, monkeypatch: pytest.MonkeyPatch) -> None: + body = _body("verify__status_cancelled_durable.json") + result = self._run( + monkeypatch, ["--json", "verify", "--resume", body["task_id"]], {f"/verify/status/{body['task_id']}": body} + ) + assert result.exit_code == 1 + assert json.loads(result.stdout)["error"]["code"] == "pipeline_failed" + assert "Unexpected status" not in result.output + + def test_review_resume_says_cancelled(self, monkeypatch: pytest.MonkeyPatch) -> None: + body = _body("review__get_cancelled.json") + result = self._run( + monkeypatch, ["review", "--resume", body["review_id"]], {f"/reviews/{body['review_id']}": body} + ) + # off a terminal the CLI prints JSON, which carries the status + assert json.loads(result.stdout)["status"] == "cancelled" + assert result.exit_code == 2 # incomplete: the same exit as any review that did not finish + + def test_citecheck_resume_says_cancelled(self, monkeypatch: pytest.MonkeyPatch) -> None: + body = _body("citecheck__get_cancelled.json") + result = self._run( + monkeypatch, ["citecheck", "--resume", body["citecheck_id"]], {f"/citechecks/{body['citecheck_id']}": body} + ) + assert json.loads(result.stdout)["status"] == "cancelled" + assert result.exit_code == 2 + + +def _styled(fn: Any, *args: Any) -> str: + """What a render prints to a terminal: the escape codes keep its styling.""" + import io + + from rich.console import Console + + from lenz_io.cli.render import Output + + buf = io.StringIO() + out = Output(json_mode=False, no_color=False) + out.json_mode = False + out.console = Console(file=buf, force_terminal=True, color_system="standard", width=100) + fn(out, *args) + return buf.getvalue() + + +class TestTheCliRenders: + def test_status_is_red_cancelled(self) -> None: + from lenz_io.cli.render import render_task_status + + text = _styled(render_task_status, TaskStatus.model_validate(_body("verify__status_cancelled_durable.json"))) + assert "\x1b[31mcancelled\x1b[0m" in text + + def test_a_batch_cell_and_its_details_say_cancelled(self) -> None: + from lenz_io.cli.render import _batch_status_cell, render_batch_details + + status = TaskStatus.model_validate(_body("verify__status_cancelled_durable.json")) + cell = _batch_status_cell(status) + assert cell.plain == "cancelled" and cell.style == "red" + text = _styled(render_batch_details, [(status.task_id, "A claim.")], {status.task_id: status}) + assert "Cancelled." in text and "Failed:" not in text and "resume:" not in text + + +def test_a_cancelled_status_with_a_null_failure_still_reads_the_2x_fields() -> None: + status = TaskStatus.model_validate({"status": "cancelled", "task_id": "t", "failure": None}) + assert (status.error, status.failure_class, status.failure_reason, status.retryable) == ( + "Cancelled.", + "cancelled", + "cancelled", + False, + ) + assert status.failure is not None + assert (status.failure.code, status.failure.detail, status.failure.retryable) == ("cancelled", "Cancelled.", False) + + +def test_the_original_shape_still_reads_as_a_failure(client: Lenz) -> None: + """A 2.x-shaped answer is not this release's concern, but the status it + carries stays what the server said.""" + body = _body("verify__status_cancelled_durable.json", "legacy") + status = TaskStatus.model_validate(body) + assert status.status == "failed" and status.failure_class == "cancelled" diff --git a/tests/test_cli.py b/tests/test_cli.py index 1051d39..490e746 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -482,9 +482,7 @@ def test_extract_pretty_renders_positions(): extracted = ExtractedClaims.model_validate( { "status": "ready", - "claim": "A rose 5%.", - "identified_claims": ["A rose 5%.", "B fell [bold]."], - "locations": [ + "claims": [ {"claim": "A rose 5%.", "positions": [{"start": 0, "end": 11, "text": "A grew 5 %."}]}, {"claim": "B fell [bold].", "positions": [{"start": 12, "end": 26, "text": "B fell [bold]."}]}, ], @@ -500,9 +498,7 @@ def test_extract_pretty_renders_url_positions_without_a_span(): extracted = ExtractedClaims.model_validate( { "status": "ready", - "claim": "A rose 5%.", - "identified_claims": [], - "locations": [{"claim": "A rose 5%.", "positions": [{"start": None, "end": None, "text": "A grew 5 %."}]}], + "claims": [{"claim": "A rose 5%.", "positions": [{"start": None, "end": None, "text": "A grew 5 %."}]}], } ) text = _render_pretty(extracted) @@ -510,7 +506,7 @@ def test_extract_pretty_renders_url_positions_without_a_span(): def test_extract_pretty_without_locations_prints_no_positions(): - text = _render_pretty(ExtractedClaims.model_validate({"status": "ready", "claim": "A rose 5%."})) + text = _render_pretty(ExtractedClaims.model_validate({"status": "ready", "claims": [{"claim": "A rose 5%."}]})) assert " at " not in text @@ -558,20 +554,18 @@ def test_extract_reads_stdin(monkeypatch): def test_extract_pretty_renders_atomic_claim(): - """Single-claim input fills atomic_claim, not identified_claims — the pretty - renderer must surface it (regression: it used to print 'no claims found').""" + """A single claim comes back as a list of one — the pretty renderer must + surface it (regression: it used to print 'no claims found').""" import io from rich.console import Console from lenz_io.cli.render import Output, render_extract - # The public /extract response carries the primary claim under `claim` - # (the server renames framing's internal `atomic_claim` → `claim`). extracted = ExtractedClaims.model_validate( { "domain": "History", - "claim": "Einstein won the 1921 Nobel Prize in Physics.", + "claims": [{"claim": "Einstein won the 1921 Nobel Prize in Physics."}], "key_entities": [{"name": "Albert Einstein", "type": "person"}], } ) @@ -586,9 +580,9 @@ def test_extract_pretty_renders_atomic_claim(): def test_extract_pretty_multi_claim_includes_primary(): - """The server puts the primary claim in atomic_claim and extras in - identified_claims — the rendered list must include BOTH (regression: the - primary used to be dropped from the list and only shown in the verify hint).""" + """Every claim in ``claims`` is rendered, the first included (regression: + the primary used to be dropped from the list and only shown in the verify + hint).""" import io from rich.console import Console @@ -598,8 +592,7 @@ def test_extract_pretty_multi_claim_includes_primary(): extracted = ExtractedClaims.model_validate( { "domain": "Science", - "claim": "The Earth is flat.", - "identified_claims": ["Ruby is harder than diamond."], + "claims": [{"claim": "The Earth is flat."}, {"claim": "Ruby is harder than diamond."}], } ) buf = io.StringIO() @@ -741,31 +734,50 @@ def test_render_assess_list_rows_show_hint_and_also_found(): out = _render( render_assess, - AssessResponse( - claims=[ - AssessClaim(claim="Water boils at 100 °C at sea level.", verdict="True", confidence="high"), - AssessClaim( - claim="Bilingual children develop stronger executive function.", - verdict="Mixed", - confidence="medium", - identified_claims=["Bilingual children learn to read later than monolingual peers."], - hint="Assessed the main claim only. Send identified_claims as their own items to check the rest.", - ), - AssessClaim( - claim="this is fine", - verdict="Error", - confidence="low", - error_code="no_claim", - hint="No factual statement that can be checked against evidence was found in the input.", - ), - AssessClaim( - claim="The Eiffel Tower is 330 metres tall.", - verdict="Error", - confidence="low", - error_code="timeout", - hint="This item was not processed inside the call's time budget; nothing was charged.", - ), - ] + AssessResponse.model_validate( + { + "status": "ok", + "claims": [ + { + "claim": "Water boils at 100 °C at sea level.", + "status": "completed", + "verdict": "True", + "confidence": "high", + "failure": None, + "more_claims": [], + }, + { + "claim": "Bilingual children develop stronger executive function.", + "status": "completed", + "verdict": "Mixed", + "confidence": "medium", + "failure": None, + "more_claims": ["Bilingual children learn to read later than monolingual peers."], + }, + { + "claim": "this is fine", + "status": "failed", + "verdict": None, + "confidence": None, + "failure": { + "code": "no_checkable_claim", + "hint": "No factual statement that can be checked against evidence was found in the input.", + }, + "more_claims": [], + }, + { + "claim": "The Eiffel Tower is 330 metres tall.", + "status": "failed", + "verdict": None, + "confidence": None, + "failure": { + "code": "timeout", + "hint": "This item was not processed inside the call's time budget; nothing was charged.", + }, + "more_claims": [], + }, + ], + } ), ) lines = out.splitlines() @@ -894,7 +906,7 @@ def _pool_usage(**overrides): payload = { "plan": "pro", "quota_resets_at": "2026-09-01", - "credits": {"total": 5200, "used": 130, "remaining": 5070, "bonus": 200, "resets_at": "2026-09-01"}, + "credits": {"total": 5200, "used": 130, "remaining": 5070, "extra": 200, "resets_at": "2026-09-01"}, "costs": {"verify": 10, "assess": 1, "ask": 1, "extract": 0}, "cost_options": {"verify": {"depth": {"standard": 10, "low": 5}}}, "verify": {"quota_used": 13, "quota_total": 520, "quota_remaining": 507, "bonus": 20, "remaining": 507}, @@ -959,8 +971,8 @@ def test_usage_pretty_leads_with_the_credit_balance(): assert "1 credit each" in text # assess/ask are the unit — singular assert text.index("credits left") < text.index("Verify:") assert text.index("Verify:") < text.index("Ask:") < text.index("Assess:") < text.index("Extract:") - # No extra-credit tail on Ask (its bonus == 0 there) - assert "extra" not in text.split("Ask:")[1].split("Assess:")[0] + # The extra credits show on every row, in that capability's unit. + assert "+ 200 extra" in text.split("Ask:")[1].split("Assess:")[0] assert "4 / 1000 today" in text # Humanized: absolute date always present; the relative prefix ("in N days") # is wall-clock-dependent so isn't asserted here (see _humanize_reset unit test). @@ -1028,23 +1040,12 @@ def test_usage_pretty_never_reads_the_deprecated_alias(): assert "20 extra" in text -def test_usage_pretty_pre_pool_server_has_no_balance_headline(): - """A server predating the pool sends no `credits` block. Print the rows it - did send rather than a confident '0 credits left'.""" - text = _render_usage_text( - _usage( - plan="plus", - quota_resets_at="2026-09-01", - verify={"quota_used": 5, "quota_total": 100, "quota_remaining": 95, "credits": 0, "remaining": 95}, - ask={"quota_used": 0, "quota_total": 50, "quota_remaining": 50, "remaining": 50}, - assess={"quota_used": 0, "quota_total": 500, "quota_remaining": 500, "remaining": 500}, - extract={"calls_today": 0, "daily_limit": 1000}, - ) - ) +def test_usage_pretty_without_a_pool_prints_no_balance_or_rows(): + """A body with no `credits` block: print the plan, not a confident '0 credits left'.""" + text = _render_usage_text(_usage(plan="plus", extract={"calls_today": 0, "daily_limit": 1000})) assert "credits left" not in text - assert "95 left" in text - assert "credits each" not in text # no price list either - assert "Credits reset" in text # falls back to quota_resets_at + assert "Verify:" not in text + assert "plus plan" in text and "0 / 1000 today" in text @pytest.mark.parametrize( diff --git a/tests/test_client.py b/tests/test_client.py index 71983db..b5baa22 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -858,6 +858,121 @@ def test_the_key_can_be_pinned_or_disabled(self, client, path, call, body): assert "Idempotency-Key" not in route.calls.last.request.headers +_ASK_REPLY = {"role": "expert", "content": "Because.", "created_at": "2026-05-22T12:00:05Z"} +_BATCH_REPLY = {"batch_id": "b", "items": [{"task_id": "t1", "claim": "A."}]} + +#: The 3.0 additions to the automatic key: batch submit (both helpers) and +#: ask.send. (path, call, success body) +_NEW_AUTO_KEY_CALLS = [ + ("/verify/batch", lambda c, **kw: c.verify_batch(claims=[{"claim": "A."}], **kw), _BATCH_REPLY), + ( + "/verify/batch", + lambda c, **kw: c.verify_batch_and_wait(claims=[{"claim": "A."}], timeout=0, **kw), + {"batch_id": "b", "items": []}, + ), + ("/ask/v1", lambda c, **kw: c.ask.send("v1", message="Why?", **kw), _ASK_REPLY), +] + + +class TestBatchAndAskIdempotency: + """Since 3.0 ``verify_batch``, ``verify_batch_and_wait`` and ``ask.send`` + send a random ``Idempotency-Key`` per call, reused across that call's own + retries: a retried batch or question replays the first answer instead of + being charged twice.""" + + @pytest.mark.parametrize(("path", "call", "body"), _NEW_AUTO_KEY_CALLS) + def test_a_random_key_per_call(self, client, path, call, body): + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.post(path).respond(200, json=body) + call(client) + call(client) + keys = [c.request.headers["Idempotency-Key"] for c in route.calls] + assert all(re.match(r"^[0-9a-f]{32}$", k) for k in keys), keys + assert keys[0] != keys[1] + + @pytest.mark.parametrize(("path", "call", "body"), _NEW_AUTO_KEY_CALLS) + def test_the_same_key_on_every_retry_attempt(self, client, path, call, body, monkeypatch): + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.post(path) + route.side_effect = [ + httpx.Response(502, json={"detail": "bad gateway"}), + httpx.ReadTimeout("slow"), + httpx.Response(503, json={"detail": "unavailable"}), + httpx.Response(200, json=body), + ] + call(client) + assert len(route.calls) == 4 + assert len({c.request.headers["Idempotency-Key"] for c in route.calls}) == 1 + + @pytest.mark.parametrize(("path", "call", "body"), _NEW_AUTO_KEY_CALLS) + def test_a_retry_after_a_lost_reply_gets_the_replayed_answer(self, client, path, call, body, monkeypatch): + # The first attempt reached the server and its reply was lost; the + # retry carries the same key and body, so the server answers with the + # replay. + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.post(path) + route.side_effect = [httpx.ReadTimeout("reply lost"), httpx.Response(200, json=body)] + out = call(client) + assert len(route.calls) == 2 + first, second = (c.request.headers["Idempotency-Key"] for c in route.calls) + assert first == second + assert route.calls[0].request.content == route.calls[1].request.content + assert out is not None + + @pytest.mark.parametrize(("path", "call", "body"), _NEW_AUTO_KEY_CALLS) + def test_an_in_flight_409_is_never_passed_with_a_second_key(self, client, path, call, body, monkeypatch): + # The retry lands while the first attempt still runs: the server + # answers 409 ``idempotency_conflict``. The SDK sends the SAME key and + # body again (as ``verify`` does) until the first call's answer comes + # back; it never mints a new key to get past it, which would run (and + # charge) the request twice. + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + conflict = {"detail": "A request with this key is still running.", "code": "idempotency_conflict"} + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.post(path) + route.side_effect = [ + httpx.ReadTimeout("slow"), + httpx.Response(409, json=conflict), + httpx.Response(200, json=body), + ] + assert call(client) is not None + assert len(route.calls) == 3 + assert len({c.request.headers["Idempotency-Key"] for c in route.calls}) == 1 + assert len({c.request.content for c in route.calls}) == 1 + + @pytest.mark.parametrize(("path", "call", "body"), _NEW_AUTO_KEY_CALLS) + def test_a_pinned_key_answers_409_then_the_result(self, client, path, call, body, monkeypatch): + # The call itself sends the caller's key again after the 409 and gets + # the first call's answer. + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + conflict = {"detail": "A request with this key is still running.", "code": "idempotency_conflict"} + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.post(path) + route.side_effect = [httpx.Response(409, json=conflict), httpx.Response(200, json=body)] + call(client, idempotency_key="mine-1") + assert [c.request.headers["Idempotency-Key"] for c in route.calls] == ["mine-1", "mine-1"] + + @pytest.mark.parametrize(("path", "call", "body"), _NEW_AUTO_KEY_CALLS) + def test_the_caller_key_wins_and_opting_out_sends_none(self, client, path, call, body): + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.post(path).respond(200, json=body) + call(client, idempotency_key="pinned-1") + assert route.calls.last.request.headers["Idempotency-Key"] == "pinned-1" + call(client, idempotency_key="pinned-2", idempotency=False) + assert route.calls.last.request.headers["Idempotency-Key"] == "pinned-2" + call(client, idempotency=False) + assert "Idempotency-Key" not in route.calls.last.request.headers + + @pytest.mark.parametrize("name", ["verify_batch", "verify_batch_and_wait"]) + def test_the_opt_out_is_named_like_verify(self, name): + param = inspect.signature(getattr(Lenz, name)).parameters["idempotency"] + assert param.default is True + assert param.kind is inspect.Parameter.KEYWORD_ONLY + assert inspect.signature(Lenz(api_key="k").ask.send).parameters["idempotency"].default is True + + class TestWaitDefaults: """The polling helpers wait 300s by default; a timeout still leaves the task resumable.""" @@ -1544,10 +1659,10 @@ def test_send_with_idempotency_key_sets_header(self, client): client.ask.send("vid_1", message="why?", idempotency_key="ask-key-1") assert route.calls.last.request.headers["Idempotency-Key"] == "ask-key-1" - def test_send_sends_no_idempotency_key_by_default(self, client): - # Never auto-generated and never derived from the message: re-asking - # the same question is normal here, so a key the caller did not - # choose would replay an old answer instead of asking again. + def test_send_sends_a_new_random_key_per_call(self, client): + # 3.0: one key per call, so the SDK's own retry replays the first + # reply. Never derived from the message: asking the same question + # again is a new call with a new key, so it is asked again. with respx.mock(base_url=DEFAULT_BASE) as r: route = r.post("/ask/vid_1").respond( 200, @@ -1555,7 +1670,9 @@ def test_send_sends_no_idempotency_key_by_default(self, client): ) client.ask.send("vid_1", message="why?") client.ask.send("vid_1", message="why?") - assert all("Idempotency-Key" not in c.request.headers for c in route.calls) + keys = [c.request.headers["Idempotency-Key"] for c in route.calls] + assert all(re.match(r"^[0-9a-f]{32}$", k) for k in keys), keys + assert keys[0] != keys[1] def test_send_legacy_reply_attr_gone(self): # REGRESSION: pre-1.0.2 `AskReply.reply` always returned `""` diff --git a/tests/test_contract.py b/tests/test_contract.py index 10a99ad..7ff4864 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -35,6 +35,7 @@ from lenz_io.models import ( AssessClaim, AssessResponse, + CancelResult, Certificate, Citecheck, CitecheckStarted, @@ -213,6 +214,8 @@ def _load(name: str) -> dict: ("citecheck_accepted.json", CitecheckStarted), ("citecheck_completed.json", Citecheck), ("citecheck_pairs_completed.json", Citecheck), + ("cancel_verify_cancelled.json", CancelResult), + ("cancel_verify_completed.json", CancelResult), ], ) def test_contract_no_unknown_fields(fixture_name, model_cls): diff --git a/tests/test_current_api_version.py b/tests/test_current_api_version.py new file mode 100644 index 0000000..5f8382b --- /dev/null +++ b/tests/test_current_api_version.py @@ -0,0 +1,351 @@ +"""3.0.0 asks for the API's current response shape (``2026-10-11``) and keeps +every 2.x attribute's meaning. + +``test_parity.py`` runs every recorded response pair through the models, the +error mapping, the CLI and the webhook parser. This file covers what the pair +fixtures cannot show on their own: the header the client sends, the request +bodies that depend on it, and the error mapping as the client drives it (the +request's endpoint decides a 422's original ``code`` and wording). +""" + +from __future__ import annotations + +import json +import warnings +from typing import Any + +import pytest +import respx +from parity_observe import load + +from lenz_io import Lenz +from lenz_io.client import API_VERSION, DEFAULT_BASE_URL +from lenz_io.errors import LenzQuotaExceededError, LenzValidationError +from lenz_io.models import AssessResponse, ReviewVerification, Usage +from lenz_io.webhooks import VerificationCompleted, parse_webhook + +KEY = "lenz_" + "0" * 32 + + +@pytest.fixture +def client() -> Any: + c = Lenz(api_key=KEY, max_retries=0) + yield c + c.close() + + +def _canonical(name: str) -> dict[str, Any]: + return load("canonical", name) + + +# ── The version the client asks for ── + + +def test_the_client_asks_for_the_current_shape(client: Lenz) -> None: + assert API_VERSION == "2026-10-11" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/me/usage").respond(200, json=_canonical("account__me_usage_free.json")["body"]) + client.usage() + assert route.calls.last.request.headers["X-Lenz-API-Version"] == "2026-10-11" + + +# ── Request bodies: ``webhook_url`` keeps its 2.x meaning ── +# +# In the current shape ``webhook_url`` means the same on every endpoint: +# left out (or null) = the key's default webhook, ``""`` = no webhook for this +# request. The SDK's parameters keep what they meant in 2.x. + + +def _sent(route: Any) -> dict[str, Any]: + return json.loads(route.calls.last.request.content) + + +def test_verify_without_a_webhook_url_leaves_it_out(client: Lenz) -> None: + """2.x: ``webhook_url=""`` (the default) meant the key's default webhook.""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.post("/verify").respond(202, json={"task_id": "t" * 32, "status": "queued"}) + client.verify("The Earth is round.") + client.verify("The Earth is round.", webhook_url="") + assert "webhook_url" not in _sent(route) + + +def test_verify_batch_leaves_out_every_empty_webhook_url(client: Lenz) -> None: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.post("/verify/batch").respond(202, json={"batch_id": "b", "items": []}) + client.verify_batch( + claims=[{"claim": "A.", "webhook_url": ""}, {"text": "B.", "webhook_url": "https://h.test/x"}] + ) + body = _sent(route) + assert "webhook_url" not in body + assert "webhook_url" not in body["claims"][0] + assert body["claims"][1]["webhook_url"] == "https://h.test/x" + + +@pytest.mark.parametrize( + ("method", "path", "receipt"), + [ + ("review", "/review", {"review_id": "r1", "status": "queued"}), + ("citecheck", "/citecheck", {"citecheck_id": "c1", "status": "queued"}), + ], +) +def test_review_and_citecheck_keep_their_webhook_url_meaning( + client: Lenz, method: str, path: str, receipt: dict[str, Any] +) -> None: + """2.x: ``None`` = the key's default (left out), ``""`` = no webhook + (sent as ``""``, which means the same in the current shape).""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.post(path).respond(202, json=receipt) + getattr(client, method)("Draft text with a claim.") + assert "webhook_url" not in _sent(route) + getattr(client, method)("Draft text with a claim.", webhook_url="") + assert _sent(route)["webhook_url"] == "" + + +def test_a_202_receipt_reads_as_the_200_did(client: Lenz) -> None: + """A repeated /verify answered from the first submission is a 202 now (a + 200 before), without ``chain_id``: the SDK returns the same receipt.""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.post("/verify").respond(202, json=_canonical("verify__implicit_repeat_replay.json")["body"]) + receipt = client.verify("The Earth is round.") + assert receipt.task_id == _canonical("verify__implicit_repeat_replay.json")["body"]["task_id"] + + +# ── Errors, as the client maps them ── + + +def _raise(client: Lenz, method: str, path: str, fixture: str, call: Any) -> Any: + response = _canonical(fixture) + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.route(method=method, path=path).respond(response["status"], json=response["body"]) + with pytest.raises(Exception) as caught: + call() + return caught.value + + +def test_a_review_422_keeps_its_original_code_and_wording(client: Lenz) -> None: + err = _raise(client, "POST", "/review", "review__422_unsupported_language.json", lambda: client.review("Draft.")) + original = load("legacy", "review__422_unsupported_language.json")["body"] + assert isinstance(err, LenzValidationError) + assert err.code == "validation_error" == original["code"] + assert err.message == original["detail"] + assert err.errors == original["errors"] + # The body as sent is still there. + assert err.body["code"] == "unsupported_language" + + +def test_a_review_schema_422_names_the_body_parameter_as_before(client: Lenz) -> None: + err = _raise(client, "POST", "/review", "review__422_depth_unknown.json", lambda: client.review("Draft.")) + original = load("legacy", "review__422_depth_unknown.json")["body"] + assert err.message == original["detail"] + assert err.errors == original["errors"] + + +def test_a_blank_assess_item_keeps_blank_item(client: Lenz) -> None: + err = _raise(client, "POST", "/assess", "assess__422_blank_item.json", lambda: client.assess(claims=["A.", " "])) + assert err.code == "blank_item" + assert err.errors == [] + + +def test_a_schema_422_lists_its_field_errors_as_the_message(client: Lenz) -> None: + """The original shape's ``detail`` was the list itself; the exception's + message and ``errors`` read exactly as they did.""" + err = _raise(client, "POST", "/verify", "verify__invalid_depth_422.json", lambda: client.verify("A claim.")) + original = load("legacy", "verify__invalid_depth_422.json")["body"]["detail"] + assert err.message == str(original) + assert err.code == "" + assert [list(i) for i in err.errors] == [list(i) for i in original] + + +def test_a_citation_402_reports_the_credit_pool(client: Lenz) -> None: + err = _raise(client, "POST", "/citecheck", "citecheck__402_no_credits.json", lambda: client.citecheck("Draft.")) + assert isinstance(err, LenzQuotaExceededError) + assert (err.remaining, err.credit_balance, err.cost) == (100, 100, 1) + + +# ── Values the current shape leaves out, rebuilt with their 2.x meaning ── + + +def test_a_no_claim_assess_keeps_its_original_error_sentence() -> None: + out = AssessResponse.model_validate(_canonical("assess__single_no_claim.json")["body"]) + assert out.error == "No verifiable claim detected" + assert out.error_code == "no_claim" + + +def test_a_failed_deep_check_in_a_review_says_not_a_claim() -> None: + row = ReviewVerification.model_validate( + {"status": "failed", "failure": {"code": "no_checkable_claim", "detail": "x", "hint": None}} + ) + assert row.failure is not None + assert row.failure.failure_reason == "not_a_claim" + assert row.failure.code == "no_checkable_claim" + + +def test_usage_blocks_without_published_prices_use_the_original_ones() -> None: + usage = Usage.model_validate({"plan": "free", "credits": {"total": 100, "used": 0, "remaining": 100, "extra": 5}}) + assert usage.verify.quota_total == 10 + assert usage.ask.quota_total == 100 + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert usage.assess.credits == 5 + + +def test_usage_quota_used_never_goes_below_zero() -> None: + usage = Usage.model_validate( + {"plan": "free", "credits": {"total": 5, "used": 0, "remaining": 25, "extra": 20}, "costs": {"verify": 10}} + ) + assert usage.verify.quota_used == 0 + + +def test_a_sparse_completed_webhook_result_has_every_original_key() -> None: + event = parse_webhook(_canonical("webhook__verification_completed_sparse_result.json")["body"]) + original = parse_webhook(load("legacy", "webhook__verification_completed_sparse_result.json")["body"]) + assert isinstance(event, VerificationCompleted) + assert isinstance(original, VerificationCompleted) + assert event.result == original.result + assert list(event.result) == list(original.result) + + +def test_a_failed_deep_check_says_not_a_claim_on_issues_and_failures() -> None: + from lenz_io.models import ReviewFailure, ReviewIssue + + block = {"code": "no_checkable_claim", "detail": "x", "hint": None} + issue = ReviewIssue.model_validate({"claim_index": 0, "verdict": "Mixed", "failure": dict(block)}) + deep = ReviewFailure.model_validate({"claim_index": 0, "stage": "verification", "failure": dict(block)}) + quick = ReviewFailure.model_validate({"claim_index": 0, "stage": "assessment", "failure": dict(block)}) + assert issue.failure is not None and issue.failure.failure_reason == "not_a_claim" + assert deep.failure is not None and deep.failure.failure_reason == "not_a_claim" + assert quick.failure is not None and quick.failure.failure_reason == "no_claim" + + +def test_a_receipt_without_chain_id_reads_it_as_empty() -> None: + from lenz_io.models import TaskAccepted + + current = TaskAccepted.model_validate(_canonical("verify__submit_202.json")["body"]) + assert current.chain_id == "" + + +@pytest.mark.parametrize("blank", ["", " ", "\t\n", None]) +def test_a_blank_verify_webhook_url_is_left_out(client: Lenz, blank: Any) -> None: + """2.x: the API read a blank ``webhook_url`` on /verify as left out, so + the key's default webhook fired. The current shape reads a blank value as + "no webhook", so the SDK leaves it out to keep the 2.x meaning.""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + single = mock.post("/verify").respond(202, json={"task_id": "t" * 32, "status": "queued"}) + batch = mock.post("/verify/batch").respond(202, json={"batch_id": "b", "items": []}) + client.verify("The Earth is round.", webhook_url=blank) + client.verify_batch(claims=[{"claim": "A.", "webhook_url": blank}], webhook_url=blank) + assert "webhook_url" not in _sent(single) + body = _sent(batch) + assert "webhook_url" not in body + assert "webhook_url" not in body["claims"][0] + + +@pytest.mark.parametrize( + ("fixture", "method", "path", "code"), + [ + ("verify__status_unknown_404.json", "GET", "/verify/status/t1", ""), + ("verify__unauthenticated_401.json", "POST", "/verify", ""), + ("review__401_no_credentials.json", "POST", "/review", ""), + ("errors__ask_not_completed.json", "POST", "/ask/v1", ""), + ("verify__idempotency_conflict_409.json", "POST", "/verify", ""), + ("verify__verification_not_ready_409.json", "GET", "/verifications/t1", "verification_not_ready"), + ("review__get_404_not_found.json", "GET", "/reviews/r1", "not_found"), + ], +) +def test_an_error_code_reads_as_it_did_in_2x(fixture: str, method: str, path: str, code: str) -> None: + """The current shape names a ``code`` on every error; where the 2.x + error carried none, the exception's ``code`` stays ``""``.""" + from lenz_io.errors import map_response_to_error + + response = _canonical(fixture) + err = map_response_to_error(response["status"], json.dumps(response["body"]), endpoint=(method, path)) + assert err.code == code == (load("legacy", fixture)["body"].get("code") or "") + assert err.body == response["body"] + + +@pytest.mark.parametrize( + ("code", "sentence"), + [ + ("research_empty", "Pipeline stopped at: research_empty"), + ("cancelled", "Cancelled."), + ("task_stuck", "The task was never completed and has been marked failed."), + ("no_checkable_claim", "Not a verifiable claim."), + ], +) +def test_a_failed_poll_reads_its_2x_sentence(code: str, sentence: str) -> None: + from lenz_io.models import TaskStatus + + status = TaskStatus.model_validate( + {"status": "failed", "task_id": "t", "failure": {"code": code, "detail": "Current wording."}} + ) + assert status.error == sentence + assert status.failure is not None and status.failure.detail == "Current wording." + + +@pytest.mark.parametrize( + ("status", "code"), [(500, "internal_error"), (400, "invalid_request"), (422, "invalid_request")] +) +def test_the_fallback_codes_read_as_none(status: int, code: str) -> None: + from lenz_io.errors import map_response_to_error + + err = map_response_to_error(status, json.dumps({"detail": "x", "code": code}), endpoint=("POST", "/verify")) + assert err.code == "" + assert err.body == {"detail": "x", "code": code} + + +def test_a_batch_item_language_error_names_its_item() -> None: + from lenz_io.errors import map_response_to_error + + sentence = "Unsupported language 'xx'. Supported: en." + body = { + "detail": sentence, + "code": "unsupported_language", + "errors": [{"loc": ["body", "claims", 1, "language"], "msg": sentence, "type": "unsupported_language"}], + } + err = map_response_to_error(422, json.dumps(body), endpoint=("POST", "/verify/batch")) + assert err.message == f"claims[1].{sentence}" + assert err.code == "" + assert err.errors == [] + + +def test_deprecated_property_aliases_are_marked_without_warning() -> None: + """PEP 702 markers (editors and type checkers see them); no runtime warning, + so code under ``-W error`` keeps working.""" + import warnings + + from lenz_io.errors import LenzQuotaExceededError + from lenz_io.models import TaskAccepted + + for prop in (TaskAccepted.chain_id, LenzQuotaExceededError.credits_remaining): + assert isinstance(prop, property) and isinstance(getattr(prop.fget, "__deprecated__", None), str) + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert TaskAccepted.model_validate({"task_id": "t"}).chain_id == "" + + +# ── ``credit_balance`` is restored for the citation check only ── + + +def _no_credits(path: str, body: dict[str, Any], client: Lenz) -> LenzQuotaExceededError: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.post(path).respond(402, json=body) + with pytest.raises(LenzQuotaExceededError) as info: + if path == "/citecheck": + client.citecheck(text="See https://example.org/a.") + else: + client.review("A claim.") + return info.value + + +def test_a_citecheck_402_without_the_pool_reads_it_as_remaining(client: Lenz) -> None: + body = _canonical("citecheck__402_no_credits.json")["body"] + assert "credits_remaining" not in body + assert _no_credits("/citecheck", body, client).credit_balance == body["remaining"] + + +def test_another_endpoint_does_not_invent_a_credit_balance(client: Lenz) -> None: + body = {"detail": "No remaining credits.", "code": "no_credits", "remaining": 7, "cost": 1} + assert _no_credits("/review", body, client).credit_balance is None + from lenz_io.errors import map_response_to_error + + assert map_response_to_error(402, json.dumps(body)).credit_balance is None # type: ignore[attr-defined] diff --git a/tests/test_deprecation_markers.py b/tests/test_deprecation_markers.py new file mode 100644 index 0000000..3ae8122 --- /dev/null +++ b/tests/test_deprecation_markers.py @@ -0,0 +1,56 @@ +"""Every field whose 2.x name is deprecated says so in the JSON schema.""" + +from __future__ import annotations + +import pytest + +from lenz_io import models + +DEPRECATED_FIELDS = { + "CandidateClaim": ["text"], + "ExtractedClaims": ["claim", "identified_claims", "locations", "candidate_claims"], + "AssessClaim": ["error_code", "hint", "identified_claims", "candidate_claims"], + "ReviewAssessment": ["error_code", "hint", "identified_claims"], + "AssessResponse": ["error", "error_code", "candidate_claims"], + "TaskAccepted": ["claim_text"], + "BatchItemResult": ["claim_text"], + "TaskStatus": [ + "error", + "failure_reason", + "failure_detail", + "failure_class", + "retryable", + "docs_url", + "candidates", + "similar_claims", + ], + "FailureBlock": ["failure_reason"], + "Verification": ["modified_at"], + "VerificationListItem": ["modified_at"], + "ReviewVerification": ["modified_at"], + "ReviewSummary": ["claim_limit_reached", "citation_limit_reached"], + "CitecheckSummary": ["citation_limit_reached"], + "Usage": ["quota_resets_at", "verify", "ask", "assess"], + "UsageCredits": ["bonus"], + "UsageCapacity": ["credits"], +} + +#: Fields that stay current: a marker here would warn about a name that has no replacement. +CURRENT_FIELDS = {"TaskStatus": ["hint", "result", "claims"], "Usage": ["credits", "extract"]} + + +def _properties(cls_name: str, mode: str) -> dict[str, dict[str, object]]: + return getattr(models, cls_name).model_json_schema(mode=mode)["properties"] + + +@pytest.mark.parametrize("mode", ["validation", "serialization"]) +@pytest.mark.parametrize(("cls_name", "fields"), sorted(DEPRECATED_FIELDS.items())) +def test_deprecated_fields_are_marked(cls_name: str, fields: list[str], mode: str) -> None: + props = _properties(cls_name, mode) + assert [f for f in fields if props[f].get("deprecated") is not True] == [] + + +@pytest.mark.parametrize(("cls_name", "fields"), sorted(CURRENT_FIELDS.items())) +def test_current_fields_are_not_marked(cls_name: str, fields: list[str]) -> None: + props = _properties(cls_name, "validation") + assert [f for f in fields if props[f].get("deprecated")] == [] diff --git a/tests/test_errors.py b/tests/test_errors.py index 6dfed18..f2cf989 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -336,7 +336,7 @@ def test_410_without_purged_code_stays_plain(self): assert e.status_code == 410 def test_gone_is_not_a_not_found(self): - # A 404 stays a plain LenzError: only 410 is gone. + # A 404 is a not-found error, never a gone one: only 410 is gone. e = map_response_to_error(404, _body({"detail": "Not found."}), {}) assert not isinstance(e, LenzGoneError) @@ -475,3 +475,212 @@ def test_errors_all_lists_every_public_error_class_and_constant(): assert {"LenzGoneError", "LenzUpstreamUnavailableError", "UPSTREAM_503_CODES"} <= set(errors.__all__) for name in errors.__all__: assert hasattr(errors, name), name + + +# ── 3.0: not found, connection failures, ``retryable`` ───────────────────── + +NOT_FOUND_FIX = ( + "Check the id or key the call names: nothing with it is visible to this credential. Retrying will not help." +) + + +class TestNotFound: + def test_404_is_its_own_error_and_still_a_lenz_error(self): + from lenz_io import LenzNotFoundError + + e = map_response_to_error(404, _body({"detail": "Not found."}), {"X-Request-ID": "rq9"}) + assert type(e) is LenzNotFoundError + assert isinstance(e, LenzError) + assert not isinstance(e, (LenzGoneError, LenzAPIError)) + assert e.status_code == 404 + assert e.request_id == "rq9" + + def test_404_keeps_its_message_and_says_what_to_check(self): + e = map_response_to_error(404, _body({"detail": "Not found."}), {}) + assert e.message == "Not found." + assert e.cause == "Not found." + assert e.doc_url == "https://lenz.io/docs/errors" + assert e.fix == NOT_FOUND_FIX + assert ( + str(e) + == f"Not found.\n Cause: Not found.\n Fix: {NOT_FOUND_FIX}\n Docs: https://lenz.io/docs/errors" + ) + + def test_404_without_a_body_keeps_the_2x_message(self): + e = map_response_to_error(404, b"", {}) + assert e.message == "HTTP 404" + + def test_other_errors_keep_their_text(self): + e = map_response_to_error(500, _body({"detail": "boom"}), {}) + assert e.fix == "Retry; if the error persists, file an issue with the Request ID." + assert repr(e) == "LenzAPIError('boom')" + + +class TestConnectionErrors: + def _fail(self, exc: Exception): + import respx + + from lenz_io import Lenz + + with respx.mock(base_url="https://lenz.io/api/v1") as r: + r.get("/me/usage").mock(side_effect=exc) + with Lenz(api_key="lenz_test", max_retries=0) as client: + try: + client.usage() + except LenzError as caught: + return caught + raise AssertionError("no error raised") # pragma: no cover + + def test_a_network_failure_is_a_connection_error_and_still_an_api_error(self): + import httpx + + from lenz_io import LenzConnectionError, LenzRequestTimeoutError, LenzTimeoutError + + cause = httpx.ConnectError("connection refused") + e = self._fail(cause) + assert type(e) is LenzConnectionError + assert isinstance(e, LenzAPIError) + assert not isinstance(e, (LenzRequestTimeoutError, LenzTimeoutError)) + assert e.__cause__ is cause + assert e.message == "GET /me/usage failed after 1 attempts: connection refused" + assert e.fix == "Check your network connection; verify base_url is reachable." + assert e.retryable is True + + def test_a_transport_timeout_is_a_request_timeout_not_a_job_timeout(self): + import httpx + + from lenz_io import LenzConnectionError, LenzRequestTimeoutError, LenzTimeoutError + + e = self._fail(httpx.ReadTimeout("timed out")) + assert type(e) is LenzRequestTimeoutError + assert isinstance(e, LenzConnectionError) + assert isinstance(e, LenzAPIError) + assert not isinstance(e, LenzTimeoutError) + assert e.message == "GET /me/usage failed after 1 attempts: timed out" + assert e.retryable is True + + +class TestRetryable: + @pytest.mark.parametrize( + ("status", "body", "expected"), + [ + (400, {}, False), + (401, {}, False), + (402, {"code": "no_credits"}, False), + (403, {}, False), + (404, {}, False), + (409, {"code": "idempotency_conflict"}, True), + (409, {"code": "select_not_pending"}, False), + (410, {"code": "purged"}, False), + (422, {}, False), + (429, {}, True), + (429, {"code": "review_in_flight"}, True), + (500, {}, True), + (502, {}, True), + (503, {}, True), + (503, {"code": "upstream_unavailable"}, True), + (503, {"code": "capacity"}, True), + (409, {"code": "verification_not_ready", "status": "processing"}, True), + ], + ) + def test_derived_from_the_response(self, status, body, expected): + assert map_response_to_error(status, _body(body), {}).retryable is expected + + @pytest.mark.parametrize( + ("endpoint", "code"), + [ + (("POST", "/verify"), "idempotency_conflict"), + (("POST", "/ask/v1"), "idempotency_conflict"), + (("POST", "/ask/v1"), "verification_not_ready"), + (("GET", "/verifications/v1"), "verification_not_ready"), + ], + ) + def test_a_409_worth_resending_is_retryable_whatever_code_the_2x_body_keeps(self, endpoint, code): + # The 2.x body has no ``code`` on some endpoints (``exc.code`` stays + # ""), but the answer is still worth sending again (with the same key). + e = map_response_to_error(409, _body({"detail": "x", "code": code}), {}, endpoint=endpoint) + assert e.retryable is True + + @pytest.mark.parametrize(("status", "flag"), [(503, False), (400, True), (500, False), (422, True)]) + def test_a_boolean_in_the_failure_block_wins(self, status, flag): + e = map_response_to_error(status, _body({"detail": "x", "failure": {"retryable": flag}}), {}) + assert e.retryable is flag + + def test_a_top_level_boolean_wins_after_the_failure_block(self): + assert map_response_to_error(503, _body({"retryable": False}), {}).retryable is False + both = {"retryable": True, "failure": {"retryable": False}} + assert map_response_to_error(400, _body(both), {}).retryable is False + + def test_a_non_boolean_in_the_failure_block_is_ignored(self): + e = map_response_to_error(503, _body({"failure": {"retryable": "yes"}}), {}) + assert e.retryable is True + + def test_a_failed_verification_keeps_the_servers_value_unknown_included(self): + failed = {"detail": "Verification failed.", "code": "verification_failed", "task_id": _TASK_ID} + assert map_response_to_error(409, _body(failed), {}).retryable is None + failed["retryable"] = True + assert map_response_to_error(409, _body(failed), {}).retryable is True + + def test_a_version_error_is_not_retryable(self): + from lenz_io import LenzApiVersionError + + assert LenzApiVersionError(api_version="2026-05-13", status_code=200).retryable is False + + def test_client_side_errors(self): + from lenz_io import ( + LenzNeedsInputError, + LenzTimeoutError, + LenzWebhookSignatureError, + ReviewFailed, + ReviewTimeout, + ) + + assert LenzError().retryable is None + assert LenzPipelineError().retryable is None + assert ReviewFailed().retryable is None + # No HTTP status: unknown. + assert LenzAuthError(message="API key required").retryable is None + assert LenzTimeoutError().retryable is None + assert ReviewTimeout().retryable is None + assert LenzNeedsInputError().retryable is None + assert LenzWebhookSignatureError().retryable is None + # No status and no connection failure: unknown, as in the Node SDK. + assert LenzAPIError().retryable is None + assert LenzRateLimitError().retryable is None + from lenz_io import LenzConnectionError, LenzRequestTimeoutError + + assert LenzConnectionError().retryable is True + assert LenzRequestTimeoutError().retryable is True + + def test_a_value_passed_in_wins(self): + assert LenzPipelineError(retryable=True).retryable is True + assert LenzAPIError(retryable=False).retryable is False + assert LenzError(status_code=503, retryable=None).retryable is None + + def test_it_is_an_instance_field_not_a_property(self): + e = map_response_to_error(500, b"{}", {}) + assert "retryable" in vars(e) + e.retryable = False + assert e.retryable is False + + +def test_the_job_error_aliases_are_the_same_classes(): + import lenz_io + from lenz_io import errors + + for alias, original in ( + ("ReviewFailedError", "ReviewFailed"), + ("ReviewTimeoutError", "ReviewTimeout"), + ("CitecheckFailedError", "CitecheckFailed"), + ("CitecheckTimeoutError", "CitecheckTimeout"), + ): + assert getattr(lenz_io, alias) is getattr(lenz_io, original) + assert alias in errors.__all__ and alias in lenz_io.__all__ + + +def test_the_new_classes_are_exported(): + import lenz_io + + for name in ("LenzNotFoundError", "LenzConnectionError", "LenzRequestTimeoutError"): + assert name in lenz_io.__all__ + assert issubclass(getattr(lenz_io, name), LenzError) diff --git a/tests/test_failure_spelling.py b/tests/test_failure_spelling.py new file mode 100644 index 0000000..17a6ed7 --- /dev/null +++ b/tests/test_failure_spelling.py @@ -0,0 +1,57 @@ +"""How "nothing checkable" is spelled in a failure block. + +2.x spelled it ``not_a_claim`` for a verification (``TaskStatus.failure_reason``, +a ``verification.failed`` webhook's ``error``) and ``no_claim`` for an +assessment or a review. ``failure.failure_reason`` keeps each spelling, and +``failure.code`` is the same ``no_checkable_claim`` everywhere. +""" + +from __future__ import annotations + +import warnings + +import pytest +from parity_observe import load + +from lenz_io.models import AssessResponse, ReviewFull, TaskStatus +from lenz_io.webhooks import VerificationFailed, parse_webhook + + +def test_a_verifications_failure_block_spells_it_not_a_claim() -> None: + status = TaskStatus.model_validate(load("canonical", "verify__status_not_a_claim.json")["body"]) + assert status.failure_reason == "not_a_claim" + assert status.failure is not None + assert status.failure.failure_reason == "not_a_claim" + assert status.failure.code == "no_checkable_claim" + assert TaskStatus.model_validate(load("legacy", "verify__status_not_a_claim.json")["body"]).failure_reason == ( + "not_a_claim" + ) + + +@pytest.mark.parametrize("shape", ["legacy", "canonical"]) +def test_a_verification_failed_event_spells_it_not_a_claim(shape: str) -> None: + event = parse_webhook(load(shape, "webhook__verification_failed_not_a_claim.json")["body"]) + assert isinstance(event, VerificationFailed) + assert event.error == "not_a_claim" + assert event.failure is not None + assert event.failure.failure_reason == "not_a_claim" + + +def test_another_failure_code_is_untouched() -> None: + body = load("canonical", "webhook__verification_failed_upstream_unavailable.json")["body"] + event = parse_webhook(body) + assert isinstance(event, VerificationFailed) + assert event.failure is not None + assert event.failure.failure_reason == event.failure.code == body["verification"]["failure"]["code"] + + +def test_an_assessment_and_a_review_keep_no_claim() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assessed = AssessResponse.model_validate(load("canonical", "assess__single_no_claim.json")["body"]) + assert assessed.failure is not None + assert assessed.failure.failure_reason == "no_claim" + assert assessed.error_code == "no_claim" + review = ReviewFull.model_validate(load("canonical", "review__get_failed_no_claim.json")["body"]) + assert review.failure is not None + assert review.failure.failure_reason == "no_claim" diff --git a/tests/test_idempotent_resend.py b/tests/test_idempotent_resend.py new file mode 100644 index 0000000..527635d --- /dev/null +++ b/tests/test_idempotent_resend.py @@ -0,0 +1,349 @@ +"""A call that sends an ``Idempotency-Key``: + +* meets a 409 ``idempotency_conflict`` (the first request with that key is + still running) by sending the SAME key and body again, after the stated + wait or the usual backoff, within the call's retry budget; if it still + conflicts, the error is ``retryable``; +* puts the key on every ``LenzError`` it raises (``exc.idempotency_key``), so + a resend can reuse it and replay instead of running twice. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from lenz_io import ( + Lenz, + LenzConnectionError, + LenzError, + LenzPipelineError, + LenzRequestTimeoutError, + LenzTimeoutError, + LenzValidationError, + ReviewFailed, +) + +BASE = "https://lenz.io/api/v1" +_CONFLICT = {"detail": "A request with this Idempotency-Key is still being processed.", "code": "idempotency_conflict"} +_ACCEPTED = {"task_id": "t1", "claim_text": "A."} +_BATCH = {"batch_id": "b", "items": [{"task_id": "t1", "claim_text": "A."}]} + + +@pytest.fixture() +def slept(monkeypatch: pytest.MonkeyPatch) -> list[float]: + calls: list[float] = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: calls.append(s)) + return calls + + +def _keys(route: respx.Route) -> list[str | None]: + return [c.request.headers.get("Idempotency-Key") for c in route.calls] + + +def _bodies(route: respx.Route) -> list[bytes]: + return [c.request.content for c in route.calls] + + +_CALLS = [ + ("/verify", _ACCEPTED, lambda c, **kw: c.verify("A.", **kw)), + ("/verify/batch", _BATCH, lambda c, **kw: c.verify_batch(claims=[{"claim": "A."}], **kw)), + ("/ask/v1", {"reply": "Yes."}, lambda c, **kw: c.ask.send("v1", message="Why?", **kw)), + ("/assess", {"claims": []}, lambda c, **kw: c.assess("A.", **kw)), + ("/extract", {"claims": []}, lambda c, **kw: c.extract(text="A.", **kw)), + ("/verify/t0/select", _BATCH, lambda c, **kw: c.select("t0", claims=["A."], **kw)), +] + + +@pytest.mark.parametrize(("path", "ok", "call"), _CALLS) +class TestAConflictIsSentAgainWithTheSameKey: + def test_then_succeeds(self, client: Lenz, slept: list[float], path, ok, call) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(path) + route.side_effect = [httpx.Response(409, json=_CONFLICT), httpx.Response(200, json=ok)] + call(client) + assert route.call_count == 2 + keys = _keys(route) + assert keys[0] and keys[0] == keys[1] + assert _bodies(route)[0] == _bodies(route)[1] + assert slept == [1.0], "the existing backoff" + + def test_a_stated_wait_is_honoured(self, client: Lenz, slept: list[float], path, ok, call) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(path) + route.side_effect = [ + httpx.Response(409, json=_CONFLICT, headers={"Retry-After": "3"}), + httpx.Response(200, json=ok), + ] + call(client) + assert slept == [3.0] + + def test_a_caller_key_is_kept(self, client: Lenz, slept: list[float], path, ok, call) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(path) + route.side_effect = [httpx.Response(409, json=_CONFLICT), httpx.Response(200, json=ok)] + call(client, idempotency_key="mine") + assert _keys(route) == ["mine", "mine"] + + def test_still_conflicting_raises_retryable_with_the_key( + self, client: Lenz, slept: list[float], path, ok, call + ) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(path).respond(409, json=_CONFLICT) + with pytest.raises(LenzError) as ei: + call(client) + assert route.call_count == 4, "the call's retry budget: 1 + max_retries" + assert len(set(_keys(route))) == 1 + err = ei.value + assert type(err) is LenzError + assert err.status_code == 409 + assert err.retryable is True + assert err.idempotency_key == _keys(route)[0] + # The 2.x attributes of that error are unchanged. + assert err.code == "" + assert err.message == _CONFLICT["detail"] + + def test_without_a_key_a_conflict_is_not_resent(self, client: Lenz, slept: list[float], path, ok, call) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(path).respond(409, json=_CONFLICT) + with pytest.raises(LenzError) as ei: + call(client, idempotency=False) + assert route.call_count == 1 + assert ei.value.idempotency_key is None + + +def test_the_retry_budget_bounds_the_resends(slept: list[float]) -> None: + with Lenz(api_key="lenz_test", max_retries=0) as client, respx.mock(base_url=BASE) as r: + route = r.post("/verify").respond(409, json=_CONFLICT) + with pytest.raises(LenzError): + client.verify("A.") + assert route.call_count == 1 + + +def test_a_long_stated_wait_is_capped(client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify") + route.side_effect = [ + httpx.Response(409, json=_CONFLICT, headers={"Retry-After": "3600"}), + httpx.Response(200, json=_ACCEPTED), + ] + client.verify("A.") + assert slept == [1.0], "a wait past the cap falls back to the backoff" + + +def test_other_409s_are_not_resent(client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify/t0/select").respond(409, json={"detail": "Nothing to select.", "code": "x"}) + with pytest.raises(LenzError): + client.select("t0", claims=["A."]) + assert route.call_count == 1 + + +class TestReviewAndCitecheck: + def test_a_conflict_naming_the_review_returns_it_at_once(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/review").respond(409, json={**_CONFLICT, "review_id": "r1"}) + started = client.review("Draft.") + assert started.review_id == "r1" + assert route.call_count == 1 + + def test_a_conflict_naming_the_check_returns_it_at_once(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/citecheck").respond(409, json={**_CONFLICT, "citecheck_id": "c1"}) + started = client.citecheck("Draft.") + assert started.citecheck_id == "c1" + assert route.call_count == 1 + + def test_a_conflict_without_the_id_is_sent_again(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/review") + route.side_effect = [ + httpx.Response(409, json=_CONFLICT), + httpx.Response(202, json={"review_id": "r1", "status": "queued"}), + ] + started = client.review("Draft.") + assert started.review_id == "r1" + keys = _keys(route) + assert keys[0] == keys[1] + + +class TestTheErrorCarriesTheKey: + def test_a_refused_call(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify").respond(422, json={"detail": "Bad.", "code": "validation_error"}) + with pytest.raises(LenzValidationError) as ei: + client.verify("A.") + assert ei.value.idempotency_key == route.calls.last.request.headers["Idempotency-Key"] + + @pytest.mark.parametrize( + ("failure", "cls"), + [(httpx.ConnectError("refused"), LenzConnectionError), (httpx.ReadTimeout("slow"), LenzRequestTimeoutError)], + ) + def test_a_connection_failure(self, client: Lenz, slept: list[float], failure, cls) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/assess").mock(side_effect=failure) + with pytest.raises(cls) as ei: + client.assess("A.", idempotency_key="k-1") + assert ei.value.idempotency_key == "k-1" + + def test_the_wait_of_a_submit_and_wait_call(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + submit = r.post("/verify").respond(200, json=_ACCEPTED) + r.get("/verify/status/t1").respond(200, json={"status": "failed", "failure_reason": "x"}) + with pytest.raises(LenzPipelineError) as ei: + client.verify_and_wait("A.") + assert ei.value.idempotency_key == submit.calls.last.request.headers["Idempotency-Key"] + + def test_a_wait_timeout_of_a_submit_and_wait_call(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/verify").respond(200, json=_ACCEPTED) + r.get("/verify/status/t1").respond(200, json={"status": "processing", "progress": {}}) + with pytest.raises(LenzTimeoutError) as ei: + client.verify_and_wait("A.", timeout=0, idempotency_key="k-2") + assert ei.value.idempotency_key == "k-2" + + def test_a_failed_review(self, client: Lenz, slept: list[float]) -> None: + failed = { + "review_id": "r1", + "status": "failed", + "issues": [], + "failures": [], + "claims": [], + "failure": {"failure_reason": "internal"}, + } + with respx.mock(base_url=BASE) as r: + submit = r.post("/review").respond(202, json={"review_id": "r1", "status": "queued"}) + r.get("/reviews/r1").respond(200, json=failed) + with pytest.raises(ReviewFailed) as ei: + client.review_and_wait("Draft.") + assert ei.value.idempotency_key == submit.calls.last.request.headers["Idempotency-Key"] + + def test_a_batch_submit_and_wait(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/verify/batch").respond(402, json={"detail": "No credits.", "code": "no_credits"}) + with pytest.raises(LenzError) as ei: + client.verify_batch_and_wait(claims=[{"claim": "A."}], idempotency_key="k-3") + assert ei.value.idempotency_key == "k-3" + + def test_a_call_that_sent_no_key(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.get("/me/usage").respond(404, json={"detail": "x"}) + r.get("/verify/status/t1").respond(404, json={"detail": "x"}) + r.post("/verify").respond(422, json={"detail": "Bad."}) + with pytest.raises(LenzError) as usage: + client.usage() + with pytest.raises(LenzError) as wait: + client.wait("t1") + with pytest.raises(LenzError) as opted_out: + client.verify("A.", idempotency=False) + assert usage.value.idempotency_key is None + assert wait.value.idempotency_key is None + assert opted_out.value.idempotency_key is None + + +def test_the_body_sent_again_is_byte_identical(client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify/batch") + route.side_effect = [httpx.Response(409, json=_CONFLICT), httpx.Response(200, json=_BATCH)] + client.verify_batch(claims=[{"claim": "A.", "depth": "low"}], language="de") + first, second = _bodies(route) + assert first == second + assert json.loads(first)["language"] == "de" + + +class TestAnAnswerThatCannotBeRead: + def test_invalid_json_keeps_its_2x_class_and_carries_the_key(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/verify").respond(200, content=b"not json") + with pytest.raises(json.JSONDecodeError) as ei: + client.verify("A.", idempotency_key="k-4") + assert ei.value.idempotency_key == "k-4" # type: ignore[attr-defined] + + def test_a_body_cut_off_mid_read_carries_the_key(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/assess").mock(side_effect=httpx.RemoteProtocolError("peer closed connection mid-body")) + with pytest.raises(LenzConnectionError) as ei: + client.assess("A.", idempotency_key="k-5") + assert ei.value.idempotency_key == "k-5" + + +class TestTheWaitHelpersGoThroughTheSameMethodsAs2x: + """A subclass or test double overriding the submit keeps working.""" + + def test_review_and_wait_calls_review_with_the_key(self, slept: list[float]) -> None: + seen: list[str | None] = [] + + class Recording(Lenz): + def review(self, text, **kw): # type: ignore[no-untyped-def, override] + seen.append(kw.get("idempotency_key")) + return super().review(text, **kw) + + done = {"review_id": "r1", "status": "completed", "issues": [], "failures": [], "claims": []} + with Recording(api_key="lenz_test") as client, respx.mock(base_url=BASE) as r: + submit = r.post("/review").respond(202, json={"review_id": "r1", "status": "queued"}) + r.get("/reviews/r1").respond(200, json=done) + client.review_and_wait("Draft.") + assert seen and seen[0] == submit.calls.last.request.headers["Idempotency-Key"] + + def test_citecheck_and_wait_calls_citecheck_with_the_key(self, slept: list[float]) -> None: + seen: list[str | None] = [] + + class Recording(Lenz): + def citecheck(self, text=None, **kw): # type: ignore[no-untyped-def, override] + seen.append(kw.get("idempotency_key")) + return super().citecheck(text, **kw) + + done = { + "citecheck_id": "c1", + "status": "completed", + "citations": [], + "citation_issues": [], + "citation_failures": [], + } + with Recording(api_key="lenz_test") as client, respx.mock(base_url=BASE) as r: + submit = r.post("/citecheck").respond(202, json={"citecheck_id": "c1", "status": "queued"}) + r.get("/citechecks/c1").respond(200, json=done) + client.citecheck_and_wait("Draft.", idempotency_key="k-6") + assert seen == ["k-6"] == [submit.calls.last.request.headers["Idempotency-Key"]] + + def test_verify_and_wait_waits_through_wait(self) -> None: + waited: list[str] = [] + + class Recording(Lenz): + def wait(self, task, **kw): # type: ignore[no-untyped-def, override] + waited.append(task.task_id) + return super().wait(task, **kw) + + with Recording(api_key="lenz_test") as client, respx.mock(base_url=BASE) as r: + r.post("/verify").respond(200, json=_ACCEPTED) + r.get("/verify/status/t1").respond( + 200, json={"status": "completed", "result": {"verification_id": "v1", "claim": "A."}} + ) + client.verify_and_wait("A.") + assert waited == ["t1"] + + +_UNREADABLE = [ + ("/ask/v1", {"content": None, "message_id": 5}, lambda c: c.ask.send("v1", message="Why?", idempotency_key="k")), + ("/verify", {"task_id": ["x"]}, lambda c: c.verify("A.", idempotency_key="k")), + ("/verify/batch", {"items": "x"}, lambda c: c.verify_batch(claims=[{"claim": "A."}], idempotency_key="k")), + ("/assess", {"claims": "x"}, lambda c: c.assess("A.", idempotency_key="k")), + ("/extract", {"status": ["x"]}, lambda c: c.extract(text="A.", idempotency_key="k")), + ("/verify/t0/select", {"items": "x"}, lambda c: c.select("t0", claims=["A."], idempotency_key="k")), + ("/review", {"review_id": ["x"]}, lambda c: c.review("Draft.", idempotency_key="k")), + ("/citecheck", {"citecheck_id": ["x"]}, lambda c: c.citecheck("Draft.", idempotency_key="k")), +] + + +@pytest.mark.parametrize(("path", "body", "call"), _UNREADABLE) +def test_an_answer_the_model_refuses_carries_the_key(client: Lenz, path, body, call) -> None: + from pydantic import ValidationError + + with respx.mock(base_url=BASE) as r: + r.post(path).respond(200, json=body) + with pytest.raises(ValidationError) as ei: + call(client) + assert ei.value.idempotency_key == "k" # type: ignore[attr-defined] diff --git a/tests/test_literal_aliases.py b/tests/test_literal_aliases.py new file mode 100644 index 0000000..871196e --- /dev/null +++ b/tests/test_literal_aliases.py @@ -0,0 +1,32 @@ +"""``Verdict``, ``VerdictLabel``, ``Confidence`` and ``Depth``: the accepted +values as exported ``Literal`` aliases, for comparisons, exhaustive matching +and docs. The model fields and method arguments stay ``str``, so a value a +later API adds still reads and still sends.""" + +from __future__ import annotations + +import inspect +from typing import get_args + +import lenz_io +from lenz_io import AssessClaim, Confidence, Depth, Lenz, Verdict, VerdictLabel, Verification + + +def test_the_values() -> None: + assert get_args(VerdictLabel) == ("True", "Mostly True", "Mixed", "Mostly False", "False") + assert get_args(Verdict) == ("True", "Mostly True", "Mixed", "Mostly False", "False", "Error") + assert get_args(Confidence) == ("low", "medium", "high") + assert get_args(Depth) == ("standard", "low") + + +def test_exported() -> None: + for name in ("Verdict", "VerdictLabel", "Confidence", "Depth"): + assert name in lenz_io.__all__ + + +def test_fields_and_arguments_stay_open_strings() -> None: + for model in (Verification, AssessClaim): + assert model.model_fields["verdict"].annotation is str + assert model.model_fields["confidence"].annotation is str + assert Verification.model_validate({"verdict": "Unproven", "confidence": "unknown"}).verdict == "Unproven" + assert inspect.signature(Lenz.verify).parameters["depth"].annotation == "str" diff --git a/tests/test_pagination_iterators.py b/tests/test_pagination_iterators.py new file mode 100644 index 0000000..95b7c2c --- /dev/null +++ b/tests/test_pagination_iterators.py @@ -0,0 +1,162 @@ +"""``verifications.iter()`` and ``library.iter()``: every item, page after +page. The page size is read from each response (the API takes none on the +request), the start page is honoured, the walk ends on a short or empty page, +a page is fetched only when the items before it have been consumed, and +``sort="random"`` (not exhaustive) is refused.""" + +from __future__ import annotations + +from itertools import islice + +import pytest +import respx + +from lenz_io import Lenz, LibraryItem, VerificationListItem + +BASE = "https://lenz.io/api/v1" + + +def _page(start: int, count: int, page: int, page_size: int, total: int = 100) -> dict: + items = [{"verification_id": f"v{start + i}", "claim": f"C{start + i}."} for i in range(count)] + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +def _pager(route: respx.Route, pages: dict[int, dict]) -> None: + def respond(request): + import httpx + + return httpx.Response(200, json=pages[int(request.url.params["page"])]) + + route.side_effect = respond + + +@pytest.mark.parametrize( + ("path", "walk", "item_type"), + [ + ("/verifications", lambda c, **kw: c.verifications.iter(**kw), VerificationListItem), + ("/library", lambda c, **kw: c.library.iter(**kw), LibraryItem), + ], +) +class TestIter: + def test_every_page_until_a_short_one(self, client: Lenz, path, walk, item_type) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: _page(0, 2, 1, 2), 2: _page(2, 2, 2, 2), 3: _page(4, 1, 3, 2)}) + items = list(walk(client)) + assert [i.verification_id for i in items] == ["v0", "v1", "v2", "v3", "v4"] + assert all(isinstance(i, item_type) for i in items) + assert [c.request.url.params["page"] for c in route.calls] == ["1", "2", "3"] + assert all("page_size" not in c.request.url.params for c in route.calls) + + def test_a_full_last_page_ends_on_the_empty_one_after_it(self, client: Lenz, path, walk, item_type) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: _page(0, 2, 1, 2), 2: _page(0, 0, 2, 2)}) + assert len(list(walk(client))) == 2 + assert route.call_count == 2 + + def test_the_page_size_is_read_from_each_response(self, client: Lenz, path, walk, item_type) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: _page(0, 3, 1, 3), 2: _page(3, 3, 2, 5)}) + assert len(list(walk(client))) == 6 + assert route.call_count == 2 + + def test_the_start_page_is_honoured(self, client: Lenz, path, walk, item_type) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {3: _page(40, 1, 3, 20)}) + items = list(walk(client, page=3)) + assert [i.verification_id for i in items] == ["v40"] + assert route.calls.last.request.url.params["page"] == "3" + + def test_an_empty_first_page(self, client: Lenz, path, walk, item_type) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: _page(0, 0, 1, 20, total=0)}) + assert list(walk(client)) == [] + assert route.call_count == 1 + + def test_no_prefetch(self, client: Lenz, path, walk, item_type) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: _page(0, 2, 1, 2), 2: _page(2, 2, 2, 2)}) + it = walk(client) + assert route.call_count == 0, "nothing is fetched before the first item is asked for" + assert [i.verification_id for i in islice(it, 2)] == ["v0", "v1"] + assert route.call_count == 1 + next(it) + assert route.call_count == 2 + + def test_it_stops_when_the_pages_read_reach_the_total(self, client: Lenz, path, walk, item_type) -> None: + # A full page that ends the list: no request for the empty one after it. + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: _page(0, 2, 1, 2, total=4), 2: _page(2, 2, 2, 2, total=4)}) + assert len(list(walk(client))) == 4 + assert route.call_count == 2 + + def test_it_stops_when_the_server_answers_another_page(self, client: Lenz, path, walk, item_type) -> None: + # A server that clamps a page past the end to the last one would + # otherwise repeat that page for ever. Its items are not yielded again. + first, clamped = _page(0, 2, 1, 2), _page(0, 2, 1, 2) + del first["total"], clamped["total"] + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: first, 2: clamped}) + items = list(walk(client)) + assert [i.verification_id for i in items] == ["v0", "v1"] + assert route.call_count == 2 + + @pytest.mark.parametrize("page_size", [0, -1, None]) + def test_it_stops_without_a_usable_page_size(self, client: Lenz, path, walk, item_type, page_size) -> None: + page = _page(0, 2, 1, 2) + del page["total"] + if page_size is None: + del page["page_size"] + else: + page["page_size"] = page_size + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: page}) + assert len(list(walk(client))) == 2 + assert route.call_count == 1 + + def test_a_missing_total_is_not_read_as_zero(self, client: Lenz, path, walk, item_type) -> None: + first, second = _page(0, 2, 1, 2), _page(2, 1, 2, 2) + del first["total"], second["total"] + with respx.mock(base_url=BASE) as r: + route = r.get(path) + _pager(route, {1: first, 2: second}) + assert len(list(walk(client))) == 3 + assert route.call_count == 2 + + @pytest.mark.parametrize("page", [0, -3]) + def test_the_start_page_must_be_one_or_more(self, client: Lenz, path, walk, item_type, page) -> None: + with respx.mock(base_url=BASE, assert_all_called=False) as r: + route = r.get(path) + with pytest.raises(ValueError, match="page"): + walk(client, page=page) + assert route.call_count == 0 + + +class TestFilters: + def test_library_filters_reach_every_page(self, unauth_client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get("/library") + _pager(route, {1: _page(0, 2, 1, 2), 2: _page(2, 0, 2, 2)}) + list(unauth_client.library.iter(sort="most_true", search="moon", curated=["trivia"], verdict="True")) + for call in route.calls: + params = call.request.url.params + assert params["sort"] == "most_true" + assert params["search"] == "moon" + assert params["curated"] == "trivia" + assert params["verdict"] == "True" + assert "Authorization" not in call.request.headers + + def test_random_order_is_refused(self, unauth_client: Lenz) -> None: + with respx.mock(base_url=BASE, assert_all_called=False) as r: + route = r.get("/library") + with pytest.raises(ValueError, match="random"): + unauth_client.library.iter(sort="random") + assert route.call_count == 0 diff --git a/tests/test_parity.py b/tests/test_parity.py index b398423..4423735 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -1,22 +1,21 @@ -"""Both response shapes give existing code exactly what the previous release did. +"""The current response shape gives existing code exactly what 2.x did. -The oracle in ``tests/fixtures/parity/expected/`` was produced by the code -BEFORE this SDK read the newer response shape (see -``tests/parity_generate.py``), from the original-shape responses in -``tests/fixtures/parity/legacy/``. Each response has its newer-shape twin in -``tests/fixtures/parity/canonical/`` (the same answer, as the API's newer +The oracle in ``tests/fixtures/parity/expected/`` was produced by the 2.x code +(see ``tests/parity_generate.py``), from the 2.x-shape responses in +``tests/fixtures/parity/legacy/``. Each response has its current-shape twin in +``tests/fixtures/parity/canonical/`` (the same answer, as the API's current version sends it). -* An original-shape response must produce the frozen output byte for byte: - the model dump, every CLI rendering (text and ``--json``), the exception - ``wait`` raises, the error an HTTP error maps to, the parsed webhook. -* Its newer-shape twin must produce the same values through every attribute - the previous release had, the same CLI text, the same ``wait`` outcome and - the same error, except for the few values the newer shape does not carry, +* A 2.x-shape WEBHOOK payload must produce the frozen output byte for byte: + the parsed event. (The SDK's own calls no longer read the 2.x response + shape; webhooks of work submitted by older clients still arrive in it.) +* A current-shape response must produce the same values through every + attribute 2.x had, the same CLI text, the same ``wait`` outcome and the + same error, except for the few values the current shape does not carry, listed one by one in ``KNOWN_GAPS`` with the reason. ``model_dump()`` and the CLI's ``--json`` show the shape the server sent, so -they are compared for the original shape only. +they are not compared for the current shape. """ from __future__ import annotations @@ -58,45 +57,104 @@ def _validation_items(body: dict[str, Any]) -> Any: return detail if isinstance(detail, list) else body.get("errors", []) +#: What a cancelled task reads differently in the current version: its own +#: status and event (the original said ``failed``), no failure block (the +#: original sent one with ``failure_class`` ``cancelled``; a wait and the +#: failed error rebuild it, which the ``wait`` paths below still hold to the +#: original), and the CLI's own words for it. +_CANCELLED_SENTENCE_PATHS = ( + "dump.error", + "wait.cause", + "wait.message", + "wait.friendly_text", + "wait.payload_json.error.message", +) + + +def _cancelled_gap(name: str, path: str) -> str | None: + last = path.rsplit(".", 1)[-1] + if path in ("dump.status", "event.status", "event.event", "event.review.status", "event.citecheck.status"): + return "a cancelled task is its own status in the current version; the original said `failed`" + top_level_block = path.startswith(("dump.failure.", "event.review.failure.", "event.citecheck.failure.")) + if top_level_block and last in ("docs_url", "failure_class", "failure_reason", "hint", "retryable"): + return "the current version sends no failure block for a cancelled task" + if name == "webhook__verification_cancelled.json" and path in ( + "event.type", + "event.error", + "event.failure_class", + "event.retryable", + ): + return "`verification.cancelled` is its own event (`VerificationCancelled`), with no failure fields" + if name == "verify__status_cancelled_live.json" and path in _CANCELLED_SENTENCE_PATHS: + return "a running task's original sentence was `Pipeline stopped at: cancelled`; the status reads `Cancelled.`" + return None + + +#: The lines the CLI prints for a cancelled task's status, in the original +#: shape and now: the rest of the text must be the same. +_CANCELLED_RENDER_LINES = { + "failed — Cancelled.": "cancelled", + "failed — Pipeline stopped at: cancelled": "cancelled", + "Failed: cancelled": "Cancelled.", +} + + +def _cancelled_render_gap(path: str, old: Any, new: Any) -> bool: + """Whether a `render` text differs only by the line the CLI words anew for + a cancelled task.""" + if path not in ("render", "render_issues") or not isinstance(old, str) or not isinstance(new, str): + return False + lines = [_CANCELLED_RENDER_LINES.get(line, line) for line in old.splitlines()] + return lines == new.splitlines() + + def _allowed(name: str, path: str, legacy: dict[str, Any], canonical: dict[str, Any]) -> str | None: """Why ``path`` may differ for ``name``, or ``None`` when it may not.""" lb, cb = legacy["body"], canonical["body"] head, _, rest = path.partition(".") if head == "error": - if rest in _MESSAGE_PATHS and lb.get("detail") != cb.get("detail"): - return "the server words `detail` differently (a 422 sends a sentence, not the list)" - if rest == "code" and lb.get("code") != cb.get("code"): - return "the server sends a different (or a first) `code`" - if rest.startswith("errors") and _validation_items(lb) != _validation_items(cb): - return "the server's field errors differ" + legacy_sentence = isinstance(lb.get("detail"), str) + if rest in _MESSAGE_PATHS and legacy_sentence and lb.get("detail") != cb.get("detail"): + return "the server words its `detail` sentence differently" + if rest in _MESSAGE_PATHS and "detail" not in lb and isinstance(cb.get("detail"), str): + return "the newer shape sends a `detail` sentence where the original sent none" if rest in ("hint", "fix", "friendly_text", "payload_json.error.fix") and lb.get("hint") != cb.get("hint"): return "the server words the hint differently" if head == "wait" or path in ("dump.error", "render"): sentence = lb.get("error") - if isinstance(sentence, str) and sentence != _failure(cb).get("detail"): + if ( + name.startswith("verify__") + and isinstance(sentence, str) + and sentence.startswith("Pipeline stopped: ") + and "failure" in cb + ): if path in ("dump.error", "render") or rest in _MESSAGE_PATHS: - return "the failed run's sentence: the newer shape's `failure.detail` is worded differently" + return ( + "a failure read back from storage said 'Pipeline stopped: .'; " + "the sentence is rebuilt in a running check's form" + ) + if cb.get("status") == "cancelled": + why = _cancelled_gap(name, path) + if why is not None: + return why if path in ("dump.hint", "wait.hint") and lb.get("hint", "") != _failure(cb).get("hint", ""): return "the newer shape carries a hint the original did not" - if path.startswith("wait.payload.") and name.startswith("verify__status_needs_input"): - return "LenzNeedsInputError.payload is the status as sent (model_dump)" + option_claim = path.startswith("wait.payload.claims[") and path.endswith("].claim") + if option_claim and name.startswith("verify__status_needs_input"): + return "LenzNeedsInputError.payload is the status dump: an option also carries its newer name `claim`" if path == "dump.chain_id" and "chain_id" in lb and "chain_id" not in cb: return "`chain_id` is not in the newer shape" if path == "event.task_id" and "task_id" in lb and "task_id" not in cb: return "review/citecheck webhooks drop `task_id` (never pollable); it reads the review or check id" - if path.startswith("event.result.") and name.startswith("webhook__verification_completed"): - return "`result` is the dict as sent; the newer shape's carries `completed_at`" + if path == "event.result.completed_at" and name.startswith("webhook__verification_completed"): + return "`result` also carries the newer `completed_at` beside `modified_at`" return KNOWN_GAPS.get((name, path)) #: Single gaps no rule covers, each with its reason. KNOWN_GAPS: dict[tuple[str, str], str] = { - ("assess__list_compound_item.json", "dump.claims[0].hint"): ( - "the newer shape sends no hint on a completed row with other claims found" - ), - ("assess__list_compound_item.json", "render"): "the same hint line, printed by the CLI", - ("review__get_assessment_rows_full_fields.json", "dump.claims[0].assessment.hint"): ( - "the newer shape sends no hint on a completed quick check with other claims found" + ("review__delete_not_a_route.json", "error.code"): ( + "a route no SDK method calls: the original answered it without a JSON body" ), ("review__get_failed_every_assessment_failed.json", "dump.failure.hint"): "the server words the hint differently", ("review__get_failed_every_assessment_failed.json", "render"): "the same hint, printed by the CLI", @@ -110,6 +168,28 @@ def _allowed(name: str, path: str, legacy: dict[str, Any], canonical: dict[str, } +#: The fix line 3.0 gives a 404 (CHANGELOG "Changed"): 2.x said to retry. +_NOT_FOUND_FIX = ( + "Check the id or key the call names: nothing with it is visible to this credential. Retrying will not help." +) + + +def _intended(path: str, old: Any, new: Any, legacy: dict[str, Any]) -> str | None: + """Why ``path`` changed on purpose in 3.0 (each listed in the CHANGELOG), + or ``None``.""" + head, _, rest = path.partition(".") + if head in ("error", "wait") and rest == "retryable" and old is MISSING and new in (True, False, None): + return "3.0 sets `retryable` on every error" + if legacy["status"] == 404 and head == "error": + if rest == "type" and (old, new) == ("LenzError", "LenzNotFoundError"): + return "3.0 raises LenzNotFoundError (a LenzError) for a 404" + if rest in ("fix", "payload_json.error.fix") and new == _NOT_FOUND_FIX: + return "3.0 says what to check on a 404 instead of advising a retry" + if rest == "friendly_text" and isinstance(new, str) and new.endswith(_NOT_FOUND_FIX): + return "the same fix line, printed by the CLI" + return None + + def _expected(name: str) -> dict[str, Any]: return json.loads((EXPECTED / name).read_text()) @@ -142,8 +222,8 @@ def _old_event_attrs(observed: dict[str, Any], expected: dict[str, Any]) -> dict return {**observed, "event": {k: v for k, v in event.items() if k in expected["event"]}} -@pytest.mark.parametrize("name", names()) -def test_original_shape_is_unchanged(name: str) -> None: +@pytest.mark.parametrize("name", [n for n in names() if n.startswith("webhook__")]) +def test_original_shape_webhook_is_unchanged(name: str) -> None: expected = _expected(name) assert _old_event_attrs(observe(name, load("legacy", name)), expected) == expected @@ -186,7 +266,11 @@ def test_newer_shape_reads_the_same(name: str) -> None: got = _canonical_view(name, expected) legacy, canonical = load("legacy", name), load("canonical", name) gaps = [ - (path, old, new) for path, old, new in _diff(expected, got) if _allowed(name, path, legacy, canonical) is None + (path, old, new) + for path, old, new in _diff(expected, got) + if _allowed(name, path, legacy, canonical) is None + and _intended(path, old, new, legacy) is None + and not (canonical["body"].get("status") == "cancelled" and _cancelled_render_gap(path, old, new)) ] assert not gaps, "\n".join(f"{path}: {old!r} -> {new!r}" for path, old, new in gaps) @@ -223,9 +307,10 @@ def test_model_schemas_match_the_previous_release() -> None: from parity_static import schemas def _shape(schema: Any) -> Any: - # Doc text (class docstrings) may change; the shape may not. + # Doc text (class docstrings) and deprecation markers may change; the + # shape may not. if isinstance(schema, dict): - return {k: _shape(v) for k, v in schema.items() if k != "description"} + return {k: _shape(v) for k, v in schema.items() if k not in ("description", "deprecated")} if isinstance(schema, list): return [_shape(v) for v in schema] return schema diff --git a/tests/test_path_ids.py b/tests/test_path_ids.py new file mode 100644 index 0000000..5a7fca7 --- /dev/null +++ b/tests/test_path_ids.py @@ -0,0 +1,133 @@ +"""An id goes into the URL path as ONE segment. + +httpx normalises dot segments and cuts a URL at ``?`` and ``#``, so an id put +into a path raw can send the request somewhere else: ``cancel_review`` with +``"../citechecks/c1"`` would POST ``/citechecks/c1/cancel``. Every id is +percent-encoded whole, ``"."`` and ``".."`` (which a path normaliser eats) are +refused like an empty id, and an ordinary id is on the wire byte for byte as +before. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any +from urllib.parse import quote + +import httpx +import pytest +import respx + +from lenz_io import Lenz + +BASE = "https://lenz.io/api/v1" +HOSTILE = [ + "../citechecks/c1", + "a#x", + "batch?", + "a/b", + "a b", + "ünï/ç", + "%2e%2e", + "a%2Fb", + "x?y=1#z", + "\\..\\", + "a;b", +] + +#: (label, call(client, id), method, path template) +CALLS: list[tuple[str, Callable[[Lenz, str], Any], str, str]] = [ + ("get_status", lambda c, i: c.get_status(i), "GET", "/verify/status/{}"), + ("cancel", lambda c, i: c.cancel(i), "POST", "/verify/{}/cancel"), + ("select", lambda c, i: c.select(i, claims=["A claim."], idempotency=False), "POST", "/verify/{}/select"), + ("get_review", lambda c, i: c.get_review(i), "GET", "/reviews/{}"), + ("get_review issues", lambda c, i: c.get_review(i, view="issues"), "GET", "/reviews/{}"), + ("cancel_review", lambda c, i: c.cancel_review(i), "POST", "/reviews/{}/cancel"), + ("get_citecheck", lambda c, i: c.get_citecheck(i), "GET", "/citechecks/{}"), + ("cancel_citecheck", lambda c, i: c.cancel_citecheck(i), "POST", "/citechecks/{}/cancel"), + ("wait review", lambda c, i: c._wait_review(i, timeout=0), "GET", "/reviews/{}"), + ("wait citecheck", lambda c, i: c._wait_citecheck(i, timeout=0), "GET", "/citechecks/{}"), + ("wait", lambda c, i: c.wait(i, timeout=0), "GET", "/verify/status/{}"), + ("verifications.get", lambda c, i: c.verifications.get(i), "GET", "/verifications/{}"), + ( + "verifications.get_certificate", + lambda c, i: c.verifications.get_certificate(i), + "GET", + "/verifications/{}/certificate", + ), + ("verifications.delete", lambda c, i: c.verifications.delete(i), "DELETE", "/verifications/{}"), + ("verifications.related", lambda c, i: c.verifications.related(i), "GET", "/verifications/{}/related"), + ("ask.history", lambda c, i: c.ask.history(i), "GET", "/ask/{}"), + ("ask.send", lambda c, i: c.ask.send(i, message="Why?", idempotency=False), "POST", "/ask/{}"), + ("ask.reset", lambda c, i: c.ask.reset(i), "DELETE", "/ask/{}"), +] +IDS = [c[0] for c in CALLS] + + +def _send(call: Callable[[Lenz, str], Any], ident: str) -> list[httpx.Request]: + with Lenz(api_key="lenz_test_abc123", max_retries=0) as client, respx.mock(assert_all_called=False) as router: + router.route().respond(200, json={}) + try: + call(client, ident) + except Exception: + # The stub answers {}: whether the call can read it is not the point. + pass + return [c.request for c in router.calls] + + +@pytest.mark.parametrize("ident", HOSTILE) +@pytest.mark.parametrize(("label", "call", "method", "template"), CALLS, ids=IDS) +def test_a_hostile_id_stays_one_segment_of_the_intended_path( + label: str, call: Callable[[Lenz, str], Any], method: str, template: str, ident: str +) -> None: + requests = _send(call, ident) + assert requests, label + for request in requests: + assert request.method == method + assert request.url.raw_path.split(b"?")[0] == ("/api/v1" + template.format(quote(ident, safe=""))).encode() + assert request.url.fragment == "" + + +@pytest.mark.parametrize( + ("call", "path"), + [ + (lambda c: c.cancel_review("../citechecks/c1"), "/api/v1/reviews/..%2Fcitechecks%2Fc1/cancel"), + (lambda c: c.cancel("a#x"), "/api/v1/verify/a%23x/cancel"), + (lambda c: c.cancel("batch?"), "/api/v1/verify/batch%3F/cancel"), + (lambda c: c.cancel_citecheck("a/b"), "/api/v1/citechecks/a%2Fb/cancel"), + (lambda c: c.get_status("ünï"), "/api/v1/verify/status/%C3%BCn%C3%AF"), + ], +) +def test_the_traversal_examples(call: Callable[[Lenz], Any], path: str) -> None: + requests = _send(lambda c, _i: call(c), "") + assert [r.url.raw_path.decode() for r in requests] == [path] + + +@pytest.mark.parametrize("ident", ["", ".", ".."]) +@pytest.mark.parametrize(("label", "call", "method", "template"), CALLS, ids=IDS) +def test_an_empty_dot_or_dotdot_id_is_refused_before_any_request( + label: str, call: Callable[[Lenz, str], Any], method: str, template: str, ident: str +) -> None: + with Lenz(api_key="lenz_test_abc123", max_retries=0) as client, respx.mock(assert_all_called=False) as router: + router.route().respond(200, json={}) + with pytest.raises(ValueError): + call(client, ident) + assert router.calls.call_count == 0 + + +@pytest.mark.parametrize( + ("label", "call", "method", "template"), + [c for c in CALLS if c[0] not in ("wait review", "wait citecheck", "wait")], + ids=[i for i in IDS if i not in ("wait review", "wait citecheck", "wait")], +) +def test_an_ordinary_id_is_on_the_wire_unchanged( + label: str, call: Callable[[Lenz, str], Any], method: str, template: str +) -> None: + for ident in ("d6b2bd72", "3f2a9c1e5b7d4a608c1d2e3f4a5b6c7d", "tsk_abc-123.v2~x"): + requests = _send(call, ident) + assert requests[0].url.raw_path.split(b"?")[0] == ("/api/v1" + template.format(ident)).encode() + + +def test_a_dotted_id_that_is_not_all_dots_is_fine() -> None: + requests = _send(lambda c, i: c.get_review(i), "a..b") + assert requests[0].url.raw_path == b"/api/v1/reviews/a..b" diff --git a/tests/test_public_call_bodies.py b/tests/test_public_call_bodies.py new file mode 100644 index 0000000..d15e06d --- /dev/null +++ b/tests/test_public_call_bodies.py @@ -0,0 +1,459 @@ +"""The request bodies of the public calls, frozen. + +A request body is part of the contract twice over: the server reads it, and +an ``Idempotency-Key`` replay hashes it, so a body that changes shape between +releases turns a retried request into a 422 (``idempotency_body_mismatch``) +instead of a replay. These tests send each public call with every option +left out, set to its empty / zero / false value, and set to a real value, +always under a pinned key, and compare the body that reached the wire, key +order included, with the one this release sends. + +They go through the public methods (never the private submit helpers), so a +change to a signature that forwards an option differently fails here. +""" + +from __future__ import annotations + +import inspect +import json +from pathlib import Path +from typing import Any + +import pytest +import respx + +from lenz_io import Lenz + +BASE = "https://lenz.io/api/v1" +KEY = "pinned-key-1" +FIXTURES = Path(__file__).parent / "fixtures" / "contract" + + +def _load(name: str) -> dict[str, Any]: + return json.loads((FIXTURES / name).read_text()) + + +REVIEW_DONE = _load("review_completed.json") +CHECK_DONE = _load("citecheck_completed.json") +_COMPLETED_STATUS = {"status": "completed", "task_id": "t", "result": {"verification_id": "v1", "claim": "A."}} + + +def _ordered(content: bytes) -> list[tuple[str, Any]]: + """The body as (key, value) pairs in wire order, nested objects too.""" + return json.loads(content, object_pairs_hook=list) + + +@pytest.fixture() +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + + +# ── verify / verify_and_wait ─────────────────────────────────────────────── + +_VERIFY_CASES: list[tuple[str, dict[str, Any], list[tuple[str, Any]]]] = [ + ("omitted", {}, [("text", "A."), ("source_url", "")]), + ( + "empty", + {"language": "", "visibility": "", "depth": "", "source_url": "", "webhook_url": ""}, + [("text", "A."), ("source_url", "")], + ), + ("blank webhook", {"webhook_url": " "}, [("text", "A."), ("source_url", "")]), + ( + "set", + { + "language": "es", + "visibility": "unlisted", + "depth": "low", + "source_url": "https://example.com/a", + "webhook_url": "https://example.com/hook", + }, + [ + ("text", "A."), + ("source_url", "https://example.com/a"), + ("webhook_url", "https://example.com/hook"), + ("language", "es"), + ("visibility", "unlisted"), + ("depth", "low"), + ], + ), +] + + +class TestVerify: + @pytest.mark.parametrize(("label", "kwargs", "expected"), _VERIFY_CASES) + def test_verify(self, client: Lenz, label: str, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify").respond(200, json={"task_id": "t"}) + client.verify("A.", idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + @pytest.mark.parametrize(("label", "kwargs", "expected"), _VERIFY_CASES) + def test_verify_and_wait(self, client: Lenz, label: str, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify").respond(200, json={"task_id": "t"}) + r.get("/verify/status/t").respond(200, json=_COMPLETED_STATUS) + client.verify_and_wait("A.", idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + def test_text_alias(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify").respond(200, json={"task_id": "t"}) + client.verify(text="A.", idempotency_key=KEY) + assert _ordered(route.calls.last.request.content) == [("text", "A."), ("source_url", "")] + + +# ── verify_batch / verify_batch_and_wait ─────────────────────────────────── + +_BATCH_CASES: list[tuple[str, dict[str, Any], list[tuple[str, Any]]]] = [ + ("omitted", {}, [("claims", [[("text", "A.")], [("text", "B.")]])]), + ( + "empty", + {"webhook_url": "", "language": "", "visibility": "", "depth": ""}, + [("claims", [[("text", "A.")], [("text", "B.")]])], + ), + ( + "set", + {"webhook_url": "https://example.com/hook", "language": "de", "visibility": "unlisted", "depth": "low"}, + [ + ("claims", [[("text", "A.")], [("text", "B.")]]), + ("webhook_url", "https://example.com/hook"), + ("language", "de"), + ("visibility", "unlisted"), + ("depth", "low"), + ], + ), +] +_BATCH_ACCEPTED = {"batch_id": "b", "items": [{"task_id": "t", "claim": "A."}]} + + +class TestVerifyBatch: + @pytest.mark.parametrize(("label", "kwargs", "expected"), _BATCH_CASES) + def test_verify_batch(self, client: Lenz, label: str, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify/batch").respond(200, json=_BATCH_ACCEPTED) + client.verify_batch(claims=[{"claim": "A."}, {"text": "B."}], idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + @pytest.mark.parametrize(("label", "kwargs", "expected"), _BATCH_CASES) + def test_verify_batch_and_wait(self, client: Lenz, label: str, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify/batch").respond(200, json=_BATCH_ACCEPTED) + r.get("/verify/status/t").respond(200, json=_COMPLETED_STATUS) + client.verify_batch_and_wait(claims=[{"claim": "A."}, {"text": "B."}], idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + def test_item_keys_pass_through_and_a_blank_item_webhook_is_left_out(self, client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify/batch").respond(200, json=_BATCH_ACCEPTED) + client.verify_batch( + claims=[ + {"claim": "A.", "language": "fr", "depth": "low", "webhook_url": ""}, + {"text": "B.", "visibility": "unlisted", "webhook_url": "https://example.com/h"}, + ], + idempotency_key=KEY, + ) + assert _ordered(route.calls.last.request.content) == [ + ( + "claims", + [ + [("language", "fr"), ("depth", "low"), ("text", "A.")], + [("text", "B."), ("visibility", "unlisted"), ("webhook_url", "https://example.com/h")], + ], + ) + ] + + +# ── assess / extract / select / ask.send ─────────────────────────────────── + + +class TestSyncCalls: + @pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({"claim": "A."}, [("text", "A.")]), + ({"text": "A.", "language": "", "suggest_rewrite": False}, [("text", "A.")]), + ( + {"claim": "A.", "language": "auto", "suggest_rewrite": True}, + [("text", "A."), ("language", "auto"), ("suggest_rewrite", True)], + ), + ({"claims": ["A.", "B."]}, [("claims", ["A.", "B."])]), + ({"claims": []}, [("claims", [])]), + ({"claims": ["A."], "language": "es"}, [("claims", ["A."]), ("language", "es")]), + ], + ) + def test_assess(self, client: Lenz, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/assess").respond(200, json={"claims": []}) + client.assess(idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + @pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({}, [("text", "Doc.")]), + ({"language": "", "focus": "", "locate": None}, [("text", "Doc.")]), + ({"locate": False}, [("text", "Doc."), ("locate", False)]), + ( + {"language": "it", "focus": "figures", "locate": True}, + [("text", "Doc."), ("language", "it"), ("focus", "figures"), ("locate", True)], + ), + ], + ) + def test_extract(self, client: Lenz, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/extract").respond(200, json={"claims": [], "status": "ok"}) + client.extract(text="Doc.", idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + @pytest.mark.parametrize( + "kwargs", + [{"claims": ["A.", "B."]}, {"texts": ["A.", "B."]}, {"claims": ["A.", "B."], "texts": ["C."]}], + ) + def test_select(self, client: Lenz, kwargs: dict[str, Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/verify/t/select").respond(200, json={"items": []}) + client.select("t", idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == [("texts", ["A.", "B."])] + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + @pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({}, [("message", "Why?")]), + ({"language": ""}, [("message", "Why?")]), + ({"language": "auto"}, [("message", "Why?"), ("language", "auto")]), + ], + ) + def test_ask_send(self, client: Lenz, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/ask/v1").respond(200, json={"role": "expert", "content": "Because."}) + client.ask.send("v1", message="Why?", idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + +# ── review / review_and_wait ─────────────────────────────────────────────── + +_REVIEW_CASES: list[tuple[str, dict[str, Any], list[tuple[str, Any]]]] = [ + ("omitted", {}, [("text", "Draft."), ("visibility", "private")]), + ( + "empty, zero and false", + { + "verdicts": [], + "confidence": [], + "max_assessments": 0, + "max_verifications": 0, + "depth": None, + "max_citations": 0, + "suggest_edits": False, + "language": "", + "webhook_url": "", + "visibility": "", + }, + [ + ("text", "Draft."), + ("webhook_url", ""), + ("escalate", [("verdicts", []), ("confidence", []), ("max_assessments", 0), ("max_verifications", 0)]), + ], + ), + ( + "max_citations None", + {"max_citations": None, "webhook_url": None}, + [("text", "Draft."), ("visibility", "private")], + ), + ( + "set", + { + "verdicts": ["False"], + "confidence": ["low", "medium"], + "max_assessments": 5, + "max_verifications": 2, + "depth": "low", + "max_citations": 3, + "suggest_edits": True, + "language": "nl", + "webhook_url": "https://example.com/hook", + "visibility": "unlisted", + }, + [ + ("text", "Draft."), + ("language", "nl"), + ("webhook_url", "https://example.com/hook"), + ("visibility", "unlisted"), + ( + "escalate", + [ + ("verdicts", ["False"]), + ("confidence", ["low", "medium"]), + ("max_assessments", 5), + ("max_verifications", 2), + ("depth", "low"), + ("max_citations", 3), + ("suggest_edits", True), + ], + ), + ], + ), +] + + +class TestReview: + @pytest.mark.parametrize(("label", "kwargs", "expected"), _REVIEW_CASES) + def test_review(self, client: Lenz, label: str, kwargs: dict[str, Any], expected: list[Any]) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/review").respond(202, json={"review_id": REVIEW_DONE["review_id"], "status": "queued"}) + client.review("Draft.", idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + @pytest.mark.parametrize(("label", "kwargs", "expected"), _REVIEW_CASES) + def test_review_and_wait( + self, client: Lenz, no_sleep: None, label: str, kwargs: dict[str, Any], expected: list[Any] + ) -> None: + rid = REVIEW_DONE["review_id"] + with respx.mock(base_url=BASE) as r: + route = r.post("/review").respond(202, json={"review_id": rid, "status": "queued"}) + r.get(f"/reviews/{rid}").respond(200, json=REVIEW_DONE) + client.review_and_wait("Draft.", idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + +# ── citecheck / citecheck_and_wait ───────────────────────────────────────── + +_PAIR = {"statement": "Water boils at 100 C.", "url": "https://example.com/boil"} +_CITECHECK_CASES: list[tuple[str, tuple[Any, ...], dict[str, Any], list[tuple[str, Any]]]] = [ + ("text omitted", ("Draft.",), {}, [("text", "Draft.")]), + ( + "text zero and empty", + ("Draft.",), + {"max_citations": 0, "language": "", "webhook_url": ""}, + [("text", "Draft."), ("max_citations", 0), ("webhook_url", "")], + ), + ( + "text set", + ("Draft.",), + {"max_citations": 4, "language": "fr", "webhook_url": "https://example.com/hook"}, + [("text", "Draft."), ("max_citations", 4), ("language", "fr"), ("webhook_url", "https://example.com/hook")], + ), + ( + "pairs", + (), + {"pairs": [_PAIR], "language": "", "webhook_url": None}, + [("pairs", [[("statement", "Water boils at 100 C."), ("url", "https://example.com/boil")]])], + ), + ( + "blank text with pairs", + (" ",), + {"pairs": [_PAIR]}, + [("pairs", [[("statement", "Water boils at 100 C."), ("url", "https://example.com/boil")]])], + ), +] + + +class TestCitecheck: + @pytest.mark.parametrize(("label", "args", "kwargs", "expected"), _CITECHECK_CASES) + def test_citecheck( + self, client: Lenz, label: str, args: tuple[Any, ...], kwargs: dict[str, Any], expected: list[Any] + ) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post("/citecheck").respond( + 202, json={"citecheck_id": CHECK_DONE["citecheck_id"], "status": "queued"} + ) + client.citecheck(*args, idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + @pytest.mark.parametrize(("label", "args", "kwargs", "expected"), _CITECHECK_CASES) + def test_citecheck_and_wait( + self, + client: Lenz, + no_sleep: None, + label: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], + expected: list[Any], + ) -> None: + cid = CHECK_DONE["citecheck_id"] + with respx.mock(base_url=BASE) as r: + route = r.post("/citecheck").respond(202, json={"citecheck_id": cid, "status": "queued"}) + r.get(f"/citechecks/{cid}").respond(200, json=CHECK_DONE) + client.citecheck_and_wait(*args, idempotency_key=KEY, **kwargs) + assert _ordered(route.calls.last.request.content) == expected + assert route.calls.last.request.headers["Idempotency-Key"] == KEY + + +# ── cancel / cancel_review / cancel_citecheck ────────────────────────────── + + +_ID = {"cancel": "task_id", "cancel_review": "review_id", "cancel_citecheck": "citecheck_id"} + + +class TestCancels: + """A cancel is a POST with no body and no key: it is safe to repeat, so + there is nothing for an ``Idempotency-Key`` to replay.""" + + @pytest.mark.parametrize( + ("method", "path", "fixture"), + [ + ("cancel", "/verify/3f2a9c1e5b7d4a608c1d2e3f4a5b6c7d/cancel", "cancel_verify_cancelled.json"), + ("cancel_review", "/reviews/d6b2bd72/cancel", "cancel_review_cancelled.json"), + ("cancel_citecheck", "/citechecks/12bbbf65/cancel", "cancel_citecheck_cancelled.json"), + ], + ) + def test_no_body_no_key(self, client: Lenz, method: str, path: str, fixture: str) -> None: + with respx.mock(base_url=BASE) as r: + route = r.post(path).respond(200, json=_load(fixture)) + getattr(client, method)(path.split("/")[2]) + request = route.calls.last.request + assert request.content == b"" + assert "Idempotency-Key" not in request.headers + # The id is the only parameter besides the request options, which are + # keyword-only and reach the wire only as the headers asked for. + params = inspect.signature(getattr(Lenz, method)).parameters + assert list(params) == ["self", _ID[method], "timeout", "max_retries", "extra_headers"] + assert all( + params[n].kind is inspect.Parameter.KEYWORD_ONLY for n in ("timeout", "max_retries", "extra_headers") + ) + + +# ── 3.0: every forwarded option is a named, keyword-only parameter ───────── + + +@pytest.mark.parametrize( + ("outer", "inner", "own"), + [ + ("verify", "_verify_submit", {"claim", "text", "idempotency", "idempotency_key"}), + ("review_and_wait", "review", {"text", "timeout", "on_update"}), + ("citecheck_and_wait", "citecheck", {"text", "timeout", "on_update"}), + ], +) +def test_forwarded_options_are_listed(outer: str, inner: str, own: set[str]) -> None: + import inspect + + outer_params = inspect.signature(getattr(Lenz, outer)).parameters + inner_params = inspect.signature(getattr(Lenz, inner)).parameters + assert not any(p.kind is inspect.Parameter.VAR_KEYWORD for p in outer_params.values()), "no **kwargs" + forwarded = {n for n in inner_params if n != "self"} - own + for name in forwarded: + assert name in outer_params, name + assert outer_params[name].kind is inspect.Parameter.KEYWORD_ONLY, name + assert outer_params[name].default == inner_params[name].default, name + first = next(n for n in outer_params if n != "self") + assert outer_params[first].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + + +def test_an_unknown_option_is_still_a_type_error(client: Lenz) -> None: + for call in ( + lambda: client.verify("A.", sorce_url="x"), # type: ignore[call-arg] + lambda: client.review_and_wait("Draft.", max_asessments=1), # type: ignore[call-arg] + lambda: client.citecheck_and_wait("Draft.", max_citation=1), # type: ignore[call-arg] + ): + with pytest.raises(TypeError): + call() diff --git a/tests/test_read_both_shapes.py b/tests/test_read_both_shapes.py index a8741bb..a936193 100644 --- a/tests/test_read_both_shapes.py +++ b/tests/test_read_both_shapes.py @@ -1,6 +1,9 @@ -"""The new attributes read the same from either response shape, and the old -ones keep their meaning. ``test_parity.py`` holds the old attributes to the -previous release's output; this file checks the new ones.""" +"""The current response shape gives the new attributes, and the 2.x names keep +their 2.x meaning. ``test_parity.py`` holds the 2.x attributes to the previous +release's output; this file checks the new ones. The SDK's own calls read only +the current shape; webhook payloads of both shapes are still parsed (the +webhook tests below), and the models they share with the polled bodies read +both.""" from __future__ import annotations @@ -40,9 +43,14 @@ def _both(name: str) -> tuple[dict, dict]: + """The recorded body in the 2.x shape and in the current shape.""" return load("legacy", name)["body"], load("canonical", name)["body"] +def _canonical(name: str) -> dict: + return load("canonical", name)["body"] + + # ── /extract ── @@ -58,21 +66,21 @@ def _both(name: str) -> tuple[dict, dict]: "extract__not_a_claim.json", ], ) -def test_extract_claims_reads_the_same_from_both_shapes(name): - legacy, canonical = (ExtractedClaims.model_validate(b) for b in _both(name)) - assert [c.model_dump() for c in legacy.claims] == [c.model_dump() for c in canonical.claims] - assert legacy.status == canonical.status # "not_a_claim" from either spelling +def test_extract_claims_and_the_2x_names(name): + legacy_body, body = _both(name) + out = ExtractedClaims.model_validate(body) + names = [c["claim"] for c in body["claims"]] + assert [c.claim for c in out.claims] == names + # the 2.x names: ``claim`` is the first, ``identified_claims`` the list when several + assert out.claim == legacy_body["claim"] + assert out.identified_claims == legacy_body["identified_claims"] + assert out.status == legacy_body["status"] # "not_a_claim" in the 2.x spelling def test_extract_one_claim_is_a_list_of_one(): - legacy, canonical = (ExtractedClaims.model_validate(b) for b in _both("extract__ready_one_claim.json")) - assert [c.claim for c in legacy.claims] == [c.claim for c in canonical.claims] == ["Alpha rose 5% in 2024."] - assert canonical.identified_claims == [] and canonical.claim == "Alpha rose 5% in 2024." - - -def test_extract_claims_union_keeps_the_primary_from_an_older_server(): - out = ExtractedClaims.model_validate({"status": "ready", "claim": "A.", "identified_claims": ["B.", "C."]}) - assert [c.claim for c in out.claims] == ["A.", "B.", "C."] + out = ExtractedClaims.model_validate(_canonical("extract__ready_one_claim.json")) + assert [c.claim for c in out.claims] == ["Alpha rose 5% in 2024."] + assert out.identified_claims == [] and out.claim == "Alpha rose 5% in 2024." def test_extract_empty_claims_does_not_fall_back_to_old_fields(): @@ -83,66 +91,47 @@ def test_extract_empty_claims_does_not_fall_back_to_old_fields(): # ── /assess ── -def test_assess_failed_rows_from_both_shapes(): - legacy, canonical = (AssessResponse.model_validate(b) for b in _both("assess__list_mixed_rows.json")) - for resp in (legacy, canonical): - assert resp.status == "ok" - assert [r.status for r in resp.claims] == ["completed", "failed", "failed", "failed"] - assert [r.failure.code if r.failure else None for r in resp.claims] == [ - None, - "no_checkable_claim", - "upstream_unavailable", - "framing_failed", - ] - assert resp.claims[1].failure.hint.startswith("The input is a greeting.") - # the newer shape: no verdict on a failed row, read as the original "Error"/"low" - row = canonical.claims[1] +def test_assess_failed_rows(): + legacy_body, body = _both("assess__list_mixed_rows.json") + resp = AssessResponse.model_validate(body) + assert resp.status == "ok" + assert [r.status for r in resp.claims] == ["completed", "failed", "failed", "failed"] + assert [r.failure.code if r.failure else None for r in resp.claims] == [ + None, + "no_checkable_claim", + "upstream_unavailable", + "framing_failed", + ] + assert resp.claims[1].failure.hint.startswith("The input is a greeting.") + # the 2.x names: no verdict on a failed row reads as "Error" / "low" + row = resp.claims[1] assert (row.verdict, row.confidence, row.error_code) == ("Error", "low", "no_claim") + assert [r.error_code for r in resp.claims] == [r.get("error_code") for r in legacy_body["claims"]] -def test_assess_single_no_claim_from_both_shapes(): - legacy, canonical = (AssessResponse.model_validate(b) for b in _both("assess__single_no_claim.json")) - for resp in (legacy, canonical): - assert resp.status == "no_checkable_claim" - assert resp.failure is not None and resp.failure.code == "no_checkable_claim" - assert resp.error_code == "no_claim" and resp.error +def test_assess_single_no_claim(): + resp = AssessResponse.model_validate(_canonical("assess__single_no_claim.json")) + assert resp.status == "no_checkable_claim" + assert resp.failure is not None and resp.failure.code == "no_checkable_claim" + assert resp.error_code == "no_claim" and resp.error -def test_assess_more_claims_on_a_row_from_both_shapes(): - legacy, canonical = (AssessResponse.model_validate(b) for b in _both("assess__list_compound_item.json")) - assert legacy.claims[0].more_claims == canonical.claims[0].more_claims == ["Second claim.", "Third claim."] - assert canonical.claims[0].identified_claims == ["Second claim.", "Third claim."] +def test_assess_more_claims_on_a_row(): + resp = AssessResponse.model_validate(_canonical("assess__list_compound_item.json")) + assert resp.claims[0].more_claims == resp.claims[0].identified_claims == ["Second claim.", "Third claim."] def test_assess_empty_more_claims_does_not_fall_back(): row = AssessClaim.model_validate({"claim": "x", "status": "completed", "verdict": "True", "more_claims": []}) assert row.more_claims == [] and row.identified_claims == [] - row = AssessClaim.model_validate( - {"claim": "x", "verdict": "True", "more_claims": [], "identified_claims": ["stale"]} - ) - assert row.more_claims == [] - - -@pytest.mark.parametrize( - ("rows", "status"), - [ - ([{"verdict": "Error", "error_code": "no_claim"}] * 2, "no_checkable_claim"), - ([{"verdict": "Error", "error_code": "timeout"}], "error"), - ([{"verdict": "Error", "error_code": "timeout"}, {"verdict": "True"}], "ok"), - ], -) -def test_assess_status_is_computed_for_the_original_shape(rows, status): - assert AssessResponse.model_validate({"claims": rows, "error": None}).status == status # ── /verify ── -def test_receipt_claim_from_both_shapes(): - legacy, canonical = (BatchAccepted.model_validate(b) for b in _both("verify__batch_202.json")) - assert ( - [i.claim for i in legacy.items] == [i.claim for i in canonical.items] == [i.claim_text for i in canonical.items] - ) +def test_receipt_claim(): + out = BatchAccepted.model_validate(_canonical("verify__batch_202.json")) + assert [i.claim for i in out.items] == [i.claim_text for i in out.items] != [] assert TaskAccepted.model_validate({"task_id": "t"}).claim == "" @@ -152,24 +141,44 @@ def test_receipt_claim_from_both_shapes(): "verify__status_failed.json", "verify__status_failed_retryable.json", "verify__status_not_a_claim.json", - "verify__status_cancelled_after_a_while.json", ], ) -def test_failed_status_failure_block_from_both_shapes(name): - legacy, canonical = (TaskStatus.model_validate(b) for b in _both(name)) - for st in (legacy, canonical): - assert st.failure is not None - assert st.failure.code == _both(name)[1]["failure"]["code"] - assert st.failure.failure_class == canonical.failure_class - assert st.failure.retryable is canonical.retryable - assert st.failure.detail - assert canonical.failure_reason == legacy.failure_reason - assert canonical.error == canonical.failure.detail - - -def test_needs_input_options_from_both_shapes(): - legacy, canonical = (TaskStatus.model_validate(b) for b in _both("verify__status_needs_input.json")) - assert [c.claim for c in legacy.claims] == [c.claim for c in canonical.claims] +def test_failed_status_failure_block(name): + legacy_body, body = _both(name) + canonical = TaskStatus.model_validate(body) + assert canonical.failure is not None + assert canonical.failure.code == body["failure"]["code"] + assert canonical.failure.failure_class == canonical.failure_class + assert canonical.failure.retryable is canonical.retryable + assert canonical.failure.detail + assert canonical.failure_reason == legacy_body["failure_reason"] + # ``error`` keeps the 2.x sentence, rebuilt from the code (a failure + # read back from storage said "Pipeline stopped: ." instead). + if not legacy_body["error"].startswith("Pipeline stopped: "): + assert canonical.error == legacy_body["error"] + + +@pytest.mark.parametrize("name", ["verify__status_cancelled_live.json", "verify__status_cancelled_durable.json"]) +def test_cancelled_status_reads_the_2x_failure_block(name): + """A task cancelled elsewhere is its own status, sent with no failure + block; ``failure`` reads the block 2.x read for it (as in Node), and the + 2.x fields what the original shape said of it.""" + legacy_body, body = _both(name) + assert "failure" not in body + canonical = TaskStatus.model_validate(body) + assert canonical.status == "cancelled" + assert canonical.failure is not None + assert (canonical.failure.code, canonical.failure.failure_class) == ("cancelled", "cancelled") + assert canonical.failure_reason == legacy_body["failure_reason"] == "cancelled" + assert canonical.failure_class == legacy_body["failure_class"] == "cancelled" + assert canonical.retryable is False + assert canonical.docs_url == legacy_body["docs_url"] + + +def test_needs_input_options(): + legacy_body, body = _both("verify__status_needs_input.json") + canonical = TaskStatus.model_validate(body) + assert [c.text for c in canonical.claims] == [c["text"] for c in legacy_body["claims"]] assert [c.text for c in canonical.claims] == [c.claim for c in canonical.claims] @@ -184,9 +193,7 @@ def test_completed_at_and_the_original_modified_at_rule(): {"created_at": "2026-10-01T22:00:00+00:00", "completed_at": "2026-10-02T01:00:00+02:00"} ) assert offset.modified_at is None - legacy = Verification.model_validate({"created_at": late, "modified_at": early}) - assert legacy.completed_at == early - assert Verification.model_validate({"created_at": late, "modified_at": None}).completed_at is None + assert Verification.model_validate({"created_at": late}).completed_at is None @pytest.mark.parametrize( @@ -197,11 +204,13 @@ def test_completed_at_and_the_original_modified_at_rule(): "verify__list_200_modified_at_crosses_midnight_by_minutes.json", ], ) -def test_modified_at_from_both_shapes(name): - cls = VerificationList if "list" in name else Verification - legacy, canonical = (cls.model_validate(b) for b in _both(name)) - pick = (lambda m: m.items[0]) if cls is VerificationList else (lambda m: m) - assert pick(legacy).modified_at == pick(canonical).modified_at +def test_modified_at_follows_the_2x_rule(name): + legacy_body, body = _both(name) + if "list" in name: + got, expected = VerificationList.model_validate(body).items[0], legacy_body["items"][0] + else: + got, expected = Verification.model_validate(body), legacy_body + assert got.modified_at == expected["modified_at"] # ── /me/usage ── @@ -212,13 +221,17 @@ def test_modified_at_from_both_shapes(name): ["account__me_usage_pro_extra.json", "account__me_usage_free_partly_spent.json", "account__me_usage_free.json"], ) def test_usage_blocks_are_computed_from_the_pool(name): - legacy, canonical = (Usage.model_validate(b) for b in _both(name)) + legacy_body, body = _both(name) + canonical = Usage.model_validate(body) for cap in ("verify", "ask", "assess"): - assert getattr(legacy, cap).model_dump() == getattr(canonical, cap).model_dump() - assert canonical.quota_resets_at == canonical.credits.resets_at + dumped = getattr(canonical, cap).model_dump() + assert {k: dumped[k] for k in legacy_body[cap] if k != "credits"} == { + k: v for k, v in legacy_body[cap].items() if k != "credits" + } + assert canonical.quota_resets_at == canonical.credits.resets_at == legacy_body["quota_resets_at"] with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - assert canonical.credits.bonus == legacy.credits.bonus + assert canonical.credits.bonus == legacy_body["credits"]["bonus"] # ── /review ── @@ -253,7 +266,7 @@ def test_review_failure_code_reads_the_new_spelling(): assert FailureBlock.model_validate({"failure_reason": "not_a_claim"}).code == "no_checkable_claim" -# ── the original shape is parsed exactly as before ── +# ── the models keep their 2.x layout ── def test_newer_shape_dump_keeps_every_key_sent(): @@ -273,12 +286,6 @@ def test_sparse_usage_dumps_every_default(): assert set(Usage.model_validate({"plan": "free"}).model_dump()) == set(Usage.model_fields) -def test_original_shape_keeps_unset_fields_unset(): - body = {"claim": "x", "verdict": "True", "confidence": "high"} - assert AssessClaim.model_validate(body).model_dump(exclude_unset=True) == body - assert AssessResponse.model_validate({"claims": []}).model_dump(exclude_unset=True) == {"claims": []} - - def test_new_names_are_properties_not_fields(): for cls, name in ( (AssessClaim, "status"), @@ -377,19 +384,16 @@ def test_409_failed_run_reads_the_failure_block(): assert map_response_to_error(409, json.dumps(canonical).encode()).failure_reason == "not_a_claim" -def test_validation_errors_from_both_shapes(): - legacy, canonical = _both("errors__validation_wrong_type.json") - for body in (legacy, canonical): - err = map_response_to_error(422, json.dumps(body).encode()) - assert isinstance(err, LenzValidationError) - assert [e["msg"] for e in err.errors] == ["Input should be a valid string"] +def test_validation_errors(): + err = map_response_to_error(422, json.dumps(_canonical("errors__validation_wrong_type.json")).encode()) + assert isinstance(err, LenzValidationError) + assert [e["msg"] for e in err.errors] == ["Input should be a valid string"] -def test_rate_limit_wait_from_both_shapes(): - for body in _both("errors__rate_limited_extract.json"): - err = map_response_to_error(429, json.dumps(body).encode()) - assert isinstance(err, LenzRateLimitError) - assert (err.retry_after, err.reset_in_seconds) == (900, 900) +def test_rate_limit_wait(): + err = map_response_to_error(429, json.dumps(_canonical("errors__rate_limited_extract.json")).encode()) + assert isinstance(err, LenzRateLimitError) + assert (err.retry_after, err.reset_in_seconds) == (900, 900) err = map_response_to_error(429, json.dumps({"code": "review_in_flight", "retry_after": 0}).encode()) assert (err.retry_after, err.reset_in_seconds) == (0, None) @@ -404,10 +408,8 @@ def test_error_fields_follow_key_presence(): def test_quota_remaining_is_derived_when_absent(): - legacy, canonical = _both("citecheck__402_no_credits.json") + canonical = _canonical("citecheck__402_no_credits.json") assert map_response_to_error(402, json.dumps(canonical).encode()).remaining == 100 - assert map_response_to_error(402, json.dumps(legacy).encode()).remaining == 100 - assert map_response_to_error(402, json.dumps({"credits_remaining": 9, "cost": 1}).encode()).remaining is None # ── requests ── @@ -435,3 +437,16 @@ def test_verify_never_sends_an_empty_webhook_url(webhook_url): def test_verify_sends_a_webhook_url_that_was_set(): body = _sent(lambda c: c.verify("x", webhook_url="https://hooks.example.test/x")) assert body["webhook_url"] == "https://hooks.example.test/x" + + +def test_a_completed_compound_quick_check_in_a_review_reads_the_2x_hint() -> None: + from parity_observe import load + + from lenz_io.models import ReviewFull + + review = ReviewFull.model_validate(load("canonical", "review__get_assessment_rows_full_fields.json")["body"]) + done, failed = (c.assessment for c in review.claims[:2]) + assert done is not None and failed is not None + assert done.hint == "This text holds more than one claim." + assert done.more_claims == done.identified_claims + assert failed.hint is None diff --git a/tests/test_release_edges.py b/tests/test_release_edges.py new file mode 100644 index 0000000..bcf29d3 --- /dev/null +++ b/tests/test_release_edges.py @@ -0,0 +1,245 @@ +"""Edge inputs found in the final 3.0 reviews: an empty webhook secret, a +timeout too large for a socket, an infinite stated wait, the 2.x wording of +a blank claim, and a cancel's 422 keeping the server's code.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +import respx + +from lenz_io import Lenz, LenzRateLimitError, LenzWebhookSignatureError, verify_signature +from lenz_io.client import DEFAULT_BASE_URL +from lenz_io.errors import map_response_to_error +from lenz_io.webhooks import _sign + +KEY = "lenz_" + "0" * 32 + + +# ── 1. a webhook secret is never empty ───────────────────────────────────── + + +@pytest.mark.parametrize("secret", ["", None]) +def test_verify_signature_refuses_an_empty_secret(secret: Any) -> None: + body = b'{"event": "verification.completed"}' + with pytest.raises(ValueError, match="non-empty secret"): + verify_signature(body, _sign(body, ""), secret) + + +def test_a_real_secret_still_verifies() -> None: + body = b"{}" + assert verify_signature(body, _sign(body, "s3cret"), "s3cret") is True + with pytest.raises(LenzWebhookSignatureError): + verify_signature(body, _sign(body, "other"), "s3cret") + + +# ── 5. a timeout a socket can take ───────────────────────────────────────── + +_TOO_LONG = [1e10, 2_147_484, (5, 1e10, 5, 5)] + + +@pytest.mark.parametrize("bad", _TOO_LONG) +def test_a_timeout_past_the_limit_is_refused_everywhere(bad: Any) -> None: + for call in ( + lambda: Lenz(api_key=KEY, timeout=bad), + lambda: Lenz(api_key=KEY).with_options(timeout=bad), + lambda: Lenz(api_key=KEY).usage(timeout=bad), + lambda: Lenz(api_key=KEY).extract(text="Doc.", timeout=bad), + ): + with pytest.raises(ValueError, match="2,147,483"): + call() + + +def test_an_httpx_timeout_past_the_limit_is_refused() -> None: + with pytest.raises(ValueError, match="2,147,483"): + Lenz(api_key=KEY, timeout=httpx.Timeout(5, read=1e10)) + + +def test_the_limit_itself_is_accepted() -> None: + Lenz(api_key=KEY, timeout=2_147_483).with_options(timeout=(1, 2_147_483, None, 1)).close() + + +# ── 6. an infinite stated wait ───────────────────────────────────────────── + + +@pytest.mark.parametrize("header", ["1e999", "inf", "-inf", "-1e999", "nan"]) +def test_a_non_finite_retry_after_is_no_stated_wait(header: str, monkeypatch: pytest.MonkeyPatch) -> None: + slept: list[float] = [] + monkeypatch.setattr("lenz_io.client.time.sleep", slept.append) + with respx.mock(base_url=DEFAULT_BASE_URL) as r: + route = r.get("/me/usage").respond( + 429, json={"code": "rate_limited", "detail": "slow"}, headers={"Retry-After": header} + ) + with pytest.raises(LenzRateLimitError): + Lenz(api_key=KEY).usage() + # As in Node: no stated wait, so the normal backoff ladder runs. + assert route.call_count == 4 + assert slept == [1.0, 2.0, 4.0] + + +def test_a_huge_finite_retry_after_is_clamped_and_raises_at_once(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + with respx.mock(base_url=DEFAULT_BASE_URL) as r: + route = r.get("/me/usage").respond( + 429, json={"code": "rate_limited", "detail": "slow"}, headers={"Retry-After": "1e300"} + ) + with pytest.raises(LenzRateLimitError): + Lenz(api_key=KEY).usage() + assert route.call_count == 1 + + +def test_an_infinite_retry_after_in_an_error_body_reads_as_unknown() -> None: + err = map_response_to_error( + 503, json.dumps({"code": "capacity", "detail": "busy", "retry_after": "1e999"}).encode(), {} + ) + assert err.retry_after is None + + +# ── 7. a blank claim reads as 2.x worded it ──────────────────────────────── + + +def _blank(loc: list[Any], detail: str) -> bytes: + return json.dumps( + {"detail": detail, "code": "blank_input", "errors": [{"loc": loc, "msg": detail, "type": "blank_input"}]} + ).encode() + + +@pytest.mark.parametrize( + ("method", "path", "loc", "expected"), + [ + ("POST", "/verify", ["body", "claim"], "Text is required."), + ("POST", "/assess", ["body", "claim"], "Text is required."), + ("POST", "/verify/batch", ["body", "claims", 1, "claim"], "claims[1].text is required."), + ("POST", "/verify/t1/select", ["body", "claims"], "texts is required and must be non-empty."), + ], +) +def test_a_blank_claim_keeps_the_2x_sentence(method: str, path: str, loc: list[Any], expected: str) -> None: + err = map_response_to_error(422, _blank(loc, "claim is required."), {}, endpoint=(method, path)) + assert err.message == expected + + +def test_another_blank_field_keeps_the_servers_sentence() -> None: + err = map_response_to_error(422, _blank(["body", "text"], "text is required."), {}, endpoint=("POST", "/extract")) + assert err.message == "text is required." + + +# ── 11. a cancel's 422 keeps the server's code ───────────────────────────── + + +@pytest.mark.parametrize("path", ["/verify/t1/cancel", "/reviews/r1/cancel", "/citechecks/c1/cancel"]) +def test_a_cancel_schema_422_keeps_its_code_and_errors(path: str) -> None: + errors = [{"type": "missing", "loc": ["body", "x"], "msg": "Field required"}] + body = {"detail": "Field required", "code": "validation_error", "errors": errors} + err = map_response_to_error(422, json.dumps(body).encode(), {}, endpoint=("POST", path)) + assert err.code == "validation_error" + assert err.message == "Field required" + + +# ── a cancelled task status carries the failure block 2.21 built ─────────── + +_CANCELLED_BLOCK = { + "code": "cancelled", + "detail": "Cancelled.", + "hint": None, + "failure_class": "cancelled", + "retryable": False, + "docs_url": "https://lenz.io/docs/errors#cancelled", + "failure_reason": "cancelled", +} + + +def _block(failure: Any) -> dict[str, Any]: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return {k: getattr(failure, k) for k in _CANCELLED_BLOCK} + + +def test_a_cancelled_task_status_has_the_cancelled_failure_block() -> None: + from lenz_io import TaskStatus + + status = TaskStatus.model_validate({"status": "cancelled", "task_id": "t1"}) + assert status.failure is not None + assert _block(status.failure) == _CANCELLED_BLOCK + + +def test_a_sent_failure_block_on_a_cancelled_status_is_read_as_sent() -> None: + from lenz_io import TaskStatus + + status = TaskStatus.model_validate( + {"status": "cancelled", "task_id": "t1", "failure": {"code": "other", "detail": "x."}} + ) + assert status.failure is not None and status.failure.code == "other" + + +def test_other_statuses_still_have_none() -> None: + from lenz_io import TaskStatus + + assert TaskStatus.model_validate({"status": "processing", "task_id": "t1"}).failure is None + assert TaskStatus.model_validate({"status": "completed", "task_id": "t1"}).failure is None + + +def test_the_cancelled_webhooks_verification_carries_it_too() -> None: + from lenz_io import VerificationCancelled, parse_webhook + + event = parse_webhook( + { + "event": "verification.cancelled", + "event_id": "e1", + "task_id": "t1", + "status": "cancelled", + "verification": {"status": "cancelled", "task_id": "t1"}, + } + ) + assert isinstance(event, VerificationCancelled) + assert event.verification is not None and _block(event.verification.failure) == _CANCELLED_BLOCK + + +def test_a_batch_items_cancelled_status_detail_carries_it(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + with respx.mock(base_url=DEFAULT_BASE_URL) as r: + r.post("/verify/batch").respond(200, json={"batch_id": "b", "items": [{"task_id": "t1", "claim": "A."}]}) + r.get("/verify/status/t1").respond(200, json={"status": "cancelled", "task_id": "t1"}) + [item] = Lenz(api_key=KEY).verify_batch_and_wait(claims=[{"claim": "A."}]) + assert item.status == "failed" and item.status_detail is not None + assert _block(item.status_detail.failure) == _CANCELLED_BLOCK + + +def test_reviews_and_citation_checks_keep_the_servers_null() -> None: + from lenz_io import Citecheck, ReviewFull + + review = ReviewFull.model_validate( + {"review_id": "r1", "status": "cancelled", "issues": [], "failures": [], "claims": []} + ) + check = Citecheck.model_validate( + {"citecheck_id": "c1", "status": "cancelled", "citations": [], "citation_issues": [], "citation_failures": []} + ) + assert review.failure is None and check.failure is None + + +# ── the public retry_after follows the same rule as the retry ladder ─────── + + +@pytest.mark.parametrize( + ("status", "body", "header", "expected"), + [ + (503, {"detail": "busy"}, "1e300", 2_147_483), + (503, {"detail": "busy"}, "inf", None), + (503, {"detail": "busy"}, "nan", None), + (503, {"detail": "busy"}, "30", 30), + (503, {"code": "capacity", "detail": "busy", "retry_after": 1e300}, None, 2_147_483), + (503, {"code": "capacity", "detail": "busy", "retry_after": "inf"}, "45", 45), + (429, {"code": "rate_limited", "detail": "slow"}, "1e300", 2_147_483), + (429, {"code": "rate_limited", "detail": "slow", "reset_in_seconds": 7}, "inf", 7), + # A 429's retry_after is a number, 0 when nothing usable is stated (as in Node). + (429, {"code": "rate_limited", "detail": "slow"}, "inf", 0), + ], +) +def test_the_public_retry_after(status: int, body: dict[str, Any], header: str | None, expected: Any) -> None: + headers = {"Retry-After": header} if header is not None else {} + err = map_response_to_error(status, json.dumps(body).encode(), headers) + assert err.retry_after == expected diff --git a/tests/test_request_freeze.py b/tests/test_request_freeze.py new file mode 100644 index 0000000..a04ba28 --- /dev/null +++ b/tests/test_request_freeze.py @@ -0,0 +1,71 @@ +"""What every public call form puts on the wire, frozen. + +For each case in ``freeze_cases.py``: every request's URL (query string as +sent), raw body bytes, ordered header pairs and the four ``httpx.Timeout`` +components handed to httpx, the time of each request and every sleep under a +fake clock, and how the call ended. Compared with +``fixtures/freeze/requests.json``, recorded from the release before request +options existed: a call that passes no request option must send exactly this. + +A difference here is a behaviour change. Regenerating the file +(``python tests/test_request_freeze.py``) is for a deliberate change only, +and the diff of the JSON is the review. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import pytest +from freeze_cases import CASES +from freeze_harness import CLIENTS, outcome, recording + +GOLDEN = Path(__file__).parent / "fixtures" / "freeze" / "requests.json" + + +def observe(monkeypatch: pytest.MonkeyPatch, name: str) -> dict[str, Any]: + _, client_name, call, answers = next(c for c in CASES if c[0] == name) + with recording(monkeypatch, answers) as rec: + client = CLIENTS[client_name]() + try: + ended = outcome(lambda: call(client)) + finally: + client.close() + return {"requests": rec.requests, "sleeps": rec.clock.sleeps, "outcome": ended} + + +@pytest.fixture(autouse=True) +def _no_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LENZ_API_KEY", raising=False) + monkeypatch.delenv("LENZ_BASE_URL", raising=False) + + +@pytest.mark.parametrize("name", [c[0] for c in CASES]) +def test_the_request_is_frozen(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + golden = json.loads(GOLDEN.read_text()) + assert observe(monkeypatch, name) == golden[name] + + +def test_every_case_is_frozen() -> None: + assert sorted(json.loads(GOLDEN.read_text())) == sorted(c[0] for c in CASES) + + +if __name__ == "__main__": # pragma: no cover - regeneration, by hand only + import os + + os.environ.pop("LENZ_API_KEY", None) + os.environ.pop("LENZ_BASE_URL", None) + out: dict[str, Any] = {} + mp = pytest.MonkeyPatch() + try: + for case in CASES: + out[case[0]] = observe(mp, case[0]) + mp.undo() + finally: + mp.undo() + GOLDEN.parent.mkdir(parents=True, exist_ok=True) + GOLDEN.write_text(json.dumps(out, indent=1, sort_keys=True, ensure_ascii=False) + "\n") + sys.stdout.write(f"wrote {len(out)} cases to {GOLDEN}\n") diff --git a/tests/test_request_options.py b/tests/test_request_options.py new file mode 100644 index 0000000..5e2b808 --- /dev/null +++ b/tests/test_request_options.py @@ -0,0 +1,1071 @@ +"""Request options: ``timeout``, ``max_retries`` and ``extra_headers`` on every +public method, and ``Lenz.with_options``. + +* The parity map: every public method and the options it takes, by name. +* Forwarding: a method's options reach every request it makes (submit, + polls, pages, retries). +* Same bytes: with options set, the URL and body are those sent without them, + and the headers differ only by the ones asked for. +* Precedence: call over copy over client, the extract / assess floors on an + inherited timeout only, ``None`` vs ``NOT_GIVEN``. +* Headers, ownership of the shared pool, and validation. +""" + +from __future__ import annotations + +import inspect +import math +import sys +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any, ClassVar + +import httpx +import pytest +from freeze_cases import ( + ACCEPTED, + ASK_HISTORY, + ASK_REPLY, + ASSESSED, + BATCH, + CANCEL_CHECK, + CANCEL_REVIEW, + CANCEL_TASK, + CERT, + CHECK_DONE, + CID, + DETAIL, + DONE, + DRAFT, + EXTRACTED, + LIB_1, + LIB_2, + PAGE_1, + PAGE_2, + RELATED, + REVIEW_DONE, + REVIEW_RUNNING, + RID, + RUNNING, + RUNNING_NO_HINT, + S503, + SELECTED, + TASK, + USAGE, +) +from freeze_harness import API_KEY, BASE, PINNED, Recording, recording + +import lenz_io +from lenz_io import NOT_GIVEN, Lenz, NotGiven +from lenz_io.client import EXTRACT_TIMEOUT, WAIT_TIMEOUT + +MARK = "X-Marker" + + +@pytest.fixture(autouse=True) +def _no_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LENZ_API_KEY", raising=False) + monkeypatch.delenv("LENZ_BASE_URL", raising=False) + + +@contextmanager +def recorded(answers: dict[tuple[str, str], list[Any]]) -> Iterator[Recording]: + """Record the requests of the block, under a fake clock patched for the + block only (so a test's own monkeypatches are left alone).""" + with pytest.MonkeyPatch.context() as mp, recording(mp, answers) as rec: + yield rec + + +def _headers(request: dict[str, Any]) -> dict[str, str]: + return {name.lower(): value for name, value in request["headers"]} + + +# ── the parity map ───────────────────────────────────────────────────────── +# +# Every public method, the Node SDK's name for it, and the request options it +# takes. ``call``: all three, ``timeout`` = one HTTP attempt (``None`` = +# inherit). ``wait``: ``timeout`` is the wait budget and only ``extra_headers`` +# is new (each poll is one request, so no ``max_retries``). ``submit_wait``: +# ``timeout`` is the wait budget, ``max_retries`` the submit's. +CALL = {"timeout", "max_retries", "extra_headers"} +PARITY: dict[str, tuple[str, str]] = { + "verify": ("verify", "call"), + "verify_batch": ("verifyBatch", "call"), + "extract": ("extract", "call"), + "assess": ("assess", "call"), + "select": ("select", "call"), + "get_status": ("getStatus", "call"), + "cancel": ("cancel", "call"), + "review": ("review", "call"), + "get_review": ("getReview", "call"), + "cancel_review": ("cancelReview", "call"), + "citecheck": ("citecheck", "call"), + "get_citecheck": ("getCitecheck", "call"), + "cancel_citecheck": ("cancelCitecheck", "call"), + "usage": ("usage", "call"), + "wait": ("wait", "wait"), + "verify_and_wait": ("verifyAndWait", "submit_wait"), + "verify_batch_and_wait": ("verifyBatchAndWait", "submit_wait"), + "review_and_wait": ("reviewAndWait", "submit_wait"), + "citecheck_and_wait": ("citecheckAndWait", "submit_wait"), + "verifications.list": ("verifications.list", "call"), + "verifications.iter": ("verifications.listAll", "call"), + "verifications.get": ("verifications.get", "call"), + "verifications.get_certificate": ("verifications.getCertificate", "call"), + "verifications.delete": ("verifications.delete", "call"), + "verifications.related": ("verifications.related", "call"), + "ask.history": ("ask.history", "call"), + "ask.send": ("ask.send", "call"), + "ask.reset": ("ask.reset", "call"), + "library.list": ("library.list", "call"), + "library.iter": ("library.listAll", "call"), + "with_options": ("withOptions", "with_options"), + "close": ("(none)", "lifecycle"), +} + + +def _public_methods() -> dict[str, Callable[..., Any]]: + client = Lenz(api_key=API_KEY) + out: dict[str, Callable[..., Any]] = {} + for name, value in inspect.getmembers(client): + if name.startswith("_"): + continue + if name in ("verifications", "ask", "library"): + for sub, method in inspect.getmembers(value): + if not sub.startswith("_") and callable(method): + out[f"{name}.{sub}"] = method + elif callable(value): + out[name] = value + client.close() + return out + + +def test_every_public_method_is_in_the_parity_map() -> None: + assert sorted(_public_methods()) == sorted(PARITY) + + +@pytest.mark.parametrize("name", sorted(PARITY)) +def test_each_method_takes_the_options_the_map_says(name: str) -> None: + kind = PARITY[name][1] + params = inspect.signature(_public_methods()[name]).parameters + options = {n: p for n, p in params.items() if n in CALL} + if kind == "lifecycle": + assert options == {} + return + for p in options.values(): + assert p.kind is inspect.Parameter.KEYWORD_ONLY, (name, p.name) + if kind == "call": + assert set(options) == CALL + assert all(p.default is None for p in options.values()) + elif kind == "wait": + assert set(options) == {"timeout", "extra_headers"} + assert options["timeout"].default == WAIT_TIMEOUT # the wait budget, unchanged + assert options["extra_headers"].default is None + elif kind == "submit_wait": + assert set(options) == CALL + assert isinstance(options["timeout"].default, float) # the wait budget, unchanged + assert options["max_retries"].default is None and options["extra_headers"].default is None + else: + assert kind == "with_options" + assert set(options) == CALL + assert options["timeout"].default is NOT_GIVEN + assert options["max_retries"].default is NOT_GIVEN + assert options["extra_headers"].default is None + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="typing.get_overloads is 3.11+") +def test_get_review_overloads_all_take_the_options() -> None: + from typing import get_overloads # type: ignore[attr-defined,unused-ignore] + + overloads = get_overloads(Lenz.get_review) + assert len(overloads) == 3 + for fn in overloads: + assert CALL <= set(inspect.signature(fn).parameters) + + +# ── every method, run with and without options ───────────────────────────── + +Answers = dict[tuple[str, str], list[Any]] +Run = Callable[[Lenz, dict[str, Any]], Any] + +# Each method: how to call it, its scripted answers (a 503 first wherever the +# call retries, so a retry is a recorded request too), and its kind. +CALLS: dict[str, tuple[Run, Answers]] = { + "verify": (lambda c, o: c.verify("A.", idempotency_key=PINNED, **o), {("POST", "/verify"): [S503, ACCEPTED]}), + "verify_batch": ( + lambda c, o: c.verify_batch(claims=[{"claim": "A."}], idempotency_key=PINNED, **o), + {("POST", "/verify/batch"): [S503, BATCH]}, + ), + "extract": ( + lambda c, o: c.extract(text="Doc.", idempotency_key=PINNED, **o), + {("POST", "/extract"): [S503, EXTRACTED]}, + ), + "assess": (lambda c, o: c.assess("A.", idempotency_key=PINNED, **o), {("POST", "/assess"): [S503, ASSESSED]}), + "select": ( + lambda c, o: c.select(TASK, claims=["A."], idempotency_key=PINNED, **o), + {("POST", f"/verify/{TASK}/select"): [S503, SELECTED]}, + ), + "get_status": (lambda c, o: c.get_status(TASK, **o), {("GET", f"/verify/status/{TASK}"): [S503, DONE]}), + "cancel": ( + lambda c, o: c.cancel(CANCEL_TASK["task_id"], **o), + {("POST", f"/verify/{CANCEL_TASK['task_id']}/cancel"): [S503, (200, CANCEL_TASK)]}, + ), + "review": ( + lambda c, o: c.review(DRAFT, idempotency_key=PINNED, **o), + {("POST", "/review"): [S503, (202, {"review_id": RID, "status": "queued"})]}, + ), + "get_review": ( + lambda c, o: c.get_review(RID, view="issues", **o), + {("GET", f"/reviews/{RID}"): [S503, (200, REVIEW_DONE)]}, + ), + "cancel_review": ( + lambda c, o: c.cancel_review(CANCEL_REVIEW["review_id"], **o), + {("POST", f"/reviews/{CANCEL_REVIEW['review_id']}/cancel"): [S503, (200, CANCEL_REVIEW)]}, + ), + "citecheck": ( + lambda c, o: c.citecheck(DRAFT, idempotency_key=PINNED, **o), + {("POST", "/citecheck"): [S503, (202, {"citecheck_id": CID, "status": "queued"})]}, + ), + "get_citecheck": ( + lambda c, o: c.get_citecheck(CID, **o), + {("GET", f"/citechecks/{CID}"): [S503, (200, CHECK_DONE)]}, + ), + "cancel_citecheck": ( + lambda c, o: c.cancel_citecheck(CID, **o), + {("POST", f"/citechecks/{CID}/cancel"): [S503, (200, CANCEL_CHECK)]}, + ), + "usage": (lambda c, o: c.usage(**o), {("GET", "/me/usage"): [S503, USAGE]}), + "wait": (lambda c, o: c.wait(TASK, **o), {("GET", f"/verify/status/{TASK}"): [S503, RUNNING, DONE]}), + "verify_and_wait": ( + lambda c, o: c.verify_and_wait("A.", idempotency_key=PINNED, **o), + {("POST", "/verify"): [S503, ACCEPTED], ("GET", f"/verify/status/{TASK}"): [S503, RUNNING, DONE]}, + ), + "verify_batch_and_wait": ( + lambda c, o: c.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], idempotency_key=PINNED, **o), + { + ("POST", "/verify/batch"): [S503, BATCH], + ("GET", f"/verify/status/{TASK}"): [RUNNING, DONE], + ("GET", "/verify/status/t2"): [S503, (200, {"status": "completed", "task_id": "t2", "result": {}})], + }, + ), + "review_and_wait": ( + lambda c, o: c.review_and_wait(DRAFT, idempotency_key=PINNED, **o), + { + ("POST", "/review"): [S503, (202, {"review_id": RID, "status": "queued"})], + ("GET", f"/reviews/{RID}"): [(200, REVIEW_RUNNING), S503, (200, REVIEW_DONE)], + }, + ), + "citecheck_and_wait": ( + lambda c, o: c.citecheck_and_wait(DRAFT, idempotency_key=PINNED, **o), + { + ("POST", "/citecheck"): [S503, (202, {"citecheck_id": CID, "status": "queued"})], + ("GET", f"/citechecks/{CID}"): [S503, (200, CHECK_DONE)], + }, + ), + "verifications.list": ( + lambda c, o: c.verifications.list(page=1, **o), + {("GET", "/verifications"): [S503, PAGE_1]}, + ), + "verifications.iter": ( + lambda c, o: list(c.verifications.iter(**o)), + {("GET", "/verifications"): [S503, PAGE_1, S503, PAGE_2]}, + ), + "verifications.get": (lambda c, o: c.verifications.get("v1", **o), {("GET", "/verifications/v1"): [S503, DETAIL]}), + "verifications.get_certificate": ( + lambda c, o: c.verifications.get_certificate("v1", **o), + {("GET", "/verifications/v1/certificate"): [S503, CERT]}, + ), + "verifications.delete": ( + lambda c, o: c.verifications.delete("v1", **o), + {("DELETE", "/verifications/v1"): [S503, (204, None)]}, + ), + "verifications.related": ( + lambda c, o: c.verifications.related("v1", limit=3, **o), + {("GET", "/verifications/v1/related"): [S503, RELATED]}, + ), + "ask.history": (lambda c, o: c.ask.history("v1", **o), {("GET", "/ask/v1"): [S503, ASK_HISTORY]}), + "ask.send": ( + lambda c, o: c.ask.send("v1", message="Why?", idempotency_key=PINNED, **o), + {("POST", "/ask/v1"): [S503, ASK_REPLY]}, + ), + "ask.reset": (lambda c, o: c.ask.reset("v1", **o), {("DELETE", "/ask/v1"): [S503, (204, None)]}), + "library.list": (lambda c, o: c.library.list(search="x", **o), {("GET", "/library"): [S503, LIB_1]}), + "library.iter": ( + lambda c, o: list(c.library.iter(search="x", **o)), + {("GET", "/library"): [S503, LIB_1, S503, LIB_2]}, + ), +} + + +def _options_for(name: str, *, headers: dict[str, str | None] | None = None) -> dict[str, Any]: + """Every option the method takes, set: a per-attempt timeout of 7 s, two + retries and a marker header.""" + kind = PARITY[name][1] + opts: dict[str, Any] = {"extra_headers": headers if headers is not None else {MARK: "m"}} + if kind in ("call", "submit_wait"): + opts["max_retries"] = 2 + if kind == "call": + opts["timeout"] = 7 + return opts + + +def _run(monkeypatch: pytest.MonkeyPatch, name: str, opts: dict[str, Any], client: Lenz | None = None) -> Any: + call, answers = CALLS[name] + with recorded(answers) as rec: + c = client or Lenz(api_key=API_KEY) + call(c, opts) + return rec + + +def test_every_method_with_options_is_exercised() -> None: + assert sorted(CALLS) == sorted(n for n, (_, kind) in PARITY.items() if kind not in ("with_options", "lifecycle")) + + +@pytest.mark.parametrize("name", sorted(CALLS)) +def test_options_reach_every_request_the_method_makes(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + rec = _run(monkeypatch, name, _options_for(name)) + assert len(rec.requests) >= 2 # a retry, a poll or a second page + for request in rec.requests: + assert _headers(request).get(MARK.lower()) == "m", (name, request["url"]) + + +@pytest.mark.parametrize("name", sorted(CALLS)) +def test_a_copys_options_reach_every_request_too(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + root = Lenz(api_key=API_KEY) + rec = _run(monkeypatch, name, {}, client=root.with_options(extra_headers={MARK: "copy"})) + for request in rec.requests: + assert _headers(request).get(MARK.lower()) == "copy", (name, request["url"]) + + +@pytest.mark.parametrize("name", sorted(CALLS)) +def test_options_change_nothing_but_the_headers_asked_for(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + """Empty options send exactly the request of a call without them; set + options send the same URLs and bodies, with only the marker header + added. The timeouts differ only where a per-attempt timeout was given.""" + plain = _run(monkeypatch, name, {}).requests + empty_opts: dict[str, Any] = {"extra_headers": {}} + if PARITY[name][1] != "wait": + empty_opts["max_retries"] = None + if PARITY[name][1] == "call": + empty_opts["timeout"] = None + empty = _run(monkeypatch, name, empty_opts).requests + assert empty == plain + + full = _run(monkeypatch, name, _options_for(name)).requests + assert [(r["method"], r["url"], r["body"]) for r in full] == [(r["method"], r["url"], r["body"]) for r in plain] + for with_opts, without in zip(full, plain, strict=True): + assert [h for h in with_opts["headers"] if h[0] != MARK] == without["headers"] + assert [h for h in with_opts["headers"] if h[0] == MARK] == [[MARK, "m"]] + if PARITY[name][1] == "call": + assert with_opts["timeout"] == {"connect": 7, "read": 7, "write": 7, "pool": 7} + else: + assert with_opts["timeout"] == without["timeout"] + + +@pytest.mark.parametrize("name", sorted(n for n in CALLS if PARITY[n][1] != "wait")) +def test_max_retries_reaches_the_request_it_retries(name: str) -> None: + """Every scripted call answers 503 first: with ``max_retries=0`` that is + the one request made, and the call fails with it.""" + call, answers = CALLS[name] + with recorded(answers) as rec, pytest.raises(lenz_io.LenzAPIError): + call(Lenz(api_key=API_KEY), {"max_retries": 0}) + assert len(rec.requests) == 1 + + +# ── the budget table: what each option reaches ───────────────────────────── + + +def _count(monkeypatch: pytest.MonkeyPatch, answers: Answers, call: Callable[[Lenz], Any], client: Lenz) -> Any: + with recorded(answers) as rec: + try: + call(client) + except lenz_io.LenzError: + pass + return rec + + +class TestBudgets: + def test_max_retries_replaces_the_clients_count(self, monkeypatch: pytest.MonkeyPatch) -> None: + answers: Answers = {("GET", "/me/usage"): [S503]} + client = Lenz(api_key=API_KEY) + assert len(_count(monkeypatch, answers, lambda c: c.usage(), client).requests) == 4 + assert len(_count(monkeypatch, answers, lambda c: c.usage(max_retries=0), client).requests) == 1 + assert len(_count(monkeypatch, answers, lambda c: c.usage(max_retries=5), client).requests) == 6 + assert len(_count(monkeypatch, answers, lambda c: c.with_options(max_retries=1).usage(), client).requests) == 2 + copy = client.with_options(max_retries=1) + assert len(_count(monkeypatch, answers, lambda c: copy.usage(max_retries=0), client).requests) == 1 + + def test_a_waits_max_retries_is_the_submits_and_never_the_polls(self, monkeypatch: pytest.MonkeyPatch) -> None: + answers: Answers = { + ("POST", "/verify"): [S503, S503, ACCEPTED], + ("GET", f"/verify/status/{TASK}"): [S503, S503, DONE], + } + rec = _count( + monkeypatch, answers, lambda c: c.verify_and_wait("A.", max_retries=5, timeout=60), Lenz(api_key=API_KEY) + ) + submits = [r for r in rec.requests if r["method"] == "POST"] + polls = [r for r in rec.requests if r["method"] == "GET"] + assert len(submits) == 3 + # Each poll is one request: the two 503s are two rounds, with a poll sleep between. + assert len(polls) == 3 and rec.clock.sleeps[-2:] == [2.0, 4.0] + + def test_max_retries_0_on_a_wait_submits_once(self, monkeypatch: pytest.MonkeyPatch) -> None: + answers: Answers = {("POST", "/review"): [S503]} + rec = _count(monkeypatch, answers, lambda c: c.review_and_wait(DRAFT, max_retries=0), Lenz(api_key=API_KEY)) + assert len(rec.requests) == 1 + + def test_a_per_call_timeout_reaches_each_page(self, monkeypatch: pytest.MonkeyPatch) -> None: + rec = _count( + monkeypatch, + {("GET", "/verifications"): [PAGE_1, PAGE_2]}, + lambda c: list(c.verifications.iter(timeout=4)), + Lenz(api_key=API_KEY), + ) + assert [r["timeout"]["read"] for r in rec.requests] == [4, 4] + + def test_a_waits_timeout_stays_the_budget(self, monkeypatch: pytest.MonkeyPatch) -> None: + # ``timeout=12`` is how long to wait: the polls' own timeout stays the + # client's 30 s, capped by what is left of the 12 s. + rec = _count( + monkeypatch, + {("GET", f"/verify/status/{TASK}"): [RUNNING_NO_HINT]}, + lambda c: c.wait(TASK, timeout=12), + Lenz(api_key=API_KEY), + ) + assert [r["timeout"]["read"] for r in rec.requests] == [12, 10, 6] + + def test_the_submit_of_a_wait_takes_the_attempt_timeout_not_the_budget( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + rec = _count( + monkeypatch, + {("POST", "/verify"): [ACCEPTED], ("GET", f"/verify/status/{TASK}"): [DONE]}, + lambda c: c.with_options(timeout=45).verify_and_wait("A.", timeout=5), + Lenz(api_key=API_KEY), + ) + assert rec.requests[0]["timeout"]["read"] == 45 # the submit: the copy's attempt timeout + assert rec.requests[1]["timeout"]["read"] == 5 # the poll: capped by the wait + + +# ── precedence ───────────────────────────────────────────────────────────── + + +def _timeout_of(monkeypatch: pytest.MonkeyPatch, call: Callable[[], Any], answers: Answers) -> dict[str, Any]: + with recorded(answers) as rec: + call() + return rec.requests[0]["timeout"] + + +def _all(value: float | None) -> dict[str, float | None]: + return {"connect": value, "read": value, "write": value, "pool": value} + + +_USAGE: Answers = {("GET", "/me/usage"): [USAGE]} +_EXTRACT: Answers = {("POST", "/extract"): [EXTRACTED]} +_ASSESS: Answers = {("POST", "/assess"): [ASSESSED]} + + +class TestTimeoutPrecedence: + @pytest.mark.parametrize( + ("build", "expected"), + [ + (lambda c: c.usage(), _all(30.0)), + (lambda c: c.usage(timeout=7), _all(7)), + (lambda c: c.usage(timeout=None), _all(30.0)), # None per call = inherit + (lambda c: c.usage(timeout=httpx.Timeout(None)), _all(None)), # unbounded for one call + (lambda c: c.usage(timeout=httpx.Timeout(3, read=9)), {"connect": 3, "read": 9, "write": 3, "pool": 3}), + (lambda c: c.with_options(timeout=12).usage(), _all(12)), + (lambda c: c.with_options(timeout=12).usage(timeout=7), _all(7)), + (lambda c: c.with_options(timeout=12).usage(timeout=None), _all(12)), + (lambda c: c.with_options(timeout=None).usage(), _all(None)), # None on a copy = unbounded + (lambda c: c.with_options(timeout=None).with_options().usage(), _all(None)), # NOT_GIVEN keeps it + (lambda c: c.with_options(timeout=12).with_options(max_retries=0).usage(), _all(12)), + (lambda c: c.with_options(timeout=12).with_options(timeout=20).usage(), _all(20)), + (lambda c: c.with_options(timeout=12).with_options(timeout=NOT_GIVEN).usage(), _all(12)), + ], + ) + def test_a_plain_call(self, monkeypatch: pytest.MonkeyPatch, build: Any, expected: dict[str, Any]) -> None: + client = Lenz(api_key=API_KEY) + assert _timeout_of(monkeypatch, lambda: build(client), _USAGE) == expected + + @pytest.mark.parametrize( + ("constructor", "build", "expected"), + [ + (30.0, lambda c: c.extract(text="Doc."), _all(EXTRACT_TIMEOUT)), + (30.0, lambda c: c.extract(text="Doc.", timeout=5), _all(5)), # explicit: used as given, below the floor + (30.0, lambda c: c.with_options(timeout=200).extract(text="Doc."), _all(200)), + (30.0, lambda c: c.with_options(timeout=20).extract(text="Doc."), _all(EXTRACT_TIMEOUT)), + (30.0, lambda c: c.with_options(timeout=None).extract(text="Doc."), _all(None)), + (30.0, lambda c: c.with_options(timeout=20).extract(text="Doc.", timeout=9), _all(9)), + ( + 30.0, + lambda c: c.with_options(timeout=httpx.Timeout(200, read=5)).extract(text="Doc."), + _all(EXTRACT_TIMEOUT), + ), + ( + 30.0, + lambda c: c.with_options(timeout=httpx.Timeout(5, read=200)).extract(text="Doc."), + {"connect": 5, "read": 200, "write": 5, "pool": 5}, + ), + (300.0, lambda c: c.extract(text="Doc."), _all(300.0)), + (300.0, lambda c: c.with_options(timeout=20).extract(text="Doc."), _all(EXTRACT_TIMEOUT)), + ], + ) + def test_extract_floor_applies_to_an_inherited_timeout_only( + self, monkeypatch: pytest.MonkeyPatch, constructor: float, build: Any, expected: dict[str, Any] + ) -> None: + client = Lenz(api_key=API_KEY, timeout=constructor) + assert _timeout_of(monkeypatch, lambda: build(client), _EXTRACT) == expected + + def test_assess_floor_on_a_copy(self, monkeypatch: pytest.MonkeyPatch) -> None: + client = Lenz(api_key=API_KEY) + assert _timeout_of(monkeypatch, lambda: client.with_options(timeout=50).assess("A."), _ASSESS) == _all(100.0) + assert _timeout_of(monkeypatch, lambda: client.assess("A.", timeout=20), _ASSESS) == _all(20) + + def test_a_borrowed_clients_own_timeout_counts_below_a_copy(self, monkeypatch: pytest.MonkeyPatch) -> None: + client = Lenz(api_key=API_KEY, http_client=httpx.Client(timeout=300.0)) + assert _timeout_of(monkeypatch, lambda: client.extract(text="Doc."), _EXTRACT) == _all(300.0) + assert _timeout_of(monkeypatch, lambda: client.with_options(timeout=40).usage(), _USAGE) == _all(40) + + def test_a_copys_timeout_bounds_a_waits_polls(self, monkeypatch: pytest.MonkeyPatch) -> None: + client = Lenz(api_key=API_KEY).with_options(timeout=httpx.Timeout(4, read=8)) + with recorded({("GET", f"/verify/status/{TASK}"): [RUNNING_NO_HINT, DONE]}) as rec: + client.wait(TASK, timeout=60) + assert [r["timeout"] for r in rec.requests] == [{"connect": 4, "read": 8, "write": 4, "pool": 4}] * 2 + + def test_a_zero_budget_poll_uses_the_copys_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: + client = Lenz(api_key=API_KEY).with_options(timeout=9) + with recorded({("GET", f"/verify/status/{TASK}"): [DONE]}) as rec: + client.wait(TASK, timeout=0) + assert rec.requests[0]["timeout"] == _all(9) + + +# ── headers ──────────────────────────────────────────────────────────────── + + +def _sent_headers(monkeypatch: pytest.MonkeyPatch, call: Callable[[], Any]) -> list[list[str]]: + with recorded(_USAGE) as rec: + call() + return rec.requests[0]["headers"] + + +class TestHeaders: + def test_merged_case_insensitively_the_calls_spelling_and_value_win(self, monkeypatch: pytest.MonkeyPatch) -> None: + copy = Lenz(api_key=API_KEY).with_options(extra_headers={"x-a": "copy", "X-B": "b"}) + sent = _sent_headers(monkeypatch, lambda: copy.usage(extra_headers={"X-A": "call"})) + names = [h[0] for h in sent] + assert ["X-A", "call"] in sent and ["X-B", "b"] in sent + assert [n.lower() for n in names].count("x-a") == 1 + + def test_a_copy_of_a_copy_merges_over_its_parent(self, monkeypatch: pytest.MonkeyPatch) -> None: + parent = Lenz(api_key=API_KEY).with_options(extra_headers={"X-A": "1", "X-B": "2"}) + child = parent.with_options(extra_headers={"x-b": "3", "X-A": None}) + sent = _sent_headers(monkeypatch, child.usage) + assert ["x-b", "3"] in sent + assert "x-a" not in [h[0].lower() for h in sent] + # The parent is unchanged. + assert ["X-A", "1"] in _sent_headers(monkeypatch, parent.usage) + + def test_none_removes_a_copy_header_and_nothing_else(self, monkeypatch: pytest.MonkeyPatch) -> None: + copy = Lenz(api_key=API_KEY).with_options(extra_headers={"X-A": "1"}) + sent = _sent_headers(monkeypatch, lambda: copy.usage(extra_headers={"x-a": None, "User-Agent": None})) + lower = [h[0].lower() for h in sent] + assert "x-a" not in lower + assert "user-agent" in lower # a default is not removed by None + + def test_an_option_header_replaces_a_default_in_any_casing(self, monkeypatch: pytest.MonkeyPatch) -> None: + client = Lenz(api_key=API_KEY) + sent = _sent_headers(monkeypatch, lambda: client.usage(extra_headers={"user-agent": "mine/1", "ACCEPT": "x/y"})) + uas = [h for h in sent if h[0].lower() == "user-agent"] + accepts = [h for h in sent if h[0].lower() == "accept"] + assert uas == [["user-agent", "mine/1"]] and accepts == [["ACCEPT", "x/y"]] + + def test_content_type_is_still_sent_on_a_bodyless_request(self, monkeypatch: pytest.MonkeyPatch) -> None: + sent = _sent_headers(monkeypatch, lambda: Lenz(api_key=API_KEY).usage(extra_headers={MARK: "m"})) + assert ["Content-Type", "application/json"] in sent + + def test_the_shared_client_is_never_changed(self, monkeypatch: pytest.MonkeyPatch) -> None: + root = Lenz(api_key=API_KEY) + before = (list(root._client.headers.raw), root._client.timeout) + copy = root.with_options(timeout=3, max_retries=0, extra_headers={"User-Agent": "x", MARK: "m"}) + _sent_headers(monkeypatch, copy.usage) + assert (list(root._client.headers.raw), root._client.timeout) == before + assert MARK.lower() not in [h[0].lower() for h in _sent_headers(monkeypatch, root.usage)] + + @pytest.mark.parametrize( + "name", + [ + "X-Lenz-API-Version", + "idempotency-key", + "Authorization", + "content-type", + "Content-Length", + "HOST", + "Transfer-Encoding", + ], + ) + def test_reserved_names_are_refused_everywhere(self, name: str) -> None: + client = Lenz(api_key=API_KEY) + for call in ( + lambda: client.usage(extra_headers={name: "x"}), + lambda: client.verify("A.", extra_headers={name: "x"}), + lambda: client.wait(TASK, extra_headers={name: "x"}), + lambda: client.review_and_wait(DRAFT, extra_headers={name: "x"}), + lambda: client.verifications.iter(extra_headers={name: "x"}), + lambda: client.with_options(extra_headers={name: "x"}), + ): + with pytest.raises(ValueError, match="set by the SDK"): + call() + + +# ── ownership of the shared pool ─────────────────────────────────────────── + + +class TestOwnership: + def test_the_root_that_made_the_pool_closes_it(self) -> None: + root = Lenz(api_key=API_KEY) + root.close() + assert root._client.is_closed + + def test_a_borrowed_pool_is_never_closed(self) -> None: + http = httpx.Client() + root = Lenz(api_key=API_KEY, http_client=http) + root.with_options(timeout=5).close() + root.close() + assert not http.is_closed + http.close() + + def test_a_copys_close_and_with_do_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + root = Lenz(api_key=API_KEY) + with root.with_options(max_retries=0) as copy: + copy.close() + assert not root._client.is_closed + sibling = root.with_options(timeout=5) + root.with_options().close() + with recorded(_USAGE): + sibling.usage() + root.close() + + def test_a_copy_after_the_root_closed_fails_with_httpxs_error(self) -> None: + root = Lenz(api_key=API_KEY) + copy = root.with_options(timeout=5) + root.close() + with pytest.raises(RuntimeError, match="closed"): + copy.usage() + + def test_parent_and_siblings_are_isolated(self, monkeypatch: pytest.MonkeyPatch) -> None: + root = Lenz(api_key=API_KEY) + a = root.with_options(timeout=3) + b = root.with_options(timeout=4) + assert _timeout_of(monkeypatch, a.usage, _USAGE) == _all(3) + assert _timeout_of(monkeypatch, b.usage, _USAGE) == _all(4) + assert _timeout_of(monkeypatch, root.usage, _USAGE) == _all(30.0) + assert root._client is a._client is b._client + + def test_a_subclass_and_its_namespaces_are_kept(self) -> None: + class MyLenz(Lenz): + def hello(self) -> str: + return "hi" + + root = MyLenz(api_key=API_KEY) + copy = root.with_options(timeout=5) + assert isinstance(copy, MyLenz) and copy.hello() == "hi" + assert copy.verifications._p is copy and copy.ask._p is copy and copy.library._p is copy + assert root.verifications._p is root + + +# ── validation ───────────────────────────────────────────────────────────── + +_BAD_TIMEOUTS = [0, -1, 0.0, math.nan, math.inf, -math.inf, True, "5"] +_BAD_RETRIES = [-1, 1.5, True, "2"] +_BAD_HEADERS: list[Any] = [["X-A"], {"X-A": 5}, {5: "x"}, {"": "x"}] + + +@pytest.fixture() +def no_key_minted(monkeypatch: pytest.MonkeyPatch) -> None: + def refuse() -> Any: + raise AssertionError("a key was minted before validation") + + monkeypatch.setattr("lenz_io.client.uuid.uuid4", refuse) + + +def _per_call_entry_points(client: Lenz) -> list[Callable[..., Any]]: + return [ + lambda **o: client.verify("A.", **o), + lambda **o: client.assess("A.", **o), + lambda **o: client.extract(text="Doc.", **o), + lambda **o: client.review(DRAFT, **o), + lambda **o: client.citecheck(DRAFT, **o), + lambda **o: client.ask.send("v1", message="Why?", **o), + lambda **o: client.usage(**o), + lambda **o: client.verifications.iter(**o), + lambda **o: client.library.iter(**o), + ] + + +class TestValidation: + @pytest.mark.parametrize("bad", _BAD_TIMEOUTS) + def test_a_bad_timeout_is_refused_before_any_request(self, bad: Any, no_key_minted: None) -> None: + client = Lenz(api_key=API_KEY) + with httpx_refused(): + for call in _per_call_entry_points(client): + with pytest.raises(ValueError, match="timeout"): + call(timeout=bad) + with pytest.raises(ValueError, match="timeout"): + client.with_options(timeout=bad) + with pytest.raises(ValueError, match="timeout"): + Lenz(api_key=API_KEY, timeout=bad) + + @pytest.mark.parametrize("bad", _BAD_RETRIES) + def test_a_bad_retry_count_is_refused_before_any_request(self, bad: Any, no_key_minted: None) -> None: + client = Lenz(api_key=API_KEY) + with httpx_refused(): + for call in _per_call_entry_points(client): + with pytest.raises(ValueError, match="max_retries"): + call(max_retries=bad) + for call in ( + lambda: client.verify_and_wait("A.", max_retries=bad), + lambda: client.verify_batch_and_wait(claims=[{"claim": "A."}], max_retries=bad), + lambda: client.review_and_wait(DRAFT, max_retries=bad), + lambda: client.citecheck_and_wait(DRAFT, max_retries=bad), + lambda: client.with_options(max_retries=bad), + lambda: Lenz(api_key=API_KEY, max_retries=bad), + ): + with pytest.raises(ValueError, match="max_retries"): + call() + + def test_with_options_refuses_none_retries(self) -> None: + with pytest.raises(ValueError, match="max_retries"): + Lenz(api_key=API_KEY).with_options(max_retries=None) # type: ignore[arg-type] + + @pytest.mark.parametrize("bad", _BAD_HEADERS) + def test_bad_headers_are_refused_before_any_request(self, bad: Any, no_key_minted: None) -> None: + client = Lenz(api_key=API_KEY) + with httpx_refused(): + for call in _per_call_entry_points(client): + with pytest.raises(ValueError): + call(extra_headers=bad) + for call in ( + lambda: client.wait(TASK, extra_headers=bad), + lambda: client.verify_and_wait("A.", extra_headers=bad), + lambda: client.with_options(extra_headers=bad), + ): + with pytest.raises(ValueError): + call() + + @pytest.mark.parametrize("good", [1, 0.5, 30, httpx.Timeout(None), httpx.Timeout(5, read=None), None]) + def test_good_timeouts_are_accepted(self, good: Any) -> None: + Lenz(api_key=API_KEY, timeout=good).with_options(timeout=good).close() + + def test_wait_budgets_are_not_newly_validated(self, monkeypatch: pytest.MonkeyPatch) -> None: + # ``<= 0`` keeps meaning "poll once". + with recorded({("GET", f"/verify/status/{TASK}"): [DONE]}) as rec: + Lenz(api_key=API_KEY).wait(TASK, timeout=-5) + assert len(rec.requests) == 1 + + def test_not_given_is_one_falsy_object(self) -> None: + assert NotGiven() is NOT_GIVEN and not NOT_GIVEN and repr(NOT_GIVEN) == "NOT_GIVEN" + assert lenz_io.NOT_GIVEN is NOT_GIVEN + + +@contextmanager +def httpx_refused() -> Iterator[None]: + """No request may be sent inside the block.""" + import respx + + with respx.mock(base_url=BASE, assert_all_called=False) as router: + router.route().mock(side_effect=AssertionError("a request was sent")) + yield + + +# ── review follow-ups: 2.21 overrides, snapshots, the httpx tuple form ───── + + +class Old221(Lenz): + """Overrides with the 2.21 signatures, which know no request option.""" + + def __init__(self, **kw: Any) -> None: + super().__init__(**kw) + self.called: list[str] = [] + + def review( # type: ignore[override] + self, + text: str, + *, + verdicts: list[str] | None = None, + confidence: list[str] | None = None, + max_assessments: int | None = None, + max_verifications: int | None = None, + depth: str | None = None, + max_citations: int | None = None, + suggest_edits: bool = False, + language: str = "", + webhook_url: str | None = None, + visibility: str = "private", + idempotency_key: str | None = None, + ) -> Any: + self.called.append("review") + return super().review( + text, + verdicts=verdicts, + confidence=confidence, + max_assessments=max_assessments, + max_verifications=max_verifications, + depth=depth, + max_citations=max_citations, + suggest_edits=suggest_edits, + language=language, + webhook_url=webhook_url, + visibility=visibility, + idempotency_key=idempotency_key, + ) + + def citecheck( # type: ignore[override] + self, + text: str | None = None, + *, + pairs: Any = None, + max_citations: int | None = None, + language: str = "", + webhook_url: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + self.called.append("citecheck") + return super().citecheck( + text, + pairs=pairs, + max_citations=max_citations, + language=language, + webhook_url=webhook_url, + idempotency_key=idempotency_key, + ) + + def wait(self, task: Any, *, timeout: float = WAIT_TIMEOUT, on_progress: Any = None) -> Any: # type: ignore[override] + self.called.append("wait") + return super().wait(task, timeout=timeout, on_progress=on_progress) + + +class _OldVerifications(lenz_io.client._VerificationsNamespace): + def list(self, *, page: int = 1) -> Any: # type: ignore[override] + return super().list(page=page) + + +class _OldLibrary(lenz_io.client._LibraryNamespace): + def list( # type: ignore[override] + self, + *, + page: int = 1, + sort: str = "recent", + search: str = "", + domain: str = "", + entity: str = "", + curated: Any = None, + verdict: str = "", + ) -> Any: + return super().list( + page=page, sort=sort, search=search, domain=domain, entity=entity, curated=curated, verdict=verdict + ) + + +_REVIEW_WAIT: Answers = { + ("POST", "/review"): [(202, {"review_id": RID, "status": "queued"})], + ("GET", f"/reviews/{RID}"): [(200, REVIEW_DONE)], +} +_CHECK_WAIT: Answers = { + ("POST", "/citecheck"): [(202, {"citecheck_id": CID, "status": "queued"})], + ("GET", f"/citechecks/{CID}"): [(200, CHECK_DONE)], +} +_VERIFY_WAIT: Answers = {("POST", "/verify"): [ACCEPTED], ("GET", f"/verify/status/{TASK}"): [DONE]} + + +class TestOverridesWritten221: + """A subclass written for 2.21 keeps working through the helpers that call + its overridden methods, as long as the caller passes no option (an empty + ``extra_headers`` counts as none).""" + + @pytest.mark.parametrize("opts", [{}, {"extra_headers": {}}, {"max_retries": None, "extra_headers": None}]) + def test_review_and_wait(self, opts: dict[str, Any]) -> None: + client = Old221(api_key=API_KEY) + with recorded(_REVIEW_WAIT): + client.review_and_wait(DRAFT, **opts) + assert client.called == ["review"] + + @pytest.mark.parametrize("opts", [{}, {"extra_headers": {}}]) + def test_citecheck_and_wait(self, opts: dict[str, Any]) -> None: + client = Old221(api_key=API_KEY) + with recorded(_CHECK_WAIT): + client.citecheck_and_wait(DRAFT, **opts) + assert client.called == ["citecheck"] + + @pytest.mark.parametrize("opts", [{}, {"extra_headers": {}}, {"max_retries": 1}]) + def test_verify_and_wait(self, opts: dict[str, Any]) -> None: + client = Old221(api_key=API_KEY) + with recorded(_VERIFY_WAIT): + client.verify_and_wait("A.", **opts) + assert client.called == ["wait"] + + def test_the_iterators(self) -> None: + client = Lenz(api_key=API_KEY) + client.verifications = _OldVerifications(client) + client.library = _OldLibrary(client) + with recorded({("GET", "/verifications"): [PAGE_1, PAGE_2], ("GET", "/library"): [LIB_1, LIB_2]}): + assert len(list(client.verifications.iter())) == 2 + assert len(list(client.library.iter(extra_headers={}))) == 2 + + def test_an_option_still_reaches_an_override_that_takes_it(self) -> None: + client = Lenz(api_key=API_KEY) + with recorded(_REVIEW_WAIT) as rec: + client.review_and_wait(DRAFT, extra_headers={MARK: "m"}) + assert all(_headers(r).get(MARK.lower()) == "m" for r in rec.requests) + + +class TestSnapshots: + def test_a_copy_keeps_the_timeout_it_was_given(self) -> None: + t = httpx.Timeout(8) + a = Lenz(api_key=API_KEY).with_options(timeout=t) + b = a.with_options() + t.read = None + for client in (a, b): + with recorded(_USAGE) as rec: + client.usage() + assert rec.requests[0]["timeout"] == _all(8) + + def test_an_iterator_keeps_the_options_it_was_created_with(self) -> None: + headers: dict[str, str | None] = {MARK: "first"} + t = httpx.Timeout(4) + with recorded({("GET", "/verifications"): [PAGE_1, PAGE_2]}) as rec: + it = Lenz(api_key=API_KEY).verifications.iter(extra_headers=headers, timeout=t) + headers[MARK] = "changed" + next(it) + headers["X-Late"] = "late" + t.read = 99 + list(it) + assert [_headers(r).get(MARK.lower()) for r in rec.requests] == ["first", "first"] + assert all("x-late" not in _headers(r) for r in rec.requests) + assert [r["timeout"]["read"] for r in rec.requests] == [4, 4] + + def test_the_library_iterator_too(self) -> None: + headers: dict[str, str | None] = {MARK: "first"} + with recorded({("GET", "/library"): [LIB_1, LIB_2]}) as rec: + it = Lenz(api_key=API_KEY).library.iter(extra_headers=headers) + next(it) + headers[MARK] = "changed" + list(it) + assert [_headers(r).get(MARK.lower()) for r in rec.requests] == ["first", "first"] + + +class TestTimeoutForms: + """What 2.21 accepted keeps working: the httpx tuple form and any real + number (not ``bool``).""" + + _TUPLE = (5, 30, 6, 7) + _AS_DICT: ClassVar[dict[str, int]] = {"connect": 5, "read": 30, "write": 6, "pool": 7} + + def test_the_tuple_form_on_the_constructor(self) -> None: + with recorded(_USAGE) as rec: + Lenz(api_key=API_KEY, timeout=self._TUPLE).usage() # type: ignore[arg-type] + assert rec.requests[0]["timeout"] == self._AS_DICT + + def test_the_tuple_form_on_a_call_and_a_copy(self) -> None: + client = Lenz(api_key=API_KEY) + with recorded(_EXTRACT) as rec: + client.extract(text="Doc.", timeout=self._TUPLE) # type: ignore[arg-type] + assert rec.requests[0]["timeout"] == self._AS_DICT + with recorded(_USAGE) as rec: + client.with_options(timeout=self._TUPLE).usage() # type: ignore[arg-type] + assert rec.requests[0]["timeout"] == self._AS_DICT + + @pytest.mark.parametrize("bad", [(5, 0, 5, 5), (5, math.nan, 5, 5), (5, -1, 5, 5), (5, True, 5, 5), (5, 5), ()]) + def test_a_bad_tuple_is_refused(self, bad: Any) -> None: + with pytest.raises(ValueError, match="timeout"): + Lenz(api_key=API_KEY, timeout=bad) + with pytest.raises(ValueError, match="timeout"): + Lenz(api_key=API_KEY).usage(timeout=bad) + + def test_a_tuple_with_an_unbounded_part(self) -> None: + with recorded(_USAGE) as rec: + Lenz(api_key=API_KEY).usage(timeout=(5, None, 5, 5)) # type: ignore[arg-type] + assert rec.requests[0]["timeout"]["read"] is None + + def test_any_real_number_and_any_integer_like_retry_count(self) -> None: + from fractions import Fraction + + class Three: + def __index__(self) -> int: + return 3 + + client = Lenz(api_key=API_KEY, timeout=Fraction(5), max_retries=Three()) # type: ignore[arg-type] + client.with_options(timeout=Fraction(1, 2), max_retries=Three()) # type: ignore[arg-type] + with recorded({("GET", "/me/usage"): [S503]}) as rec, pytest.raises(lenz_io.LenzAPIError): + client.usage(max_retries=Three(), timeout=7) # type: ignore[arg-type] + assert len(rec.requests) == 4 + + +class TestHeaderSyntax: + @pytest.mark.parametrize( + "headers", + [ + {"Bad Name": "x"}, + {"X:A": "x"}, + {"X-Ä": "x"}, + {"X-A\n": "x"}, + {"X-A": "a\r\nInjected: 1"}, + {"X-A": "a\x00"}, + {"X-A": "café"}, + ], + ) + def test_refused_before_a_key_or_a_request(self, headers: dict[str, str], no_key_minted: None) -> None: + client = Lenz(api_key=API_KEY) + with httpx_refused(): + for call in ( + lambda: client.verify("A.", extra_headers=headers), + lambda: client.review_and_wait(DRAFT, extra_headers=headers), + lambda: client.with_options(extra_headers=headers), + ): + with pytest.raises(ValueError, match="header"): + call() + + def test_tab_and_space_are_allowed_in_a_value(self) -> None: + with recorded(_USAGE) as rec: + Lenz(api_key=API_KEY).usage(extra_headers={"X-A": "a b\tc", "X-Empty": ""}) + assert _headers(rec.requests[0])["x-a"] == "a b\tc" + assert _headers(rec.requests[0])["x-empty"] == "" + + @pytest.mark.parametrize("value", ["trace ", " trace", "\ttrace", "trace\t", " ", "\t"]) + def test_whitespace_at_either_end_of_a_value_is_refused(self, value: str, no_key_minted: None) -> None: + client = Lenz(api_key=API_KEY) + with httpx_refused(): + for call in ( + lambda: client.verify("A.", extra_headers={"X-A": value}), + lambda: client.usage(extra_headers={"X-A": value}), + lambda: client.with_options(extra_headers={"X-A": value}), + ): + with pytest.raises(ValueError, match="header"): + call() + + def test_a_poll_that_cannot_be_sent_is_not_read_as_an_unreadable_body( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # An error raised while building a poll request (before anything is + # sent) ends the wait at once instead of being polled to the timeout. + real = Lenz._request + + def failing(self: Lenz, method: str, path: str, **kw: Any) -> Any: + if method == "GET": + raise UnicodeEncodeError("ascii", "é", 0, 1, "not ASCII") + return real(self, method, path, **kw) + + monkeypatch.setattr(Lenz, "_request", failing) + with recorded(_REVIEW_WAIT) as rec, pytest.raises(UnicodeEncodeError): + Lenz(api_key=API_KEY).review_and_wait(DRAFT, timeout=60) + assert rec.clock.sleeps == [] + + +def test_a_with_block_on_a_subclass_copy_keeps_the_subclass() -> None: + class Sub(Lenz): + pass + + with Sub(api_key=API_KEY).with_options(timeout=5) as c: + assert isinstance(c, Sub) + ann = inspect.signature(Lenz.__enter__).return_annotation + assert "Self" in str(ann) diff --git a/tests/test_review.py b/tests/test_review.py index aabf0d7..aaf8f47 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -527,9 +527,10 @@ def test_timeout_raises_with_the_last_body(self, client, monkeypatch): assert isinstance(err, LenzTimeoutError) assert err.review_id == "442b6aa9" assert isinstance(err.partial, ReviewFull) and err.partial.status == "verifying" - # never sleeps past the deadline, and polls once more at it + # never sleeps past the deadline, and starts no poll once it is spent + # (3.0; 2.x polled once more at the deadline, past it) assert clock[0] == 40 - assert get.call_count == 4 + assert get.call_count == 3 def test_timeout_before_any_body_has_no_partial(self, client, monkeypatch): clock = [0.0] diff --git a/tests/test_smoke_staging.py b/tests/test_smoke_staging.py index f067060..6678513 100644 --- a/tests/test_smoke_staging.py +++ b/tests/test_smoke_staging.py @@ -4,12 +4,13 @@ release workflow invokes `pytest -m smoke` with `LENZ_E2E_KEY` set; tests are skipped if the env var is absent. -These exercise the SDK against the live API across the four primitives: +These exercise the SDK against the live API: 1. ``extract`` — free, parses identified_claims 2. ``assess`` — fast 3-model verdict, returns flat claim entries 3. ``verify_and_wait`` — the quickstart claim at ``depth="low"``, the cheap run (~60s); a cache hit is a bonus, never assumed - 4. ``ask.history`` — read-only follow-up surface (no exchange burned) + 4. ``cancel`` — a ``depth="low"`` run stopped right after it starts + 5. request options — ``with_options`` and a per-call ``timeout`` on ``assess`` Plus webhook signature roundtrip + ``/me/usage`` shape. @@ -27,7 +28,7 @@ import pytest -from lenz_io import Lenz, LenzWebhooks, verify_signature +from lenz_io import Lenz, LenzPipelineError, LenzWebhooks, verify_signature pytestmark = pytest.mark.smoke @@ -57,6 +58,30 @@ def test_quickstart_claim_verifies_at_low_depth(smoke_client): assert v.verdict # any non-empty verdict string +def test_a_run_can_be_cancelled(smoke_client): + """``cancel`` answers 200 whatever the state of the run: stopped by this + call, or already ended. Neither is an error, so the test cannot be flaky. + + A cancelled verification is not charged. A run the API answers from its + cache still goes through the pipeline, so the cancel may stop it or find + it already completed; either branch passes. Not the quickstart claim, so + a cache hit is less likely.""" + started = smoke_client.verify(claim="The Eiffel Tower is in Paris", depth="low") + result = smoke_client.cancel(started.task_id) + assert result.task_id == started.task_id + if result.cancelled: + assert result.status == "cancelled" + # Safe to repeat: still cancelled, and a wait ends on it at once. + again = smoke_client.cancel(started.task_id) + assert (again.cancelled, again.status) == (True, "cancelled") + with pytest.raises(LenzPipelineError) as raised: + smoke_client.wait(started.task_id, timeout=30) + assert raised.value.failure_class == "cancelled" + else: + # It finished first; nothing was left to stop. + assert result.status in ("completed", "failed") + + def test_assess_returns_typed_claims(smoke_client): """``/assess`` is sync, ~15s. Returns one entry per identified claim.""" out = smoke_client.assess(text="Sharks don't get cancer") @@ -67,6 +92,15 @@ def test_assess_returns_typed_claims(smoke_client): assert first.confidence in ("high", "medium", "low") +def test_request_options_reach_the_live_api(smoke_client): + """The request options on a real call: a copy with its own retries and an + extra header, and a per-call timeout (the same claim as above, so the + API's cache usually answers it).""" + copy = smoke_client.with_options(max_retries=1, extra_headers={"X-Smoke-Check": "request-options"}) + out = copy.assess(claim="Sharks don't get cancer", timeout=120) + assert out.claims and out.claims[0].verdict + + def test_webhook_signature_roundtrip(): """The signing path matches the server. Cross-check against a known payload.""" secret = "whsec_smoke_fixed" diff --git a/tests/test_transport_errors.py b/tests/test_transport_errors.py new file mode 100644 index 0000000..a8dafe0 --- /dev/null +++ b/tests/test_transport_errors.py @@ -0,0 +1,80 @@ +"""Every transport failure of a request that may succeed when sent again (a +dropped connection, a server that hung up, a proxy failure) is a +``LenzConnectionError``, retried like a network error, and a wait polls again +after one. A request that could never be sent (an unsupported URL scheme, a +request httpx refuses to write) is a programming error and stays the httpx +exception.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from lenz_io import Lenz, LenzAPIError, LenzConnectionError, LenzRequestTimeoutError + +BASE = "https://lenz.io/api/v1" +_USAGE = {"credits": {"remaining": 1}} +_DONE = {"status": "completed", "task_id": "t", "result": {"verification_id": "v1", "claim": "A."}} + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: None) + + +_TRANSIENT = [ + httpx.RemoteProtocolError("Server disconnected without sending a response."), + httpx.ProxyError("proxy refused"), + httpx.ReadError("reset"), +] + + +@pytest.mark.parametrize("failure", _TRANSIENT) +def test_raised_as_a_connection_error_after_the_retries(client: Lenz, failure: Exception) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get("/me/usage").mock(side_effect=failure) + with pytest.raises(LenzConnectionError) as ei: + client.usage() + assert route.call_count == 4 + err = ei.value + assert type(err) is LenzConnectionError + assert isinstance(err, LenzAPIError) and not isinstance(err, LenzRequestTimeoutError) + assert err.__cause__ is failure + assert err.retryable is True + + +@pytest.mark.parametrize("failure", _TRANSIENT) +def test_retried_like_a_network_error(client: Lenz, failure: Exception) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get("/me/usage") + route.side_effect = [failure, httpx.Response(200, json=_USAGE)] + client.usage() + assert route.call_count == 2 + + +@pytest.mark.parametrize("failure", [httpx.UnsupportedProtocol("ftp"), httpx.LocalProtocolError("bad header")]) +def test_a_request_that_cannot_be_sent_is_not_wrapped(client: Lenz, failure: Exception) -> None: + with respx.mock(base_url=BASE) as r: + route = r.get("/me/usage").mock(side_effect=failure) + with pytest.raises(type(failure)): + client.usage() + assert route.call_count == 1 + + +def test_a_server_that_hangs_up_mid_wait_is_polled_again(client: Lenz) -> None: + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t") + poll.side_effect = [httpx.RemoteProtocolError("Server disconnected"), httpx.Response(200, json=_DONE)] + assert client.wait("t", timeout=300).verification_id == "v1" + assert poll.call_count == 2 + + +def test_a_server_that_hangs_up_mid_review_wait_is_polled_again(client: Lenz) -> None: + done = {"review_id": "r1", "status": "completed", "issues": [], "failures": [], "claims": []} + with respx.mock(base_url=BASE) as r: + r.post("/review").respond(202, json={"review_id": "r1", "status": "queued"}) + poll = r.get("/reviews/r1") + poll.side_effect = [httpx.RemoteProtocolError("Server disconnected"), httpx.Response(200, json=done)] + assert client.review_and_wait("Draft.").status == "completed" + assert poll.call_count == 2 diff --git a/tests/test_usage_models.py b/tests/test_usage_models.py index b54604e..41fb564 100644 --- a/tests/test_usage_models.py +++ b/tests/test_usage_models.py @@ -133,16 +133,6 @@ def test_dumping_keeps_the_alias_and_does_not_warn(): assert dumped["credits"] == 20 -def test_old_server_sending_only_credits_still_fills_bonus(): - """Pre-pool server (or a mid-deploy revision): `credits` only.""" - cap = UsageCapacity.model_validate( - {"quota_used": 120, "quota_total": 500, "quota_remaining": 380, "credits": 25, "remaining": 405} - ) - assert cap.bonus == 25 - with pytest.deprecated_call(): - assert cap.credits == 25 - - def test_server_after_the_alias_removal_still_fills_credits(): """A response without `credits` (a computed block) still reads the alias.""" cap = UsageCapacity.model_validate( @@ -187,11 +177,6 @@ def test_dumping_the_pool_keeps_bonus_and_does_not_warn(): assert dumped["extra"] == dumped["bonus"] == 200 -def test_a_server_sending_only_bonus_still_fills_extra(): - c = UsageCredits.model_validate({"total": 300, "used": 0, "remaining": 300, "bonus": 200}) - assert c.extra == 200 - - def test_a_server_sending_only_extra_still_fills_bonus(): """The newer response shape sends only `extra`; `bonus` keeps reading.""" c = UsageCredits.model_validate({"total": 300, "used": 0, "remaining": 300, "extra": 200}) diff --git a/tests/test_wait_stops.py b/tests/test_wait_stops.py new file mode 100644 index 0000000..7c2b7ea --- /dev/null +++ b/tests/test_wait_stops.py @@ -0,0 +1,425 @@ +"""The wait helpers stop at once on an error no later poll can change. + +Before 3.0 ``wait`` (and so ``verify_and_wait`` and ``verify_batch_and_wait``) +retried every failed poll until its deadline, so a revoked key or a wrong +task id surfaced minutes later as a misleading ``LenzTimeoutError``. A 401, +403 or 404 (and a version error) now ends the wait with that error; a 5xx, a +429 or a network failure is still retried on the next round. Each poll is one +HTTP request bounded by what is left of the deadline. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from lenz_io import ( + Lenz, + LenzApiVersionError, + LenzAuthError, + LenzError, + LenzNotFoundError, + LenzTimeoutError, +) + +BASE = "https://lenz.io/api/v1" +_DONE = {"status": "completed", "task_id": "t", "result": {"verification_id": "v1", "claim": "A."}} +_RUNNING = {"status": "processing", "task_id": "t", "progress": {"step": "research"}} + + +@pytest.fixture() +def slept(monkeypatch: pytest.MonkeyPatch) -> list[float]: + calls: list[float] = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: calls.append(s)) + return calls + + +@pytest.mark.parametrize( + ("status", "body", "cls"), + [ + (401, {"detail": "Invalid API key."}, LenzAuthError), + (403, {"detail": "Forbidden."}, LenzAuthError), + (404, {"detail": "Not found."}, LenzNotFoundError), + ], +) +class TestPermanentErrorsStopTheWait: + def test_wait_raises_it_at_once(self, client: Lenz, slept: list[float], status, body, cls) -> None: + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t") + poll.side_effect = [httpx.Response(200, json=_RUNNING), httpx.Response(status, json=body)] + with pytest.raises(cls) as ei: + client.wait("t", timeout=300) + assert poll.call_count == 2 + assert len(slept) == 1, "no sleep after the permanent error" + assert not isinstance(ei.value, LenzTimeoutError) + assert ei.value.status_code == status + + def test_verify_and_wait_raises_it(self, client: Lenz, slept: list[float], status, body, cls) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/verify").respond(200, json={"task_id": "t"}) + poll = r.get("/verify/status/t").respond(status, json=body) + with pytest.raises(cls): + client.verify_and_wait("A.", timeout=300) + assert poll.call_count == 1 + assert slept == [] + + def test_a_batch(self, client: Lenz, slept: list[float], status, body, cls) -> None: + # A 404 is that item's outcome; a 401 / 403 is the whole account's, so + # the batch wait raises it (before the other item is polled). + with respx.mock(base_url=BASE, assert_all_called=False) as r: + r.post("/verify/batch").respond( + 200, json={"batch_id": "b", "items": [{"task_id": "a", "claim": "A."}, {"task_id": "t", "claim": "B."}]} + ) + bad = r.get("/verify/status/a").respond(status, json=body) + good = r.get("/verify/status/t") + good.side_effect = [httpx.Response(200, json=_RUNNING), httpx.Response(200, json=_DONE)] + if status in (401, 403): + with pytest.raises(cls): + client.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], timeout=300) + assert bad.call_count == 1 + return + results = client.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], timeout=300) + assert [x.status for x in results] == ["failed", "completed"] + assert results[0].status_detail is None + assert results[0].verification is None + assert bad.call_count == 1 + assert good.call_count == 2 + + +_OLD = {"X-Lenz-API-Version": "2026-05-13"} + + +class TestVersionErrorsInAWait: + def test_a_batch_item_fails_and_the_others_continue(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/verify/batch").respond( + 200, json={"batch_id": "b", "items": [{"task_id": "a", "claim": "A."}, {"task_id": "t", "claim": "B."}]} + ) + bad = r.get("/verify/status/a").respond(200, json=_DONE, headers=_OLD) + good = r.get("/verify/status/t") + good.side_effect = [httpx.Response(200, json=_RUNNING), httpx.Response(200, json=_DONE)] + results = client.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], timeout=300) + assert [x.status for x in results] == ["failed", "completed"] + assert results[0].status_detail is None + assert bad.call_count == 1 + + def test_a_single_wait_raises_it(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_DONE, headers=_OLD) + with pytest.raises(LenzApiVersionError): + client.wait("t", timeout=300) + assert poll.call_count == 1 + + +class TestTransientErrorsAreRetried: + @pytest.mark.parametrize( + "failure", + [ + httpx.Response(500, json={"detail": "boom"}), + httpx.Response(502, text="bad gateway"), + httpx.Response(503, json={"detail": "unavailable"}), + httpx.Response(429, json={"detail": "slow down"}), + httpx.ConnectError("refused"), + httpx.ReadTimeout("slow"), + ], + ) + def test_the_next_round_polls_again(self, client: Lenz, slept: list[float], failure) -> None: + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t") + poll.side_effect = [failure, httpx.Response(200, json=_DONE)] + out = client.wait("t", timeout=300) + assert out.verification_id == "v1" + assert poll.call_count == 2 + + def test_a_stated_wait_paces_the_next_poll(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t") + poll.side_effect = [ + httpx.Response(429, json={"detail": "slow down"}, headers={"Retry-After": "7"}), + httpx.Response(200, json=_DONE), + ] + client.wait("t", timeout=300) + assert slept == [7.0] + + def test_a_persistent_5xx_still_ends_in_a_timeout(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + r.get("/verify/status/t").respond(500, json={"detail": "boom"}) + with pytest.raises(LenzTimeoutError): + client.wait("t", timeout=0) + + def test_other_4xx_keep_the_2x_behaviour(self, client: Lenz, slept: list[float]) -> None: + # Only 401, 403, 404 (and 410, a version error) end a wait early. + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t") + poll.side_effect = [httpx.Response(409, json={"detail": "busy"}), httpx.Response(200, json=_DONE)] + client.wait("t", timeout=300) + assert poll.call_count == 2 + + +class TestEachPollIsBounded: + def test_one_request_per_poll(self, client: Lenz, slept: list[float]) -> None: + # A failed poll is retried on the next round, never inside the poll by + # the client's own retry ladder (which could run past the deadline). + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t") + poll.side_effect = [httpx.Response(503, json={"detail": "x"}), httpx.Response(200, json=_DONE)] + client.wait("t", timeout=300) + assert poll.call_count == 2 + assert len(slept) == 1 + + def test_the_request_timeout_never_runs_past_the_deadline(self, slept: list[float]) -> None: + with Lenz(api_key="lenz_test", timeout=60.0) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t") + poll.side_effect = [httpx.Response(200, json=_RUNNING), httpx.Response(200, json=_DONE)] + client.wait("t", timeout=5) + timeouts = [c.request.extensions["timeout"]["read"] for c in poll.calls] + assert all(t <= 5 for t in timeouts), timeouts + + def test_a_zero_timeout_still_reads_once(self, slept: list[float]) -> None: + # As in 2.x, ``timeout=0`` reads the status once (bounded by the + # client timeout, there being no deadline left to bound it), then stops. + with Lenz(api_key="lenz_test", timeout=20.0) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_RUNNING) + with pytest.raises(LenzTimeoutError): + client.wait("t", timeout=0) + assert poll.call_count == 1 + assert poll.calls.last.request.extensions["timeout"]["read"] == 20.0 + assert slept == [] + + def test_the_client_timeout_bounds_a_long_wait(self, slept: list[float]) -> None: + with Lenz(api_key="lenz_test", timeout=20.0) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_DONE) + client.wait("t", timeout=300) + assert poll.calls.last.request.extensions["timeout"]["read"] == 20.0 + + +class TestJobWaits: + """``review_and_wait`` and ``citecheck_and_wait`` already stopped on a + 401, 403 or 404 and retried the rest; pinned here beside the others.""" + + @pytest.mark.parametrize(("status", "cls"), [(401, LenzAuthError), (403, LenzAuthError), (404, LenzNotFoundError)]) + def test_review_wait_raises_at_once(self, client: Lenz, slept: list[float], status, cls) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/review").respond(202, json={"review_id": "r1", "status": "queued"}) + poll = r.get("/reviews/r1").respond(status, json={"detail": "x", "code": "not_found"}) + with pytest.raises(cls): + client.review_and_wait("Draft.", timeout=300) + assert poll.call_count == 1 + + @pytest.mark.parametrize(("status", "cls"), [(401, LenzAuthError), (404, LenzNotFoundError)]) + def test_citecheck_wait_raises_at_once(self, client: Lenz, slept: list[float], status, cls) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/citecheck").respond(202, json={"citecheck_id": "c1", "status": "queued"}) + poll = r.get("/citechecks/c1").respond(status, json={"detail": "x"}) + with pytest.raises(cls): + client.citecheck_and_wait("Draft.", timeout=300) + assert poll.call_count == 1 + + def test_a_connection_error_mid_wait_is_retried(self, client: Lenz, slept: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/citecheck").respond(202, json={"citecheck_id": "c1", "status": "queued"}) + poll = r.get("/citechecks/c1") + poll.side_effect = [httpx.ConnectError("refused"), httpx.Response(404, json={"detail": "x"})] + with pytest.raises(LenzError): + client.citecheck_and_wait("Draft.", timeout=300) + assert poll.call_count == 2 + + +@pytest.fixture() +def clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """A fake clock: ``time.sleep`` moves it, requests take no time unless a + test moves it.""" + now = [0.0] + monkeypatch.setattr("lenz_io.client.time.monotonic", lambda: now[0]) + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: now.__setitem__(0, now[0] + s)) + return now + + +def _read_timeouts(route: respx.Route) -> list[float | None]: + return [c.request.extensions["timeout"]["read"] for c in route.calls] + + +class TestNoPollPastTheDeadline: + def test_no_poll_starts_once_the_deadline_is_spent(self, clock: list[float]) -> None: + with Lenz(api_key="lenz_test", timeout=30.0) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_RUNNING) + with pytest.raises(LenzTimeoutError): + client.wait("t", timeout=5) + # t=0 (5s left), sleep 2, t=2 (3s left), sleep 3, t=5: nothing left. + assert _read_timeouts(poll) == [5.0, 3.0] + assert clock[0] == 5.0 + + def test_a_smaller_client_timeout_bounds_every_poll(self, clock: list[float]) -> None: + with Lenz(api_key="lenz_test", timeout=2.5) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_RUNNING) + with pytest.raises(LenzTimeoutError): + client.wait("t", timeout=5) + assert _read_timeouts(poll) == [2.5, 2.5] + + def test_a_batch_marks_the_items_it_had_no_time_for_as_timed_out(self, client: Lenz, clock: list[float]) -> None: + polls: list[str] = [] + + def answer(request: httpx.Request) -> httpx.Response: + polls.append(request.url.path.rsplit("/", 1)[-1]) + if len(polls) == 3: + clock[0] += 100.0 # the second round's first poll is slow + return httpx.Response(200, json=_RUNNING) + + with respx.mock(base_url=BASE) as r: + r.post("/verify/batch").respond( + 200, json={"batch_id": "b", "items": [{"task_id": "a", "claim": "A."}, {"task_id": "b", "claim": "B."}]} + ) + r.get(url__regex=r"/verify/status/.*").mock(side_effect=answer) + results = client.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], timeout=10) + assert polls == ["a", "b", "a"], "b is not polled after the deadline" + assert [x.status for x in results] == ["timeout", "timeout"] + + @pytest.mark.parametrize( + ("submit", "path", "body", "call"), + [ + ( + "/review", + "/reviews/r1", + {"review_id": "r1", "status": "verifying", "issues": [], "failures": [], "claims": []}, + lambda c: c.review_and_wait("Draft.", timeout=12), + ), + ( + "/citecheck", + "/citechecks/c1", + { + "citecheck_id": "c1", + "status": "checking", + "citations": [], + "citation_issues": [], + "citation_failures": [], + }, + lambda c: c.citecheck_and_wait("Draft.", timeout=12), + ), + ], + ) + def test_job_waits_stop_at_the_deadline(self, clock: list[float], submit, path, body, call) -> None: + accepted = {"review_id": "r1", "citecheck_id": "c1", "status": "queued"} + with Lenz(api_key="lenz_test", timeout=30.0) as client, respx.mock(base_url=BASE) as r: + r.post(submit).respond(202, json=accepted) + poll = r.get(path).respond(200, json=body) + with pytest.raises(LenzTimeoutError): + call(client) + # t=0 (12 left), sleep 10, t=10 (2 left), sleep 2, t=12: nothing left. + assert _read_timeouts(poll) == [12.0, 2.0] + + +class TestTheClientTimeoutSetting: + """The per-poll bound reads the client's own timeout, whatever form it + took (2.x accepted ``None`` and an ``httpx.Timeout`` there).""" + + @pytest.mark.parametrize( + ("timeout", "expected"), + [(None, 300.0), (4.0, 4.0), (httpx.Timeout(7.0), 7.0), (httpx.Timeout(10.0, read=6.0), 6.0)], + ) + def test_wait(self, slept: list[float], timeout, expected) -> None: + with Lenz(api_key="lenz_test", timeout=timeout) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_DONE) + client.wait("t", timeout=300) + assert _read_timeouts(poll) == [pytest.approx(expected, abs=1.0)] + + @pytest.mark.parametrize("timeout", [None, httpx.Timeout(7.0)]) + def test_review_and_citecheck_waits(self, slept: list[float], timeout) -> None: + done_review = {"review_id": "r1", "status": "completed", "issues": [], "failures": [], "claims": []} + done_check = { + "citecheck_id": "c1", + "status": "completed", + "citations": [], + "citation_issues": [], + "citation_failures": [], + } + with Lenz(api_key="lenz_test", timeout=timeout) as client, respx.mock(base_url=BASE) as r: + r.post("/review").respond(202, json={"review_id": "r1", "status": "queued"}) + r.get("/reviews/r1").respond(200, json=done_review) + r.post("/citecheck").respond(202, json={"citecheck_id": "c1", "status": "queued"}) + r.get("/citechecks/c1").respond(200, json=done_check) + assert client.review_and_wait("Draft.").status == "completed" + assert client.citecheck_and_wait("Draft.").status == "completed" + + def test_an_injected_http_client_keeps_its_own_timeout(self, slept: list[float]) -> None: + with httpx.Client(timeout=9.0) as http, Lenz(api_key="lenz_test", http_client=http) as client: + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_DONE) + client.wait("t", timeout=300) + assert _read_timeouts(poll) == [9.0] + + def test_an_injected_unbounded_http_client(self, slept: list[float]) -> None: + with httpx.Client(timeout=None) as http, Lenz(api_key="lenz_test", http_client=http) as client: + with respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_DONE) + client.wait("t", timeout=300) + assert _read_timeouts(poll) == [pytest.approx(300.0, abs=1.0)] + + +class TestTheDeadlineBoundsEveryItem: + def test_items_after_a_slow_first_poll_are_timed_out_not_polled(self, clock: list[float]) -> None: + polls: list[str] = [] + + def answer(request: httpx.Request) -> httpx.Response: + polls.append(request.url.path.rsplit("/", 1)[-1]) + clock[0] += 100.0 # every poll is slow + return httpx.Response(200, json=_RUNNING) + + with Lenz(api_key="lenz_test", timeout=None) as client, respx.mock(base_url=BASE) as r: + r.post("/verify/batch").respond( + 200, + json={"batch_id": "b", "items": [{"task_id": t, "claim": "A."} for t in ("a", "b", "c")]}, + ) + r.get(url__regex=r"/verify/status/.*").mock(side_effect=answer) + results = client.verify_batch_and_wait(claims=[{"claim": "A."}] * 3, timeout=10) + assert polls == ["a"] + assert [x.status for x in results] == ["timeout"] * 3 + + def test_a_zero_timeout_batch_reads_every_item_once_as_in_2x(self, client: Lenz, clock: list[float]) -> None: + with respx.mock(base_url=BASE) as r: + r.post("/verify/batch").respond( + 200, json={"batch_id": "b", "items": [{"task_id": "a", "claim": "A."}, {"task_id": "t", "claim": "B."}]} + ) + a = r.get("/verify/status/a").respond(200, json=_RUNNING) + t = r.get("/verify/status/t").respond(200, json=_DONE) + results = client.verify_batch_and_wait(claims=[{"claim": "A."}, {"claim": "B."}], timeout=0) + assert (a.call_count, t.call_count) == (1, 1) + assert [x.status for x in results] == ["timeout", "completed"] + + +class TestEveryTimeoutPhaseIsKept: + def test_each_phase_capped_by_what_is_left(self, clock: list[float]) -> None: + configured = httpx.Timeout(30.0, connect=1.0, pool=0.25, write=2.0) + with Lenz(api_key="lenz_test", timeout=configured) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_RUNNING) + with pytest.raises(LenzTimeoutError): + client.wait("t", timeout=5) + phases = [c.request.extensions["timeout"] for c in poll.calls] + assert phases[0] == {"connect": 1.0, "read": 5.0, "write": 2.0, "pool": 0.25} + assert phases[1] == {"connect": 1.0, "read": 3.0, "write": 2.0, "pool": 0.25} + + def test_an_unbounded_phase_is_bounded_by_the_deadline(self, clock: list[float]) -> None: + with ( + Lenz(api_key="lenz_test", timeout=httpx.Timeout(None, connect=1.0)) as client, + respx.mock(base_url=BASE) as r, + ): + poll = r.get("/verify/status/t").respond(200, json=_DONE) + client.wait("t", timeout=5) + assert poll.calls.last.request.extensions["timeout"] == { + "connect": 1.0, + "read": 5.0, + "write": 5.0, + "pool": 5.0, + } + + def test_a_zero_timeout_poll_keeps_the_client_phases(self, clock: list[float]) -> None: + configured = httpx.Timeout(30.0, connect=1.0, pool=0.25, write=2.0) + with Lenz(api_key="lenz_test", timeout=configured) as client, respx.mock(base_url=BASE) as r: + poll = r.get("/verify/status/t").respond(200, json=_DONE) + client.wait("t", timeout=0) + assert poll.calls.last.request.extensions["timeout"] == { + "connect": 1.0, + "read": 30.0, + "write": 2.0, + "pool": 0.25, + } diff --git a/tests/test_webhook_verification.py b/tests/test_webhook_verification.py new file mode 100644 index 0000000..4f43cb3 --- /dev/null +++ b/tests/test_webhook_verification.py @@ -0,0 +1,146 @@ +"""``.verification`` on the ``verification.*`` webhook events: the +verification as ``client.get_status`` returns it (a ``TaskStatus``; on a +completed event the verdict is ``.verification.result``), built from either +payload shape, as in the Node SDK.""" + +from __future__ import annotations + +import pytest +from parity_observe import load, names + +from lenz_io import ( + TaskStatus, + Verification, + VerificationCompleted, + VerificationFailed, + VerificationNeedsInput, + WebhookEvent, + parse_webhook, +) + +# A cancellation is a different event in each shape (``verification.cancelled`` +# against ``verification.failed``): ``tests/test_cancelled.py`` reads both. +_VERIFICATION_EVENTS = [n for n in names() if n.startswith("webhook__verification_") and "cancelled" not in n] + + +def _both(name: str) -> tuple[WebhookEvent, WebhookEvent]: + return parse_webhook(load("legacy", name)["body"]), parse_webhook(load("canonical", name)["body"]) + + +@pytest.mark.parametrize("name", _VERIFICATION_EVENTS) +def test_both_shapes_give_the_same_status_envelope(name: str) -> None: + old, new = _both(name) + assert type(old) is type(new) + a, b = old.verification, new.verification # type: ignore[attr-defined] + assert isinstance(a, TaskStatus) and isinstance(b, TaskStatus) + assert a.status == b.status == old.status + assert a.task_id == b.task_id == old.task_id + if isinstance(old, VerificationCompleted): + assert isinstance(a.result, Verification) and isinstance(b.result, Verification) + for field in ("verification_id", "claim", "verdict", "confidence", "lenz_score", "key_finding"): + assert getattr(a.result, field) == getattr(b.result, field), field + assert a.result.verification_id == old.verification_id + assert a.result.verdict == old.result.get("verdict") + elif isinstance(old, VerificationFailed): + assert a.failure is not None and b.failure is not None + for field in ("code", "failure_class", "retryable"): + assert getattr(a.failure, field) == getattr(b.failure, field), field + assert a.failure_reason == old.error + assert a.retryable == old.retryable + else: + assert isinstance(old, VerificationNeedsInput) + assert a.reason == b.reason == old.reason + assert [c.claim for c in a.claims] == [c.claim for c in b.claims] == [c.claim for c in old.claims] + assert a.hint == b.hint == old.hint + + +def test_it_is_a_property_so_the_event_repr_and_fields_are_unchanged() -> None: + event = parse_webhook(load("canonical", _VERIFICATION_EVENTS[0])["body"]) + assert "verification" not in vars(event) + assert "verification=" not in repr(event) + + +@pytest.mark.parametrize( + "payload", + [ + {"event": "verification.completed", "task_id": "t", "verification": {"status": "completed", "result": 5}}, + {"event": "verification.completed", "task_id": "t", "result": "not an object"}, + {"event": "verification.failed", "task_id": "t", "verification": {"status": ["failed"]}}, + {"event": "verification.needs_input", "task_id": "t", "needs_input": {"claims": "x"}}, + ], +) +def test_a_malformed_recognised_event_reads_none_never_raises(payload: dict) -> None: + event = parse_webhook(payload) + assert event.verification is None # type: ignore[attr-defined] + + +def test_a_completed_event_without_a_result_reads_a_status_without_one() -> None: + event = parse_webhook({"event": "verification.completed", "task_id": "t"}) + assert isinstance(event, VerificationCompleted) + assert event.verification is not None + assert event.verification.status == "completed" + assert event.verification.task_id == "t" + assert event.verification.result is None + + +def test_other_events_do_not_carry_it() -> None: + for payload in ( + {"event": "certificate.timestamped", "task_id": "t"}, + {"event": "review.completed", "review_id": "r"}, + {"event": "something.new", "verification": {"status": "completed"}}, + ): + assert not hasattr(parse_webhook(payload), "verification") + + +_SPARSE_RESULT = {"verification_id": "v1", "claim": "A.", "verdict": "True", "lenz_score": 9} +_SPARSE = [ + pytest.param( + { + "event": "verification.completed", + "event_id": "evt_1", + "verification": {"status": "completed", "task_id": "t", "result": dict(_SPARSE_RESULT)}, + }, + id="current-shape", + ), + pytest.param( + {"event": "verification.completed", "task_id": "t", "result": dict(_SPARSE_RESULT)}, + id="original-shape", + ), +] + + +@pytest.mark.parametrize("payload", _SPARSE) +def test_a_sparse_result_reads_the_webhook_defaults(payload: dict) -> None: + # The values ``event.result`` gives a key the payload left out (and the + # Node SDK's ``event.verification.result``), not the model's own defaults. + event = parse_webhook(payload) + assert isinstance(event, VerificationCompleted) + assert event.verification is not None + result = event.verification.result + assert isinstance(result, Verification) + assert (result.visibility, result.depth, result.created_at) == ("private", "standard", "") + assert (result.confidence, result.language, result.verdict, result.lenz_score) == ("low", "en", "True", 9) + + +def test_a_sparse_current_shape_result_matches_event_result() -> None: + event = parse_webhook(_SPARSE[0].values[0]) + assert isinstance(event, VerificationCompleted) + assert event.verification is not None and event.verification.result is not None + result = event.verification.result + for key in ("verification_id", "claim", "visibility", "depth", "verdict", "confidence", "created_at", "language"): + assert getattr(result, key) == event.result[key], key + + +@pytest.mark.parametrize( + ("event", "nested"), + [ + ("verification.completed", "failed"), + ("verification.failed", "completed"), + ("verification.needs_input", "processing"), + ("verification.completed", None), + ], +) +def test_a_nested_status_of_another_kind_is_not_presented_as_this_one(event: str, nested: str | None) -> None: + body: dict = {"task_id": "t"} if nested is None else {"status": nested, "task_id": "t"} + parsed = parse_webhook({"event": event, "event_id": "evt_1", "verification": body}) + assert parsed.verification is None # type: ignore[attr-defined] diff --git a/uv.lock b/uv.lock index 998c139..013068c 100644 --- a/uv.lock +++ b/uv.lock @@ -414,6 +414,8 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "pydantic" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, ] [package.optional-dependencies] @@ -454,6 +456,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12" }, { name = "typer", marker = "extra == 'dev'", specifier = ">=0.12" }, + { name = "typing-extensions", specifier = ">=4.5" }, ] provides-extras = ["cli", "dev"]