@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 +77 -0
- package/REFERENCE.md +174 -9
- package/dist/index.cjs +204 -2
- package/dist/index.d.cts +130 -5
- package/dist/index.d.ts +130 -5
- package/dist/index.js +201 -1
- package/llms.txt +143 -17
- package/package.json +1 -1
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 //
|
|
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
|
-
- **
|
|
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"
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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.
|
|
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
|
*
|