@gemmein/sdk 0.5.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 +224 -0
- package/REFERENCE.md +397 -20
- package/dist/index.cjs +259 -5
- package/dist/index.d.cts +179 -4
- package/dist/index.d.ts +179 -4
- package/dist/index.js +256 -4
- package/llms.txt +260 -25
- package/migrations/README.md +35 -0
- package/migrations/list-limit-refusal.md +42 -0
- package/package.json +7 -3
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
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 = 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
|
+
/** This build's version — the value `package.json` carries. Kept inline
|
|
7
|
+
* because the package is one source file and this file is imported
|
|
8
|
+
* straight from source by the security suite (no runtime file read, no
|
|
9
|
+
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
10
|
+
* package.json before every build (`prebuild`), and a test pins the two
|
|
11
|
+
* equal, so a bump can never ship with a stale header. */
|
|
12
|
+
exports.SDK_VERSION = "0.7.0"; // synced from package.json — do not edit by hand
|
|
13
|
+
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
14
|
+
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
15
|
+
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
16
|
+
* misbehaving integration can be attributed to an SDK version from day
|
|
17
|
+
* one. It is a report, not a proof — any caller can set it. */
|
|
18
|
+
exports.CLIENT_INFO = `gemmein-sdk/${exports.SDK_VERSION}`;
|
|
6
19
|
class GemmeinError extends Error {
|
|
7
20
|
constructor(input) {
|
|
8
21
|
super(input.message);
|
|
@@ -111,6 +124,8 @@ class Gemmein {
|
|
|
111
124
|
this.purchases = new PurchasesClient(config);
|
|
112
125
|
this.account = new AccountClient(config);
|
|
113
126
|
this.files = new FilesClient(config);
|
|
127
|
+
this.credits = new CreditsClient(config);
|
|
128
|
+
this.ai = new AiClient(config);
|
|
114
129
|
}
|
|
115
130
|
/**
|
|
116
131
|
* Your app's data — `g.collection<{ title: string }>("notes")`. The
|
|
@@ -164,7 +179,15 @@ class AuthClient {
|
|
|
164
179
|
await this.config.tokenStore.set(result.token);
|
|
165
180
|
return result;
|
|
166
181
|
}
|
|
167
|
-
|
|
182
|
+
// W9.1: the one place the SDK threw a bare Error. Every refusal the SDK
|
|
183
|
+
// raises is a GemmeinError so `err.code` is always there to branch on;
|
|
184
|
+
// status 0 is the SDK's own convention for "no HTTP status applies"
|
|
185
|
+
// (see invalid_collection_name, missing_app_key).
|
|
186
|
+
throw new GemmeinError({
|
|
187
|
+
status: 0,
|
|
188
|
+
code: "invalid_response",
|
|
189
|
+
message: "Gemmein auth response did not include a session token"
|
|
190
|
+
});
|
|
168
191
|
}
|
|
169
192
|
async logout() {
|
|
170
193
|
try {
|
|
@@ -351,6 +374,169 @@ class AccountClient {
|
|
|
351
374
|
}
|
|
352
375
|
}
|
|
353
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
|
+
}
|
|
354
540
|
function isSessionResponse(value) {
|
|
355
541
|
return (typeof value === "object" &&
|
|
356
542
|
value !== null &&
|
|
@@ -764,7 +950,7 @@ class GemmeinServer {
|
|
|
764
950
|
}
|
|
765
951
|
const response = await fetch(new URL("/server/test-session", this.apiUrl), {
|
|
766
952
|
method: "POST",
|
|
767
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
953
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
|
|
768
954
|
body: JSON.stringify({ email }),
|
|
769
955
|
});
|
|
770
956
|
if (!response.ok) {
|
|
@@ -802,7 +988,7 @@ class GemmeinServer {
|
|
|
802
988
|
async notify(personId, input) {
|
|
803
989
|
const response = await fetch(new URL("/server/notify", this.apiUrl), {
|
|
804
990
|
method: "POST",
|
|
805
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
991
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
|
|
806
992
|
body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
|
|
807
993
|
});
|
|
808
994
|
if (!response.ok) {
|
|
@@ -843,6 +1029,35 @@ class GemmeinServer {
|
|
|
843
1029
|
body: JSON.stringify({ token }),
|
|
844
1030
|
});
|
|
845
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
|
|
1034
|
+
* The envelope, the invoice, the client portal, the booking-winner
|
|
1035
|
+
* email — all address someone who has never signed in and so has no
|
|
1036
|
+
* id yet. This is the one server call that takes an email:
|
|
1037
|
+
*
|
|
1038
|
+
* const { person, created } = await g.invitePerson("client@example.com");
|
|
1039
|
+
* await g.notify(person.id, { subject: "Your contract", text: "…" });
|
|
1040
|
+
*
|
|
1041
|
+
* Create-or-fetch, idempotent, case-insensitive: the first call makes
|
|
1042
|
+
* the person (`created: true`, HTTP 201), every later call finds them
|
|
1043
|
+
* (`created: false`, 200) — one id either way, the address returned
|
|
1044
|
+
* lowercased. Their first sign-in lands on this account: records and
|
|
1045
|
+
* files you addressed to `person.id` are already theirs. `person.invited`
|
|
1046
|
+
* stays true until that sign-in; a suspended person is returned with
|
|
1047
|
+
* `suspended: true`, never refused.
|
|
1048
|
+
*
|
|
1049
|
+
* Needs the key's "Create a person by email before they sign in" box
|
|
1050
|
+
* ticked by your human. Refusals: `capability_required` (the box isn't
|
|
1051
|
+
* ticked) · `invalid_email` (400 — must look like name@domain) ·
|
|
1052
|
+
* `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
|
|
1053
|
+
* where to write to raise it; `err.resetAt` says when the window ends).
|
|
1054
|
+
*/
|
|
1055
|
+
async invitePerson(email) {
|
|
1056
|
+
return this.gate("/server/people", {
|
|
1057
|
+
method: "POST",
|
|
1058
|
+
body: JSON.stringify({ email }),
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
846
1061
|
/**
|
|
847
1062
|
* What one of YOUR people holds, by person id — for the paths where no
|
|
848
1063
|
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
@@ -902,6 +1117,43 @@ class GemmeinServer {
|
|
|
902
1117
|
}),
|
|
903
1118
|
});
|
|
904
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
|
+
}
|
|
905
1157
|
/**
|
|
906
1158
|
* End one grant — the reversibility law in one call:
|
|
907
1159
|
*
|
|
@@ -927,7 +1179,7 @@ class GemmeinServer {
|
|
|
927
1179
|
// `err.code` carries the server's own code, `err.message` its sentence
|
|
928
1180
|
// (which always names the next action).
|
|
929
1181
|
async gate(path, init = {}) {
|
|
930
|
-
const headers = { "x-app-key": this.secretKey };
|
|
1182
|
+
const headers = { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO };
|
|
931
1183
|
if (init.body)
|
|
932
1184
|
headers["content-type"] = "application/json";
|
|
933
1185
|
const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
|
|
@@ -977,6 +1229,7 @@ class ServerCollectionClient {
|
|
|
977
1229
|
async request(suffix, init = {}) {
|
|
978
1230
|
const headers = {
|
|
979
1231
|
"x-app-key": this.secretKey,
|
|
1232
|
+
"x-client-info": exports.CLIENT_INFO,
|
|
980
1233
|
};
|
|
981
1234
|
if (init.body) {
|
|
982
1235
|
headers["content-type"] = "application/json";
|
|
@@ -1052,6 +1305,7 @@ async function runtimeHeaders(config, headers) {
|
|
|
1052
1305
|
return {
|
|
1053
1306
|
...headers,
|
|
1054
1307
|
"x-app-key": config.appKey,
|
|
1308
|
+
"x-client-info": exports.CLIENT_INFO,
|
|
1055
1309
|
...(token ? { authorization: `Bearer ${token}` } : {})
|
|
1056
1310
|
};
|
|
1057
1311
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -114,6 +114,19 @@ export type AuthSession = {
|
|
|
114
114
|
email: string;
|
|
115
115
|
};
|
|
116
116
|
};
|
|
117
|
+
/** This build's version — the value `package.json` carries. Kept inline
|
|
118
|
+
* because the package is one source file and this file is imported
|
|
119
|
+
* straight from source by the security suite (no runtime file read, no
|
|
120
|
+
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
121
|
+
* package.json before every build (`prebuild`), and a test pins the two
|
|
122
|
+
* equal, so a bump can never ship with a stale header. */
|
|
123
|
+
export declare const SDK_VERSION = "0.7.0";
|
|
124
|
+
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
125
|
+
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
126
|
+
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
127
|
+
* misbehaving integration can be attributed to an SDK version from day
|
|
128
|
+
* one. It is a report, not a proof — any caller can set it. */
|
|
129
|
+
export declare const CLIENT_INFO = "gemmein-sdk/0.7.0";
|
|
117
130
|
export declare class GemmeinError extends Error {
|
|
118
131
|
readonly status: number;
|
|
119
132
|
readonly code: string;
|
|
@@ -170,6 +183,10 @@ export declare class Gemmein {
|
|
|
170
183
|
readonly purchases: PurchasesClient;
|
|
171
184
|
readonly account: AccountClient;
|
|
172
185
|
readonly files: FilesClient;
|
|
186
|
+
/** W9.3 — the signed-in person's own credit balance (engine 0.8.0). */
|
|
187
|
+
readonly credits: CreditsClient;
|
|
188
|
+
/** W9.3 — the AI route: OpenAI / Anthropic / Google on the owner's key. */
|
|
189
|
+
readonly ai: AiClient;
|
|
173
190
|
constructor(options: GemmeinOptions);
|
|
174
191
|
/**
|
|
175
192
|
* Your app's data — `g.collection<{ title: string }>("notes")`. The
|
|
@@ -369,6 +386,93 @@ export declare class AccountClient {
|
|
|
369
386
|
*/
|
|
370
387
|
delete(): Promise<unknown>;
|
|
371
388
|
}
|
|
389
|
+
export type AiProvider = "openai" | "anthropic" | "google";
|
|
390
|
+
export type AiChatOptions = {
|
|
391
|
+
/** Which configured provider answers. Optional when exactly one key is
|
|
392
|
+
* set; refused `provider_required` (400) when it is ambiguous. */
|
|
393
|
+
provider?: AiProvider;
|
|
394
|
+
/** Abort the call — the stream closes; a call that dies mid-stream is
|
|
395
|
+
* not refunded. */
|
|
396
|
+
signal?: AbortSignal;
|
|
397
|
+
};
|
|
398
|
+
/** `spendCredits`'s answer: `spent` is the amount taken (0 on a deduped
|
|
399
|
+
* repeat); `event` is the ledger line — the same one on a repeat. */
|
|
400
|
+
export type SpendCreditsResult = {
|
|
401
|
+
ok: true;
|
|
402
|
+
spent: number;
|
|
403
|
+
deduped: boolean;
|
|
404
|
+
balance: {
|
|
405
|
+
before: number;
|
|
406
|
+
after: number;
|
|
407
|
+
};
|
|
408
|
+
event: {
|
|
409
|
+
id: string;
|
|
410
|
+
reason: string | null;
|
|
411
|
+
actor: string;
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
/** The signed-in person's own credits — so an app can draw its own meter. */
|
|
415
|
+
export declare class CreditsClient {
|
|
416
|
+
private readonly config;
|
|
417
|
+
constructor(config: ClientConfig);
|
|
418
|
+
/**
|
|
419
|
+
* The balance RIGHT NOW, server-resolved — the number that refuses at
|
|
420
|
+
* zero, never client math. Session required (`session_required`, 401).
|
|
421
|
+
*
|
|
422
|
+
* const { balance } = await g.credits.balance();
|
|
423
|
+
*/
|
|
424
|
+
balance(): Promise<{
|
|
425
|
+
balance: number;
|
|
426
|
+
}>;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The AI route. `chat` takes the provider's own request body — exactly what
|
|
430
|
+
* you would POST to OpenAI's /v1/chat/completions, Anthropic's /v1/messages
|
|
431
|
+
* or Google's generateContent — and answers with the fetch `Response`
|
|
432
|
+
* untouched, streaming intact (SSE stays SSE). Gemmein spends one credit,
|
|
433
|
+
* adds the owner's key, forwards, and passes status and bytes back.
|
|
434
|
+
* Response headers: `x-gemmein-credits-remaining` on every answer that
|
|
435
|
+
* passed the spend; `x-gemmein-credit: refunded` when the provider failed
|
|
436
|
+
* before its first byte.
|
|
437
|
+
*/
|
|
438
|
+
export declare class AiClient {
|
|
439
|
+
private readonly config;
|
|
440
|
+
constructor(config: ClientConfig);
|
|
441
|
+
/**
|
|
442
|
+
* const res = await g.ai.chat({ model: "gpt-4o-mini", messages, stream: true });
|
|
443
|
+
* for await (const chunk of res.body) { … }
|
|
444
|
+
*
|
|
445
|
+
* Browser sessions only — a server key is refused (`scope_denied`, 403).
|
|
446
|
+
* Refusals, all `GemmeinError`: `session_required` (401) ·
|
|
447
|
+
* `credits_exhausted` (402 — the message carries the balance; show your
|
|
448
|
+
* own "buy more" door, which is a product checkout) · `ai_not_configured`
|
|
449
|
+
* (409 — the owner has set no key) · `provider_required` (400) ·
|
|
450
|
+
* `model_not_allowed` (403 — the owner's models list) · `ai_capped`
|
|
451
|
+
* (429 — 20 calls a minute per person; `err.resetAt`) ·
|
|
452
|
+
* `payload_too_large` (413 — 256 KB) · `provider_unreachable` (502,
|
|
453
|
+
* before the first byte, refunded). Those are GEMMEIN's refusals. The
|
|
454
|
+
* PROVIDER's own answer — 2xx or not — is returned as it came: an answer
|
|
455
|
+
* that carries `x-gemmein-credits-remaining` (or `x-gemmein-credit`)
|
|
456
|
+
* passed the spend, so its status and body are the provider's; read
|
|
457
|
+
* `res.ok` / `res.status` yourself (a provider 4xx before the first byte
|
|
458
|
+
* is refunded, header `x-gemmein-credit: refunded`).
|
|
459
|
+
*/
|
|
460
|
+
chat(body: Record<string, unknown>, options?: AiChatOptions): Promise<Response>;
|
|
461
|
+
/**
|
|
462
|
+
* The non-streaming convenience: one call, one string. Pass a body that
|
|
463
|
+
* does NOT stream (`stream` unset or false); the provider's JSON answer is
|
|
464
|
+
* read whole and the text is lifted out per provider — OpenAI
|
|
465
|
+
* `choices[0].message.content`, Anthropic `content[].text` joined,
|
|
466
|
+
* Google `candidates[0].content.parts[].text` joined. An answer with no
|
|
467
|
+
* text in any of those places throws `invalid_response` (status 0). A
|
|
468
|
+
* provider's own non-2xx throws `provider_error` with the provider's
|
|
469
|
+
* status and the provider's message (the owner's key masked to its
|
|
470
|
+
* hint if the provider echoed it).
|
|
471
|
+
*
|
|
472
|
+
* const answer = await g.ai.text({ model: "claude-sonnet-4-5", max_tokens: 400, messages });
|
|
473
|
+
*/
|
|
474
|
+
text(body: Record<string, unknown>, options?: AiChatOptions): Promise<string>;
|
|
475
|
+
}
|
|
372
476
|
export declare class StorageClient {
|
|
373
477
|
private readonly config;
|
|
374
478
|
constructor(config: ClientConfig);
|
|
@@ -529,7 +633,7 @@ export type GemmeinServerOptions = {
|
|
|
529
633
|
* Where a grant came from — the KIND only. The gate never returns the
|
|
530
634
|
* source's id, an amount, or anything from Stripe.
|
|
531
635
|
*/
|
|
532
|
-
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration";
|
|
636
|
+
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay";
|
|
533
637
|
/**
|
|
534
638
|
* The sources a secret key may create by hand. `purchase` and
|
|
535
639
|
* `subscription` are deliberately absent: money-made access comes only
|
|
@@ -549,9 +653,11 @@ export type Grant = {
|
|
|
549
653
|
/**
|
|
550
654
|
* What a person holds RIGHT NOW — never what they pay. `access` is the
|
|
551
655
|
* union of live grants' keys; `grants` lists those live grants (revoked
|
|
552
|
-
* and expired ones are gone, not flagged). `credits` is
|
|
553
|
-
*
|
|
554
|
-
*
|
|
656
|
+
* and expired ones are gone, not flagged). `credits` is `{ balance }` —
|
|
657
|
+
* filled from engine 0.8.0 (W9.3): the person's credit balance, floored
|
|
658
|
+
* at 0, the same number `g.credits.balance()` answers and the one the AI
|
|
659
|
+
* route and `spendCredits` refuse against. An engine before 0.8.0 answers
|
|
660
|
+
* `null` here — read it as "not carried", never as zero.
|
|
555
661
|
*/
|
|
556
662
|
export type Holdings = {
|
|
557
663
|
access: string[];
|
|
@@ -565,6 +671,16 @@ export type GatePerson = {
|
|
|
565
671
|
email: string;
|
|
566
672
|
role: string;
|
|
567
673
|
};
|
|
674
|
+
/**
|
|
675
|
+
* W9.2 — the person `invitePerson` returns. `invited` is true until they
|
|
676
|
+
* sign in for the first time; `suspended` is the owner's switch (a
|
|
677
|
+
* suspended person is returned, never refused — `verifySession` is what
|
|
678
|
+
* refuses them).
|
|
679
|
+
*/
|
|
680
|
+
export type InvitedPerson = GatePerson & {
|
|
681
|
+
invited: boolean;
|
|
682
|
+
suspended: boolean;
|
|
683
|
+
};
|
|
568
684
|
export declare class GemmeinServer {
|
|
569
685
|
private readonly apiUrl;
|
|
570
686
|
private readonly secretKey;
|
|
@@ -648,6 +764,33 @@ export declare class GemmeinServer {
|
|
|
648
764
|
person: GatePerson;
|
|
649
765
|
holdings: Holdings;
|
|
650
766
|
}>;
|
|
767
|
+
/**
|
|
768
|
+
* W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
|
|
769
|
+
* The envelope, the invoice, the client portal, the booking-winner
|
|
770
|
+
* email — all address someone who has never signed in and so has no
|
|
771
|
+
* id yet. This is the one server call that takes an email:
|
|
772
|
+
*
|
|
773
|
+
* const { person, created } = await g.invitePerson("client@example.com");
|
|
774
|
+
* await g.notify(person.id, { subject: "Your contract", text: "…" });
|
|
775
|
+
*
|
|
776
|
+
* Create-or-fetch, idempotent, case-insensitive: the first call makes
|
|
777
|
+
* the person (`created: true`, HTTP 201), every later call finds them
|
|
778
|
+
* (`created: false`, 200) — one id either way, the address returned
|
|
779
|
+
* lowercased. Their first sign-in lands on this account: records and
|
|
780
|
+
* files you addressed to `person.id` are already theirs. `person.invited`
|
|
781
|
+
* stays true until that sign-in; a suspended person is returned with
|
|
782
|
+
* `suspended: true`, never refused.
|
|
783
|
+
*
|
|
784
|
+
* Needs the key's "Create a person by email before they sign in" box
|
|
785
|
+
* ticked by your human. Refusals: `capability_required` (the box isn't
|
|
786
|
+
* ticked) · `invalid_email` (400 — must look like name@domain) ·
|
|
787
|
+
* `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
|
|
788
|
+
* where to write to raise it; `err.resetAt` says when the window ends).
|
|
789
|
+
*/
|
|
790
|
+
invitePerson(email: string): Promise<{
|
|
791
|
+
person: InvitedPerson;
|
|
792
|
+
created: boolean;
|
|
793
|
+
}>;
|
|
651
794
|
/**
|
|
652
795
|
* What one of YOUR people holds, by person id — for the paths where no
|
|
653
796
|
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
@@ -710,6 +853,38 @@ export declare class GemmeinServer {
|
|
|
710
853
|
grant: Grant;
|
|
711
854
|
holdings: Holdings;
|
|
712
855
|
}>;
|
|
856
|
+
/**
|
|
857
|
+
* W9.3 — spend a person's credits from YOUR server, with a reason:
|
|
858
|
+
*
|
|
859
|
+
* const { balance } = await g.spendCredits(personId, {
|
|
860
|
+
* amount: 5, // default 1; 1..10,000 per call
|
|
861
|
+
* reason: "render:4k", // what the owner reads in the ledger
|
|
862
|
+
* key: `render:${jobId}`, // your retry key — a repeat is deduped
|
|
863
|
+
* });
|
|
864
|
+
* // { ok: true, spent: 5, deduped: false, balance: { before: 20, after: 15 },
|
|
865
|
+
* // event: { id: "cev_…", reason: "render:4k", actor: "<your key's name>" } }
|
|
866
|
+
*
|
|
867
|
+
* ONE conditional update, floored at 0: the spend succeeds whole or not at
|
|
868
|
+
* all, and `402 credits_exhausted` says "this person has {balance} credits
|
|
869
|
+
* — the spend needs {amount}". Pass `key` when the caller can retry — the
|
|
870
|
+
* same key answers the first spend again with `deduped: true`, `spent: 0`,
|
|
871
|
+
* the same `event` and the balance untouched. The key is scoped to the
|
|
872
|
+
* person: one order id reused for two people charges both. Credits come
|
|
873
|
+
* from a pack they bought, a comp in the back office, or a relay; a key
|
|
874
|
+
* cannot mint them.
|
|
875
|
+
*
|
|
876
|
+
* Needs the key's "Spend a person's credits" box ticked by your human.
|
|
877
|
+
* Refusals: `capability_required` (403 — "this key can't spend credits —
|
|
878
|
+
* mint a key with 'Spend a person's credits' ticked") ·
|
|
879
|
+
* `credits_exhausted` (402) · `person_not_found` (404) · `invalid_amount` /
|
|
880
|
+
* `invalid_reason` / `invalid_key` (400) · `dedupe_conflict` (409 — the
|
|
881
|
+
* key already names a different movement).
|
|
882
|
+
*/
|
|
883
|
+
spendCredits(personId: string, input: {
|
|
884
|
+
amount?: number;
|
|
885
|
+
reason: string;
|
|
886
|
+
key?: string;
|
|
887
|
+
}): Promise<SpendCreditsResult>;
|
|
713
888
|
/**
|
|
714
889
|
* End one grant — the reversibility law in one call:
|
|
715
890
|
*
|