@byte-42/fantastico-sdk 0.0.0-stage → 0.22.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/auth.d.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { type ToolOriginMap } from "./tool-origin.js";
2
+ import type { ContoBalance, RuntimeKeyHandle, ToolConfig } from "./types.js";
3
+ export interface AuthClientOptions {
4
+ /** La credenziale verso il Cloud: project key `sk-fant-…` o chiave di conto `ck-fant-…`. */
5
+ apiKey: string;
6
+ /** Base url comprensivo di `/api`. */
7
+ baseUrl: string;
8
+ /** La sessione di attività da continuare, se il chiamante ne aveva una. Vedi il campo omonimo. */
9
+ activitySessionId?: string;
10
+ /** Il prodotto del marketplace che questo client integra, mandato a ogni conio. Vedi
11
+ * `FantasticoOptions.product`. */
12
+ product?: string;
13
+ /** Origini pubbliche dei tool → origini interne. Vedi `tool-origin.ts`. */
14
+ toolOriginMap?: ToolOriginMap;
15
+ fetchImpl: typeof fetch;
16
+ }
17
+ /** Un tool raggiungibile e la credenziale con cui parlarci, dal conio. */
18
+ export interface ToolGrant {
19
+ id: string;
20
+ name: string;
21
+ endpoint: string;
22
+ /** Cambia quando cambiano i tool: e' la chiave di cache degli schemi. `null` = non dichiarata. */
23
+ schemaVersion: string | null;
24
+ /**
25
+ * Il digest di cio' che il control plane ha registrato per questo tool.
26
+ *
27
+ * Arriva col conio, cioe' per una strada diversa da quella con cui arrivera' il documento; e' il
28
+ * confronto fra le due che vale qualcosa. `null` non dovrebbe capitare — un tool senza schema non
29
+ * riceve un token, quindi non produce una concessione — e se capita si tratta come "non
30
+ * verificabile", che ha lo stesso esito di "non corrisponde".
31
+ */
32
+ schemaHash: string | null;
33
+ /** Il segreto. Torna una volta sola dal conio e non e' recuperabile da nessuna parte. */
34
+ token: string;
35
+ }
36
+ /** Esito di una prenotazione di budget per un tenant. */
37
+ export interface BudgetResult {
38
+ granted: number;
39
+ remaining: number;
40
+ lowBalance: boolean;
41
+ }
42
+ /**
43
+ * Client verso il control plane FANTASTICO.
44
+ *
45
+ * Una sola credenziale, e la sceglie chi costruisce: project key `sk-fant-…` (superficie
46
+ * `/api/runtime`) o chiave di conto `ck-fant-…` (superficie `/api/edge`). In nessuno dei due casi
47
+ * entra un segreto di piattaforma nel perimetro del cliente. Tiene le runtime key OpenRouter in
48
+ * memoria con rinnovo prima della scadenza — mai su disco, mai nei log — **una per conto**.
49
+ */
50
+ export declare class AuthClient {
51
+ private readonly opts;
52
+ /**
53
+ * Una chiave viva **per conto**, non una per client — e la chiave della mappa è la `userKey`
54
+ * del tenant, `""` per il conto che la credenziale sottintende (il proprietario del progetto
55
+ * su `/runtime`, il conto della credenziale su `/edge`).
56
+ *
57
+ * **È il conto che paga, quindi è il conto che conia.** Un conio apre un *hold* sul conto che
58
+ * nomina, quindi ogni tenant ha la propria: una key, una sessione e dei tool token per
59
+ * ciascuno.
60
+ *
61
+ * Non è una cache che cresce senza fine nel senso che preoccupa: una voce tiene una chiave che
62
+ * scade da sola, e il `maxOpenKeys` del piano è **per conto**, quindi un tenant in più non
63
+ * consuma gli slot degli altri.
64
+ */
65
+ private readonly accounts;
66
+ /** Listino del provider, per {@link outputPricePerToken}. Vedi il TTL li accanto.
67
+ * Uno solo per client: il prezzo di un modello non dipende da chi paga. */
68
+ private models;
69
+ /** Schemi gia' scaricati, per `${tool}@${versione}`: la versione e' il motivo per cui il
70
+ * Cloud la manda, e cambia esattamente quando cambiano i tool. */
71
+ private schemas;
72
+ /**
73
+ * Quale delle due superfici parlare, **dedotta dalla chiave e non ricevuta**.
74
+ *
75
+ * Cambia il prefisso delle rotte e nient'altro: `/runtime/key` e `/edge/key` portano allo stesso
76
+ * `ledger.mint` e rispondono la stessa forma. Quel che cambia davvero è **chi nomina il conto**:
77
+ * su `/runtime` lo nomina il chiamante — la project key vale per tutti i conti del progetto,
78
+ * quindi il conio deve dire quale paga — mentre su `/edge` il conto è quello della credenziale e
79
+ * nel corpo non c'è un campo di identità da riempire, né deve essercene uno.
80
+ *
81
+ * Dedotta qui e non passata dal costruttore perché `surfaceOf` è una funzione pura della stessa
82
+ * stringa: chiamarla due volte dà due volte lo stesso valore, mentre un parametro può portarne
83
+ * uno che non corrisponde alla chiave con cui viaggia.
84
+ */
85
+ private readonly surface;
86
+ constructor(opts: AuthClientOptions);
87
+ /**
88
+ * Lo stato del conto nominato, creandolo vuoto la prima volta. `undefined` = il conto che la
89
+ * credenziale sottintende.
90
+ */
91
+ private state;
92
+ private headers;
93
+ /**
94
+ * L'url di una rotta del control plane sulla superficie di questa credenziale.
95
+ *
96
+ * `/runtime` ed `/edge` sono anche i due valori di {@link Surface}, e non è una coincidenza da
97
+ * cui dipendere per caso: le due superfici portano le stesse rotte con lo stesso nome
98
+ * (`/key`, `/keys`, `/models`), e a cambiare è chi nomina il conto. Le poche che esistono solo
99
+ * di qua o solo di là non passano da qui — hanno un metodo loro che dice perché.
100
+ */
101
+ private url;
102
+ /**
103
+ * Runtime key OpenRouter **del conto indicato**: conia (o riusa quella in memoria) via
104
+ * `POST <superficie>/key`.
105
+ *
106
+ * `userKey` è il tenant a cui la chiamata è attribuita — il campo `user` di
107
+ * `chat.completions.create`. Omesso, è il conto che la credenziale sottintende. La chiave
108
+ * coniata **impegna il conto che nomina**, quindi passarlo o no decide chi paga.
109
+ */
110
+ getRuntimeKey(userKey?: string): Promise<string>;
111
+ /**
112
+ * Butta via la runtime key in mano (es. dopo un 401 da OpenRouter): il prossimo giro ne conia
113
+ * un'altra.
114
+ *
115
+ * **Non c'è niente da dire al Cloud, e prima si diceva.** Qui partiva un `force: true` nel corpo
116
+ * del conio, per saltare una cache di riuso che il vecchio servizio `vending` teneva: senza, il
117
+ * Cloud avrebbe restituito la STESSA chiave appena scartata. Il ledger non ha quella cache —
118
+ * ogni conio apre un hold suo — e `ledger.mint` non dichiara affatto quel parametro, quindi il
119
+ * campo viaggiava e nessuno lo leggeva. Quel che conta è tutto qui: senza la copia locale, il
120
+ * prossimo `getRuntimeKey()` chiede davvero.
121
+ */
122
+ invalidate(userKey?: string): void;
123
+ /** I tool token in mano per quel conto, gia' con endpoint e versione dello schema. Vuoto
124
+ * prima del conio. */
125
+ getToolGrants(userKey?: string): readonly ToolGrant[];
126
+ /**
127
+ * Il tetto in USD della runtime key in mano per quel conto, o `null` se non se ne è ancora
128
+ * coniata una. Da leggere subito dopo `getRuntimeKey()`, **con lo stesso conto**: due tenant
129
+ * hanno due chiavi e due tetti, e leggere quello sbagliato darebbe un `max_tokens` calcolato
130
+ * su una chiave che non sta per essere usata.
131
+ */
132
+ getKeyLimitUsd(userKey?: string): number | null;
133
+ /**
134
+ * Cosa si sta usando, senza il segreto: handle, tetto, impegno, scadenza e sessione.
135
+ *
136
+ * `null` finché non si è coniato — e chiamarlo prima del primo conio è la cosa da non fare:
137
+ * `client.ts` lo legge sempre dopo un `getRuntimeKey()`, che è ciò che rende il `null`
138
+ * impossibile al chiamante invece che soltanto improbabile.
139
+ */
140
+ getHandle(userKey?: string): RuntimeKeyHandle | null;
141
+ /** L'handle che porta questo `recordId`, se è uno di quelli in mano — e il conto a cui
142
+ * appartiene. Serve a `runtimeKeys.revoke`, che nomina una chiave e non un conto. */
143
+ findByRecordId(recordId: string): {
144
+ userKey: string;
145
+ } | null;
146
+ /** La sessione di attività corrente di quel conto, o `null` prima del suo primo conio. Non è
147
+ * una credenziale: non autorizza niente e si può loggare e mostrare. Serve a raggruppare la
148
+ * spesa a valle. */
149
+ getActivitySessionId(userKey?: string): string | null;
150
+ /**
151
+ * Il saldo del proprio conto (`GET /api/edge/balance`) — **solo sulla superficie edge**.
152
+ *
153
+ * È il gemello di `reserveBudget` per una credenziale che non può nominare un conto: la
154
+ * domanda "quanto resta" è la stessa, ma qui non c'è niente da prenotare e niente da regolare,
155
+ * perché non c'è un tenant a cui attribuire. Il blocco vero resta il conio.
156
+ */
157
+ balance(): Promise<ContoBalance>;
158
+ /**
159
+ * Regola il consumo di un tenant e prenota il budget successivo (`/api/runtime/budget`).
160
+ * Se il tenant o il progetto sono a secco, il Cloud risponde 402 e qui si solleva un
161
+ * FantasticoError **prima** che parta la chiamata LLM.
162
+ */
163
+ reserveBudget(userKey: string, usedUsd: number, requestUsd: number): Promise<BudgetResult>;
164
+ /**
165
+ * I tool di questa sessione: **uno per concessione**, chiesti a ciascun tool.
166
+ *
167
+ * **Non c'e piu un ripiego**. Sotto c'era `legacyCatalogTools`: un solo
168
+ * "tool" sintetico chiamato `fantastico` che leggeva `GET /api/tools` e instradava ogni
169
+ * esecuzione su `POST /api/tools/execute` con la project key. Quelle due rotte non esistono
170
+ * piu, e il ripiego non era neutro — **saltava la verifica**: il catalogo lo componeva il
171
+ * Cloud, quindi non c'era un documento firmato di cui ricalcolare il digest, e le descrizioni
172
+ * che finivano davanti al modello arrivavano senza che niente ne rispondesse.
173
+ *
174
+ * **Nessuna concessione significa nessun tool, e va detto cosi.** Prima significava "prova
175
+ * dall'altra parte", quindi un conio senza token — un piano che non prezza un tratto di
176
+ * lavoro, un progetto che non ha abilitato niente — si vedeva solo come una chat che
177
+ * funzionava un po' peggio. Ora e un elenco vuoto, che e la verita.
178
+ *
179
+ * **Non conia.** La discovery e' una lettura, e restituisce quello che l'ultimo conio ha
180
+ * portato: chi ha bisogno dei token si assicura la chiave **prima** di chiamare qui, ed e'
181
+ * `client.ts` a farlo in un posto solo (`remoteTools`). Se coniasse anche questo, ci sarebbero
182
+ * due punti da cui parte un conio, e il secondo sarebbe quello che nessuno si aspetta.
183
+ */
184
+ getTools(userKey?: string): Promise<ToolConfig[]>;
185
+ /**
186
+ * Chiede a UN tool i propri tool, presentando il token emesso per lui.
187
+ *
188
+ * **Lo schema lo dice il tool, non il control plane.** Il ledger tiene endpoint, listino e
189
+ * il *digest* dello schema, mai le descrizioni: sono dati del tool, e una copia nel control
190
+ * plane sarebbe una seconda fonte di verita' per qualcosa che non puo' validare. In piu' una
191
+ * descrizione di tool e' testo che finisce nel contesto di un modello, quindi deve venire da chi
192
+ * ne risponde.
193
+ *
194
+ * **Un tool che non risponde esce dal catalogo di questo giro, e basta.** Il degrado e' lo
195
+ * stesso che questo metodo ha sempre avuto verso il Cloud: N endpoint non devono diventare N modi
196
+ * nuovi di far fallire una chat che senza tool funzionerebbe.
197
+ */
198
+ private describeTool;
199
+ /**
200
+ * Il prezzo in USD di **un** token in uscita per un modello, dal catalogo del provider.
201
+ *
202
+ * Serve solo a `max-tokens.ts`, ed e' per questo che sta qui accanto al tetto della chiave: i due
203
+ * numeri hanno senso solo insieme, e chiederli a due posti diversi era il motivo per cui il conto
204
+ * non veniva fatto affatto.
205
+ *
206
+ * **Best-effort per costruzione**: se il catalogo non risponde si torna `null`, che a valle
207
+ * significa "non scrivere `max_tokens`" — cioe' esattamente il comportamento di prima. Un
208
+ * arricchimento che fallisce deve togliere un miglioramento, mai una chiamata.
209
+ *
210
+ * Cache in memoria con TTL, come quella che il Cloud tiene davanti alla stessa rotta: il
211
+ * listino non cambia fra due messaggi di una chat, e senza cache questo aggiungerebbe un giro di
212
+ * rete a ogni conio.
213
+ */
214
+ outputPricePerToken(model: string): Promise<number | null>;
215
+ private mint;
216
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,224 @@
1
+ import { resolveToolEndpoint } from "./tool-origin.js";
2
+ import { schemaDigest } from "./schema-digest.js";
3
+ import { wireTool } from "./tool-definition.js";
4
+ import { FantasticoError } from "./types.js";
5
+ import { surfaceOf } from "./surface.js";
6
+ const MODELS_TTL_MS = 5 * 60_000;
7
+ export class AuthClient {
8
+ opts;
9
+ accounts = new Map();
10
+ models = null;
11
+ schemas = new Map();
12
+ surface;
13
+ constructor(opts) {
14
+ this.opts = opts;
15
+ this.surface = surfaceOf(opts.apiKey);
16
+ this.state().activitySessionId = opts.activitySessionId ?? null;
17
+ }
18
+ state(userKey) {
19
+ const id = userKey ?? "";
20
+ let s = this.accounts.get(id);
21
+ if (s === undefined) {
22
+ s = { runtimeKey: null, mintPromise: null, activitySessionId: null, tools: [] };
23
+ this.accounts.set(id, s);
24
+ }
25
+ return s;
26
+ }
27
+ headers(json = false) {
28
+ const h = { Authorization: `Bearer ${this.opts.apiKey}` };
29
+ if (json)
30
+ h["Content-Type"] = "application/json";
31
+ return h;
32
+ }
33
+ url(path) {
34
+ return `${this.opts.baseUrl}/${this.surface}${path}`;
35
+ }
36
+ async getRuntimeKey(userKey) {
37
+ const st = this.state(userKey);
38
+ const skew = 60_000;
39
+ if (st.runtimeKey && st.runtimeKey.expiresAt - Date.now() > skew) {
40
+ return st.runtimeKey.key;
41
+ }
42
+ if (st.mintPromise)
43
+ return (await st.mintPromise).key;
44
+ st.mintPromise = this.mint(userKey).finally(() => {
45
+ st.mintPromise = null;
46
+ });
47
+ const fresh = await st.mintPromise;
48
+ st.runtimeKey = fresh;
49
+ return fresh.key;
50
+ }
51
+ invalidate(userKey) {
52
+ const st = this.state(userKey);
53
+ st.runtimeKey = null;
54
+ st.tools = [];
55
+ }
56
+ getToolGrants(userKey) {
57
+ return this.state(userKey).tools;
58
+ }
59
+ getKeyLimitUsd(userKey) {
60
+ return this.state(userKey).runtimeKey?.limitUsd ?? null;
61
+ }
62
+ getHandle(userKey) {
63
+ const st = this.state(userKey);
64
+ const k = st.runtimeKey;
65
+ if (k === null)
66
+ return null;
67
+ return {
68
+ recordId: k.recordId,
69
+ limitUsd: k.limitUsd,
70
+ costUsd: k.costUsd,
71
+ expiresAt: new Date(k.expiresAt).toISOString(),
72
+ activitySessionId: st.activitySessionId,
73
+ tools: st.tools.map((g) => ({ id: g.id, name: g.name })),
74
+ };
75
+ }
76
+ findByRecordId(recordId) {
77
+ for (const [userKey, st] of this.accounts) {
78
+ if (st.runtimeKey?.recordId === recordId)
79
+ return { userKey };
80
+ }
81
+ return null;
82
+ }
83
+ getActivitySessionId(userKey) {
84
+ return this.state(userKey).activitySessionId;
85
+ }
86
+ async balance() {
87
+ const res = await this.opts.fetchImpl(this.url("/balance"), {
88
+ method: "GET",
89
+ headers: this.headers(),
90
+ });
91
+ if (!res.ok) {
92
+ const body = (await res.json().catch(() => ({})));
93
+ throw new FantasticoError(body.message ?? `Saldo non disponibile (${res.status})`, body.type ?? "BALANCE_FAILED", res.status);
94
+ }
95
+ return (await res.json());
96
+ }
97
+ async reserveBudget(userKey, usedUsd, requestUsd) {
98
+ const res = await this.opts.fetchImpl(`${this.opts.baseUrl}/runtime/budget`, {
99
+ method: "POST",
100
+ headers: this.headers(true),
101
+ body: JSON.stringify({ userKey, used: usedUsd, request: requestUsd }),
102
+ });
103
+ if (!res.ok) {
104
+ const body = (await res.json().catch(() => ({})));
105
+ throw new FantasticoError(body.message ?? `Budget negato (${res.status})`, body.type ?? "BUDGET_DENIED", res.status);
106
+ }
107
+ const data = (await res.json());
108
+ return data;
109
+ }
110
+ async getTools(userKey) {
111
+ const grants = this.getToolGrants(userKey);
112
+ if (grants.length === 0)
113
+ return [];
114
+ const configs = await Promise.all(grants.map((g) => this.describeTool(g)));
115
+ return configs.filter((c) => c !== null);
116
+ }
117
+ async describeTool(grant) {
118
+ const cacheKey = `${grant.id}@${grant.schemaVersion ?? "?"}`;
119
+ const cached = grant.schemaVersion === null ? undefined : this.schemas.get(cacheKey);
120
+ let tools = cached;
121
+ if (tools === undefined) {
122
+ try {
123
+ const res = await this.opts.fetchImpl(resolveToolEndpoint(grant.endpoint, this.opts.toolOriginMap), {
124
+ method: "GET",
125
+ headers: { Authorization: `Bearer ${grant.token}` },
126
+ });
127
+ if (!res.ok)
128
+ return null;
129
+ const body = (await res.json().catch(() => ({})));
130
+ if (body.schema === undefined)
131
+ return null;
132
+ const digest = await schemaDigest(body.schema);
133
+ if (digest === null || grant.schemaHash === null || digest !== grant.schemaHash) {
134
+ return null;
135
+ }
136
+ tools = body.schema.functions ?? [];
137
+ if (grant.schemaVersion !== null)
138
+ this.schemas.set(cacheKey, tools);
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ if (tools.length === 0)
145
+ return null;
146
+ return {
147
+ name: grant.id,
148
+ url: `${resolveToolEndpoint(grant.endpoint, this.opts.toolOriginMap)}/execute`,
149
+ authToken: grant.token,
150
+ allowedTools: tools.map((t) => t.function.name),
151
+ toolDefinitions: tools.map(wireTool),
152
+ };
153
+ }
154
+ async outputPricePerToken(model) {
155
+ const now = Date.now();
156
+ if (this.models === null || now - this.models.at > MODELS_TTL_MS) {
157
+ try {
158
+ const res = await this.opts.fetchImpl(this.url("/models"), {
159
+ method: "GET",
160
+ headers: this.headers(),
161
+ });
162
+ if (!res.ok)
163
+ return null;
164
+ const body = (await res.json());
165
+ const byId = new Map();
166
+ for (const m of body.models ?? []) {
167
+ const price = Number(m.pricing?.completion);
168
+ if (m.id !== undefined && Number.isFinite(price) && price > 0)
169
+ byId.set(m.id, price);
170
+ }
171
+ this.models = { at: now, byId };
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
177
+ return this.models.byId.get(model) ?? null;
178
+ }
179
+ async mint(userKey) {
180
+ const st = this.state(userKey);
181
+ const sent = st.activitySessionId;
182
+ const named = this.surface === "runtime" && userKey !== undefined ? { userKey } : {};
183
+ const bought = this.opts.product === undefined ? {} : { product: this.opts.product };
184
+ const res = await this.opts.fetchImpl(this.url("/key"), {
185
+ method: "POST",
186
+ headers: this.headers(true),
187
+ body: JSON.stringify({
188
+ ...named,
189
+ ...bought,
190
+ ...(sent === null ? {} : { activitySessionId: sent }),
191
+ }),
192
+ });
193
+ if (!res.ok) {
194
+ const body = (await res.json().catch(() => ({})));
195
+ throw new FantasticoError(body.message ?? `Conio runtime key fallito (${res.status})`, body.type ?? "MINT_FAILED", res.status);
196
+ }
197
+ const data = (await res.json());
198
+ if (data.activitySessionId !== undefined) {
199
+ if (sent !== null && sent !== data.activitySessionId) {
200
+ console.warn(`[fantastico] sessione di attività sostituita dal ledger: ${sent} -> ` +
201
+ `${data.activitySessionId}. La spesa precedente resta raggruppata sotto la vecchia.`);
202
+ }
203
+ st.activitySessionId = data.activitySessionId;
204
+ }
205
+ const limitUsd = data.limit == null ? null : Number(data.limit);
206
+ st.tools = (data.tools ?? [])
207
+ .filter((svc) => typeof svc.endpoint === "string")
208
+ .map((svc) => ({
209
+ id: svc.id,
210
+ name: svc.name ?? svc.id,
211
+ endpoint: svc.endpoint,
212
+ schemaVersion: svc.schemaVersion ?? null,
213
+ schemaHash: typeof svc.schemaHash === "string" ? svc.schemaHash : null,
214
+ token: svc.token,
215
+ }));
216
+ return {
217
+ key: data.runtimeKey,
218
+ expiresAt: new Date(data.expiresAt).getTime(),
219
+ recordId: data.recordId,
220
+ limitUsd: limitUsd !== null && Number.isFinite(limitUsd) ? limitUsd : null,
221
+ costUsd: data.cost == null ? null : String(data.cost),
222
+ };
223
+ }
224
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Quanto insistere su un errore che passa da solo (429, provider sovraccarico).
3
+ *
4
+ * `false` la spegne del tutto. I default sono pensati per una chiamata dentro una richiesta HTTP:
5
+ * due ritentativi, qualche secondo in tutto, mai più di dieci per singola attesa.
6
+ */
7
+ export interface RetryOptions {
8
+ /** Quanti **ritentativi** dopo il primo tentativo. `0` equivale a spegnerla. Default 2. */
9
+ attempts?: number;
10
+ /** Base dell'esponenziale quando il provider non dice `Retry-After`. Default 500 ms. */
11
+ baseDelayMs?: number;
12
+ /** Tetto su ogni singola attesa, **compreso** un `Retry-After` esagerato. Default 10 s. */
13
+ maxDelayMs?: number;
14
+ }
15
+ export interface RetryConfig extends RetryOptions {
16
+ /**
17
+ * L'istante oltre il quale non si dorme più.
18
+ *
19
+ * Serve perché l'orchestratore controlla il proprio `budgetMs` solo **in cima a ogni turno**
20
+ * (`orchestrator.ts`): un'attesa dentro la chiamata HTTP gli è invisibile, quindi senza questo
21
+ * limite un backoff potrebbe sforare il budget del chiamante e far emergere un `TIMEOUT` al
22
+ * turno successivo — al posto della causa vera, che era un rate limit. Se non c'è tempo per
23
+ * l'attesa, l'errore del provider si propaga subito: dire "429" è più utile che dire "timeout".
24
+ */
25
+ deadlineAt?: number;
26
+ /** Iniettabili nei test: un'attesa vera renderebbe la suite lenta e il jitter non asseribile. */
27
+ sleep?: (ms: number) => Promise<void>;
28
+ random?: () => number;
29
+ }
30
+ /**
31
+ * L'attesa prima del ritentativo numero `attempt` (1-based).
32
+ *
33
+ * Ordine delle preferenze: **`Retry-After` se c'è** — il provider ha detto il numero, e indovinarne
34
+ * un altro dopo essere stati informati è l'unica opzione chiaramente sbagliata — altrimenti un
35
+ * esponenziale `base * 2^(attempt-1)`. In entrambi i casi si applica il tetto, e in entrambi il
36
+ * **jitter**, che non è un ornamento: molti client diversi condividono lo stesso
37
+ * account presso il provider, quindi prendono il 429 nello stesso istante. Senza jitter ritenterebbero
38
+ * all'unisono e se lo ri-procurerebbero — la ritentata diventerebbe la causa.
39
+ */
40
+ export declare function delayFor(err: unknown, attempt: number, cfg: RetryConfig): number;
41
+ /**
42
+ * Esegue `run`, e lo rifà se fallisce con un errore transitorio.
43
+ *
44
+ * Sta **attorno alla singola richiesta HTTP**, non attorno all'orchestrazione: lì un ritentativo
45
+ * rigioverebbe la conversazione da capo, ripagando i turni già fatti e rieseguendo i loro tool
46
+ * (vedi la nota su `RemintingLlmClient` in `client.ts`). Qui invece non c'è niente da rieseguire.
47
+ */
48
+ export declare function withTransientRetry<T>(run: () => Promise<T>, cfg: RetryConfig | false): Promise<T>;
@@ -0,0 +1,31 @@
1
+ import { ProviderError, isTransient } from "./provider-error.js";
2
+ const DEFAULTS = { attempts: 2, baseDelayMs: 500, maxDelayMs: 10_000 };
3
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ export function delayFor(err, attempt, cfg) {
5
+ const base = cfg.baseDelayMs ?? DEFAULTS.baseDelayMs;
6
+ const max = cfg.maxDelayMs ?? DEFAULTS.maxDelayMs;
7
+ const random = cfg.random ?? Math.random;
8
+ const stated = err instanceof ProviderError ? err.retryAfterMs : null;
9
+ const wanted = stated ?? base * 2 ** (attempt - 1);
10
+ const capped = Math.min(wanted, max);
11
+ return Math.round(capped * (0.5 + random() * 0.5));
12
+ }
13
+ export async function withTransientRetry(run, cfg) {
14
+ if (cfg === false)
15
+ return run();
16
+ const attempts = cfg.attempts ?? DEFAULTS.attempts;
17
+ const sleep = cfg.sleep ?? wait;
18
+ for (let attempt = 0;; attempt++) {
19
+ try {
20
+ return await run();
21
+ }
22
+ catch (err) {
23
+ if (attempt >= attempts || !isTransient(err))
24
+ throw err;
25
+ const delay = delayFor(err, attempt + 1, cfg);
26
+ if (cfg.deadlineAt !== undefined && Date.now() + delay > cfg.deadlineAt)
27
+ throw err;
28
+ await sleep(delay);
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,28 @@
1
+ import type { ClientAction } from "./types.js";
2
+ export type ClientActionHandler = (args: Record<string, unknown>) => unknown | Promise<unknown>;
3
+ /**
4
+ * Esecutore lato client delle azioni consegnate dal server — `ChatCompletion.clientActions`
5
+ * (fire-and-forget) o l'esito da rimandare a `resume()` per un `PendingToolCall` con
6
+ * `reason: "client_action"`. Nessuna dipendenza Node: niente fetch, niente chiavi — gira nel
7
+ * browser, si limita a mappare nome→funzione reale registrata dall'integratore, simmetrico
8
+ * a `{ definition, execute }` lato server ma senza mai vedere una project key.
9
+ */
10
+ export declare class FantasticoActions {
11
+ private readonly handlers;
12
+ constructor(initial?: Record<string, ClientActionHandler>);
13
+ on(name: string, handler: ClientActionHandler): void;
14
+ /** Esegue una singola azione. Non lancia mai: un handler mancante o che fallisce diventa
15
+ * un esito d'errore, non un'eccezione che spacca il resto del batch. */
16
+ run(action: ClientAction): Promise<{
17
+ id: string;
18
+ result?: unknown;
19
+ error?: string;
20
+ }>;
21
+ /** Esegue un elenco di azioni (tipicamente `ChatCompletion.clientActions`) in parallelo,
22
+ * isolando i fallimenti gli uni dagli altri. */
23
+ runAll(actions: ClientAction[]): Promise<Array<{
24
+ id: string;
25
+ result?: unknown;
26
+ error?: string;
27
+ }>>;
28
+ }
@@ -0,0 +1,25 @@
1
+ export class FantasticoActions {
2
+ handlers = new Map();
3
+ constructor(initial = {}) {
4
+ for (const [name, handler] of Object.entries(initial))
5
+ this.on(name, handler);
6
+ }
7
+ on(name, handler) {
8
+ this.handlers.set(name, handler);
9
+ }
10
+ async run(action) {
11
+ const handler = this.handlers.get(action.name);
12
+ if (!handler)
13
+ return { id: action.id, error: `Nessun handler registrato per "${action.name}"` };
14
+ try {
15
+ const result = await handler(action.arguments);
16
+ return { id: action.id, result };
17
+ }
18
+ catch (err) {
19
+ return { id: action.id, error: err.message ?? "Errore azione client" };
20
+ }
21
+ }
22
+ async runAll(actions) {
23
+ return Promise.all(actions.map((a) => this.run(a)));
24
+ }
25
+ }