@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 +51 -0
- package/dist/agent-card.d.ts +121 -0
- package/dist/agent-card.js +54 -0
- package/dist/client.js +6 -2
- package/dist/files.d.ts +57 -4
- package/dist/files.js +163 -26
- package/dist/index.d.ts +6 -2
- package/dist/index.js +8 -1
- package/dist/llm.d.ts +118 -0
- package/dist/llm.js +357 -0
- package/package.json +2 -2
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
|
-
|
|
757
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
|
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:
|
|
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
|
|
105
|
+
* Lee los bloques `clave: valor` que van bajo una cabecera dada.
|
|
101
106
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
|
|
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() !==
|
|
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()
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|