@gemmein/sdk 0.9.0 → 0.10.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 +179 -0
- package/README.md +79 -18
- package/REFERENCE.md +282 -14
- package/dist/expo.cjs +423 -0
- package/dist/expo.d.cts +152 -0
- package/dist/expo.d.ts +152 -0
- package/dist/expo.js +387 -0
- package/dist/index.cjs +224 -34
- package/dist/index.d.cts +143 -9
- package/dist/index.d.ts +143 -9
- package/dist/index.js +223 -34
- package/llms.txt +320 -34
- package/migrations/README.md +1 -0
- package/migrations/secure-store-set-throws.md +75 -0
- package/package.json +23 -2
package/REFERENCE.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Gemmein SDK — API Reference
|
|
2
2
|
|
|
3
|
-
Complete surface of `@gemmein/sdk`, generated from the type definitions
|
|
3
|
+
Complete surface of `@gemmein/sdk`, generated from the type definitions —
|
|
4
|
+
with, beside each JavaScript method, the `GemmeinSwift` signature and return
|
|
5
|
+
type for the same method, and everything `@gemmein/sdk/expo` adds.
|
|
4
6
|
This is the **reference** (every method, signature, return shape, error);
|
|
5
7
|
`llms.txt` is the **guide** (how the model works and the safe patterns). Both
|
|
6
8
|
are needed. Draft — feeds docs.gemmein.com.
|
|
@@ -16,9 +18,45 @@ const g = gemmein("pk_..."); // browser: your public app key (domain-lock
|
|
|
16
18
|
|
|
17
19
|
### `gemmein(appKey, options?) → Gemmein`
|
|
18
20
|
The browser client. `appKey` is your public `pk_...` key. `options` (optional):
|
|
19
|
-
`{ apiUrl?: string, tokenStore?: TokenStore }`.
|
|
21
|
+
`{ apiUrl?: string, tokenStore?: TokenStore, fetch?: typeof fetch, visibility?: VisibilityHook, platform?: string }`.
|
|
22
|
+
Throws `GemmeinError`
|
|
20
23
|
(`missing_app_key` / `invalid_app_key`) if the key is absent or an `sk_`.
|
|
21
24
|
|
|
25
|
+
The last three are what a non-browser runtime needs and a browser never passes.
|
|
26
|
+
`fetch` is used for **every** request this client makes, in place of
|
|
27
|
+
`globalThis.fetch`. `visibility`
|
|
28
|
+
(`{ isHidden(): boolean; onChange(cb: () => void): () => void }`) is where
|
|
29
|
+
`watch()` learns the app went to the background, in place of
|
|
30
|
+
`document.visibilityState`; `onChange` returns its own unsubscribe. `platform`
|
|
31
|
+
is appended to the `x-client-info` header — `gemmein-sdk/<version> expo-ios` —
|
|
32
|
+
cleaned and capped to the 64 characters the usage ledger stores.
|
|
33
|
+
|
|
34
|
+
On Expo, `createExpoGemmein` from `@gemmein/sdk/expo` fills those three in for
|
|
35
|
+
a phone, and turns `upload()`'s picker shape into bytes Expo's fetch will
|
|
36
|
+
send. Every export that entry adds is in **Expo** below.
|
|
37
|
+
|
|
38
|
+
### `Gemmein(appKey:) → GemmeinSwift.Gemmein`
|
|
39
|
+
The Swift package, for iOS 17+ and macOS 14+, with no dependencies:
|
|
40
|
+
`.package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")`.
|
|
41
|
+
The same HTTP contract and the same method names as `@gemmein/sdk`, in Swift
|
|
42
|
+
idiom — `g.auth`, `g.collection(_:)`, `g.files`, `g.subscriptions`,
|
|
43
|
+
`g.payments`, `g.purchases`, `g.account`, `g.credits`, `g.ai`, and
|
|
44
|
+
`GemmeinServer` for Swift that runs on a server.
|
|
45
|
+
|
|
46
|
+
```swift
|
|
47
|
+
let g = try Gemmein(appKey: "pk_live_…") // KeychainTokenStore by default
|
|
48
|
+
|
|
49
|
+
try await g.auth.sendEmailCode(email)
|
|
50
|
+
_ = try await g.auth.verifyEmailCode(email: email, code: code)
|
|
51
|
+
let page = try await g.collection("notes").list(ListOptions(limit: 20))
|
|
52
|
+
let me = try await g.auth.currentUser() // .storeAccountToken for RevenueCat
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
An `sk_` key is refused here, as in the browser. Every Swift signature sits
|
|
56
|
+
beside its JavaScript twin in the sections below, under **Swift**; the client's
|
|
57
|
+
own initialiser, the shapes that differ, and the session store are in **Swift —
|
|
58
|
+
the package**.
|
|
59
|
+
|
|
22
60
|
### `gemmeinServer(secretKey, options?) → GemmeinServer`
|
|
23
61
|
Server-only client for a `sk_...` secret key — **never ship this to the
|
|
24
62
|
browser.** `options` (optional): `{ apiUrl?: string }` — **the option is
|
|
@@ -49,12 +87,32 @@ happens in the owner's dashboard, deliberately not in this SDK.
|
|
|
49
87
|
| `currentUser` | `()` | `Promise<CurrentUser>` — **never throws** for session state; safe on load |
|
|
50
88
|
| `logout` | `()` | `Promise<void>` — revokes the server session **(the method is `logout`, not `signOut`)** |
|
|
51
89
|
|
|
90
|
+
|
|
91
|
+
**Swift.** `GemmeinSwift` names the same four methods, with Swift's argument
|
|
92
|
+
labels. Everything that leaves the device is `async throws` and throws
|
|
93
|
+
`GemmeinError`.
|
|
94
|
+
|
|
95
|
+
| Swift | Signature | Returns |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `sendEmailCode(_:)` | `func sendEmailCode(_ email: String) async throws` | `Void` |
|
|
98
|
+
| `verifyEmailCode(email:code:)` | `func verifyEmailCode(email: String, code: String) async throws -> AuthSession` | `AuthSession { token: String, expiresAt: String, user: AuthUser }` |
|
|
99
|
+
| `currentUser()` | `func currentUser() async throws -> CurrentUser` | `CurrentUser { authenticated: Bool, userId: String?, email: String?, storeAccountToken: String? }` — one struct with optionals where JS has a union: read `authenticated` first, then the rest |
|
|
100
|
+
| `logout()` | `func logout() async throws` | `Void` — idempotent |
|
|
101
|
+
|
|
52
102
|
```ts
|
|
53
103
|
type AuthSession = { token: string; expiresAt: string; user: { id: string; email: string } };
|
|
54
104
|
type CurrentUser =
|
|
55
|
-
| { authenticated: true; userId: string; email: string } // note: userId, NOT id
|
|
56
|
-
| { authenticated: false; userId?: undefined; email?: undefined };
|
|
105
|
+
| { authenticated: true; userId: string; email: string; storeAccountToken?: string | null } // note: userId, NOT id
|
|
106
|
+
| { authenticated: false; userId?: undefined; email?: undefined; storeAccountToken?: undefined };
|
|
57
107
|
```
|
|
108
|
+
`storeAccountToken` is the opaque per-person name to hand a store — RevenueCat's
|
|
109
|
+
app user id, Apple's `appAccountToken`, Google's `obfuscatedExternalAccountId`.
|
|
110
|
+
It is minted on the first `currentUser()` call, stable for the life of the
|
|
111
|
+
person, correlated to nothing, and gone when they are erased. Hand a store
|
|
112
|
+
that, never `userId` and never the address: a value inside a third party's
|
|
113
|
+
ledger is outside the erasure cascade. An engine that does not carry one leaves
|
|
114
|
+
the field absent — read that as "not carried", never as "no account". See
|
|
115
|
+
**Mobile** below.
|
|
58
116
|
`verifyEmailCode` resolves the **session** (`user.id`, nested). `currentUser`
|
|
59
117
|
resolves the **identity** (`userId`, flat). Use `currentUser()` for who's
|
|
60
118
|
signed in. Sessions are one-per-user: verifying a new code revokes older ones.
|
|
@@ -72,6 +130,11 @@ screen covers all three.
|
|
|
72
130
|
|--------|-----------|---------|
|
|
73
131
|
| `delete` | `()` | `Promise<unknown>` — erases the signed-in user, clears the stored token |
|
|
74
132
|
|
|
133
|
+
|
|
134
|
+
| Swift | Signature | Returns |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `delete()` | `@discardableResult func delete() async throws -> JSONValue?` | `JSONValue?` — the server's body as a `JSONValue?` where JS has `unknown`; `@discardableResult`, so ignoring it is legal |
|
|
137
|
+
|
|
75
138
|
The "delete my account" screen. Every app it **applies** to needs one (GDPR
|
|
76
139
|
right to erasure; Apple 5.1.1(v) for any app with account creation).
|
|
77
140
|
Server-side it's the full cascade — sessions revoked, records and files
|
|
@@ -103,15 +166,46 @@ certain the collection exists yet.
|
|
|
103
166
|
| `delete` | `(id: string)` | `Promise<void>` |
|
|
104
167
|
| `upload` | `(file: Blob \| File, options?: { name?: string; contentType?: string; for?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
|
|
105
168
|
|
|
169
|
+
|
|
170
|
+
**Swift.** The same seven methods. Three shapes differ on purpose: a record
|
|
171
|
+
body is `[String: JSONValue]` (a literal needs no ceremony —
|
|
172
|
+
`["title": "Ran", "done": false]` — a variable needs its case,
|
|
173
|
+
`["title": .string(typed)]`); `upload` takes `Data`, not a `Blob`; and `watch`
|
|
174
|
+
is not `async` — it hands back a `Watcher` you end with `stop()`.
|
|
175
|
+
|
|
176
|
+
| Swift | Signature | Returns |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `create(_:key:for:published:)` | `@discardableResult func create(_ data: [String: JSONValue], key: String? = nil, for recipient: String? = nil, published: Bool? = nil) async throws -> GemmeinRecord` | `GemmeinRecord` |
|
|
179
|
+
| `list(_:)` | `func list(_ options: ListOptions = ListOptions()) async throws -> ListResult` | `ListResult { records: [GemmeinRecord], cursor: String?, hasMore: Bool, deleted: [String], watermark: String? }` |
|
|
180
|
+
| `watch(every:where:search:limit:onChange:)` | `func watch(every: TimeInterval? = nil, where filter: [String: JSONValue]? = nil, search: String? = nil, limit: Int? = nil, onChange: @escaping @Sendable (WatchDelta) -> Void) -> Watcher` | `Watcher` — **not `async`**; `watcher.stop()` ends it. `WatchDelta { records: [GemmeinRecord], deleted: [String], initial: Bool }` |
|
|
181
|
+
| `get(_:expand:)` | `func get(_ id: String, expand: [String] = []) async throws -> GemmeinRecord` | `GemmeinRecord` |
|
|
182
|
+
| `update(_:_:ifVersion:published:)` | `@discardableResult func update(_ id: String, _ data: [String: JSONValue], ifVersion: Int? = nil, published: Bool? = nil) async throws -> GemmeinRecord` | `GemmeinRecord` |
|
|
183
|
+
| `delete(_:)` | `func delete(_ id: String) async throws` | `Void` |
|
|
184
|
+
| `upload(_:name:contentType:for:)` | `func upload(_ data: Data, name: String = "upload", contentType: String = "", for recipient: String? = nil) async throws -> UploadedFile` | `UploadedFile { id: String, ref: String, contentType: String, sizeBytes: Int }` — `ref` is `file:<uuid>` |
|
|
185
|
+
|
|
186
|
+
`ListOptions(limit:sort:where:cursor:search:expand:since:)` — every argument
|
|
187
|
+
optional, `sort` is `ListSort` (`.newest`, `.oldest`, `.updated`), and `where`
|
|
188
|
+
keeps the label `where`, binding a `[String: JSONValue]`. `GemmeinRecord`
|
|
189
|
+
carries `id`, `data: [String: JSONValue]`, `createdAt`, `updatedAt`,
|
|
190
|
+
`ownerUserId: String?`, `collectionId`, `appId`, `environmentId`,
|
|
191
|
+
`version: Int`, `key: String?`, `audienceUserId: String?`, `published: Bool`,
|
|
192
|
+
`expand: [String: GemmeinRecord?]` and `existing: Bool?` — your fields live
|
|
193
|
+
under `data`, read as `note.data["title"]?.string`.
|
|
194
|
+
|
|
106
195
|
### Files
|
|
107
196
|
|
|
108
|
-
`upload()` returns a **reference** (`file
|
|
197
|
+
`upload()` returns a **reference** (`file:<uuid>`), not a URL. Store the reference
|
|
109
198
|
in your record — it never expires and grants nothing on its own.
|
|
110
199
|
|
|
111
200
|
| Method | Signature | Returns |
|
|
112
201
|
|--------|-----------|---------|
|
|
113
202
|
| `g.files.link` | `(ref: FileRef \| string, options?: { intent?: "inline" \| "download" })` | `Promise<{ ref, url, expiresAt?, contentType, sizeBytes?, name? }>` |
|
|
114
203
|
|
|
204
|
+
|
|
205
|
+
| Swift | Signature | Returns |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `g.files.link(_:intent:)` | `func link(_ ref: String, intent: LinkIntent? = nil) async throws -> FileLink` | `FileLink { ref: String, url: String, expiresAt: String?, contentType: String, sizeBytes: Int?, name: String? }` — **`url` is a `String`**, not a `URL`: pass it through `URL(string:)` before handing it to `AsyncImage`. `intent` is `.inline` or `.download` |
|
|
208
|
+
|
|
115
209
|
```ts
|
|
116
210
|
const { ref } = await g.collection("films").upload(file)
|
|
117
211
|
await g.collection("films").create({ title, poster: ref })
|
|
@@ -172,7 +266,7 @@ type GemmeinRecord<T> = {
|
|
|
172
266
|
version: number; // +1 per update; pass as { ifVersion } to guard concurrent edits
|
|
173
267
|
key?: string; // the create-if-absent key, when one was used
|
|
174
268
|
audienceUserId?: string; // recipient on addressed/direct collections (server-stamped)
|
|
175
|
-
published: boolean; // top-level, not under .data;
|
|
269
|
+
published: boolean; // top-level, not under .data; on EVERY record — a private record reads true
|
|
176
270
|
expand?: Record<string, GemmeinRecord | null>; // filled by list/get { expand: [...] }
|
|
177
271
|
existing?: true; // present only when a keyed create returned YOUR existing record
|
|
178
272
|
};
|
|
@@ -201,7 +295,9 @@ retry doubles as the lookup: there is no separate get-by-key call — re-issue
|
|
|
201
295
|
the same `create()` with the same key to fetch your own record (it counts
|
|
202
296
|
as a write; a re-fetch idiom, not a read path). `for` =
|
|
203
297
|
recipient id on `addressed`/`direct`. `published: false` = draft on a public
|
|
204
|
-
rule
|
|
298
|
+
rule; the OPTION is refused anywhere else (400 `invalid_publish`), while the
|
|
299
|
+
FIELD comes back on every record — a private record reads `published: true`,
|
|
300
|
+
and it is not writable there. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
|
|
205
301
|
position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
|
|
206
302
|
on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
|
|
207
303
|
`since` fixes the order (oldest change first) — pairing it with `sort` is a 400
|
|
@@ -233,6 +329,17 @@ Never hand-roll a polling loop; this is the sanctioned one.
|
|
|
233
329
|
| `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
|
|
234
330
|
| `purchases.mine` | `()` | `Promise<Purchase[]>` — the array itself (not wrapped): `{ item, kind, amountMinor, currency, refundedMinor, status, grants, paidAt, delivery? }` — gate one-off fulfilment on this, never the redirect |
|
|
235
331
|
|
|
332
|
+
|
|
333
|
+
**Swift.** Nothing navigates: `checkout` and `buy` answer a url and the app
|
|
334
|
+
opens it (`openURL`, `SFSafariViewController`).
|
|
335
|
+
|
|
336
|
+
| Swift | Signature | Returns |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| `g.subscriptions.mine()` | `func mine() async throws -> Subscription?` | `Subscription? { plan: String, status: String }` — `nil` when they never paid |
|
|
339
|
+
| `g.subscriptions.checkout(plan:)` | `func checkout(plan: String? = nil) async throws -> CheckoutSession` | `CheckoutSession { url: String, plan: String }` |
|
|
340
|
+
| `g.payments.buy(_:item:)` | `func buy(_ product: String, item: String? = nil) async throws -> PaymentSession` | `PaymentSession { url: String, product: String, item: String? }` |
|
|
341
|
+
| `g.purchases.mine()` | `func mine() async throws -> [Purchase]` | `[Purchase]` — `item`, `kind`, `amountMinor: Int?`, `currency: String?`, `refundedMinor: Int`, `status`, `grants: [String]`, `paidAt`, `delivery: Purchase.Delivery?` (`.gemmeinFile(String)` or `.externalURL(String)` — an enum where JS has a tagged object) |
|
|
342
|
+
|
|
236
343
|
Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
237
344
|
`buy` self-navigate via `window.location` — just `await` them on the click;
|
|
238
345
|
don't also redirect to the returned `url`, and never build a Stripe URL
|
|
@@ -278,6 +385,14 @@ honestly, not hidden.)
|
|
|
278
385
|
carries a footer naming why it arrived; replies land in the owner's Inbox
|
|
279
386
|
as a thread (`threadId`), and `replyRail` states honestly whether
|
|
280
387
|
reply-by-email is on. Every send is on the record.
|
|
388
|
+
- **Your domain, or nothing.** `notify()` sends only from your verified
|
|
389
|
+
sender domain. Until one is verified on the Domains page the send is
|
|
390
|
+
refused `409 sender_domain_required` — nothing leaves, nothing is
|
|
391
|
+
recorded, no cap is spent, and the same `key` sends once you verify. If
|
|
392
|
+
the provider refuses that address at send time you get `502 send_failed`
|
|
393
|
+
and can retry; the email is never re-sent from a Gemmein address. Sign-in
|
|
394
|
+
codes are the one email Gemmein sends on your behalf before a domain is
|
|
395
|
+
verified, as `<App name> (via Gemmein)`.
|
|
281
396
|
|
|
282
397
|
---
|
|
283
398
|
|
|
@@ -297,6 +412,12 @@ the message), never partly. No expiry. No per-token pricing.
|
|
|
297
412
|
| `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
413
|
| `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
414
|
|
|
415
|
+
|
|
416
|
+
| Swift | Signature | Returns |
|
|
417
|
+
|---|---|---|
|
|
418
|
+
| `g.credits.balance()` | `func balance() async throws -> Int` | **`Int`** — the balance itself, where JS answers `{ balance }`. Read it directly |
|
|
419
|
+
| `gemmeinServer(sk).spendCredits` | `@discardableResult func spendCredits(_ personId: String, amount: Int? = nil, reason: String, key: String? = nil) async throws -> SpendCreditsResult` | `SpendCreditsResult { spent: Int, deduped: Bool, balanceBefore: Int, balanceAfter: Int, event: Event }` — two flat `Int`s where JS nests `balance: { before, after }` |
|
|
420
|
+
|
|
300
421
|
Where credits come from, and where they go:
|
|
301
422
|
|
|
302
423
|
- **A product with `grantsCredits`** (the "Grants credits" field on the
|
|
@@ -376,6 +497,26 @@ development, sync also writes a tool that exists only in the cloud back to
|
|
|
376
497
|
A tool's name is fixed once created. Removing a tool is a step-up action, like
|
|
377
498
|
removing a key.
|
|
378
499
|
|
|
500
|
+
|
|
501
|
+
**Swift.** `AiResponse` stands where JS returns the fetch `Response`, and
|
|
502
|
+
`run` / `runText` take the label **`inputs:`** —
|
|
503
|
+
`runText("summary", inputs: ["text": .string(body)])`.
|
|
504
|
+
|
|
505
|
+
| Swift | Signature | Returns |
|
|
506
|
+
|---|---|---|
|
|
507
|
+
| `run(_:inputs:stream:)` | `func run(_ tool: String, inputs: [String: JSONValue] = [:], stream: Bool = false) async throws -> AiResponse` | `AiResponse` — `stream` is a plain `Bool` argument, not an options object |
|
|
508
|
+
| `runText(_:inputs:)` | `func runText(_ tool: String, inputs: [String: JSONValue] = [:]) async throws -> String` | `String` — the label is **`inputs:`** |
|
|
509
|
+
| `calls(limit:before:)` | `func calls(limit: Int? = nil, before: String? = nil) async throws -> AiCallPage` | `AiCallPage { calls: [AiCallRecord], nextCursor: String? }` |
|
|
510
|
+
| `chat(_:provider:tool:)` | `func chat(_ body: [String: JSONValue], provider: AiProvider? = nil, tool: String? = nil) async throws -> AiResponse` | `AiResponse` |
|
|
511
|
+
| `text(_:provider:tool:)` | `func text(_ body: [String: JSONValue], provider: AiProvider? = nil, tool: String? = nil) async throws -> String` | `String` |
|
|
512
|
+
|
|
513
|
+
`AiResponse` carries `status: Int`, `headers: [String: String]`, `ok: Bool`,
|
|
514
|
+
`creditsRemaining: Int?`, `refunded: Bool`, `tool: String?`, `isFake: Bool`,
|
|
515
|
+
`func data() async throws -> Data`, and two streams —
|
|
516
|
+
`func lines() -> AsyncThrowingStream<String, Error>` and
|
|
517
|
+
`func events() -> AsyncThrowingStream<String, Error>` (the SSE `data:`
|
|
518
|
+
payloads). `AiProvider` is `.openai`, `.anthropic`, `.google`.
|
|
519
|
+
|
|
379
520
|
**Raw calls are off by default.** `g.ai.chat(body)` — the browser sending
|
|
380
521
|
the provider's own request body — answers `403 raw_calls_off` unless the
|
|
381
522
|
owner switches **raw calls** on for that provider's key on the AI tools page. Behind the switch, the raw route is what it always was: the body is
|
|
@@ -502,11 +643,10 @@ const raw = await g.ai.chat({ model: "gpt-4o-mini", stream: true, messages: [{ r
|
|
|
502
643
|
| `tool_disabled` | 403 | The owner switched this tool off — turn it on, or use another |
|
|
503
644
|
| `entitlement_required` | 403 | The tool's `requires` names a plan or product this person lacks — the message names it |
|
|
504
645
|
| `model_pinned` | 403 | This tool's model is fixed — leave `model` out of the body |
|
|
505
|
-
| `provider_not_configured` | 409 | Creating or updating a tool: no key is set for that provider yet — add one on the AI tools page first |
|
|
506
646
|
| `too_many_tools` | 409 | This environment already holds 50 AI tools — delete one before adding another |
|
|
507
647
|
| `invalid_tool` | 400 | Creating or updating a tool with a bad field — the message names which one and its rule |
|
|
508
648
|
| `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 |
|
|
509
|
-
| `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one on the AI tools page |
|
|
649
|
+
| `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one on the AI tools page. A tool can be defined before its key exists; it runs the moment the key is pasted, and the console shows it as waiting on one until then |
|
|
510
650
|
| `provider_required` | 400 | More than one provider key is set — pass `provider` |
|
|
511
651
|
| `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) |
|
|
512
652
|
| `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
|
|
@@ -522,7 +662,7 @@ const raw = await g.ai.chat({ model: "gpt-4o-mini", stream: true, messages: [{ r
|
|
|
522
662
|
`gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
|
|
523
663
|
fake`, an echo stream), so the loop runs locally; set
|
|
524
664
|
`GEMMEIN_AI_KEY_OPENAI`, `GEMMEIN_AI_KEY_ANTHROPIC` or `GEMMEIN_AI_KEY_GOOGLE`
|
|
525
|
-
in the local rail's environment for a real call. The owner's Usage
|
|
665
|
+
in the local rail's environment for a real call. The owner's Usage & billing page
|
|
526
666
|
counts AI calls for the last 30 days: spent by the customers' credits, priced
|
|
527
667
|
by the provider — Gemmein meters the calls, the provider bills the tokens.
|
|
528
668
|
|
|
@@ -542,6 +682,32 @@ signed in, it can create them by email first.
|
|
|
542
682
|
| `grantAccess` | `(personId, { entitlement, source?, expiresAt?, reason? })` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` (201) — `holdings` is the state **after**; the owner's audit row carries before→after and the key's name |
|
|
543
683
|
| `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
|
|
544
684
|
| `invitePerson` | `(email)` | `Promise<{ person: InvitedPerson, created: boolean }>` — create a person by email **before they sign in** (201 `created: true`), or find them (200 `created: false`); idempotent, case-insensitive, one id. `InvitedPerson = { id, email, role, invited, suspended }` — `invited` stays true until their first sign-in; a suspended person is returned flagged. The one server call that takes an email |
|
|
685
|
+
| `notify` | `(personId, { subject, text, kind?, key? })` | `Promise<{ sent, deduped?, recorded?, id, threadId, replyRail? }>` — email one of your verified people by id; see **Notify** above |
|
|
686
|
+
| `spendCredits` | `(personId, { amount?, reason, key? })` | `Promise<{ ok, spent, deduped, balance: { before, after }, event }>` — one conditional decrement, floored at zero; see **Credits** above |
|
|
687
|
+
| `collection(name).get / .list / .update` | `(as the browser client)` | scoped per collection on the key; no creates, no deletes, no auth access; see **Setup** |
|
|
688
|
+
| `testSession` | `(email)` | `Promise<{ token, expiresAt, user }>` — development only; `sk_live` throws `test_session_forbidden_live`; see **Reaffirm** below |
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
**Swift.** `GemmeinServer` ships in the Swift package for Swift that runs on a
|
|
692
|
+
server. It takes a secret key, and a secret key never belongs in an app bundle
|
|
693
|
+
— `Gemmein` refuses one.
|
|
694
|
+
|
|
695
|
+
| Swift | Signature | Returns |
|
|
696
|
+
|---|---|---|
|
|
697
|
+
| `verifySession(_:)` | `func verifySession(_ token: String) async throws -> PersonHoldings` | `PersonHoldings { person: GatePerson, holdings: Holdings }` — no `ok` field; a refusal throws |
|
|
698
|
+
| `holdings(_:)` | `func holdings(_ personId: String) async throws -> PersonHoldings` | `PersonHoldings` |
|
|
699
|
+
| `grantAccess(_:entitlement:source:expiresAt:reason:)` | `@discardableResult func grantAccess(_ personId: String, entitlement: String, source: ManualGrantSource? = nil, expiresAt: String? = nil, reason: String? = nil) async throws -> GrantResult` | `GrantResult { grant: Grant, holdings: Holdings }` |
|
|
700
|
+
| `revokeAccess(_:_:reason:)` | `@discardableResult func revokeAccess(_ personId: String, grantId: String, reason: String? = nil) async throws -> GrantResult` | `GrantResult` |
|
|
701
|
+
| `invitePerson(_:)` | `func invitePerson(_ email: String) async throws -> InviteResult` | `InviteResult { person: GatePerson, created: Bool }` |
|
|
702
|
+
| `notify(_:subject:text:kind:key:)` | `@discardableResult func notify(_ personId: String, subject: String, text: String, kind: NotifyKind? = nil, key: String? = nil) async throws -> NotifyResult` | `NotifyResult { sent: Bool, deduped: Bool?, id: String?, threadId: String?, replyRail: Bool?, recorded: Bool? }` — `subject` and `text` are required labels, not an options object |
|
|
703
|
+
| `spendCredits(_:amount:reason:key:)` | see **Credits** above | `SpendCreditsResult` |
|
|
704
|
+
| `collection(_:)` | `func collection(_ name: String) throws -> ServerCollectionClient` | `ServerCollectionClient` — `func get(_ id: String) async throws -> GemmeinRecord`, `func list(_ options: ListOptions = ListOptions()) async throws -> ListResult`, `@discardableResult func update(_ id: String, _ data: [String: JSONValue]) async throws -> GemmeinRecord` |
|
|
705
|
+
| `testSession(_:)` | `func testSession(_ email: String) async throws -> AuthSession` | `AuthSession` — development only |
|
|
706
|
+
|
|
707
|
+
`GatePerson { id: String, email: String, role: String, suspended: Bool?, invited: Bool? }`,
|
|
708
|
+
`Holdings { access: [String], grants: [Grant], credits: Int? }` — a bare `Int?`
|
|
709
|
+
where JS nests `{ balance }`. `ManualGrantSource` is `.manual`, `.trial`,
|
|
710
|
+
`.promotion`, `.migration`; `NotifyKind` is `.event` or `.account`.
|
|
545
711
|
|
|
546
712
|
```ts
|
|
547
713
|
type Holdings = {
|
|
@@ -595,7 +761,7 @@ type Grant = {
|
|
|
595
761
|
make two grants — call it once and keep your own retry key.
|
|
596
762
|
- **Nothing silent, within a stated bound.** Every `/server/*` call a **resolved
|
|
597
763
|
secret key** makes, ok or refused, lands in that key's usage ledger — the owner
|
|
598
|
-
reads the summary on their
|
|
764
|
+
reads the summary on their Secret keys page and the full day × route × outcome table in
|
|
599
765
|
the key's own room. A rejected or publishable (`pk_`) key can't be attributed to a
|
|
600
766
|
key row, so its refusal reaches only the request log. Refusals also write one audit
|
|
601
767
|
row per key, route and code each hour (exact counts stay in the ledger); grants and
|
|
@@ -740,7 +906,7 @@ authorise on fields they cannot set, or from a receiver.
|
|
|
740
906
|
| `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
|
|
741
907
|
| `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. |
|
|
742
908
|
| `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` |
|
|
743
|
-
| `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 |
|
|
909
|
+
| `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; and rides `notify()`'s sender law — it goes only from your verified sender domain, and until one is verified this action alone is refused `sender_domain_required` and the event's result says so. Nothing is sent via Gemmein |
|
|
744
910
|
| `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 |
|
|
745
911
|
|
|
746
912
|
**The `call_url` contract.** Gemmein POSTs JSON:
|
|
@@ -896,6 +1062,102 @@ current. Add a probe whenever you add a feature.
|
|
|
896
1062
|
|
|
897
1063
|
---
|
|
898
1064
|
|
|
1065
|
+
## Swift — the package
|
|
1066
|
+
|
|
1067
|
+
`GemmeinSwift` is the same HTTP contract and the same method names as
|
|
1068
|
+
`@gemmein/sdk`, in Swift idiom, for iOS 17+ and macOS 14+, with no
|
|
1069
|
+
dependencies:
|
|
1070
|
+
|
|
1071
|
+
```swift
|
|
1072
|
+
.package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
Every method is beside its JavaScript twin in the sections above, under
|
|
1076
|
+
**Swift**. Argument labels are as written there; everything that leaves the
|
|
1077
|
+
device is `async throws` and throws `GemmeinError`. The parity check
|
|
1078
|
+
(`tests/security/sdkParity.test.ts`) holds the two method lists equal and
|
|
1079
|
+
`tests/security/docsSwiftReference.test.ts` holds every signature on this page
|
|
1080
|
+
to the package's own source, so a signature here is the signature the compiler
|
|
1081
|
+
sees.
|
|
1082
|
+
|
|
1083
|
+
```swift
|
|
1084
|
+
let g = try Gemmein(appKey: "pk_live_…")
|
|
1085
|
+
let notes = try g.collection("notes")
|
|
1086
|
+
let page = try await notes.list(ListOptions(limit: 20, sort: .newest))
|
|
1087
|
+
let note = try await notes.create(["title": .string(typed), "done": false])
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
### The client
|
|
1091
|
+
|
|
1092
|
+
| Swift | Signature | Returns |
|
|
1093
|
+
|---|---|---|
|
|
1094
|
+
| `Gemmein(appKey:…)` | `init(appKey: String, apiURL: URL = defaultAPIURL, tokenStore: TokenStore? = nil, platform: String? = nil, session: URLSession = .shared) throws` | `Gemmein` — throws `invalid_app_key` on an `sk_`, `missing_app_key` on anything that is not a `pk_` |
|
|
1095
|
+
| `g.collection(_:intent:)` | `func collection(_ name: String, intent: String? = nil) throws -> CollectionClient` | `CollectionClient` — `g.storage.collection(_:intent:)` is the same factory, the same signature |
|
|
1096
|
+
| `GemmeinServer(secretKey:…)` | `init(secretKey: String, apiURL: URL = defaultAPIURL, session: URLSession = .shared) throws` | `GemmeinServer` — see **Server gate** above |
|
|
1097
|
+
|
|
1098
|
+
`apiURL` points at a local `gemmein dev` engine while you build; `tokenStore`
|
|
1099
|
+
defaults to `KeychainTokenStore`, so a relaunch keeps the person signed in;
|
|
1100
|
+
`platform` defaults to this OS and is appended to `x-client-info`.
|
|
1101
|
+
`g.auth`, `g.storage`, `g.subscriptions`, `g.payments`, `g.purchases`,
|
|
1102
|
+
`g.account`, `g.files`, `g.credits`, `g.ai` and `g.tokenStore` are `public let`
|
|
1103
|
+
properties on `Gemmein`.
|
|
1104
|
+
|
|
1105
|
+
### The three shapes that differ from JavaScript
|
|
1106
|
+
|
|
1107
|
+
- `credits.balance()` answers a bare **`Int`** — the number itself, not a
|
|
1108
|
+
`{ balance }` wrapper.
|
|
1109
|
+
- `files.link(_:)` answers a `FileLink` whose `url` is a **`String`**; pass it
|
|
1110
|
+
through `URL(string:)` before `AsyncImage`.
|
|
1111
|
+
- A record body and an AI body are `[String: JSONValue]`. A **literal** needs
|
|
1112
|
+
no ceremony (`["title": "Ran", "done": false, "seats": 4]`); a **variable**
|
|
1113
|
+
needs its case (`["title": .string(typed), "seats": .int(count)]`).
|
|
1114
|
+
|
|
1115
|
+
### The session store
|
|
1116
|
+
|
|
1117
|
+
`TokenStore` is a protocol — `func get() async -> String?`,
|
|
1118
|
+
`func set(_ token: String) async throws`, `func clear() async`.
|
|
1119
|
+
`KeychainTokenStore(appKey:)` is the default and `MemoryTokenStore(token:)` is
|
|
1120
|
+
there for tests and for the app that catches `secure_store_unavailable`.
|
|
1121
|
+
Only `set` throws, on all three of the SDK's stores.
|
|
1122
|
+
|
|
1123
|
+
Everything the package throws is a `GemmeinError`
|
|
1124
|
+
(`status: Int`, `code: String`, `message: String`, `resetAt: String?`,
|
|
1125
|
+
`requires: String?`) — the same table below.
|
|
1126
|
+
|
|
1127
|
+
---
|
|
1128
|
+
|
|
1129
|
+
## Expo — `@gemmein/sdk/expo`
|
|
1130
|
+
|
|
1131
|
+
The same package, a second entry point. `export *` re-exports the whole core
|
|
1132
|
+
surface, so an Expo app imports from one place; what follows is everything the
|
|
1133
|
+
entry adds. The peers install in the app, not in your monorepo:
|
|
1134
|
+
`npx expo install expo-secure-store expo-file-system`.
|
|
1135
|
+
|
|
1136
|
+
```ts
|
|
1137
|
+
import { createExpoGemmein } from "@gemmein/sdk/expo"
|
|
1138
|
+
import { AppState, Platform } from "react-native"
|
|
1139
|
+
|
|
1140
|
+
export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
| Export | Signature | What it is |
|
|
1144
|
+
|---|---|---|
|
|
1145
|
+
| `createExpoGemmein` | `(options: GemmeinOptions, modules?: ExpoModules) => Gemmein` | The client. It **is** `Gemmein` — the four seams a phone has, filled in, plus the picker conversion in front of every `upload()`. Anything you pass in `options` wins over the defaults |
|
|
1146
|
+
| `SecureStoreTokenStore` | `new SecureStoreTokenStore(appKey: string, secureStore?: SecureStoreModule)` | The `TokenStore` backed by `expo-secure-store` — `get(): Promise<string \| undefined>`, `set(token: string): Promise<void>`, `clear(): Promise<void>`. Keyed `gemmein_session_<first 20 of the app key>`, `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, so the token never rides an iCloud backup. Reads and clears degrade to "signed out"; a refused **write** throws `secure_store_unavailable` |
|
|
1147
|
+
| `appStateVisibility` | `(appState?: AppStateModule) => VisibilityHook` | The `visibility` hook `watch()` sleeps on, answered by React Native's `AppState` instead of `document.visibilityState`. Only `"active"` counts as visible; before the module resolves the app is treated as visible |
|
|
1148
|
+
| `expoFetch` | `(injected?: typeof fetch) => typeof fetch` | `expo/fetch`, resolved on the first request, `globalThis.fetch` if it is not there. React Native's own fetch is XHR-backed and cannot give a streaming body, which is what `g.ai.chat({ stream: true })` and `g.ai.run(…, { stream: true })` return |
|
|
1149
|
+
| `expoPlatformTag` | `(platform?: PlatformModule) => string` | The `platform` string on `x-client-info`: `expo-ios`, `expo-android`, `expo-web`, or `expo` when no `Platform` was passed |
|
|
1150
|
+
| `expoUploadPart` | `(file: UploadInput, modules?: ExpoModules) => Promise<UploadInput>` | A picker's `{ uri, name, type, size }` turned into an `expo-file-system` `File`, because Expo's `FormData` refuses a bare picker part. Anything already carrying bytes passes through. Without `expo-file-system` it throws `upload_input_unsupported` |
|
|
1151
|
+
| `SECURE_STORE_VALUE_LIMIT` | `2048` | The byte ceiling on a secure-store value. Over it, `set` throws `token_too_large` rather than letting iOS throw a native error the app cannot read |
|
|
1152
|
+
| `ExpoModules` | `{ SecureStore?: SecureStoreModule; AppState?: AppStateModule; Platform?: PlatformModule; FileSystem?: FileSystemModule; fetch?: typeof fetch }` | The second argument. Every key optional: pass none and the entry resolves each module lazily on the first call that needs it; pass your own — an app's static imports, a test's fakes — and there is no dynamic resolution at all |
|
|
1153
|
+
|
|
1154
|
+
`SecureStoreModule`, `AppStateModule`, `PlatformModule` and `FileSystemModule`
|
|
1155
|
+
are the exported types of those four slices — the little of each module this
|
|
1156
|
+
entry calls, and nothing more. Nothing is imported from Expo or React Native at
|
|
1157
|
+
module load, so the entry resolves in Node too.
|
|
1158
|
+
|
|
1159
|
+
---
|
|
1160
|
+
|
|
899
1161
|
## Errors — `GemmeinError`
|
|
900
1162
|
|
|
901
1163
|
Every failed call throws a `GemmeinError`:
|
|
@@ -935,9 +1197,10 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
935
1197
|
| `invalid_limit` | `limit` isn't a whole number from 1 to 100 (the message says so: "limit must be between 1 and 100") | ask for at most 100 and page with `cursor` — nothing is clamped for you |
|
|
936
1198
|
| `not_a_customer` (404) | `notify()`'s recipient isn't a verified person of this app and environment | fix the person id — one code on purpose |
|
|
937
1199
|
| `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
|
|
1200
|
+
| `sender_domain_required` (409) | `notify()` (and a relay's `email_person`) with no verified sender domain — your app's mail goes only from your own domain | verify a sender domain on the Domains page, then retry with the same key. Nothing was sent, recorded or capped |
|
|
938
1201
|
| `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
|
|
939
1202
|
| `unknown_record` | a link field points at a missing record (live only) | fix the id |
|
|
940
|
-
| `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
|
|
1203
|
+
| `payload_too_large` / `file_too_large` | over the size cap (message states it): a record's data over 32 KB, an upload over 25 MB | shrink it — files go through uploads, never inline |
|
|
941
1204
|
| `invalid_file_content` | uploaded bytes aren't the claimed type (usually a renamed file) | send the real file |
|
|
942
1205
|
| `test_session_forbidden_live` | `testSession()` called on a live env / `sk_live` key | reaffirm's Tier B is dev-only — point it at your dev environment |
|
|
943
1206
|
| `unknown_plan` | no plan by that name | use a name from the list in the message |
|
|
@@ -955,6 +1218,11 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
955
1218
|
| `ai_test_capped` (429) | the AI tools page's test call — one a minute per app | wait a minute |
|
|
956
1219
|
| `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
|
|
957
1220
|
| `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; or `g.ai.text` / `g.ai.runText` got a non-stream provider answer with no text to lift out | check `apiUrl` and anything rewriting responses; the call is safe to retry — for AI, read the stream with `g.ai.chat` or `g.ai.run` |
|
|
1221
|
+
| `network_unreachable` (client-side, status 0) | the request never reached Gemmein — no connection, an offline device, a host that does not resolve, an `apiUrl` pointing at nothing; the message names the host, and `err.cause` carries the fetch's own error. A cancel (`{ signal }`) is not this: it stays an `AbortError` | check the connection and the `apiUrl`; the call is safe to retry. "The server said no" and "the server was never reached" are different problems — branch on them separately |
|
|
1222
|
+
| `upload_input_unsupported` (client-side, status 0) | `@gemmein/sdk/expo`: `upload()` was handed a picker's `{ uri }` and `expo-file-system` is not installed, so there is nothing to read the bytes with | `npx expo install expo-file-system`, or pass a `Blob` / an `expo-file-system` `File` |
|
|
1223
|
+
| `token_too_large` (client-side, status 0) | `@gemmein/sdk/expo`: a value over 2,048 bytes was handed to the device secure store, which iOS refuses with a native throw the app cannot read | store the token, not the session payload — a Gemmein session token is two orders of magnitude smaller |
|
|
1224
|
+
| `secure_store_unavailable` (client-side, status 0) | the token store could not KEEP the session, so sign-in throws rather than handing back a session the next launch will lose. **All three stores.** `BrowserTokenStore` (`@gemmein/sdk`): the browser refused the `localStorage` write — private mode past its quota, site data blocked, a sandboxed iframe. `SecureStoreTokenStore` (`@gemmein/sdk/expo`): `expo-secure-store` is not installed, or the device refused the write. Swift `KeychainTokenStore`: the Keychain refused, and the OSStatus is in the message; `-34018` is an unsigned build with no keychain access group. The store's own error is on `err.cause` (in Swift, in the message). Reads and clears stay lenient on all three — an unreadable store means signed out, never a crash | install `expo-secure-store`, or sign the app (ad-hoc is enough). The session is real — the server minted it — so an app that would rather run than stop catches this one code and rebuilds its client with a `MemoryTokenStore`: signed in until the process ends |
|
|
1225
|
+
| `invalid_collection_name` (client-side, status 0) | `g.collection(name)` was given a name outside the grammar: lowercase letters, digits and underscores, starting with a letter, 2-63 characters (`tasks`, `user_notes`). Thrown synchronously, before any request exists — the message echoes what it got | fix the name. A collection that exists but is not reachable is a different code (`unknown_collection`, 404) |
|
|
958
1226
|
| `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
|
|
959
1227
|
| `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
|
|
960
1228
|
| `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 |
|
|
@@ -965,7 +1233,7 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
965
1233
|
| `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
|
|
966
1234
|
| `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
|
|
967
1235
|
| `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |
|
|
968
|
-
| `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB | shorten the conversation you send |
|
|
1236
|
+
| `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB; on `g.ai.run`: the inputs are over 64 KB; on a record write: the data is over 32 KB | shorten the conversation you send; send less; move files to uploads |
|
|
969
1237
|
| `session_required` (401) | `g.ai.chat` / `g.credits.balance` without a signed-in person | sign in first |
|
|
970
1238
|
| `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 |
|
|
971
1239
|
| `provider_unreachable` (502) | the provider did not answer before the first byte; the credit is refunded | retry |
|