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 documentShort formClass
A file at a public URLThe https:// URL, as a stringUrlSource
A file on your machineUpload 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
sources.py
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:

local_file.py
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 pass on_conflict="new_version", which stores them as that file's next version, or a different path.
  • Into a workspace: pass workspace_id, and path for where it goes, such as client.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

SourceFieldsWhat it is
UrlSourceurl: str, file_name: str | None = NoneA public https:// link the document service downloads.
WorkspaceFileSourceworkspace_id: str, file_id: strA file you uploaded to one of your workspaces. UploadedFile.as_source() builds one.
ParseResultSourcejob_id: strOne 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 raises NaceError.
  • 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 raises NaceError before sending anything.
  • A file_name you pass is sent as it is. Pass one 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.
url_sources.py
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:

ParameterTypeRequiredDescription
wait_secondsintNo0 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_keystrNoUp to 200 characters. Default: one minted per call.
retryRetryPolicyNoReplaces the client's retry policy for this call.
timeoutfloat | httpx.TimeoutNoReplaces the client's timeout for this call.
extra_headersMapping[str, str]NoExtra headers for this call.
extra_bodyMapping[str, Any]NoBody fields merged last, so they win over every other field.
namestrNoA display name for the job, up to 200 characters.
project_idstrNoA UUID you mint to group jobs you submit together.

Other keyword arguments

  • Body fields: every other keyword argument, name and project_id included, becomes a body field of that tool as you give it. None values 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_seconds outside 0 to 60, or an empty or too-long idempotency_key, raises NaceError before 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.

WhenWhat to do
You retry a create yourself, after a 429, a 503 job_pending or a crashPass your own idempotency_key, and reuse it on each try.
A create failed after the SDK's retriesThe 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 bodyThat 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 429 or a 503 job_pending: the error's headers carry the charge id too.
  • Drex refused it first, such as a 402 or a 409 idempotency_conflict: there is no charge id.

Quote the request id and the charge id when you contact support about a charge.

On this page