@genex-ai/embed-sdk 0.23.0 → 0.24.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/README.md CHANGED
@@ -16,8 +16,11 @@ The game never handles an OpenRouter key or confirms a charge itself.
16
16
  import { generate, getGenerationModels } from '@genex-ai/embed-sdk';
17
17
 
18
18
  // After initEmbed() and player sign-in, populate your model picker from Genex.
19
- const { models } = await getGenerationModels();
20
- const model = models[0];
19
+ // The list is featured-first: show the featured models unless the user asked to
20
+ // see more, and never a label that differs from the model you bill.
21
+ const { models, featuredCount } = await getGenerationModels();
22
+ const shortlist = featuredCount ? models.filter((m) => m.featured) : models;
23
+ const model = shortlist[0];
21
24
 
22
25
  generateButton.addEventListener('click', async () => {
23
26
  if (!model) return;
@@ -48,8 +51,9 @@ return `native_unsupported` and `local_test_unsupported` until they have support
48
51
  confirmation surfaces.
49
52
 
50
53
  `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
54
+ optional bounded `schema`, `allowExternal` (default false), `idempotencyKey`,
55
+ `timeoutMs` (default ten minutes) and `grantId` a standing budget the player
56
+ already approved, which removes the popup entirely (see *Standing budgets*). Reuse an idempotency key only for the same
53
57
  operation; store it if you need retries to survive a reload. Only models returned
54
58
  by Genex are accepted. This first adapter generates text and JSON, including
55
59
  creature descriptions, dialogue, rules and other structured game data.
@@ -107,6 +111,128 @@ nor hosts subscription credentials.
107
111
  generated it; `modelProvenance: 'unverified'` makes that explicit. Treat every generated output as data. Never execute returned code
108
112
  or use a claimed model/source as authority to mint coins, rewards or items.
109
113
 
114
+ ## Standing budgets
115
+
116
+ SDK **0.24.0+**. A one-time `generate()` asks the player once per call. When a
117
+ feature calls the model continuously — a thinking NPC, a director rewriting
118
+ the next encounter — ask for a **standing budget** instead: the player approves
119
+ once, sets their own limit on a Genex slider, and the game then calls the model
120
+ with no popup at all until the limit, a stop, or the budget's expiry.
121
+
122
+ ```ts
123
+ import { requestSpendGrant, generate, getSpendGrant, stopSpendGrant, generationErrorMessage } from '@genex-ai/embed-sdk';
124
+
125
+ let grantId: string | undefined;
126
+
127
+ // Call it from the click, before awaiting: the approval surface is reserved
128
+ // synchronously, exactly like generate().
129
+ talkButton.addEventListener('click', async () => {
130
+ if (!grantId) {
131
+ const budget = await requestSpendGrant({
132
+ models: [model.id],
133
+ perCallMaxCoins: 8, // Hard ceiling per call. The server refuses more.
134
+ perCallEstimateCoins: 5, // The honest per-call price, benchmarked first.
135
+ disclosure: { periodLabel: 'minute', estimatedCallsPerPeriod: 4, estimatedCoinsPerPeriod: 20 },
136
+ maxConcurrent: 2,
137
+ idempotencyKey: 'npc-conversation-budget',
138
+ });
139
+ if (budget.status !== 'active') return showNotice(generationErrorMessage(budget.error));
140
+ grantId = budget.grantId;
141
+ }
142
+ const reply = await generate({ ...request, grantId });
143
+ if (reply.status === 'succeeded') applyGeneration(reply);
144
+ else showNotice(generationErrorMessage(reply.error));
145
+ });
146
+ ```
147
+
148
+ `requestSpendGrant()` resolves `active` once the budget can serve calls,
149
+ `canceled` when the player closes the sheet or stops it, `expired` when the
150
+ approval window passes, `pending` with `error: 'wait_timeout'` when the SDK
151
+ stopped waiting (the sheet may still be open), and `failed` with a server code.
152
+ The successful result carries `grantId` and the full server `grant` view.
153
+
154
+ `generate({ grantId })` runs one call under that budget. It opens **no window,
155
+ posts no parent message and needs no user gesture**, so it works from a game
156
+ loop or a timer. Every other guard is unchanged: production play, a signed-in
157
+ player, a trusted parent origin, and no native or local-test surface. The
158
+ funding door was chosen once in the sheet, so `allowExternal` is ignored on a
159
+ budget call. Everything else is the ordinary generation result, including the
160
+ receipt: a started attempt is charged its fixed price whether it succeeds,
161
+ fails, or is stopped.
162
+
163
+ `getSpendGrant(id)` is the readout to draw in the game: `limitCoins`,
164
+ `spentCoins`, `heldCoins` (admitted, not settled yet), `remainingCoins`,
165
+ `limitCalls`, `callCount`, `status` and the frozen `terms`. Poll it on the
166
+ cadence your HUD needs — never derive the remaining budget from your own count
167
+ of calls, and never show a figure the server did not send. Store `grantId` the
168
+ way you store a `generationId`; `getSpendGrant()` recovers the budget after a
169
+ reload, and a budget whose status is no longer live simply needs a new
170
+ approval. `stopSpendGrant(id)` releases it when your feature ends. Stopping is
171
+ prospective: calls already started finish and are charged. The player has the
172
+ same lever on Genex, on every page, and theirs always outranks the game's.
173
+
174
+ **The disclosure is a promise, not a guess.** `disclosure` is what the Genex
175
+ sheet shows the player, attributed to your game — Genex prints a server-computed
176
+ worst case beside it, so an optimistic number is visible as one. Declare the
177
+ rate you actually expect, benchmark `perCallEstimateCoins` before you declare it
178
+ (see *Benchmark your own coin costs* below), and keep `perCallMaxCoins` a real
179
+ ceiling. The server refuses a call whose declared price is out of proportion to
180
+ the model's own cost (`grant_price_unreasonable`) — that check is what replaces
181
+ the popup, so a budget is never a blank cheque. The slider's bounds, the coin
182
+ value and the recommended headroom all come from the server; read them from
183
+ `getGenerationModels()` (`standingGrant`, `recommendedDeclaredHeadroomBps`)
184
+ rather than typing any of them into the game.
185
+
186
+ **Handle the refusals as game states.** A budget ends for ordinary reasons and
187
+ the game should have believable copy for each:
188
+ `grant_limit_reached` (the player's limit is spent), `grant_stopped`,
189
+ `grant_expired`, `grant_not_active`, `grant_concurrency` and
190
+ `grant_rate_limited` (your own declared ceilings), `grant_insufficient_funds`
191
+ (the wallet ran low — the budget resumes when it is topped up),
192
+ `waiting_for_plan` (a personal plan's own rate limit; it resumes by itself),
193
+ `grant_price_unreasonable`, `grant_already_active`, `external_grant_active` and
194
+ `invalid_grant_limit`. `generationErrorMessage(code)` returns a player-facing
195
+ sentence for every one of these and for every one-time code as well; show it, or
196
+ write your own in-fiction line keyed off the same code. Never show the raw code,
197
+ and never treat `unknown` as failure — it means the outcome is not settled yet.
198
+
199
+ **The receiver pattern.** Persistence is the game's job, and it is what decides
200
+ whether a generated thing survives a reload:
201
+
202
+ - Write exactly one function, `applyGeneration(result)`, that turns an output
203
+ into game state and saves it through the SDK's player-state API.
204
+ - In the click path, save the `generationId` (and `grantId`) into player state
205
+ **before** awaiting, then call `applyGeneration`.
206
+ - On boot, call `waitForGeneration(savedId)` for any stored id and call the
207
+ *same* `applyGeneration`.
208
+
209
+ One writer, two entry points: the difference between something that works once
210
+ and something that survives a reload. Validate `result.output` against the shape
211
+ your game expects before using it, always. It is data, never authority for
212
+ coins, rewards or items.
213
+
214
+ ## Your subscription and the Genex player watcher
215
+
216
+ Both approval sheets can offer the player their own Claude or ChatGPT plan
217
+ beside coins, when the selected model's provider matches. That choice lives in
218
+ the trusted Genex surface only — never add a plan row to the game's own model
219
+ picker, and never ask a player for a subscription credential. Genex neither
220
+ collects nor hosts them.
221
+
222
+ A personal-plan request is answered on the player's own computer by the **Genex
223
+ player watcher**, a small program that runs the official `claude` / `codex` CLIs
224
+ they signed into themselves. If a player picks their plan and has not installed
225
+ it, Genex shows them the one-time prompt: *"Install the Genex player watcher:
226
+ run `npx @genex-ai/cli-demo@latest player install` and approve it on Genex."*
227
+ The game does not install, start or speak to the watcher; it only reads
228
+ `watcherOnline` on a grant or generation view when Genex includes it. A
229
+ personal-plan result costs zero coin, is metered in requests rather than coin,
230
+ and arrives with `source: 'external'` and `modelProvenance: 'unverified'`:
231
+ user-supplied output, never proof that a particular model produced it, and
232
+ never authority for money or rewards. When the player's own plan hits its rate limit
233
+ the budget reports `waiting_for_plan` and resumes on its own; it never falls back
234
+ to paid work.
235
+
110
236
  ## Registered generation workflows
111
237
 
112
238
  SDK **0.21.0+** supports multi-step generation and usage receipts through an
@@ -207,6 +333,11 @@ describes both APIs, executor authorization, recovery and connector endpoints.
207
333
 
208
334
  ## Benchmark your own coin costs on the server
209
335
 
336
+ `npx genex llm bench` is the CLI door onto this lane: it runs the real model
337
+ against your own coin wallet, reports what it actually charged, and recommends
338
+ the price to declare. Reach for the API below when you want the samples in your
339
+ own script.
340
+
210
341
  SDK **0.21.0+** provides `@genex-ai/embed-sdk/development`. Use it only in a
211
342
  local Node script or trusted server with your own full creator bearer credential
212
343
  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" };
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 {
269
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.23.0";
580
+ var SDK_VERSION = "0.24.1";
302
581
  var config2 = null;
303
582
  var state = "pending";
304
583
  var user = null;
@@ -1549,6 +1828,10 @@ export {
1549
1828
  generate,
1550
1829
  requestWorkflow,
1551
1830
  resumeWorkflow,
1831
+ getSpendGrant,
1832
+ stopSpendGrant,
1833
+ requestSpendGrant,
1834
+ generationErrorMessage,
1552
1835
  initEmbed,
1553
1836
  isEmbedded,
1554
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. */
@@ -451,4 +526,4 @@ declare function __resetForTests(overrides?: {
451
526
  heartbeatIntervalMs?: number;
452
527
  }): void;
453
528
 
454
- 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-CBR3BK66.js";
32
- import "./chunk-Q476GJHI.js";
35
+ } from "./chunk-ETIZW77E.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-CBR3BK66.js";
6
- import "./chunk-Q476GJHI.js";
5
+ } from "./chunk-ETIZW77E.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.23.0",
3
+ "version": "0.24.1",
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