@gemmein/sdk 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -120,13 +120,13 @@ export type AuthSession = {
120
120
  * second module). `scripts/sync-version.mjs` rewrites the literal from
121
121
  * package.json before every build (`prebuild`), and a test pins the two
122
122
  * equal, so a bump can never ship with a stale header. */
123
- export declare const SDK_VERSION = "0.6.0";
123
+ export declare const SDK_VERSION = "0.7.0";
124
124
  /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
125
125
  * `x-client-info: gemmein-sdk/<version>`. The server records it on the
126
126
  * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
127
127
  * misbehaving integration can be attributed to an SDK version from day
128
128
  * one. It is a report, not a proof — any caller can set it. */
129
- export declare const CLIENT_INFO = "gemmein-sdk/0.6.0";
129
+ export declare const CLIENT_INFO = "gemmein-sdk/0.7.0";
130
130
  export declare class GemmeinError extends Error {
131
131
  readonly status: number;
132
132
  readonly code: string;
@@ -183,6 +183,10 @@ export declare class Gemmein {
183
183
  readonly purchases: PurchasesClient;
184
184
  readonly account: AccountClient;
185
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;
186
190
  constructor(options: GemmeinOptions);
187
191
  /**
188
192
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
@@ -382,6 +386,93 @@ export declare class AccountClient {
382
386
  */
383
387
  delete(): Promise<unknown>;
384
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
+ }
385
476
  export declare class StorageClient {
386
477
  private readonly config;
387
478
  constructor(config: ClientConfig);
@@ -562,9 +653,11 @@ export type Grant = {
562
653
  /**
563
654
  * What a person holds RIGHT NOW — never what they pay. `access` is the
564
655
  * union of live grants' keys; `grants` lists those live grants (revoked
565
- * and expired ones are gone, not flagged). `credits` is a reserved slot:
566
- * it is `null` today because consumable credits are not shipped — don't
567
- * design around them until this type says otherwise.
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.
568
661
  */
569
662
  export type Holdings = {
570
663
  access: string[];
@@ -760,6 +853,38 @@ export declare class GemmeinServer {
760
853
  grant: Grant;
761
854
  holdings: Holdings;
762
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>;
763
888
  /**
764
889
  * End one grant — the reversibility law in one call:
765
890
  *
package/dist/index.d.ts CHANGED
@@ -120,13 +120,13 @@ export type AuthSession = {
120
120
  * second module). `scripts/sync-version.mjs` rewrites the literal from
121
121
  * package.json before every build (`prebuild`), and a test pins the two
122
122
  * equal, so a bump can never ship with a stale header. */
123
- export declare const SDK_VERSION = "0.6.0";
123
+ export declare const SDK_VERSION = "0.7.0";
124
124
  /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
125
125
  * `x-client-info: gemmein-sdk/<version>`. The server records it on the
126
126
  * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
127
127
  * misbehaving integration can be attributed to an SDK version from day
128
128
  * one. It is a report, not a proof — any caller can set it. */
129
- export declare const CLIENT_INFO = "gemmein-sdk/0.6.0";
129
+ export declare const CLIENT_INFO = "gemmein-sdk/0.7.0";
130
130
  export declare class GemmeinError extends Error {
131
131
  readonly status: number;
132
132
  readonly code: string;
@@ -183,6 +183,10 @@ export declare class Gemmein {
183
183
  readonly purchases: PurchasesClient;
184
184
  readonly account: AccountClient;
185
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;
186
190
  constructor(options: GemmeinOptions);
187
191
  /**
188
192
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
@@ -382,6 +386,93 @@ export declare class AccountClient {
382
386
  */
383
387
  delete(): Promise<unknown>;
384
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
+ }
385
476
  export declare class StorageClient {
386
477
  private readonly config;
387
478
  constructor(config: ClientConfig);
@@ -562,9 +653,11 @@ export type Grant = {
562
653
  /**
563
654
  * What a person holds RIGHT NOW — never what they pay. `access` is the
564
655
  * union of live grants' keys; `grants` lists those live grants (revoked
565
- * and expired ones are gone, not flagged). `credits` is a reserved slot:
566
- * it is `null` today because consumable credits are not shipped — don't
567
- * design around them until this type says otherwise.
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.
568
661
  */
569
662
  export type Holdings = {
570
663
  access: string[];
@@ -760,6 +853,38 @@ export declare class GemmeinServer {
760
853
  grant: Grant;
761
854
  holdings: Holdings;
762
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>;
763
888
  /**
764
889
  * End one grant — the reversibility law in one call:
765
890
  *
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * second module). `scripts/sync-version.mjs` rewrites the literal from
5
5
  * package.json before every build (`prebuild`), and a test pins the two
6
6
  * equal, so a bump can never ship with a stale header. */
7
- export const SDK_VERSION = "0.6.0"; // synced from package.json — do not edit by hand
7
+ export const SDK_VERSION = "0.7.0"; // synced from package.json — do not edit by hand
8
8
  /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
9
9
  * `x-client-info: gemmein-sdk/<version>`. The server records it on the
10
10
  * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
@@ -116,6 +116,8 @@ export class Gemmein {
116
116
  this.purchases = new PurchasesClient(config);
117
117
  this.account = new AccountClient(config);
118
118
  this.files = new FilesClient(config);
119
+ this.credits = new CreditsClient(config);
120
+ this.ai = new AiClient(config);
119
121
  }
120
122
  /**
121
123
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
@@ -357,6 +359,167 @@ export class AccountClient {
357
359
  return result;
358
360
  }
359
361
  }
362
+ /** The signed-in person's own credits — so an app can draw its own meter. */
363
+ export class CreditsClient {
364
+ constructor(config) {
365
+ this.config = config;
366
+ }
367
+ /**
368
+ * The balance RIGHT NOW, server-resolved — the number that refuses at
369
+ * zero, never client math. Session required (`session_required`, 401).
370
+ *
371
+ * const { balance } = await g.credits.balance();
372
+ */
373
+ async balance() {
374
+ return runtimeRequest(this.config, "/auth/credits");
375
+ }
376
+ }
377
+ /**
378
+ * The AI route. `chat` takes the provider's own request body — exactly what
379
+ * you would POST to OpenAI's /v1/chat/completions, Anthropic's /v1/messages
380
+ * or Google's generateContent — and answers with the fetch `Response`
381
+ * untouched, streaming intact (SSE stays SSE). Gemmein spends one credit,
382
+ * adds the owner's key, forwards, and passes status and bytes back.
383
+ * Response headers: `x-gemmein-credits-remaining` on every answer that
384
+ * passed the spend; `x-gemmein-credit: refunded` when the provider failed
385
+ * before its first byte.
386
+ */
387
+ export class AiClient {
388
+ constructor(config) {
389
+ this.config = config;
390
+ }
391
+ /**
392
+ * const res = await g.ai.chat({ model: "gpt-4o-mini", messages, stream: true });
393
+ * for await (const chunk of res.body) { … }
394
+ *
395
+ * Browser sessions only — a server key is refused (`scope_denied`, 403).
396
+ * Refusals, all `GemmeinError`: `session_required` (401) ·
397
+ * `credits_exhausted` (402 — the message carries the balance; show your
398
+ * own "buy more" door, which is a product checkout) · `ai_not_configured`
399
+ * (409 — the owner has set no key) · `provider_required` (400) ·
400
+ * `model_not_allowed` (403 — the owner's models list) · `ai_capped`
401
+ * (429 — 20 calls a minute per person; `err.resetAt`) ·
402
+ * `payload_too_large` (413 — 256 KB) · `provider_unreachable` (502,
403
+ * before the first byte, refunded). Those are GEMMEIN's refusals. The
404
+ * PROVIDER's own answer — 2xx or not — is returned as it came: an answer
405
+ * that carries `x-gemmein-credits-remaining` (or `x-gemmein-credit`)
406
+ * passed the spend, so its status and body are the provider's; read
407
+ * `res.ok` / `res.status` yourself (a provider 4xx before the first byte
408
+ * is refunded, header `x-gemmein-credit: refunded`).
409
+ */
410
+ async chat(body, options = {}) {
411
+ const payload = options.provider ? { provider: options.provider, ...body } : body;
412
+ const response = await fetch(new URL("/ai/chat", this.config.apiUrl), {
413
+ method: "POST",
414
+ body: JSON.stringify(payload),
415
+ headers: await runtimeHeaders(this.config, { "content-type": "application/json" }),
416
+ ...(options.signal ? { signal: options.signal } : {}),
417
+ });
418
+ if (!response.ok) {
419
+ if (isForwardedAnswer(response)) {
420
+ // Past the spend: the provider answered. `provider_unreachable` is
421
+ // the one Gemmein refusal written after the spend (it carries the
422
+ // refund header) — peek without consuming so it stays typed.
423
+ const peek = (await response.clone().json().catch(() => null));
424
+ if (peek?.code !== "provider_unreachable")
425
+ return response;
426
+ }
427
+ const errorBody = await readErrorBody(response);
428
+ if (errorBody.code === "auth_expired")
429
+ await this.config.tokenStore.clear();
430
+ throw new GemmeinError({ status: response.status, ...errorBody });
431
+ }
432
+ return response;
433
+ }
434
+ /**
435
+ * The non-streaming convenience: one call, one string. Pass a body that
436
+ * does NOT stream (`stream` unset or false); the provider's JSON answer is
437
+ * read whole and the text is lifted out per provider — OpenAI
438
+ * `choices[0].message.content`, Anthropic `content[].text` joined,
439
+ * Google `candidates[0].content.parts[].text` joined. An answer with no
440
+ * text in any of those places throws `invalid_response` (status 0). A
441
+ * provider's own non-2xx throws `provider_error` with the provider's
442
+ * status and the provider's message (the owner's key masked to its
443
+ * hint if the provider echoed it).
444
+ *
445
+ * const answer = await g.ai.text({ model: "claude-sonnet-4-5", max_tokens: 400, messages });
446
+ */
447
+ async text(body, options = {}) {
448
+ const response = await this.chat(body, options);
449
+ if (!response.ok) {
450
+ throw new GemmeinError({
451
+ status: response.status,
452
+ code: "provider_error",
453
+ message: await providerErrorMessage(response),
454
+ });
455
+ }
456
+ const data = (await response.json());
457
+ const text = extractAiText(data);
458
+ if (text === null) {
459
+ throw new GemmeinError({
460
+ status: 0,
461
+ code: "invalid_response",
462
+ message: "the provider answered without any text — for a streaming body use g.ai.chat() and read the stream",
463
+ });
464
+ }
465
+ return text;
466
+ }
467
+ }
468
+ /** An answer that passed the spend: the engine stamps the balance (and, on
469
+ * a refund, `x-gemmein-credit`) only after the credit moved — a refusal
470
+ * before the spend carries neither. */
471
+ function isForwardedAnswer(response) {
472
+ return response.headers.has("x-gemmein-credits-remaining") || response.headers.has("x-gemmein-credit");
473
+ }
474
+ /** The provider's own reason, whichever shape it used — OpenAI, Anthropic
475
+ * and Google all nest it as `error.message`; anything else is the text. */
476
+ async function providerErrorMessage(response) {
477
+ const text = await response.text().catch(() => "");
478
+ try {
479
+ const parsed = JSON.parse(text);
480
+ if (parsed && typeof parsed === "object") {
481
+ const nested = typeof parsed.error === "object" && parsed.error !== null ? parsed.error.message : parsed.error;
482
+ const message = typeof nested === "string" ? nested : typeof parsed.message === "string" ? parsed.message : null;
483
+ if (message)
484
+ return message;
485
+ }
486
+ }
487
+ catch {
488
+ // not JSON — the text is the reason
489
+ }
490
+ return text.trim().slice(0, 500) || `the provider answered ${response.status}`;
491
+ }
492
+ /** The per-provider lift, by shape (a provider's answer is unmistakable). */
493
+ function extractAiText(data) {
494
+ if (!data || typeof data !== "object")
495
+ return null;
496
+ const d = data;
497
+ // OpenAI: choices[0].message.content (a string, or content parts)
498
+ if (Array.isArray(d.choices)) {
499
+ const message = d.choices[0]?.message;
500
+ const content = message?.content;
501
+ if (typeof content === "string")
502
+ return content;
503
+ if (Array.isArray(content))
504
+ return joinTextParts(content);
505
+ return null;
506
+ }
507
+ // Anthropic: content[] blocks, the text ones joined
508
+ if (Array.isArray(d.content))
509
+ return joinTextParts(d.content);
510
+ // Google: candidates[0].content.parts[].text joined
511
+ if (Array.isArray(d.candidates)) {
512
+ const parts = d.candidates[0]?.content?.parts;
513
+ return Array.isArray(parts) ? joinTextParts(parts) : null;
514
+ }
515
+ return null;
516
+ }
517
+ function joinTextParts(parts) {
518
+ const texts = parts
519
+ .map((p) => (p && typeof p === "object" && typeof p.text === "string" ? p.text : null))
520
+ .filter((t) => t !== null);
521
+ return texts.length > 0 ? texts.join("") : null;
522
+ }
360
523
  function isSessionResponse(value) {
361
524
  return (typeof value === "object" &&
362
525
  value !== null &&
@@ -935,6 +1098,43 @@ export class GemmeinServer {
935
1098
  }),
936
1099
  });
937
1100
  }
1101
+ /**
1102
+ * W9.3 — spend a person's credits from YOUR server, with a reason:
1103
+ *
1104
+ * const { balance } = await g.spendCredits(personId, {
1105
+ * amount: 5, // default 1; 1..10,000 per call
1106
+ * reason: "render:4k", // what the owner reads in the ledger
1107
+ * key: `render:${jobId}`, // your retry key — a repeat is deduped
1108
+ * });
1109
+ * // { ok: true, spent: 5, deduped: false, balance: { before: 20, after: 15 },
1110
+ * // event: { id: "cev_…", reason: "render:4k", actor: "<your key's name>" } }
1111
+ *
1112
+ * ONE conditional update, floored at 0: the spend succeeds whole or not at
1113
+ * all, and `402 credits_exhausted` says "this person has {balance} credits
1114
+ * — the spend needs {amount}". Pass `key` when the caller can retry — the
1115
+ * same key answers the first spend again with `deduped: true`, `spent: 0`,
1116
+ * the same `event` and the balance untouched. The key is scoped to the
1117
+ * person: one order id reused for two people charges both. Credits come
1118
+ * from a pack they bought, a comp in the back office, or a relay; a key
1119
+ * cannot mint them.
1120
+ *
1121
+ * Needs the key's "Spend a person's credits" box ticked by your human.
1122
+ * Refusals: `capability_required` (403 — "this key can't spend credits —
1123
+ * mint a key with 'Spend a person's credits' ticked") ·
1124
+ * `credits_exhausted` (402) · `person_not_found` (404) · `invalid_amount` /
1125
+ * `invalid_reason` / `invalid_key` (400) · `dedupe_conflict` (409 — the
1126
+ * key already names a different movement).
1127
+ */
1128
+ async spendCredits(personId, input) {
1129
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/credits/spend`, {
1130
+ method: "POST",
1131
+ body: JSON.stringify({
1132
+ ...(input.amount !== undefined ? { amount: input.amount } : {}),
1133
+ reason: input.reason,
1134
+ ...(input.key !== undefined ? { key: input.key } : {}),
1135
+ }),
1136
+ });
1137
+ }
938
1138
  /**
939
1139
  * End one grant — the reversibility law in one call:
940
1140
  *