@spheredata/sdk 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Zhang, Zihuai He
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @spheredata/sdk
2
+
3
+ The SPHERE client SDK — the single integration surface for the SPHERE metered AI
4
+ backend. One client for the web portal, the Electron desktop app, and the CLI;
5
+ surfaces differ only in *where the token is stored* and *which tools run locally*.
6
+
7
+ The product/brand is **SPHERE** — this package is published under the
8
+ `@spheredata` npm scope.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm i @spheredata/sdk
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```js
19
+ import { createSphere } from "@spheredata/sdk";
20
+
21
+ const sphere = createSphere({ portal: "adrc" }); // web: sessionStorage by default
22
+
23
+ await sphere.signIn({ email, password });
24
+ const res = await sphere.streamAgent({ messages, tools }); // SSE Response, one turn/call
25
+ const balance = await sphere.getBalance();
26
+ ```
27
+
28
+ The `portal` slug is sent as `?portal=<slug>` on every metered request so the
29
+ backend applies that client's config (model, margin) and tags usage.
30
+
31
+ ## `createSphere(options)`
32
+
33
+ | Option | Default | Purpose |
34
+ | --- | --- | --- |
35
+ | `portal` | `"adrc"` | Attribution slug; sets server-side config + usage tag. |
36
+ | `tokenStore` | `sessionStorage` (else in-memory) | Sync string KV `{ get, set, clear }`. Inject `safeStorage` (desktop) or a file store (CLI). |
37
+ | `onAuthInvalid` | no-op | Called on any hard auth failure (store is cleared) — drop to a login screen. |
38
+ | `fetch` | `globalThis.fetch` | Override the fetch implementation. |
39
+ | `apiBase`, `appId` | SPHERE defaults | Point at a different backend / app. |
40
+
41
+ > **Token store contract:** operations must be **synchronous and atomic** —
42
+ > single-flight refresh relies on the rotated bundle being persisted before the
43
+ > next read. The SDK refreshes transparently (proactive near-expiry,
44
+ > retry-once-on-401).
45
+
46
+ ## Client methods
47
+
48
+ `createSphere()` returns:
49
+
50
+ - **Auth:** `signUp`, `signIn`, `signOut`, `oauthUrl`, `isSignedIn()`
51
+ - **Agent:** `ask`, `streamAgent(payload, signal) → Response` (SSE, one model turn per call)
52
+ - **Account / wallet:** `getAccount`, `getBalance`, `listCredits`, `buyCredits`, `redeem`
53
+ - **Metering:** `charge({ bytes, op, tag })` — **pre-charge** before a data op; returns
54
+ `{ ok:false, insufficient:true, ... }` on a 402 (an expected business outcome, not thrown).
55
+ When charging is gated off server-side, returns `{ ok:true, charged:false, cost_usd:0 }`.
56
+ - **Identity:** `portal`, `appId`, `apiBase`
57
+
58
+ A ready-made default instance for the ADRC portal is also exported:
59
+
60
+ ```js
61
+ import { sphere } from "@spheredata/sdk";
62
+ ```
63
+
64
+ ## Pairs with
65
+
66
+ [`@spheredata/embed`](https://www.npmjs.com/package/@spheredata/embed) — the
67
+ `<sphere-agent>` web component. Pass the client in via `el.sphere = sphere`.
68
+
69
+ ## License
70
+
71
+ MIT
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@spheredata/sdk",
3
+ "version": "0.0.1",
4
+ "publishConfig": { "access": "public" },
5
+ "description": "SPHERE client SDK — talks to the SPHERE backend (auth, metered agent, account).",
6
+ "keywords": ["sphere", "spheredata", "sdk", "ai", "agent", "metering", "wallet", "synthetic-data"],
7
+ "homepage": "https://github.com/statzihuai/sphere-app-dev/tree/main/packages/sdk#readme",
8
+ "repository": { "type": "git", "url": "git+https://github.com/statzihuai/sphere-app-dev.git", "directory": "packages/sdk" },
9
+ "contributors": ["Josh Zhang", "Zihuai He"],
10
+ "type": "module",
11
+ "main": "sphere-client.js",
12
+ "module": "sphere-client.js",
13
+ "exports": { ".": "./sphere-client.js" },
14
+ "scripts": {
15
+ "test": "node --test"
16
+ },
17
+ "files": ["sphere-client.js"],
18
+ "license": "MIT"
19
+ }
@@ -0,0 +1,332 @@
1
+ // SPHERE client SDK — the single integration surface every surface drops in.
2
+ //
3
+ // One client for the web portal, the Electron desktop app, and the CLI. Each
4
+ // integrates identically; surfaces differ only in *where the token is stored*
5
+ // (inject `tokenStore`) and *which tools run locally*:
6
+ //
7
+ // import { createSphere } from "@spheredata/sdk";
8
+ // const sphere = createSphere({ portal: "adrc" }); // web (sessionStorage)
9
+ // createSphere({ portal: "desktop", tokenStore: safeStorageAdapter });
10
+ // createSphere({ portal: "cli", tokenStore: fileAdapter });
11
+ //
12
+ // The portal slug is sent as ?portal=<slug> on every metered request so the
13
+ // backend applies that client's config (model, margin) and tags usage.
14
+ //
15
+ // Auth model (Butterbase native): login/refresh return
16
+ // { access_token, refresh_token, expires_in, user }
17
+ // access_token ~1h, refresh_token rotating. The SDK persists the full bundle via
18
+ // the injected `tokenStore` and refreshes transparently (single-flight, proactive
19
+ // near-expiry, retry-once on 401). On ANY hard auth failure it clears the store
20
+ // and calls `onAuthInvalid` so the surface can drop to a login screen.
21
+
22
+ const DEFAULTS = {
23
+ apiBase: "https://api.butterbase.ai",
24
+ appId: "app_wvujnt4zpp55",
25
+ portal: "adrc",
26
+ };
27
+
28
+ // Refresh this many ms BEFORE the access token's expiry, to avoid a guaranteed
29
+ // 401 mid-call (and, for streams, mid-body where we cannot retry).
30
+ const REFRESH_SKEW_MS = 60_000;
31
+
32
+ // A token store is a tiny string KV: { get(): string|null, set(v: string), clear() }.
33
+ // CONTRACT: its operations MUST be synchronous and atomic — single-flight refresh
34
+ // relies on a rotated bundle being persisted before the next read. Sync adapters
35
+ // are always feasible: Electron `safeStorage` is synchronous, and a CLI file store
36
+ // can use `readFileSync`/`writeFileSync`. The SDK serializes the full bundle to/from
37
+ // JSON, so stores stay dumb. Default: sessionStorage when present, else in-memory —
38
+ // so importing the SDK in Node never throws.
39
+ function defaultTokenStore(key) {
40
+ const ss = typeof globalThis !== "undefined" ? globalThis.sessionStorage : undefined;
41
+ if (ss && typeof ss.getItem === "function") {
42
+ return {
43
+ get: () => ss.getItem(key),
44
+ set: (v) => ss.setItem(key, v),
45
+ clear: () => ss.removeItem(key),
46
+ };
47
+ }
48
+ let mem = null;
49
+ return {
50
+ get: () => mem,
51
+ set: (v) => { mem = v; },
52
+ clear: () => { mem = null; },
53
+ };
54
+ }
55
+
56
+ export function createSphere(opts = {}) {
57
+ const { apiBase, appId, portal } = { ...DEFAULTS, ...opts };
58
+ const doFetch = opts.fetch || globalThis.fetch;
59
+ const onAuthInvalid = typeof opts.onAuthInvalid === "function" ? opts.onAuthInvalid : () => {};
60
+
61
+ const authBase = `${apiBase}/auth/${appId}`;
62
+ const fnBase = `${apiBase}/v1/${appId}/fn`;
63
+ const billingBase = `${apiBase}/v1/${appId}/billing`;
64
+ const q = `?portal=${encodeURIComponent(portal)}`;
65
+ const TOKEN_KEY = `sphere_token:${portal}`;
66
+
67
+ const tokenStore = opts.tokenStore || defaultTokenStore(TOKEN_KEY);
68
+
69
+ // --- bundle persistence ---------------------------------------------------
70
+ // Stored shape: { access_token, refresh_token, expires_at, user }.
71
+ function loadBundle() {
72
+ const raw = tokenStore.get();
73
+ if (!raw) return null;
74
+ if (typeof raw === "object") return raw; // tolerate a bundle-aware store (null excluded above)
75
+ try { return JSON.parse(raw); } catch { return null; }
76
+ }
77
+ function saveBundle(b) {
78
+ tokenStore.set(JSON.stringify(b)); // may throw (e.g. locked keychain) — callers route to hardFail
79
+ }
80
+ function bundleFromAuthResponse(data) {
81
+ const ttlSec = Number(data.expires_in);
82
+ const ttl = Number.isFinite(ttlSec) && ttlSec > 0 ? ttlSec : 3600; // seconds; ~1h fallback
83
+ return {
84
+ access_token: data.access_token,
85
+ refresh_token: data.refresh_token ?? null,
86
+ expires_at: Date.now() + ttl * 1000,
87
+ user: data.user ?? null,
88
+ };
89
+ }
90
+
91
+ // Any unrecoverable auth failure converges here: clear the store and signal the
92
+ // surface, then throw. clear() and onAuthInvalid() are each guarded so neither
93
+ // can suppress the other or the throw.
94
+ function hardFail(message) {
95
+ try { tokenStore.clear(); } catch { /* store may be unavailable; still signal */ }
96
+ try { onAuthInvalid(); } catch { /* surface callback must not mask the failure */ }
97
+ throw new Error(message);
98
+ }
99
+
100
+ // --- auth -----------------------------------------------------------------
101
+ async function signUp(email, password, displayName) {
102
+ const r = await doFetch(`${authBase}/signup`, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ email, password, display_name: displayName }),
106
+ });
107
+ if (!r.ok) throw new Error(`signup failed: ${r.status}`); // don't echo the body (may reflect input)
108
+ return r.json();
109
+ }
110
+
111
+ async function signIn(email, password) {
112
+ const r = await doFetch(`${authBase}/login`, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ email, password }),
116
+ });
117
+ if (!r.ok) throw new Error(`login failed: ${r.status}`);
118
+ const data = await r.json();
119
+ saveBundle(bundleFromAuthResponse(data));
120
+ return data.user;
121
+ }
122
+
123
+ // Single-flight refresh: concurrent callers share one in-flight promise so two
124
+ // 401s don't both rotate the refresh token and invalidate each other.
125
+ let refreshing = null;
126
+ async function refresh() {
127
+ if (refreshing) return refreshing;
128
+ refreshing = (async () => {
129
+ const b = loadBundle();
130
+ if (!b?.refresh_token) hardFail("session expired");
131
+ const r = await doFetch(`${authBase}/refresh`, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({ refresh_token: b.refresh_token }),
135
+ });
136
+ if (!r.ok) hardFail(`refresh failed: ${r.status}`);
137
+ const data = await r.json();
138
+ try {
139
+ saveBundle(bundleFromAuthResponse(data)); // persist the ROTATED pair
140
+ } catch {
141
+ // Rotation succeeded server-side but we can't persist it — the old refresh
142
+ // token is now consumed. Fail hard so the surface re-auths instead of
143
+ // silently replaying a dead token.
144
+ hardFail("token persist failed");
145
+ }
146
+ return data.access_token;
147
+ })();
148
+ try {
149
+ return await refreshing;
150
+ } finally {
151
+ refreshing = null;
152
+ }
153
+ }
154
+
155
+ // Return a valid access token, proactively refreshing within the skew window.
156
+ // - no session at all -> throw "not signed in"
157
+ // - expired/near-expiry, can refresh -> refresh()
158
+ // - expired/near-expiry, cannot -> hardFail (clear + signal)
159
+ // A missing expires_at is treated as expired (never "immortal").
160
+ async function validAccessToken() {
161
+ const b = loadBundle();
162
+ if (!b?.access_token) throw new Error("not signed in");
163
+ const stale = !b.expires_at || Date.now() > b.expires_at - REFRESH_SKEW_MS;
164
+ if (stale) {
165
+ if (b.refresh_token) return refresh();
166
+ hardFail("session expired");
167
+ }
168
+ return b.access_token;
169
+ }
170
+
171
+ // Authenticated fetch with retry-once-on-401. NOT for streams (see streamAgent).
172
+ async function authedFetch(url, init = {}) {
173
+ const token = await validAccessToken();
174
+ const withAuth = (t) => ({
175
+ ...init,
176
+ headers: { ...(init.headers || {}), Authorization: `Bearer ${t}` },
177
+ });
178
+ let r = await doFetch(url, withAuth(token));
179
+ if (r.status === 401) {
180
+ if (!loadBundle()?.refresh_token) hardFail("session expired");
181
+ const fresh = await refresh(); // throws + signals on hard failure
182
+ r = await doFetch(url, withAuth(fresh));
183
+ }
184
+ return r;
185
+ }
186
+
187
+ async function signOut() {
188
+ const b = loadBundle();
189
+ // Best-effort server-side revoke of the refresh token (network only in the try).
190
+ if (b?.access_token) {
191
+ try {
192
+ await doFetch(`${authBase}/logout`, {
193
+ method: "POST",
194
+ headers: { Authorization: `Bearer ${b.access_token}` },
195
+ });
196
+ } catch { /* ignore network/HTTP errors on logout */ }
197
+ }
198
+ tokenStore.clear();
199
+ }
200
+
201
+ // Build the hosted OAuth start URL; the surface opens it (web redirect or
202
+ // desktop/CLI loopback) and captures the returned tokens. `redirectTo` must be
203
+ // an absolute http(s) URL — reject other schemes (javascript:, data:, relative)
204
+ // to avoid handing the auth flow somewhere unintended. Host allow-listing is the
205
+ // backend's responsibility.
206
+ function oauthUrl(provider, redirectTo) {
207
+ const base = `${authBase}/oauth/${encodeURIComponent(provider)}`;
208
+ if (!redirectTo) return base;
209
+ let u;
210
+ try { u = new URL(redirectTo); } catch { throw new Error("oauthUrl: redirectTo must be an absolute URL"); }
211
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("oauthUrl: redirectTo must be http(s)");
212
+ return `${base}?redirect_to=${encodeURIComponent(redirectTo)}`;
213
+ }
214
+
215
+ // --- AI -------------------------------------------------------------------
216
+ // Ask the model through SPHERE. Metered to the user's wallet under this portal.
217
+ async function ask(messages, { model, max_tokens } = {}) {
218
+ const r = await authedFetch(`${fnBase}/messages${q}`, {
219
+ method: "POST",
220
+ headers: { "Content-Type": "application/json" },
221
+ body: JSON.stringify({
222
+ messages,
223
+ ...(model ? { model } : {}),
224
+ ...(max_tokens != null ? { max_tokens } : {}),
225
+ }),
226
+ });
227
+ const data = await r.json();
228
+ if (!r.ok) throw new Error(data?.error || `message failed: ${r.status}`);
229
+ return data; // { message, model, portal, usage, cost_usd, balance_usd }
230
+ }
231
+
232
+ // Open a streaming agent turn. Returns the raw Response (text/event-stream):
233
+ // OpenAI chat.completion.chunk lines + a trailing
234
+ // `data: {"sphere":{balance_usd,cost_usd,usage,portal}}` event. One model turn
235
+ // per call; the caller runs the tool loop, feeding tool results back.
236
+ //
237
+ // Pre-flight refresh + retry-once ONLY on the initial response status (body not
238
+ // yet read — safe to re-open). We never retry once the body is being consumed.
239
+ async function streamAgent(payload, signal) {
240
+ const open = (t) => doFetch(`${fnBase}/agent${q}`, {
241
+ method: "POST",
242
+ signal,
243
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
244
+ body: JSON.stringify(payload),
245
+ });
246
+ let r = await open(await validAccessToken());
247
+ if (r.status === 401) {
248
+ if (!loadBundle()?.refresh_token) hardFail("session expired");
249
+ r = await open(await refresh());
250
+ }
251
+ return r;
252
+ }
253
+
254
+ // --- account / wallet -----------------------------------------------------
255
+ async function getAccount() {
256
+ const r = await authedFetch(`${fnBase}/account${q}`, {});
257
+ if (!r.ok) throw new Error(`account failed: ${r.status}`);
258
+ return r.json(); // { user, portal, wallet, recent_usage }
259
+ }
260
+
261
+ async function getBalance() {
262
+ const r = await authedFetch(`${fnBase}/balance`, {});
263
+ if (!r.ok) throw new Error(`balance failed: ${r.status}`);
264
+ return r.json();
265
+ }
266
+
267
+ // --- billing (credit purchase) --------------------------------------------
268
+ // NOTE: backend shapes are pending the Slice-1 billing spike; these wrappers
269
+ // follow the documented Butterbase Stripe Connect / SPHERE redeem contract.
270
+ async function listCredits() {
271
+ const r = await authedFetch(`${billingBase}/products`, {});
272
+ if (!r.ok) throw new Error(`listCredits failed: ${r.status}`);
273
+ return r.json(); // [{ productId, name, credit_usd, priceCents }]
274
+ }
275
+
276
+ async function buyCredits(productId, { successUrl, cancelUrl } = {}) {
277
+ const r = await authedFetch(`${billingBase}/purchase`, {
278
+ method: "POST",
279
+ headers: { "Content-Type": "application/json" },
280
+ body: JSON.stringify({ productId, ...(successUrl ? { successUrl } : {}), ...(cancelUrl ? { cancelUrl } : {}) }),
281
+ });
282
+ const data = await r.json();
283
+ if (!r.ok) throw new Error(data?.error || `buyCredits failed: ${r.status}`);
284
+ return data; // { url, orderId }
285
+ }
286
+
287
+ async function redeem(orderId) {
288
+ const r = await authedFetch(`${fnBase}/redeem`, {
289
+ method: "POST",
290
+ headers: { "Content-Type": "application/json" },
291
+ body: JSON.stringify({ orderId }),
292
+ });
293
+ const data = await r.json();
294
+ if (!r.ok) throw new Error(data?.error || `redeem failed: ${r.status}`);
295
+ return data; // { balance_usd }
296
+ }
297
+
298
+ // --- data-op metering -----------------------------------------------------
299
+ // Charge a local data operation (generate/evaluate/certify) by input dataset
300
+ // bytes, debited from the wallet under this portal/tag. Pricing is server-side.
301
+ // PRE-CHARGE: call this BEFORE running the op; if `ok` is false (insufficient
302
+ // credits) do NOT run it. A 402 is an expected business outcome (not thrown);
303
+ // other failures throw. When charging is gated off server-side, returns
304
+ // { ok:true, charged:false, cost_usd:0 } and never blocks.
305
+ async function charge({ bytes, op, tag } = {}) {
306
+ const r = await authedFetch(`${fnBase}/charge${q}`, {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({ bytes, op, ...(tag ? { tag } : {}) }),
310
+ });
311
+ const data = await r.json().catch(() => ({}));
312
+ if (r.status === 402) {
313
+ return { ok: false, insufficient: true, ...data }; // { cost_usd, balance_usd, ... }
314
+ }
315
+ if (!r.ok) throw new Error(data?.error || `charge failed: ${r.status}`);
316
+ return data; // { ok:true, charged, op, bytes, cost_usd, balance_usd }
317
+ }
318
+
319
+ return {
320
+ portal, appId, apiBase,
321
+ signUp, signIn, signOut, oauthUrl,
322
+ ask, streamAgent,
323
+ getAccount, getBalance,
324
+ listCredits, buyCredits, redeem, charge,
325
+ // NOTE: reflects token PRESENCE, not validity — a present-but-expired token
326
+ // returns true here and is refreshed lazily on the next call.
327
+ isSignedIn: () => !!loadBundle()?.access_token,
328
+ };
329
+ }
330
+
331
+ // Default instance for the ADRC portal (safe to construct in any environment).
332
+ export const sphere = createSphere();