@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/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,126 @@ the same release.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.8.0] — 2026-09-05
|
|
16
|
+
|
|
17
|
+
### Products are provider-neutral (engine 0.9.0, sdk 0.8.0, mcp 0.5.0)
|
|
18
|
+
|
|
19
|
+
A product is sold one of three ways, set on the Payments page ("How it's
|
|
20
|
+
sold" in the console, `sold` in the CLI): via a Stripe Payment Link, via a
|
|
21
|
+
relay (any provider whose webhook the founder maps — GoCardless, Lemon
|
|
22
|
+
Squeezy, Paddle, bank transfer), or not yet (defined, its grants and
|
|
23
|
+
credits known, no road wired). Two new relay actions carry the relay
|
|
24
|
+
road: `fulfil_product { product, ref? }` grants the product's key and
|
|
25
|
+
credits to the event's person and writes the receipt, exactly as a
|
|
26
|
+
Stripe purchase does; `refund_product { product, ref? }` takes them
|
|
27
|
+
back. Both are idempotent on `ref`. `g.payments.buy` on a product sold
|
|
28
|
+
via a relay or not yet answers 409 `product_not_sellable`; saving a
|
|
29
|
+
relay action or a product's relay road against a relay that does not
|
|
30
|
+
exist in this environment answers 400 `relay_missing`.
|
|
31
|
+
|
|
32
|
+
### The AI tool primitive (engine 0.9.0, sdk 0.8.0, mcp 0.5.0)
|
|
33
|
+
|
|
34
|
+
The AI route now runs named, priced tools instead of one flat rate. An
|
|
35
|
+
owner creates an `ai_tools` row (console Tools table, or `PUT
|
|
36
|
+
/internal/ai/tools`; the CLI's `gemmein ai tools` lists, sets and removes
|
|
37
|
+
them, and the local rail reads `gemmein/ai/tools/<name>.json`) — a slug,
|
|
38
|
+
a label, a provider, an optional pinned model, a credit price (1 to
|
|
39
|
+
10,000), an optional `requires` entitlement key, and optional bounds
|
|
40
|
+
(`maxBodyBytes`, `maxOutputTokens`, `stream`). `g.ai.chat` and `g.ai.text`
|
|
41
|
+
take a new `tool` option naming one; a call with no `tool` runs as the
|
|
42
|
+
**default tool** — one credit, the app's configured provider, any allowed
|
|
43
|
+
model — so every existing integration keeps working unchanged. Up to 50
|
|
44
|
+
tools per environment.
|
|
45
|
+
|
|
46
|
+
New codes: `unknown_tool` (404 — no tool by that name), `tool_disabled`
|
|
47
|
+
(403 — the owner switched it off), `entitlement_required` (403 — the
|
|
48
|
+
message names the plan or product the tool requires), `model_pinned`
|
|
49
|
+
(403 — the tool's model is fixed; leave `model` out of the body),
|
|
50
|
+
`provider_not_configured` (409 — creating a tool on a provider with no
|
|
51
|
+
key set), `too_many_tools` (409 — 50 tools per environment) and
|
|
52
|
+
`invalid_tool` (400 — a bad field creating or updating a tool).
|
|
53
|
+
|
|
54
|
+
A response from a named tool now carries `x-gemmein-tool` — the tool's slug —
|
|
55
|
+
alongside `x-gemmein-credits-remaining`. `x-gemmein-tool` names the tool;
|
|
56
|
+
absent on the implicit default.
|
|
57
|
+
|
|
58
|
+
## [0.7.0] — 2026-09-04
|
|
59
|
+
|
|
60
|
+
### Credits and the AI route
|
|
61
|
+
|
|
62
|
+
Credits are a general consumable: a balance your customers hold and your
|
|
63
|
+
product spends. A product carries `grantsCredits` (1..1,000,000) and each
|
|
64
|
+
confirmed purchase adds that many to the buyer's balance, idempotent on the
|
|
65
|
+
payment; a full refund claws back what is still unspent, floor zero. The
|
|
66
|
+
browser reads the balance with `g.credits.balance()`; a server spends with
|
|
67
|
+
`gemmeinServer(sk).spendCredits(personId, { amount?, reason, key? })` behind
|
|
68
|
+
the fourth key capability, "Spend a person's credits" — one atomic
|
|
69
|
+
decrement, refused whole below zero with `402 credits_exhausted` and the
|
|
70
|
+
balance in the message, at-most-once on `key`. `Holdings.credits` is
|
|
71
|
+
`{ balance }` from engine 0.8.0 (the type is unchanged; an older local engine
|
|
72
|
+
still answers `null`). A relay adds with `grant_credits`; the owner comps
|
|
73
|
+
from the person's page. Credits never expire and are never priced by token.
|
|
74
|
+
|
|
75
|
+
The AI route lets a browser session call OpenAI, Anthropic or Google through
|
|
76
|
+
Gemmein on the owner's own provider key, pasted once in the Keys room and
|
|
77
|
+
write-only from then on. `g.ai.chat(body, { provider?, signal? })` spends one
|
|
78
|
+
credit, forwards the provider's own request body as sent (minus `provider`,
|
|
79
|
+
and for Google `model` and `stream`, which ride the URL) with the auth
|
|
80
|
+
headers, `content-type` and `accept` added, and returns the fetch `Response`
|
|
81
|
+
untouched — the provider's answer, 2xx or not, is yours to read; a stream
|
|
82
|
+
stays a stream. `g.ai.text(body)` collects a non-stream answer to one string
|
|
83
|
+
per provider and throws `provider_error` on a provider's own non-2xx. A
|
|
84
|
+
credit is refunded only when the provider fails before its first byte
|
|
85
|
+
(`x-gemmein-credit: refunded`); a stream that dies mid-way does not refund,
|
|
86
|
+
and hanging up before the headers does not refund. A secret key is refused
|
|
87
|
+
(`403 scope_denied`): a server calls the provider directly. The codes that
|
|
88
|
+
join `GemmeinError`: `credits_exhausted`, `credits_ceiling`,
|
|
89
|
+
`dedupe_conflict`, `invalid_amount`, `invalid_reason`, `invalid_key`,
|
|
90
|
+
`person_not_found`, `capability_required`, `invalid_body`,
|
|
91
|
+
`ai_not_configured`, `provider_required`, `model_not_allowed`, `ai_capped`,
|
|
92
|
+
`payload_too_large`, `session_required`, `scope_denied`,
|
|
93
|
+
`provider_unreachable`, and `provider_error` from `g.ai.text`. `REFERENCE.md`
|
|
94
|
+
carries the Credits and AI chapters; `llms.txt` carries both capability
|
|
95
|
+
cards. `gemmein dev` answers a fake provider without a key and its pay
|
|
96
|
+
simulator honours the `grantsCredits` a product declares in
|
|
97
|
+
`gemmein/payments.json` (`gemmein payments setup` asks for it).
|
|
98
|
+
|
|
99
|
+
The ledger's laws, stated: a full refund claws back what that purchase's own
|
|
100
|
+
ledger row granted, whatever the product says today; a balance carries at
|
|
101
|
+
most 1,000,000,000 (`credits_ceiling`, nothing added past it); a server
|
|
102
|
+
spend's `key` is scoped to the person, so one order id reused for two people
|
|
103
|
+
charges both and a key that names a different movement is `dedupe_conflict`;
|
|
104
|
+
the person's page shows the credits that person spent in the last 30 days; a
|
|
105
|
+
provider that echoes the key in a refusal reaches the app as `***<hint>`.
|
|
106
|
+
|
|
107
|
+
### Added
|
|
108
|
+
- `g.credits.balance()` → `GET /auth/credits` → `{ balance }` (session required).
|
|
109
|
+
- `g.ai.chat(body, { provider?, signal? })` → `POST /ai/chat`; answers the
|
|
110
|
+
fetch `Response` untouched (streaming intact) whenever the answer passed
|
|
111
|
+
the spend — the provider's own status and body, 2xx or not; Gemmein's
|
|
112
|
+
refusals throw `GemmeinError` with the engine's code — `credits_exhausted`
|
|
113
|
+
(402), `ai_not_configured` (409), `provider_required` (400),
|
|
114
|
+
`model_not_allowed` (403), `ai_capped` (429), `payload_too_large` (413),
|
|
115
|
+
`invalid_body` (400), `session_required` (401), `scope_denied` (403),
|
|
116
|
+
`provider_unreachable` (502).
|
|
117
|
+
- `g.ai.text(body, options?)` — the non-streaming answer as a string
|
|
118
|
+
(OpenAI `choices[0].message.content`; Anthropic `content[].text`;
|
|
119
|
+
Google `candidates[0].content.parts[].text`); `invalid_response` when
|
|
120
|
+
no text came back; `provider_error` (the provider's status and message)
|
|
121
|
+
on a provider's own non-2xx.
|
|
122
|
+
- `gemmeinServer(sk).spendCredits(personId, { amount?, reason, key? })` →
|
|
123
|
+
`POST /server/people/:id/credits/spend` → `{ ok, spent, deduped,
|
|
124
|
+
balance: { before, after }, event: { id, reason, actor } }` (a deduped
|
|
125
|
+
repeat answers `spent: 0` and the same event); needs the key's "Spend a
|
|
126
|
+
person's credits" capability; `402 credits_exhausted`.
|
|
127
|
+
- `Holdings.credits` is filled (`{ balance }`) from engine 0.8.0 — the
|
|
128
|
+
type is unchanged.
|
|
129
|
+
- Relays: `grant_credits { amount 1..10,000, reason? }` action, mirrored
|
|
130
|
+
in `@gemmein/mcp`'s offline validator.
|
|
131
|
+
- Console: the Keys room's AI provider card (set · test · replace ·
|
|
132
|
+
remove), "Grants credits" on a product, CREDITS + Grant credits + the
|
|
133
|
+
ledger on a person, the Usage room's "AI calls" row.
|
|
134
|
+
|
|
15
135
|
## [0.6.0] — 2026-09-04
|
|
16
136
|
|
|
17
137
|
### 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
|
|
|
@@ -238,6 +238,16 @@ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
|
238
238
|
don't also redirect to the returned `url`, and never build a Stripe URL
|
|
239
239
|
yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
|
|
240
240
|
gate one-off fulfilment on the receipt record, never the redirect.
|
|
241
|
+
|
|
242
|
+
A product is sold one of three ways, set on the Payments page: **via a
|
|
243
|
+
Stripe Payment Link** (`buy` navigates there); **via a relay** — any
|
|
244
|
+
provider whose webhook the founder maps (GoCardless, Lemon Squeezy,
|
|
245
|
+
Paddle, bank transfer) fulfils it with the `fulfil_product` relay
|
|
246
|
+
action, and refunds it with `refund_product`; or **not yet** — the
|
|
247
|
+
product is defined, its grants and credits are known, no road wired.
|
|
248
|
+
`buy` on a product sold via a relay or not yet answers 409
|
|
249
|
+
`product_not_sellable`. The price always comes from the provider — the
|
|
250
|
+
Payment Link or the relay — never the app.
|
|
241
251
|
By-hand grants (trial, promotion, a support comp) are not listed to the
|
|
242
252
|
app — a gated read simply succeeds — so never rebuild the paywall from
|
|
243
253
|
`mine()`; let the server refuse with `entitlement_required`. The two grant
|
|
@@ -271,6 +281,188 @@ honestly, not hidden.)
|
|
|
271
281
|
|
|
272
282
|
---
|
|
273
283
|
|
|
284
|
+
## Credits — `g.credits` / `gemmeinServer(sk).spendCredits`
|
|
285
|
+
|
|
286
|
+
A balance your customers hold and your product spends: a pack they buy, a
|
|
287
|
+
comp the owner gives, an AI tool call priced in credits. Credits are a **quantity beside
|
|
288
|
+
access**, never access itself — a locked collection still asks for the
|
|
289
|
+
entitlement, whatever the balance. The ledger adds at every purchase, grant
|
|
290
|
+
and refund, subtracts at every spend, and never goes below zero: a spend
|
|
291
|
+
past the balance is refused whole (`402 credits_exhausted`, the balance in
|
|
292
|
+
the message), never partly. No expiry. No per-token pricing.
|
|
293
|
+
|
|
294
|
+
| Surface | Signature | Returns |
|
|
295
|
+
|---------|-----------|---------|
|
|
296
|
+
| `g.credits.balance` | `()` | `Promise<{ balance: number }>` — the signed-in person's balance now (`401 session_required` without a session) |
|
|
297
|
+
| `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) |
|
|
298
|
+
| `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 |
|
|
299
|
+
|
|
300
|
+
Where credits come from, and where they go:
|
|
301
|
+
|
|
302
|
+
- **A product with `grantsCredits`** (the "Grants credits" field on the
|
|
303
|
+
Payments page, 1..1,000,000). Each confirmed purchase **adds** that many to
|
|
304
|
+
the buyer's balance — two packs make double, never a reset — idempotent on
|
|
305
|
+
the payment. A purchase by an email that has never signed in creates the
|
|
306
|
+
person and credits them. A **full** refund claws back what that purchase's
|
|
307
|
+
own ledger row granted — at most what is still unspent, floor 0 — whatever
|
|
308
|
+
the product says today; a partial refund moves nothing. The purchase, the
|
|
309
|
+
grant and the clawback are each one ledger line.
|
|
310
|
+
- **The owner's dashboard** comps by hand from the person's page (≤100,000 per
|
|
311
|
+
action, a note required), and shows the balance, the newest 50 non-spend
|
|
312
|
+
ledger lines and the credits the person spent in the last 30 days.
|
|
313
|
+
- **A relay** adds with `grant_credits { amount (1..10,000), reason? }` — see
|
|
314
|
+
**Relays**.
|
|
315
|
+
- **Your server** spends with `spendCredits`; **the AI route** spends the
|
|
316
|
+
credits the owner set for the named tool, one by default (see **AI**).
|
|
317
|
+
Nothing spends from the browser.
|
|
318
|
+
- Spend lines are kept 90 days on the cloud rail; purchases, grants and
|
|
319
|
+
clawbacks are kept. A balance carries at most 1,000,000,000. Account
|
|
320
|
+
erasure removes the person's balance and ledger.
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
// browser — show the balance
|
|
324
|
+
const { balance } = await g.credits.balance()
|
|
325
|
+
|
|
326
|
+
// server — one export costs one credit; the key makes a retry safe
|
|
327
|
+
const r = await gemmeinServer(process.env.GEMMEIN_SECRET_KEY).spendCredits(person.id, {
|
|
328
|
+
amount: 1, reason: "export", key: `export:${jobId}`
|
|
329
|
+
})
|
|
330
|
+
// r = { ok: true, spent: 1, deduped: false, balance: { before: 12, after: 11 },
|
|
331
|
+
// event: { id: "cev_…", reason: "export", actor: "<the key's name>" } }
|
|
332
|
+
// a retry with the same key: { …, spent: 0, deduped: true, event: the same one }
|
|
333
|
+
// GemmeinError 402 credits_exhausted — "this person has 0 credits — the spend needs 1"
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
| code | status | meaning · do |
|
|
337
|
+
|------|--------|--------------|
|
|
338
|
+
| `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 |
|
|
339
|
+
| `session_required` | 401 | `g.credits.balance()` without a signed-in person — sign in first |
|
|
340
|
+
| `capability_required` | 403 | `spendCredits` on a key without "Spend a person's credits" — mint one with it ticked |
|
|
341
|
+
| `person_not_found` | 404 | No person with this id in this app and environment — ids come from `verifySession` or the dashboard |
|
|
342
|
+
| `invalid_amount` | 400 | `amount` outside 1..10,000 (or not a whole number) |
|
|
343
|
+
| `invalid_reason` | 400 | `reason` missing, not text, or over 200 chars |
|
|
344
|
+
| `invalid_key` | 400 | `key` not text, empty, or over 200 chars |
|
|
345
|
+
| `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 |
|
|
346
|
+
| `credits_ceiling` | 409 | A credit that would carry the balance past 1,000,000,000 — nothing was added |
|
|
347
|
+
|
|
348
|
+
`gemmein dev` keeps the same ledger in memory with snapshot and restore, and
|
|
349
|
+
its pay simulator honours the `grantsCredits` a product declares in
|
|
350
|
+
`gemmein/payments.json` (`gemmein payments setup` asks for it), so the
|
|
351
|
+
buy-then-spend loop runs on your machine before a payment provider is
|
|
352
|
+
connected.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## AI — `g.ai.chat` / `g.ai.text`
|
|
357
|
+
|
|
358
|
+
Your app talks to **OpenAI, Anthropic or Google** through Gemmein, on the
|
|
359
|
+
**owner's own provider key**, which never reaches the browser, through a
|
|
360
|
+
**named AI tool** — a slug the owner prices in credits, pins a model or
|
|
361
|
+
provider to, and gates behind an entitlement, from the dashboard's Tools
|
|
362
|
+
page or a local `gemmein/ai/tools/<name>.json` file. Removing a tool is a
|
|
363
|
+
step-up action, like removing a key. A call that names no
|
|
364
|
+
`tool` runs as the **default tool**: one credit, the app's configured
|
|
365
|
+
provider, any allowed model — every existing integration that never passed
|
|
366
|
+
`tool` keeps working unchanged. The owner pastes each provider key once in
|
|
367
|
+
the dashboard's Keys room; it is write-only from then on. The route
|
|
368
|
+
forwards the provider's own request body as sent — minus the `provider`
|
|
369
|
+
field, and for Google minus `model` and `stream`, which ride the URL — adds
|
|
370
|
+
the provider's auth headers, `content-type` and `accept`, and passes the
|
|
371
|
+
status and the bytes straight back — a stream stays a stream. It does not
|
|
372
|
+
choose models, cache, summarise, moderate or reshape anything, and it is for
|
|
373
|
+
the browser only: a server key is refused (`403 scope_denied`) — a server
|
|
374
|
+
calls the provider directly.
|
|
375
|
+
|
|
376
|
+
| Method | Signature | Returns |
|
|
377
|
+
|--------|-----------|---------|
|
|
378
|
+
| `ai.chat` | `(body: object, options?: { tool?: string; 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` |
|
|
379
|
+
| `ai.text` | `(body: object, options?: { tool?: string; provider?: …; signal?: AbortSignal })` | `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 |
|
|
380
|
+
|
|
381
|
+
- `tool` names an `ai_tools` row by its slug; omit it for the default tool.
|
|
382
|
+
An unknown name is `404 unknown_tool`; a tool the owner switched off is
|
|
383
|
+
`403 tool_disabled`; a tool gated with `requires` refuses a person who
|
|
384
|
+
lacks that entitlement with `403 entitlement_required` (the message names
|
|
385
|
+
the plan or product). A tool with a pinned `model` refuses a body that
|
|
386
|
+
sets one with `403 model_pinned`; a tool with no pinned model follows the
|
|
387
|
+
allowlist rule below. A tool's own `bounds.maxBodyBytes` (≤ 262,144)
|
|
388
|
+
replaces the default 256 KB cap. `bounds.maxOutputTokens` is a ceiling: a
|
|
389
|
+
larger or absent value in the body becomes the bound; a smaller one stays.
|
|
390
|
+
It reaches the provider as ONE field, never two: the ceiling is written
|
|
391
|
+
onto the field the body already used, and a second spelling of the same
|
|
392
|
+
ceiling is folded away, so a browser cannot buy more output by naming the
|
|
393
|
+
field the route was not watching. A body that names none is sent the
|
|
394
|
+
current field — `max_completion_tokens` on OpenAI, `max_tokens` on
|
|
395
|
+
Anthropic, `generationConfig.maxOutputTokens` on Google.
|
|
396
|
+
- `body` is exactly what the provider documents for its chat endpoint —
|
|
397
|
+
OpenAI chat completions, Anthropic messages, Google generateContent. Its
|
|
398
|
+
`model` field, when present, must match `^[A-Za-z0-9._:-]{1,80}$`; Google
|
|
399
|
+
needs it (it rides the URL); when the owner lists allowed models (up to
|
|
400
|
+
20 — the Keys room's test call uses the first), any other answers
|
|
401
|
+
`403 model_not_allowed`.
|
|
402
|
+
- `provider` is optional when one key is configured and required when more
|
|
403
|
+
than one is (`400 provider_required`); a named tool fixes its own
|
|
404
|
+
provider, and a `provider` in the body or `?provider=` that disagrees with
|
|
405
|
+
it is `400 invalid_body`. No key at all is `409 ai_not_configured`.
|
|
406
|
+
`?provider=` and `?stream=1` on the URL do what the body fields do.
|
|
407
|
+
- Response headers from Gemmein. `x-gemmein-credits-remaining` (the balance
|
|
408
|
+
after this call) rides every answer that passed the spend.
|
|
409
|
+
`x-gemmein-credit` reads `refunded`, and is there only when the credits
|
|
410
|
+
came back. `x-gemmein-ai` reads `fake`, and is there only on the local
|
|
411
|
+
engine's rehearsal answer. `x-gemmein-tool` names the tool; absent on the
|
|
412
|
+
implicit default.
|
|
413
|
+
- **The refund rule.** The tool's credits are refunded only when the
|
|
414
|
+
provider fails before its first byte (a non-2xx, or
|
|
415
|
+
`502 provider_unreachable`). A stream that dies after the first byte is
|
|
416
|
+
not refunded; hanging up early does not refund. A provider that echoes
|
|
417
|
+
the key in a refusal reaches the app as `***<hint>`.
|
|
418
|
+
- Limits: 20 calls per person per minute (`429 ai_capped`, `resetAt`), 256 KB
|
|
419
|
+
body by default (`413 payload_too_large`, unless a tool sets a smaller
|
|
420
|
+
`bounds.maxBodyBytes`) nested at most 32 levels (`400 invalid_body`),
|
|
421
|
+
170 s in all and, on a stream, 10 s to the first response headers. Every
|
|
422
|
+
`/ai/chat` call counts toward the app's `api_requests` band like any other
|
|
423
|
+
request.
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
const res = await g.ai.chat({
|
|
427
|
+
model: "gpt-4o-mini", stream: true,
|
|
428
|
+
messages: [{ role: "user", content: text }]
|
|
429
|
+
}, { tool: "deep-research" })
|
|
430
|
+
for await (const chunk of res.body) render(chunk) // the provider's SSE, byte for byte
|
|
431
|
+
|
|
432
|
+
const answer = await g.ai.text({ messages: [{ role: "user", content: text }] }, { tool: "deep-research" })
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
| code | status | meaning · do |
|
|
436
|
+
|------|--------|--------------|
|
|
437
|
+
| `unknown_tool` | 404 | No AI tool by this name in this environment — check the slug or the environment |
|
|
438
|
+
| `tool_disabled` | 403 | The owner switched this tool off — turn it on, or use another |
|
|
439
|
+
| `entitlement_required` | 403 | The tool's `requires` names a plan or product this person lacks — the message names it |
|
|
440
|
+
| `model_pinned` | 403 | This tool's model is fixed — leave `model` out of the body |
|
|
441
|
+
| `provider_not_configured` | 409 | Creating or updating a tool: no key is set for that provider yet — add one on the Keys page first |
|
|
442
|
+
| `too_many_tools` | 409 | This environment already holds 50 AI tools — delete one before adding another |
|
|
443
|
+
| `invalid_tool` | 400 | Creating or updating a tool with a bad field — the message names which one and its rule |
|
|
444
|
+
| `credits_exhausted` | 402 | The person's balance is below the tool's price — the message names the tool, the price and the balance ("Deep Research costs 20 credits. You have 7.", singular for 1). Show the pack |
|
|
445
|
+
| `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one in the Keys room |
|
|
446
|
+
| `provider_required` | 400 | More than one provider key is set — pass `provider` |
|
|
447
|
+
| `model_not_allowed` | 403 | The owner's allowlist names the models this app may call; the message lists them (only for a tool with no pinned model) |
|
|
448
|
+
| `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
|
|
449
|
+
| `payload_too_large` | 413 | The body is over the cap (256 KB, or the tool's own `bounds.maxBodyBytes`) — shorten the conversation you send |
|
|
450
|
+
| `invalid_body` | 400 | The body must be the provider's JSON request object, nested at most 32 levels; or a named tool's own provider was contradicted |
|
|
451
|
+
| `session_required` | 401 | No signed-in person — sign in first |
|
|
452
|
+
| `scope_denied` | 403 | A secret key called the route — the route is for the browser; a server calls the provider directly |
|
|
453
|
+
| `provider_unreachable` | 502 | The provider did not answer before the first byte — nothing was charged (the tool's credits are refunded); retry |
|
|
454
|
+
| `provider_error` | the provider's | `g.ai.text` only (client-side): the provider's own non-2xx, its message in `err.message` |
|
|
455
|
+
| `ai_test_capped` | 429 | The Keys room's test call — one a minute per app |
|
|
456
|
+
|
|
457
|
+
`gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
|
|
458
|
+
fake`, an echo stream), so the loop runs locally; set
|
|
459
|
+
`GEMMEIN_AI_KEY_OPENAI`, `GEMMEIN_AI_KEY_ANTHROPIC` or `GEMMEIN_AI_KEY_GOOGLE`
|
|
460
|
+
in the local rail's environment for a real call. The owner's Usage room
|
|
461
|
+
counts AI calls for the last 30 days: spent by the customers' credits, priced
|
|
462
|
+
by the provider — Gemmein meters the calls, the provider bills the tokens.
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
274
466
|
## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess` / `invitePerson`
|
|
275
467
|
|
|
276
468
|
Gemmein hosts no compute. **Your own** function — Vercel, a VPS, a cron box,
|
|
@@ -290,7 +482,7 @@ signed in, it can create them by email first.
|
|
|
290
482
|
type Holdings = {
|
|
291
483
|
access: string[] // the keys they hold NOW — ["access:pro"]
|
|
292
484
|
grants: Grant[] // the LIVE grants behind them (revoked/expired are gone)
|
|
293
|
-
credits: { balance: number } | null //
|
|
485
|
+
credits: { balance: number } | null // the balance now (engine 0.8.0+); null from an older local engine — see Credits
|
|
294
486
|
}
|
|
295
487
|
|
|
296
488
|
type Grant = {
|
|
@@ -313,12 +505,13 @@ type Grant = {
|
|
|
313
505
|
(`relay` is the kind a relay's `grant_access` writes; see
|
|
314
506
|
**Relays**). Gate
|
|
315
507
|
on what a person *holds*, never on what they pay. Person id, never an email.
|
|
316
|
-
- **
|
|
508
|
+
- **Four capabilities, ticked by the human.** `verifySession` needs none.
|
|
317
509
|
`holdings` needs **"Look up a person's access by id"**; `grantAccess` and
|
|
318
510
|
`revokeAccess` need **"Grant and revoke access"**; `invitePerson` needs
|
|
319
|
-
**"Create a person by email before they sign in"
|
|
320
|
-
|
|
321
|
-
|
|
511
|
+
**"Create a person by email before they sign in"**; `spendCredits` needs
|
|
512
|
+
**"Spend a person's credits"** — plain-English checkboxes the owner ticks
|
|
513
|
+
when minting the key. Existing keys have all four off, so nothing in
|
|
514
|
+
production changes.
|
|
322
515
|
- **The invite door.** `invitePerson(email)` is how your server addresses
|
|
323
516
|
someone who has never signed in — the envelope, the invoice, the client
|
|
324
517
|
portal, the booking-winner email: create the person, hand them the file,
|
|
@@ -353,7 +546,7 @@ type Grant = {
|
|
|
353
546
|
| `session_revoked` | 401 | A newer sign-in, a sign-out, or the owner ended it — send them back to sign-in |
|
|
354
547
|
| `person_suspended` | 403 | The owner suspended this person — access is off until the owner reactivates them in the dashboard |
|
|
355
548
|
| `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) |
|
|
549
|
+
| `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
550
|
| `invalid_email` | 400 | `invitePerson`: the address must look like `name@domain` — trimmed, one `@`, a dotted domain, at most 254 characters, no whitespace |
|
|
358
551
|
| `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
552
|
| `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 +557,7 @@ type Grant = {
|
|
|
364
557
|
| `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
558
|
| `scope_denied` | 403 | Not a secret key — the gate is server-only, never the browser |
|
|
366
559
|
| `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
|
|
560
|
+
| `unknown_route` | 404 | Not one of the gate's six routes — the message lists them all |
|
|
368
561
|
| `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess`, `revokeAccess` and `invitePerson` are POST, `holdings` is GET |
|
|
369
562
|
|
|
370
563
|
---
|
|
@@ -476,7 +669,10 @@ authorise on fields they cannot set, or from a receiver.
|
|
|
476
669
|
|---|---|---|
|
|
477
670
|
| `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
671
|
| `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 |
|
|
672
|
+
| `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
673
|
| `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
|
|
674
|
+
| `fulfil_product` | `product` (name, 1-40 chars), `ref?` (≤ 200, templated) | grants the product's key and credits to the event's person and writes the receipt, exactly as a Stripe purchase does; idempotent on `ref` (an absent `ref` → `rly:<eventId>`; a `ref` you wrote that names nothing on the event is refused, never replaced); a ref must be the provider's unique payment identifier; a ref already used by another purchase is refused and audited; the product's road must be this relay. The relay road binds by name: renaming or deleting the relay stops fulfilment until a relay with that name exists again; the product card shows it. |
|
|
675
|
+
| `refund_product` | `product` (name, 1-40 chars), `ref?` (≤ 200, templated) | takes back what `fulfil_product` under the same `ref` granted; `refund_product` may run from any relay in the environment; it refunds only a purchase a relay fulfilled, for the event's person and the product it names; idempotent on `ref` |
|
|
480
676
|
| `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
677
|
| `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 |
|
|
482
678
|
|
|
@@ -515,11 +711,16 @@ Egress refusals arrive as the action's error, naming the rule: `https_only`,
|
|
|
515
711
|
### Templates
|
|
516
712
|
|
|
517
713
|
In string values only — `write_record.data`, `email_person.subject` / `text`,
|
|
518
|
-
`grant_access.reason
|
|
714
|
+
`grant_access.reason`, `fulfil_product.ref` / `refund_product.ref`; never in
|
|
715
|
+
`call_url.url` or an `entitlement`:
|
|
519
716
|
`{{event.a.b}}` (the raw body), `{{mapped.x}}`, `{{record.data.x}}`,
|
|
520
717
|
`{{record.id}}`, `{{person.email}}`, `{{person.id}}`, `{{tick.periodStart}}`.
|
|
718
|
+
Mapped fields ride into templates as `{{mapped.<name>}}`; `{{event.<path>}}`
|
|
719
|
+
reads the raw payload.
|
|
521
720
|
A path that names nothing renders empty and adds a warning to the event;
|
|
522
|
-
objects render as JSON. No expressions, no filters.
|
|
721
|
+
objects render as JSON. No expressions, no filters. The one place an empty
|
|
722
|
+
render is a refusal rather than a blank is a `ref` you wrote on
|
|
723
|
+
`fulfil_product` / `refund_product`.
|
|
523
724
|
|
|
524
725
|
### Lifecycle
|
|
525
726
|
|
|
@@ -662,11 +863,28 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
662
863
|
| `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
|
|
663
864
|
| `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
865
|
| `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, `: _ . @ / -` |
|
|
866
|
+
| `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) |
|
|
867
|
+
| `invalid_amount` / `invalid_reason` (400) | `spendCredits`: `amount` outside 1..10,000, or `reason` missing / over 200 chars | fix the field the code names |
|
|
868
|
+
| `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 |
|
|
869
|
+
| `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 |
|
|
870
|
+
| `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 |
|
|
871
|
+
| `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 |
|
|
872
|
+
| `ai_test_capped` (429) | the Keys room's test call — one a minute per app | wait a minute |
|
|
666
873
|
| `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
|
|
667
874
|
| `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
875
|
| `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
|
|
669
876
|
| `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
|
|
877
|
+
| `product_not_sellable` (409) | `g.payments.buy` on a product sold via a relay or not yet — there is no Payment Link to open | tell the buyer how the product is sold, or ask the owner to wire a road |
|
|
878
|
+
| `relay_missing` (400) | saving a relay action (or a product's `sold.relay`) names a relay that does not exist in this environment | create that relay first, or name one that exists |
|
|
670
879
|
| `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
|
|
880
|
+
| `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 |
|
|
881
|
+
| `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one in the Keys room |
|
|
882
|
+
| `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
|
|
883
|
+
| `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
|
|
884
|
+
| `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |
|
|
885
|
+
| `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB | shorten the conversation you send |
|
|
886
|
+
| `session_required` (401) | `g.ai.chat` / `g.credits.balance` without a signed-in person | sign in first |
|
|
887
|
+
| `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 |
|
|
888
|
+
| `provider_unreachable` (502) | the provider did not answer before the first byte; the credit is refunded | retry |
|
|
671
889
|
|
|
672
890
|
Keys: `pk_` (public, domain-locked, browser-safe) vs `sk_` (secret, server only).
|