@gemmein/sdk 0.6.0 → 0.7.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 CHANGED
@@ -12,6 +12,83 @@ the same release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.7.0] — 2026-09-04
16
+
17
+ ### Credits and the AI route
18
+
19
+ Credits are a general consumable: a balance your customers hold and your
20
+ product spends. A product carries `grantsCredits` (1..1,000,000) and each
21
+ confirmed purchase adds that many to the buyer's balance, idempotent on the
22
+ payment; a full refund claws back what is still unspent, floor zero. The
23
+ browser reads the balance with `g.credits.balance()`; a server spends with
24
+ `gemmeinServer(sk).spendCredits(personId, { amount?, reason, key? })` behind
25
+ the fourth key capability, "Spend a person's credits" — one atomic
26
+ decrement, refused whole below zero with `402 credits_exhausted` and the
27
+ balance in the message, at-most-once on `key`. `Holdings.credits` is
28
+ `{ balance }` from engine 0.8.0 (the type is unchanged; an older local engine
29
+ still answers `null`). A relay adds with `grant_credits`; the owner comps
30
+ from the person's page. Credits never expire and are never priced by token.
31
+
32
+ The AI route lets a browser session call OpenAI, Anthropic or Google through
33
+ Gemmein on the owner's own provider key, pasted once in the Keys room and
34
+ write-only from then on. `g.ai.chat(body, { provider?, signal? })` spends one
35
+ credit, forwards the provider's own request body as sent (minus `provider`,
36
+ and for Google `model` and `stream`, which ride the URL) with the auth
37
+ headers, `content-type` and `accept` added, and returns the fetch `Response`
38
+ untouched — the provider's answer, 2xx or not, is yours to read; a stream
39
+ stays a stream. `g.ai.text(body)` collects a non-stream answer to one string
40
+ per provider and throws `provider_error` on a provider's own non-2xx. A
41
+ credit is refunded only when the provider fails before its first byte
42
+ (`x-gemmein-credit: refunded`); a stream that dies mid-way does not refund,
43
+ and hanging up before the headers does not refund. A secret key is refused
44
+ (`403 scope_denied`): a server calls the provider directly. The codes that
45
+ join `GemmeinError`: `credits_exhausted`, `credits_ceiling`,
46
+ `dedupe_conflict`, `invalid_amount`, `invalid_reason`, `invalid_key`,
47
+ `person_not_found`, `capability_required`, `invalid_body`,
48
+ `ai_not_configured`, `provider_required`, `model_not_allowed`, `ai_capped`,
49
+ `payload_too_large`, `session_required`, `scope_denied`,
50
+ `provider_unreachable`, and `provider_error` from `g.ai.text`. `REFERENCE.md`
51
+ carries the Credits and AI chapters; `llms.txt` carries both capability
52
+ cards. `gemmein dev` answers a fake provider without a key and its pay
53
+ simulator honours the `grantsCredits` a product declares in
54
+ `gemmein/payments.json` (`gemmein payments setup` asks for it).
55
+
56
+ The ledger's laws, stated: a full refund claws back what that purchase's own
57
+ ledger row granted, whatever the product says today; a balance carries at
58
+ most 1,000,000,000 (`credits_ceiling`, nothing added past it); a server
59
+ spend's `key` is scoped to the person, so one order id reused for two people
60
+ charges both and a key that names a different movement is `dedupe_conflict`;
61
+ the person's page shows the credits that person spent in the last 30 days; a
62
+ provider that echoes the key in a refusal reaches the app as `***<hint>`.
63
+
64
+ ### Added
65
+ - `g.credits.balance()` → `GET /auth/credits` → `{ balance }` (session required).
66
+ - `g.ai.chat(body, { provider?, signal? })` → `POST /ai/chat`; answers the
67
+ fetch `Response` untouched (streaming intact) whenever the answer passed
68
+ the spend — the provider's own status and body, 2xx or not; Gemmein's
69
+ refusals throw `GemmeinError` with the engine's code — `credits_exhausted`
70
+ (402), `ai_not_configured` (409), `provider_required` (400),
71
+ `model_not_allowed` (403), `ai_capped` (429), `payload_too_large` (413),
72
+ `invalid_body` (400), `session_required` (401), `scope_denied` (403),
73
+ `provider_unreachable` (502).
74
+ - `g.ai.text(body, options?)` — the non-streaming answer as a string
75
+ (OpenAI `choices[0].message.content`; Anthropic `content[].text`;
76
+ Google `candidates[0].content.parts[].text`); `invalid_response` when
77
+ no text came back; `provider_error` (the provider's status and message)
78
+ on a provider's own non-2xx.
79
+ - `gemmeinServer(sk).spendCredits(personId, { amount?, reason, key? })` →
80
+ `POST /server/people/:id/credits/spend` → `{ ok, spent, deduped,
81
+ balance: { before, after }, event: { id, reason, actor } }` (a deduped
82
+ repeat answers `spent: 0` and the same event); needs the key's "Spend a
83
+ person's credits" capability; `402 credits_exhausted`.
84
+ - `Holdings.credits` is filled (`{ balance }`) from engine 0.8.0 — the
85
+ type is unchanged.
86
+ - Relays: `grant_credits { amount 1..10,000, reason? }` action, mirrored
87
+ in `@gemmein/mcp`'s offline validator.
88
+ - Console: the Keys room's AI provider card (set · test · replace ·
89
+ remove), "Grants credits" on a product, CREDITS + Grant credits + the
90
+ ledger on a person, the Usage room's "AI calls" row.
91
+
15
92
  ## [0.6.0] — 2026-09-04
16
93
 
17
94
  ### Changed
package/REFERENCE.md CHANGED
@@ -27,7 +27,7 @@ that passes `baseUrl` to reach a local or staging server quietly talks to
27
27
  production instead. Exposes read/update on collections without a signed-in user,
28
28
  `notify()` to email one of your app's own verified people (see **Notify**),
29
29
  the gate — `verifySession()` / `holdings()` / `grantAccess()` /
30
- `revokeAccess()` / `invitePerson()`, for code of yours running on your own
30
+ `revokeAccess()` / `invitePerson()` / `spendCredits()`, for code of yours running on your own
31
31
  host (see **Server gate**) — plus `testSession()` for CI self-tests (dev
32
32
  environments only — see **Reaffirm**).
33
33
 
@@ -271,6 +271,154 @@ honestly, not hidden.)
271
271
 
272
272
  ---
273
273
 
274
+ ## Credits — `g.credits` / `gemmeinServer(sk).spendCredits`
275
+
276
+ A balance your customers hold and your product spends: a pack they buy, a
277
+ comp the owner gives, a call that costs one. Credits are a **quantity beside
278
+ access**, never access itself — a locked collection still asks for the
279
+ entitlement, whatever the balance. The ledger adds at every purchase, grant
280
+ and refund, subtracts at every spend, and never goes below zero: a spend
281
+ past the balance is refused whole (`402 credits_exhausted`, the balance in
282
+ the message), never partly. No expiry. No per-token pricing.
283
+
284
+ | Surface | Signature | Returns |
285
+ |---------|-----------|---------|
286
+ | `g.credits.balance` | `()` | `Promise<{ balance: number }>` — the signed-in person's balance now (`401 session_required` without a session) |
287
+ | `gemmeinServer(sk).spendCredits` | `(personId, { amount?: number; reason: string; key?: string })` | `Promise<{ ok: true; spent: number; deduped: boolean; balance: { before: number; after: number }; event: { id, reason, actor } }>` — `amount` defaults to 1 (1..10,000); `reason` ≤ 200 chars is what the owner reads on the person's ledger; `key` makes the spend at-most-once per person (a repeat answers `deduped: true`, `spent: 0`, the same `event`, and moves nothing; one key reused for a second person charges that person). Needs **"Spend a person's credits"** ticked on the key (`403 capability_required` otherwise) |
288
+ | `Holdings.credits` | — | `{ balance: number }` on `verifySession()` and `holdings()` from engine 0.8.0 (`null` from an older local engine) — one verify answers who, what they hold and how many |
289
+
290
+ Where credits come from, and where they go:
291
+
292
+ - **A product with `grantsCredits`** (the "Grants credits" field on the
293
+ Payments page, 1..1,000,000). Each confirmed purchase **adds** that many to
294
+ the buyer's balance — two packs make double, never a reset — idempotent on
295
+ the payment. A purchase by an email that has never signed in creates the
296
+ person and credits them. A **full** refund claws back what that purchase's
297
+ own ledger row granted — at most what is still unspent, floor 0 — whatever
298
+ the product says today; a partial refund moves nothing. The purchase, the
299
+ grant and the clawback are each one ledger line.
300
+ - **The owner's dashboard** comps by hand from the person's page (≤100,000 per
301
+ action, a note required), and shows the balance, the newest 50 non-spend
302
+ ledger lines and the credits the person spent in the last 30 days.
303
+ - **A relay** adds with `grant_credits { amount (1..10,000), reason? }` — see
304
+ **Relays**.
305
+ - **Your server** spends with `spendCredits`; **the AI route** spends one per
306
+ call (see **AI**). Nothing spends from the browser.
307
+ - Spend lines are kept 90 days on the cloud rail; purchases, grants and
308
+ clawbacks are kept. A balance carries at most 1,000,000,000. Account
309
+ erasure removes the person's balance and ledger.
310
+
311
+ ```ts
312
+ // browser — show the balance
313
+ const { balance } = await g.credits.balance()
314
+
315
+ // server — one export costs one credit; the key makes a retry safe
316
+ const r = await gemmeinServer(process.env.GEMMEIN_SECRET_KEY).spendCredits(person.id, {
317
+ amount: 1, reason: "export", key: `export:${jobId}`
318
+ })
319
+ // r = { ok: true, spent: 1, deduped: false, balance: { before: 12, after: 11 },
320
+ // event: { id: "cev_…", reason: "export", actor: "<the key's name>" } }
321
+ // a retry with the same key: { …, spent: 0, deduped: true, event: the same one }
322
+ // GemmeinError 402 credits_exhausted — "this person has 0 credits — the spend needs 1"
323
+ ```
324
+
325
+ | code | status | meaning · do |
326
+ |------|--------|--------------|
327
+ | `credits_exhausted` | 402 | The balance is below the spend — the message carries the balance ("this person has {balance} credits — the spend needs {amount}"). Show the pack; never retry the same spend |
328
+ | `session_required` | 401 | `g.credits.balance()` without a signed-in person — sign in first |
329
+ | `capability_required` | 403 | `spendCredits` on a key without "Spend a person's credits" — mint one with it ticked |
330
+ | `person_not_found` | 404 | No person with this id in this app and environment — ids come from `verifySession` or the dashboard |
331
+ | `invalid_amount` | 400 | `amount` outside 1..10,000 (or not a whole number) |
332
+ | `invalid_reason` | 400 | `reason` missing, not text, or over 200 chars |
333
+ | `invalid_key` | 400 | `key` not text, empty, or over 200 chars |
334
+ | `dedupe_conflict` | 409 | The `key` already names a different movement (another kind, or another person) — a key is one movement; reuse it only to retry that same one |
335
+ | `credits_ceiling` | 409 | A credit that would carry the balance past 1,000,000,000 — nothing was added |
336
+
337
+ `gemmein dev` keeps the same ledger in memory with snapshot and restore, and
338
+ its pay simulator honours the `grantsCredits` a product declares in
339
+ `gemmein/payments.json` (`gemmein payments setup` asks for it), so the
340
+ buy-then-spend loop runs on your machine before a payment provider is
341
+ connected.
342
+
343
+ ---
344
+
345
+ ## AI — `g.ai.chat` / `g.ai.text`
346
+
347
+ Your app talks to **OpenAI, Anthropic or Google** through Gemmein, on the
348
+ **owner's own provider key**, which never reaches the browser. The owner
349
+ pastes the key once in the dashboard's Keys room; it is write-only from then
350
+ on. A signed-in person's request costs **one credit**, spent before the
351
+ request is forwarded; the provider bills the tokens on the owner's own
352
+ account. The route forwards the provider's own request body as sent — minus
353
+ the `provider` field, and for Google minus `model` and `stream`, which ride
354
+ the URL — adds the provider's auth headers, `content-type` and `accept`, and
355
+ passes the status and the bytes straight back — a stream stays a stream. It does not choose models, cache, summarise,
356
+ moderate or reshape anything, and it is for the browser only: a server key is
357
+ refused (`403 scope_denied`) — a server calls the provider directly.
358
+
359
+ | Method | Signature | Returns |
360
+ |--------|-----------|---------|
361
+ | `ai.chat` | `(body: object, options?: { provider?: "openai" \| "anthropic" \| "google"; signal?: AbortSignal })` | `Promise<Response>` — the fetch `Response`, untouched: the provider's status, headers and body, streaming intact. A non-2xx from the provider is returned as-is (not thrown) — an answer carrying `x-gemmein-credits-remaining` passed the spend and is the provider's; a Gemmein refusal throws `GemmeinError` |
362
+ | `ai.text` | `(body: object, options?)` | `Promise<string>` — a non-stream call collected to one string, whichever provider answered (openai `choices[0].message.content`; anthropic `content[].text` joined; google `candidates[0].content.parts[].text` joined); a provider's non-2xx throws `provider_error` with the provider's status and message |
363
+
364
+ - `body` is exactly what the provider documents for its chat endpoint —
365
+ OpenAI chat completions, Anthropic messages, Google generateContent. Its
366
+ `model` field, when present, must match `^[A-Za-z0-9._:-]{1,80}$`; Google
367
+ needs it (it rides the URL); when the owner lists allowed models (up to
368
+ 20 — the Keys room's test call uses the first), any other answers
369
+ `403 model_not_allowed`.
370
+ - `provider` is optional when one key is configured and required when more
371
+ than one is (`400 provider_required`). No key at all is
372
+ `409 ai_not_configured`. `?provider=` and `?stream=1` on the URL do what
373
+ the body fields do.
374
+ - Two response headers from Gemmein on every answer that passed the spend:
375
+ `x-gemmein-credits-remaining` (the balance after this call) and, on a
376
+ refund, `x-gemmein-credit: refunded`.
377
+ - **The refund rule.** A credit is refunded only when the provider fails
378
+ before its first byte (a non-2xx, or `502 provider_unreachable`). A stream
379
+ that dies after the first byte is not refunded; hanging up early does not
380
+ refund. A provider that echoes the key in a refusal reaches the app as
381
+ `***<hint>`.
382
+ - Limits: 20 calls per person per minute (`429 ai_capped`, `resetAt`), 256 KB
383
+ body (`413 payload_too_large`) nested at most 32 levels (`400 invalid_body`),
384
+ 170 s in all and, on a stream, 10 s to the first response headers. Every
385
+ `/ai/chat` call counts toward the app's `api_requests` band like any other
386
+ request.
387
+
388
+ ```ts
389
+ const res = await g.ai.chat({
390
+ model: "gpt-4o-mini", stream: true,
391
+ messages: [{ role: "user", content: text }]
392
+ }, { provider: "openai" })
393
+ for await (const chunk of res.body) render(chunk) // the provider's SSE, byte for byte
394
+
395
+ const answer = await g.ai.text({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] })
396
+ ```
397
+
398
+ | code | status | meaning · do |
399
+ |------|--------|--------------|
400
+ | `credits_exhausted` | 402 | The person's balance is 0 — the message carries it. Show the pack |
401
+ | `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one in the Keys room |
402
+ | `provider_required` | 400 | More than one provider key is set — pass `provider` |
403
+ | `model_not_allowed` | 403 | The owner's allowlist names the models this app may call; the message lists them |
404
+ | `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
405
+ | `payload_too_large` | 413 | The body is over 256 KB — shorten the conversation you send |
406
+ | `invalid_body` | 400 | The body must be the provider's JSON request object, nested at most 32 levels |
407
+ | `session_required` | 401 | No signed-in person — sign in first |
408
+ | `scope_denied` | 403 | A secret key called the route — the route is for the browser; a server calls the provider directly |
409
+ | `provider_unreachable` | 502 | The provider did not answer before the first byte — nothing was charged (the credit is refunded); retry |
410
+ | `provider_error` | the provider's | `g.ai.text` only (client-side): the provider's own non-2xx, its message in `err.message` |
411
+ | `ai_test_capped` | 429 | The Keys room's test call — one a minute per app |
412
+
413
+ `gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
414
+ fake`, an echo stream), so the loop runs locally; set
415
+ `GEMMEIN_AI_KEY_OPENAI`, `GEMMEIN_AI_KEY_ANTHROPIC` or `GEMMEIN_AI_KEY_GOOGLE`
416
+ in the local rail's environment for a real call. The owner's Usage room
417
+ counts AI calls for the last 30 days: spent by the customers' credits, priced
418
+ by the provider — Gemmein meters the calls, the provider bills the tokens.
419
+
420
+ ---
421
+
274
422
  ## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess` / `invitePerson`
275
423
 
276
424
  Gemmein hosts no compute. **Your own** function — Vercel, a VPS, a cron box,
@@ -290,7 +438,7 @@ signed in, it can create them by email first.
290
438
  type Holdings = {
291
439
  access: string[] // the keys they hold NOW — ["access:pro"]
292
440
  grants: Grant[] // the LIVE grants behind them (revoked/expired are gone)
293
- credits: { balance: number } | null // reserved null today; credits are NOT shipped
441
+ credits: { balance: number } | null // the balance now (engine 0.8.0+); null from an older local engine — see Credits
294
442
  }
295
443
 
296
444
  type Grant = {
@@ -313,12 +461,13 @@ type Grant = {
313
461
  (`relay` is the kind a relay's `grant_access` writes; see
314
462
  **Relays**). Gate
315
463
  on what a person *holds*, never on what they pay. Person id, never an email.
316
- - **Three capabilities, ticked by the human.** `verifySession` needs none.
464
+ - **Four capabilities, ticked by the human.** `verifySession` needs none.
317
465
  `holdings` needs **"Look up a person's access by id"**; `grantAccess` and
318
466
  `revokeAccess` need **"Grant and revoke access"**; `invitePerson` needs
319
- **"Create a person by email before they sign in"** plain-English checkboxes
320
- the owner ticks when minting the key. Existing keys have all three off, so
321
- nothing in production changes.
467
+ **"Create a person by email before they sign in"**; `spendCredits` needs
468
+ **"Spend a person's credits"** plain-English checkboxes the owner ticks
469
+ when minting the key. Existing keys have all four off, so nothing in
470
+ production changes.
322
471
  - **The invite door.** `invitePerson(email)` is how your server addresses
323
472
  someone who has never signed in — the envelope, the invoice, the client
324
473
  portal, the booking-winner email: create the person, hand them the file,
@@ -353,7 +502,7 @@ type Grant = {
353
502
  | `session_revoked` | 401 | A newer sign-in, a sign-out, or the owner ended it — send them back to sign-in |
354
503
  | `person_suspended` | 403 | The owner suspended this person — access is off until the owner reactivates them in the dashboard |
355
504
  | `person_not_found` | 404 | No person with this id in this app and environment — ids come from `verifySession` or the dashboard, never from an email. Existence is never leaked |
356
- | `capability_required` | 403 | The key's box isn't ticked — mint a key with "Look up a person's access by id" / "Grant and revoke access" / "Create a person by email before they sign in" ticked (purchase and subscription grants come from the built-in Stripe path; another provider's payment grants through a relay) |
505
+ | `capability_required` | 403 | The key's box isn't ticked — mint a key with "Look up a person's access by id" / "Grant and revoke access" / "Create a person by email before they sign in" / "Spend a person's credits" ticked (purchase and subscription grants come from the built-in Stripe path; another provider's payment grants through a relay) |
357
506
  | `invalid_email` | 400 | `invitePerson`: the address must look like `name@domain` — trimmed, one `@`, a dotted domain, at most 254 characters, no whitespace |
358
507
  | `invite_capped` | 429 | `invitePerson`: this app has made 500 invite calls today — the limit is temporary; write to hello@gemmein.com to raise it. `err.resetAt` says when the window ends |
359
508
  | `invalid_source` | 400 | `purchase` / `subscription` asked for by hand — refused; those come from Stripe's signed webhook, and another provider's payment grants through a relay |
@@ -364,7 +513,7 @@ type Grant = {
364
513
  | `invalid_body` | 400 | One malformed field, whichever it is — `token` (missing, not a string, over 512 chars), `expiresAt` (unparseable or in the past), `reason` (not text, over 200 chars). Branch on the code, read the **message**: it names the field |
365
514
  | `scope_denied` | 403 | Not a secret key — the gate is server-only, never the browser |
366
515
  | `invalid_id` | 400 | A prototype name (`__proto__`, `constructor`, `prototype`) was sent as a person id or a grant id. Ids come from `verifySession()` or the dashboard — never from a name |
367
- | `unknown_route` | 404 | Not one of the gate's five routes — the message lists them all |
516
+ | `unknown_route` | 404 | Not one of the gate's six routes — the message lists them all |
368
517
  | `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess`, `revokeAccess` and `invitePerson` are POST, `holdings` is GET |
369
518
 
370
519
  ---
@@ -476,6 +625,7 @@ authorise on fields they cannot set, or from a receiver.
476
625
  |---|---|---|
477
626
  | `write_record` | `collection`, `data`, `to?: "person"` | keyed per event so a retry never duplicates; written through the same door as your app (plain-text and file laws apply); `to: "person"` addresses it to the event's person and is required on addressed and direct collections; the record is app-owned |
478
627
  | `grant_access` | `entitlement` (plan or product **name**, or `access:<slug>`), `expiresAt?` (`"30d"`, `"12h"`, `"2w"` or an ISO date), `reason?` (≤ 200, templated) | source `relay`, the seventh grant source; skipped when this event already granted |
628
+ | `grant_credits` | `amount` (1..10,000), `reason?` (≤ 200, templated) | adds to the person's credit balance, once per event (a replay finds its own row); the ledger line reads "relay: <reason>"; a relay adds and never spends |
479
629
  | `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
480
630
  | `email_person` | `subject` (≤ 300), `text` (≤ 10,000), `kind?: "event" \| "account"` | rides `notify()`'s caps (200 per app per hour, 5 event sends per person per day) and the owner's sends switch; deduped per event |
481
631
  | `call_url` | `url` | https only, no template in the URL, no IP literal, never a gemmein.com host, no credentials in the URL; `gemmein dev` allows http to localhost |
@@ -662,11 +812,26 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
662
812
  | `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
663
813
  | `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes) | scope the key to that collection, or use the right surface |
664
814
  | `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
665
- | `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
815
+ | `invalid_key` | a keyed create's `key` breaks the charset/length law — or `spendCredits`' `key` is not text of up to 200 chars | 1-120 chars of letters, numbers, `: _ . @ / -` (a create); text ≤ 200 (a spend) |
816
+ | `invalid_amount` / `invalid_reason` (400) | `spendCredits`: `amount` outside 1..10,000, or `reason` missing / over 200 chars | fix the field the code names |
817
+ | `dedupe_conflict` (409) | `spendCredits`: the `key` already names a different movement (another kind or another person) | a key is one movement — reuse it only to retry that same one |
818
+ | `credits_ceiling` (409) | a credit would carry the balance past 1,000,000,000 (a comp, a pack, a relay grant) — nothing was added | the balance is at its most |
819
+ | `invalid_body` (400) | on `g.ai.chat`: the body is not the provider's JSON request object, or is nested deeper than 32 levels | send the provider's own request object |
820
+ | `provider_error` (client-side, the provider's status) | `g.ai.text`: the provider answered a non-2xx; `err.message` is the provider's own reason | read it — the credit was refunded when the provider failed before its first byte |
821
+ | `ai_test_capped` (429) | the Keys room's test call — one a minute per app | wait a minute |
666
822
  | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
667
823
  | `invalid_response` (client-side, status 0) | the server answered 200 to `verifyEmailCode` without a session token — a proxy or mock in the path, not Gemmein | check `apiUrl` and anything rewriting responses; the call is safe to retry |
668
824
  | `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
669
825
  | `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
670
826
  | `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
827
+ | `credits_exhausted` (402) | the person's balance is below the spend — the message carries the balance ("this person has {balance} credits — the spend needs {amount}") | show the pack; never retry the same spend |
828
+ | `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one in the Keys room |
829
+ | `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
830
+ | `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
831
+ | `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |
832
+ | `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB | shorten the conversation you send |
833
+ | `session_required` (401) | `g.ai.chat` / `g.credits.balance` without a signed-in person | sign in first |
834
+ | `scope_denied` (403) | on `g.ai.chat`: a secret key called the route | the route is for the browser — a server calls the provider directly |
835
+ | `provider_unreachable` (502) | the provider did not answer before the first byte; the credit is refunded | retry |
671
836
 
672
837
  Keys: `pk_` (public, domain-locked, browser-safe) vs `sk_` (secret, server only).
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = exports.CLIENT_INFO = exports.SDK_VERSION = void 0;
3
+ exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AiClient = exports.CreditsClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = exports.CLIENT_INFO = exports.SDK_VERSION = void 0;
4
4
  exports.gemmein = gemmein;
5
5
  exports.gemmeinServer = gemmeinServer;
6
6
  /** This build's version — the value `package.json` carries. Kept inline
@@ -9,7 +9,7 @@ exports.gemmeinServer = gemmeinServer;
9
9
  * second module). `scripts/sync-version.mjs` rewrites the literal from
10
10
  * package.json before every build (`prebuild`), and a test pins the two
11
11
  * equal, so a bump can never ship with a stale header. */
12
- exports.SDK_VERSION = "0.6.0"; // synced from package.json — do not edit by hand
12
+ exports.SDK_VERSION = "0.7.0"; // synced from package.json — do not edit by hand
13
13
  /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
14
14
  * `x-client-info: gemmein-sdk/<version>`. The server records it on the
15
15
  * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
@@ -124,6 +124,8 @@ class Gemmein {
124
124
  this.purchases = new PurchasesClient(config);
125
125
  this.account = new AccountClient(config);
126
126
  this.files = new FilesClient(config);
127
+ this.credits = new CreditsClient(config);
128
+ this.ai = new AiClient(config);
127
129
  }
128
130
  /**
129
131
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
@@ -372,6 +374,169 @@ class AccountClient {
372
374
  }
373
375
  }
374
376
  exports.AccountClient = AccountClient;
377
+ /** The signed-in person's own credits — so an app can draw its own meter. */
378
+ class CreditsClient {
379
+ constructor(config) {
380
+ this.config = config;
381
+ }
382
+ /**
383
+ * The balance RIGHT NOW, server-resolved — the number that refuses at
384
+ * zero, never client math. Session required (`session_required`, 401).
385
+ *
386
+ * const { balance } = await g.credits.balance();
387
+ */
388
+ async balance() {
389
+ return runtimeRequest(this.config, "/auth/credits");
390
+ }
391
+ }
392
+ exports.CreditsClient = CreditsClient;
393
+ /**
394
+ * The AI route. `chat` takes the provider's own request body — exactly what
395
+ * you would POST to OpenAI's /v1/chat/completions, Anthropic's /v1/messages
396
+ * or Google's generateContent — and answers with the fetch `Response`
397
+ * untouched, streaming intact (SSE stays SSE). Gemmein spends one credit,
398
+ * adds the owner's key, forwards, and passes status and bytes back.
399
+ * Response headers: `x-gemmein-credits-remaining` on every answer that
400
+ * passed the spend; `x-gemmein-credit: refunded` when the provider failed
401
+ * before its first byte.
402
+ */
403
+ class AiClient {
404
+ constructor(config) {
405
+ this.config = config;
406
+ }
407
+ /**
408
+ * const res = await g.ai.chat({ model: "gpt-4o-mini", messages, stream: true });
409
+ * for await (const chunk of res.body) { … }
410
+ *
411
+ * Browser sessions only — a server key is refused (`scope_denied`, 403).
412
+ * Refusals, all `GemmeinError`: `session_required` (401) ·
413
+ * `credits_exhausted` (402 — the message carries the balance; show your
414
+ * own "buy more" door, which is a product checkout) · `ai_not_configured`
415
+ * (409 — the owner has set no key) · `provider_required` (400) ·
416
+ * `model_not_allowed` (403 — the owner's models list) · `ai_capped`
417
+ * (429 — 20 calls a minute per person; `err.resetAt`) ·
418
+ * `payload_too_large` (413 — 256 KB) · `provider_unreachable` (502,
419
+ * before the first byte, refunded). Those are GEMMEIN's refusals. The
420
+ * PROVIDER's own answer — 2xx or not — is returned as it came: an answer
421
+ * that carries `x-gemmein-credits-remaining` (or `x-gemmein-credit`)
422
+ * passed the spend, so its status and body are the provider's; read
423
+ * `res.ok` / `res.status` yourself (a provider 4xx before the first byte
424
+ * is refunded, header `x-gemmein-credit: refunded`).
425
+ */
426
+ async chat(body, options = {}) {
427
+ const payload = options.provider ? { provider: options.provider, ...body } : body;
428
+ const response = await fetch(new URL("/ai/chat", this.config.apiUrl), {
429
+ method: "POST",
430
+ body: JSON.stringify(payload),
431
+ headers: await runtimeHeaders(this.config, { "content-type": "application/json" }),
432
+ ...(options.signal ? { signal: options.signal } : {}),
433
+ });
434
+ if (!response.ok) {
435
+ if (isForwardedAnswer(response)) {
436
+ // Past the spend: the provider answered. `provider_unreachable` is
437
+ // the one Gemmein refusal written after the spend (it carries the
438
+ // refund header) — peek without consuming so it stays typed.
439
+ const peek = (await response.clone().json().catch(() => null));
440
+ if (peek?.code !== "provider_unreachable")
441
+ return response;
442
+ }
443
+ const errorBody = await readErrorBody(response);
444
+ if (errorBody.code === "auth_expired")
445
+ await this.config.tokenStore.clear();
446
+ throw new GemmeinError({ status: response.status, ...errorBody });
447
+ }
448
+ return response;
449
+ }
450
+ /**
451
+ * The non-streaming convenience: one call, one string. Pass a body that
452
+ * does NOT stream (`stream` unset or false); the provider's JSON answer is
453
+ * read whole and the text is lifted out per provider — OpenAI
454
+ * `choices[0].message.content`, Anthropic `content[].text` joined,
455
+ * Google `candidates[0].content.parts[].text` joined. An answer with no
456
+ * text in any of those places throws `invalid_response` (status 0). A
457
+ * provider's own non-2xx throws `provider_error` with the provider's
458
+ * status and the provider's message (the owner's key masked to its
459
+ * hint if the provider echoed it).
460
+ *
461
+ * const answer = await g.ai.text({ model: "claude-sonnet-4-5", max_tokens: 400, messages });
462
+ */
463
+ async text(body, options = {}) {
464
+ const response = await this.chat(body, options);
465
+ if (!response.ok) {
466
+ throw new GemmeinError({
467
+ status: response.status,
468
+ code: "provider_error",
469
+ message: await providerErrorMessage(response),
470
+ });
471
+ }
472
+ const data = (await response.json());
473
+ const text = extractAiText(data);
474
+ if (text === null) {
475
+ throw new GemmeinError({
476
+ status: 0,
477
+ code: "invalid_response",
478
+ message: "the provider answered without any text — for a streaming body use g.ai.chat() and read the stream",
479
+ });
480
+ }
481
+ return text;
482
+ }
483
+ }
484
+ exports.AiClient = AiClient;
485
+ /** An answer that passed the spend: the engine stamps the balance (and, on
486
+ * a refund, `x-gemmein-credit`) only after the credit moved — a refusal
487
+ * before the spend carries neither. */
488
+ function isForwardedAnswer(response) {
489
+ return response.headers.has("x-gemmein-credits-remaining") || response.headers.has("x-gemmein-credit");
490
+ }
491
+ /** The provider's own reason, whichever shape it used — OpenAI, Anthropic
492
+ * and Google all nest it as `error.message`; anything else is the text. */
493
+ async function providerErrorMessage(response) {
494
+ const text = await response.text().catch(() => "");
495
+ try {
496
+ const parsed = JSON.parse(text);
497
+ if (parsed && typeof parsed === "object") {
498
+ const nested = typeof parsed.error === "object" && parsed.error !== null ? parsed.error.message : parsed.error;
499
+ const message = typeof nested === "string" ? nested : typeof parsed.message === "string" ? parsed.message : null;
500
+ if (message)
501
+ return message;
502
+ }
503
+ }
504
+ catch {
505
+ // not JSON — the text is the reason
506
+ }
507
+ return text.trim().slice(0, 500) || `the provider answered ${response.status}`;
508
+ }
509
+ /** The per-provider lift, by shape (a provider's answer is unmistakable). */
510
+ function extractAiText(data) {
511
+ if (!data || typeof data !== "object")
512
+ return null;
513
+ const d = data;
514
+ // OpenAI: choices[0].message.content (a string, or content parts)
515
+ if (Array.isArray(d.choices)) {
516
+ const message = d.choices[0]?.message;
517
+ const content = message?.content;
518
+ if (typeof content === "string")
519
+ return content;
520
+ if (Array.isArray(content))
521
+ return joinTextParts(content);
522
+ return null;
523
+ }
524
+ // Anthropic: content[] blocks, the text ones joined
525
+ if (Array.isArray(d.content))
526
+ return joinTextParts(d.content);
527
+ // Google: candidates[0].content.parts[].text joined
528
+ if (Array.isArray(d.candidates)) {
529
+ const parts = d.candidates[0]?.content?.parts;
530
+ return Array.isArray(parts) ? joinTextParts(parts) : null;
531
+ }
532
+ return null;
533
+ }
534
+ function joinTextParts(parts) {
535
+ const texts = parts
536
+ .map((p) => (p && typeof p === "object" && typeof p.text === "string" ? p.text : null))
537
+ .filter((t) => t !== null);
538
+ return texts.length > 0 ? texts.join("") : null;
539
+ }
375
540
  function isSessionResponse(value) {
376
541
  return (typeof value === "object" &&
377
542
  value !== null &&
@@ -952,6 +1117,43 @@ class GemmeinServer {
952
1117
  }),
953
1118
  });
954
1119
  }
1120
+ /**
1121
+ * W9.3 — spend a person's credits from YOUR server, with a reason:
1122
+ *
1123
+ * const { balance } = await g.spendCredits(personId, {
1124
+ * amount: 5, // default 1; 1..10,000 per call
1125
+ * reason: "render:4k", // what the owner reads in the ledger
1126
+ * key: `render:${jobId}`, // your retry key — a repeat is deduped
1127
+ * });
1128
+ * // { ok: true, spent: 5, deduped: false, balance: { before: 20, after: 15 },
1129
+ * // event: { id: "cev_…", reason: "render:4k", actor: "<your key's name>" } }
1130
+ *
1131
+ * ONE conditional update, floored at 0: the spend succeeds whole or not at
1132
+ * all, and `402 credits_exhausted` says "this person has {balance} credits
1133
+ * — the spend needs {amount}". Pass `key` when the caller can retry — the
1134
+ * same key answers the first spend again with `deduped: true`, `spent: 0`,
1135
+ * the same `event` and the balance untouched. The key is scoped to the
1136
+ * person: one order id reused for two people charges both. Credits come
1137
+ * from a pack they bought, a comp in the back office, or a relay; a key
1138
+ * cannot mint them.
1139
+ *
1140
+ * Needs the key's "Spend a person's credits" box ticked by your human.
1141
+ * Refusals: `capability_required` (403 — "this key can't spend credits —
1142
+ * mint a key with 'Spend a person's credits' ticked") ·
1143
+ * `credits_exhausted` (402) · `person_not_found` (404) · `invalid_amount` /
1144
+ * `invalid_reason` / `invalid_key` (400) · `dedupe_conflict` (409 — the
1145
+ * key already names a different movement).
1146
+ */
1147
+ async spendCredits(personId, input) {
1148
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/credits/spend`, {
1149
+ method: "POST",
1150
+ body: JSON.stringify({
1151
+ ...(input.amount !== undefined ? { amount: input.amount } : {}),
1152
+ reason: input.reason,
1153
+ ...(input.key !== undefined ? { key: input.key } : {}),
1154
+ }),
1155
+ });
1156
+ }
955
1157
  /**
956
1158
  * End one grant — the reversibility law in one call:
957
1159
  *