Skip to content

[DE-8270] Model weights upload & download (SDK side) - #469

Open
luke-e-schaefer wants to merge 5 commits into
masterfrom
lukeschaefer/de-8270-upload-download-model-weights
Open

[DE-8270] Model weights upload & download (SDK side)#469
luke-e-schaefer wants to merge 5 commits into
masterfrom
lukeschaefer/de-8270-upload-download-model-weights

Conversation

@luke-e-schaefer

@luke-e-schaefer luke-e-schaefer commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

SDK side of model weights upload / download, mirroring the REST surface merged in scaleapi#149063.

The two primary methods are the ones the server PR's API-docs pages (ApiDocsPage/models-python/{upload,download}-model-weights.md) already document, so the published docs and the SDK agree:

import nucleus

client = nucleus.NucleusClient(YOUR_SCALE_API_KEY)
model = client.get_model(reference_id="My-CNN")

client.upload_model_weights(model, "/path/to/weights.bin")
client.download_model_weights(model, "/path/to/save/weights.bin")

Added

  • NucleusClient.upload_model_weights(model, path, *, content_type=None, original_filename=None, checksum_sha256=None, on_progress=None) — presign → PUT direct to storage → finalize. Returns ModelWeights.
  • NucleusClient.download_model_weights(model, path, *, on_progress=None) — resolves the signed URL and streams to disk (creating parent dirs). Returns the path written.
  • get_model_weights(model) / delete_model_weights(model) for the remaining two routes.
  • Model.upload_weights() / .download_weights() / .weights() / .delete_weights() — thin delegation to the client, matching how Benchmark wraps its client methods.
  • ModelWeights metadata type: present, status, size_bytes, original_filename, content_type, download_url.

All model arguments accept either a Model or a bare model id (prj_*).

Notes for review

  • Bytes never transit the Nucleus API. Transfers go straight to storage over presigned URLs, so multi-GB artifacts aren't subject to API request-size limits.
  • Part PUTs are sent with no headers. They're signed without the Content-Type condition, so forwarding requiredHeaders (which the single PUT does need) makes S3 reject the signature. There's a test pinning this in both directions — it's the easiest thing to get wrong here, and the frontend hook in 149063 has the same split.
  • Download uses ?json=1 to fetch the signed URL rather than following the 302, so the API's auth headers are never sent to storage.
  • Multipart above 5 GB, 4 parts in flight (a single S3 PUT is connection-throughput-bound). Missing part ETags fail on the first part rather than after transferring everything and dying at finalize.
  • The 10 GB server cap is checked client-side before presign, so an oversized file fails without a network round-trip.
  • The weights routes serialize camelCase in both directions, unlike most endpoints this SDK talks to, so the new payload keys are grouped and labelled as such in constants.py.

Tests / Version

  • tests/test_model_weights.py — 24 mock-based unit tests (no live API, no real S3): DTO parsing, payload builders, single vs. multipart transfer, header split, ETag/failure handling, progress callbacks, download streaming, all four client methods, and the Model wrappers.
  • Verified locally the way CI does: pylint nucleus 10.00/10, mypy --ignore-missing-imports nucleus clean, ruff clean, black + isort clean, 61 mock-based tests passing across the eval/benchmark/leaderboard/weights suites.
  • pyproject.toml0.19.1 + CHANGELOG entry (patch bump: additive new methods, per CLAUDE.md).

resolves https://linear.app/scale-epd/issue/DE-8270

🤖 Generated with Claude Code

Greptile Summary

This PR adds model weights upload/download to the Nucleus Python SDK, mirroring a REST surface already merged on the server side. Bytes flow directly to/from storage via presigned URLs, keeping large artifacts out of the API request pipeline entirely.

  • NucleusClient.upload_model_weights drives a presign → PUT → finalize flow, using _ProgressReader for incremental single-PUT callbacks and a locked counter for concurrent multipart callbacks. Files above 5 GB automatically switch to multipart with up to 4 concurrent part uploads, memory-bounded to 512 MB in-flight.
  • NucleusClient.download_model_weights fetches a signed URL via ?json=1 (avoiding credential forwarding on redirect) and streams to a sibling temp file, replacing the target atomically only on success to prevent truncated artifacts.
  • Model.upload_weights / download_weights / weights / delete_weights are thin delegation wrappers matching the pattern used by Benchmark. A new ModelWeights dataclass surfaces the artifact metadata.

Confidence Score: 5/5

  • The new upload/download code is safe to merge. Credentials never touch storage, transfers are atomic, thread-safety is properly handled, and the 24-test suite covers the corner cases that are easiest to get wrong.
  • The implementation is additive (no existing behaviour changes), bytes-to-storage flow is well-designed, multipart concurrency is lock-protected, and the atomic temp-file rename on download prevents truncated artifacts from being silently accepted. The one style note about private helpers leaking into the package namespace does not affect correctness.
  • No files require special attention, though nucleus/__init__.py is where the private helper imports land.

Important Files Changed

Filename Overview
nucleus/model_weights.py New module implementing presign → PUT → finalize upload flow and atomic temp-file download. Thread-safety for multipart progress is handled via a lock. _ProgressReader correctly delegates all file attributes to preserve Content-Length sizing. Atomic rename on download guards against truncated artifacts. No logic defects found.
nucleus/init.py Adds four NucleusClient methods (upload, download, get, delete weights) wired correctly to presign/transfer/finalize helpers. Private internal functions are imported into the package namespace as a side-effect of defining client methods in __init__.py.
nucleus/model.py Adds upload_weights, download_weights, weights, and delete_weights delegation methods to Model. Clean thin wrappers, no logic.
nucleus/constants.py Adds camelCase payload keys for the weights routes, clearly grouped and documented as intentionally camelCase unlike the rest of the constants file.
tests/test_model_weights.py Comprehensive mock-based suite: DTO parsing, payload builders, single vs. multipart transfer, header split correctness, ETag failure, progress callbacks (including the double-fire at completion), download streaming, atomic replace, and all client/model wrapper methods.
CHANGELOG.md Adds a 0.20.1 entry describing the new model weights API. Version is consistent with the pyproject.toml bump.
pyproject.toml Patch version bump from 0.20.0 to 0.20.1, appropriate for an additive change.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant NucleusClient
    participant NucleusAPI
    participant Storage as S3 Storage

    Note over Caller,Storage: Upload flow
    Caller->>NucleusClient: upload_model_weights(model, path)
    NucleusClient->>NucleusClient: os.path.getsize(path) ≤ 10 GB check
    NucleusClient->>NucleusAPI: "POST model/{id}/weights/presign"
    NucleusAPI-->>NucleusClient: "{uploadId, uploadUrl/parts, requiredHeaders}"

    alt "Single PUT (< 5 GB)"
        NucleusClient->>Storage: PUT uploadUrl (with requiredHeaders + ProgressReader)
        Storage-->>NucleusClient: ETag (ignored for single PUT)
    else Multipart (≥ 5 GB)
        par 4 concurrent workers
            NucleusClient->>Storage: PUT part[n].url (no extra headers)
            Storage-->>NucleusClient: ETag[n]
        end
    end

    NucleusClient->>NucleusAPI: "POST model/{id}/weights/finalize {uploadId, parts?}"
    NucleusAPI-->>NucleusClient: ModelWeights JSON
    NucleusClient-->>Caller: ModelWeights

    Note over Caller,Storage: Download flow
    Caller->>NucleusClient: download_model_weights(model, path)
    NucleusClient->>NucleusAPI: "GET model/{id}/weights/download?json=1"
    NucleusAPI-->>NucleusClient: "{url: signedGetUrl}"
    NucleusClient->>Storage: "GET signedGetUrl (stream=True, no API credentials)"
    Storage-->>NucleusClient: streamed body → tmp file
    NucleusClient->>NucleusClient: os.replace(tmp, path) — atomic
    NucleusClient-->>Caller: path written
Loading

Fix All in Greploop

Reviews (7): Last reviewed commit: "fix(weights): atomic download, serialize..." | Re-trigger Greptile

Comment thread nucleus/model_weights.py
Comment thread nucleus/model_weights.py
Base automatically changed from update-nuc-sdk-for-new-eval-stuff-pt1 to master August 11, 2026 14:24
luke-e-schaefer and others added 2 commits August 11, 2026 14:31
Mirrors the REST surface shipped in scaleapi#149063 so users can attach a
weights artifact to a model and fetch it back from Python.

- `NucleusClient.upload_model_weights` / `download_model_weights` — the two
  methods the server PR's API-docs pages already document — plus
  `get_model_weights` and `delete_model_weights` for the remaining routes.
- `Model.upload_weights()` / `download_weights()` / `weights()` /
  `delete_weights()` delegate to the client, matching how `Benchmark` does it.
- New `ModelWeights` metadata type parsed from the weights DTO. The weights
  routes serialize camelCase both ways, unlike most of this SDK's endpoints,
  so the new payload keys are grouped and labelled in `constants.py`.
- Transfers go straight to storage via presigned URLs and never through the
  API, so artifacts aren't subject to API request-size limits. Over 5 GB the
  server hands back multipart parts, which upload 4 at a time; `on_progress`
  reports `(bytes_transferred, total_bytes)`.
- Size is checked against the server's 10 GB cap before presign, so an
  oversized file fails without a network round-trip.

Two things worth knowing for review: part PUTs must be sent with *no* headers
(they're signed without the Content-Type condition, so forwarding
`requiredHeaders` makes S3 reject the signature), and download resolves the
signed URL via `?json=1` rather than following the 302, so the API's auth
headers are never sent to storage.

24 mock-based unit tests in `tests/test_model_weights.py`; version bumped to
0.19.1 (additive, per CLAUDE.md).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docstrings are what users read, so they shouldn't describe how the
artifact gets stored or moved. Dropped the presign/multipart/direct-to-storage
narration from every public docstring, the `ModelWeights` attribute docs, and
the CHANGELOG, leaving what a caller actually needs: what the method does, who
can call it, the size limit, and the arguments.

Also made the transfer helpers private (`_presign_payload`,
`_transfer_weights_to_storage`, `_stream_weights_to_file`,
`_finalize_payload`) so the mechanics don't show up in the generated API docs
at all, rather than only being reworded.

Kept the two in-body comments that explain why part uploads send no headers
and why the download URL is fetched as JSON — those aren't user-visible and
each one guards a real footgun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@luke-e-schaefer
luke-e-schaefer force-pushed the lukeschaefer/de-8270-upload-download-model-weights branch from df6ede7 to 83c25f7 Compare August 11, 2026 14:35
luke-e-schaefer and others added 2 commits August 11, 2026 15:24
…progress

Addresses two review comments:
- transferred += len(chunk) ran unsynchronized across the part-upload pool,
  so concurrent workers could drop updates. The counter and the value handed
  to on_progress are now taken under a lock.
- A single PUT reported nothing until it finished, then jumped to 100%.
  When a callback is supplied the body is wrapped so progress comes from the
  read side; the wrapper delegates everything but read(), so requests still
  sizes the body from fileno()/tell() and sends Content-Length as before.
Resolves two version-bump conflicts from #470 (v0.20.0):
- pyproject.toml: 0.19.2/0.20.0 -> 0.20.1
- CHANGELOG.md: keep both sections, retitle the weights entry to 0.20.1

nucleus/__init__.py auto-merged cleanly (master touched create_benchmark,
this branch adds the model-weights methods).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@edwinpav
edwinpav self-requested a review August 13, 2026 16:14
Three fixes from review:

- Download streamed straight into the destination, so an interrupted
  transfer left a truncated artifact that looked complete. Stream to a
  sibling temp file and os.replace() on success; a failed re-download now
  also leaves any existing artifact intact.

- The multipart progress counter was locked but on_progress was called
  after releasing, so two threads could compute 100 and 200 and then call
  in either order. Invoke the callback under the same lock.

- Each in-flight part is read fully into memory, so peak usage was
  4 * partSizeBytes with a server-chosen part size. Bound concurrency by a
  512 MB budget via _part_upload_workers(), never below 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@edwinpav

Copy link
Copy Markdown
Contributor

👀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants