@beam-network/sdk 0.5.15 → 0.5.17-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ console.log(status.status);
43
43
  await beam.close();
44
44
  ```
45
45
 
46
- The main `createTransfer` API is provider-aware and strictly typed for S3, R2, S3-compatible, and Hippius configs.
46
+ The main `createTransfer` API is provider-aware and strictly typed for S3, R2, S3-compatible, Hippius, and Hugging Face configs.
47
47
  Use `testMode: true` to create a BeamCore test-mode transfer.
48
48
 
49
49
  ## S3-Compatible Providers
@@ -92,14 +92,69 @@ Use `S3ProviderConfig` for ordinary AWS S3 if preferred, and keep using
92
92
  `S3CompatibleProviderConfig` when a provider needs a custom S3-compatible
93
93
  endpoint.
94
94
 
95
+ ## Hugging Face Hub
96
+
97
+ Use `HuggingFaceProviderConfig` to pull a file out of a Hub repo or push one into
98
+ one. The token stays in this process: a source resolves to the presigned CDN URL
99
+ the Hub redirects to, and a destination uses the presigned part URLs the Hub's
100
+ LFS batch endpoint issues.
101
+
102
+ ```ts
103
+ import { BeamClient, HuggingFaceProviderConfig, S3ProviderConfig } from "@beam-network/sdk";
104
+
105
+ const beam = new BeamClient({ apiKey: process.env.BEAM_API_KEY! });
106
+
107
+ const transfer = await beam.prepareProviderTransfer({
108
+ sources: [
109
+ HuggingFaceProviderConfig.create({
110
+ repo_id: "org/dataset",
111
+ path: "data/train.parquet",
112
+ repo_type: "dataset",
113
+ token: process.env.HF_TOKEN!
114
+ })
115
+ ],
116
+ destinations: [
117
+ S3ProviderConfig.create({
118
+ bucket: "archive",
119
+ key: "datasets/train.parquet",
120
+ access_key_id: process.env.AWS_ACCESS_KEY_ID!,
121
+ secret_access_key: process.env.AWS_SECRET_ACCESS_KEY!
122
+ })
123
+ ],
124
+ name: "Hugging Face pull"
125
+ });
126
+
127
+ // waitForTransfer commits any Hugging Face destination once the parts land.
128
+ await beam.waitForTransfer(transfer.transfer_id);
129
+ ```
130
+
131
+ Constraints, all of them the Hub's rather than Beam's — see
132
+ [`HUGGINGFACE_PROVIDER.md`](../../HUGGINGFACE_PROVIDER.md) for the full protocol:
133
+
134
+ - **Sources are large-file (LFS or Xet) content.** Only those get the
135
+ credential-free CDN redirect; a small regular file is served inline and is
136
+ rejected with that reason. Hub **buckets** work as sources too, but cannot be
137
+ written to — they expose no LFS endpoint.
138
+ - **A destination needs the object's sha256 up front**, because the LFS batch
139
+ endpoint will not issue upload URLs without it. When the source is not itself a
140
+ Hub file, set `allow_source_rehash: true` to let the SDK read the source once to
141
+ compute it.
142
+ - **One source per Hugging Face destination**, because the Hub dictates the part
143
+ size and a plan carries a single chunk size.
144
+ - If you do not call `waitForTransfer`, call `finalizeHuggingFaceUploads(transferId)`
145
+ yourself once the transfer completes — without it the parts are uploaded but no
146
+ commit is made and the file does not appear in the repo.
147
+
95
148
  For low-level raw transfer configs, use `createRawTransfer`; lifecycle transport still goes through NATS.
96
149
 
97
150
  ## Route Streaming And Payload Size
98
151
 
99
- Provider transfers use `transfer-client-control/v6`. Every reply carries Runtime and transport epochs; prepare and every route-stream message carry a UUID route generation. S3, R2, and S3-compatible destinations retain direct multipart UploadPart/ListParts/HEAD handling.
152
+ Provider transfers use `transfer-client-control/v6`. Every reply carries Runtime and transport epochs; prepare and every route-stream message carry a UUID route generation. S3, R2, and S3-compatible destinations retain direct multipart UploadPart/ListParts/HEAD handling. Hippius and Hugging Face destinations take a plain PUT per chunk instead, so they carry no multipart group manifest.
100
153
 
101
154
  The client signs up to 64 routes concurrently by default and emits 2,048-route logical batches. Encoded MessagePack requests target 4 MiB physical batches under `maxPayloadBytes` (8 MiB by default; explicit positive overrides remain supported). The splitter reserves control-envelope headroom for the live auth token and stable request identity so the final encoded request remains below the configured guard. Lifecycle control refreshes cached SDK auth before the final 30 seconds of token lifetime and retries `auth_token_expired` replies with the same request identity and payload but a fresh token, preserving Core request-conflict protection during large route streams. A single route above the 4 MiB target but within the configured guard publishes alone; a single route larger than the configured limit fails before publication with both sizes in the error. Multipart create and abort requests use a separate `multipartControlConcurrency` limit of 2 by default, with explicit positive overrides supported, and S3-compatible control requests make up to five attempts for transient provider failures. This keeps URL signing throughput independent from provider control-plane pressure.
102
155
 
156
+ Callers that need durable recovery accounting can provide `onMultipartGroupReady`. The SDK invokes it after creating a multipart upload and before streaming that group’s signed routes. Its payload contains transfer, source, destination, object-key, upload, size, part-count, and expiry identities only; it never includes credentials, request headers, or signed URLs. A rejected callback aborts the newly created upload and fails preparation closed.
157
+
103
158
  The stream ID derives from the transfer, selected flow, and immutable plan identity, and each ordered batch ID includes its route-coordinate checksum. Manual multi-destination attachment requires `delivery_index` on every route. Lifecycle mutations retry transient failures up to three times with the same request identity. A non-recoverable provider setup or route failure raises `BeamProviderTransferError`, whose `errors`, `transferCancelled`, and `multipartCleanupComplete` fields retain the original provider, cancellation, and cleanup outcomes. `waitForTransfer` subscribes to the API-key-owned, at-most-once terminal signal before its first status read and reconciles every signal through authoritative status; subscription failure degrades to the same jittered 15-to-30-second status fallback. Call `close()` when the client is no longer needed; it also closes outstanding terminal waiters.
104
159
 
105
160
  On completed provider transfers, Runtime may include an `integrity_audit_challenge` in status. Before releasing provider signing state, the TypeScript client signs exact read-only source and final-destination GET ranges and submits `transfer.integrity_audit_grants`; audit submission failures are best-effort and do not change transfer completion.
@@ -111,3 +166,15 @@ NATS requires this guard because the broker rejects messages above its configure
111
166
  ## Restart Recovery
112
167
 
113
168
  The client keeps one in-memory recovery lease per active transfer and one `runtime.hello` monitor per active shard. The lease is installed before route-stream begin, and a per-transfer lock coalesces initial streaming with replay. Multipart recovery retains the existing upload IDs and compact group state, then re-signs only the expiring route and commit controls. Each `resumeProviderTransfer` invocation re-prepares an existing transfer by explicit transfer id with a fresh request and route generation, while transport retries within that invocation reuse the same request. It then reattaches route-recovery signing from the current provider configs without creating duplicate multipart uploads. A Runtime epoch change invalidates cached auth, coalesces one `transfer.resume`, and regenerates routes under a fresh generation; a transport-only epoch change replays only when Runtime reports routes missing or expired. Provider credentials and signing inputs are retained only in memory and released on terminal status or `close()`. Low-level `attachSignedUrls` requires `routeGenerationId`, the plan fingerprint/checksum, and a `recoveryFactory`; signed URLs are never journaled to disk. Foreground cancellation, deadline expiry, and Runtime state-loss responses keep the background lease and retained multipart upload alive.
169
+
170
+ ## Hybrid endpoint signing
171
+
172
+ Credential adapters can use `signDestinationUrl` independently of `signDestinationRoute`, using the same provider configuration and multipart signer. Destination-only signing requires no synthetic source descriptor and reads no source data. Optional `contentMd5` binds the worker-computed checksum to the provider upload without a separate source checksum scan.
173
+
174
+ `signSourceReadRange` accepts `ifMatch` and `versionId` for frozen S3-compatible sources. Replay the returned headers unchanged. The ETag condition is signed and the version is included in the signed request. Unsupported non-S3 credential modes reject these conditions instead of dropping them. These helpers support the existing S3-compatible profiles used for R2, Hippius S3 and Hugging Face Storage Buckets; Hub repository tokens are a different provider mode.
175
+
176
+ Only short-lived range/upload routes go to workers. Multipart creation, verification, completion and abort remain in the existing control path. A worker upload response alone is not final-object completion.
177
+
178
+ `listMultipartParts`, `completeMultipartUpload`, and `inspectDestinationObject` reuse the same provider client and return metadata only. ListParts walks every page; completion submits the selected provider-verified parts. Adapters must durably retain the upload and verified manifest before completion so restart recovery cannot infer delivery from size alone.
179
+
180
+ Manual releases from `dev` require a committed `-dev.N` version and publish only to npm tag `dev`; the stable tag is unchanged.
package/dist/client.d.ts CHANGED
@@ -34,6 +34,7 @@ type TransferPrepareInput = {
34
34
  signedUrlFlow?: SignedUrlFlow;
35
35
  idempotencyKey?: string;
36
36
  routeGenerationId?: string;
37
+ chunkSize?: number;
37
38
  };
38
39
  export declare class BeamClient {
39
40
  readonly apiKey: string;
@@ -46,6 +47,7 @@ export declare class BeamClient {
46
47
  private readonly recoverySigners;
47
48
  private readonly integrityAuditSigners;
48
49
  private readonly integrityAuditSubmissions;
50
+ private readonly huggingFaceUploads;
49
51
  constructor(options?: BeamClientOptions);
50
52
  close(): Promise<void>;
51
53
  openTransferTerminalWaiter(transferId: string): Promise<TransferTerminalSignalWaiter>;
@@ -62,6 +64,7 @@ export declare class BeamClient {
62
64
  testMode?: boolean;
63
65
  urlsExpiresAt?: string;
64
66
  signedUrlFlow?: SignedUrlFlow;
67
+ chunkSize?: number;
65
68
  }): Promise<TransferPlanResponse>;
66
69
  prepareTransfer(input: TransferPrepareInput): Promise<TransferPrepareResponse>;
67
70
  private prepareTransferWithRequestKey;
@@ -81,6 +84,21 @@ export declare class BeamClient {
81
84
  autoDistribute?: boolean;
82
85
  }): Promise<AttachSignedUrlsResponse>;
83
86
  prepareProviderTransfer(input: ProviderTransferCreateInput): Promise<TransferPrepareResponse>;
87
+ /**
88
+ * Negotiate every Hugging Face destination before the plan exists.
89
+ *
90
+ * The Hub will not issue upload URLs without the object's sha256, and it chooses the part
91
+ * size itself, so this runs first and the plan is then requested at the Hub's chunk size.
92
+ */
93
+ private planHuggingFaceUploads;
94
+ /** Fail before any byte moves if BeamCore did not adopt the Hub's part layout. */
95
+ private assertHuggingFacePlan;
96
+ /**
97
+ * Close every Hugging Face upload for a transfer: complete the LFS multipart, verify it, and
98
+ * commit the blob so the file appears in the repo. Runs only after the transfer is complete,
99
+ * so it never sits in the transfer's progression path.
100
+ */
101
+ finalizeHuggingFaceUploads(transferId: string): Promise<void>;
84
102
  private signProviderRoute;
85
103
  private startProviderRouteRecoverySigner;
86
104
  private recoveryMultipartUploadState;
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
- import { abortMultipartUpload, createMultipartUpload, expiresAtIso, prepareProviderDestination, prepareProviderSource, signAbortMultipartUpload, signCompleteMultipartUpload, signDestinationReadRange, signDestinationRoute, signFinalObjectHead, signListMultipartUpload, signSourceReadRange } from "./provider-signing.js";
1
+ import { abortMultipartUpload, createMultipartUpload, expiresAtIso, prepareProviderDestination, prepareProviderSource, signAbortMultipartUpload, signCompleteMultipartUpload, signDestinationReadRange, signDestinationRoute, signFinalObjectHead, signListMultipartUpload, signSourceReadRange, isHuggingFaceProvider } from "./provider-signing.js";
2
+ import { describe as describeHuggingFace, hashSourceStream, huggingFaceCommit, huggingFaceCompleteLfsUpload, huggingFaceLfsBatch, huggingFacePreupload, huggingFaceVerifyLfsUpload, readSourceSample } from "./huggingface.js";
2
3
  import { BeamTransferControl, BEAM_DEFAULT_NATS_URL, compactSignedRoutes, isRecoverableRouteStreamError } from "./nats-control.js";
3
4
  export class BeamRouteRecoveryPendingError extends Error {
4
5
  transferId;
@@ -55,6 +56,7 @@ export class BeamClient {
55
56
  recoverySigners = new Map();
56
57
  integrityAuditSigners = new Map();
57
58
  integrityAuditSubmissions = new Map();
59
+ huggingFaceUploads = new Map();
58
60
  constructor(options = {}) {
59
61
  if (!options.apiKey?.trim()) {
60
62
  throw new Error("apiKey is required.");
@@ -203,6 +205,7 @@ export class BeamClient {
203
205
  destinations: input.destinations,
204
206
  name: input.name,
205
207
  test_mode: input.testMode || undefined,
208
+ chunk_size: input.chunkSize,
206
209
  urls_expires_at: input.urlsExpiresAt,
207
210
  signed_url_flow: input.signedUrlFlow ?? "signed_url"
208
211
  }));
@@ -225,6 +228,7 @@ export class BeamClient {
225
228
  destinations: input.destinations,
226
229
  name: input.name,
227
230
  test_mode: input.testMode || undefined,
231
+ chunk_size: input.chunkSize,
228
232
  urls_expires_at: input.urlsExpiresAt,
229
233
  signed_url_flow: input.signedUrlFlow ?? "signed_url"
230
234
  }), {
@@ -318,6 +322,7 @@ export class BeamClient {
318
322
  destinations: input.destinations.map((destination) => ({ ...destination })),
319
323
  onBeforeTransferPrepare: undefined,
320
324
  onPrepared: undefined,
325
+ onMultipartGroupReady: undefined,
321
326
  throwIfCancelled: undefined
322
327
  };
323
328
  const preparedDestinations = retainedRecoveryInput.destinations.map((destination, index) => prepareProviderDestination(destination, { index }));
@@ -332,12 +337,20 @@ export class BeamClient {
332
337
  await input.throwIfCancelled?.();
333
338
  const preparedSources = await Promise.all(retainedRecoveryInput.sources.map((source, index) => prepareProviderSource(source, { index, expiresIn, fetchImpl: this.fetchImpl })));
334
339
  await input.throwIfCancelled?.();
340
+ const huggingFace = await this.planHuggingFaceUploads({
341
+ sources: retainedRecoveryInput.sources,
342
+ preparedSources,
343
+ destinations: retainedRecoveryInput.destinations,
344
+ preparedDestinations
345
+ });
346
+ await input.throwIfCancelled?.();
335
347
  await input.onBeforeTransferPrepare?.();
336
348
  const prepared = await this.prepareTransfer({
337
349
  sources: preparedSources,
338
350
  destinations: preparedDestinations,
339
351
  name: input.name,
340
352
  testMode: input.testMode,
353
+ chunkSize: huggingFace.chunkSize ?? input.chunkSize,
341
354
  signedUrlFlow: requestedSignedUrlFlow,
342
355
  idempotencyKey: input.idempotencyKey,
343
356
  routeGenerationId: input.routeGenerationId
@@ -345,6 +358,11 @@ export class BeamClient {
345
358
  if (!prepared.success) {
346
359
  return prepared;
347
360
  }
361
+ if (huggingFace.states.length) {
362
+ this.assertHuggingFacePlan(prepared, huggingFace.states);
363
+ this.huggingFaceUploads.set(prepared.transfer_id, huggingFace.states);
364
+ }
365
+ const huggingFaceByDestination = new Map(huggingFace.states.map((state) => [state.destinationId, state]));
348
366
  const sourcesById = new Map();
349
367
  preparedSources.forEach((preparedSource, index) => {
350
368
  const source = retainedRecoveryInput.sources[index];
@@ -409,6 +427,7 @@ export class BeamClient {
409
427
  onGroupReady: async (state) => {
410
428
  recoveryMultipartUploads.set(state.manifest.multipart_group_id, state);
411
429
  validateMultipartGroupManifest([state.manifest], prepared.transfer_id);
430
+ await input.onMultipartGroupReady?.(multipartGroupIdentity(prepared.transfer_id, state.manifest));
412
431
  await routeStream.addManifestGroups([state.manifest]);
413
432
  multipartGroupWaiters.get(state.manifest.multipart_group_id)?.resolve(state);
414
433
  },
@@ -444,10 +463,10 @@ export class BeamClient {
444
463
  if (!finalObjectKey)
445
464
  throw new Error("destination signing target is missing object_key");
446
465
  const multipartGroupId = multipartGroupStateKey(prepared.transfer_id, target.destination_id, chunk.source_id, finalObjectKey);
447
- const upload = isHippiusDestination(destination)
466
+ const upload = isDirectPutDestination(destination)
448
467
  ? undefined
449
468
  : await multipartGroupWaiters.get(multipartGroupId)?.promise;
450
- if (!isHippiusDestination(destination) && !upload)
469
+ if (!isDirectPutDestination(destination) && !upload)
451
470
  throw new Error(`multipart group manifest is missing for ${chunk.source_id}:${target.destination_id}`);
452
471
  return this.signProviderRoute({
453
472
  chunk,
@@ -456,6 +475,7 @@ export class BeamClient {
456
475
  destination,
457
476
  expiresIn,
458
477
  ...(upload ? { upload } : {}),
478
+ huggingFaceUpload: huggingFaceByDestination.get(target.destination_id),
459
479
  transferId: prepared.transfer_id,
460
480
  finalObjectKey,
461
481
  signedUrlFlow: requestedSignedUrlFlow,
@@ -546,7 +566,186 @@ export class BeamClient {
546
566
  }
547
567
  return prepared;
548
568
  }
569
+ /**
570
+ * Negotiate every Hugging Face destination before the plan exists.
571
+ *
572
+ * The Hub will not issue upload URLs without the object's sha256, and it chooses the part
573
+ * size itself, so this runs first and the plan is then requested at the Hub's chunk size.
574
+ */
575
+ async planHuggingFaceUploads(input) {
576
+ const targets = input.destinations
577
+ .map((destination, index) => ({ destination, index }))
578
+ .filter((entry) => isHuggingFaceProvider(entry.destination));
579
+ if (!targets.length)
580
+ return { states: [] };
581
+ if (input.preparedSources.length !== 1) {
582
+ throw new Error("a huggingface destination requires exactly one source: the Hub dictates the part size "
583
+ + `and the plan carries a single chunk size, but ${input.preparedSources.length} sources were given`);
584
+ }
585
+ const preparedSource = input.preparedSources[0];
586
+ const sourceConfig = input.sources[0];
587
+ // For an LFS source the Hub already published the sha256 as the linked ETag.
588
+ const publishedSha256 = isHuggingFaceProvider(sourceConfig)
589
+ ? typeof preparedSource.metadata?.sha256 === "string" ? preparedSource.metadata.sha256 : undefined
590
+ : undefined;
591
+ const states = [];
592
+ let chunkSize;
593
+ for (const { destination, index } of targets) {
594
+ const preparedDestination = input.preparedDestinations[index];
595
+ if ((destination.repo_type ?? "model") === "bucket") {
596
+ throw new Error(`${destination.repo_id} is a Hugging Face bucket, which Beam cannot write to. Buckets `
597
+ + "expose no LFS batch endpoint; their only upload path is the Hub's Xet CAS client, "
598
+ + "which cannot be expressed as presigned URLs for Beam's workers. Buckets do work as "
599
+ + "a transfer source. Use `hf sync` to write to a bucket.");
600
+ }
601
+ const path = huggingFaceTargetPath(destination, preparedSource.filename);
602
+ const config = { ...destination, path };
603
+ let oid = publishedSha256;
604
+ if (!oid) {
605
+ if (!destination.allow_source_rehash) {
606
+ throw new Error(`uploading to ${describeHuggingFace(config)} needs the source sha256, which the Hub requires `
607
+ + "before it issues upload URLs. The SDK must read the source once to compute it; set "
608
+ + "allow_source_rehash: true to opt in.");
609
+ }
610
+ const hashed = await hashSourceStream(this.fetchImpl, preparedSource.url);
611
+ oid = hashed.sha256;
612
+ }
613
+ const sample = await readSourceSample(this.fetchImpl, preparedSource.url);
614
+ const preupload = await huggingFacePreupload(this.fetchImpl, config, {
615
+ size: preparedSource.size,
616
+ sample
617
+ });
618
+ if (preupload.shouldIgnore) {
619
+ throw new Error(`${describeHuggingFace(config)} is excluded by the repo's .gitignore`);
620
+ }
621
+ if (preupload.uploadMode !== "lfs") {
622
+ throw new Error(`${describeHuggingFace(config)} would be committed as a regular git blob, not an LFS blob. `
623
+ + "Beam uploads through the LFS protocol only; add the path to .gitattributes as LFS.");
624
+ }
625
+ const plan = await huggingFaceLfsBatch(this.fetchImpl, config, {
626
+ oid,
627
+ size: preparedSource.size
628
+ });
629
+ if (plan.upload?.chunkSize !== undefined) {
630
+ if (chunkSize !== undefined && chunkSize !== plan.upload.chunkSize) {
631
+ throw new Error(`huggingface destinations disagree on part size (${chunkSize} vs ${plan.upload.chunkSize}); `
632
+ + "the plan carries a single chunk size");
633
+ }
634
+ chunkSize = plan.upload.chunkSize;
635
+ }
636
+ states.push({
637
+ destination: config,
638
+ destinationId: preparedDestination.destination_id,
639
+ sourceId: preparedSource.source_id,
640
+ oid,
641
+ size: preparedSource.size,
642
+ chunkSize: plan.upload?.chunkSize,
643
+ partUrls: plan.upload?.partUrls ?? [],
644
+ uploadHref: plan.upload?.href,
645
+ verifyHref: plan.verifyHref,
646
+ // Runs alongside the transfer; the ETags are only needed at completion time.
647
+ partEtags: plan.upload?.chunkSize === undefined
648
+ ? Promise.resolve([])
649
+ : hashSourceStream(this.fetchImpl, preparedSource.url, {
650
+ partSize: plan.upload.chunkSize,
651
+ sha256: false
652
+ }).then((hashed) => hashed.partEtags)
653
+ });
654
+ }
655
+ return { states, chunkSize };
656
+ }
657
+ /** Fail before any byte moves if BeamCore did not adopt the Hub's part layout. */
658
+ assertHuggingFacePlan(prepared, states) {
659
+ for (const state of states) {
660
+ const planDestination = prepared.plan_descriptor.destinations.find((candidate) => candidate.destination_id === state.destinationId);
661
+ const planSource = prepared.plan_descriptor.sources.find((candidate) => candidate.source_id === state.sourceId);
662
+ if (!planDestination || !planSource) {
663
+ throw new Error(`BeamCore plan is missing the huggingface coordinate ${state.sourceId}:${state.destinationId}`);
664
+ }
665
+ const finalObjectKey = planDestination.final_object_keys[state.sourceId];
666
+ if (finalObjectKey !== state.destination.path) {
667
+ throw new Error(`BeamCore planned ${finalObjectKey} but the Hub upload was negotiated for ${state.destination.path}`);
668
+ }
669
+ if (state.chunkSize === undefined) {
670
+ if (planSource.chunk_count !== 1) {
671
+ throw new Error(`${describeHuggingFace(state.destination)} was issued a single-part upload, but the plan has `
672
+ + `${planSource.chunk_count} chunks`);
673
+ }
674
+ continue;
675
+ }
676
+ if (prepared.plan_descriptor.chunk_size !== state.chunkSize) {
677
+ throw new Error(`the Hub requires ${state.chunkSize}-byte parts for ${describeHuggingFace(state.destination)}, `
678
+ + `but BeamCore planned ${prepared.plan_descriptor.chunk_size}-byte chunks`);
679
+ }
680
+ if (planSource.chunk_count !== state.partUrls.length) {
681
+ throw new Error(`the Hub issued ${state.partUrls.length} part URLs for ${describeHuggingFace(state.destination)}, `
682
+ + `but the plan has ${planSource.chunk_count} chunks`);
683
+ }
684
+ }
685
+ }
686
+ /**
687
+ * Close every Hugging Face upload for a transfer: complete the LFS multipart, verify it, and
688
+ * commit the blob so the file appears in the repo. Runs only after the transfer is complete,
689
+ * so it never sits in the transfer's progression path.
690
+ */
691
+ async finalizeHuggingFaceUploads(transferId) {
692
+ const states = this.huggingFaceUploads.get(transferId);
693
+ if (!states?.length)
694
+ return;
695
+ this.huggingFaceUploads.delete(transferId);
696
+ for (const state of states) {
697
+ if (state.uploadHref && state.chunkSize !== undefined) {
698
+ const etags = await state.partEtags;
699
+ if (etags.length !== state.partUrls.length) {
700
+ throw new Error(`computed ${etags.length} part ETags for ${describeHuggingFace(state.destination)}, `
701
+ + `expected ${state.partUrls.length}`);
702
+ }
703
+ await huggingFaceCompleteLfsUpload(this.fetchImpl, state.destination, {
704
+ href: state.uploadHref,
705
+ oid: state.oid,
706
+ etags
707
+ });
708
+ }
709
+ if (state.verifyHref) {
710
+ await huggingFaceVerifyLfsUpload(this.fetchImpl, state.destination, {
711
+ href: state.verifyHref,
712
+ oid: state.oid,
713
+ size: state.size
714
+ });
715
+ }
716
+ await huggingFaceCommit(this.fetchImpl, state.destination, {
717
+ oid: state.oid,
718
+ size: state.size
719
+ });
720
+ }
721
+ }
549
722
  async signProviderRoute(input) {
723
+ if (isHuggingFaceProvider(input.destination)) {
724
+ const state = input.huggingFaceUpload;
725
+ if (!state)
726
+ throw new Error(`huggingface upload state is missing for ${input.target.destination_id}`);
727
+ // The Hub presigns its own part targets; chunk N carries the URL for part N + 1.
728
+ const destUrl = state.chunkSize === undefined
729
+ ? state.uploadHref
730
+ : state.partUrls[input.chunk.source_chunk_index];
731
+ if (!destUrl) {
732
+ throw new Error(`the Hub issued no upload URL for chunk ${input.chunk.source_chunk_index} of `
733
+ + describeHuggingFace(state.destination));
734
+ }
735
+ return signDestinationRoute({
736
+ chunk: input.chunk,
737
+ target: {
738
+ ...input.target,
739
+ object_key: input.finalObjectKey,
740
+ metadata: directPutRouteMetadata(input.target.metadata)
741
+ },
742
+ source: input.source,
743
+ destination: input.destination,
744
+ expiresIn: input.expiresIn,
745
+ destUrl,
746
+ fetchImpl: this.fetchImpl
747
+ });
748
+ }
550
749
  if (isHippiusDestination(input.destination)) {
551
750
  return signDestinationRoute({
552
751
  chunk: input.chunk,
@@ -611,7 +810,7 @@ export class BeamClient {
611
810
  const destination = input.destinationsById.get(requested.destination_id);
612
811
  if (!source || !destination)
613
812
  throw new Error("route recovery provider configuration is unavailable");
614
- const upload = isHippiusDestination(destination)
813
+ const upload = isDirectPutDestination(destination)
615
814
  ? undefined
616
815
  : await this.recoveryMultipartUploadState({
617
816
  prepared: input.prepared,
@@ -643,6 +842,9 @@ export class BeamClient {
643
842
  destination,
644
843
  expiresIn: input.expiresIn,
645
844
  ...(upload ? { upload } : {}),
845
+ huggingFaceUpload: this.huggingFaceUploads
846
+ .get(input.prepared.transfer_id)
847
+ ?.find((state) => state.destinationId === requested.destination_id),
646
848
  transferId: input.prepared.transfer_id,
647
849
  finalObjectKey: requested.final_object_key,
648
850
  signedUrlFlow: "signed_url",
@@ -845,6 +1047,8 @@ export class BeamClient {
845
1047
  while (true) {
846
1048
  const status = await this.transferStatus(transferId);
847
1049
  if (status.status === "completed") {
1050
+ // The parts have landed; publish them as a Hub commit before reporting success.
1051
+ await this.finalizeHuggingFaceUploads(transferId);
848
1052
  return status;
849
1053
  }
850
1054
  if (status.status === "failed") {
@@ -1263,6 +1467,19 @@ function safeErrorCode(error) {
1263
1467
  .find((value) => Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599);
1264
1468
  return status === undefined ? code : `${code}:status=${status}`;
1265
1469
  }
1470
+ function multipartGroupIdentity(transferId, manifest) {
1471
+ return {
1472
+ transferId,
1473
+ multipartGroupId: manifest.multipart_group_id,
1474
+ sourceId: manifest.source_id,
1475
+ destinationId: manifest.destination_id,
1476
+ objectKey: manifest.final_object_key,
1477
+ uploadId: manifest.upload_id,
1478
+ expectedObjectSize: manifest.expected_object_size,
1479
+ expectedPartCount: manifest.expected_part_count,
1480
+ expiresAt: manifest.urls_expires_at
1481
+ };
1482
+ }
1266
1483
  function createDeferred() {
1267
1484
  let resolve;
1268
1485
  let reject;
@@ -1277,7 +1494,7 @@ function createMultipartGroupWaiters(prepared, destinationsById) {
1277
1494
  for (const source of prepared.plan_descriptor.sources) {
1278
1495
  for (const destination of prepared.plan_descriptor.destinations) {
1279
1496
  const destinationConfig = destinationsById.get(destination.destination_id);
1280
- if (!destinationConfig || isHippiusDestination(destinationConfig))
1497
+ if (!destinationConfig || isDirectPutDestination(destinationConfig))
1281
1498
  continue;
1282
1499
  const finalObjectKey = destination.final_object_keys[source.source_id];
1283
1500
  if (!finalObjectKey)
@@ -1297,7 +1514,7 @@ async function createMultipartGroupManifest(input) {
1297
1514
  if (!destinationConfig) {
1298
1515
  throw new Error(`BeamCore returned unknown destination_id: ${destination.destination_id}`);
1299
1516
  }
1300
- if (isHippiusDestination(destinationConfig)) {
1517
+ if (isDirectPutDestination(destinationConfig)) {
1301
1518
  return { ok: true, state: null };
1302
1519
  }
1303
1520
  const finalObjectKey = destination.final_object_keys[source.source_id];
@@ -1542,6 +1759,14 @@ const PART_ROUTE_METADATA_KEYS = new Set([
1542
1759
  function partRouteMetadata(metadata) {
1543
1760
  return Object.fromEntries(Object.entries(metadata ?? {}).filter(([key]) => PART_ROUTE_METADATA_KEYS.has(key)));
1544
1761
  }
1762
+ /**
1763
+ * Route metadata for a destination BeamCore does not treat as an S3 multipart target. It
1764
+ * rejects `part_number` there as an unexpected multipart signal, so drop it.
1765
+ */
1766
+ function directPutRouteMetadata(metadata) {
1767
+ const { part_number: _partNumber, ...rest } = partRouteMetadata(metadata);
1768
+ return rest;
1769
+ }
1545
1770
  function randomUuid() {
1546
1771
  const cryptoApi = globalThis.crypto;
1547
1772
  if (cryptoApi?.randomUUID)
@@ -1603,6 +1828,23 @@ async function abortCreatedUploads(uploads, concurrency) {
1603
1828
  function isHippiusDestination(destination) {
1604
1829
  return destination.provider === "hippius" && "api_token" in destination;
1605
1830
  }
1831
+ /**
1832
+ * Destinations that take a plain PUT per chunk instead of an S3 multipart upload. BeamCore
1833
+ * rejects multipart route metadata for these, so they never get a multipart group manifest.
1834
+ */
1835
+ function isDirectPutDestination(destination) {
1836
+ return isHippiusDestination(destination) || isHuggingFaceProvider(destination);
1837
+ }
1838
+ /** Resolve the path a Hugging Face upload commits to, expanding a trailing-slash prefix. */
1839
+ function huggingFaceTargetPath(destination, sourceFilename) {
1840
+ const path = destination.path.replace(/^\/+/, "");
1841
+ if (!path.endsWith("/"))
1842
+ return path;
1843
+ if (!sourceFilename) {
1844
+ throw new Error(`huggingface path ${destination.path} is a folder and the source has no filename`);
1845
+ }
1846
+ return `${path}${sourceFilename}`;
1847
+ }
1606
1848
  function sleep(ms) {
1607
1849
  return new Promise((resolve) => setTimeout(resolve, ms));
1608
1850
  }
@@ -0,0 +1,101 @@
1
+ import type { HuggingFaceProviderConfig, HuggingFaceRepoType } from "./models.js";
2
+ export declare const HUGGINGFACE_DEFAULT_ENDPOINT = "https://huggingface.co";
3
+ export declare const HUGGINGFACE_DEFAULT_REVISION = "main";
4
+ export declare const HUGGINGFACE_DEFAULT_REPO_TYPE: HuggingFaceRepoType;
5
+ export interface HuggingFaceFileMetadata {
6
+ /** Credential-free presigned CDN URL the workers read from. */
7
+ url: string;
8
+ size: number;
9
+ /** sha256 for an LFS blob, git sha1 otherwise. */
10
+ etag?: string;
11
+ commitHash?: string;
12
+ }
13
+ export interface HuggingFaceLfsUploadPlan {
14
+ oid: string;
15
+ size: number;
16
+ /** Absent when the Hub already stores this content and no upload is needed. */
17
+ upload?: {
18
+ /** Completion endpoint for multipart, or the single-part PUT target. */
19
+ href: string;
20
+ /** Part size the Hub requires. Absent for a single-part upload. */
21
+ chunkSize?: number;
22
+ /** Presigned part PUT URLs, ordered by part number. Empty for a single-part upload. */
23
+ partUrls: string[];
24
+ };
25
+ verifyHref?: string;
26
+ }
27
+ export declare function huggingFaceEndpoint(config: HuggingFaceProviderConfig): string;
28
+ export declare function huggingFaceRepoType(config: HuggingFaceProviderConfig): HuggingFaceRepoType;
29
+ export declare function huggingFaceRevision(config: HuggingFaceProviderConfig): string;
30
+ /**
31
+ * `{endpoint}/{prefix}{repo_id}/resolve/{revision}/{path}`, as built by `hf_hub_url`.
32
+ *
33
+ * Buckets are unversioned and take no revision segment, and the Hub escapes their whole key
34
+ * as one component — see `HfApi.get_bucket_file_metadata`.
35
+ */
36
+ export declare function huggingFaceResolveUrl(config: HuggingFaceProviderConfig): string;
37
+ /** `{endpoint}/api/{repo_type}s/{repo_id}`. */
38
+ export declare function huggingFaceApiBase(config: HuggingFaceProviderConfig): string;
39
+ /** `{endpoint}/{prefix}{repo_id}.git/info/lfs/objects/batch`. */
40
+ export declare function huggingFaceLfsBatchUrl(config: HuggingFaceProviderConfig): string;
41
+ /**
42
+ * HEAD the resolve URL and require the Hub to redirect to its CDN. The redirect target is
43
+ * presigned and carries no credential, so it is the only form of this URL that may be handed
44
+ * to BeamCore and the workers.
45
+ */
46
+ export declare function huggingFaceFileMetadata(fetchImpl: typeof fetch, config: HuggingFaceProviderConfig): Promise<HuggingFaceFileMetadata>;
47
+ /**
48
+ * Ask the Hub whether a path is stored as an LFS blob or as a regular git blob.
49
+ * `sample` is the base64 of the first 512 bytes, exactly as `_fetch_upload_modes` sends it.
50
+ */
51
+ export declare function huggingFacePreupload(fetchImpl: typeof fetch, config: HuggingFaceProviderConfig, input: {
52
+ size: number;
53
+ sample: string;
54
+ }): Promise<{
55
+ uploadMode: "lfs" | "regular";
56
+ shouldIgnore: boolean;
57
+ oid?: string;
58
+ }>;
59
+ /**
60
+ * Request upload instructions for one object. The Hub answers with either a single-part PUT
61
+ * target or a completion endpoint plus one presigned PUT URL per part.
62
+ */
63
+ export declare function huggingFaceLfsBatch(fetchImpl: typeof fetch, config: HuggingFaceProviderConfig, input: {
64
+ oid: string;
65
+ size: number;
66
+ }): Promise<HuggingFaceLfsUploadPlan>;
67
+ /** Close a multipart LFS upload: `{oid, parts:[{partNumber, etag}]}` to the completion href. */
68
+ export declare function huggingFaceCompleteLfsUpload(fetchImpl: typeof fetch, config: HuggingFaceProviderConfig, input: {
69
+ href: string;
70
+ oid: string;
71
+ etags: string[];
72
+ }): Promise<void>;
73
+ /** Optional server-side check that the object landed intact. */
74
+ export declare function huggingFaceVerifyLfsUpload(fetchImpl: typeof fetch, config: HuggingFaceProviderConfig, input: {
75
+ href: string;
76
+ oid: string;
77
+ size: number;
78
+ }): Promise<void>;
79
+ /** Publish the uploaded blob as a commit. The body is NDJSON: a header line then one file line. */
80
+ export declare function huggingFaceCommit(fetchImpl: typeof fetch, config: HuggingFaceProviderConfig, input: {
81
+ oid: string;
82
+ size: number;
83
+ }): Promise<{
84
+ commitOid?: string;
85
+ pullRequestUrl?: string;
86
+ }>;
87
+ /** Read the first `length` bytes of a URL and return them base64-encoded, for `preupload`. */
88
+ export declare function readSourceSample(fetchImpl: typeof fetch, url: string, length?: number): Promise<string>;
89
+ /**
90
+ * Stream a URL once and return the sha256 of the whole body plus, when `partSize` is given,
91
+ * the hex MD5 of every part. The Hub will not issue upload URLs without the sha256, and the
92
+ * per-part MD5 is the ETag the completion payload has to quote.
93
+ */
94
+ export declare function hashSourceStream(fetchImpl: typeof fetch, url: string, options?: {
95
+ partSize?: number;
96
+ sha256?: boolean;
97
+ }): Promise<{
98
+ sha256?: string;
99
+ partEtags: string[];
100
+ }>;
101
+ export declare function describe(config: HuggingFaceProviderConfig): string;