@byte-42/fantastico-sdk 0.0.0-stage → 0.21.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/raw.js ADDED
@@ -0,0 +1,72 @@
1
+ import { providerErrorFromResponse, errorInBody, emptyCompletionError } from "./provider-error.js";
2
+ import { withTransientRetry } from "./backoff.js";
3
+ function hasCacheControl(messages) {
4
+ return messages.some((m) => Array.isArray(m.content) && m.content.some((part) => part.cache_control != null));
5
+ }
6
+ export async function callOpenRouterRaw(runtimeKey, params, fetchImpl, retry = {}) {
7
+ const pinProvider = params.pinProvider ?? hasCacheControl(params.messages);
8
+ const body = {
9
+ model: params.model,
10
+ messages: params.messages,
11
+ usage: { include: true },
12
+ ...(pinProvider ? { provider: { order: ["Anthropic"], allow_fallbacks: true } } : {}),
13
+ ...(params.tools && params.tools.length > 0 ? { tools: params.tools } : {}),
14
+ ...(params.tool_choice != null ? { tool_choice: params.tool_choice } : {}),
15
+ ...(params.max_tool_calls != null ? { max_tool_calls: params.max_tool_calls } : {}),
16
+ ...(params.temperature != null ? { temperature: params.temperature } : {}),
17
+ ...(params.max_tokens != null ? { max_tokens: params.max_tokens } : {}),
18
+ ...(params.reasoning != null ? { reasoning: params.reasoning } : {}),
19
+ };
20
+ return withTransientRetry(async () => {
21
+ const res = await fetchImpl("https://openrouter.ai/api/v1/chat/completions", {
22
+ method: "POST",
23
+ headers: {
24
+ Authorization: `Bearer ${runtimeKey}`,
25
+ "Content-Type": "application/json",
26
+ "HTTP-Referer": "https://fantastico.cc",
27
+ "X-Title": "FANTASTICO SDK",
28
+ },
29
+ body: JSON.stringify(body),
30
+ });
31
+ if (!res.ok)
32
+ throw await providerErrorFromResponse(res, "OpenRouter");
33
+ const payload = await res.json();
34
+ const embedded = errorInBody(payload, "OpenRouter");
35
+ if (embedded)
36
+ throw embedded;
37
+ const completion = payload;
38
+ const choice = completion.choices?.[0];
39
+ const empty = emptyCompletionError({
40
+ model: completion.model ?? params.model,
41
+ finishReason: choice?.finish_reason,
42
+ content: choice?.message.content,
43
+ toolCalls: choice?.message.tool_calls,
44
+ maxTokens: params.max_tokens ?? null,
45
+ completionTokens: completion.usage?.completion_tokens ?? 0,
46
+ reasoningTokens: completion.usage?.completion_tokens_details?.reasoning_tokens ?? 0,
47
+ });
48
+ if (empty)
49
+ throw empty;
50
+ return completion;
51
+ }, retry);
52
+ }
53
+ export function normalizeRawUsage(usage) {
54
+ return {
55
+ prompt_tokens: usage?.prompt_tokens ?? 0,
56
+ completion_tokens: usage?.completion_tokens ?? 0,
57
+ total_tokens: usage?.total_tokens ?? 0,
58
+ internal_turns: 1,
59
+ cache_creation_tokens: usage?.prompt_tokens_details?.cache_write_tokens ?? 0,
60
+ cache_read_tokens: usage?.prompt_tokens_details?.cached_tokens ?? 0,
61
+ reasoning_tokens: usage?.completion_tokens_details?.reasoning_tokens ?? 0,
62
+ mcp_calls: 0,
63
+ cost_usd: usage?.cost ?? 0,
64
+ };
65
+ }
66
+ export function normalizeFinishReason(reason) {
67
+ if (reason === "length")
68
+ return "length";
69
+ if (reason === "tool_calls")
70
+ return "tool_calls";
71
+ return "stop";
72
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Il digest del documento di uno strumento, ricalcolato da chi lo riceve.
3
+ *
4
+ * **Serve a rispondere a una domanda sola**: cio che il tool mi ha appena consegnato e' cio che il
5
+ * control plane ha registrato? Le due cose arrivano per strade diverse — la concessione col conio,
6
+ * il documento dal tool — e se non coincidono o il tool e' cambiato o qualcosa in mezzo lo ha
7
+ * cambiato. Da qui non si distinguono, e nessuno dei due casi e' un motivo per proseguire: quel
8
+ * documento sta per essere messo davanti a un modello per conto di chi ci ha dato la chiave.
9
+ *
10
+ * **Canonicalizza prima di firmare, non firma i byte.** Questo lato fa `JSON.parse` e il testo
11
+ * originale non esiste piu'; e un tool di terze parti serializzera' a modo suo, con un altro ordine
12
+ * di chiavi e un'altra spaziatura. Firmare i byte renderebbe la verifica sensibile a una virgola,
13
+ * cioe' rumore. Gemello di `backend/src/lib/tool-schema.ts` — due copie perche' questo pacchetto
14
+ * non ha dipendenze, tenute oneste dallo stesso vettore di prova nei due test.
15
+ */
16
+ /** JSON deterministico: chiavi ordinate, nessuno spazio. Gli array NON si ordinano. */
17
+ export declare function canonicalJson(value: unknown): string;
18
+ /**
19
+ * SHA-256 esadecimale della forma canonica.
20
+ *
21
+ * **WebCrypto e non `node:crypto`**: questo pacchetto non importa nulla, nemmeno un builtin, ed e'
22
+ * una proprieta' che vale la pena non perdere per una riga di hash. `crypto.subtle` c'e' su Node 18+
23
+ * e nei browser. Asincrona, che qui non costa niente: si e' gia' dentro una `await`.
24
+ */
25
+ export declare function schemaDigest(document: unknown): Promise<string | null>;
@@ -0,0 +1,20 @@
1
+ export function canonicalJson(value) {
2
+ if (value === null || typeof value !== "object")
3
+ return JSON.stringify(value) ?? "null";
4
+ if (Array.isArray(value))
5
+ return `[${value.map(canonicalJson).join(",")}]`;
6
+ const entries = Object.entries(value)
7
+ .filter(([, v]) => v !== undefined)
8
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
9
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
10
+ }
11
+ export async function schemaDigest(document) {
12
+ const subtle = globalThis.crypto?.subtle;
13
+ if (subtle === undefined)
14
+ return null;
15
+ const bytes = new TextEncoder().encode(canonicalJson(document));
16
+ const digest = await subtle.digest("SHA-256", bytes);
17
+ return Array.from(new Uint8Array(digest))
18
+ .map((b) => b.toString(16).padStart(2, "0"))
19
+ .join("");
20
+ }
@@ -0,0 +1,107 @@
1
+ import type { OpenRouterModel } from "./types.js";
2
+ import { type RetryConfig } from "./backoff.js";
3
+ /**
4
+ * Voce sintetica (text-to-speech) sullo stesso conto e la stessa runtime key di ogni altra chiamata.
5
+ *
6
+ * OpenRouter ha un endpoint dedicato, `POST /api/v1/audio/speech`, compatibile con l'Audio Speech
7
+ * API di OpenAI: non passa dalla `/chat/completions` di `llm.ts`/`raw.ts`, e i suoi modelli **non
8
+ * compaiono nel catalogo `/models` senza filtro** — si elencano con `?output_modalities=speech`,
9
+ * ed è ciò che `ai.audio.speech.models()` restituisce, voci comprese (`supported_voices`).
10
+ *
11
+ * **Verificato sul campo (2026-10-17)**, con una runtime key a tetto di 5 centesimi:
12
+ *
13
+ * - **la spesa finisce nell'`usage` della chiave** come quella di una chat, quindi il ledger la
14
+ * liquida sull'hold del conio senza una riga di codice in più — è per questo che qui non c'è
15
+ * nessuna dichiarazione di consumo;
16
+ * - **il prezzo è per carattere di testo** dove il catalogo ha `pricing.completion: "0"` (Mistral
17
+ * e ElevenLabs: 70 caratteri × prezzo = costo esatto), **per token** su Gemini TTS (token di
18
+ * testo in ingresso più token di audio in uscita, che dipendono dalla durata), per secondo di
19
+ * audio su Seed Audio. Il catalogo non dichiara l'unità: la si legge da quale dei due prezzi è
20
+ * diverso da zero;
21
+ * - **il formato dipende dal modello e il catalogo non lo dice**: HeyGen e Gemini rispondono solo
22
+ * `pcm` (400 con il messaggio esplicito se si chiede `mp3`), Mistral ed ElevenLabs servono
23
+ * `mp3`. Per questo `response_format` omesso **non si scrive**: vale il default del provider
24
+ * (`pcm`), e chi vuole `mp3` lo chiede sapendo che il modello lo serve;
25
+ * - la risposta **non porta il costo**: solo `X-Generation-Id`, e `GET /generation?id=` risponde
26
+ * dopo circa cinque secondi. Leggerlo dentro la chiamata la rallenterebbe di quel tanto per un
27
+ * numero che il ledger misura comunque; quindi il risultato porta l'id e non un costo stimato.
28
+ * Un `cost_usd` inventato qui sarebbe la stima lato client che il resto dell'SDK non fa mai.
29
+ */
30
+ /** Un turno di un input a più voci (solo Gemini TTS e i Fish Audio non-S1 lo accettano). */
31
+ export interface SpeechTurn {
32
+ text: string;
33
+ /** Eredita `voice` della richiesta se omessa. */
34
+ voice?: string;
35
+ /** Eredita `instructions` della richiesta se omesse. */
36
+ instructions?: string;
37
+ }
38
+ export interface SpeechCreateParams {
39
+ /**
40
+ * Un modello con `output_modalities: ["speech"]` — da `ai.audio.speech.models()`. Senza default,
41
+ * di proposito: le voci sono del modello, quindi scegliere una voce è già scegliere un modello,
42
+ * e un default che un giorno sparisse dal catalogo fallirebbe in silenzio per tutti.
43
+ */
44
+ model: string;
45
+ /** Il testo da leggere, o una sequenza di turni con la loro voce. */
46
+ input: string | SpeechTurn[];
47
+ /** Una delle `supported_voices` del modello. Obbligatoria per quasi tutti: omessa, il
48
+ * provider risponde 400 a meno che non documenti un default. */
49
+ voice?: string;
50
+ /** Tono o stile ("warm and friendly"). Letto da OpenAI, Gemini e Fish Audio, ignorato altrove. */
51
+ instructions?: string;
52
+ /**
53
+ * `mp3` o `pcm`. **Omesso non si scrive**, e vale il default del provider (`pcm`): quale dei
54
+ * due un modello serva non è nel catalogo, e un default `mp3` scritto qui romperebbe HeyGen e
55
+ * Gemini con un 400. Il `contentType` della risposta dice cosa è arrivato — per `pcm` anche
56
+ * frequenza e canali (`audio/pcm;rate=24000;channels=1`).
57
+ */
58
+ response_format?: "mp3" | "pcm";
59
+ /** Velocità, `1.0` di default. Alcuni modelli la ignorano, altri rifiutano un valore non standard. */
60
+ speed?: number;
61
+ /** Riferimenti audio/immagine per clonare o disegnare una voce: pass-through verso il provider. */
62
+ input_references?: Array<Record<string, unknown>>;
63
+ /** `zdr: true` instrada solo verso endpoint Zero Data Retention. */
64
+ provider?: {
65
+ zdr?: boolean;
66
+ [key: string]: unknown;
67
+ };
68
+ /** Il tenant finale, come `user` in `chat.completions.create()`: la spesa va sul suo conto. */
69
+ user?: string;
70
+ }
71
+ export interface SpeechResult {
72
+ /** I byte dell'audio, nel formato che `contentType` dichiara. */
73
+ audio: Uint8Array;
74
+ /** `audio/mpeg` per mp3; per pcm porta anche frequenza e canali. */
75
+ contentType: string;
76
+ /** Il modello richiesto: il provider non lo ripete nella risposta. */
77
+ model: string;
78
+ /**
79
+ * L'id della generazione presso il provider (`X-Generation-Id`), per i log. Non è il costo:
80
+ * quello lo misura il ledger dall'usage della chiave e compare nell'estratto conto alla
81
+ * liquidazione, come per una chat.
82
+ */
83
+ generationId: string | null;
84
+ }
85
+ /**
86
+ * Un modello text-to-speech del catalogo (`ai.audio.speech.models()`): la stessa forma di
87
+ * `OpenRouterModel`, con `supported_voices` dove il modello le dichiara.
88
+ *
89
+ * **Come leggere il prezzo.** `pricing.completion: "0"` → `pricing.prompt` è USD per **carattere**
90
+ * di testo (il caso di quasi tutti). Entrambi diversi da zero → per token, in ingresso e in uscita
91
+ * (Gemini TTS). `pricing.prompt: "0"` con `completion` > 0 → per **secondo** di audio generato
92
+ * (Seed Audio). Alcuni modelli non dichiarano voci: Fish Audio le prende da `input_references`.
93
+ */
94
+ export type SpeechModel = OpenRouterModel & {
95
+ supported_voices?: string[];
96
+ };
97
+ /**
98
+ * Una singola chiamata all'endpoint TTS — stessa forma di `callOpenRouterRaw` e
99
+ * `callOpenRouterDecision`: nessuna orchestrazione, ritentativo sui soli errori transitori
100
+ * (`backoff.ts`), errore tipizzato (`provider-error.ts`) su cui `client.ts` decide se ri-coniare.
101
+ *
102
+ * **La risposta è audio, l'errore è JSON.** Lo stato HTTP decide: un non-2xx porta l'envelope
103
+ * `{ error: { code, message, metadata } }` di sempre — quindi 401/402 fanno ri-coniare e 429/503
104
+ * si riprovano come per una chat. Un 200 che porta JSON al posto dell'audio è un errore nel corpo
105
+ * (`errorInBody`), e un 200 vuoto è un errore e basta: nessuno dei due è una voce.
106
+ */
107
+ export declare function callOpenRouterSpeech(runtimeKey: string, params: SpeechCreateParams, fetchImpl: typeof fetch, retry?: RetryConfig | false): Promise<SpeechResult>;
package/dist/speech.js ADDED
@@ -0,0 +1,48 @@
1
+ import { FantasticoError } from "./types.js";
2
+ import { providerErrorFromResponse, errorInBody } from "./provider-error.js";
3
+ import { withTransientRetry } from "./backoff.js";
4
+ const SPEECH_URL = "https://openrouter.ai/api/v1/audio/speech";
5
+ export async function callOpenRouterSpeech(runtimeKey, params, fetchImpl, retry = {}) {
6
+ const body = {
7
+ model: params.model,
8
+ input: params.input,
9
+ ...(params.voice != null ? { voice: params.voice } : {}),
10
+ ...(params.instructions != null ? { instructions: params.instructions } : {}),
11
+ ...(params.response_format != null ? { response_format: params.response_format } : {}),
12
+ ...(params.speed != null ? { speed: params.speed } : {}),
13
+ ...(params.input_references != null ? { input_references: params.input_references } : {}),
14
+ ...(params.provider != null ? { provider: params.provider } : {}),
15
+ };
16
+ return withTransientRetry(async () => {
17
+ const res = await fetchImpl(SPEECH_URL, {
18
+ method: "POST",
19
+ headers: {
20
+ Authorization: `Bearer ${runtimeKey}`,
21
+ "Content-Type": "application/json",
22
+ "HTTP-Referer": "https://fantastico.cc",
23
+ "X-Title": "FANTASTICO SDK",
24
+ },
25
+ body: JSON.stringify(body),
26
+ });
27
+ if (!res.ok)
28
+ throw await providerErrorFromResponse(res, "OpenRouter Speech");
29
+ const contentType = res.headers.get("content-type") ?? "";
30
+ if (/json/i.test(contentType)) {
31
+ const payload = await res.json().catch(() => null);
32
+ const embedded = errorInBody(payload, "OpenRouter Speech");
33
+ if (embedded)
34
+ throw embedded;
35
+ throw new FantasticoError(`${params.model}: il provider ha risposto JSON al posto dell'audio`, "EMPTY_AUDIO", 502);
36
+ }
37
+ const audio = new Uint8Array(await res.arrayBuffer());
38
+ if (audio.byteLength === 0) {
39
+ throw new FantasticoError(`${params.model}: il provider ha risposto senza audio`, "EMPTY_AUDIO", 502);
40
+ }
41
+ return {
42
+ audio,
43
+ contentType,
44
+ model: params.model,
45
+ generationId: res.headers.get("x-generation-id"),
46
+ };
47
+ }, retry);
48
+ }
@@ -0,0 +1,35 @@
1
+ import { FantasticoError } from "./types.js";
2
+ /**
3
+ * Le due superfici del control plane, e la credenziale che le sceglie.
4
+ *
5
+ * **Non è una modalità del client: è una proprietà della chiave.** `/api/runtime/*` vuole una
6
+ * project key e legge il conto dal corpo della richiesta; `/api/edge/*` vuole una chiave di conto
7
+ * e non ha **nessun campo** con cui nominarne uno — l'identità è quella della credenziale, punto
8
+ *. Sono due contratti diversi per due assunzioni di fiducia diverse, e il
9
+ * prefisso della chiave dice già quale dei due si sta usando.
10
+ *
11
+ * Quindi qui non si sceglie niente e non si configura niente: si **legge** la chiave. Un'opzione
12
+ * con cui dichiarare la superficie sarebbe un secondo posto in cui dire la stessa cosa, cioè un
13
+ * posto in cui dirla diversamente — e il sintomo sarebbe un 401 su ogni chiamata, senza che
14
+ * niente indichi quale delle due dichiarazioni fosse quella sbagliata.
15
+ */
16
+ export type Surface = "runtime" | "edge";
17
+ /**
18
+ * La superficie di una chiave, dal suo prefisso.
19
+ *
20
+ * È lo stesso controllo che il backend fa prima di guardare il resto della stringa
21
+ * (`authorizeProjectKey` / `authorizeContoKey` in `api.service.ts`), quindi non si indovina
22
+ * nulla: una chiave che non comincia in uno dei due modi non sarebbe accettata da nessuna delle
23
+ * due superfici, e fermarsi alla costruzione risparmia un 401 su ogni chiamata successiva.
24
+ */
25
+ export declare function surfaceOf(apiKey: string): Surface;
26
+ /**
27
+ * Il rifiuto per un'operazione che **su questa superficie non esiste**.
28
+ *
29
+ * Le due non offrono le stesse cose, e fingere di sì costerebbe più di quanto renda: l'anagrafica
30
+ * dei conti è di un progetto e una chiave di conto ne vale uno solo; il saldo del proprio conto è
31
+ * una domanda che solo chi ne ha uno può porre. Detto con un errore parlante, chi integra lo
32
+ * scopre alla prima chiamata; simulato, lo scoprirebbe da un elenco vuoto o da un numero preso da
33
+ * qualcun altro.
34
+ */
35
+ export declare function unsupportedOnSurface(what: string, needs: Surface): FantasticoError;
@@ -0,0 +1,13 @@
1
+ import { FantasticoError } from "./types.js";
2
+ export function surfaceOf(apiKey) {
3
+ if (apiKey.startsWith("sk-fant-"))
4
+ return "runtime";
5
+ if (apiKey.startsWith("ck-fant-"))
6
+ return "edge";
7
+ throw new FantasticoError('Credenziale non riconosciuta: una project key comincia per "sk-fant-", '
8
+ + 'una chiave di conto per "ck-fant-"', "UNKNOWN_CREDENTIAL", 400);
9
+ }
10
+ export function unsupportedOnSurface(what, needs) {
11
+ const credential = needs === "runtime" ? "project key `sk-fant-…`" : "chiave di conto `ck-fant-…`";
12
+ return new FantasticoError(`${what}: serve una ${credential}, cioè la superficie /api/${needs}`, "SURFACE_UNSUPPORTED", 400);
13
+ }
@@ -0,0 +1,22 @@
1
+ import type { ToolDefinition } from "./types.js";
2
+ /**
3
+ * Un tool ridotto a **cio che l'array `tools` di una richiesta al provider ammette**:
4
+ * `{type, function:{name, description, parameters}}`, ricostruito campo per campo.
5
+ *
6
+ * **Perche' qui e non solo dal lato di chi scrive il manifest.** Questo pacchetto e' l'unico posto
7
+ * che compone davvero la richiesta al provider, e cio' che ci mette dentro arriva da `JSON.parse`
8
+ * della risposta di un tool — cioe' da un host che non e' il nostro. Un tool di terze parti puo'
9
+ * annotare le proprie funzioni come vuole: nel suo documento quei campi sono legittimi, il digest
10
+ * torna e la verifica passa. Quello che non e' legittimo e' **inoltrarli**, perche' da li' in poi
11
+ * non sono piu' annotazioni di un manifest, sono un oggetto fuori contratto spedito a un endpoint
12
+ * di qualcun altro, che ha tutto il diritto di rifiutarlo.
13
+ *
14
+ * **Ricostruito e non ripulito**: l'insieme delle chiavi che escono e' scritto qui, invece di
15
+ * essere "tutto quello che c'era meno quelle a cui avevo pensato". Un campo che nessuno ha previsto
16
+ * non passa per il solo fatto di esistere.
17
+ *
18
+ * **Dopo la verifica, mai prima.** Quel che si firma e' il documento come il tool l'ha scritto: se
19
+ * si normalizzasse prima di ricalcolare il digest, la verifica direbbe che due documenti diversi
20
+ * sono lo stesso — che e' esattamente cio' che non deve poter dire.
21
+ */
22
+ export declare function wireTool(t: ToolDefinition): ToolDefinition;
@@ -0,0 +1,10 @@
1
+ export function wireTool(t) {
2
+ return {
3
+ type: "function",
4
+ function: {
5
+ name: t.function.name,
6
+ description: t.function.description,
7
+ parameters: t.function.parameters,
8
+ },
9
+ };
10
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Raggiungere un tool per una strada interna invece che per il suo indirizzo pubblico.
3
+ *
4
+ * **Perché esiste.** Un tool si registra con il suo indirizzo pubblico, e chi lo chiama dalla
5
+ * stessa macchina (o dalla stessa rete privata) su cui gira uscirebbe su internet per rientrare,
6
+ * passando per proxy e CDN con i loro timeout: una chiamata lunga verrebbe tagliata a metà
7
+ * mentre il tool finisce il lavoro e lo addebita.
8
+ *
9
+ * Con la mappa la chiamata va diretta al processo del tool (es. `http://127.0.0.1:8080`). Cambia
10
+ * solo **dove** si manda la richiesta: il token resta quello della concessione e il documento del
11
+ * tool si verifica contro lo stesso digest, quindi una mappa sbagliata fa cadere il tool dalla
12
+ * sessione come un tool irraggiungibile, mai eseguire qualcos'altro al suo posto.
13
+ *
14
+ * **Solo dentro la stessa macchina o rete privata**: verso un'origine `http://` il token viaggia
15
+ * in chiaro, quindi una destinazione `http://` è accettata solo se è loopback o un indirizzo di
16
+ * rete privata, un nome senza punti (come il nome di un container) o un dominio riservato all'uso
17
+ * privato (`host.docker.internal`, `*.internal`, `*.local`). Verso qualunque altro host
18
+ * la destinazione deve essere `https://`.
19
+ *
20
+ * Forma: `{ "https://tools.example.com": "http://127.0.0.1:8080" }`, oppure la stessa cosa come
21
+ * stringa `pubblica=interna[,pubblica=interna…]` (la variabile `FANTASTICO_TOOL_ORIGIN_MAP`).
22
+ */
23
+ export type ToolOriginMap = Record<string, string>;
24
+ /** Legge la forma testuale. Una voce malformata è un errore: una mappa ignorata in silenzio è il
25
+ * modo di scoprire mesi dopo che le chiamate passavano ancora da fuori. */
26
+ export declare function parseToolOriginMap(text: string): ToolOriginMap;
27
+ /** Origini confrontabili: protocollo e host minuscoli, senza barra finale né percorso. */
28
+ export declare function normalizeToolOriginMap(map: ToolOriginMap): ToolOriginMap;
29
+ /** L'indirizzo da usare davvero per un endpoint di tool: quello interno se la sua origine è mappata. */
30
+ export declare function resolveToolEndpoint(endpoint: string, map: ToolOriginMap | undefined): string;
31
+ /** La mappa dall'ambiente, se il runtime ne ha uno (Node sì, un browser no). */
32
+ export declare function toolOriginMapFromEnv(): ToolOriginMap | undefined;
@@ -0,0 +1,65 @@
1
+ export function parseToolOriginMap(text) {
2
+ const map = {};
3
+ for (const entry of text.split(",").map((e) => e.trim()).filter(Boolean)) {
4
+ const eq = entry.indexOf("=");
5
+ if (eq <= 0)
6
+ throw new Error(`FANTASTICO_TOOL_ORIGIN_MAP: voce senza '=': '${entry}'`);
7
+ map[entry.slice(0, eq).trim()] = entry.slice(eq + 1).trim();
8
+ }
9
+ return normalizeToolOriginMap(map);
10
+ }
11
+ export function normalizeToolOriginMap(map) {
12
+ const out = {};
13
+ for (const [from, to] of Object.entries(map)) {
14
+ let source;
15
+ let target;
16
+ try {
17
+ source = new URL(from);
18
+ target = new URL(to);
19
+ }
20
+ catch {
21
+ throw new Error(`mappa delle origini dei tool: indirizzo non valido in '${from}=${to}'`);
22
+ }
23
+ if (target.protocol !== "https:" && !(target.protocol === "http:" && isLocalHost(target.hostname))) {
24
+ throw new Error(`mappa delle origini dei tool: '${to}' non è https:// né un indirizzo locale — ` +
25
+ `il token del tool viaggerebbe in chiaro fuori dalla macchina`);
26
+ }
27
+ out[source.origin] = target.origin;
28
+ }
29
+ return out;
30
+ }
31
+ function isLocalHost(hostname) {
32
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
33
+ if (h === "localhost" || h === "::1")
34
+ return true;
35
+ if ([".localhost", ".internal", ".local"].some((suffix) => h.endsWith(suffix)))
36
+ return true;
37
+ if (!h.includes(".") && !h.includes(":"))
38
+ return true;
39
+ const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
40
+ if (v4) {
41
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
42
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
43
+ }
44
+ return /^f[cd][0-9a-f]{2}:/.test(h) || /^fe[89ab][0-9a-f]:/.test(h);
45
+ }
46
+ export function resolveToolEndpoint(endpoint, map) {
47
+ if (!map)
48
+ return endpoint;
49
+ let url;
50
+ try {
51
+ url = new URL(endpoint);
52
+ }
53
+ catch {
54
+ return endpoint;
55
+ }
56
+ const target = map[url.origin];
57
+ if (!target)
58
+ return endpoint;
59
+ return target + endpoint.slice(url.origin.length);
60
+ }
61
+ export function toolOriginMapFromEnv() {
62
+ const env = globalThis.process?.env;
63
+ const text = env?.FANTASTICO_TOOL_ORIGIN_MAP;
64
+ return text ? parseToolOriginMap(text) : undefined;
65
+ }