Perception
Sources
Every tool takes a source: the document to work on. It can be one of three things, each with a short form you can pass instead of the class:
| The document | Short form | Class |
|---|---|---|
| A file at a public URL | The https:// URL, as a string | UrlSource |
| A file on your machine | Upload it first, then pass uploaded.as_source(). See Local files. | WorkspaceFileSource |
| A file you uploaded to a workspace | {"type": "workspace_file", "workspace_id": ..., "file_id": ...}, or uploaded.as_source() | WorkspaceFileSource |
| One of your finished parse jobs | {"type": "parse_result", "job_id": ...} | ParseResultSource |
from nace_sdk import NaceClient, UrlSource, WorkspaceFileSource
with NaceClient() as client:
# From a URL: the string, or UrlSource when the URL doesn't end in the file's name.
client.documents.parse("https://example.com/report.pdf")
client.documents.parse(UrlSource(url="https://example.com/download?id=42", file_name="report.pdf"))
# From a workspace file: a dict, the class, or the file `upload` returned.
client.documents.parse({"type": "workspace_file", "workspace_id": workspace_id, "file_id": file_id})
client.documents.parse(WorkspaceFileSource(workspace_id=workspace_id, file_id=file_id))
client.documents.parse(uploaded.as_source())
# From a finished parse job: Extract reads it instead of parsing, and paying, again.
client.documents.extract({"type": "parse_result", "job_id": parsed.job_id}, schema=schema)Local files
A path on your machine isn't a source: a string that isn't an https:// URL raises NaceError. Upload the file with client.documents.upload, then pass the upload to any tool:
from nace_sdk import NaceClient
with NaceClient() as client:
# Upload once. Without workspace_id, the file goes to the server, as a source for the tools.
uploaded = client.documents.upload("invoice.pdf", on_conflict="new_version")
# Parse it.
parsed = client.documents.parse(uploaded.as_source(), wait_seconds=60)
if parsed.status != "succeeded":
parsed = client.jobs.wait(parsed.job_id)
print(parsed.result["document"]["markdown"])
# Extract from that parse, instead of parsing and paying again.
schema = {"type": "object", "properties": {"total": {"type": "number"}}}
extracted = client.documents.extract({"type": "parse_result", "job_id": parsed.job_id}, schema=schema, wait_seconds=60)
if extracted.status != "succeeded":
extracted = client.jobs.wait(extracted.job_id)
print(extracted.result["fields"])
# Run another tool on the same upload, without uploading again.
found = client.documents.ground(uploaded.as_source(), targets=[{"id": "total", "text": "1,200.50"}], wait_seconds=60)- Upload once, reuse the source.
uploaded.as_source()works for every tool, as often as you like. - The same path again: uploading the same bytes to the same path returns the file already there. Different bytes at a path already in use raise
NaceConflictError(path_conflict) unless you passon_conflict="new_version", which stores them as that file's next version, or a differentpath. - Into a workspace: pass
workspace_id, andpathfor where it goes, such asclient.documents.upload("invoice.pdf", path="invoices/2026/invoice.pdf", workspace_id=workspace_id). The file then also shows in that workspace, and can be ingested and searched.
The source classes
| 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 one of your workspaces. 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. A dictionary must be in this wire form, with its type.
URL sources
- A string must start with
https://. Anything else raisesNaceError. - The file's name tells the document service which parser to use, and it refuses an extension the tool doesn't read with
unsupported_file_type. See Format support. - Without
file_name, the SDK takes the name 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), or the SDK raisesNaceErrorbefore sending anything. - A
file_nameyou pass is sent as it is. Pass one whenever the last segment isn't the file's real name:https://arxiv.org/pdf/1706.03762would otherwise be sent as1706.03762, which the document service refuses.
client.documents.parse("https://example.com/files/report%20v2.pdf") # file_name="report v2.pdf"
client.documents.parse(UrlSource(url="https://arxiv.org/pdf/1706.03762", file_name="attention.pdf"))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. |
Other keyword arguments
- Body fields: every other keyword argument,
nameandproject_idincluded, becomes a body field of that tool as you give it.Nonevalues are left out. - Unknown fields: the document service refuses a field it doesn't know, a misspelled one included, with
422. - Checked before sending: a
wait_secondsoutside 0 to 60, or an empty or too-longidempotency_key, raisesNaceErrorbefore anything is sent. page_ranges(Parse, Split, Classify and Extract): a list of{"start": int, "end": int}, one-based and inclusive. At most 200 ranges and 500 pages in all.
Wait for the result
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 can use the remaining retries after a retryable failure. There is no total retry budget by default; an explicit budget can prevent a retry after a long attempt. See Retries.
Idempotency
Every create carries an Idempotency-Key, so the SDK's own retries return the same job and never bill twice. The key it mints is new on each call, though.
| When | What to do |
|---|---|
You retry a create yourself, after a 429, a 503 job_pending or a crash | Pass your own idempotency_key, and reuse it on each try. |
| A create failed after the SDK's retries | The error's idempotency_key holds the key it sent. After a 503 job_pending, send the same body with that key to resume the same job. |
| You reuse a key with a different body | That raises NaceConflictError (idempotency_conflict). |
from nace_sdk import NaceInternalServerError
source = "https://example.com/report.pdf"
try:
job = client.documents.parse(source)
except NaceInternalServerError as error:
if error.code != "job_pending":
raise
# The job may still run: the same body with the same key resumes it instead of starting another.
job = client.documents.parse(source, idempotency_key=error.idempotency_key)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:
job = client.documents.parse("https://example.com/report.pdf")
print(job.request_id, job.raw_http_response.headers.get("x-drex-document-job"))When a create fails:
- It failed at the document service, such as a
429or a503 job_pending: the error'sheaderscarry the charge id too. - Drex refused it first, such as a
402or a409 idempotency_conflict: there is no charge id.
Quote the request id and the charge id when you contact support about a charge.