@gemmein/sdk 0.6.0 → 0.8.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/CHANGELOG.md +120 -0
- package/REFERENCE.md +229 -11
- package/dist/index.cjs +214 -2
- package/dist/index.d.cts +144 -5
- package/dist/index.d.ts +144 -5
- package/dist/index.js +211 -1
- package/llms.txt +257 -21
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
5
5
|
* package.json before every build (`prebuild`), and a test pins the two
|
|
6
6
|
* equal, so a bump can never ship with a stale header. */
|
|
7
|
-
export const SDK_VERSION = "0.
|
|
7
|
+
export const SDK_VERSION = "0.8.0"; // synced from package.json — do not edit by hand
|
|
8
8
|
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
9
9
|
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
10
10
|
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
@@ -116,6 +116,8 @@ export class Gemmein {
|
|
|
116
116
|
this.purchases = new PurchasesClient(config);
|
|
117
117
|
this.account = new AccountClient(config);
|
|
118
118
|
this.files = new FilesClient(config);
|
|
119
|
+
this.credits = new CreditsClient(config);
|
|
120
|
+
this.ai = new AiClient(config);
|
|
119
121
|
}
|
|
120
122
|
/**
|
|
121
123
|
* Your app's data — `g.collection<{ title: string }>("notes")`. The
|
|
@@ -357,6 +359,177 @@ export class AccountClient {
|
|
|
357
359
|
return result;
|
|
358
360
|
}
|
|
359
361
|
}
|
|
362
|
+
/** The signed-in person's own credits — so an app can draw its own meter. */
|
|
363
|
+
export class CreditsClient {
|
|
364
|
+
constructor(config) {
|
|
365
|
+
this.config = config;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The balance RIGHT NOW, server-resolved — the number that refuses at
|
|
369
|
+
* zero, never client math. Session required (`session_required`, 401).
|
|
370
|
+
*
|
|
371
|
+
* const { balance } = await g.credits.balance();
|
|
372
|
+
*/
|
|
373
|
+
async balance() {
|
|
374
|
+
return runtimeRequest(this.config, "/auth/credits");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The AI route. `chat` takes the provider's own request body — exactly what
|
|
379
|
+
* you would POST to OpenAI's /v1/chat/completions, Anthropic's /v1/messages
|
|
380
|
+
* or Google's generateContent — and answers with the fetch `Response`
|
|
381
|
+
* untouched, streaming intact (SSE stays SSE). Gemmein spends a credit,
|
|
382
|
+
* adds the owner's key, forwards, and passes status and bytes back. Pass
|
|
383
|
+
* `tool` (W9.3b) to run a named, owner-priced-and-gated operation instead
|
|
384
|
+
* of the implicit default (one credit, any allowed model, no gate).
|
|
385
|
+
* Response headers: `x-gemmein-credits-remaining` on every answer that
|
|
386
|
+
* passed the spend; `x-gemmein-credit: refunded` when the provider failed
|
|
387
|
+
* before its first byte.
|
|
388
|
+
*/
|
|
389
|
+
export class AiClient {
|
|
390
|
+
constructor(config) {
|
|
391
|
+
this.config = config;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* const res = await g.ai.chat({ model: "gpt-4o-mini", messages, stream: true });
|
|
395
|
+
* for await (const chunk of res.body) { … }
|
|
396
|
+
*
|
|
397
|
+
* Browser sessions only — a server key is refused (`scope_denied`, 403).
|
|
398
|
+
* Refusals, all `GemmeinError`: `session_required` (401) ·
|
|
399
|
+
* `credits_exhausted` (402 — the message carries the balance; show your
|
|
400
|
+
* own "buy more" door, which is a product checkout) · `ai_not_configured`
|
|
401
|
+
* (409 — the owner has set no key) · `provider_required` (400) ·
|
|
402
|
+
* `model_not_allowed` (403 — the owner's models list) · `ai_capped`
|
|
403
|
+
* (429 — 20 calls a minute per person; `err.resetAt`) ·
|
|
404
|
+
* `payload_too_large` (413 — 256 KB) · `provider_unreachable` (502,
|
|
405
|
+
* before the first byte, refunded). W9.3b, `tool` only: `unknown_tool`
|
|
406
|
+
* (404 — no tool by that name in this environment) · `tool_disabled`
|
|
407
|
+
* (403) · `entitlement_required` (403 — the message names the plan or
|
|
408
|
+
* product that unlocks it) · `model_pinned` (403 — the tool's model is
|
|
409
|
+
* fixed; leave `model` out of the body). Those are GEMMEIN's refusals.
|
|
410
|
+
* The PROVIDER's own answer — 2xx or not — is returned as it came: an
|
|
411
|
+
* answer that carries `x-gemmein-credits-remaining` passed the spend, so
|
|
412
|
+
* its status and body are the provider's; read `res.ok` / `res.status`
|
|
413
|
+
* yourself (a provider 4xx before the first byte is refunded, header
|
|
414
|
+
* `x-gemmein-credit: refunded`). `x-gemmein-tool` names the tool; absent
|
|
415
|
+
* on the implicit default.
|
|
416
|
+
*/
|
|
417
|
+
async chat(body, options = {}) {
|
|
418
|
+
const payload = options.provider ? { provider: options.provider, ...body } : body;
|
|
419
|
+
const url = new URL("/ai/chat", this.config.apiUrl);
|
|
420
|
+
if (options.tool)
|
|
421
|
+
url.searchParams.set("tool", options.tool);
|
|
422
|
+
const response = await fetch(url, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
body: JSON.stringify(payload),
|
|
425
|
+
headers: await runtimeHeaders(this.config, { "content-type": "application/json" }),
|
|
426
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
427
|
+
});
|
|
428
|
+
if (!response.ok) {
|
|
429
|
+
if (isForwardedAnswer(response)) {
|
|
430
|
+
// Past the spend: the provider answered. `provider_unreachable` is
|
|
431
|
+
// the one Gemmein refusal written after the spend (it carries the
|
|
432
|
+
// refund header) — peek without consuming so it stays typed.
|
|
433
|
+
const peek = (await response.clone().json().catch(() => null));
|
|
434
|
+
if (peek?.code !== "provider_unreachable")
|
|
435
|
+
return response;
|
|
436
|
+
}
|
|
437
|
+
const errorBody = await readErrorBody(response);
|
|
438
|
+
if (errorBody.code === "auth_expired")
|
|
439
|
+
await this.config.tokenStore.clear();
|
|
440
|
+
throw new GemmeinError({ status: response.status, ...errorBody });
|
|
441
|
+
}
|
|
442
|
+
return response;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The non-streaming convenience: one call, one string. Pass a body that
|
|
446
|
+
* does NOT stream (`stream` unset or false); the provider's JSON answer is
|
|
447
|
+
* read whole and the text is lifted out per provider — OpenAI
|
|
448
|
+
* `choices[0].message.content`, Anthropic `content[].text` joined,
|
|
449
|
+
* Google `candidates[0].content.parts[].text` joined. An answer with no
|
|
450
|
+
* text in any of those places throws `invalid_response` (status 0). A
|
|
451
|
+
* provider's own non-2xx throws `provider_error` with the provider's
|
|
452
|
+
* status and the provider's message (the owner's key masked to its
|
|
453
|
+
* hint if the provider echoed it).
|
|
454
|
+
*
|
|
455
|
+
* const answer = await g.ai.text({ model: "claude-sonnet-4-5", max_tokens: 400, messages });
|
|
456
|
+
*/
|
|
457
|
+
async text(body, options = {}) {
|
|
458
|
+
const response = await this.chat(body, options);
|
|
459
|
+
if (!response.ok) {
|
|
460
|
+
throw new GemmeinError({
|
|
461
|
+
status: response.status,
|
|
462
|
+
code: "provider_error",
|
|
463
|
+
message: await providerErrorMessage(response),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const data = (await response.json());
|
|
467
|
+
const text = extractAiText(data);
|
|
468
|
+
if (text === null) {
|
|
469
|
+
throw new GemmeinError({
|
|
470
|
+
status: 0,
|
|
471
|
+
code: "invalid_response",
|
|
472
|
+
message: "the provider answered without any text — for a streaming body use g.ai.chat() and read the stream",
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return text;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/** An answer that passed the spend: the engine stamps the balance (and, on
|
|
479
|
+
* a refund, `x-gemmein-credit`) only after the credit moved — a refusal
|
|
480
|
+
* before the spend carries neither. */
|
|
481
|
+
function isForwardedAnswer(response) {
|
|
482
|
+
return response.headers.has("x-gemmein-credits-remaining") || response.headers.has("x-gemmein-credit");
|
|
483
|
+
}
|
|
484
|
+
/** The provider's own reason, whichever shape it used — OpenAI, Anthropic
|
|
485
|
+
* and Google all nest it as `error.message`; anything else is the text. */
|
|
486
|
+
async function providerErrorMessage(response) {
|
|
487
|
+
const text = await response.text().catch(() => "");
|
|
488
|
+
try {
|
|
489
|
+
const parsed = JSON.parse(text);
|
|
490
|
+
if (parsed && typeof parsed === "object") {
|
|
491
|
+
const nested = typeof parsed.error === "object" && parsed.error !== null ? parsed.error.message : parsed.error;
|
|
492
|
+
const message = typeof nested === "string" ? nested : typeof parsed.message === "string" ? parsed.message : null;
|
|
493
|
+
if (message)
|
|
494
|
+
return message;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// not JSON — the text is the reason
|
|
499
|
+
}
|
|
500
|
+
return text.trim().slice(0, 500) || `the provider answered ${response.status}`;
|
|
501
|
+
}
|
|
502
|
+
/** The per-provider lift, by shape (a provider's answer is unmistakable). */
|
|
503
|
+
function extractAiText(data) {
|
|
504
|
+
if (!data || typeof data !== "object")
|
|
505
|
+
return null;
|
|
506
|
+
const d = data;
|
|
507
|
+
// OpenAI: choices[0].message.content (a string, or content parts)
|
|
508
|
+
if (Array.isArray(d.choices)) {
|
|
509
|
+
const message = d.choices[0]?.message;
|
|
510
|
+
const content = message?.content;
|
|
511
|
+
if (typeof content === "string")
|
|
512
|
+
return content;
|
|
513
|
+
if (Array.isArray(content))
|
|
514
|
+
return joinTextParts(content);
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
// Anthropic: content[] blocks, the text ones joined
|
|
518
|
+
if (Array.isArray(d.content))
|
|
519
|
+
return joinTextParts(d.content);
|
|
520
|
+
// Google: candidates[0].content.parts[].text joined
|
|
521
|
+
if (Array.isArray(d.candidates)) {
|
|
522
|
+
const parts = d.candidates[0]?.content?.parts;
|
|
523
|
+
return Array.isArray(parts) ? joinTextParts(parts) : null;
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
function joinTextParts(parts) {
|
|
528
|
+
const texts = parts
|
|
529
|
+
.map((p) => (p && typeof p === "object" && typeof p.text === "string" ? p.text : null))
|
|
530
|
+
.filter((t) => t !== null);
|
|
531
|
+
return texts.length > 0 ? texts.join("") : null;
|
|
532
|
+
}
|
|
360
533
|
function isSessionResponse(value) {
|
|
361
534
|
return (typeof value === "object" &&
|
|
362
535
|
value !== null &&
|
|
@@ -935,6 +1108,43 @@ export class GemmeinServer {
|
|
|
935
1108
|
}),
|
|
936
1109
|
});
|
|
937
1110
|
}
|
|
1111
|
+
/**
|
|
1112
|
+
* W9.3 — spend a person's credits from YOUR server, with a reason:
|
|
1113
|
+
*
|
|
1114
|
+
* const { balance } = await g.spendCredits(personId, {
|
|
1115
|
+
* amount: 5, // default 1; 1..10,000 per call
|
|
1116
|
+
* reason: "render:4k", // what the owner reads in the ledger
|
|
1117
|
+
* key: `render:${jobId}`, // your retry key — a repeat is deduped
|
|
1118
|
+
* });
|
|
1119
|
+
* // { ok: true, spent: 5, deduped: false, balance: { before: 20, after: 15 },
|
|
1120
|
+
* // event: { id: "cev_…", reason: "render:4k", actor: "<your key's name>" } }
|
|
1121
|
+
*
|
|
1122
|
+
* ONE conditional update, floored at 0: the spend succeeds whole or not at
|
|
1123
|
+
* all, and `402 credits_exhausted` says "this person has {balance} credits
|
|
1124
|
+
* — the spend needs {amount}". Pass `key` when the caller can retry — the
|
|
1125
|
+
* same key answers the first spend again with `deduped: true`, `spent: 0`,
|
|
1126
|
+
* the same `event` and the balance untouched. The key is scoped to the
|
|
1127
|
+
* person: one order id reused for two people charges both. Credits come
|
|
1128
|
+
* from a pack they bought, a comp in the back office, or a relay; a key
|
|
1129
|
+
* cannot mint them.
|
|
1130
|
+
*
|
|
1131
|
+
* Needs the key's "Spend a person's credits" box ticked by your human.
|
|
1132
|
+
* Refusals: `capability_required` (403 — "this key can't spend credits —
|
|
1133
|
+
* mint a key with 'Spend a person's credits' ticked") ·
|
|
1134
|
+
* `credits_exhausted` (402) · `person_not_found` (404) · `invalid_amount` /
|
|
1135
|
+
* `invalid_reason` / `invalid_key` (400) · `dedupe_conflict` (409 — the
|
|
1136
|
+
* key already names a different movement).
|
|
1137
|
+
*/
|
|
1138
|
+
async spendCredits(personId, input) {
|
|
1139
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/credits/spend`, {
|
|
1140
|
+
method: "POST",
|
|
1141
|
+
body: JSON.stringify({
|
|
1142
|
+
...(input.amount !== undefined ? { amount: input.amount } : {}),
|
|
1143
|
+
reason: input.reason,
|
|
1144
|
+
...(input.key !== undefined ? { key: input.key } : {}),
|
|
1145
|
+
}),
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
938
1148
|
/**
|
|
939
1149
|
* End one grant — the reversibility law in one call:
|
|
940
1150
|
*
|
package/llms.txt
CHANGED
|
@@ -39,7 +39,11 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
39
39
|
safety rules (private, shared, admin_write, public_read, community,
|
|
40
40
|
addressed, direct), subscriptions and one-off digital products (Stripe
|
|
41
41
|
built in; any provider that signs its webhooks drives access through a
|
|
42
|
-
relay),
|
|
42
|
+
relay), consumable credits (buy a pack, spend atomically, a zero
|
|
43
|
+
floor — see Credits), a chat call to OpenAI, Anthropic or Google on
|
|
44
|
+
the founder's own key, at the credits the owner set for that tool
|
|
45
|
+
(one by default — see The AI route),
|
|
46
|
+
image uploads and paid file delivery, entitlement-gated
|
|
43
47
|
access, race-safe bookings/stock/counters, account-deletion cascade.
|
|
44
48
|
Out of scope — each of these downgrades the verdict and must never be
|
|
45
49
|
silently approximated:
|
|
@@ -50,9 +54,11 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
50
54
|
nothing in between — so an app whose CORE loop needs teams, roles
|
|
51
55
|
or seats DOESN'T FIT
|
|
52
56
|
- carts, quantities, physical goods, shipping, inventory
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
- usage-based INVOICING (a bill after the fact, by token or by minute):
|
|
58
|
+
not shipped. Credits ARE — consumable credits: buy a pack, spend
|
|
59
|
+
atomically, a zero floor — see Credits; a "100 credit pack" is a
|
|
60
|
+
product with Grants credits = 100, and an AI tool call costs what
|
|
61
|
+
the owner priced that tool at (one credit by default)
|
|
56
62
|
- seats (owner-run: not coming)
|
|
57
63
|
- hosted functions or cron: Gemmein runs no code of yours — but your
|
|
58
64
|
own function anywhere asks Gemmein who a person is and what they
|
|
@@ -391,14 +397,15 @@ contents.
|
|
|
391
397
|
what the person holds NOW: `access` (keys like "access:pro"), `grants`
|
|
392
398
|
(each with its source KIND only — subscription | purchase | manual |
|
|
393
399
|
trial | promotion | migration | relay — plus start and expiry), and `credits`,
|
|
394
|
-
|
|
395
|
-
|
|
400
|
+
`{ balance }` — how many credits the person holds now (engine 0.8.0 and
|
|
401
|
+
later; `null` from an older local engine). Never subscription status, amounts
|
|
396
402
|
or Stripe ids: gate on what a person HOLDS, never on billing. Person
|
|
397
403
|
id, NEVER an email address. verifySession needs nothing extra; looking
|
|
398
404
|
someone up by id and granting are new power, so they sit behind per-key
|
|
399
405
|
checkboxes the HUMAN ticks when minting the key — ask your human to
|
|
400
406
|
tick "Look up a person's access by id" / "Grant and revoke access" /
|
|
401
|
-
"Create a person by email before they sign in" (invitePerson)
|
|
407
|
+
"Create a person by email before they sign in" (invitePerson) /
|
|
408
|
+
"Spend a person's credits" (spendCredits). A
|
|
402
409
|
key grants MANUAL access only (manual | trial | promotion | migration):
|
|
403
410
|
purchases and subscriptions come from the built-in Stripe path, and
|
|
404
411
|
another provider's payment grants through a relay (source `relay`). Every /server/*
|
|
@@ -442,17 +449,21 @@ contents.
|
|
|
442
449
|
writes the receipt, tells the person, passes the signal to your URL.
|
|
443
450
|
Stripe stays built in; any provider that signs its webhooks drives
|
|
444
451
|
access the same way.
|
|
445
|
-
Does: write a record · grant access · revoke access ·
|
|
446
|
-
|
|
452
|
+
Does: write a record · grant access · revoke access · grant credits ·
|
|
453
|
+
fulfil or refund a product · email the person the event is about · call
|
|
454
|
+
your URL (a signed notice to a server YOU run).
|
|
447
455
|
Does not: run your code; call OpenAI or any third API for you (call_url
|
|
448
456
|
carries Gemmein's body and signature, never your key or a request you
|
|
449
457
|
shape); attach a person to a schedule (no "email everyone due tomorrow"
|
|
450
458
|
yet); transform or compute data; act on more than one person per event
|
|
451
|
-
(a list is a broadcast);
|
|
459
|
+
(a list is a broadcast); spend credits (a spend is your server's or the
|
|
460
|
+
AI route's — a relay only adds).
|
|
452
461
|
Needs something else when: the work THINKS — a model call, a score, a
|
|
453
462
|
transform, a third API — put that in your own function behind call_url;
|
|
454
463
|
it receives a verified event with the person already resolved. The AI
|
|
455
|
-
route
|
|
464
|
+
route is where Gemmein holds your provider key and makes a chat call for
|
|
465
|
+
a signed-in person at the tool's credit price (one by default) — see
|
|
466
|
+
The AI route.
|
|
456
467
|
Use a relay when: a provider other than Stripe signs a webhook that
|
|
457
468
|
should change access or write a record (GoCardless, Paddle, Lemon
|
|
458
469
|
Squeezy, a form or signature tool); a record change should tell the
|
|
@@ -504,9 +515,59 @@ contents.
|
|
|
504
515
|
- grant_access { entitlement, expiresAt?, reason? } — source `relay`, the
|
|
505
516
|
seventh grant source; "granted by relay <name>" on the person's page.
|
|
506
517
|
- revoke_access { entitlement } — ends every live grant of it.
|
|
518
|
+
- grant_credits { amount (1..10,000), reason? } — adds to the person's
|
|
519
|
+
balance, once per event; the ledger line reads "relay: <reason>".
|
|
507
520
|
- email_person { subject, text, kind? } — rides notify's caps.
|
|
508
521
|
- call_url { url } — https, signed POST (`X-Gemmein-Signature`), 10 s; the
|
|
509
522
|
same `id` on every retry and replay — your URL deduplicates on it.
|
|
523
|
+
- fulfil_product { product, ref? } — product is a name on the Payments
|
|
524
|
+
page; ref is the provider's payment reference.
|
|
525
|
+
- refund_product { product, ref? } — the refund twin.
|
|
526
|
+
fulfil_product grants the product's key and credits to the event's
|
|
527
|
+
person and writes the receipt, exactly as a Stripe purchase does;
|
|
528
|
+
refund_product takes them back. Both are idempotent on ref: a ref must
|
|
529
|
+
be the provider's unique payment identifier; a ref already used by
|
|
530
|
+
another purchase is refused and audited. A ref you wrote that names
|
|
531
|
+
nothing on the event is refused, never replaced: only an absent ref
|
|
532
|
+
falls back to rly:<eventId>. The relay road binds by name for
|
|
533
|
+
FULFILMENT: renaming or deleting the relay stops fulfilment until a
|
|
534
|
+
relay with that name exists again; the product card shows it.
|
|
535
|
+
refund_product may run from any relay in the environment; it refunds
|
|
536
|
+
only a purchase a relay fulfilled, for the event's person and the
|
|
537
|
+
product it names. This is how
|
|
538
|
+
a product sold via a relay (How it's sold = a relay's name, not a
|
|
539
|
+
Stripe Payment Link) gets fulfilled — a provider without Payment Links
|
|
540
|
+
(GoCardless, Lemon Squeezy, Paddle, bank transfer) confirms a payment,
|
|
541
|
+
the relay fulfils the product:
|
|
542
|
+
|
|
543
|
+
{
|
|
544
|
+
"name": "gocardless-paid",
|
|
545
|
+
"trigger": {
|
|
546
|
+
"kind": "receiver",
|
|
547
|
+
"verify": { "scheme": "shared_token" },
|
|
548
|
+
"map": {
|
|
549
|
+
"event_id": "events.0.id",
|
|
550
|
+
"event_type": "events.0.action",
|
|
551
|
+
"person_email": "events.0.details.customer_email",
|
|
552
|
+
"payment_id": "events.0.links.payment"
|
|
553
|
+
},
|
|
554
|
+
"when": { "event_type": "confirmed" }
|
|
555
|
+
},
|
|
556
|
+
"actions": [
|
|
557
|
+
{ "type": "fulfil_product", "product": "Starter pack", "ref": "{{mapped.payment_id}}" }
|
|
558
|
+
]
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
Mapped fields ride into templates as {{mapped.<name>}}; {{event.<path>}}
|
|
562
|
+
reads the raw payload.
|
|
563
|
+
A template that names nothing is recorded as a warning on the relay
|
|
564
|
+
event — read it on the Relays page before trusting a ref.
|
|
565
|
+
|
|
566
|
+
The refund twin swaps the trigger's `when` to
|
|
567
|
+
`{ "event_type": "refunded" }` and the action to `refund_product`.
|
|
568
|
+
`g.payments.buy` on a product sold this way answers 409
|
|
569
|
+
`product_not_sellable`; saving a relay action against a relay name that
|
|
570
|
+
does not exist in this environment answers 400 `relay_missing`.
|
|
510
571
|
Full depth — every verify scheme, the map and when grammar, the call_url
|
|
511
572
|
contract and signature, every refusal code, the limits and both rails:
|
|
512
573
|
https://docs.gemmein.com/relays (the same chapter is REFERENCE.md
|
|
@@ -635,6 +696,17 @@ contents.
|
|
|
635
696
|
returns. Elevation is READ-only: build admin views that see everything,
|
|
636
697
|
but route status changes on other users' records (fulfilment, moderation)
|
|
637
698
|
to your human's Gemmein dashboard — they click the record there.
|
|
699
|
+
- Products — a capability card.
|
|
700
|
+
What it is: A named thing you sell once: a download, a licence, a
|
|
701
|
+
credit pack.
|
|
702
|
+
Does: Grants its key and its credits on purchase; writes the buyer's
|
|
703
|
+
receipt; a full refund takes both back.
|
|
704
|
+
Does not: Does not set a price — the provider does. Does not sell
|
|
705
|
+
subscriptions (those are plans).
|
|
706
|
+
Needs something else when: You sell through a provider without a
|
|
707
|
+
Payment Link → a relay with fulfil_product; you meter by usage →
|
|
708
|
+
credits spent per AI tool.
|
|
709
|
+
Example: "Starter pack", 100 credits, sold via a GoCardless relay.
|
|
638
710
|
- Payments: the builder names plans in the dashboard, pastes one Stripe
|
|
639
711
|
signing secret, and pastes each paid plan's Stripe Payment Link there too.
|
|
640
712
|
The app's ONLY checkout job is `await g.subscriptions.checkout("pro")` on the upgrade
|
|
@@ -675,11 +747,10 @@ contents.
|
|
|
675
747
|
screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
|
|
676
748
|
(everything they paid for, refunds applied, with the `grants` each purchase
|
|
677
749
|
carries) and `await g.subscriptions.mine()`.
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
first). NO seats, by design — not coming.
|
|
750
|
+
Access is yes-or-no. A QUANTITY beside it is credits — buy a pack, spend
|
|
751
|
+
atomically, a zero floor (see Credits): a "100 credit pack" is a product
|
|
752
|
+
with Grants credits = 100, and the ledger ADDS at every purchase, so a
|
|
753
|
+
second pack makes 200. NO seats, by design — not coming.
|
|
683
754
|
- Two FAMILIES of grant, and the line between them is law. Every grant is
|
|
684
755
|
one key + one reason + a start + maybe an end, never edited (an extension
|
|
685
756
|
is a NEW grant). PURCHASE-TIED (`subscription`, `purchase`): written only
|
|
@@ -714,13 +785,19 @@ contents.
|
|
|
714
785
|
scope, said out loud): plans are for subscriptions; products are for
|
|
715
786
|
things. Selling a SERVICE session this way (tutoring, coaching, a
|
|
716
787
|
consultation) is fine — nothing ships; the recorded purchase is the
|
|
717
|
-
proof the session was paid for. The builder adds products
|
|
718
|
-
|
|
788
|
+
proof the session was paid for. The builder adds products on the same
|
|
789
|
+
Payments page and sets how each is sold: a Stripe Payment Link, a relay
|
|
790
|
+
(any provider whose webhook the founder maps — GoCardless, Lemon
|
|
791
|
+
Squeezy, Paddle, bank transfer — fulfils it with `fulfil_product`), or
|
|
792
|
+
not yet (the product is defined, its grants and credits are known, no
|
|
793
|
+
road wired). The app calls
|
|
719
794
|
`await g.payments.buy("beat")` — or, when one product covers many items (license
|
|
720
795
|
tiers over a catalog), names the item:
|
|
721
796
|
`await g.payments.buy("premium license", { item: "beat_37" })` (display text
|
|
722
|
-
only; the PRICE always comes from the
|
|
723
|
-
item note can never change what's paid).
|
|
797
|
+
only; the PRICE always comes from the provider — the Payment Link or
|
|
798
|
+
the relay — so the item note can never change what's paid). A product
|
|
799
|
+
sold via a relay or not yet has no Payment Link to open:
|
|
800
|
+
`g.payments.buy` answers 409 `product_not_sellable`. Gemmein records every completed
|
|
724
801
|
payment itself — `await g.purchases.mine()` is the buyer's proof:
|
|
725
802
|
{ item, kind, status: "paid"|"part_refunded"|"refunded", amountMinor,
|
|
726
803
|
currency, refundedMinor, grants, paidAt, delivery? }. Selling a FILE (a
|
|
@@ -740,6 +817,164 @@ contents.
|
|
|
740
817
|
webhook. NO carts, NO quantities — one product per checkout by design; a
|
|
741
818
|
cart is N checkouts or one bundled product. 404 unknown_product lists
|
|
742
819
|
what the app actually sells — use those names.
|
|
820
|
+
- Credits — a quantity beside access. See https://docs.gemmein.com/credits
|
|
821
|
+
What it is: a balance your customers hold and your product spends — a
|
|
822
|
+
pack they buy, a comp you give, an AI tool call priced in credits.
|
|
823
|
+
Does: a product grants credits at purchase (its Grants credits field);
|
|
824
|
+
your server spends them with a reason; the AI route spends the credits
|
|
825
|
+
the owner set for the named tool, one by default;
|
|
826
|
+
a relay grants them; the owner's dashboard shows the balance and the
|
|
827
|
+
ledger and comps by hand. Every movement is one ledger line.
|
|
828
|
+
Does not: expire; go negative (floor 0 — a spend past the balance is
|
|
829
|
+
refused whole, never partly); price by token; open access (that is an
|
|
830
|
+
entitlement — a person with 500 credits and no plan is still refused by
|
|
831
|
+
a locked collection); count seats or quotas; spend from the browser.
|
|
832
|
+
Needs something else when: access should switch on with a plan (an
|
|
833
|
+
entitlement — see Paid ACCESS); you bill by usage after the fact (not
|
|
834
|
+
shipped — sell packs up front); the spend must happen in the browser
|
|
835
|
+
(it never does: your server, a relay or the AI route moves credits).
|
|
836
|
+
The case: "sell a 100-credit pack and let each chat cost one". The pack
|
|
837
|
+
is a product on the Payments page with Grants credits = 100 (the
|
|
838
|
+
product's `grantsCredits`); each purchase ADDS 100 to the buyer's
|
|
839
|
+
balance (two packs make 200), and a FULL refund claws back what is still
|
|
840
|
+
unspent, floor 0. The app reads the balance to show it:
|
|
841
|
+
|
|
842
|
+
const { balance } = await g.credits.balance() // browser, signed in
|
|
843
|
+
|
|
844
|
+
Your server spends by person id, with a reason and a key, so a retry
|
|
845
|
+
never spends twice (the key needs "Spend a person's credits" ticked):
|
|
846
|
+
|
|
847
|
+
const r = await gemmeinServer(sk).spendCredits(person.id, {
|
|
848
|
+
amount: 1, reason: "export", key: `export:${jobId}`
|
|
849
|
+
})
|
|
850
|
+
// { ok: true, spent: 1, deduped: false, balance: { before: 12, after: 11 },
|
|
851
|
+
// event: { id: "cev_…", reason: "export", actor: "<the key's name>" } }
|
|
852
|
+
// the same key again: spent: 0, deduped: true, the same event, nothing moved
|
|
853
|
+
// 402 credits_exhausted — "this person has 0 credits — the spend needs 1"
|
|
854
|
+
|
|
855
|
+
`holdings.credits` is `{ balance }` on verifySession and holdings, so one
|
|
856
|
+
verify answers who, what they hold AND how many. Numbers: no expiry ·
|
|
857
|
+
floor 0 · a balance carries at most 1,000,000,000 · a pack 1..1,000,000 ·
|
|
858
|
+
a server spend 1..10,000 per call · a comp ≤100,000 · a relay grant
|
|
859
|
+
1..10,000 · spend lines kept 90 days on the cloud rail (purchases, grants
|
|
860
|
+
and clawbacks are kept). Facts: a purchase by an email that has never
|
|
861
|
+
signed in creates the person and credits them; your `key` is scoped to
|
|
862
|
+
the person and namespaced (`srv:`), so one order id reused for two people
|
|
863
|
+
charges both and rotating the secret key never re-charges; a full refund
|
|
864
|
+
claws back what THAT purchase's ledger row granted, whatever the product
|
|
865
|
+
says today; the owner's page lists the newest 50 non-spend lines and the
|
|
866
|
+
credits the person spent in the last 30 days. Codes: `credits_exhausted`
|
|
867
|
+
(402 — carries the balance; show the pack, never retry the spend) ·
|
|
868
|
+
`capability_required` (403) · `person_not_found` (404) · `invalid_amount`
|
|
869
|
+
/ `invalid_reason` / `invalid_key` (400) · `dedupe_conflict` (409 — the
|
|
870
|
+
key already names a different movement) · `credits_ceiling` (409 — the
|
|
871
|
+
balance would pass 1,000,000,000; nothing added).
|
|
872
|
+
- The AI route — your app talks to OpenAI, Anthropic or Google through
|
|
873
|
+
Gemmein, on YOUR provider key, which never reaches the browser, through
|
|
874
|
+
a named AI tool your human prices and gates in the dashboard (or the
|
|
875
|
+
local `gemmein/ai/tools/<name>.json` file). Removing a tool is a
|
|
876
|
+
step-up action, like removing a key.
|
|
877
|
+
See https://docs.gemmein.com/ai
|
|
878
|
+
What it is: A named AI operation you price in credits and gate by
|
|
879
|
+
access.
|
|
880
|
+
Does: Runs on your provider key with the model and limits you set;
|
|
881
|
+
spends the tool's credits before the call and refunds them if the
|
|
882
|
+
provider fails before answering.
|
|
883
|
+
Does not: Does not price by token, does not let the browser set a
|
|
884
|
+
price or a model the tool pins, does not run from a server key.
|
|
885
|
+
Needs something else when: You want a plan-dependent price for the
|
|
886
|
+
same operation → make two tools and gate each; you meter something
|
|
887
|
+
that is not an AI call → spendCredits from your server.
|
|
888
|
+
Example: "Deep Research", openai, 20 credits, requires
|
|
889
|
+
access:pro-max — a tool file at `gemmein/ai/tools/deep-research.json`:
|
|
890
|
+
|
|
891
|
+
{
|
|
892
|
+
"label": "Deep Research",
|
|
893
|
+
"provider": "openai",
|
|
894
|
+
"credits": 20,
|
|
895
|
+
"requires": "access:pro-max"
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
On the local rail a tool saves without a Keys-page allowlist or a
|
|
899
|
+
configured key; the cloud refuses both at save.
|
|
900
|
+
Also true of the route itself: `sk_` is refused; it does not choose
|
|
901
|
+
models, cache, summarise, moderate, or reshape the request or the
|
|
902
|
+
answer; it does not refund a call that dies mid-stream, or one the
|
|
903
|
+
caller abandons before the headers (hanging up early does not
|
|
904
|
+
refund). Your SERVER makes the model call → call the provider
|
|
905
|
+
directly with your key (the gate still answers who they are and what
|
|
906
|
+
they hold). The call is not a chat call → embeddings, images, audio
|
|
907
|
+
go to the provider directly, from your server. The provider is not on
|
|
908
|
+
the list → write to hello@gemmein.com.
|
|
909
|
+
A call that names no tool runs as the default tool: one credit, your
|
|
910
|
+
configured provider, any allowed model.
|
|
911
|
+
A person who lacks the entitlement sees "Deep Research requires Pro
|
|
912
|
+
Max."; one short of the price sees "Deep Research costs 20 credits.
|
|
913
|
+
You have 7."; the ledger line reads "20 credits spent · Deep Research
|
|
914
|
+
· 87 remaining."
|
|
915
|
+
The case: "a chat app where each message costs the credits the owner
|
|
916
|
+
set for that tool — one by default — and my OpenAI key stays
|
|
917
|
+
private". The body is exactly what the provider documents for its
|
|
918
|
+
chat endpoint; `g.ai.chat` returns the fetch Response untouched:
|
|
919
|
+
|
|
920
|
+
const res = await g.ai.chat({
|
|
921
|
+
model: "gpt-4o-mini", stream: true,
|
|
922
|
+
messages: [{ role: "user", content: text }]
|
|
923
|
+
}, { tool: "deep-research" }) // tool is optional — leaving it
|
|
924
|
+
// out runs the default tool
|
|
925
|
+
// res.status and res.body are the provider's own (SSE stays SSE) —
|
|
926
|
+
// a provider 4xx/5xx comes back the same way: read res.ok; only
|
|
927
|
+
// Gemmein's own refusals throw
|
|
928
|
+
// res.headers: x-gemmein-credits-remaining: 41
|
|
929
|
+
// x-gemmein-tool: deep-research (names the tool;
|
|
930
|
+
// absent on the implicit default)
|
|
931
|
+
// x-gemmein-credit: refunded (only when the credits
|
|
932
|
+
// came back — a failure before the first byte)
|
|
933
|
+
|
|
934
|
+
For a non-stream answer as one string, whichever provider answered (a
|
|
935
|
+
provider non-2xx throws `provider_error` with its status and message):
|
|
936
|
+
|
|
937
|
+
const answer = await g.ai.text({ messages: [{ role: "user",
|
|
938
|
+
content: text }] }, { tool: "deep-research" })
|
|
939
|
+
|
|
940
|
+
Branch on `err.code`: unknown_tool (404 — no tool by that name in
|
|
941
|
+
this environment) · tool_disabled (403 — the owner switched it off)
|
|
942
|
+
· entitlement_required (403 — the message names the plan or product
|
|
943
|
+
it needs) · model_pinned (403 — the tool's model is fixed; leave
|
|
944
|
+
`model` out of the body) · provider_not_configured (409 — creating a
|
|
945
|
+
tool: add that provider's key first) · too_many_tools (409 — 50
|
|
946
|
+
tools per environment) · invalid_tool (400 — creating or updating a
|
|
947
|
+
tool with a bad field) · credits_exhausted (402 — the message names
|
|
948
|
+
the tool, its price and the balance, singular for 1; show the pack)
|
|
949
|
+
· ai_not_configured (409 — no
|
|
950
|
+
key set; the owner pastes one) · provider_required (400 — more than
|
|
951
|
+
one key set; name `provider`) · model_not_allowed (403 — the owner's
|
|
952
|
+
allowlist names what is allowed, for a tool with no pinned model) ·
|
|
953
|
+
ai_capped (429 — 20 per person per minute; wait for `resetAt`) ·
|
|
954
|
+
payload_too_large (413 — 256 KB, unless the tool sets a smaller
|
|
955
|
+
cap) · invalid_body (400 — the body must be the provider's JSON
|
|
956
|
+
object, nested at most 32 levels; a `?provider=` that disagrees with
|
|
957
|
+
a named tool's provider is refused the same way) · session_required
|
|
958
|
+
(401 — sign in first) · scope_denied (403 — a server key; the route
|
|
959
|
+
is for the browser) · provider_unreachable (502 — no answer before
|
|
960
|
+
the first byte; the credit is refunded; retry) · provider_error
|
|
961
|
+
(`g.ai.text` only — the provider's own non-2xx, its status and
|
|
962
|
+
message). Numbers: the credits the owner set for that tool, one by
|
|
963
|
+
default · ≤ 50 tools per environment · name ≤ 40 chars · label ≤ 60
|
|
964
|
+
chars · 20/min/person · 256 KB (a tool may set a smaller
|
|
965
|
+
`bounds.maxBodyBytes`, up to 262,144) · 170 s in all, and on a
|
|
966
|
+
stream 10 s to the first response headers.
|
|
967
|
+
Facts: `?provider=` and `?stream=1` on the URL do what the body
|
|
968
|
+
fields do (a named tool's own provider always wins); the owner may
|
|
969
|
+
list up to 20 allowed models for the default tool and any tool with
|
|
970
|
+
no pinned model (the Keys room's test call uses the first); every
|
|
971
|
+
`/ai/chat` call counts toward the app's api_requests band like any
|
|
972
|
+
other request; a provider that echoes the key in a refusal reaches
|
|
973
|
+
you as `***<hint>`. The owner's Usage room counts the calls; the
|
|
974
|
+
provider bills the tokens on the owner's own account. `gemmein dev`
|
|
975
|
+
answers a fake provider without a key (header `x-gemmein-ai: fake`),
|
|
976
|
+
so the loop runs locally; set GEMMEIN_AI_KEY_OPENAI / _ANTHROPIC /
|
|
977
|
+
_GOOGLE there for a real call.
|
|
743
978
|
- Drafts on PUBLIC collections (public_read, community): create with the
|
|
744
979
|
OPTION `{ published: false }` → hidden from every reader except its
|
|
745
980
|
author and the owner, server-enforced; publish with
|
|
@@ -789,7 +1024,8 @@ contents.
|
|
|
789
1024
|
conversation and customer replies land; holding the billing band;
|
|
790
1025
|
running a relay: an inbound webhook from any provider that
|
|
791
1026
|
signs its calls, a schedule, or a record change → write a record,
|
|
792
|
-
grant or revoke access,
|
|
1027
|
+
grant or revoke access, grant credits, fulfil or refund a product,
|
|
1028
|
+
email the person, or call your URL, with
|
|
793
1029
|
retries and a replay button (the definition is a file you write —
|
|
794
1030
|
see Relays — and the dashboard is where it is read, paused,
|
|
795
1031
|
replayed and its secrets rotated). None
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Gemmein SDK \u2014 passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|