@panal/sdk 0.10.1 → 0.12.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/README.md CHANGED
@@ -94,6 +94,57 @@ const uri = formatAgentMetadata({
94
94
  });
95
95
  ```
96
96
 
97
+ ### Archivos: en las dos direcciones
98
+
99
+ La cadena solo guarda un hash, así que ni un PDF ni una foto caben en ella. La salida fácil —entregar un enlace— es una trampa: el hash cubriría el enlace y no el archivo, y quien lo aloja podría cambiarlo después de cobrar. Lo que se hace es anclar el **hash de los bytes** dentro del texto, y así la cadena de custodia se cierra sin confiar en el servidor que sirve la descarga.
100
+
101
+ **El agente entrega archivos** (`[panal-files/1]` dentro del texto de la entrega):
102
+
103
+ ```ts
104
+ import { appendFilesManifest, downloadDeliveredFile, parseFilesManifest } from '@panal/sdk';
105
+
106
+ // Quien entrega: el manifiesto entra en el texto ANTES de anclarlo.
107
+ const texto = appendFilesManifest('Aquí tienes el informe.', archivos);
108
+
109
+ // Quien recibe: si los bytes no dan el hash anclado, la descarga lanza.
110
+ for (const f of parseFilesManifest(texto)) await downloadDeliveredFile(f, { baseUrl, address, signature, expira });
111
+ ```
112
+
113
+ **El cliente adjunta archivos** (`[panal-attach/1]` dentro del brief). El hash se calcula **antes de pagar** y viaja dentro del encargo, así que el `taskHash` del escrow lo cubre desde el primer momento:
114
+
115
+ ```ts
116
+ import { attachmentFrom, appendAttachmentsManifest, matchAttachment, parseAttachmentsManifest } from '@panal/sdk';
117
+
118
+ // Cliente, antes de contratar:
119
+ const foto = attachmentFrom('recibo.png', bytes, 'image/png');
120
+ const brief = appendAttachmentsManifest('Léeme este recibo.', [foto]); // esto es lo que se hashea
121
+
122
+ // Agente, al recibir una subida: lo que nadie anunció, no se escribe.
123
+ const anunciado = matchAttachment(parseAttachmentsManifest(brief), subidos, nombre);
124
+ if (!anunciado) throw new Error('esos bytes no se pagaron');
125
+ ```
126
+
127
+ Los bytes suben aparte, después de contratar. `stripFilesManifest` quita los dos bloques cuando el texto va a ojos de una persona.
128
+
129
+ ### El modelo, libre
130
+
131
+ Un agente cobra y entrega on-chain; qué modelo piensa por dentro es asunto suyo. No hay SDK de ningún proveedor: son tres formatos de red, y con esos tres se habla con todos.
132
+
133
+ ```ts
134
+ import { llmChat, resolverLlm } from '@panal/sdk';
135
+
136
+ const cfg = resolverLlm(process.env); // LLM_PROVIDER=claude|kimi|grok|glm|gemini|deepseek|groq|ollama…
137
+ const respuesta = await llmChat(cfg, {
138
+ system: 'Eres un agente de Panal.',
139
+ user: brief,
140
+ imagenes: [{ mime: 'image/png', bytes }], // lo que adjuntó el cliente
141
+ });
142
+ ```
143
+
144
+ El dialecto (`openai`, `anthropic`, `gemini`) se adivina por la URL; `LLM_DIALECT` lo fuerza si haces de puente con algo raro. Un proveedor que no esté en la lista no necesita tocar el SDK: se pone `LLM_BASE_URL` a pelo. Y los modelos sugeridos son una comodidad, no una promesa — `LLM_MODEL` manda siempre.
145
+
146
+ Ojo con una cosa: quien mira la foto es **el modelo**. Si el que configuraste no es multimodal, la llamada falla con lo que diga el proveedor.
147
+
97
148
  ## Contratar desde Claude
98
149
 
99
150
  Si prefieres hacerlo conversando en vez de programando, existe [`panal-mcp`](../mcp), un servidor MCP construido sobre este SDK.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * La ficha que un agente sirve en `GET /agent.json`.
3
+ *
4
+ * POR QUÉ ESTE ARCHIVO EXISTE
5
+ *
6
+ * Había dos formatos. El bot colocaba el cobro por llamada en
7
+ * `endpoints.x402Ask`; la plantilla lo ponía en la raíz, como `x402Ask`. Los
8
+ * dos servían la misma información y ningún lector veía las dos, porque cada
9
+ * uno parseaba con un tipo escrito a mano allí donde hacía falta.
10
+ *
11
+ * No fue un descuido de nadie: el tipo `AgentJson` vivía dentro del bot, así
12
+ * que la plantilla —que no depende del bot— no tenía de dónde copiarlo y
13
+ * escribió su propio objeto. Dos implementaciones honestas de una idea que
14
+ * nunca se escribió en un sitio común divergen solas.
15
+ *
16
+ * Aquí está esa idea, en el paquete del que ya dependen la plantilla y el MCP.
17
+ * Quien sirva una ficha, que la sirva con esta forma; quien la lea, que la lea
18
+ * con `leerX402` y `leerMaxBriefChars`, que entienden también la forma vieja.
19
+ *
20
+ * COMPATIBILIDAD
21
+ *
22
+ * Hay agentes desplegados sirviendo el formato antiguo, y no se les puede
23
+ * pedir que se actualicen para seguir siendo contratables. Los lectores
24
+ * aceptan las dos formas y lo seguirán haciendo: quien escribe se moderniza,
25
+ * quien lee perdona. Es la misma regla que ya seguía `agentAddress`.
26
+ */
27
+ import type { Address } from 'viem';
28
+ /** Cobro por llamada (x402), tal y como lo anuncia un agente. */
29
+ export interface FichaX402 {
30
+ method?: 'POST';
31
+ /** Ruta relativa. `url` gana si están las dos. */
32
+ path?: string;
33
+ url?: string;
34
+ scheme?: string;
35
+ /** Token ERC-20 en el que cobra. */
36
+ asset?: Address;
37
+ assetSymbol?: string;
38
+ /** Precio por llamada, en wei del token, como cadena decimal. */
39
+ amount?: string;
40
+ payTo?: Address;
41
+ howTo?: string;
42
+ }
43
+ /** Cómo mandarle el encargo a un agente, y cuánto texto acepta. */
44
+ export interface FichaPostBrief {
45
+ method?: 'POST';
46
+ path?: string;
47
+ signMessage?: string;
48
+ body?: string;
49
+ /**
50
+ * Tope de caracteres del encargo.
51
+ *
52
+ * Publicarlo es lo que evita que un cliente bloquee el pago con un encargo
53
+ * que el agente va a rechazar. Ausente significa NO LO DICE, que no es lo
54
+ * mismo que «no hay tope»: tratarlo como ilimitado es volver a averiguarlo
55
+ * pagando.
56
+ */
57
+ maxBriefChars?: number;
58
+ }
59
+ /** Cómo descargar el resultado ya entregado. */
60
+ export interface FichaGetResult {
61
+ method?: 'GET';
62
+ path?: string;
63
+ signMessage?: string;
64
+ }
65
+ /**
66
+ * La ficha completa. Casi todo es opcional a propósito: los dos motores tienen
67
+ * capacidades distintas —el de la plantilla no lee el registry al servirla, y
68
+ * el del bot sí— y un esquema que obligue a rellenar lo que no se sabe termina
69
+ * rellenándose con mentiras.
70
+ */
71
+ export interface AgentCard {
72
+ /** La dirección on-chain que este dominio declara suya. Es lo que verifica. */
73
+ agent?: Address;
74
+ /** Alias antiguo de `agent`. Se sigue leyendo; no lo escribas en fichas nuevas. */
75
+ agentAddress?: Address;
76
+ protocol?: 'panal';
77
+ network?: string;
78
+ chainId?: number;
79
+ name?: string;
80
+ description?: string;
81
+ skills?: string[];
82
+ price?: {
83
+ amountWei?: string;
84
+ currency?: Address;
85
+ symbol?: string;
86
+ } | null;
87
+ active?: boolean | null;
88
+ contracts?: {
89
+ escrow?: Address;
90
+ registry?: Address;
91
+ token?: Address;
92
+ };
93
+ endpoints?: {
94
+ base?: string | null;
95
+ postBrief?: FichaPostBrief;
96
+ getResult?: FichaGetResult;
97
+ x402Ask?: FichaX402;
98
+ indexer?: string | null;
99
+ };
100
+ /** Alias ANTIGUO de `endpoints.x402Ask`. Se lee; no se escribe. */
101
+ x402Ask?: FichaX402;
102
+ howToHire?: string[];
103
+ }
104
+ /** La dirección que la ficha declara suya, mirando también el alias viejo. */
105
+ export declare function leerDireccion(card: unknown): string | null;
106
+ /**
107
+ * El bloque de cobro por llamada, venga en el sitio nuevo o en el viejo.
108
+ *
109
+ * El sitio canónico gana si están los dos: un agente que sirva ambos está en
110
+ * mitad de una migración, y el nuevo es el que va a seguir manteniendo.
111
+ */
112
+ export declare function leerX402(card: unknown): FichaX402 | null;
113
+ /**
114
+ * El tope de caracteres del encargo, o `null` si la ficha no lo dice.
115
+ *
116
+ * `null` es NO LO SÉ y hay que tratarlo así. La ficha la sirve un desconocido,
117
+ * así que solo cuenta un entero positivo: un 0 o un negativo harían imposible
118
+ * cualquier encargo, y eso lo decide el agente bajando su tope, no mandando
119
+ * basura en un campo.
120
+ */
121
+ export declare function leerMaxBriefChars(card: unknown): number | null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * La ficha que un agente sirve en `GET /agent.json`.
3
+ *
4
+ * POR QUÉ ESTE ARCHIVO EXISTE
5
+ *
6
+ * Había dos formatos. El bot colocaba el cobro por llamada en
7
+ * `endpoints.x402Ask`; la plantilla lo ponía en la raíz, como `x402Ask`. Los
8
+ * dos servían la misma información y ningún lector veía las dos, porque cada
9
+ * uno parseaba con un tipo escrito a mano allí donde hacía falta.
10
+ *
11
+ * No fue un descuido de nadie: el tipo `AgentJson` vivía dentro del bot, así
12
+ * que la plantilla —que no depende del bot— no tenía de dónde copiarlo y
13
+ * escribió su propio objeto. Dos implementaciones honestas de una idea que
14
+ * nunca se escribió en un sitio común divergen solas.
15
+ *
16
+ * Aquí está esa idea, en el paquete del que ya dependen la plantilla y el MCP.
17
+ * Quien sirva una ficha, que la sirva con esta forma; quien la lea, que la lea
18
+ * con `leerX402` y `leerMaxBriefChars`, que entienden también la forma vieja.
19
+ *
20
+ * COMPATIBILIDAD
21
+ *
22
+ * Hay agentes desplegados sirviendo el formato antiguo, y no se les puede
23
+ * pedir que se actualicen para seguir siendo contratables. Los lectores
24
+ * aceptan las dos formas y lo seguirán haciendo: quien escribe se moderniza,
25
+ * quien lee perdona. Es la misma regla que ya seguía `agentAddress`.
26
+ */
27
+ /** La dirección que la ficha declara suya, mirando también el alias viejo. */
28
+ export function leerDireccion(card) {
29
+ const c = card;
30
+ const dir = typeof c?.agent === 'string' ? c.agent : typeof c?.agentAddress === 'string' ? c.agentAddress : '';
31
+ return dir || null;
32
+ }
33
+ /**
34
+ * El bloque de cobro por llamada, venga en el sitio nuevo o en el viejo.
35
+ *
36
+ * El sitio canónico gana si están los dos: un agente que sirva ambos está en
37
+ * mitad de una migración, y el nuevo es el que va a seguir manteniendo.
38
+ */
39
+ export function leerX402(card) {
40
+ const c = card;
41
+ return c?.endpoints?.x402Ask ?? c?.x402Ask ?? null;
42
+ }
43
+ /**
44
+ * El tope de caracteres del encargo, o `null` si la ficha no lo dice.
45
+ *
46
+ * `null` es NO LO SÉ y hay que tratarlo así. La ficha la sirve un desconocido,
47
+ * así que solo cuenta un entero positivo: un 0 o un negativo harían imposible
48
+ * cualquier encargo, y eso lo decide el agente bajando su tope, no mandando
49
+ * basura en un campo.
50
+ */
51
+ export function leerMaxBriefChars(card) {
52
+ const max = card?.endpoints?.postBrief?.maxBriefChars;
53
+ return typeof max === 'number' && Number.isInteger(max) && max > 0 ? max : null;
54
+ }
package/dist/client.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { createPublicClient, createWalletClient, formatEther, getAddress, http, keccak256, toBytes } from 'viem';
19
19
  import { erc20Abi, escrowAbi, namesAbi, registryAbi } from './abis.js';
20
+ import { leerX402 } from './agent-card.js';
20
21
  import { assertPublicUrl, fetchLimited } from './net.js';
21
22
  import { X402Error, payAndAsk, quoteAsk } from './x402.js';
22
23
  import { descend, newEnvelope, remainingBudget } from './envelope.js';
@@ -753,8 +754,11 @@ export class PanalClient {
753
754
  const url = await assertPublicUrl(new URL('/agent.json', base).toString(), options);
754
755
  const res = await fetchLimited(url, { timeoutMs: 10_000 });
755
756
  if (res.status === 200) {
756
- const card = JSON.parse(res.text);
757
- const anunciado = card.endpoints?.x402Ask;
757
+ // `leerX402` entiende las dos formas. Antes esto solo miraba
758
+ // `endpoints.x402Ask`, así que con la ficha de un agente de plantilla
759
+ // no encontraba nada y caía a la convención: funcionaba de casualidad,
760
+ // y solo mientras el agente escuchara justo en /x402/ask.
761
+ const anunciado = leerX402(JSON.parse(res.text));
758
762
  if (anunciado?.url)
759
763
  return anunciado.url;
760
764
  if (anunciado?.path)
package/dist/files.d.ts CHANGED
@@ -33,6 +33,11 @@
33
33
  * on-chain, no contra algo que venga en el texto. Así un agente no puede
34
34
  * mandar a su cliente a un tercero. `url` absoluta existe para quien aloja
35
35
  * fuera, y es igual de segura porque la garantía la da el hash, no el sitio.
36
+ *
37
+ * La segunda mitad del archivo hace lo mismo en la otra dirección: los
38
+ * adjuntos que el CLIENTE manda con su encargo —una foto, un PDF que hay que
39
+ * revisar— anunciados dentro del brief con `[panal-attach/1]`, para que el
40
+ * hash de la tarea los cubra desde el momento del pago.
36
41
  */
37
42
  import type { Hex } from 'viem';
38
43
  import { type UrlGuardOptions } from './net.js';
@@ -40,16 +45,26 @@ import { type UrlGuardOptions } from './net.js';
40
45
  export declare const FILES_BLOCK = "[panal-files/1]";
41
46
  /** Tope por defecto de una descarga: 25 MB. */
42
47
  export declare const MAX_FILE_BYTES: number;
43
- /** Un archivo anunciado en la entrega. */
44
- export interface DeliveredFile {
48
+ /**
49
+ * Lo que hace falta para reconocer unos bytes sin fiarse de quien los sirve.
50
+ *
51
+ * Es lo único que comparten las dos direcciones —el archivo que el agente
52
+ * entrega y la foto que el cliente adjunta—, y por eso `verifyFileBytes` pide
53
+ * esto y no un `DeliveredFile`: la comprobación es la misma en los dos
54
+ * sentidos, y el sitio de descarga no pinta nada en ella.
55
+ */
56
+ export interface HashedFile {
45
57
  /** Nombre del archivo, sin rutas. */
46
58
  name: string;
47
59
  /** Tamaño en bytes. Se comprueba junto al hash. */
48
60
  size: number;
49
- /** Tipo MIME, si el agente lo declaró. */
61
+ /** Tipo MIME, si quien lo mandó lo declaró. */
50
62
  mime?: string;
51
63
  /** keccak256 de los bytes. Es la garantía; todo lo demás es logística. */
52
64
  hash: Hex;
65
+ }
66
+ /** Un archivo anunciado en la entrega. */
67
+ export interface DeliveredFile extends HashedFile {
53
68
  /** Ruta relativa, a resolver contra el botUrl registrado del agente. */
54
69
  path?: string;
55
70
  /** URL absoluta, para quien aloja fuera de su propio servidor. */
@@ -93,7 +108,7 @@ export declare function parseFilesManifest(text: string): DeliveredFile[];
93
108
  */
94
109
  export declare function stripFilesManifest(text: string): string;
95
110
  /** Comprueba unos bytes contra lo que el manifiesto prometía. Lanza si no cuadra. */
96
- export declare function verifyFileBytes(file: DeliveredFile, bytes: Uint8Array): void;
111
+ export declare function verifyFileBytes(file: HashedFile, bytes: Uint8Array): void;
97
112
  /**
98
113
  * De dónde se baja un archivo.
99
114
  *
@@ -128,3 +143,41 @@ export interface DownloadOptions extends UrlGuardOptions {
128
143
  * que no cuadra "avisando" no serviría de nada: quien llama lo guardaría igual.
129
144
  */
130
145
  export declare function downloadDeliveredFile(file: DeliveredFile, options?: DownloadOptions): Promise<Uint8Array>;
146
+ /** Cabecera del bloque de adjuntos. Versionada: se ancla en la cadena. */
147
+ export declare const ATTACH_BLOCK = "[panal-attach/1]";
148
+ /** Un archivo que el cliente adjunta al encargo. */
149
+ export type AttachedFile = HashedFile;
150
+ /**
151
+ * Describe unos bytes para anunciarlos en el brief.
152
+ *
153
+ * El nombre se limpia aquí y no al recibirlo: lo que se anuncia tiene que ser
154
+ * lo mismo que luego se busca, y un nombre saneado a medias haría que el
155
+ * agente no reconociera su propio adjunto.
156
+ */
157
+ export declare function attachmentFrom(name: string, bytes: Uint8Array, mime?: string): AttachedFile;
158
+ /**
159
+ * Construye el bloque de adjuntos.
160
+ *
161
+ * Orden de claves fijo y `\n`, por lo mismo que en la entrega: este texto entra
162
+ * en el brief, y el brief se hashea. Un espacio de más y el agente rechaza el
163
+ * encargo con el pago ya bloqueado.
164
+ */
165
+ export declare function buildAttachmentsManifest(files: AttachedFile[]): string;
166
+ /** El encargo con los adjuntos anunciados al final. Esto es lo que se hashea. */
167
+ export declare function appendAttachmentsManifest(brief: string, files: AttachedFile[]): string;
168
+ /** Lee los adjuntos anunciados en un encargo. No lanza por un bloque roto. */
169
+ export declare function parseAttachmentsManifest(brief: string): AttachedFile[];
170
+ /**
171
+ * ¿Estos bytes son alguno de los adjuntos anunciados?
172
+ *
173
+ * Es la guarda del agente al recibir una subida: se busca por HASH, no por
174
+ * nombre, porque el nombre lo elige quien sube y el hash no. Devuelve el
175
+ * adjunto tal y como se anunció —con su nombre ya limpio— o `null`, y un
176
+ * `null` significa exactamente una cosa: esos bytes no se pagaron, no se
177
+ * escriben.
178
+ *
179
+ * `name` sólo desempata cuando el mismo archivo se adjuntó dos veces con
180
+ * nombres distintos; los bytes son los mismos, así que cualquiera valdría,
181
+ * pero devolver el que pidieron evita guardarlo con el nombre del otro.
182
+ */
183
+ export declare function matchAttachment(files: AttachedFile[], bytes: Uint8Array, name?: string): AttachedFile | null;
package/dist/files.js CHANGED
@@ -33,6 +33,11 @@
33
33
  * on-chain, no contra algo que venga en el texto. Así un agente no puede
34
34
  * mandar a su cliente a un tercero. `url` absoluta existe para quien aloja
35
35
  * fuera, y es igual de segura porque la garantía la da el hash, no el sitio.
36
+ *
37
+ * La segunda mitad del archivo hace lo mismo en la otra dirección: los
38
+ * adjuntos que el CLIENTE manda con su encargo —una foto, un PDF que hay que
39
+ * revisar— anunciados dentro del brief con `[panal-attach/1]`, para que el
40
+ * hash de la tarea los cubra desde el momento del pago.
36
41
  */
37
42
  import { keccak256 } from 'viem';
38
43
  import { assertPublicUrl, fetchBytesLimited } from './net.js';
@@ -97,49 +102,74 @@ export function appendFilesManifest(text, files) {
97
102
  return `${cuerpo}\n\n${buildFilesManifest(files)}\n`;
98
103
  }
99
104
  /**
100
- * Lee los archivos anunciados en el texto de una entrega.
105
+ * Lee los bloques `clave: valor` que van bajo una cabecera dada.
101
106
  *
102
- * Nunca lanza por un bloque mal formado: devuelve los que se entienden. Un
103
- * manifiesto roto no puede impedirle al cliente leer la parte escrita de su
104
- * entrega, que ya pagó.
107
+ * Lo comparten el manifiesto de entrega y el de adjuntos: los dos se anclan en
108
+ * la cadena y los dos tienen que leerse igual en todas partes. Un bloque
109
+ * termina en la primera línea vacía o que ya no es `clave: valor`, y eso basta
110
+ * para que dos manifiestos pegados no se contaminen: ninguna cabecera lleva
111
+ * dos puntos.
105
112
  */
106
- export function parseFilesManifest(text) {
113
+ function parseBloques(text, tag) {
107
114
  const out = [];
108
115
  const lineas = text.split(/\r?\n/);
109
116
  for (let i = 0; i < lineas.length; i++) {
110
- if (lineas[i].trim() !== FILES_BLOCK)
117
+ if (lineas[i].trim() !== tag)
111
118
  continue;
112
119
  const campos = {};
113
120
  for (let j = i + 1; j < lineas.length; j++) {
114
121
  const linea = lineas[j];
115
- if (!linea.trim() || linea.trim() === FILES_BLOCK)
122
+ if (!linea.trim())
116
123
  break;
117
124
  const sep = linea.indexOf(':');
118
125
  if (sep === -1)
119
126
  break;
120
127
  campos[linea.slice(0, sep).trim().toLowerCase()] = linea.slice(sep + 1).trim();
121
128
  }
122
- const { name, size, hash, mime, path, url } = campos;
123
- if (!name || !hash || !/^0x[0-9a-fA-F]{64}$/.test(hash))
124
- continue;
125
- const bytes = Number(size);
126
- if (!Number.isInteger(bytes) || bytes < 0)
129
+ out.push(campos);
130
+ }
131
+ return out;
132
+ }
133
+ /** Lo común a los dos manifiestos: un nombre limpio, un tamaño y un hash. */
134
+ function parseComun(campos) {
135
+ const { name, size, hash, mime } = campos;
136
+ if (!name || !hash || !/^0x[0-9a-fA-F]{64}$/.test(hash))
137
+ return null;
138
+ const bytes = Number(size);
139
+ if (!Number.isInteger(bytes) || bytes < 0)
140
+ return null;
141
+ try {
142
+ return {
143
+ name: sanitizeFileName(name),
144
+ size: bytes,
145
+ hash: hash.toLowerCase(),
146
+ ...(mime ? { mime } : {}),
147
+ };
148
+ }
149
+ catch {
150
+ // Nombre inservible: se descarta ese archivo, no el manifiesto entero.
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Lee los archivos anunciados en el texto de una entrega.
156
+ *
157
+ * Nunca lanza por un bloque mal formado: devuelve los que sí se entienden. Un
158
+ * manifiesto roto no puede impedirle al cliente leer la parte escrita de su
159
+ * entrega, que ya pagó.
160
+ */
161
+ export function parseFilesManifest(text) {
162
+ const out = [];
163
+ for (const campos of parseBloques(text, FILES_BLOCK)) {
164
+ const comun = parseComun(campos);
165
+ if (!comun)
127
166
  continue;
167
+ // Sin `path` ni `url` el archivo no se puede bajar de ningún sitio, y
168
+ // anunciarlo sólo serviría para prometer algo que no se puede cumplir.
169
+ const { path, url } = campos;
128
170
  if (!path && !url)
129
171
  continue;
130
- try {
131
- out.push({
132
- name: sanitizeFileName(name),
133
- size: bytes,
134
- hash: hash.toLowerCase(),
135
- ...(mime ? { mime } : {}),
136
- ...(path ? { path } : {}),
137
- ...(url ? { url } : {}),
138
- });
139
- }
140
- catch {
141
- // Nombre inservible: se descarta ese archivo, no la entrega entera.
142
- }
172
+ out.push({ ...comun, ...(path ? { path } : {}), ...(url ? { url } : {}) });
143
173
  }
144
174
  return out;
145
175
  }
@@ -154,7 +184,7 @@ export function stripFilesManifest(text) {
154
184
  const fuera = [];
155
185
  let dentro = false;
156
186
  for (const linea of lineas) {
157
- if (linea.trim() === FILES_BLOCK) {
187
+ if (linea.trim() === FILES_BLOCK || linea.trim() === ATTACH_BLOCK) {
158
188
  dentro = true;
159
189
  continue;
160
190
  }
@@ -233,3 +263,110 @@ export async function downloadDeliveredFile(file, options = {}) {
233
263
  verifyFileBytes(file, bytes);
234
264
  return bytes;
235
265
  }
266
+ // ---------------------------------------------------------------------------
267
+ // La otra dirección: lo que el CLIENTE adjunta a su encargo.
268
+ //
269
+ // El escrow ancla `keccak256(brief)` al contratar, y el agente rechaza el
270
+ // encargo si el texto que le llega no da exactamente ese hash. Eso deja el
271
+ // brief cerrado, que es justo lo que se quiere… y también significa que una
272
+ // foto no puede viajar dentro: no cabe en 32.000 caracteres, y meterla en
273
+ // base64 cambiaría el texto que ya se hasheó.
274
+ //
275
+ // Se hace lo mismo que en la entrega, en espejo. El navegador calcula el hash
276
+ // de la foto ANTES de pagar y lo anuncia dentro del brief; los bytes suben
277
+ // después, por su cuenta. La cadena de custodia queda cerrada igual:
278
+ //
279
+ // taskHash on-chain → texto del brief → hash del adjunto → bytes
280
+ //
281
+ // Y hay una propiedad que sale gratis y es la que de verdad importa para el
282
+ // agente: puede RECHAZAR cualquier byte que no estuviera anunciado. Nadie le
283
+ // deja archivos en el disco; sólo entran los que el cliente pagó por anunciar.
284
+ //
285
+ // No lleva `path` ni `url`, y no es un olvido: el cliente es un navegador y no
286
+ // aloja nada. Por eso es un bloque aparte y no un `[panal-files/1]` sin ruta —
287
+ // un manifiesto de entrega sin sitio de descarga es una promesa rota, y ahí
288
+ // conviene seguir rechazándolo.
289
+ // ---------------------------------------------------------------------------
290
+ /** Cabecera del bloque de adjuntos. Versionada: se ancla en la cadena. */
291
+ export const ATTACH_BLOCK = '[panal-attach/1]';
292
+ /**
293
+ * Describe unos bytes para anunciarlos en el brief.
294
+ *
295
+ * El nombre se limpia aquí y no al recibirlo: lo que se anuncia tiene que ser
296
+ * lo mismo que luego se busca, y un nombre saneado a medias haría que el
297
+ * agente no reconociera su propio adjunto.
298
+ */
299
+ export function attachmentFrom(name, bytes, mime) {
300
+ return {
301
+ name: sanitizeFileName(name),
302
+ size: bytes.byteLength,
303
+ hash: keccak256(bytes),
304
+ ...(mime ? { mime } : {}),
305
+ };
306
+ }
307
+ /**
308
+ * Construye el bloque de adjuntos.
309
+ *
310
+ * Orden de claves fijo y `\n`, por lo mismo que en la entrega: este texto entra
311
+ * en el brief, y el brief se hashea. Un espacio de más y el agente rechaza el
312
+ * encargo con el pago ya bloqueado.
313
+ */
314
+ export function buildAttachmentsManifest(files) {
315
+ return files
316
+ .map((f) => {
317
+ const lineas = [ATTACH_BLOCK, `name: ${sanitizeFileName(f.name)}`, `size: ${f.size}`];
318
+ if (f.mime)
319
+ lineas.push(`mime: ${f.mime}`);
320
+ lineas.push(`hash: ${f.hash}`);
321
+ return lineas.join('\n');
322
+ })
323
+ .join('\n\n');
324
+ }
325
+ /** El encargo con los adjuntos anunciados al final. Esto es lo que se hashea. */
326
+ export function appendAttachmentsManifest(brief, files) {
327
+ if (files.length === 0)
328
+ return brief;
329
+ return `${brief.trimEnd()}\n\n${buildAttachmentsManifest(files)}\n`;
330
+ }
331
+ /** Lee los adjuntos anunciados en un encargo. No lanza por un bloque roto. */
332
+ export function parseAttachmentsManifest(brief) {
333
+ const out = [];
334
+ for (const campos of parseBloques(brief, ATTACH_BLOCK)) {
335
+ const comun = parseComun(campos);
336
+ if (comun)
337
+ out.push(comun);
338
+ }
339
+ return out;
340
+ }
341
+ /**
342
+ * ¿Estos bytes son alguno de los adjuntos anunciados?
343
+ *
344
+ * Es la guarda del agente al recibir una subida: se busca por HASH, no por
345
+ * nombre, porque el nombre lo elige quien sube y el hash no. Devuelve el
346
+ * adjunto tal y como se anunció —con su nombre ya limpio— o `null`, y un
347
+ * `null` significa exactamente una cosa: esos bytes no se pagaron, no se
348
+ * escriben.
349
+ *
350
+ * `name` sólo desempata cuando el mismo archivo se adjuntó dos veces con
351
+ * nombres distintos; los bytes son los mismos, así que cualquiera valdría,
352
+ * pero devolver el que pidieron evita guardarlo con el nombre del otro.
353
+ */
354
+ export function matchAttachment(files, bytes, name) {
355
+ const real = keccak256(bytes).toLowerCase();
356
+ const iguales = files.filter((f) => f.hash.toLowerCase() === real && f.size === bytes.byteLength);
357
+ if (iguales.length === 0)
358
+ return null;
359
+ if (name) {
360
+ let limpio = null;
361
+ try {
362
+ limpio = sanitizeFileName(name);
363
+ }
364
+ catch {
365
+ limpio = null;
366
+ }
367
+ const exacto = limpio ? iguales.find((f) => f.name === limpio) : undefined;
368
+ if (exacto)
369
+ return exacto;
370
+ }
371
+ return iguales[0];
372
+ }
package/dist/index.d.ts CHANGED
@@ -29,10 +29,14 @@ export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
29
29
  export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
30
30
  export type { AskResult, PayAndAskOptions, PermitDomain, X402Accept, X402Quote } from './x402.js';
31
31
  export { assertPublicUrl, fetchBytesLimited, fetchLimited, isPrivateIp } from './net.js';
32
- export { FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appendFilesManifest, buildFilesManifest, downloadDeliveredFile, fileUrl, parseFilesManifest, sanitizeFileName, stripFilesManifest, verifyFileBytes, } from './files.js';
33
- export type { DeliveredFile, DownloadOptions } from './files.js';
32
+ export { ATTACH_BLOCK, FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appendAttachmentsManifest, appendFilesManifest, attachmentFrom, buildAttachmentsManifest, buildFilesManifest, downloadDeliveredFile, fileUrl, matchAttachment, parseAttachmentsManifest, parseFilesManifest, sanitizeFileName, stripFilesManifest, verifyFileBytes, } from './files.js';
33
+ export type { AttachedFile, DeliveredFile, DownloadOptions, HashedFile } from './files.js';
34
+ export { MAX_IMAGEN_BYTES, MIMES_IMAGEN, PROVEEDORES, LlmError, dialectoDe, esImagenSoportada, llmChat, resolverLlm, } from './llm.js';
35
+ export type { LlmConfig, LlmDialecto, LlmImagen, LlmPeticion, LlmProveedor } from './llm.js';
34
36
  export { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
35
37
  export type { SettleDeps, SettleResult, X402Payment, X402ServerAccept, X402ServerQuote, } from './x402-server.js';
36
38
  export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
37
39
  export type { CallEnvelope } from './envelope.js';
38
40
  export type { UrlGuardOptions } from './net.js';
41
+ export { leerDireccion, leerMaxBriefChars, leerX402 } from './agent-card.js';
42
+ export type { AgentCard, FichaGetResult, FichaPostBrief, FichaX402 } from './agent-card.js';
package/dist/index.js CHANGED
@@ -27,9 +27,16 @@ export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
27
27
  export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
28
28
  export { assertPublicUrl, fetchBytesLimited, fetchLimited, isPrivateIp } from './net.js';
29
29
  // Archivos: entregar un PDF o un vídeo anclando SU hash, no el del enlace.
30
- export { FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appendFilesManifest, buildFilesManifest, downloadDeliveredFile, fileUrl, parseFilesManifest, sanitizeFileName, stripFilesManifest, verifyFileBytes, } from './files.js';
30
+ // Y en la otra dirección, adjuntar una foto al encargo con la misma garantía.
31
+ export { ATTACH_BLOCK, FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appendAttachmentsManifest, appendFilesManifest, attachmentFrom, buildAttachmentsManifest, buildFilesManifest, downloadDeliveredFile, fileUrl, matchAttachment, parseAttachmentsManifest, parseFilesManifest, sanitizeFileName, stripFilesManifest, verifyFileBytes, } from './files.js';
32
+ // El modelo, libre: tres dialectos de red y con esos tres se habla con todos
33
+ // (Claude, Gemini, Kimi, Grok, GLM, DeepSeek, Groq, OpenAI, Ollama…).
34
+ export { MAX_IMAGEN_BYTES, MIMES_IMAGEN, PROVEEDORES, LlmError, dialectoDe, esImagenSoportada, llmChat, resolverLlm, } from './llm.js';
31
35
  // x402: la otra mitad, cobrar por llamada. Portada del bot de LexPanal, donde
32
36
  // lleva meses cobrando en produccion.
33
37
  export { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
34
38
  // El sobre que viaja entre agentes: profundidad, presupuesto y detección de ciclos.
35
39
  export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
40
+ // La ficha de GET /agent.json: un solo formato, y lectores que perdonan el
41
+ // antiguo. En agent-card.ts está por qué llegó a haber dos.
42
+ export { leerDireccion, leerMaxBriefChars, leerX402 } from './agent-card.js';
package/dist/llm.d.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Panal SDK — hablarle a un modelo sin casarse con quien lo sirve.
3
+ *
4
+ * Un agente de Panal cobra on-chain y entrega on-chain; qué modelo piensa por
5
+ * dentro es asunto suyo, y no debería costarle un cambio de código. Aquí no
6
+ * hay SDK de ningún proveedor: son tres formatos de red, y con esos tres se
7
+ * habla con todos.
8
+ *
9
+ * - `openai` · OpenAI, Kimi (Moonshot), Grok (xAI), GLM (Zhipu),
10
+ * DeepSeek, Groq, OpenRouter, Mistral, Together, Ollama…
11
+ * Es el formato que ha copiado casi todo el mundo.
12
+ * - `anthropic` · Claude. Cambia la ruta, la cabecera de la clave y dónde
13
+ * va el system; el resto es lo mismo.
14
+ * - `gemini` · Google. Cambia hasta cómo se llaman los campos.
15
+ *
16
+ * El dialecto se ADIVINA a partir de la URL, así que en el caso normal basta
17
+ * con poner el proveedor y la clave. Se puede forzar si haces de puente con
18
+ * algo raro.
19
+ *
20
+ * Las imágenes viajan en base64 dentro de la petición, en el formato que cada
21
+ * dialecto entiende. Eso es lo que permite que un cliente mande una foto y el
22
+ * agente la MIRE — pero ojo, quien la mira es el modelo: si el que has
23
+ * configurado no es multimodal, la llamada falla con lo que diga el
24
+ * proveedor, y eso es justo lo que su autor necesita leer.
25
+ *
26
+ * Sin dependencias a propósito, base64 incluido: este archivo lo carga todo
27
+ * agente que arranca, y no sale a cuenta arrastrar un paquete por 12 líneas.
28
+ */
29
+ /** Los tres formatos de red que sabemos hablar. */
30
+ export type LlmDialecto = 'openai' | 'anthropic' | 'gemini';
31
+ export interface LlmProveedor {
32
+ /** Base de la API, sin la ruta del método. */
33
+ baseUrl: string;
34
+ dialecto: LlmDialecto;
35
+ /**
36
+ * Un modelo que existía cuando se escribió esto.
37
+ *
38
+ * Es una comodidad, no una promesa: los nombres cambian cada pocos meses y
39
+ * ninguna lista dentro de un paquete sobrevive a eso. `LLM_MODEL` manda
40
+ * siempre, y es lo que se debe poner en producción.
41
+ */
42
+ modeloSugerido?: string;
43
+ }
44
+ /**
45
+ * Los proveedores conocidos, por nombre corto.
46
+ *
47
+ * Lo que fija cada entrada es la URL y el dialecto —lo estructural, lo que no
48
+ * cambia—. Añadir uno nuevo es una línea, y usar uno que no esté en la lista
49
+ * no requiere tocar este archivo: se pone `LLM_BASE_URL` a pelo.
50
+ */
51
+ export declare const PROVEEDORES: Record<string, LlmProveedor>;
52
+ /**
53
+ * Qué dialecto habla una URL.
54
+ *
55
+ * Se mira el host y no la ruta: un proxy corporativo cambia el camino, pero
56
+ * quien contesta al otro lado sigue siendo el mismo. Lo que no se reconoce se
57
+ * trata como OpenAI, que es lo que implementa casi todo el mundo — y si se
58
+ * falla, se falla del lado que tiene arreglo con `LLM_DIALECT`.
59
+ */
60
+ export declare function dialectoDe(baseUrl: string): LlmDialecto;
61
+ export interface LlmConfig {
62
+ baseUrl: string;
63
+ apiKey: string;
64
+ model: string;
65
+ dialecto: LlmDialecto;
66
+ /** Corta la llamada. Un modelo colgado deja la tarea colgada. */
67
+ timeoutMs?: number;
68
+ /** Reintentos ante 429 y 5xx. Los 4xx no se reintentan: son de configuración. */
69
+ maxRetries?: number;
70
+ maxTokens?: number;
71
+ temperature?: number;
72
+ }
73
+ /** Una imagen que se le enseña al modelo. */
74
+ export interface LlmImagen {
75
+ mime: string;
76
+ bytes: Uint8Array;
77
+ }
78
+ export interface LlmPeticion {
79
+ system?: string;
80
+ user: string;
81
+ /** Lo que el cliente adjuntó, ya filtrado a formatos que un modelo entiende. */
82
+ imagenes?: LlmImagen[];
83
+ }
84
+ export declare class LlmError extends Error {
85
+ /** De configuración: reintentarlo sólo gasta tiempo y dinero. */
86
+ readonly fatal: boolean;
87
+ constructor(message: string,
88
+ /** De configuración: reintentarlo sólo gasta tiempo y dinero. */
89
+ fatal?: boolean);
90
+ }
91
+ /**
92
+ * Lee la configuración del entorno.
93
+ *
94
+ * `LLM_PROVIDER` es el camino corto (`claude`, `kimi`, `gemini`…) y
95
+ * `LLM_BASE_URL` el largo, para cualquiera que no esté en la lista. Si están
96
+ * los dos, manda la URL: quien la escribe a mano sabe lo que quiere.
97
+ */
98
+ export declare function resolverLlm(env: Record<string, string | undefined>): LlmConfig;
99
+ /**
100
+ * Los formatos que aceptan los tres dialectos a la vez.
101
+ *
102
+ * Es la intersección y no la unión: un agente que acepta un TIFF porque su
103
+ * proveedor de hoy lo admite se rompe el día que cambie de proveedor, y se
104
+ * rompe con el cliente esperando y el pago bloqueado.
105
+ */
106
+ export declare const MIMES_IMAGEN: readonly ["image/png", "image/jpeg", "image/gif", "image/webp"];
107
+ /** Tope por imagen. Por encima, los proveedores empiezan a rechazar peticiones. */
108
+ export declare const MAX_IMAGEN_BYTES: number;
109
+ export declare function esImagenSoportada(mime: string | undefined): boolean;
110
+ /**
111
+ * Una pregunta, una respuesta, con reintentos.
112
+ *
113
+ * Se reintentan 429 y 5xx —el proveedor está saturado, es cuestión de
114
+ * esperar— y NO se reintenta un 4xx: una clave mal escrita o un modelo que no
115
+ * existe dan lo mismo al segundo intento, y mientras tanto el plazo de la
116
+ * tarea corre.
117
+ */
118
+ export declare function llmChat(cfg: LlmConfig, pet: LlmPeticion): Promise<string>;
package/dist/llm.js ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Panal SDK — hablarle a un modelo sin casarse con quien lo sirve.
3
+ *
4
+ * Un agente de Panal cobra on-chain y entrega on-chain; qué modelo piensa por
5
+ * dentro es asunto suyo, y no debería costarle un cambio de código. Aquí no
6
+ * hay SDK de ningún proveedor: son tres formatos de red, y con esos tres se
7
+ * habla con todos.
8
+ *
9
+ * - `openai` · OpenAI, Kimi (Moonshot), Grok (xAI), GLM (Zhipu),
10
+ * DeepSeek, Groq, OpenRouter, Mistral, Together, Ollama…
11
+ * Es el formato que ha copiado casi todo el mundo.
12
+ * - `anthropic` · Claude. Cambia la ruta, la cabecera de la clave y dónde
13
+ * va el system; el resto es lo mismo.
14
+ * - `gemini` · Google. Cambia hasta cómo se llaman los campos.
15
+ *
16
+ * El dialecto se ADIVINA a partir de la URL, así que en el caso normal basta
17
+ * con poner el proveedor y la clave. Se puede forzar si haces de puente con
18
+ * algo raro.
19
+ *
20
+ * Las imágenes viajan en base64 dentro de la petición, en el formato que cada
21
+ * dialecto entiende. Eso es lo que permite que un cliente mande una foto y el
22
+ * agente la MIRE — pero ojo, quien la mira es el modelo: si el que has
23
+ * configurado no es multimodal, la llamada falla con lo que diga el
24
+ * proveedor, y eso es justo lo que su autor necesita leer.
25
+ *
26
+ * Sin dependencias a propósito, base64 incluido: este archivo lo carga todo
27
+ * agente que arranca, y no sale a cuenta arrastrar un paquete por 12 líneas.
28
+ */
29
+ /**
30
+ * Los proveedores conocidos, por nombre corto.
31
+ *
32
+ * Lo que fija cada entrada es la URL y el dialecto —lo estructural, lo que no
33
+ * cambia—. Añadir uno nuevo es una línea, y usar uno que no esté en la lista
34
+ * no requiere tocar este archivo: se pone `LLM_BASE_URL` a pelo.
35
+ */
36
+ export const PROVEEDORES = {
37
+ openai: { baseUrl: 'https://api.openai.com/v1', dialecto: 'openai', modeloSugerido: 'gpt-4o-mini' },
38
+ claude: { baseUrl: 'https://api.anthropic.com', dialecto: 'anthropic', modeloSugerido: 'claude-opus-5' },
39
+ anthropic: { baseUrl: 'https://api.anthropic.com', dialecto: 'anthropic', modeloSugerido: 'claude-opus-5' },
40
+ gemini: {
41
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
42
+ dialecto: 'gemini',
43
+ modeloSugerido: 'gemini-2.0-flash',
44
+ },
45
+ kimi: { baseUrl: 'https://api.moonshot.ai/v1', dialecto: 'openai', modeloSugerido: 'moonshot-v1-8k' },
46
+ moonshot: { baseUrl: 'https://api.moonshot.ai/v1', dialecto: 'openai', modeloSugerido: 'moonshot-v1-8k' },
47
+ grok: { baseUrl: 'https://api.x.ai/v1', dialecto: 'openai', modeloSugerido: 'grok-2-vision-1212' },
48
+ xai: { baseUrl: 'https://api.x.ai/v1', dialecto: 'openai', modeloSugerido: 'grok-2-vision-1212' },
49
+ glm: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', dialecto: 'openai', modeloSugerido: 'glm-4v' },
50
+ zhipu: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', dialecto: 'openai', modeloSugerido: 'glm-4v' },
51
+ deepseek: { baseUrl: 'https://api.deepseek.com/v1', dialecto: 'openai', modeloSugerido: 'deepseek-chat' },
52
+ groq: {
53
+ baseUrl: 'https://api.groq.com/openai/v1',
54
+ dialecto: 'openai',
55
+ modeloSugerido: 'llama-3.3-70b-versatile',
56
+ },
57
+ openrouter: { baseUrl: 'https://openrouter.ai/api/v1', dialecto: 'openai' },
58
+ mistral: { baseUrl: 'https://api.mistral.ai/v1', dialecto: 'openai', modeloSugerido: 'mistral-small-latest' },
59
+ together: { baseUrl: 'https://api.together.xyz/v1', dialecto: 'openai' },
60
+ // Local, sin clave y sin factura. Útil para probar un agente sin gastar.
61
+ ollama: { baseUrl: 'http://localhost:11434/v1', dialecto: 'openai', modeloSugerido: 'llama3.2' },
62
+ };
63
+ /**
64
+ * Qué dialecto habla una URL.
65
+ *
66
+ * Se mira el host y no la ruta: un proxy corporativo cambia el camino, pero
67
+ * quien contesta al otro lado sigue siendo el mismo. Lo que no se reconoce se
68
+ * trata como OpenAI, que es lo que implementa casi todo el mundo — y si se
69
+ * falla, se falla del lado que tiene arreglo con `LLM_DIALECT`.
70
+ */
71
+ export function dialectoDe(baseUrl) {
72
+ let host;
73
+ try {
74
+ host = new URL(baseUrl).hostname.toLowerCase();
75
+ }
76
+ catch {
77
+ return 'openai';
78
+ }
79
+ if (host.endsWith('anthropic.com'))
80
+ return 'anthropic';
81
+ if (host.endsWith('googleapis.com'))
82
+ return 'gemini';
83
+ return 'openai';
84
+ }
85
+ export class LlmError extends Error {
86
+ fatal;
87
+ constructor(message,
88
+ /** De configuración: reintentarlo sólo gasta tiempo y dinero. */
89
+ fatal = false) {
90
+ super(message);
91
+ this.fatal = fatal;
92
+ this.name = 'LlmError';
93
+ }
94
+ }
95
+ /**
96
+ * Lee la configuración del entorno.
97
+ *
98
+ * `LLM_PROVIDER` es el camino corto (`claude`, `kimi`, `gemini`…) y
99
+ * `LLM_BASE_URL` el largo, para cualquiera que no esté en la lista. Si están
100
+ * los dos, manda la URL: quien la escribe a mano sabe lo que quiere.
101
+ */
102
+ export function resolverLlm(env) {
103
+ const nombre = env.LLM_PROVIDER?.trim().toLowerCase();
104
+ const preset = nombre ? PROVEEDORES[nombre] : undefined;
105
+ if (nombre && !preset && !env.LLM_BASE_URL?.trim()) {
106
+ throw new LlmError(`LLM_PROVIDER="${nombre}" no está en la lista (${Object.keys(PROVEEDORES).join(', ')}). ` +
107
+ `Si tu proveedor no está, pon LLM_BASE_URL con su endpoint y ya.`, true);
108
+ }
109
+ const baseUrl = (env.LLM_BASE_URL?.trim() || preset?.baseUrl || '').replace(/\/$/, '');
110
+ if (!baseUrl) {
111
+ throw new LlmError('Falta LLM_PROVIDER o LLM_BASE_URL: el agente no sabe a quién preguntarle.', true);
112
+ }
113
+ const model = env.LLM_MODEL?.trim() || preset?.modeloSugerido;
114
+ if (!model) {
115
+ throw new LlmError(`Falta LLM_MODEL: ${baseUrl} no tiene un modelo por defecto que se pueda suponer.`, true);
116
+ }
117
+ const forzado = env.LLM_DIALECT?.trim().toLowerCase();
118
+ if (forzado && forzado !== 'openai' && forzado !== 'anthropic' && forzado !== 'gemini') {
119
+ throw new LlmError(`LLM_DIALECT="${forzado}" no existe. Es openai, anthropic o gemini.`, true);
120
+ }
121
+ return {
122
+ baseUrl,
123
+ apiKey: env.LLM_API_KEY?.trim() ?? '',
124
+ model,
125
+ dialecto: forzado ?? preset?.dialecto ?? dialectoDe(baseUrl),
126
+ ...(env.LLM_TIMEOUT_MS ? { timeoutMs: Number(env.LLM_TIMEOUT_MS) } : {}),
127
+ ...(env.LLM_MAX_TOKENS ? { maxTokens: Number(env.LLM_MAX_TOKENS) } : {}),
128
+ };
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Imágenes
132
+ // ---------------------------------------------------------------------------
133
+ /**
134
+ * Los formatos que aceptan los tres dialectos a la vez.
135
+ *
136
+ * Es la intersección y no la unión: un agente que acepta un TIFF porque su
137
+ * proveedor de hoy lo admite se rompe el día que cambie de proveedor, y se
138
+ * rompe con el cliente esperando y el pago bloqueado.
139
+ */
140
+ export const MIMES_IMAGEN = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
141
+ /** Tope por imagen. Por encima, los proveedores empiezan a rechazar peticiones. */
142
+ export const MAX_IMAGEN_BYTES = 5 * 1024 * 1024;
143
+ export function esImagenSoportada(mime) {
144
+ return !!mime && MIMES_IMAGEN.includes(mime.toLowerCase());
145
+ }
146
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
147
+ /** base64 a mano: ni Buffer (Node) ni btoa (navegador), así vale en los dos. */
148
+ function aBase64(bytes) {
149
+ let out = '';
150
+ for (let i = 0; i < bytes.length; i += 3) {
151
+ const a = bytes[i];
152
+ const b = bytes[i + 1];
153
+ const c = bytes[i + 2];
154
+ const trio = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
155
+ out += B64[(trio >> 18) & 63] + B64[(trio >> 12) & 63];
156
+ out += b === undefined ? '=' : B64[(trio >> 6) & 63];
157
+ out += c === undefined ? '=' : B64[trio & 63];
158
+ }
159
+ return out;
160
+ }
161
+ function prepararImagenes(imagenes) {
162
+ return imagenes.map((img) => {
163
+ const mime = img.mime.toLowerCase();
164
+ if (!esImagenSoportada(mime)) {
165
+ throw new LlmError(`No se puede enseñar un ${img.mime} a un modelo. Sólo ${MIMES_IMAGEN.join(', ')}.`, true);
166
+ }
167
+ if (img.bytes.byteLength > MAX_IMAGEN_BYTES) {
168
+ throw new LlmError(`Una imagen de ${img.bytes.byteLength} bytes pasa del tope de ${MAX_IMAGEN_BYTES}. Redúcela antes de mandarla.`, true);
169
+ }
170
+ return { mime, b64: aBase64(img.bytes) };
171
+ });
172
+ }
173
+ function construir(cfg, pet) {
174
+ const imgs = prepararImagenes(pet.imagenes ?? []);
175
+ const maxTokens = cfg.maxTokens ?? 4096;
176
+ const base = cfg.baseUrl.replace(/\/$/, '');
177
+ if (cfg.dialecto === 'anthropic') {
178
+ // La base puede venir con o sin /v1 según de dónde la haya copiado quien
179
+ // configura; las dos formas circulan en la documentación de todo el mundo.
180
+ const url = /\/v1$/.test(base) ? `${base}/messages` : `${base}/v1/messages`;
181
+ return {
182
+ url,
183
+ headers: {
184
+ 'content-type': 'application/json',
185
+ 'x-api-key': cfg.apiKey,
186
+ 'anthropic-version': '2023-06-01',
187
+ },
188
+ body: {
189
+ model: cfg.model,
190
+ max_tokens: maxTokens,
191
+ ...(pet.system ? { system: pet.system } : {}),
192
+ messages: [
193
+ {
194
+ role: 'user',
195
+ content: [
196
+ ...imgs.map((i) => ({
197
+ type: 'image',
198
+ source: { type: 'base64', media_type: i.mime, data: i.b64 },
199
+ })),
200
+ { type: 'text', text: pet.user },
201
+ ],
202
+ },
203
+ ],
204
+ },
205
+ };
206
+ }
207
+ if (cfg.dialecto === 'gemini') {
208
+ return {
209
+ url: `${base}/models/${encodeURIComponent(cfg.model)}:generateContent`,
210
+ // La clave va en cabecera y no en la query: una URL acaba en los logs
211
+ // del proxy, en el historial y en el `Referer`, y una clave ahí es una
212
+ // clave regalada.
213
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': cfg.apiKey },
214
+ body: {
215
+ ...(pet.system ? { systemInstruction: { parts: [{ text: pet.system }] } } : {}),
216
+ contents: [
217
+ {
218
+ role: 'user',
219
+ parts: [
220
+ ...imgs.map((i) => ({ inline_data: { mime_type: i.mime, data: i.b64 } })),
221
+ { text: pet.user },
222
+ ],
223
+ },
224
+ ],
225
+ generationConfig: {
226
+ maxOutputTokens: maxTokens,
227
+ ...(cfg.temperature === undefined ? {} : { temperature: cfg.temperature }),
228
+ },
229
+ },
230
+ };
231
+ }
232
+ // openai. El contenido va como STRING cuando no hay imágenes: la forma de
233
+ // array es válida en la especificación, pero unos cuantos clones compatibles
234
+ // la rechazan, y no hay motivo para arriesgarse en el caso normal.
235
+ return {
236
+ url: `${base}/chat/completions`,
237
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
238
+ body: {
239
+ model: cfg.model,
240
+ max_tokens: maxTokens,
241
+ ...(cfg.temperature === undefined ? {} : { temperature: cfg.temperature }),
242
+ messages: [
243
+ ...(pet.system ? [{ role: 'system', content: pet.system }] : []),
244
+ {
245
+ role: 'user',
246
+ content: imgs.length
247
+ ? [
248
+ { type: 'text', text: pet.user },
249
+ ...imgs.map((i) => ({
250
+ type: 'image_url',
251
+ image_url: { url: `data:${i.mime};base64,${i.b64}` },
252
+ })),
253
+ ]
254
+ : pet.user,
255
+ },
256
+ ],
257
+ },
258
+ };
259
+ }
260
+ /**
261
+ * Baja por una ruta de un JSON ajeno sin fiarse de ningún tramo.
262
+ *
263
+ * Lo que contesta el proveedor no lo controlamos, y un `choices[0].message`
264
+ * que hoy existe puede llegar mañana como `null` en mitad de una incidencia
265
+ * suya. Devolver `undefined` en vez de reventar deja que el error que se
266
+ * enseñe sea el de arriba, que sí explica lo que pasó.
267
+ */
268
+ function campo(raiz, ...ruta) {
269
+ let actual = raiz;
270
+ for (const paso of ruta) {
271
+ if (actual === null || typeof actual !== 'object')
272
+ return undefined;
273
+ actual = actual[paso];
274
+ }
275
+ return actual;
276
+ }
277
+ function texto(valor) {
278
+ return typeof valor === 'string' ? valor : '';
279
+ }
280
+ /** Junta el texto de una lista de bloques, saltándose lo que no lo sea. */
281
+ function juntarBloques(bloques, clave, tipo) {
282
+ if (!Array.isArray(bloques))
283
+ return '';
284
+ return bloques
285
+ .map((b) => (tipo && campo(b, 'type') !== tipo ? '' : texto(campo(b, clave))))
286
+ .join('')
287
+ .trim();
288
+ }
289
+ function leer(dialecto, json) {
290
+ const error = texto(campo(json, 'error', 'message'));
291
+ if (dialecto === 'anthropic') {
292
+ const salida = juntarBloques(campo(json, 'content'), 'text', 'text');
293
+ if (salida)
294
+ return salida;
295
+ if (campo(json, 'stop_reason') === 'refusal') {
296
+ throw new LlmError('El modelo se negó a responder a este encargo.', true);
297
+ }
298
+ throw new LlmError(`Respuesta sin texto: ${error || texto(campo(json, 'stop_reason')) || 'vacía'}`);
299
+ }
300
+ if (dialecto === 'gemini') {
301
+ const salida = juntarBloques(campo(json, 'candidates', 0, 'content', 'parts'), 'text');
302
+ if (salida)
303
+ return salida;
304
+ const motivo = texto(campo(json, 'candidates', 0, 'finishReason')) || texto(campo(json, 'promptFeedback', 'blockReason'));
305
+ throw new LlmError(`Respuesta sin texto: ${error || motivo || 'sin candidatos'}`);
306
+ }
307
+ const salida = texto(campo(json, 'choices', 0, 'message', 'content')).trim();
308
+ if (!salida)
309
+ throw new LlmError(`Respuesta sin texto: ${error || 'choices vacío'}`);
310
+ return salida;
311
+ }
312
+ function dormir(ms) {
313
+ return new Promise((r) => setTimeout(r, ms));
314
+ }
315
+ /**
316
+ * Una pregunta, una respuesta, con reintentos.
317
+ *
318
+ * Se reintentan 429 y 5xx —el proveedor está saturado, es cuestión de
319
+ * esperar— y NO se reintenta un 4xx: una clave mal escrita o un modelo que no
320
+ * existe dan lo mismo al segundo intento, y mientras tanto el plazo de la
321
+ * tarea corre.
322
+ */
323
+ export async function llmChat(cfg, pet) {
324
+ if (!cfg.apiKey && !/^https?:\/\/(localhost|127\.0\.0\.1)/.test(cfg.baseUrl)) {
325
+ throw new LlmError('Falta LLM_API_KEY.', true);
326
+ }
327
+ const { url, headers, body } = construir(cfg, pet);
328
+ const maxRetries = cfg.maxRetries ?? 2;
329
+ const timeoutMs = cfg.timeoutMs ?? 120_000;
330
+ let ultimo;
331
+ for (let intento = 0; intento <= maxRetries; intento++) {
332
+ try {
333
+ const res = await fetch(url, {
334
+ method: 'POST',
335
+ headers,
336
+ body: JSON.stringify(body),
337
+ signal: AbortSignal.timeout(timeoutMs),
338
+ });
339
+ if (res.status === 429 || res.status >= 500) {
340
+ throw new LlmError(`HTTP ${res.status} ${res.statusText}`);
341
+ }
342
+ if (!res.ok) {
343
+ const cuerpo = await res.text().catch(() => '');
344
+ throw new LlmError(`HTTP ${res.status}: ${cuerpo.slice(0, 300)}`, true);
345
+ }
346
+ return leer(cfg.dialecto, await res.json());
347
+ }
348
+ catch (err) {
349
+ ultimo = err;
350
+ if (err instanceof LlmError && err.fatal)
351
+ throw err;
352
+ if (intento < maxRetries)
353
+ await dormir(Math.min(2_000 * 2 ** intento, 30_000));
354
+ }
355
+ }
356
+ throw ultimo instanceof Error ? ultimo : new LlmError(String(ultimo));
357
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panal/sdk",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "SDK de Panal: contrata agentes de IA autonomos on-chain en Monad",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.json",
47
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
48
- "test": "tsx test/sdk.test.ts && tsx test/x402.test.ts && tsx test/x402-server.test.ts && tsx test/envelope.test.ts && tsx test/files.test.ts && tsx test/skill.test.ts",
48
+ "test": "tsx test/sdk.test.ts && tsx test/x402.test.ts && tsx test/x402-server.test.ts && tsx test/envelope.test.ts && tsx test/files.test.ts && tsx test/attachments.test.ts && tsx test/llm.test.ts && tsx test/skill.test.ts && tsx test/agent-card.test.ts",
49
49
  "test:nombres": "tsx test/nombres.test.ts"
50
50
  }
51
51
  }