@genex-ai/embed-sdk 0.22.3 → 0.24.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
@@ -48,8 +48,9 @@ return `native_unsupported` and `local_test_unsupported` until they have support
48
48
  confirmation surfaces.
49
49
 
50
50
  `GenerateOptions` requires `estimateCoins` (an integer from 1 to 1,000,000 for an API-backed selection), `modelId`, `prompt`, `outputFormat: 'text' | 'json'`,
51
- optional bounded `schema`, `allowExternal` (default false), `idempotencyKey` and
52
- `timeoutMs` (default ten minutes). Reuse an idempotency key only for the same
51
+ optional bounded `schema`, `allowExternal` (default false), `idempotencyKey`,
52
+ `timeoutMs` (default ten minutes) and `grantId` a standing budget the player
53
+ already approved, which removes the popup entirely (see *Standing budgets*). Reuse an idempotency key only for the same
53
54
  operation; store it if you need retries to survive a reload. Only models returned
54
55
  by Genex are accepted. This first adapter generates text and JSON, including
55
56
  creature descriptions, dialogue, rules and other structured game data.
@@ -107,6 +108,128 @@ nor hosts subscription credentials.
107
108
  generated it; `modelProvenance: 'unverified'` makes that explicit. Treat every generated output as data. Never execute returned code
108
109
  or use a claimed model/source as authority to mint coins, rewards or items.
109
110
 
111
+ ## Standing budgets
112
+
113
+ SDK **0.24.0+**. A one-time `generate()` asks the player once per call. When a
114
+ feature calls the model continuously — a thinking NPC, a director rewriting
115
+ the next encounter — ask for a **standing budget** instead: the player approves
116
+ once, sets their own limit on a Genex slider, and the game then calls the model
117
+ with no popup at all until the limit, a stop, or the budget's expiry.
118
+
119
+ ```ts
120
+ import { requestSpendGrant, generate, getSpendGrant, stopSpendGrant, generationErrorMessage } from '@genex-ai/embed-sdk';
121
+
122
+ let grantId: string | undefined;
123
+
124
+ // Call it from the click, before awaiting: the approval surface is reserved
125
+ // synchronously, exactly like generate().
126
+ talkButton.addEventListener('click', async () => {
127
+ if (!grantId) {
128
+ const budget = await requestSpendGrant({
129
+ models: [model.id],
130
+ perCallMaxCoins: 8, // Hard ceiling per call. The server refuses more.
131
+ perCallEstimateCoins: 5, // The honest per-call price, benchmarked first.
132
+ disclosure: { periodLabel: 'minute', estimatedCallsPerPeriod: 4, estimatedCoinsPerPeriod: 20 },
133
+ maxConcurrent: 2,
134
+ idempotencyKey: 'npc-conversation-budget',
135
+ });
136
+ if (budget.status !== 'active') return showNotice(generationErrorMessage(budget.error));
137
+ grantId = budget.grantId;
138
+ }
139
+ const reply = await generate({ ...request, grantId });
140
+ if (reply.status === 'succeeded') applyGeneration(reply);
141
+ else showNotice(generationErrorMessage(reply.error));
142
+ });
143
+ ```
144
+
145
+ `requestSpendGrant()` resolves `active` once the budget can serve calls,
146
+ `canceled` when the player closes the sheet or stops it, `expired` when the
147
+ approval window passes, `pending` with `error: 'wait_timeout'` when the SDK
148
+ stopped waiting (the sheet may still be open), and `failed` with a server code.
149
+ The successful result carries `grantId` and the full server `grant` view.
150
+
151
+ `generate({ grantId })` runs one call under that budget. It opens **no window,
152
+ posts no parent message and needs no user gesture**, so it works from a game
153
+ loop or a timer. Every other guard is unchanged: production play, a signed-in
154
+ player, a trusted parent origin, and no native or local-test surface. The
155
+ funding door was chosen once in the sheet, so `allowExternal` is ignored on a
156
+ budget call. Everything else is the ordinary generation result, including the
157
+ receipt: a started attempt is charged its fixed price whether it succeeds,
158
+ fails, or is stopped.
159
+
160
+ `getSpendGrant(id)` is the readout to draw in the game: `limitCoins`,
161
+ `spentCoins`, `heldCoins` (admitted, not settled yet), `remainingCoins`,
162
+ `limitCalls`, `callCount`, `status` and the frozen `terms`. Poll it on the
163
+ cadence your HUD needs — never derive the remaining budget from your own count
164
+ of calls, and never show a figure the server did not send. Store `grantId` the
165
+ way you store a `generationId`; `getSpendGrant()` recovers the budget after a
166
+ reload, and a budget whose status is no longer live simply needs a new
167
+ approval. `stopSpendGrant(id)` releases it when your feature ends. Stopping is
168
+ prospective: calls already started finish and are charged. The player has the
169
+ same lever on Genex, on every page, and theirs always outranks the game's.
170
+
171
+ **The disclosure is a promise, not a guess.** `disclosure` is what the Genex
172
+ sheet shows the player, attributed to your game — Genex prints a server-computed
173
+ worst case beside it, so an optimistic number is visible as one. Declare the
174
+ rate you actually expect, benchmark `perCallEstimateCoins` before you declare it
175
+ (see *Benchmark your own coin costs* below), and keep `perCallMaxCoins` a real
176
+ ceiling. The server refuses a call whose declared price is out of proportion to
177
+ the model's own cost (`grant_price_unreasonable`) — that check is what replaces
178
+ the popup, so a budget is never a blank cheque. The slider's bounds, the coin
179
+ value and the recommended headroom all come from the server; read them from
180
+ `getGenerationModels()` (`standingGrant`, `recommendedDeclaredHeadroomBps`)
181
+ rather than typing any of them into the game.
182
+
183
+ **Handle the refusals as game states.** A budget ends for ordinary reasons and
184
+ the game should have believable copy for each:
185
+ `grant_limit_reached` (the player's limit is spent), `grant_stopped`,
186
+ `grant_expired`, `grant_not_active`, `grant_concurrency` and
187
+ `grant_rate_limited` (your own declared ceilings), `grant_insufficient_funds`
188
+ (the wallet ran low — the budget resumes when it is topped up),
189
+ `waiting_for_plan` (a personal plan's own rate limit; it resumes by itself),
190
+ `grant_price_unreasonable`, `grant_already_active`, `external_grant_active` and
191
+ `invalid_grant_limit`. `generationErrorMessage(code)` returns a player-facing
192
+ sentence for every one of these and for every one-time code as well; show it, or
193
+ write your own in-fiction line keyed off the same code. Never show the raw code,
194
+ and never treat `unknown` as failure — it means the outcome is not settled yet.
195
+
196
+ **The receiver pattern.** Persistence is the game's job, and it is what decides
197
+ whether a generated thing survives a reload:
198
+
199
+ - Write exactly one function, `applyGeneration(result)`, that turns an output
200
+ into game state and saves it through the SDK's player-state API.
201
+ - In the click path, save the `generationId` (and `grantId`) into player state
202
+ **before** awaiting, then call `applyGeneration`.
203
+ - On boot, call `waitForGeneration(savedId)` for any stored id and call the
204
+ *same* `applyGeneration`.
205
+
206
+ One writer, two entry points: the difference between something that works once
207
+ and something that survives a reload. Validate `result.output` against the shape
208
+ your game expects before using it, always. It is data, never authority for
209
+ coins, rewards or items.
210
+
211
+ ## Your subscription and the Genex player watcher
212
+
213
+ Both approval sheets can offer the player their own Claude or ChatGPT plan
214
+ beside coins, when the selected model's provider matches. That choice lives in
215
+ the trusted Genex surface only — never add a plan row to the game's own model
216
+ picker, and never ask a player for a subscription credential. Genex neither
217
+ collects nor hosts them.
218
+
219
+ A personal-plan request is answered on the player's own computer by the **Genex
220
+ player watcher**, a small program that runs the official `claude` / `codex` CLIs
221
+ they signed into themselves. If a player picks their plan and has not installed
222
+ it, Genex shows them the one-time prompt: *"Install the Genex player watcher:
223
+ run `npx @genex-ai/cli-demo@latest player install` and approve it on Genex."*
224
+ The game does not install, start or speak to the watcher; it only reads
225
+ `watcherOnline` on a grant or generation view when Genex includes it. A
226
+ personal-plan result costs zero coin, is metered in requests rather than coin,
227
+ and arrives with `source: 'external'` and `modelProvenance: 'unverified'`:
228
+ user-supplied output, never proof that a particular model produced it, and
229
+ never authority for money or rewards. When the player's own plan hits its rate limit
230
+ the budget reports `waiting_for_plan` and resumes on its own; it never falls back
231
+ to paid work.
232
+
110
233
  ## Registered generation workflows
111
234
 
112
235
  SDK **0.21.0+** supports multi-step generation and usage receipts through an
@@ -207,6 +330,11 @@ describes both APIs, executor authorization, recovery and connector endpoints.
207
330
 
208
331
  ## Benchmark your own coin costs on the server
209
332
 
333
+ `npx genex llm bench` is the CLI door onto this lane: it runs the real model
334
+ against your own coin wallet, reports what it actually charged, and recommends
335
+ the price to declare. Reach for the API below when you want the samples in your
336
+ own script.
337
+
210
338
  SDK **0.21.0+** provides `@genex-ai/embed-sdk/development`. Use it only in a
211
339
  local Node script or trusted server with your own full creator bearer credential
212
340
  and an owned `projectId`. It needs no production play token and cannot choose a
@@ -8,7 +8,7 @@ import {
8
8
  isGenerationTerminal,
9
9
  readBridgeNativeCommand,
10
10
  readNativeEntry
11
- } from "./chunk-Q476GJHI.js";
11
+ } from "./chunk-VPIQVRVS.js";
12
12
 
13
13
  // src/commerce.ts
14
14
  var cfg = null;
@@ -217,7 +217,8 @@ function waitForGeneration(generationId, timeoutMs = 6e5) {
217
217
  }
218
218
  async function generate(options) {
219
219
  if (!validEstimateCoins(options.estimateCoins)) return { status: "failed", error: "invalid_estimate_coins" };
220
- const { timeoutMs, idempotencyKey, ...input } = options;
220
+ const { timeoutMs, idempotencyKey, grantId, ...input } = options;
221
+ if (grantId !== void 0) return generateUnderGrant(grantId, input, idempotencyKey, timeoutMs ?? 6e5);
221
222
  return startGeneration(
222
223
  "/generations",
223
224
  { ...input, idempotencyKey },
@@ -241,7 +242,7 @@ async function resumeWorkflow(generationId, options = {}) {
241
242
  if (!isGenerationId(generationId)) return { status: "failed", error: "invalid_generation_id" };
242
243
  return startGeneration("", {}, options.timeoutMs ?? 6e5, authorizationOutcome, void 0, generationId);
243
244
  }
244
- async function startGeneration(path, body, timeoutMs, readOutcome, workflow, resumeId) {
245
+ function approvalGuards() {
245
246
  const c = must2();
246
247
  if (c.isLocalTest()) return { status: "failed", error: "local_test_unsupported" };
247
248
  if (c.isNative()) return { status: "failed", error: "native_unsupported" };
@@ -251,22 +252,32 @@ async function startGeneration(path, body, timeoutMs, readOutcome, workflow, res
251
252
  if (c.isEmbedded() && (!parentOrigin2 || !c.dashboardOrigins.includes(parentOrigin2))) {
252
253
  return { status: "failed", error: "untrusted_parent" };
253
254
  }
254
- let popup = null;
255
+ return { parentOrigin: parentOrigin2 };
256
+ }
257
+ function reserveApprovalSurface(placeholder) {
258
+ const guarded = approvalGuards();
259
+ if ("status" in guarded) return guarded;
260
+ const c = must2();
261
+ if (c.isEmbedded()) return { popup: null, parentOrigin: guarded.parentOrigin };
255
262
  let dashboard;
256
- if (!c.isEmbedded()) {
257
- try {
258
- dashboard = new URL(c.dashboardOrigins[0]);
259
- if (dashboard.protocol !== "https:" && !(dashboard.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(dashboard.hostname))) throw new Error();
260
- } catch {
261
- return { status: "failed", error: "invalid_dashboard_origin" };
262
- }
263
- popup = window.open(`${dashboard.origin}/play/generate`, "_blank", "popup,width=480,height=760");
264
- if (!popup) return { status: "failed", error: "popup_blocked" };
265
- try {
266
- popup.opener = null;
267
- } catch {
268
- }
263
+ try {
264
+ dashboard = new URL(c.dashboardOrigins[0]);
265
+ if (dashboard.protocol !== "https:" && !(dashboard.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(dashboard.hostname))) throw new Error();
266
+ } catch {
267
+ return { status: "failed", error: "invalid_dashboard_origin" };
269
268
  }
269
+ const popup = window.open(`${dashboard.origin}${placeholder}`, "_blank", "popup,width=480,height=760");
270
+ if (!popup) return { status: "failed", error: "popup_blocked" };
271
+ try {
272
+ popup.opener = null;
273
+ } catch {
274
+ }
275
+ return { popup, dashboard, parentOrigin: guarded.parentOrigin };
276
+ }
277
+ async function startGeneration(path, body, timeoutMs, readOutcome, workflow, resumeId) {
278
+ const surface = reserveApprovalSurface("/play/generate");
279
+ if ("status" in surface) return surface;
280
+ const { popup, dashboard, parentOrigin: parentOrigin2 } = surface;
270
281
  try {
271
282
  const view = resumeId ? await getGeneration(resumeId) : await request(path, { ...body, idempotencyKey: body.idempotencyKey ?? crypto.randomUUID() });
272
283
  if (resumeId && (view.id !== resumeId || view.kind !== "workflow")) throw new Error("invalid_generation_response");
@@ -291,6 +302,274 @@ async function startGeneration(path, body, timeoutMs, readOutcome, workflow, res
291
302
  return { status: "failed", error: error instanceof Error ? error.message : "generation_unavailable" };
292
303
  }
293
304
  }
305
+ var LIVE_GRANT_STATUSES = ["active", "suspended_insufficient_funds", "waiting_for_plan"];
306
+ function isGrantId(value) {
307
+ return isGenerationId(value);
308
+ }
309
+ function validCount(value, min, max) {
310
+ return Number.isSafeInteger(value) && value >= min && value <= max;
311
+ }
312
+ function invalidGrantOptions(options) {
313
+ if (!Array.isArray(options.models) || options.models.length < 1 || options.models.length > 8 || options.models.some((id) => typeof id !== "string" || !id || id.length > 150) || new Set(options.models).size !== options.models.length) return "invalid_models";
314
+ if (!validCount(options.perCallMaxCoins, 1, 1e6) || !validCount(options.perCallEstimateCoins, 1, 1e6) || options.perCallEstimateCoins > options.perCallMaxCoins) return "invalid_declared_price";
315
+ const d = options.disclosure;
316
+ if (!d || typeof d !== "object" || !["minute", "hour", "session"].includes(d.periodLabel) || !validCount(d.estimatedCallsPerPeriod, 1, 1e4) || !validCount(d.estimatedCoinsPerPeriod, 0, 1e6)) return "invalid_disclosure";
317
+ if (options.maxConcurrent !== void 0 && !validCount(options.maxConcurrent, 1, 8)) return "invalid_grant_terms";
318
+ if (options.maxCallsPerMinute !== void 0 && !validCount(options.maxCallsPerMinute, 1, 120)) return "invalid_grant_terms";
319
+ if (options.maxPendingExternal !== void 0 && !validCount(options.maxPendingExternal, 1, 8)) return "invalid_grant_terms";
320
+ if (options.allowExternal !== void 0 && typeof options.allowExternal !== "boolean") return "invalid_grant_terms";
321
+ if (options.idempotencyKey !== void 0 && (typeof options.idempotencyKey !== "string" || options.idempotencyKey.length < 8 || options.idempotencyKey.length > 100)) return "invalid_idempotency_key";
322
+ return void 0;
323
+ }
324
+ function grantOutcome(view) {
325
+ if (LIVE_GRANT_STATUSES.includes(view.status)) return { status: "active", grantId: view.id, grant: view };
326
+ switch (view.status) {
327
+ // The player closing the sheet, or stopping later, is a normal outcome.
328
+ case "stopped":
329
+ return { status: "canceled", grantId: view.id, grant: view };
330
+ case "expired":
331
+ return { status: "expired", grantId: view.id, grant: view };
332
+ // Spent out before it could serve this caller — a real answer, with the
333
+ // same code a call under an exhausted budget reports.
334
+ case "exhausted":
335
+ return { status: "failed", grantId: view.id, grant: view, error: "grant_limit_reached" };
336
+ default:
337
+ return void 0;
338
+ }
339
+ }
340
+ function getSpendGrant(grantId) {
341
+ if (!isGrantId(grantId)) return Promise.reject(new Error("invalid_grant_id"));
342
+ return request(`/grants/${encodeURIComponent(grantId)}`);
343
+ }
344
+ function stopSpendGrant(grantId) {
345
+ if (!isGrantId(grantId)) return Promise.reject(new Error("invalid_grant_id"));
346
+ return request(`/grants/${encodeURIComponent(grantId)}/stop`, {});
347
+ }
348
+ async function pollGrant(grantId, timeoutMs, popup) {
349
+ const deadline = Date.now() + timeoutMs;
350
+ while (Date.now() < deadline) {
351
+ try {
352
+ const view = await getSpendGrant(grantId);
353
+ const result = grantOutcome(view);
354
+ if (result) return result;
355
+ if (popup?.closed && view.status === "requires_confirmation") {
356
+ const stopped = await request(`/grants/${encodeURIComponent(grantId)}/stop`, {});
357
+ const stopResult = grantOutcome(stopped);
358
+ if (stopResult) return stopResult;
359
+ }
360
+ } catch {
361
+ }
362
+ await new Promise((resolve) => setTimeout(resolve, 750));
363
+ }
364
+ return { status: "pending", grantId, error: "wait_timeout" };
365
+ }
366
+ async function requestSpendGrant(options) {
367
+ const invalid = invalidGrantOptions(options);
368
+ if (invalid) return { status: "failed", error: invalid };
369
+ const surface = reserveApprovalSurface("/play/grant");
370
+ if ("status" in surface) return surface;
371
+ const { popup, dashboard, parentOrigin: parentOrigin2 } = surface;
372
+ try {
373
+ const view = await request("/grants", {
374
+ idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(),
375
+ models: options.models,
376
+ perCallMaxCoins: options.perCallMaxCoins,
377
+ perCallEstimateCoins: options.perCallEstimateCoins,
378
+ disclosure: {
379
+ periodLabel: options.disclosure.periodLabel,
380
+ estimatedCallsPerPeriod: options.disclosure.estimatedCallsPerPeriod,
381
+ estimatedCoinsPerPeriod: options.disclosure.estimatedCoinsPerPeriod
382
+ },
383
+ ...options.maxConcurrent !== void 0 ? { maxConcurrent: options.maxConcurrent } : {},
384
+ ...options.maxCallsPerMinute !== void 0 ? { maxCallsPerMinute: options.maxCallsPerMinute } : {},
385
+ ...options.maxPendingExternal !== void 0 ? { maxPendingExternal: options.maxPendingExternal } : {},
386
+ ...options.allowExternal !== void 0 ? { allowExternal: options.allowExternal } : {}
387
+ });
388
+ if (!isGrantId(view.id)) throw new Error("invalid_grant_response");
389
+ const settled = grantOutcome(view);
390
+ if (settled) {
391
+ popup?.close();
392
+ return settled;
393
+ }
394
+ if (popup && dashboard) {
395
+ if (!popup.closed) popup.location.href = `${dashboard.origin}/play/grant/${encodeURIComponent(view.id)}`;
396
+ } else if (parentOrigin2) {
397
+ window.parent.postMessage({ type: "genex:grant:confirm", v: 1, grantId: view.id }, parentOrigin2);
398
+ }
399
+ return pollGrant(view.id, options.timeoutMs ?? 6e5, popup);
400
+ } catch (error) {
401
+ popup?.close();
402
+ return { status: "failed", error: error instanceof Error ? error.message : "grant_unavailable" };
403
+ }
404
+ }
405
+ async function generateUnderGrant(grantId, input, idempotencyKey, timeoutMs) {
406
+ if (!isGrantId(grantId)) return { status: "failed", error: "invalid_grant_id" };
407
+ const guarded = approvalGuards();
408
+ if ("status" in guarded) return guarded;
409
+ const { allowExternal: _unused, ...body } = input;
410
+ try {
411
+ const view = await request(
412
+ `/grants/${encodeURIComponent(grantId)}/generations`,
413
+ { ...body, idempotencyKey: idempotencyKey ?? crypto.randomUUID() }
414
+ );
415
+ if (!isGenerationId(view.id) || view.grantId !== grantId) throw new Error("invalid_generation_response");
416
+ const result = outcome(view);
417
+ if (result) return result;
418
+ return poll(view.id, timeoutMs, outcome);
419
+ } catch (error) {
420
+ return { status: "failed", error: error instanceof Error ? error.message : "generation_unavailable" };
421
+ }
422
+ }
423
+ function generationErrorMessage(code) {
424
+ switch (code) {
425
+ // --- the outcome is not known yet. Never a failure sentence. ------------
426
+ case "unknown":
427
+ case "unknown_execution":
428
+ case "provider_outcome_unknown":
429
+ return "We don\u2019t know how this one ended yet \u2014 it is still being settled. Check back in a moment.";
430
+ case "wait_timeout":
431
+ return "This is taking longer than usual. It is still running \u2014 check back in a moment.";
432
+ // --- the player's wallet ------------------------------------------------
433
+ case "insufficient_balance":
434
+ return "There isn\u2019t enough coin in your Genex wallet for this request.";
435
+ case "grant_insufficient_funds":
436
+ return "Your budget paused because your Genex wallet ran low. Top it up and it picks up again.";
437
+ // --- standing budgets ---------------------------------------------------
438
+ case "grant_limit_reached":
439
+ return "This budget has reached the limit you set. Set up a new one to keep going.";
440
+ case "grant_stopped":
441
+ return "This budget was stopped. Set up a new one to keep going.";
442
+ case "grant_expired":
443
+ return "This budget has ended. Set up a new one to keep going.";
444
+ case "grant_not_active":
445
+ case "grant_not_found":
446
+ return "There is no budget running for this. Set one up to keep going.";
447
+ case "grant_price_unreasonable":
448
+ return "This asked to charge more for one request than your approval allows, so nothing was charged.";
449
+ case "grant_concurrency":
450
+ return "As many requests are running as your budget allows at once. Wait for one to finish.";
451
+ case "grant_rate_limited":
452
+ return "Requests are coming too quickly for this budget. Wait a moment and try again.";
453
+ case "grant_already_active":
454
+ return "A budget is already running here. Stop it on Genex before setting up another.";
455
+ case "external_grant_active":
456
+ return "A budget on your own plan is already running. Stop it on Genex before setting up another.";
457
+ case "invalid_grant_limit":
458
+ return "That limit is outside the range Genex offers. Choose one on the slider.";
459
+ case "waiting_for_plan":
460
+ return "Your own plan is at its rate limit right now. This resumes on its own once the limit resets.";
461
+ // --- personal plans and the player watcher ------------------------------
462
+ case "external_request_active":
463
+ return "Finish your current request on your own plan before starting another.";
464
+ case "external_not_allowed":
465
+ return "This game only generates through Genex.";
466
+ case "external_unavailable":
467
+ return "Your own plan can\u2019t answer this right now.";
468
+ case "player_update_required":
469
+ return "The Genex player watcher on your computer is out of date. Update it and try again.";
470
+ // --- approval, identity and the trusted surface -------------------------
471
+ case "popup_blocked":
472
+ return "Your browser blocked the Genex approval window. Allow pop-ups for this page and try again.";
473
+ case "no_player_session":
474
+ case "unauthorized":
475
+ case "guest_no_wallet":
476
+ case "player_wallet_required":
477
+ return "Sign in to Genex to use this.";
478
+ case "session_revoked":
479
+ return "Your Genex session ended. Sign in again to continue.";
480
+ case "untrusted_parent":
481
+ case "invalid_dashboard_origin":
482
+ case "forbidden_origin":
483
+ case "credential_scope":
484
+ return "This page can\u2019t reach Genex safely, so nothing was requested.";
485
+ case "native_unsupported":
486
+ return "In-game generation isn\u2019t available in the app yet.";
487
+ case "local_test_unsupported":
488
+ return "In-game generation doesn\u2019t run in local test mode.";
489
+ case "staging_no_generation":
490
+ return "Generation only works in the published game, not in a preview build.";
491
+ // --- quotes and terms ---------------------------------------------------
492
+ case "quote_expired":
493
+ case "generation_expired":
494
+ case "expired":
495
+ return "This request expired before it was approved. Try again.";
496
+ case "quote_changed":
497
+ case "quote_mismatch":
498
+ case "offering_changed":
499
+ return "The price changed. Start this again to see the new offer.";
500
+ case "billing_policy_mismatch":
501
+ case "billing_policy_required":
502
+ case "billing_policy_unsupported":
503
+ return "These terms need a fresh review on Genex. Reload the page and try again.";
504
+ case "funding_mismatch":
505
+ case "funding_conflict":
506
+ return "That payment option doesn\u2019t match this request. Start it again.";
507
+ case "invalid_declared_price":
508
+ case "invalid_estimate_coins":
509
+ return "This request\u2019s price isn\u2019t valid, so nothing was charged.";
510
+ // --- what the game asked for --------------------------------------------
511
+ case "model_not_allowed":
512
+ return "This budget doesn\u2019t cover that model. Choose one it does.";
513
+ case "model_not_available":
514
+ case "offering_unavailable":
515
+ case "workflow_unavailable":
516
+ case "runtime_models_unavailable":
517
+ return "That model isn\u2019t available here. Choose another.";
518
+ case "provider_budget_insufficient":
519
+ case "estimate_exceeds_provider_budget":
520
+ return "This request is too large for its approved budget. Make it smaller and try again.";
521
+ case "provider_budget_exceeded":
522
+ case "provider_budget_exhausted":
523
+ case "workflow_provider_budget_exhausted":
524
+ case "budget_exhausted":
525
+ case "budget_limit":
526
+ case "runtime_budget_exhausted":
527
+ case "provider_token_limit":
528
+ return "The model ran out of room before a usable answer was ready. The attempt is still charged.";
529
+ case "request_too_large":
530
+ return "That request is too long. Shorten it and try again.";
531
+ case "invalid_request":
532
+ case "invalid_schema":
533
+ return "This request wasn\u2019t something Genex could accept.";
534
+ case "generation_limit":
535
+ return "As many requests are running as Genex allows at once. Wait for one to finish.";
536
+ case "generation_not_found":
537
+ return "That request no longer exists.";
538
+ case "idempotency_conflict":
539
+ return "That request was already started with different details.";
540
+ // --- the result -----------------------------------------------------------
541
+ case "rejected":
542
+ case "invalid_output":
543
+ case "validation_failed":
544
+ case "invalid_provider_response":
545
+ return "The answer didn\u2019t match what this game asked for.";
546
+ // TWO DIFFERENT ENDINGS, and the difference is money. A budget that was
547
+ // stopped, expired or revoked cancels what it admitted but never
548
+ // dispatched, at zero cost — that is the one case where "before it ran" is
549
+ // true. A `canceled` row that carries this code instead comes from the
550
+ // cancel door closing on work already dispatched, or a workflow stopped
551
+ // after steps ran: a fixed-price attempt is charged in full and a metered
552
+ // one is charged for what it consumed. Denying the charge in the sentence a
553
+ // game shows was the SDK telling the player the opposite of their receipt.
554
+ case "player_stopped":
555
+ case "game_stopped":
556
+ case "operator_stopped":
557
+ return "This request was stopped before it ran, so nothing was charged for it.";
558
+ case "admission_revoked":
559
+ case "canceled":
560
+ return "This request was stopped. Work that had already started is still charged.";
561
+ case "account_erased":
562
+ return "This request was stopped because the account was erased.";
563
+ case "provider_failed":
564
+ case "provider_unavailable":
565
+ case "provider_response_unavailable":
566
+ case "provider_response_too_large":
567
+ case "context_unavailable":
568
+ return "The model couldn\u2019t finish this request.";
569
+ default:
570
+ return "Generation isn\u2019t available right now. Try again in a moment.";
571
+ }
572
+ }
294
573
 
295
574
  // src/index.ts
296
575
  var PROTOCOL_VERSION = 1;
@@ -298,7 +577,7 @@ var RETRY_FLAG = "genex:embed:retry";
298
577
  var POPOVER_DISMISSED_FLAG = "genex:guest:popover-dismissed";
299
578
  var LOCAL_AUTH_FLAG = "genex:embed:local-auth";
300
579
  var PLAYER_ID_KEY = "genex:player";
301
- var SDK_VERSION = "0.22.3";
580
+ var SDK_VERSION = "0.24.0";
302
581
  var config2 = null;
303
582
  var state = "pending";
304
583
  var user = null;
@@ -548,12 +827,18 @@ function stateFetch(path, init = {}) {
548
827
  keepalive: init.body !== void 0 && init.body.length <= KEEPALIVE_MAX_BYTES ? true : void 0
549
828
  });
550
829
  }
830
+ async function refusedAsStaging(res) {
831
+ if (res.status !== 403) return false;
832
+ const body = await res.json().catch(() => null);
833
+ return body?.error === "staging_no_save";
834
+ }
551
835
  async function decodeSave(res) {
552
836
  if (res.status === 409) {
553
837
  const body2 = await res.json().catch(() => ({}));
554
838
  return { saved: false, conflict: true, version: body2.version };
555
839
  }
556
840
  if (!res.ok) {
841
+ if (await refusedAsStaging(res)) return { saved: false, staging: true };
557
842
  emit("error", { error: new Error(`genex state save failed (${res.status})`) });
558
843
  return { saved: false };
559
844
  }
@@ -566,7 +851,10 @@ async function loadPlayerState() {
566
851
  return { data: hasPendingPlayerSave ? pendingPlayerSave : null, version: 0, guest: true };
567
852
  }
568
853
  const res = await stateFetch("/state/me");
569
- if (!res.ok) throw new Error(`genex load player state failed (${res.status})`);
854
+ if (!res.ok) {
855
+ if (await refusedAsStaging(res)) return { data: null, version: 0, staging: true };
856
+ throw new Error(`genex load player state failed (${res.status})`);
857
+ }
570
858
  const body = await res.json();
571
859
  return { data: body.data, version: body.version };
572
860
  }
@@ -589,7 +877,10 @@ async function loadWorldState() {
589
877
  await ensurePlayer();
590
878
  if (state === "guest") return { data: null, version: 0, guest: true };
591
879
  const res = await stateFetch("/state");
592
- if (!res.ok) throw new Error(`genex load world state failed (${res.status})`);
880
+ if (!res.ok) {
881
+ if (await refusedAsStaging(res)) return { data: null, version: 0, staging: true };
882
+ throw new Error(`genex load world state failed (${res.status})`);
883
+ }
593
884
  const body = await res.json();
594
885
  lastWorldVersion = body.version;
595
886
  return { data: body.data, version: body.version };
@@ -1537,6 +1828,10 @@ export {
1537
1828
  generate,
1538
1829
  requestWorkflow,
1539
1830
  resumeWorkflow,
1831
+ getSpendGrant,
1832
+ stopSpendGrant,
1833
+ requestSpendGrant,
1834
+ generationErrorMessage,
1540
1835
  initEmbed,
1541
1836
  isEmbedded,
1542
1837
  getAuthState,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  isGenerationId,
3
3
  isGenerationTerminal
4
- } from "./chunk-Q476GJHI.js";
4
+ } from "./chunk-VPIQVRVS.js";
5
5
 
6
6
  // src/development.ts
7
7
  function assertIdentifier(value) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { RuntimeWorkflowInput, RuntimeGenerationBilling, RuntimeGenerationFunding, RuntimeGenerationView, RuntimeGenerationModels, RuntimeWorkflowOfferings } from '@genex/embed-protocol';
2
- export { RuntimeBillingPolicy, RuntimeExternalProvider, RuntimeGenerationBilling, RuntimeGenerationFunding, RuntimeGenerationModels, RuntimeGenerationStatus, RuntimeGenerationView, RuntimeWorkflowInput, RuntimeWorkflowOfferings } from '@genex/embed-protocol';
1
+ import { RuntimeWorkflowInput, RuntimeGenerationBilling, RuntimeGenerationFunding, RuntimeGrantView, RuntimeGenerationView, RuntimeGenerationModels, RuntimeWorkflowOfferings } from '@genex/embed-protocol';
2
+ export { RuntimeBillingPolicy, RuntimeExternalProvider, RuntimeGenerationBilling, RuntimeGenerationFunding, RuntimeGenerationModels, RuntimeGenerationStatus, RuntimeGenerationView, RuntimeGrantStatus, RuntimeGrantTerms, RuntimeGrantView, RuntimeWorkflowInput, RuntimeWorkflowOfferings } from '@genex/embed-protocol';
3
3
 
4
4
  /** A thing the game sells. Resolved server-side; the game never sets a price. */
5
5
  interface ShopItem {
@@ -134,6 +134,13 @@ interface GenerateOptions {
134
134
  idempotencyKey?: string;
135
135
  /** Waiting time only. Reaching it does not cancel an accepted generation. */
136
136
  timeoutMs?: number;
137
+ /**
138
+ * A standing budget from requestSpendGrant(). With one, the call is admitted
139
+ * by the budget the player already approved: no popup, no parent modal and no
140
+ * user gesture. Every other guard still applies, and `allowExternal` is
141
+ * ignored because the funding door was chosen once, in the sheet.
142
+ */
143
+ grantId?: string;
137
144
  }
138
145
  interface RuntimeGenerationResult extends RuntimeGenerationBilling {
139
146
  status: 'succeeded' | 'failed' | 'unknown' | 'canceled' | 'expired' | 'pending';
@@ -176,6 +183,9 @@ declare function waitForGeneration(generationId: string, timeoutMs?: number): Pr
176
183
  * asynchronous work, so standalone games retain the browser's user gesture.
177
184
  * The player approves on Genex. Cancellation resolves normally; only API state
178
185
  * can return an output. Store generationId to recover a pending result on boot.
186
+ *
187
+ * With `grantId`, the player already approved a standing budget: the call needs
188
+ * no popup and no user gesture, and may run from a loop or a timer.
179
189
  */
180
190
  declare function generate(options: GenerateOptions): Promise<RuntimeGenerationResult>;
181
191
  /**
@@ -189,6 +199,71 @@ declare function requestWorkflow(options: RequestWorkflowOptions): Promise<Runti
189
199
  declare function resumeWorkflow(generationId: string, options?: {
190
200
  timeoutMs?: number;
191
201
  }): Promise<RuntimeWorkflowAuthorization>;
202
+ interface SpendGrantDisclosure {
203
+ /** The period the estimate below is expressed in. */
204
+ periodLabel: 'minute' | 'hour' | 'session';
205
+ /** Honest expected calls per period. The sheet shows the player a worst case beside it. */
206
+ estimatedCallsPerPeriod: number;
207
+ /** Honest expected coin per period, attributed to the game, never as a Genex figure. */
208
+ estimatedCoinsPerPeriod: number;
209
+ }
210
+ interface SpendGrantOptions {
211
+ /** Models this budget may spend on, from getGenerationModels(). One to eight. */
212
+ models: string[];
213
+ /** Hard per-call ceiling. The server refuses a call declaring more. */
214
+ perCallMaxCoins: number;
215
+ /** The honest per-call price the disclosure is built on. At most perCallMaxCoins. */
216
+ perCallEstimateCoins: number;
217
+ disclosure: SpendGrantDisclosure;
218
+ /** Calls this budget may have in flight at once. Server default applies when omitted. */
219
+ maxConcurrent?: number;
220
+ /** Calls this budget may start per minute. Server default applies when omitted. */
221
+ maxCallsPerMinute?: number;
222
+ /** Personal-plan jobs that may be queued at once. Server default applies when omitted. */
223
+ maxPendingExternal?: number;
224
+ /** Offer the player's own Claude/ChatGPT plan beside coins. Defaults off. */
225
+ allowExternal?: boolean;
226
+ /** Reuse for the same budget request across retries and reloads. */
227
+ idempotencyKey?: string;
228
+ /** Approval waiting time only. A timeout neither cancels nor charges. */
229
+ timeoutMs?: number;
230
+ }
231
+ interface SpendGrantResult {
232
+ /** `active` means the budget is approved and generate({ grantId }) may run. */
233
+ status: 'active' | 'canceled' | 'expired' | 'failed' | 'pending';
234
+ grantId?: string;
235
+ /** The authoritative server view: limit, spend, counts and the frozen terms. */
236
+ grant?: RuntimeGrantView;
237
+ error?: string;
238
+ }
239
+ /** Read a budget: limit, spend, calls and status. Scoped to this player AND this game. */
240
+ declare function getSpendGrant(grantId: string): Promise<RuntimeGrantView>;
241
+ /**
242
+ * Release a budget when the feature that needed it ends. Stopping is
243
+ * PROSPECTIVE: calls already started finish and are charged. The player has the
244
+ * same lever on Genex and it always outranks the game's.
245
+ */
246
+ declare function stopSpendGrant(grantId: string): Promise<RuntimeGrantView>;
247
+ /**
248
+ * Ask the player for a standing budget. Call it directly from a click handler,
249
+ * before awaiting anything: the trusted surface is reserved synchronously, just
250
+ * like generate(). The player picks the limit and the funding door on Genex —
251
+ * the game never sees or chooses either.
252
+ *
253
+ * Resolves `active` once the budget can serve calls; pass its `grantId` to
254
+ * generate() from then on. A player who closes the sheet resolves `canceled`.
255
+ * Store the id: getSpendGrant(id) recovers the budget after a reload.
256
+ */
257
+ declare function requestSpendGrant(options: SpendGrantOptions): Promise<SpendGrantResult>;
258
+ /**
259
+ * A player-facing sentence for any code this lane can put in `result.error`.
260
+ *
261
+ * Show it, or write your own in-fiction line keyed off the same code — never
262
+ * the raw code. Two rules the copy keeps: an uncertain outcome reads as
263
+ * uncertain rather than as failure, and no sentence here states a price, an
264
+ * allowance or a plan name. Those are the server's to show, in the Genex sheet.
265
+ */
266
+ declare function generationErrorMessage(code: string | undefined): string;
192
267
 
193
268
  interface EmbedConfig {
194
269
  /** This game's own slug (GENEX.slug) — identifies the project to /play/authorize. */
@@ -222,11 +297,20 @@ interface PlayerStateResult {
222
297
  version: number;
223
298
  /** True when the current identity is a guest (server storage untouched). */
224
299
  guest?: boolean;
300
+ /**
301
+ * True when the build is running on the PREVIEW channel — the owner's draft
302
+ * workspace, any `preview--<slug>` URL. Server saves are off there by design,
303
+ * so nothing was read: keep progress in localStorage and say "preview build",
304
+ * never "offline".
305
+ */
306
+ staging?: boolean;
225
307
  }
226
308
  interface WorldStateResult {
227
309
  data: unknown;
228
310
  version: number;
229
311
  guest?: boolean;
312
+ /** True on the preview channel — see PlayerStateResult.staging. */
313
+ staging?: boolean;
230
314
  }
231
315
  interface SaveStateResult {
232
316
  /** True when the write landed on the server. */
@@ -239,6 +323,13 @@ interface SaveStateResult {
239
323
  guest?: boolean;
240
324
  /** True when the value was queued to auto-flush if this guest signs in mid-game. */
241
325
  queued?: boolean;
326
+ /**
327
+ * True when the build is running on the PREVIEW channel (the owner's draft
328
+ * workspace, `preview--<slug>`): the server refused the write by policy, not
329
+ * by failure — nothing is queued, no `error` event fires. Keep the value in
330
+ * localStorage and tell the player it is a preview build.
331
+ */
332
+ staging?: boolean;
242
333
  }
243
334
  interface SubmitScoreResult {
244
335
  submitted: boolean;
@@ -355,14 +446,19 @@ declare function on(event: EmbedEvent, cb: (ctx?: EventContext) => void): () =>
355
446
  * Load THIS player's own save slot (per-player, per-game — another player can
356
447
  * never read or clobber it). Resolves { data: null, version: 0 } when they
357
448
  * never saved. Guests get their queued pending save (if any) so a round-trip
358
- * works mid-session; the server is untouched. Await-safe from boot: resolves
359
- * after identity exists, rejects only when the session is blocked.
449
+ * works mid-session; the server is untouched. On the preview channel (the
450
+ * owner's draft workspace) resolves { data: null, version: 0, staging: true }
451
+ * — server saves are off there by design, and that is not an error. Await-safe
452
+ * from boot: resolves after identity exists, rejects only when the session is
453
+ * blocked.
360
454
  */
361
455
  declare function loadPlayerState(): Promise<PlayerStateResult>;
362
456
  /**
363
457
  * Save THIS player's own slot (any JSON ≤ 256KB; last-write-wins on their own
364
458
  * row). Guests: the value is QUEUED in memory and auto-flushed if they sign in
365
459
  * mid-game ({ saved: false, guest: true, queued: true }) — no extra game code.
460
+ * On the preview channel: { saved: false, staging: true }, nothing queued
461
+ * (nothing would ever flush it) and no `error` event — keep it in localStorage.
366
462
  * Pass { ifVersion } (from the last load/save) to detect same-account
367
463
  * two-device races: a losing write resolves { conflict: true, version } —
368
464
  * reload, merge, retry. Small saves survive tab close (fetch keepalive).
@@ -374,7 +470,8 @@ declare function savePlayerState(data: unknown, opts?: {
374
470
  * Load the game's SHARED world slot (one blob per game — every player reads
375
471
  * the same world). Guests resolve { data: null, guest: true } — the server
376
472
  * refuses guest reads; in multiplayer, guests receive the live world through
377
- * the room instead.
473
+ * the room instead. The preview channel resolves { data: null, version: 0,
474
+ * staging: true } for the same reason the player slot does.
378
475
  */
379
476
  declare function loadWorldState(): Promise<WorldStateResult>;
380
477
  /**
@@ -429,4 +526,4 @@ declare function __resetForTests(overrides?: {
429
526
  heartbeatIntervalMs?: number;
430
527
  }): void;
431
528
 
432
- export { type AuthState, type EmbedConfig, type EmbedEvent, type EmbedUser, type Entitlement, type GenerateOptions, type Leaderboard, type LeaderboardEntry, type PlayerStateResult, type PurchaseResult, type PurchaseStatus, type RequestWorkflowOptions, type RuntimeGenerationResult, type RuntimeWorkflowAuthorization, type SaveStateResult, type ShopItem, type SubmitScoreResult, type WorldStateResult, __resetForTests, _stashTicketFromUrl, buy, consumeEntitlement, generate, getAuthState, getColyseusAuth, getColyseusUrls, getEmbedToken, getEntitlements, getGeneration, getGenerationModels, getLeaderboard, getShop, getUser, getWorkflowOfferings, initEmbed, isEmbedded, loadPlayerState, loadWorldState, on, requestWorkflow, resumeWorkflow, savePlayerState, saveWorldState, submitScore, waitForAuth, waitForGeneration, waitForPlayer };
529
+ export { type AuthState, type EmbedConfig, type EmbedEvent, type EmbedUser, type Entitlement, type GenerateOptions, type Leaderboard, type LeaderboardEntry, type PlayerStateResult, type PurchaseResult, type PurchaseStatus, type RequestWorkflowOptions, type RuntimeGenerationResult, type RuntimeWorkflowAuthorization, type SaveStateResult, type ShopItem, type SpendGrantDisclosure, type SpendGrantOptions, type SpendGrantResult, type SubmitScoreResult, type WorldStateResult, __resetForTests, _stashTicketFromUrl, buy, consumeEntitlement, generate, generationErrorMessage, getAuthState, getColyseusAuth, getColyseusUrls, getEmbedToken, getEntitlements, getGeneration, getGenerationModels, getLeaderboard, getShop, getSpendGrant, getUser, getWorkflowOfferings, initEmbed, isEmbedded, loadPlayerState, loadWorldState, on, requestSpendGrant, requestWorkflow, resumeWorkflow, savePlayerState, saveWorldState, stopSpendGrant, submitScore, waitForAuth, waitForGeneration, waitForPlayer };
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  buy,
5
5
  consumeEntitlement,
6
6
  generate,
7
+ generationErrorMessage,
7
8
  getAuthState,
8
9
  getColyseusAuth,
9
10
  getColyseusUrls,
@@ -13,6 +14,7 @@ import {
13
14
  getGenerationModels,
14
15
  getLeaderboard,
15
16
  getShop,
17
+ getSpendGrant,
16
18
  getUser,
17
19
  getWorkflowOfferings,
18
20
  initEmbed,
@@ -20,22 +22,25 @@ import {
20
22
  loadPlayerState,
21
23
  loadWorldState,
22
24
  on,
25
+ requestSpendGrant,
23
26
  requestWorkflow,
24
27
  resumeWorkflow,
25
28
  savePlayerState,
26
29
  saveWorldState,
30
+ stopSpendGrant,
27
31
  submitScore,
28
32
  waitForAuth,
29
33
  waitForGeneration,
30
34
  waitForPlayer
31
- } from "./chunk-IFI2NYBC.js";
32
- import "./chunk-Q476GJHI.js";
35
+ } from "./chunk-GR56KHWN.js";
36
+ import "./chunk-VPIQVRVS.js";
33
37
  export {
34
38
  __resetForTests,
35
39
  _stashTicketFromUrl,
36
40
  buy,
37
41
  consumeEntitlement,
38
42
  generate,
43
+ generationErrorMessage,
39
44
  getAuthState,
40
45
  getColyseusAuth,
41
46
  getColyseusUrls,
@@ -45,6 +50,7 @@ export {
45
50
  getGenerationModels,
46
51
  getLeaderboard,
47
52
  getShop,
53
+ getSpendGrant,
48
54
  getUser,
49
55
  getWorkflowOfferings,
50
56
  initEmbed,
@@ -52,10 +58,12 @@ export {
52
58
  loadPlayerState,
53
59
  loadWorldState,
54
60
  on,
61
+ requestSpendGrant,
55
62
  requestWorkflow,
56
63
  resumeWorkflow,
57
64
  savePlayerState,
58
65
  saveWorldState,
66
+ stopSpendGrant,
59
67
  submitScore,
60
68
  waitForAuth,
61
69
  waitForGeneration,
package/dist/sentry.js CHANGED
@@ -2,8 +2,8 @@ import {
2
2
  _stashTicketFromUrl,
3
3
  getUser,
4
4
  on
5
- } from "./chunk-IFI2NYBC.js";
6
- import "./chunk-Q476GJHI.js";
5
+ } from "./chunk-GR56KHWN.js";
6
+ import "./chunk-VPIQVRVS.js";
7
7
 
8
8
  // src/sentry.ts
9
9
  import * as Sentry from "@sentry/browser";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/embed-sdk",
3
- "version": "0.22.3",
3
+ "version": "0.24.0",
4
4
  "description": "Player identity + durable game state for genex games \u2014 signed-in or guest play, per-player save slots, shared world state, and soft-trust leaderboards.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
File without changes