@spheredata/sdk 0.0.1 → 0.0.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sphere-client.js +128 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spheredata/sdk",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SPHERE client SDK — talks to the SPHERE backend (auth, metered agent, account).",
6
6
  "keywords": ["sphere", "spheredata", "sdk", "ai", "agent", "metering", "wallet", "synthetic-data"],
package/sphere-client.js CHANGED
@@ -212,6 +212,31 @@ export function createSphere(opts = {}) {
212
212
  return `${base}?redirect_to=${encodeURIComponent(redirectTo)}`;
213
213
  }
214
214
 
215
+ // Complete a hosted OAuth flow: when the backend redirects back to a surface's
216
+ // `redirect_to`, it appends the tokens as query params
217
+ // (?access_token=…&refresh_token=…&expires_in=…). Parse them and persist a
218
+ // session, exactly as signIn would. Returns true if a session was established,
219
+ // false if no oauth tokens were present (so callers can no-op on a normal load).
220
+ // `input` may be a query string ("?a=b" or "a=b"), a URLSearchParams, or omitted
221
+ // to read globalThis.location.search. The user profile isn't in the redirect, so
222
+ // the bundle's user is null until the next getAccount().
223
+ function completeOAuth(input) {
224
+ const params =
225
+ input instanceof URLSearchParams ? input
226
+ : typeof input === "string" ? new URLSearchParams(input.replace(/^\?/, ""))
227
+ : (typeof globalThis !== "undefined" && globalThis.location)
228
+ ? new URLSearchParams(globalThis.location.search)
229
+ : new URLSearchParams();
230
+ const access_token = params.get("access_token");
231
+ if (!access_token) return false;
232
+ saveBundle(bundleFromAuthResponse({
233
+ access_token,
234
+ refresh_token: params.get("refresh_token"),
235
+ expires_in: params.get("expires_in"),
236
+ }));
237
+ return true;
238
+ }
239
+
215
240
  // --- AI -------------------------------------------------------------------
216
241
  // Ask the model through SPHERE. Metered to the user's wallet under this portal.
217
242
  async function ask(messages, { model, max_tokens } = {}) {
@@ -316,12 +341,114 @@ export function createSphere(opts = {}) {
316
341
  return data; // { ok:true, charged, op, bytes, cost_usd, balance_usd }
317
342
  }
318
343
 
344
+ // --- synthetic-dataset upload (direct-to-GCS via AgentDrive) --------------
345
+ // NODE / ELECTRON ONLY (CLI + desktop) — not the browser. Per file:
346
+ // 1. begin → backend mints a GCS resumable-session URL (the AgentDrive
347
+ // credential stays server-side; the client never sees it),
348
+ // 2. PUT bytes DIRECTLY to GCS (bytes never transit the SPHERE backend),
349
+ // 3. commit → backend finalizes the artifact.
350
+ // Large files (e.g. 1 GiB scrna.csv) stream from disk via fs.openAsBlob — never
351
+ // loaded whole into memory. fs/path are imported lazily so the SDK still imports
352
+ // cleanly in the browser (where upload isn't offered).
353
+ const CONTENT_TYPES = {
354
+ csv: "text/csv", tsv: "text/tab-separated-values",
355
+ parquet: "application/vnd.apache.parquet", json: "application/json",
356
+ txt: "text/plain", html: "text/html",
357
+ };
358
+ const guessType = (name) =>
359
+ CONTENT_TYPES[String(name).toLowerCase().split(".").pop()] || "application/octet-stream";
360
+
361
+ // Upload one file. `file` is { name?, path } (a local file, streamed) OR
362
+ // { name, body, size, contentType? } (a pre-read Blob/Buffer + byte length).
363
+ // Returns the AgentDrive artifact { id, path, url, size_bytes, hash, ... }.
364
+ async function uploadFile(dataset, file, defaultContentType) {
365
+ if (!dataset) throw new Error("uploadFile: dataset name required");
366
+ let { name, body, size } = file || {};
367
+ if (file?.path) {
368
+ // openAsBlob lives on node:fs (not node:fs/promises) — gives a streaming
369
+ // Blob backed by the file, so large files are never read into memory.
370
+ const { openAsBlob } = await import("node:fs");
371
+ const blob = await openAsBlob(file.path);
372
+ body = blob;
373
+ size = blob.size;
374
+ if (!name) name = (await import("node:path")).basename(file.path);
375
+ }
376
+ if (!name) throw new Error("uploadFile: file.name or file.path required");
377
+ if (!(size > 0)) throw new Error(`uploadFile: "${name}" has no readable bytes`);
378
+ const contentType = file?.contentType || defaultContentType || guessType(name);
379
+
380
+ const begin = await authedFetch(`${fnBase}/upload-begin${q}`, {
381
+ method: "POST",
382
+ headers: { "Content-Type": "application/json" },
383
+ body: JSON.stringify({ dataset, filename: name, content_type: contentType, size_bytes: size }),
384
+ });
385
+ const b = await begin.json().catch(() => ({}));
386
+ if (!begin.ok) throw new Error(b?.error || `upload-begin failed: ${begin.status}`);
387
+
388
+ // Direct PUT to the GCS session URL — NO Authorization header (the URL is the
389
+ // credential). `duplex` is required by undici for a streamed body; ignored for Blobs.
390
+ const put = await doFetch(b.upload_url, {
391
+ method: "PUT",
392
+ headers: { "Content-Type": contentType, "Content-Range": `bytes 0-${size - 1}/${size}` },
393
+ body,
394
+ duplex: "half",
395
+ });
396
+ if (put.status < 200 || put.status >= 300) {
397
+ throw new Error(`GCS upload failed for "${name}": ${put.status}`);
398
+ }
399
+
400
+ const commit = await authedFetch(`${fnBase}/upload-commit${q}`, {
401
+ method: "POST",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify({ upload_id: b.upload_id }),
404
+ });
405
+ const c = await commit.json().catch(() => ({}));
406
+ if (!commit.ok) throw new Error(c?.error || `upload-commit failed: ${commit.status}`);
407
+ return c.artifact;
408
+ }
409
+
410
+ // Upload a whole synthetic dataset (its files). `files` is an array of the per-file
411
+ // shapes above. Files upload sequentially; `onProgress({done,total,name,artifact})`
412
+ // fires after each. Returns { dataset, artifacts }.
413
+ async function uploadDataset(dataset, files, { onProgress, contentType } = {}) {
414
+ if (!dataset) throw new Error("uploadDataset: dataset name required");
415
+ if (!Array.isArray(files) || files.length === 0) {
416
+ throw new Error("uploadDataset: a non-empty files[] is required");
417
+ }
418
+ const artifacts = [];
419
+ for (let i = 0; i < files.length; i++) {
420
+ const art = await uploadFile(dataset, files[i], contentType);
421
+ artifacts.push(art);
422
+ if (onProgress) onProgress({ done: i + 1, total: files.length, name: files[i].name, artifact: art });
423
+ }
424
+ return { dataset, artifacts };
425
+ }
426
+
427
+ // List the datasets uploaded under this portal (shared catalog). Returns
428
+ // [{ dataset, file_count, total_bytes, updated_at, files:[{name,path,url,...}] }].
429
+ async function listDatasets() {
430
+ const r = await authedFetch(`${fnBase}/datasets${q}`, {});
431
+ const data = await r.json().catch(() => ({}));
432
+ if (!r.ok) throw new Error(data?.error || `datasets failed: ${r.status}`);
433
+ return data.datasets || [];
434
+ }
435
+
436
+ // List datasets across ALL portals/labs — the cross-lab marketplace. Each item
437
+ // carries its source `portal` plus the same shape as listDatasets().
438
+ async function listMarketplace() {
439
+ const r = await authedFetch(`${fnBase}/marketplace`, {});
440
+ const data = await r.json().catch(() => ({}));
441
+ if (!r.ok) throw new Error(data?.error || `marketplace failed: ${r.status}`);
442
+ return data.datasets || [];
443
+ }
444
+
319
445
  return {
320
446
  portal, appId, apiBase,
321
- signUp, signIn, signOut, oauthUrl,
447
+ signUp, signIn, signOut, oauthUrl, completeOAuth,
322
448
  ask, streamAgent,
323
449
  getAccount, getBalance,
324
450
  listCredits, buyCredits, redeem, charge,
451
+ uploadFile, uploadDataset, listDatasets, listMarketplace,
325
452
  // NOTE: reflects token PRESENCE, not validity — a present-but-expired token
326
453
  // returns true here and is refreshed lazily on the next call.
327
454
  isSignedIn: () => !!loadBundle()?.access_token,