Python SDK
Call Drex's decision model and run Perception's document tools from Python with nace-sdk — client options, calibrated questions, typed answers, sources, parse, split, classify, extract, ground, uploads, jobs, saved schemas, errors, retries and logging.
nace-sdk is the official Python client for https://console.nace.ai. It covers Drex's decision model, system_one and models.list, and Perception's five document tools, uploads, jobs, job files and saved schemas. It needs Python 3.11 or later and depends only on httpx, pydantic and typing-extensions.
The client is built on the TypeSafe Python SDK, so system_one, Noul, Choice, Score, SystemOneResponse with .nouls, .choices and .scores, RetryPolicy and the error classes match typesafe-sdk. Code written for TypeSafeClient runs on NaceClient once you rename the imports. The differences are Drex's: the 402, 409 and 529 error classes, a 60-second default timeout, instructions and criteria as text only, and legend as a tuple. See Migrate from TypeSafe.
pip install nace-sdk
export NACE_API_KEY="nace_sk_..."Create a key as shown in Authentication.
Configure the client
NaceClient(**options)
| Parameter | Type | Required | Description |
|---|---|---|---|
api_key | str | Yes, or NACE_API_KEY | Your nace_sk_ key. Surrounding whitespace is stripped; a missing key, or one with spaces or non-ASCII characters, raises NaceError. |
base_url | str | No | API root. Default: NACE_BASE_URL, else https://console.nace.ai. A trailing / is dropped. |
model | str | No | Default model for system_one. Default: NACE_DEFAULT_DECISION_MODEL, else drex-latest. |
timeout | float | httpx.Timeout | No | Timeout of each HTTP attempt, in seconds. Default: 60, or the timeout of the http_client you pass. Must be positive and finite. |
retry | RetryPolicy | No | How failed requests are retried. Default: RetryPolicy(); see Retries. |
headers | Mapping[str, str] | No | Extra headers sent on every request to Drex. Never sent to the document service's upload URLs or to signed file links. |
transport | httpx.BaseTransport | No | A custom httpx transport, closed with the client. Can't be combined with http_client (ValueError). |
http_client | httpx.Client | No | Your own httpx.Client for proxies, TLS or connection pooling. The SDK closes it when the client closes. Its own auth is never sent: requests to Drex carry the API key instead, and signed file links and the document service's upload URLs get neither. |
An argument you pass wins over its environment variable, and an empty or whitespace-only variable counts as unset:
| Variable | Read | What it sets |
|---|---|---|
NACE_API_KEY | By each NaceClient() | api_key |
NACE_BASE_URL | By each NaceClient() | base_url |
NACE_DEFAULT_DECISION_MODEL | By each NaceClient() | model |
NACE_LOG_LEVEL | Once, when nace_sdk is imported | The nace_sdk logger's level. See Logging. |
Each variable's name from before the packages were renamed from drex-* still works when the new one is unset or blank: DREX_API_KEY, DREX_BASE_URL, DREX_DEFAULT_MODEL and DREX_LOG_LEVEL. The new name wins when both are set.
NaceClient() never reads the CLI's ~/.nace/config.toml; use nace_sdk.credentials for that. The SDK sets Authorization, User-Agent, X-Nace-SDK, X-Nace-Runtime and X-Nace-Retry-Count itself, plus Idempotency-Key and Content-Type when a request carries them, so headers and extra_headers can't override those.
| Member | Description |
|---|---|
client.base_url | The resolved API root, as a str. |
client.models | A Models resource: List models. |
client.documents, client.jobs, client.extraction_schemas | Documents, Jobs and ExtractionSchemas: document jobs, uploads, job files and saved schemas. See Uploads, Jobs and Saved schemas. |
client.system_one(...) | Ask questions. |
client.close() | Closes the HTTP connections, including an http_client you passed. with NaceClient() as client: calls it for you. |
import httpx
from nace_sdk import NaceClient, RetryPolicy
client = NaceClient(
base_url="https://console.nace.ai",
timeout=httpx.Timeout(90, connect=5),
retry=RetryPolicy(max_retries=4, timeout=500), # room for 5 attempts of 90 s and the waits between them
headers={"X-Team": "billing-bot"},
)
try:
print(client.base_url)
finally:
client.close()Most methods of client.documents, client.jobs and client.extraction_schemas also take retry, timeout and extra_headers for one call, and the five tools also take extra_body. The methods that don't are documents.upload, documents.upload_part, documents.complete_upload_session, documents.get_upload_session, documents.abort_upload_session, jobs.iter, jobs.wait, jobs.events and jobs.download; they use the client's retry policy and timeout, except jobs.events, which is never retried. jobs.wait's own timeout is how long to keep polling.
Async
AsyncNaceClient takes the same arguments, except that transport is an httpx.AsyncBaseTransport and http_client an httpx.AsyncClient. Its models, documents, jobs and extraction_schemas (AsyncModels, AsyncDocuments, AsyncJobs, AsyncExtractionSchemas) have the same methods and parameters as the sync ones. Await each call, iterate jobs.iter(), jobs.events(), extraction_schemas.iter() and extraction_schemas.iter_versions() with async for, and close the client with async with or await client.aclose().
import asyncio
from nace_sdk import AsyncNaceClient, Noul
async def main() -> None:
async with AsyncNaceClient() as client:
result = await client.system_one(
state="I was charged twice for my March invoice.",
questions={"wants_refund": Noul(instructions="Is the customer asking for a refund?")},
)
print(result.nouls["wants_refund"].noul)
asyncio.run(main())The document methods are awaited the same way:
import asyncio
from nace_sdk import AsyncNaceClient
async def main() -> None:
async with AsyncNaceClient() as client:
job = await client.documents.parse("https://example.com/report.pdf")
async for event in client.jobs.events(job.job_id):
print(event.status, event.progress)
job = await client.jobs.wait(job.job_id)
print(job.result["document"]["markdown"])
asyncio.run(main())Ask questions
client.system_one(state, questions, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
state | Any | Yes | Text, a JSON object, or an array to evaluate. See State. |
questions | Mapping[str, Question] | Yes | 1 to 512 questions, keyed by the name used to identify each answer. Each is a Noul, Choice or Score, or a plain dictionary. See Questions. |
model | str | No | Model for this call. Default: the client's model. |
retry | RetryPolicy | No | Replaces the client's retry policy for this call. |
timeout | float | httpx.Timeout | No | Replaces the client's timeout for this call. |
extra_headers | Mapping[str, str] | No | Extra headers for this call. |
extra_body | Mapping[str, Any] | No | Extra top-level body fields, merged over the body last, so they win. |
response_model | type[BaseModel] | No | Parse the response into your own Pydantic model instead of SystemOneResponse. See Parse into your own model. |
It returns a SystemOneResponse. A decision is billed per input token and only when it returns 200, so the SDK's retries never bill twice.
from nace_sdk import Choice, NaceClient, Noul, Score
with NaceClient() as client:
result = client.system_one(
state="I was charged twice for my March invoice.",
questions={
"wants_refund": Noul(instructions="Is the customer asking for a refund?"),
"topic": Choice(instructions="Which topic is it?", criteria={"billing": None, "shipping": None, "other": None}),
"urgency": Score(instructions="How urgent is it?", criteria=["low", "medium", "high"]),
},
)
print(result.nouls["wants_refund"].noul)
print(result.choices["topic"].choice, result.choices["topic"].confidence)
print(result.scores["urgency"].score, result.usage.input_tokens, result.request_id)Question types
| Type | Fields | Description |
|---|---|---|
Noul | type ("noul", set for you), instructions: str | None = None, criteria: NoulCriteria | None = None | A yes/no question. Needs instructions, or a criteria that describes an outcome. |
Choice | type ("choice", set for you), criteria: Mapping[str, str | None], instructions: str | None = None | Picks one label. criteria maps each label to a description, or None. At least one label. |
Score | type ("score", set for you), criteria: Sequence[str], instructions: str | None = None | Scores on an ordered rubric, one description per score from zero. At least one entry. |
NoulCriteria | true: str | None, false: str | None (both optional) | A TypedDict describing the yes and no outcomes of a Noul. |
NoulModel, ChoiceModel, ScoreModel | type ("noul", "choice", "score"), plus the same fields | The plain-dictionary forms, as TypedDicts. criteria is required for choice and score. |
QuestionModel | NoulModel | ChoiceModel | ScoreModel. | |
Question | Noul | Choice | Score | QuestionModel: what questions holds. | |
Questions | Mapping[str, Question]: the type of questions. |
questions = {
"wants_refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
"is_spam": Noul(criteria={"true": "Unsolicited advertising", "false": "A real customer message"}),
}The SDK checks the questions before it sends anything and raises NaceError for no questions or more than 512, a Noul with neither instructions nor a described outcome, a Choice whose criteria isn't a nonempty mapping, a Score with no criteria, and a dictionary without a nonempty type (or, for choice and score, without criteria). An unknown field on a question object raises pydantic.ValidationError. Unset instructions and criteria are left out of the request.
Read the response
SystemOneResponse:
| Member | Type | Description |
|---|---|---|
answers | dict[str, Answer] | Every answer, keyed by question name. |
nouls | dict[str, NoulAnswer] | The yes/no answers. |
choices | dict[str, ChoiceAnswer] | The choice answers. |
scores | dict[str, ScoreAnswer] | The score answers. |
model | str | The model that answered. An alias such as drex-latest resolves to the model it serves. |
usage | Usage | input_tokens (billed) and output_tokens, each int | None. |
evaluation_time_ms | float | None | How long the model took, in milliseconds. |
request_id | str | The x-request-id header (else the body's request_id). Quote it in support requests. |
raw_http_response | httpx.Response | The HTTP response, for its status, headers and body. |
request_id and raw_http_response aren't fields, so model_dump() leaves them out. Every response type has them, and a from_http_response(response) class method that builds one from an httpx.Response you fetched yourself, raising the same errors as a call.
| Answer | Fields |
|---|---|
NoulAnswer | type ("noul"), noul: float: the probability of yes, from 0 to 1. |
ChoiceAnswer | type ("choice"), choice: str (one of your labels), confidence: float (0 to 1), probabilities: dict[str, float] keyed by label. |
ScoreAnswer | type ("score"), score: float (expected score, can fall between levels), confidence: float, legend: tuple[str, ...] (your rubric), probabilities: dict[int, float] keyed by integer score. |
Answer is the union of the three, told apart by type. Score probabilities are keyed by int, so read them as result.scores["urgency"].probabilities[2]. An answer of a type this SDK version doesn't know is dropped with a warning on the nace_sdk logger; it is still in raw_http_response. See Reading answers.
Parse into your own model
Subclass SystemOneResponse and declare an answer per question name. The SDK lifts each declared answer out of answers into its field, and you keep request_id and raw_http_response:
from nace_sdk import Choice, ChoiceAnswer, NaceClient, Noul, NoulAnswer, SystemOneResponse
class Ticket(SystemOneResponse):
wants_refund: NoulAnswer
topic: ChoiceAnswer
with NaceClient() as client:
ticket = client.system_one(
state="I was charged twice for my March invoice.",
questions={
"wants_refund": Noul(instructions="Is the customer asking for a refund?"),
"topic": Choice(criteria={"billing": None, "shipping": None}),
},
response_model=Ticket,
)
print(ticket.wants_refund.noul, ticket.topic.choice, ticket.request_id)Any other pydantic.BaseModel is validated against the raw JSON body and has no request_id or raw_http_response. A body that doesn't match raises NaceAPIResponseValidationError.
List models
client.models.list(**options) takes retry, timeout and extra_headers, and returns a ListModelsResponse. It works at any balance and is free, so it doubles as a key check.
| Type | Fields |
|---|---|
ListModelsResponse | models: tuple[ModelMetadata, ...], plus request_id and raw_http_response. |
ModelMetadata | name (what model takes), description, release_date (YYYY-MM-DD), alias_for (the model an alias such as drex-latest serves; None for a pinned model). |
with NaceClient() as client:
print([model.name for model in client.models.list().models])See Models.
Sources
Every tool takes a source: the document to work on.
| Source | Fields | What it is |
|---|---|---|
UrlSource | url: str, file_name: str | None = None | A public https:// link the document service downloads. |
WorkspaceFileSource | workspace_id: str, file_id: str | A file you uploaded to your workspace. UploadedFile.as_source() builds one. |
ParseResultSource | job_id: str | One of your finished parse jobs, so Parse, Split, Extract or Ground reuse it instead of parsing and paying again. Classify doesn't take it. |
Each also has a type ("url", "workspace_file", "parse_result") that is set for you, and an unknown field raises pydantic.ValidationError. Source is the union of the three. You can also pass:
- A string, which must start with
https://and becomes aUrlSource. Anything else raisesNaceError. - A dictionary in the wire form, such as
{"type": "workspace_file", "workspace_id": "...", "file_id": "..."}.
The document service picks the parser from a URL source's file_name and refuses an extension the tool doesn't read with unsupported_file_type (see Format support). When you leave file_name out, the SDK takes it from the URL: the last path segment, without its query or fragment, percent-decoded. That segment must have an extension, a . that is neither its first nor its last character; otherwise the SDK raises NaceError before sending anything. A file_name you pass is sent as it is.
client.documents.parse("https://example.com/files/report%20v2.pdf") # file_name="report v2.pdf"
client.documents.parse(UrlSource(url="https://example.com/download?id=42", file_name="report.pdf"))
client.documents.parse(UrlSource(url="https://arxiv.org/pdf/1706.03762", file_name="attention.pdf"))Name the file whenever the last segment isn't the file's real name: https://arxiv.org/pdf/1706.03762 would otherwise be sent as 1706.03762, which the document service refuses.
Create a job
The five tools create a job and return it as a DocumentJob: status="queued", or the finished job when wait_seconds was long enough. Besides source and the tool's own options, each takes:
| Parameter | Type | Required | Description |
|---|---|---|---|
wait_seconds | int | No | 0 to 60. Holds the request open until the job finishes, up to this long; the SDK adds the same time to that request's timeout. Default: None, which returns at once. |
idempotency_key | str | No | Up to 200 characters. Default: one minted per call. |
retry | RetryPolicy | No | Replaces the client's retry policy for this call. |
timeout | float | httpx.Timeout | No | Replaces the client's timeout for this call. |
extra_headers | Mapping[str, str] | No | Extra headers for this call. |
extra_body | Mapping[str, Any] | No | Body fields merged last, so they win over every other field. |
name | str | No | A display name for the job, up to 200 characters. |
project_id | str | No | A UUID you mint to group jobs you submit together. |
Every other keyword argument, name and project_id included, becomes a body field of that tool as you give it, except that None values are left out. The document service refuses a field it doesn't know, a misspelled one included, with 422. A wait_seconds outside 0 to 60, or an empty or too-long idempotency_key, raises NaceError before anything is sent.
Parse, Split, Classify and Extract take page_ranges as a list of {"start": int, "end": int}: one-based and inclusive, at most 200 ranges and 500 pages in all.
wait_seconds is best effort: a job that takes longer comes back queued or running, with result still None. Check status before you read result:
job = client.documents.parse("https://example.com/report.pdf", wait_seconds=60)
if job.status != "succeeded":
job = client.jobs.wait(job.job_id) # raises NaceJobFailedError if it failed
print(job.result["document"]["markdown"])A create that holds the request open is retried like any other call, but the default retry budget is 30 seconds in all, so an attempt that fails after waiting longer than that isn't retried. Raise RetryPolicy.timeout to retry those; see Retries.
Idempotency. Every create carries an Idempotency-Key, so the SDK's own retries return the same job and never bill twice. The minted key is new on each call, though. To retry a create yourself, after a 429, a 503 job_pending or a crash, pass your own idempotency_key and reuse it. The same key with a different body raises NaceConflictError (idempotency_conflict). See Retry safely.
Charge id. A create's response carries Drex's id for the charge in the x-drex-document-job header. Read it from the job's HTTP response, or from the error's headers when a create fails:
job = client.documents.parse("https://example.com/report.pdf")
print(job.request_id, job.raw_http_response.headers.get("x-drex-document-job"))Quote both when you contact support about a charge.
Parse
client.documents.parse(source, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
source | str | UrlSource | WorkspaceFileSource | ParseResultSource | Yes | The document to parse. |
page_ranges | list[dict] | No | One-based, inclusive ranges, such as [{"start": 1, "end": 3}]. Not with a parse_result source. Default: all pages. |
parse_mode | "low" / "medium" / "high" | No | Effort. low uses native OCR, medium routes to the best parser, high adds page verification and correction. Default: "low", the only value a parse_result source takes. |
output | dict | No | formats (default ["markdown", "blocks"]; add "text"), table_format ("html" by default, or "markdown"), include_images (default False; not with a parse_result source), include_page_markers (default True), return_ocr_data (default False: word and line OCR geometry; PDF sources only). |
figures | dict | No | mode: "include" (default), "omit", or "describe" to caption each figure with one vision call. |
diagrams | dict | No | mode: "omit" (default) or "mermaid" to rewrite flowchart-like figures. |
chunking | dict | No | strategy: "none" (default), "page" or "section", sized for retrieval. |
spreadsheet | dict | No | sheets (names to parse; default all), include_hidden_sheets, include_hidden_rows, include_hidden_columns (each default True), include_formulas (default False; needs table_format "html" and markdown or blocks output). Not with a parse_result source. |
password | str | No | Password for an encrypted PDF. Never stored or returned, and masked in the SDK's logs. Not with a parse_result source. |
A parse_result source with any of the options marked "not with a parse_result source" set to something other than its default (for spreadsheet: sheets, an include_hidden_* of False, or include_formulas=True), or with a parse_mode other than low, is refused with 422 (NaceUnprocessableEntityError) before a job is created. OCR runs when the file needs it: the document service refuses any ocr setting other than the default with 422 invalid_request.
from nace_sdk import NaceClient
with NaceClient() as client:
job = client.documents.parse(
"https://example.com/report.pdf",
page_ranges=[{"start": 1, "end": 3}],
output={"formats": ["markdown", "blocks"]},
chunking={"strategy": "page"},
wait_seconds=60,
)
if job.status != "succeeded":
job = client.jobs.wait(job.job_id)
print(job.result["document"]["markdown"])For a very large file, document.markdown is only a preview and document.content_url links the full text: save it with jobs.download. See Parse for the result shape.
Split
client.documents.split(source, *, classes, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
source | str | UrlSource | WorkspaceFileSource | ParseResultSource | Yes | The document to split. |
classes | list[dict] | Yes | The document classes to cut on, each {"id", "label", "description"}, with optional subclasses ({"id", "label", "description"}) and instance_key ({"name", "description", "required"}: a value that tells one document of the class from the next, such as an invoice number). See Split's options. |
unknown_policy | "include" / "force" / "error" | No | include (default) keeps unmatched pages as unclassified, force gives them the best class, error fails the job. |
overlap_policy | "exclusive" / "shared_boundary_page" | No | exclusive (default) keeps segments apart; shared_boundary_page puts a boundary page in both neighbors. |
split_rules | str | No | Free-text guidance on how to cut the packet. |
output | dict | No | include_content (default False) puts each segment's Markdown in content; materialize_files (default False) gives each segment a downloadable file. |
page_ranges | list[dict] | No | One-based, inclusive ranges. Not for workbooks. Default: all pages. |
parse_mode | "low" / "medium" / "high" | No | Effort of the parse Split runs first. Default: "low", the only value a parse_result source takes. |
from nace_sdk import NaceClient
with NaceClient() as client:
job = client.documents.split(
"https://example.com/packet.pdf",
classes=[
{"id": "invoice", "label": "Invoice", "description": "A supplier invoice"},
{"id": "receipt", "label": "Receipt", "description": "A payment receipt"},
],
wait_seconds=60,
)
if job.status != "succeeded":
job = client.jobs.wait(job.job_id)
for segment in job.result["segments"]:
print(segment["start_page"], segment["end_page"], segment["class"]["id"] if segment["class"] else None)Class ids must be unique and can't contain /, and blank_page, unreadable_content and __ndi_unclassified are reserved. See Split.
Classify
client.documents.classify(source, *, classes, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
source | str | UrlSource | WorkspaceFileSource | Yes | The document to classify. Doesn't take a parse_result source. |
classes | list[dict] | Yes | Your labels, each {"id", "label", "description"}, with optional criteria (a list of strings) and subclasses. |
granularity | "document" / "page" | No | Score the whole file once, or once per page. Default: "document". |
page_ranges | list[dict] | No | One-based, inclusive ranges to read and classify. Not for workbooks. Default: all pages. |
unknown_policy | "allow" / "force_best" | No | allow (default) may mark a unit unknown; force_best commits an ambiguous unit to one of your classes. |
output | dict | No | max_alternatives (1 to 10, default 3) ranked labels per unit; include_reason (default True) adds a short reason per label. |
parse_mode | "low" / "medium" / "high" | No | Effort of the parse Classify runs first. Default: "low". |
from nace_sdk import NaceClient
with NaceClient() as client:
job = client.documents.classify(
"https://example.com/invoice.pdf",
classes=[
{"id": "invoice", "label": "Invoice", "description": "A supplier invoice"},
{"id": "contract", "label": "Contract", "description": "A signed agreement between two parties"},
],
granularity="page",
wait_seconds=60,
)
if job.status != "succeeded":
job = client.jobs.wait(job.job_id)
for unit in job.result["units"]:
print(unit["page_range"], unit["labels"][0]["class_id"])Class ids must be unique and can't contain /, and other, blank_page, unreadable_content and __ndi_unclassified are reserved. See Classify.
Extract
client.documents.extract(source, *, schema=None, schema_id=None, schema_version=None, instructions=None, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
source | str | UrlSource | WorkspaceFileSource | ParseResultSource | Yes | The document to extract from. Reuse a finished parse with ParseResultSource so it isn't parsed and paid for twice. |
schema | dict | Yes, or schema_id | The fields to extract, as a JSON Schema. |
schema_id | str | Yes, or schema | A saved schema's id (sch_...). |
schema_version | int | No | The saved schema's version to use. Only with schema_id. Default: its latest version. |
instructions | str | No | Extra guidance for filling the declared fields. Can't add fields the schema doesn't list. |
page_ranges | list[dict] | No | One-based, inclusive ranges. Not with a parse_result source. Default: all pages. |
citations | dict | No | enabled (default True) says where in the document each field was read; include_source_text (default True) adds a short quote. |
parse_mode | "low" / "medium" / "high" | No | Effort of the parse Extract runs first. Default: "low", the only value a parse_result source takes. |
Pass exactly one of schema and schema_id. Both, neither, or a schema_version without schema_id raises NaceError before anything is sent.
from nace_sdk import NaceClient, ParseResultSource
with NaceClient() as client:
parsed = client.jobs.wait(client.documents.parse("https://example.com/invoice.pdf").job_id)
job = client.documents.extract(
ParseResultSource(job_id=parsed.job_id),
schema={
"type": "object",
"properties": {
"invoice_number": {"type": "string"},
"total": {"type": "number"},
},
"required": ["invoice_number", "total"],
},
wait_seconds=60,
)
if job.status != "succeeded":
job = client.jobs.wait(job.job_id)
print(job.result["data"]) # {'invoice_number': 'INV-1042', 'total': 1200.5}See Extract for the result: data, shaped by your schema, and fields, one entry per schema field with its path, value, status (found, not_found or ambiguous), confidence and citations.
Ground
client.documents.ground(source, *, targets, **options)
| Parameter | Type | Required | Description |
|---|---|---|---|
source | str | UrlSource | WorkspaceFileSource | ParseResultSource | Yes | The document to search. |
targets | list[dict] | Yes | 1 to 30 quotes, each {"id", "text"}. Optional per target: hint (nearby text, for a repeated quote), semantic (default False; deprecated: True fails that target with semantic_mode_deprecated on PDF, Word and PowerPoint sources), page_hints (one-based pages to search first) and sheet (workbook tab). |
options | dict | No | max_matches (1 to 50, default 10) locations per target; minimum_semantic_score (0 to 1) drops weaker semantic matches, and does nothing on PDF, Word and PowerPoint; include_previews (default False) links a cropped image per visual hit. |
semantic belongs on each target; there is no top-level semantic. Ground takes neither page_ranges nor parse_mode.
from nace_sdk import NaceClient
with NaceClient() as client:
job = client.documents.ground(
"https://example.com/report.pdf",
targets=[
{"id": "total", "text": "1,200.50"},
{"id": "signer", "text": "Jane Smith", "hint": "Signed on behalf of the supplier"},
{"id": "late_fee", "text": "Late payment fee"},
],
wait_seconds=60,
)
if job.status != "succeeded":
job = client.jobs.wait(job.job_id)
for target in job.result["targets"]:
print(target["id"], target["status"], target["matches"][:1])job.result is a plain dict. Each target has id, status and matches; each match has rank, matched_text, match_method, confidence, cropped_image_url and location, whose kind decides its other keys: page_region (page, polygons), text_span (page, char_start, char_end), sheet_range (sheet, a1_range), row_range (start_row, end_row, column), json_pointer (pointer), jsonl_record (row_offset, column) or audio_range (start_ms, end_ms). See Matches.
Uploads
Uploads are free and work at any balance. The bytes go from your machine straight to the document service with a one-use token, never through Drex and never with your API key. See Uploads.
Upload a file
client.documents.upload(content, *, path=None, file_name=None, on_conflict=None)
| Parameter | Type | Required | Description |
|---|---|---|---|
content | str | PathLike | bytes | BinaryIO | Yes | A path to the file, its bytes, or an open binary file. |
path | str | No | Path in the workspace, relative (no leading /). Keep the file's extension. Default: file_name. |
file_name | str | For bytes and nameless streams | The name sent with the file, and the default path. Default: the name of the path or stream. Bytes without it raise ValueError. |
on_conflict | "reject" / "new_version" | No | What happens when a different file already has path. reject (the document service's default) fails with path_conflict; new_version stores the upload as that file's next version. |
It returns an UploadedFile. Pass file.as_source() to any tool.
from nace_sdk import NaceClient
with NaceClient() as client:
file = client.documents.upload("invoice.pdf", path="invoices/invoice.pdf", on_conflict="new_version")
job = client.documents.extract(file.as_source(), schema={"type": "object", "properties": {"total": {"type": "number"}}}, wait_seconds=60)- Keep the extension on
path. The document service types an upload from its first bytes, and from the extension when those don't settle it; it refuses a file it can't type withunsupported_file_type. See Format support. - Files of 32 MiB or more (
UPLOAD_SESSION_THRESHOLD_BYTES) go up in parts through an upload session, automatically. - A path conflict raises
NaceConflictErrorwithcodepath_conflictfor a file sent whole. For a file sent in parts it is found when the parts are assembled, and raisesNaceErrornaming the failed upload job andpath_conflict. - Uploading the same bytes to the same path again returns the file already there.
- A file still assembling after about ten minutes raises
NaceError. The document service keeps assembling it; upload the same file to the same path again later to get it.
Upload grants
client.documents.create_upload_grant(**options) mints a one-use token for one upload into your workspace, so another client can upload without your API key. It returns an UploadGrant.
| Parameter | Type | Required | Description |
|---|---|---|---|
path | str | No | Pin the upload to exactly this path. |
max_bytes | int | No | Refuse a larger file. Default: the document service's limit for the file type. |
total_size_bytes | int | No | The exact size the file must have. |
ttl_seconds | int | No | How long the token works, 1 to 3600. Default: 3600. |
Mint the grant on your server and hand upload_url and token to the browser. The browser POSTs the file there as multipart form data, with a metadata part such as {"path": "...", "on_conflict": "new_version"}, only the X-Upload-Token header, and credentials: "omit". The upload's answer carries the file under result.file; send its workspace_id and file_id back to your server and use them as a WorkspaceFileSource. /v1 routes send no Access-Control-Allow-Origin header, so browser code can't call Drex itself; keep every Drex call on your server.
grant = client.documents.create_upload_grant(path="invoices/invoice.pdf", total_size_bytes=48213, ttl_seconds=600)
print(grant.upload_url, grant.expires_at) # hand grant.upload_url and grant.token to the uploaderUpload sessions
A session uploads a large file in parts, and can resume after a dropped connection. upload runs one for you; drive one yourself to upload parts in parallel or across process restarts.
| Method | Parameters | Returns | Description |
|---|---|---|---|
create_upload_session(**options) | path: str, total_size_bytes: int (required); ttl_seconds: int (1 to 5400, default 5400), on_conflict, idempotency_key | UploadSession | Opens the session. Carries an Idempotency-Key, yours or one minted per call, so a retry never opens a second one. |
upload_part(session, number, data) | session: UploadSession, number: int (from 0), data: bytes | None | Sends one part: session.chunk_size bytes, the last one shorter. Sending a part again replaces it. |
complete_upload_session(session, *, idempotency_key=None) | session, idempotency_key: str | UploadedFile | Assembles the parts into one file and waits for it, for about ten minutes. Calling again with the same key resumes the same completion. |
get_upload_session(session) | session | UploadSessionStatus | The session's state and the parts that landed. |
abort_upload_session(session) | session | None | Abandons the session and discards its parts. Aborting a finished or aborted session does nothing. |
The part, status, complete and abort calls go straight to the session's upload_session_url with its session_token. complete_upload_session raises NaceError when the upload fails, for example with path_conflict, or when the file is still assembling after its wait; that message names the key to call it again with.
A session refuses calls that don't fit its state. None of these is retried:
session_busy(NaceConflictError): sending a part or aborting while the session iscompleting, or completing it with a different key while a completion runs. Callcomplete_upload_sessionagain with the same key to resume that completion.upload_expired(NaceConflictError): sending a part to a session that completed or was aborted, or completing an aborted one. Completing a completed session again returns its file.- After
expires_atthe session token stops working, and every call on the session raisesNaceAuthenticationError(401). Open a new session.
from pathlib import Path
from nace_sdk import NaceClient
source = Path("scan.tiff")
with NaceClient() as client:
session = client.documents.create_upload_session(path="scans/scan.tiff", total_size_bytes=source.stat().st_size)
with source.open("rb") as handle:
# After a drop, send only the parts that haven't landed.
landed = {part["part_number"] for part in client.documents.get_upload_session(session).parts}
for number in range(session.total_parts):
if number not in landed:
handle.seek(number * session.chunk_size)
client.documents.upload_part(session, number, handle.read(session.chunk_size))
file = client.documents.complete_upload_session(session)To resume from another process, save session.model_dump() and rebuild it with UploadSession.model_validate(saved).
Jobs
client.jobs reads, lists, waits for and cancels jobs. job_id is a str or a uuid.UUID; anything that isn't a job id raises NaceError before anything is sent.
| Method | Parameters | Returns | Description |
|---|---|---|---|
get(job_id) | job_id | DocumentJob | One job and, once it has succeeded, its result until retention drops it. Classify may include a partial result while still running. Another account's job is a 404. |
list(**options) | limit: int (1 to 50, default 20), operation: str, status: str, cursor: str | DocumentJobList | One page of jobs, newest first, without results. Pass next_cursor as cursor for the next page. |
iter(**options) | operation: str, status: str | Iterator[DocumentJob] | Every job across every page, 50 a page. |
wait(job_id, *, timeout=600, raise_on_failure=True) | job_id, timeout: float (seconds), raise_on_failure: bool | DocumentJob | Polls until the job is terminal. See Wait for a job. |
delete(job_id) | job_id | None | Cancels a running job and drops it from the list. A finished job is only dropped. It stays readable by id, and work done is still charged. |
events(job_id, *, last_event_id=None) | job_id, last_event_id: str | Iterator[JobEvent] | Streams progress as Server-Sent Events. See Progress events. |
list with status filters on the status Drex last recorded and leaves off a job that has moved on since, so a page can hold fewer jobs than limit, even none, while next_cursor says more follow. Keep paging until next_cursor is None, or use iter. A limit outside 1 to 50 raises NaceError.
client.jobs.list(limit=20, operation="parse", status="succeeded")
for job in client.jobs.iter(operation="extract"):
print(job.job_id, job.status, job.credits)
client.jobs.delete(job_id)See Jobs and results.
Wait for a job
wait polls get until status is succeeded, failed or cancelled: after 1 second, then doubling to every 10 seconds, for up to timeout seconds in all, 600 by default. Each poll is a get, with the client's retries.
- Failed or cancelled: raises
NaceJobFailedError, with the job aserror.job. Withraise_on_failure=False, it returns the job instead. - Out of time: raises
NaceJobTimeoutError, witherror.jobanderror.timeout. The job keeps running; wait again or read it later.
from nace_sdk import NaceJobFailedError, NaceJobTimeoutError
try:
job = client.jobs.wait(job_id, timeout=1200)
except NaceJobFailedError as error:
print(error.job.error_code, error.job.error_message)
except NaceJobTimeoutError as error:
print("still", error.job.status)Progress events
events yields a JobEvent whenever the job's status or progress changes. The first event is the job's current state, and the stream ends when the job is terminal or after five minutes. It is a latency convenience: read the outcome with get or wait. The stream is never retried and counts against your per-minute limit. After a drop, pass the last event's sequence to continue its numbering:
last = None
for event in client.jobs.events(job_id):
last = event
print(event.sequence, event.status, event.progress)
if last is not None and last.status not in ("succeeded", "failed", "cancelled"):
for event in client.jobs.events(job_id, last_event_id=str(last.sequence)):
print(event.sequence, event.status)Job files
A job file's path is a link from the result, such as document.content_url, a sheet's rows_url or a figure block's image_url, or the part after the job id, such as parse-images/<ref>. Its query and fragment are ignored. A path that is empty or holds . or .. raises NaceError, and so does a full link from another job's result, before any request: pass that job's id instead.
| Method | Parameters | Returns | Description |
|---|---|---|---|
download(job_id, path, destination, *, wait_timeout=600) | job_id, path, destination: str | Path, wait_timeout: float (seconds) | Path | Saves any file the job produced. See Download a file. |
file_link(job_id, path) | job_id, path | FileLink | A five-minute signed link to a stored file. See Signed links. |
get_request(job_id) | job_id | JobRequest | The request the job ran under. See Job request. |
rows(job_id, path, *, start_row=None, limit=None) | job_id, path, start_row: int, limit: int | RowsPage | One page of a parsed sheet's rows. See Spreadsheet rows. |
| Kind | Path starts with | Read it with |
|---|---|---|
| Split segment files, figure crops, parse transcripts, ground crops, ground transcripts, converted PDFs, cell maps | parse-images/, parse-transcript/, ground-crops/, ground-transcript/, converted-pdf/, spreadsheet-cell-maps/ | download, or file_link for a signed link |
| Full Markdown of a document or sheet | document-content/, spreadsheet-content/ | download |
| A sheet's rows | spreadsheet-rows/ | rows, or download for one page as JSON |
| The request echo | request | get_request |
| The event stream | events | events |
Downloads of job files don't count against your per-minute limit; the request echo and the event stream do. Results and their files expire on the document service's retention schedule. After that, job files and the request echo raise NaceNotFoundError (404, or 410 with result_expired), while get and wait still return the job, with result set to None and job.result_state saying why. See Jobs and results.
Download a file
download(job_id, path, destination, *, wait_timeout=600) saves any job file to destination, overwriting it, and returns its Path. A stored file comes from its signed link, without your API key; everything else streams from Drex.
Markdown the document service is still preparing answers 202: download waits as long as its retry-after header or retry_after_seconds says (else 3 seconds) and asks again, for up to wait_timeout seconds, then raises NaceError; the content keeps being prepared, so download it again later. Preparing content that fails raises NaceConflictError. Opening each request is retried under the client's policy; a transfer cut off midway is not.
from nace_sdk import NaceClient
with NaceClient() as client:
job = client.jobs.wait(client.documents.parse("https://example.com/ledger.parquet").job_id)
document = job.result["document"]
if document.get("content_url"): # markdown is only a preview
client.jobs.download(job.job_id, document["content_url"], "ledger.md")Signed links
file_link(job_id, path) returns a FileLink: url, which works for five minutes without your API key, to hand to a browser or another service, and expires_at, in Unix seconds. Only stored files have one: split-artifacts, ground-crops, ground-transcript, parse-images, parse-transcript, converted-pdf and spreadsheet-cell-maps. Any other path raises NaceError pointing at download before a request goes out; so does a stored file where the document service can't sign links, after the request. A stored-file path this job doesn't have raises NaceNotFoundError (404). download works in every case.
client.jobs.download(job_id, "parse-images/<ref>", "figure-1.png")
link = client.jobs.file_link(job_id, "parse-images/<ref>")
print(link.url, link.expires_at)Job request
get_request(job_id) returns a JobRequest: request_type (the operation), and what the job was asked for, such as its source, options, schema, classes or targets, in model_extra. A PDF password is never returned. It expires with the job's result.
print(client.jobs.get_request(job_id).model_extra)Spreadsheet rows
rows(job_id, path, *, start_row=None, limit=None) reads one page of a parsed sheet's rows, in file order: path is the sheet's rows_url, which only Parquet sources carry, start_row is zero-based (default 0), and limit is 1 to 100 (MAX_ROWS_PAGE_SIZE, default 50). A negative start_row or a limit out of range raises NaceError. It returns a RowsPage.
for sheet in (job.result["document"].get("spreadsheet") or {}).get("sheets", []):
if not sheet.get("rows_url"):
continue
start = 0
while True:
page = client.jobs.rows(job.job_id, sheet["rows_url"], start_row=start, limit=100)
print([column.name for column in page.columns], page.rows[:2])
start += len(page.rows)
if not page.rows or start >= page.total_rows:
breakSaved schemas
Save a JSON Schema once, then name it by schema_id on extract instead of sending it each time. Schemas can't be edited or deleted; add a version instead. Schema routes are free and work at any balance.
| Method | Parameters | Returns | Description |
|---|---|---|---|
create(name, schema, *, description=None) | name: str, schema: dict, description: str | ExtractionSchema | Saves version 1 of a new schema. |
list(**options) | limit: int (1 to 200, default 50), cursor: str | ExtractionSchemaList | One page of schemas at their latest version, without their JSON Schema, sorted by schema_id (see Order below). |
iter(**options) | retry, timeout, extra_headers | Iterator[ExtractionSchema] | Every schema across every page, 200 a page. |
get(schema_id) | schema_id: str | ExtractionSchema | A schema's latest version. |
create_version(schema_id, schema, *, name=None, description=None) | schema_id: str, schema: dict, name: str, description: str | ExtractionSchema | Adds the next version. Leave out name and description to keep the current ones. |
list_versions(schema_id, **options) | schema_id: str, limit: int (1 to 200, default 50), cursor: str | ExtractionSchemaList | One page of the schema's versions, oldest first. |
iter_versions(schema_id, **options) | schema_id: str, retry, timeout, extra_headers | Iterator[ExtractionSchema] | Every version, oldest first. |
get_version(schema_id, version) | schema_id: str, version: int | ExtractionSchema | One specific version. |
- Order:
listanditerreturn your schemas and any Nace provides (owner="platform"), which can't take versions, sorted byschema_idthe way the document service's database compares text: case-insensitively, with-and_ignored at first, so not in byte order;sorted(ids)won't match it. Versions come oldest first, version 1 first; for the newest, callget. - No automatic retries on creates:
createandcreate_versionaren't idempotent, since a retried create can save a second schema or version. They aren't retried unless you passretry. - The JSON Schema is
schema_, becauseschemais a Pydantic method name.model_dump(by_alias=True)["schema"]gives it under its wire name. - A
limitoutside 1 to 200, or aschema_idthat is empty or holds/,?or#, raisesNaceError. Another account'sschema_idraisesNaceNotFoundError.
from nace_sdk import NaceClient
invoice = {
"type": "object",
"properties": {"invoice_number": {"type": "string"}, "total": {"type": "number"}},
}
with NaceClient() as client:
saved = client.extraction_schemas.create("invoice", invoice, description="Invoice header fields")
client.extraction_schemas.create_version(
saved.schema_id,
{**invoice, "properties": {**invoice["properties"], "due_date": {"type": "string"}}},
)
for version in client.extraction_schemas.iter_versions(saved.schema_id):
print(version.version, version.schema_)
job = client.documents.extract("https://example.com/invoice.pdf", schema_id=saved.schema_id, schema_version=1)Types and constants
Every object a call returns directly also has request_id and raw_http_response, as in Read the response. Items inside a page don't carry them (reading one raises NaceError), so read the page's own; UploadedFile and JobEvent have neither. DocumentJob, UploadedFile, UploadSession, UploadSessionStatus, JobEvent and JobRequest keep any other field the document service sends as an attribute and in model_extra, such as job.progress.
DocumentJob
| Member | Type | Description |
|---|---|---|
job_id | str | The job's id, which every jobs method takes. |
kind | str | The operation: parse, split, classify, extract or ground. |
status | str | queued, running, succeeded, failed or cancelled. |
result | Any | The result, usually once status is succeeded, else None. Classify may include a partial result while still running, with reason_status (pending, complete, failed or not_requested); a result without reason_status is complete. Its shape is on each tool's page. A succeeded job's result is None again once retention drops it; job.result_state (in model_extra) is then expired or not_retained. See Jobs and results. |
error | Any | The document service's error, usually {"code", "message"}, when the job failed. |
credits | int | None | Credits the job used; final once usage_final is True. Drex charges credits times the operation's price, once. |
usage_final | bool | None | Whether credits is final. |
created_at | str | None | When the job was created, ISO 8601. |
is_terminal | bool | True for succeeded, failed and cancelled. |
error_message | str | None | error["message"], or error itself when it is a string. |
error_code | str | None | error["code"]. |
DocumentJobList has items: tuple[DocumentJob, ...] and next_cursor: str | None, None on the last page.
Upload types
| Type | Fields |
|---|---|
UploadedFile | workspace_id: str, file_id: str, path: str | None, plus the document service's other file fields. as_source() returns a WorkspaceFileSource. |
UploadGrant | workspace_id, upload_url (a document-service URL, not Drex's), token (send as X-Upload-Token), expires_at, max_bytes: int | None. |
UploadSession | workspace_id, session_id, session_token, chunk_size: int, total_parts: int, expires_at, upload_session_url. |
UploadSessionStatus | session_id, status (open, completing, completed or aborted), chunk_size, total_parts, parts (the landed parts, unordered, as {"part_number", "size_bytes"}), received_bytes, declared_size_bytes, expires_at. |
Job file types
| Type | Fields |
|---|---|
JobEvent | sequence: int | None (the SSE id), at: str | None (ISO 8601), status: str | None, progress: dict | None (such as units_done, units_total, stage), message: str | None. |
JobRequest | request_type: str; the rest of the request in model_extra. |
FileLink | url: str (fetch it without your API key), expires_at: int (Unix seconds). |
RowsPage | path: str, start_row: int (row number of rows[0]), limit: int, total_rows: int, columns: tuple[RowsColumn, ...], rows: tuple[dict[str, str], ...] (cells keyed by column name). |
RowsColumn | name: str, data_type: str. |
Schema types
| Type | Fields |
|---|---|
ExtractionSchema | schema_id (sch_...), name, description: str | None, version: int, owner ("account", or "platform" for one Nace provides), created_at, updated_at, schema_: dict | None (the JSON Schema; None on a list item). |
ExtractionSchemaList | items: tuple[ExtractionSchema, ...], next_cursor: str | None, total_count: int | None. |
Constants
Client constants
nace_sdk.constants holds the environment variable names and defaults the client uses:
| Name | Value |
|---|---|
API_KEY_ENV | "NACE_API_KEY" |
BASE_URL_ENV | "NACE_BASE_URL" |
DEFAULT_MODEL_ENV | "NACE_DEFAULT_DECISION_MODEL" |
LOG_LEVEL_ENV | "NACE_LOG_LEVEL" |
CONFIG_PATH_ENV | "NACE_CONFIG_PATH", the saved-credentials file's location. See Saved credentials. |
LEGACY_ENV | {"NACE_API_KEY": "DREX_API_KEY", "NACE_BASE_URL": "DREX_BASE_URL", "NACE_DEFAULT_DECISION_MODEL": "DREX_DEFAULT_MODEL", "NACE_LOG_LEVEL": "DREX_LOG_LEVEL", "NACE_CONFIG_PATH": "DREX_CONFIG_PATH"} |
DEFAULT_BASE_URL | "https://console.nace.ai" |
DEFAULT_MODEL | "drex-latest" |
DEFAULT_TIMEOUT | 60.0 seconds per HTTP attempt |
LEGACY_ENV maps each variable to its name before the packages were renamed from drex-*. read_env(name) returns the stripped value of name, else of its LEGACY_ENV name, and None when both are unset or blank; the SDK, CLI and MCP server read every variable through it.
Document constants
nace_sdk exports the document constants and literal types itself:
| Name | Value |
|---|---|
DOCUMENT_OPERATIONS | ("parse", "split", "classify", "extract", "ground") |
JOB_STATUSES | ("queued", "running", "succeeded", "failed", "cancelled") |
TERMINAL_STATUSES | frozenset({"succeeded", "failed", "cancelled"}) |
MAX_WAIT_SECONDS | 60: the longest wait_seconds. |
MAX_ROWS_PAGE_SIZE | 100: the largest rows limit. |
UPLOAD_SESSION_THRESHOLD_BYTES | 33554432 (32 MiB): upload sends files this size or larger in parts. |
DocumentOperation | Literal["parse", "split", "classify", "extract", "ground"] |
JobStatus | Literal["queued", "running", "succeeded", "failed", "cancelled"] |
OnConflict | Literal["reject", "new_version"] |
nace_sdk.__version__ is the installed version, as a string.
Handle errors
Every API, connection and job failure raises a NaceError. An error response raises a NaceAPIError subclass:
| Status | Class |
|---|---|
| 400 | NaceBadRequestError |
| 401 | NaceAuthenticationError |
| 402 | NaceInsufficientCreditError (type is insufficient_credit or payment_required) |
| 403 | NacePermissionDeniedError |
| 404, 410 | NaceNotFoundError |
| 409 | NaceConflictError |
| 422 | NaceUnprocessableEntityError |
| 429 | NaceRateLimitError |
| 529 | NaceOverloadedError (a subclass of NaceInternalServerError) |
| Other 5xx | NaceInternalServerError |
| Any other error status, such as 408 or 413 | NaceAPIError |
Every NaceAPIError has:
| Attribute | Type | Description |
|---|---|---|
status | int | The HTTP status. |
message | str | None | The server's message, without the status or request id. |
type | str | None | Drex's error.type, such as rate_limit_error. Branch on it. |
code | str | None | The exact cause (error.code), sent by the document routes. |
issues | list[dict] | Where a request failed validation (error.issues), as {"path", "message"} entries. |
detail | Any | The document service's detail (error.detail), such as detail["errors"] on a refused body. |
server_retryable | bool | None | The server's error.retryable verdict, or None when it gave none. |
request_id | str | None | The x-request-id header, else the body's request_id. Include it in support requests. |
body | Any | The decoded JSON body, plain text, or None. |
headers | httpx.Headers | The response headers. |
endpoint | str | None | The method and URL, without credentials or query. |
| Class | Raised when | Extra attributes |
|---|---|---|
NaceRateLimitError | 429 | retry_after_ms: the server's requested wait, or None. |
NaceAPIResponseValidationError | A successful response whose body doesn't match the expected type. A NaceAPIError subclass with the response's own status. | field_path: the first bad field, such as answers.topic.confidence. |
NaceAPIConnectionError | No response: the connection failed or dropped. Also a ConnectionError. | |
NaceAPITimeoutError | An attempt exceeded timeout. A NaceAPIConnectionError and a TimeoutError. | timeout: the setting that ran out. |
NaceJobFailedError | jobs.wait saw a document job end failed or cancelled. | job: the DocumentJob as last read. |
NaceJobTimeoutError | jobs.wait ran out of time; the job keeps running. | job, and timeout in seconds. |
Invalid arguments the SDK checks itself raise a plain NaceError before anything is sent. A few errors aren't NaceErrors: ValueError for both transport and http_client, or for uploading bytes without a file_name; pydantic.ValidationError for an unknown field on a question or source object; and OSError when a file can't be read or written.
from nace_sdk import NaceAPIError, NaceClient, NaceRateLimitError, Noul
with NaceClient() as client:
try:
client.system_one(state="Hello", questions={"greeting": Noul(instructions="Is this a greeting?")})
except NaceRateLimitError as error:
print("slow down", error.retry_after_ms, error.request_id)
except NaceAPIError as error:
print(error.status, error.type, error.message, error.issues, error.request_id)The document routes raise the same classes as system_one. On them, error.code is the exact cause, and error.detail carries the document service's detail, such as detail["errors"] listing the fields a refused body got wrong. It's None when there is no detail, as for unsupported_file_type.
Status and code | Class | When |
|---|---|---|
| 402 | NaceInsufficientCreditError | A create while the balance isn't positive and no card pays for usage beyond it (insufficient_credit), or while the account has an unpaid invoice (payment_required, not retryable until the invoice is paid on the Billing page). |
| 404 | NaceNotFoundError | No such job or saved schema for this account (schema_not_found). |
409 idempotency_conflict | NaceConflictError | An Idempotency-Key reused with a different body. |
409 path_conflict | NaceConflictError | Another file already has an upload's path. Upload with on_conflict="new_version" or another path. |
409 session_busy, upload_expired | NaceConflictError | An upload session call that doesn't fit the session's state. See Upload sessions. |
| 409 | NaceConflictError | download of Markdown content the document service failed to prepare. |
410 result_expired, job_unavailable | NaceNotFoundError | A job file or the request echo has expired, or the job was created before 3 October 2026 and its results are no longer available. An expired job itself still reads, with result set to None. |
422 invalid_source, invalid_request, unsupported_file_type, invalid_schema | NaceUnprocessableEntityError | The body or source was refused before any job was created or billed. |
| 429 | NaceRateLimitError | Over the document routes' own per-minute limit, separate from system_one's. |
503 document_billing_not_configured, job_pending | NaceInternalServerError | Document prices aren't configured, or a retried key's job isn't confirmed yet. |
A failed multi-part upload raises NaceError, and jobs.wait raises NaceJobFailedError and NaceJobTimeoutError from the table above (see Wait for a job). See Errors for every code and the rate limits.
from nace_sdk import NaceConflictError, NaceUnprocessableEntityError
try:
file = client.documents.upload("invoice.pdf", path="invoices/invoice.pdf")
except NaceConflictError as error:
if error.code == "path_conflict":
file = client.documents.upload("invoice.pdf", path="invoices/invoice.pdf", on_conflict="new_version")
else:
raise
try:
client.documents.parse(file.as_source(), page_ranges=[{"start": 0, "end": 1}])
except NaceUnprocessableEntityError as error:
print(error.code, error.message, error.detail)See Errors and retries for what each error means.
Retries
The client retries 408, 429 and 5xx responses, dropped connections and timeouts, with exponential backoff, and honors retry-after-ms and retry-after. It doesn't retry an error whose body says "retryable": false. Every retry of a document create or upload reuses the same Idempotency-Key, so it never starts or bills a second job.
RetryPolicy is a frozen dataclass:
| Field | Type | Default | Description |
|---|---|---|---|
max_retries | int | 2 | Retries after the first attempt. 0 disables retries. |
backoff_initial | float | 0.5 | First delay in seconds, doubled on each retry. 0 disables backoff. |
backoff_max | float | 5.0 | Longest delay in seconds. 0 disables backoff. |
backoff_jitter | float | 0.25 | Fraction of each delay randomly taken off, from 0 to 1. |
http_statuses | set[int] | {408, 429, 500, ..., 599} | Statuses that are retried. |
respect_retry_after | bool | True | Wait what retry-after-ms or retry-after says instead of the backoff. |
api_connection_error | bool | True | Retry NaceAPIConnectionError. |
api_timeout_error | bool | True | Retry NaceAPITimeoutError. |
exceptions | set[type[BaseException]] | set() | More exception types to retry. |
predicate | Callable[[BaseException], bool] | None | None | Called with each error; True retries it too. |
timeout | float | None | 30.0 | Total budget in seconds for one call: every attempt plus the waits between them. None removes it. |
The budget counts from the first attempt. Before each retry the client adds the wait to the time already spent, and if that reaches timeout, it stops and raises the last error. So with the defaults, an attempt that fails after 30 seconds or more is not retried: a request that runs into the 60-second timeout, or a 529 that came after Drex's 55-second wait for the model. A retry-after longer than what is left of the budget isn't waited out either: the error is raised at once. To retry those, raise the budget above your timeout:
from nace_sdk import NaceClient, RetryPolicy
client = NaceClient(timeout=60, retry=RetryPolicy(max_retries=3, timeout=200))A retry= on one call replaces the client's policy for that call. The client doesn't expose its policy, so build one from the policy you passed to the client (or from RetryPolicy()) with dataclasses.replace(policy, max_retries=0). A negative max_retries, a negative or infinite delay, a jitter outside 0 to 1, or a nonpositive timeout raises NaceError. Retried attempts carry an X-Nace-Retry-Count header. The job event stream and a download cut off midway are never retried, and saved-schema creates aren't retried unless you pass retry; see Progress events, Download a file and Saved schemas.
Logging
The SDK logs through the standard logging module, to the nace_sdk logger, which has only a NullHandler. Nothing prints until your app configures a handler, and you choose the level with NACE_LOG_LEVEL or setLevel:
import logging
logging.basicConfig() # a stderr handler
logging.getLogger("nace_sdk").setLevel(logging.INFO)NACE_LOG_LEVEL is read once, when nace_sdk is first imported. It takes debug, info, warn, warning, error or off, in any case; anything else is ignored.
| Level | What it logs |
|---|---|
info | One line per request, except streamed calls (below): method, URL, status, duration and x-request-id. Each retry and each failed connection. |
debug | Also the headers and JSON body of each request and response. A body that isn't JSON, such as a file part, shows only its size. |
warning | Answers of an unknown type that were dropped. |
Streamed calls log nothing, not even their retries, except an info line when their connection fails. They are jobs.events, jobs.file_link, and jobs.download with its fetch of a signed link.
Before any record reaches a handler, the SDK masks with *** the headers Authorization, Proxy-Authorization, X-Api-Key, Api-Key, X-Upload-Token, Cookie and Set-Cookie, any header whose name contains token or secret, and the body fields token, session_token and password. Connection errors are logged and raised with your credentials masked. URLs are logged as sent, query included, so a signed link's failed fetch logs the link.
Saved credentials
nace_sdk.credentials reads and writes the key that nace login saves, so the CLI and your own scripts share it. The key is resolved from NACE_API_KEY, then from the config file's api_key. The base URL comes from NACE_BASE_URL, then the file's base_url. The file is ~/.nace/config.toml, or the path in NACE_CONFIG_PATH.
The names from before the packages were renamed from drex-* still work when the new ones are absent: DREX_API_KEY, DREX_BASE_URL and DREX_CONFIG_PATH, and the file drex login wrote, ~/.drex/config.toml, which is read when ~/.nace/config.toml doesn't exist and no path variable is set. login always writes the new file.
| Name | Description |
|---|---|
resolve_credentials(*, login_command="nace login") | Returns Credentials(api_key, base_url), with base_url None when neither source sets it. Raises CredentialsError when there's no key, naming login_command in its message, or when the file is unreadable and the key had to come from it. |
Credentials | A frozen dataclass: api_key: str, base_url: str | None. |
login(api_key, *, base_url=None, http_client=None) | Checks the key's format, then calls models.list() with it, then writes it to the config file with mode 0600 and returns the file's path. A malformed key raises CredentialsError; a refused one, NaceAuthenticationError. A base_url you pass is saved too; otherwise the file keeps its own. |
logout() | Deletes the config file, saved base_url included, and the legacy ~/.drex/config.toml too when no path variable is set, so no saved key is left. Returns the first file it deleted, or None when there was none. |
config_path() | Where login writes: NACE_CONFIG_PATH (else DREX_CONFIG_PATH), else ~/.nace/config.toml. |
legacy_config_path() | ~/.drex/config.toml, the file drex login wrote before the rename. |
read_config(path, *, recover=False) | The file's api_key and base_url as a dictionary, {} when it doesn't exist. An unreadable file raises CredentialsError, or returns {} with recover=True. |
write_config(*, api_key, base_url=None, path=None) | Writes the file atomically with mode 0600, keeping a saved base_url when you pass none. |
check_key_format(api_key) | The stripped key when it is nace_sk_ and 43 URL-safe characters, else CredentialsError. |
CredentialsError | A ValueError, not a NaceError. |
CONFIG_PATH_ENV | "NACE_CONFIG_PATH", from nace_sdk.constants. |
API_KEY_PATTERN | The compiled pattern check_key_format uses. |
DEFAULT_LOGIN_COMMAND | "nace login". |
from nace_sdk import NaceClient
from nace_sdk.credentials import resolve_credentials
credentials = resolve_credentials()
with NaceClient(api_key=credentials.api_key, base_url=credentials.base_url) as client:
print(client.models.list().request_id)Developer tools
Official clients for Drex and Perception. Each one covers both products with one API key.
TypeScript SDK
Call Drex's decision model and run Perception's document tools from Node with nace-sdk — client options, calibrated questions, typed answers, parse, split, classify, extract, ground, uploads, jobs, saved schemas, errors, retries and logging.