Documentazione dell'API
Tutto quello che serve per integrare AgileV: endpoint, parametri, errori, limiti ed esempi funzionanti. Se qualcosa non è chiaro o non si comporta come scritto qui, scrivicelo: è un difetto della documentazione.
Basi e autenticazione
Base URL: https://api.agilev.agile.software
Ogni richiesta autenticata porta la chiave in un'intestazione. Sono equivalenti:
Authorization: Bearer agv_…
X-Api-Key: agv_…
Ogni chiave ha ambito (embed, rerank), limite al minuto e quota mensile di caratteri. Vedi i tuoi consumi con GET /v1/usage: una chiave vede solo i propri.
E la chiave da dove arriva. Si chiede una licenza — il modulo è sulla pagina di AgileV, e i tre piani con quota e limite al minuto stanno in Piani — e le chiavi stanno sotto la licenza: si ruota una chiave senza toccare il contratto, e si sospende un contratto senza cambiare la chiave. Chi vuole chiamare il modulo da un programma trova la rotta in POST /v1/richieste-licenza.
Una chiave può essere di una persona digitale, e allora ogni richiesta dice a nome di chi calcola (dall'11/10/2026). È una cosa in più, non un cambio: le chiavi di oggi non sono di nessuna persona e non devono fare niente — stesso corpo, stessi vettori, stessa cache, stessa fattura. Quando invece la chiave viene emessa per una persona (il caso delle persone digitali dello store), quella persona la mette il motore su ogni richiesta: non dovete scrivere niente nel corpo, e il campo persona lo potete mandare solo per dire la sua — con un nome diverso dal suo è 403 PERSONA_UNKNOWN (dal 12/10/2026: fino all'11 il campo era obbligatorio e senza era 400 PERSONA_MISSING). E al contrario: una chiave che non è di nessuna persona che manda quel campo riceve 403 PERSONA_UNKNOWN invece di un 200, perché calcolare, mettere in cache e fatturare a nome di un soggetto che nessuno ha dichiarato è la cosa che questa regola esiste per impedire.
Endpoint
GET/health — stato del servizio
Senza chiave. loaded dice se i modelli sono in memoria; subito dopo un riavvio può essere false per una ventina di secondi. contract è la versione del contratto in esecuzione, novita dove leggere cosa è cambiato: confrontateli con l'ultima lettura per sapere se c'è qualcosa da valutare.
/health risponde anche quando il servizio è pieno di lavoro, ed è una promessa nuova del contratto 1.2 (29/9): questa rotta non passa dalla fila in cui stanno le richieste di calcolo, quindi vale come controllo di vita anche sotto carico. Prima non era vero, e non per poco: con 48 richieste di calcolo insieme /health non rispondeva affatto per decine di secondi (misurato sul servizio 1.1: sonde da 10 s tutte scadute, 20 s di silenzio di fila nel caso peggiore, in 5 giri su 5), quindi chi lo usava per vedere se il servizio era vivo leggeva «morto» su un servizio che stava lavorando. Se avete una vigilanza che chiama /health, dal 1.2 potete crederle; e se vi risponde ok:true mentre le vostre richieste sono lente, non è un guasto: è la fila, e la fila si legge nei tempi, non nella salute.
{"ok":true,"loaded":true,"model":"bge-m3","service":"agilev","dim":1024,
"contract":"1.2","novita":"https://agilev.agile.software/novita.html",
"coda":{"stato":"libera","posti":40,"occupati":0,"in_attesa":0}}
coda dice se il servizio è in fila, e ok non cambia mai per questo (29/9). Le richieste di calcolo occupano un posto in una fila da 40: un posto è preso da una richiesta che sta lavorando o che aspetta il suo turno sul modello. Quattro nomi: libera (sotto l'80 % dei posti), carica (oltre, ma un posto c'è), satura (nessun posto libero, o c'è già qualcuno in attesa: chi arriva aspetta), indecidibile (non siamo riusciti a leggere il contatore: vuol dire non lo sappiamo, non va tutto bene). Accanto al nome ci sono i numeri — posti, occupati, in_attesa — così il conto lo potete rifare voi. Misurato con 48 richieste insieme: 95 letture su 95 portano il campo, occupati 40, in_attesa 8; a servizio libero libera con 0 occupati.
Come leggerlo, e come non leggerlo: satura non è un guasto — ok resta true e il codice resta 200, per scelta, quindi se una vigilanza o un grep decide «vivo/morto» da ok continuate a leggere quello: un servizio in fila non va riavviato. Serve a leggere i vostri tempi (con la fila satura il tempo che misurate è attesa, non calcolo); il freno vero resta il 429 con Retry-After. È un'istantanea, non una media. E un campo assente non è libera: un servizio più vecchio di questo contratto non lo manda affatto, e allora il vostro controllo deve dire «non lo so».
GET/v1/models — modelli disponibili
Senza chiave. Elenca solo i modelli realmente disponibili su questa istanza.
{"object":"list","data":[
{"id":"agilev-m3","object":"model","owned_by":"agile","dim":1024,
"output_dtypes":["float","int8","uint8","binary","ubinary"],"max_tokens":8192,"sparse":true},
{"id":"agilev-rerank","object":"model","owned_by":"agile"}]}
sparse: true dice che il modello sa restituire anche i pesi lessicali (return_sparse, contratto 1.2).
Il campo model — cosa fa oggi (e cosa cambierà, con preavviso)
Oggi nessun nome di modello viene rifiutato: il campo è registrato, non validato. Misurato il 28/9 su 36 casi (4 rotte × 9 nomi), sul codice 1.1 in produzione e sulla 1.2, con esito identico sulle due: zero 400. Il deploy della 1.2 non cambia una riga di questa tabella.
| Nome inviato | /v1/embeddings, /v1/tokenize | /v1/rerank | /embed |
|---|---|---|---|
agilev-m3 | riconosciuto | servito, contato come sconosciuto | ignorato |
agilev-rerank | servito, contato come sconosciuto | riconosciuto | ignorato |
bge-m3 | riconosciuto (alias) | servito, contato come sconosciuto | ignorato |
agilev-m3-fast | riconosciuto, ma oggi 503 MODEL_UNAVAILABLE su /v1/embeddings (su /v1/tokenize risponde 200: i token si contano col tokenizer, uguale per le due varianti) | servito, contato come sconosciuto | ignorato |
qualunque altro (voyage-3, modello-che-non-esiste…) | servito, contato come sconosciuto | servito, contato come sconosciuto | ignorato |
"", null, campo assente | riconosciuto: vale il valore di default | riconosciuto: vale il valore di default | ignorato |
"", null e il campo assente valgono come «default» e non vengono contati da nessuna parte. Se il nome del modello vi arriva da una variabile d'ambiente e quella variabile è vuota, oggi non ve ne accorgete: il controllo che regge è il model della risposta, che dichiara sempre il modello usato — /v1/rerank risponde sempre "model":"agilev-rerank", /v1/embeddings "model":"agilev-m3", /embed "model":"bge-m3". Confrontate quello, non quello che avete spedito./embed non guarda il campo affatto: il formato storico ha solo texts e un model nel corpo viene scartato senza nemmeno essere contato. Una richiesta con un nome sconosciuto viene comunque servita e contata in fattura: la validazione è di forma, non di sostanza.
Come sapere se vi riguarda, senza chiedercelo (dal 29/9/2026): GET /v1/usage porta il campo model_unknown — quante volte la vostra chiave ha mandato un nome che non riconosciamo, per mese e per rotta. È gratis e si può guardare da un controllo automatico. Vuota è la risposta normale.
Quando diventerà 400 MODEL_UNKNOWN. Non presto: il 29/9 alle 03:2x, letto dai contatori di produzione, 4 delle 7 richieste di rerank riuscite dal riavvio del 27/9 portavano un nome non riconosciuto (il 28/9 erano 3 su 6). Prima di cambiare succederanno, in quest'ordine, tutte e quattro queste cose: (1) il contatore dei nomi sconosciuti resta a zero in modo stabile — e fino al 29/9 questa frase non era nemmeno misurabile, perché il contatore vive in memoria e ripartiva da zero a ogni riavvio: «stabilmente a zero» si otteneva riavviando. Ora il conto per chiave è sul disco; (2) il contatore diventa attribuibile — sapere che qualcuno sbaglia senza sapere chi avvisare non basta. Fatto il 29/9/2026: il servizio registra, per chiave e per rotta, quante richieste hanno portato un nome che non riconosce (solo numeri, mai il nome ricevuto, che è testo vostro); (3) la voce compare in Novità e arriva un messaggio diretto a ogni titolare di chiave, con almeno 30 giorni fra l'avviso e il cambio; (4) il cambio va in un deploy da solo, mai insieme ad altro.
POST/v1/embeddings — vettori (compatibile OpenAI)
Ambito embed. I vettori sono normalizzati: il prodotto scalare è già il coseno.
| Campo | Tipo | Default | Descrizione |
|---|---|---|---|
input | stringa o lista di stringhe | — | il testo o i testi da trasformare. Obbligatorio. |
model | stringa | agilev-m3 | modello da usare. |
output_dtype | stringa | float | float, int8, uint8, binary, ubinary. Vedi formati. |
encoding_format | stringa | float | float (lista di numeri) oppure base64 dei byte grezzi: payload molto più piccolo. |
truncation | booleano | true | con true un testo oltre 8192 token viene tagliato; con false la richiesta è rifiutata con 413. |
input_type | stringa | assente | query o document. Accettato per compatibilità: da noi non cambia il risultato, vedi la nota qui sotto. |
return_sparse | booleano | false | dal contratto 1.2. Con true ogni elemento di data ha in più sparse_embedding (indices, values): i pesi lessicali di bge-m3 per la ricerca ibrida. Solo agilev-m3. Vedi pesi lessicali. |
persona | stringa | assente | dall'11/10/2026. A nome di chi si calcola. Non è obbligatorio per nessuno (dal 12/10/2026): se la vostra chiave è di una persona digitale e voi non scrivete niente, la persona è la sua e la mette il motore; se lo scrivete deve essere la sua, un nome diverso è 403 PERSONA_UNKNOWN. Se la vostra chiave non è di nessuna persona — tutte quelle emesse fino a oggi — il campo non si manda: mandarlo è 403 PERSONA_UNKNOWN, non un campo ignorato. Etichetta di 1-32 caratteri fra lettere, cifre, _, ., -, la prima è una lettera o una cifra. |
tenant | stringa | assente | dal 12/10/2026. Per quale cliente si calcola. Facoltativo, e non diventerà obbligatorio senza una data detta prima: se non lo scrivete, il tenant è quello della vostra chiave e lo mette il motore — è quello che è sempre accaduto, e nessuna richiesta che oggi riceve 200 cambia. Se lo scrivete, può essere solo il vostro: un altro nome è 403 TENANT_UNKNOWN. Scritto giusto è una dichiarazione che verifichiamo, quindi non finisce in ignored_fields, e non cambia niente di quello che ricevete: stessi vettori, stessa cache, stessa fattura. Vale sulle stesse quattro rotte di calcolo di persona. |
ignored_fields (una lista di nomi, presente solo quando ce n'è almeno uno: una risposta sana non la porta mai). Lo stato non cambia: una richiesta per il resto valida resta 200/201. Prima di oggi quel campo veniva buttato in silenzio, e chi aveva sbagliato il nome riceveva una risposta diversa da quella che aveva chiesto senza una riga che glielo dicesse.| scritto così | da chi arriva | da noi |
|---|---|---|
dimensions | OpenAI (text-embedding-3-*) | non esiste: le dimensioni sono 1024 e non si scelgono. Tagliare un vettore bge-m3 non è previsto dal modello: se il vostro indice ne vuole 512 o 1536, l'indice va rifatto, non il vettore accorciato. |
top_k | Cohere (e gli adattatori scritti per loro) | si chiama top_n su /v1/rerank. Col nome sbagliato ricevete tutti i documenti, non i primi k. |
truncate | Voyage | si chiama truncation (booleano). |
persona non è uno di questi: è un campo che la rotta applica, e per questo si rifiuta invece di ignorarlo. Vale sulle quattro rotte di calcolo — /v1/embeddings, /v1/rerank, /v1/tokenize e anche /embed. Se la vostra chiave non è di nessuna persona, non cambia niente: non mandate il campo e tutto resta com'era, inclusa la cache. Se è di una persona digitale, non dovete fare niente lo stesso: la persona per cui la chiave è stata emessa la mette il motore su ogni richiesta, e se il campo lo scrivete deve dire quella — una chiave vale per quella persona e nessun'altra. Il 403 dice PERSONA_UNKNOWN e non «non è la tua», perché dirvi che una persona esiste ma è di qualcun altro sarebbe già un dato di un'altra persona.
E tenant ha la stessa natura (dal 12/10/2026): anche lui è un campo che le quattro rotte di calcolo applicano, e per questo si rifiuta invece di ignorarlo. La differenza con persona è che un tenant ce l'hanno tutte le chiavi, quindi il campo lo può scrivere chiunque — ma solo per dire il proprio: un altro nome è 403 TENANT_UNKNOWN, «sconosciuto» e non «non è il vostro» per la stessa ragione di PERSONA_UNKNOWN. Se non lo scrivete, non cambia niente: il tenant è quello della vostra chiave e lo mette il motore, oggi come ieri. Non confondetelo con il tenant delle rotte di amministrazione qui sotto: là il campo c'era già e vale come prima.
Tutti questi rifiuti si contano come gli altri: li ritrovate in GET /v1/usage, campo rejected.
Sulle rotte di amministrazione (/admin/*) un campo in più non si elenca e basta: se è il nome che la rotta sorella usa per la stessa cosa — rate_per_min/limite_al_minuto, quota_chars_month/quota_mensile, tenant_id/tenant — la chiamata viene rifiutata con 400 FIELD_WRONG_NAME e il messaggio dice come si chiama il campo su quella rotta. Il motivo è che lì il danno era peggiore del silenzio: una chiave chiesta con limite_al_minuto: 300 nasceva a 120 e una chiesta con tenant_id nasceva nel nostro tenant, cioè fuori dalla fattura del cliente.
input_type. Altri fornitori usano questo campo per codificare domande e documenti in modo diverso. bge-m3 è addestrato senza istruzioni e produce lo stesso vettore nei due casi: accettiamo il campo perché chi arriva da Voyage non debba cambiare codice, ma non migliora la qualità. Preferiamo dirlo piuttosto che lasciarvelo credere.curl https://api.agilev.agile.software/v1/embeddings \
-H "Authorization: Bearer $AGILEV_KEY" -H "Content-Type: application/json" \
-d '{"model":"agilev-m3","input":["Il contratto scade il 30 settembre"]}'
{"object":"list","model":"agilev-m3","dtype":"float",
"data":[{"object":"embedding","index":0,"embedding":[-0.0850,0.0054,…]}],
"usage":{"prompt_tokens":8,"total_tokens":8,"chars":34,"truncated_texts":0,"cached_texts":0}}
L'ordine di data segue sempre l'ordine di input, e index lo conferma. usage.prompt_tokens è il conteggio reale del tokenizer; usage.chars è l'unità su cui si misura la quota.
usage.cached_texts (dal 7/10/2026): quanti testi di questa richiesta erano già nella nostra cache, cioè non sono passati dal modello. Sono fatturati come gli altri — la quota e il prezzo si misurano sui caratteri che ci spedite, usage.chars non cambia — e il campo serve a farvene accorgere. La cache è separata per cliente: «già visto» vuol dire visto da voi.
Pesi lessicali (return_sparse, contratto 1.2)
Perché esistono: sui testi con articoli numerati il solo vettore denso confonde «25-bis» con «25-ter» o «25-undevicies» (sul d.lgs. 231/2001 l'articolo citato è al primo posto in 1 caso su 28). I pesi lessicali contano i token, e le citazioni le risolvono; mescolati al denso (ricerca ibrida) la qualità complessiva sale da 0,63 a 0,76 di nDCG@10 sul nostro corpus di prova (normativa pubblica). I dettagli sono nelle novità.
{"object":"embedding","index":0,"embedding":[-0.0850,0.0054,…],
"sparse_embedding":{"indices":[211,496,891,48287,110006,137164],
"values":[0.1351,0.2416,0.1561,0.2973,0.2951,0.2626]}}
indices: identificatori dei token del tokenizer di bge-m3 (vocabolario XLM-R, 250.002 voci), ordinati crescenti, senza ripetizioni;values: un peso > 0 per ciascuno, 4 decimali. Stessa definizione dei «lexical weights» di FlagEmbedding.- (Esempio vero per «Il contratto scade il 30 settembre»: i sei token sono
il,30,Il,settembre,contratto,scade; le parole di contenuto pesano di più.) - Il vettore denso non cambia:
return_sparseaggiunge, non sostituisce (scarto misurato sul modello vero: 1,5·10⁻⁸, l'ultimo bit del float32). I pesi sono semprefloat:output_dtypeedencoding_formattoccano solo il denso. - Punteggio sparso fra domanda e documento = somma dei prodotti dei pesi sui token in comune. Ibrido:
α · coseno_denso + (1-α) · sparso / max_sparso_della_domanda; α si sceglie sulle proprie domande (0,6 se contano le citazioni di articolo, 0,9 se sono quasi tutte in linguaggio naturale). - Costa come un embedding normale (stessa quota a caratteri). Cache in RAM separata: impronte e pesi, mai testi, e separata per cliente come quella dei vettori densi.
- Esempi completi, testati (i primi due senza dipendenze oltre la libreria standard in Python o
ext-curl/ext-jsonin PHP):- client di riferimento (da copiare nel progetto, nessuna dipendenza oltre la libreria standard /
ext-curl): esempi/agilev_client.py, esempi/agilev_client.php e esempi/agilev_client.mjs (Node ≥ 18, dal 9/10/2026:fetchglobale, ESM, zero dipendenze), stessa classeAgileVconembed(),rerank(),usage(),health(). Incorporano le regole di questa pagina così non vanno riscoperte: connessione riusata (keep-alive), lotti uno alla volta da 64 (massimo 128) con timeout 120 s, limiti controllati prima di spedire (mai un 413 per sbaglio), ritentativo solo su429 RATE_LIMIT, 5xx e connessione caduta con attesa 1-2-4-8 s (tetto 30 s) oppure esattamenteRetry-Afterquando il servizio lo manda (dal contratto 1.2 su ogni429 RATE_LIMIT, fino a 60 s); una connessione riusata che il server ha chiuso nel frattempo (keep-alive scaduto) si rifà subito senza contarla come ritentativo; si frena prima del429: quando una risposta diceX-RateLimit-Remaining: 0la richiesta successiva aspettaX-RateLimit-Resetsecondi prima di partire invece di partire e prendere un errore (misurato il 27/9 sulla 1.2: 5 richieste con una chiave da 2 al minuto,429ricevuti 2 → 0, stesso tempo totale perché la finestra è al minuto di orologio); niente ritentativi su 400/401/403/413 eQUOTA_EXCEEDED, al timeout il lotto si dimezza invece di essere ripetuto uguale, rerank sui primi 800 caratteri di ogni candidato,spezza()per i documenti lunghi (frammenti da 1.500 caratteri a righe/frasi/parole con 150 di sovrapposizione e l'intestazione ripetuta, identici in Python, PHP e Node). Provati contro il servizio 1.2 e contro un server finto che simula 429, 500, quota e timeout; non registrano nulla. - senza database vettoriale (vettori in MySQL o pgvector): esempi/ricerca_ibrida.py e esempi/ricerca_ibrida.php (stessa fusione, stessi punteggi al millesimo sugli stessi articoli). I pesi si conservano come mappa
token → peso(JSON) accanto al denso; il punteggio si somma sui token in comune. - con LangChain (Python): esempi/agilev_langchain.py, tre classi provate contro il servizio 1.2:
AgileVEmbeddings(Embeddingssopra/v1/embeddings, lotti da 64,input_typegiusto per documenti e domande; conreturn_sparse=Trueprende anche i pesi lessicali nella stessa chiamata),AgileVSparseEmbeddings(i pesi nel formatoSparseVectordilangchain-qdrant:QdrantVectorStore(..., retrieval_mode=HYBRID, embedding=emb, sparse_embedding=emb.sparse())costa una passata del modello per testo, non due) eAgileVRerank(BaseDocumentCompressorsopra/v1/rerank, perContextualCompressionRetriever; legge di ogni candidato i primi 800 caratteri, il valore misurato). Dal 22/9 ancheAgileVSpezza(split_text/create_documents/split_documentscome unTextSplitter, ma sopraspezza()del client di riferimento, con l'intestazione presa da un campo dei metadati e ripetuta in ogni frammento:RecursiveCharacterTextSplitternon lo fa). Dipende solo dalangchain-core(e daagilev_client.pynella stessa cartella per lo splitter);requests, se c'è, riusa la connessione TLS. Per LlamaIndex non serve un adattatore:OpenAILikeEmbedding(api_base="https://api.agilev.agile.software/v1", model_name="agilev-m3")copre il denso; per lo sparso e il rerank valgono i corpi REST qui sotto. - con Qdrant: esempi/qdrant_ibrido.py, ricetta REST copiabile in qualsiasi linguaggio (provata su Qdrant 1.19): una collection con due vettori per punto,
denso(1024,Cosine) elessicale(sparse vector:indices/valuessono già nel formato di Qdrant), interrogata conprefetch+fusion(Qdrant fonde da solo) oppure con due query e α a mano.
Chi ha già una collection col solo denso ne crea una nuova con i due vettori, reindicizza con// PUT /collections/<nome> {"vectors":{"denso":{"size":1024,"distance":"Cosine"}},"sparse_vectors":{"lessicale":{}}} // PUT /collections/<nome>/points?wait=true (embedding e sparse_embedding di /v1/embeddings, tali e quali) {"points":[{"id":1,"vector":{"denso":[…1024…],"lessicale":{"indices":[…],"values":[…]}},"payload":{"doc":"…"}}]} // POST /collections/<nome>/points/query (la domanda passa da /v1/embeddings con return_sparse, come i documenti) {"prefetch":[{"query":[…denso…],"using":"denso","limit":50}, {"query":{"indices":[…],"values":[…]},"using":"lessicale","limit":50}], "query":{"fusion":"rrf"},"limit":10,"with_payload":true}return_sparsee sposta l'alias: il denso non cambia. Misurato su Qdrant 1.19 con il corpus delle novità: denso 0,63 →fusion: rrf0,74 → α 0,6 a mano 0,76 (nDCG@10);dbsf0,73. Partite da RRF, passate all'α se le citazioni sono il caso principale. - client di riferimento (da copiare nel progetto, nessuna dipendenza oltre la libreria standard /
POST/v1/rerank — riordino per pertinenza
Ambito rerank. Un modello dedicato (cross-encoder) confronta la domanda con ogni documento: più preciso della sola somiglianza fra vettori, ma va usato su pochi candidati perché costa di più.
curl https://api.agilev.agile.software/v1/rerank \
-H "Authorization: Bearer $AGILEV_KEY" -H "Content-Type: application/json" \
-d '{"model":"agilev-rerank","query":"scadenza del contratto",
"documents":["Il contratto scade il 30 settembre","Ricetta della carbonara",
"Termine dell accordo a fine mese"],"top_n":3}'
{"model":"agilev-rerank","results":[
{"index":0,"relevance_score":0.9633},
{"index":2,"relevance_score":0.8426},
{"index":1,"relevance_score":0.0000}],"usage":{"total_tokens":38}}
I risultati arrivano già ordinati dal più pertinente; index si riferisce alla posizione nell'array documents che avete inviato. Con top_n ne ricevete solo i primi.
La query è un testo come i documents: stesso tetto di 32.000 caratteri (413 TEXT_TOO_LONG, e il messaggio dice quale dei due campi ha sforato), e si paga una volta per documento. Il conto, e il massimo che una richiesta può costarvi, sono in Limiti.
top_n si applica solo se è positivo. 0, assente o negativo vogliono dire «nessun limite»: ricevete tutti i documenti riordinati, e quando il valore c'era e non si è potuto applicare la risposta lo dice in ignored_values: ["top_n"]. È il caso di chi lo calcola — top_n = quanti_me_ne_servono - quanti_ne_ho_già — e arriva a zero o sotto: prima un -1 tagliava dalla coda (su 4 documenti ne tornavano 3, col peggiore buttato via) e un -4 ne faceva tornare zero, sempre con 200 e sempre con tutti i documenti addebitati. I documenti si pagano a uno a uno: il taglio fatelo voi sui punteggi, che arrivano tutti.
I campi obbligatori sono query e documents: input qui non esiste e una richiesta senza documents riceve 422. Il model, invece, oggi non fa fallire nulla: {"model":"agilev-m3", …} su /v1/rerank funziona e risponde "model":"agilev-rerank", perché il nome viene solo contato. Se un rerank non passa, il nome del modello non è la causa: guardate documents. Misurato il 28/9 sul codice 1.1 in produzione e sulla 1.2.
POST/v1/tokenize — contare i token prima di inviare
Ambito embed. Non consuma quota. Serve a sapere in anticipo se un testo verrà tagliato.
{"model":"agilev-m3","max_tokens":8192,
"data":[{"index":0,"tokens":11,"characters":45,"truncated":false}],
"usage":{"total_tokens":11}}
POST/embed — formato storico
Ambito embed. Contratto congelato per gli integratori della prima ora: {"texts":[…]} → {"vectors":[[…]],"model":"bge-m3","dim":1024}. Sempre float a 1024 dimensioni, ignora i parametri nuovi. Per il resto usate /v1/embeddings.
texts, non input: questo endpoint non parla OpenAI. {"model":"agilev-m3","input":[…]} su /embed risponde 422 (campo texts mancante), e allo stesso modo {"texts":[…]} su /v1/embeddings risponde 422 (campo input mancante). Non è un guasto e non cambia col contratto: misurato il 28/9 sul codice 1.1 in produzione e sulla 1.2. Chi porta un client nuovo usi /v1/embeddings col corpo OpenAI; /embed resta per chi integrò AgileVoyage.GET/v1/usage — i vostri consumi
Richieste, testi, caratteri e millisecondi per mese e operazione, più i limiti della chiave. Una chiave vede solo i propri consumi.
cached_texts (dal 7/10/2026): quanti di quei texts, in quel mese, li ha serviti la nostra cache — non sono passati dal modello e sono fatturati come gli altri. È il numero da guardare prima di chiederci una quota più alta: se è quasi uguale a texts, state spedendo molte volte gli stessi testi.
Su chiave revocata risponde 403 KEY_REVOKED, su chiave scaduta 403 KEY_EXPIRED. Il campo expires_at dice quando la chiave finisce (secondi epoch UTC; 0 = non scade): è il modo per accorgersi che una chiave di prova sta per finire prima che smetta di rispondere.
last_at e rejected: «ci stiamo ancora parlando?», dalle due parti (dal 10/10/2026). last_at è l'istante dell'ultima chiamata riuscita della chiave, su qualunque operazione (secondi epoch UTC; 0 = non ne ha mai fatta una), e lo stesso campo sta su ogni riga di usage, mese per mese. rejected è l'altra metà della stessa domanda: una riga per mese, operazione e code, con quante volte vi abbiamo respinto e quando l'ultima — i 403 (KEY_REVOKED, KEY_EXPIRED, SCOPE_NOT_ALLOWED e i codici del contratto) e i 429 (RATE_LIMIT, QUOTA_EXCEEDED). Lista vuota è la risposta normale; i due campi sono gratis come il resto della rotta e chi non li legge non vede cambiare niente.
"last_at":1791620771,
"rejected":[{"month":"2026-10","op":"embed","code":"RATE_LIMIT","requests":245,"last_at":1791619502}]
Servono perché i numeri del mese non dicono se un'integrazione si è fermata: una riga che non cresce è identica a un cliente che in quel mese non ha avuto lavoro, e un deploy che di notte parte con la chiave sbagliata vede i suoi 403 nell'istante in cui li prende e poi niente — fino al 10/10 quei rifiuti vivevano in RAM, aggregati per rotta e azzerati a ogni riavvio del servizio. Quattro limiti, detti prima che ci contiate sopra. I 401 (KEY_MISSING, KEY_INVALID) non ci sono e non ci saranno: arrivano da una chiave che non è nel nostro archivio, quindi non c'è nessuna chiave a cui addebitarli, e l'impronta di un segreto che non è nostro non è un dato che vogliamo conservare — una chiave sbagliata di un carattere si vede solo dal vostro lato. I numeri partono dal 10/10/2026 e non sono retroattivi: i mesi già scritti hanno last_at a 0 finché non arriva la prima chiamata riuscita. Su chiave revocata o scaduta questa rotta risponde 403, quindi la chiave che vorreste indagare è proprio quella che non vi apre più: i rifiuti sono per chiave e in quel caso restano visibili solo a noi, chiedeteceli; si leggono invece con la propria chiave i 429 e i 403 SCOPE_NOT_ALLOWED, perché su quelli la chiave resta buona. Le liste sono tagliate: ultimi 24 mesi per usage, 48 righe più recenti per rejected.
Chiavi di prova (ambito prova). Per far provare AgileV esistono chiavi con ambito prova: coprono /v1/embeddings, /embed, /v1/rerank e /v1/tokenize — cioè il servizio — e non coprono /metrics (sono numeri nostri, aggregati su tutti i clienti) né l'amministrazione delle chiavi. Hanno sempre una scadenza: una chiave di prova senza data di fine non viene accettata, e la regola sta nel servizio, non solo in chi la emette. Quando la data è passata ogni richiesta, compresa GET /v1/usage, risponde 403 KEY_EXPIRED.
In difetto, mai in eccesso (dal 29/9/2026). Se il nostro archivio dei consumi ha un guasto — disco pieno, file bloccato — la vostra richiesta viene servita lo stesso e quella riga di consumo si perde. Quindi i numeri qui, e la fattura che ne esce, possono essere più bassi del vero; non saranno mai più alti. Per lo stesso motivo, durante un guasto del genere una chiave a quota può sforare: preferiamo servirvi e perdere il conto piuttosto che negarvi una risposta per un numero che serve a noi. Prima del 29/9 andava al contrario: la risposta, già calcolata, veniva buttata via e ricevevate un 500. Durante un guasto prolungato l'attesa la paga una richiesta sola: al primo guasto smettiamo di provare a scrivere per 30 secondi, e allo scadere riprova una sola richiesta per vedere se l'archivio è tornato (misurato il 29/9: senza questa regola erano 8 richieste su 8 a fermarsi per 10 s a ogni risveglio, adesso 1 su 8).
model_unknown: la lista che deve restare vuota (dal 29/9/2026). Quante volte la vostra chiave ci ha mandato un campo model che non riconosciamo, per mese e per rotta, con l'ora dell'ultima volta (last_at, secondi Unix UTC):
"model_unknown":[{"month":"2026-09","op":"rerank","requests":4,"last_at":1790651460}]
Oggi quei nomi vengono serviti lo stesso e non vi succede nulla (vedi Il campo model); il giorno in cui arriverà 400 MODEL_UNKNOWN sarebbero errori. Nasce da una domanda di un cliente (28/9): il nome del modello lo mandiamo da una variabile d'ambiente, vogliamo accorgercene prima dei nostri clienti. Guardarlo non consuma né quota né richieste al minuto, quindi si può mettere in un controllo automatico: se la lista non è vuota, la vostra configurazione manda un nome che non conosciamo — i nomi validi sono quelli di /v1/models.
Quattro limiti, detti prima che ci contiate sopra. La lista vuota non vuol dire che la configurazione è giusta: "", null e il campo assente valgono come default e non vengono contati (se la vostra variabile d'ambiente è vuota, qui non si vede — si vede nel model della risposta). E /embed non guarda il campo affatto: chi usa il formato storico avrà sempre la lista vuota. Terzo limite, dal 29/9 e ce l'ha detto trpg misurandolo in casa: la lista è per chiave. Se un domani le vostre installazioni avranno una chiave loro, i nomi che mandano non compaiono sotto la vostra. E il primo limite, al rovescio, per chi ha un ripiego nel codice: se la vostra variabile vuota fa scattare un ?: 'nome-di-un-altro-fornitore', quel nome viene contato — quindi per voi la lista è più utile, non meno. Quarto limite, dal 29/9, e anche questo ce l'ha misurato trpg in casa propria: se il vostro codice legge la configurazione da $_SERVER invece che da getenv(), sotto un server web quel valore può essere assente (nel loro contenitore $_SERVER['LLM_API_URL'] è null sotto Apache, mentre getenv() e $_ENV portano il valore) — e allora non c'è nessun ripiego da contare: c'è una configurazione che sparisce quando il codice gira dentro il server web e funziona in ogni prova da riga di comando. Da noi si vede come un nome che manca, o come una chiave che non compare affatto nella lista. La forma da riconoscere: la stessa chiave che alterna il nome giusto e nessuna riga, senza che nessuno abbia toccato niente.
POST/v1/richieste-licenza — chiedere una licenza
Pubblica e senza chiave: è il modulo con cui si chiede una licenza, e chi lo compila per definizione non ha ancora niente. È l'unica rotta che scrive qualcosa senza autenticazione, quindi ha un limite per indirizzo: al massimo 5 richieste in un'ora, poi 429 RATE_LIMIT. Il modulo pronto sta sulla pagina di AgileV; qui c'è la rotta, per chi la vuole chiamare da un programma.
Risponde nella forma in cui le si parla: con application/x-www-form-urlencoded (un <form method="post"> senza enctype) torna una pagina col numero della richiesta; con application/json torna 201 e {"n":<numero>,"messaggio":"Richiesta registrata"}. Con ?lang=en risponde in inglese.
| Campo | Cos'è | Massimo |
|---|---|---|
chi | chi chiede (azienda o persona, come preferite scriverlo) | 120 caratteri |
contatto | come vi ricontattiamo | 120 caratteri |
piano | uno fra prova, standard, enterprise — vedi I tre piani | — |
uso | a cosa vi serve, in breve | 500 caratteri |
Tutti e quattro sono obbligatori. Gli errori: 400 BAD_BODY (corpo che non è né modulo né JSON), 400 CAMPO_MANCANTE, 413 CAMPO_TROPPO_LUNGO, 400 BAD_PLAN, 429 RATE_LIMIT e 503 NON_SALVATA. Il messaggio dice quale campo, e non ripete niente di quello che avete scritto.
Il 429 di questa rotta dice quando tornare, e non è il minuto (dal 20/10/2026). Il limite qui è 5 richieste in un'ora dallo stesso indirizzo, e la risposta porta Retry-After coi secondi che mancano all'ora nuova in UTC, da 1 a 3.600 — il momento esatto in cui il contatore si riapre, non una stima. Lo porta in entrambe le forme: la risposta JSON e la pagina del <form> della landing. Attenzione a una cosa, perché il code è lo stesso delle rotte con chiave ma la finestra no: se il vostro client taglia Retry-After a 60 s — la regola giusta là, e quella che applica il nostro client di riferimento — qui busserebbe fino a 60 volte dentro la stessa ora chiusa invece di una. Onorate il numero per intero.
Il 503 è l'unico posto in cui non ingoiamo un guasto dell'archivio. Su /v1/embeddings una richiesta viene servita anche se il contatore dei consumi non si lascia scrivere (in difetto, mai in eccesso): lì il servizio era già stato dato. Qui la scrittura è il servizio, e un «registrata n. 0» sopra una richiesta che non esiste sarebbe una persona che aspetta una risposta che nessuno darà. Se arriva un 503, riprovate: non è stata presa.
Formati dei vettori
Un vettore float occupa 4 KB. Se indicizzate milioni di frammenti, la memoria del database vettoriale diventa la voce di costo principale: potete chiedere i vettori già compressi.
output_dtype | Dimensione | Spazio | Quando usarlo |
|---|---|---|---|
float | 1024 × float32 = 4096 byte | 100% | default: massima fedeltà. |
int8 / uint8 | 1024 byte | 25% | ottimo compromesso: il coseno col vettore float resta ≥ 0,997 nelle nostre misure. |
binary / ubinary | 128 byte | 3% | indici enormi: si cerca con distanza di Hamming e si riordina poi con i vettori pieni o col reranker. |
La quantizzazione int8 usa una calibrazione fissa e documentata, non adattiva: vettori chiesti in momenti diversi restano confrontabili fra loro. Non cambierà sotto il nome agilev-m3.
Limiti
| Limite | Valore | Se lo superate |
|---|---|---|
| Testi per richiesta | 128 | 413 BATCH_TOO_LARGE |
Caratteri per singolo testo (anche la query di /v1/rerank) | 32.000 | 413 TEXT_TOO_LONG |
| Token per singolo testo | 8192 | tagliato, oppure 413 con truncation:false |
| Corpo della richiesta | 8.000.000 byte (8 MB, che non sono 8 MiB) | 413 BODY_TOO_LARGE, dal bordo |
| Richieste al minuto | secondo la chiave, e secondo la licenza se ce n'è una | 429 RATE_LIMIT |
| Caratteri al mese | secondo la chiave, e secondo la licenza se ce n'è una, e secondo l'azienda se le è stato scritto un tetto | 429 QUOTA_EXCEEDED |
| Richieste insieme (stessa chiave) | nessun limite dichiarato | nessun errore: si mettono in fila |
I due primi limiti non si raggiungono insieme con testo multibyte, e il tetto del corpo arriva prima. 128 testi da 32.000 caratteri sono dentro a tutto quello che c'è scritto qui sopra, ma in UTF-8 un carattere cinese, russo o greco pesa 2 o 3 byte: con testo CJK quella richiesta è di 24.576.545 byte, cioè 3,07 volte il tetto del corpo, e bastano 42 testi su 128 per superarlo (misurato al byte in gabbia). Chi indicizza in una di quelle lingue sbatte sul tetto del corpo restando dentro ai due limiti pubblicati: il 413 che riceve lo scrive il bordo e ha code BODY_TOO_LARGE, non BATCH_TOO_LARGE. Il conto da fare prima di spedire è in byte (len(json.dumps(corpo).encode("utf-8"))), non in caratteri; e il tetto è 8.000.000, non 8 MiB — chi dimensiona i lotti a 8.388.608 byte sbatte per 388.608 byte. Il client di riferimento manda lotti da 64 e non ci arriva nemmeno in cinese.
Quanto può costare al massimo una richiesta di /v1/rerank. Il conto di quella rotta è len(query) × numero di documenti + somma dei documenti: la domanda si paga una volta per documento, perché il cross-encoder legge la coppia (domanda, documento) una volta per documento. La query è stata per mesi il solo campo del contratto senza il tetto della riga qui sopra, ed era proprio quello moltiplicato: una domanda da 200.000 caratteri con 128 documenti passava con 200 e contava 25.601.280 caratteri, e il corpo più grande che il bordo accetta ne contava 1.023.750.400, cioè 249,94 volte i 4.096.000 che si calcolano dalle prime due righe. Con lo stesso tetto di un documento il massimo di una richiesta di rerank è 8.192.000 caratteri — due volte il tetto implicito e non duecentocinquanta: il suo per i documenti, e uno per la domanda ripetuta. Se lo superate ricevete 413 TEXT_TOO_LONG e il messaggio dice quale dei due campi (query o documents) ha sforato, perché su questa rotta quel 413 ha due origini. Nessun cliente ci è mai arrivato: chi usa il rerank più di tutti ha una domanda media sotto i cento caratteri, misurata dai nostri contatori.
Le due righe di mezzo hanno due numeri, non uno, quando la vostra chiave sta sotto una licenza (il livello arrivato il 30/9: un contratto, N chiavi sotto). La quota mensile e il limite al minuto sono numeri del contratto e le chiavi che ci stanno sotto li consumano insieme: una licenza da 5 al minuto serve 5 richieste al minuto, che ci sia una chiave sotto o dieci. Il rate_per_min della singola chiave resta un tetto suo e non viene allargato dal contratto; basta che morda uno dei due, e il messaggio dell'errore dice quale. Le chiavi senza licenza (tutte quelle emesse prima) hanno un numero solo, il loro.
Il mese è tagliato alle 00:00Z, e ve lo diciamo. La quota mensile riparte quando cambia l'etichetta AAAA-MM dell'orologio UTC, cioè al primo istante del mese nuovo: alle 00:00Z del giorno 1, che a Roma sono le 01:00 (le 02:00 con l'ora legale), non la vostra mezzanotte. Lo stesso orologio taglia la fattura e le etichette che GET /v1/usage vi rende. Non dipende dal fuso del nostro box né dal vostro: se avete lavori che consumano quota all'inizio del mese, l'istante da aspettare è quello.
L'ultima riga è quella che si legge male. Il limite per minuto non limita le richieste simultanee: una chiave da 120 al minuto può averne quaranta in volo nello stesso istante senza prendere un 429. Non guadagnate niente a farlo — il calcolo è uno alla volta, vedi Prestazioni — e fino al contratto 1.1 ci perdevate: lì ci sono i numeri misurati.
Errori
Ogni errore ha lo stesso corpo, con un codice stabile su cui potete ramificare.
422», e non era vero: erano quattro, e due di loro erano già capitate a clienti veri. Sono tutte risposte che AgileV non scrive — le scrive il framework sotto di noi o il bordo davanti a noi, prima che il nostro codice veda la richiesta. Non hanno error.code, hanno detail come stringa (o non sono nemmeno JSON), e cadono tutte nello stesso ramo del vostro lettore d'errore. Due delle quattro le ha chiuse il bordo, il 29/9 e il 5/10. Ma una non l'avevamo contata, e l'abbiamo trovata noi il 5/10 misurando in gabbia: il 413 che il bordo scrive quando il corpo supera gli 8.000.000 byte usciva con zero byte di corpo, senza Content-Type. E una sesta, trovata in gabbia l'8/10: il 502/503/504 che il bordo scrive quando il motore dietro non risponde — un riavvio, un rilascio, o troppo lavoro insieme — usciva anch'esso con zero byte e senza Content-Type. È la risposta che tocca a chiunque mandi una richiesta mentre il motore si riavvia, cioè a ogni rilascio, e non è teorica: il 15/9 alle 12:43:55Z un cliente ne ha ricevuta una due secondi dopo un nostro riavvio, e dal suo lato era un errore senza nome. Anche quella è curata nel nostro file del bordo — code SERVICE_UNAVAILABLE, con lo stato che arriva — ed è andata in linea con quello stesso rilascio, il 9/10 alle 07:31:16Z. E l'8/10, nello stesso giro sulla stessa famiglia, anche il 405 del metodo sbagliato — l'unica delle quattro di cui questa pagina diceva che restava — ha preso la nostra forma nel file del bordo: code METHOD_NOT_ALLOWED, e dentro il messaggio il metodo giusto di quella rotta, letto dall'intestazione Allow che il framework manda già (e che la risposta continua a portare). Quindi oggi non resta aperta nessuna forma senza code: le altre tre — il 405, quel 413 e quei tre 50x — sono al bordo pubblico dal 9/10 alle 07:31:16Z, e il 422 l'abbiamo chiuso noi, nell'app, il 17/10 alle 03:37:52Z. Il 413 è curato nel nostro file del bordo e arriva a voi — stessa forma degli altri, code BODY_TOO_LARGE — dal 9/10 alle 07:31:16Z: la data non l'avevamo detta prima del reload, perché la volta scorsa l'avevamo fatto e nel mezzo erano uscite altre 48 risposte senza code (la riga qui sotto). L'istante è quello misurato, e sta nel marcatore di questa riga.
| Quando | Cosa ricevete | Chi la scrive |
|---|---|---|
| corpo della richiesta non valido | 422 con {"detail":[{…}]} — vedi la riga in fondo alla tabella | FastAPI |
metodo HTTP sbagliato (GET su /v1/embeddings) | 405 con {"detail":"Method Not Allowed"} — chiusa il 9/10 alle 07:31Z: adesso è {"error":{"code":"METHOD_NOT_ALLOWED",…}}, ed è la stessa riga che fino a ieri prometteva quel codice «col prossimo caddy reload» | FastAPI, riscritta dal bordo |
| corpo oltre gli 8.000.000 byte | 413 con zero byte di corpo, nessun Content-Type, Connection: close — chiusa il 9/10 alle 07:31Z: adesso è {"error":{"code":"BODY_TOO_LARGE",…}}, ed è la stessa riga che fino a ieri prometteva quel codice «col prossimo caddy reload» | il bordo (Caddy) |
| il motore dietro il bordo non risponde (riavvio, rilascio, sovraccarico) | 502/503/504 con zero byte di corpo, nessun Content-Type — chiusa il 9/10 alle 07:31Z: adesso è {"error":{"code":"SERVICE_UNAVAILABLE",…}}, ed è la stessa riga che fino a ieri prometteva quel codice «col prossimo caddy reload» | il bordo (Caddy) |
/v1/inventata) | chiusa il 5/10 alle 06:40Z (scritta l'1/10, in linea col caddy reload del 5/10): era 404 con {"detail":"Not Found"} dall'app, adesso è {"error":{"code":"NOT_FOUND",…}} | FastAPI, riscritta dal bordo |
chiusa il 29/9 (in linea dall'1/10, 02:00 di Roma): era 404 con not found in testo semplice, nemmeno JSON | il bordo (Caddy) |
500 usciva come testo semplice. Dal contratto 1.2 ha la nostra forma (INTERNAL). Cosa farne nel vostro client: regola 5 — il codice mettetecelo voi, deducendolo dallo stato HTTP e mai dal corpo. Dare al 422 la nostra forma l'abbiamo fatto il 17/10 alle 03:37:52Z, e non è stato un cambio di contratto: error.code si aggiunge accanto a detail, che resta dov'era, e il contratto è rimasto 1.2. Del 405 questa pagina diceva che restava, perché «rotta inesistente» su una rotta che esiste sarebbe un consiglio falso: quella ragione è vera, e quel consiglio falso non ve lo diamo nemmeno adesso — il 405 non riusa il corpo del 404. Ma escludeva una cura, non la cura: dall'8/10 ha un code tutto suo, METHOD_NOT_ALLOWED, e il messaggio dice quale metodo vuole quella rotta, preso dall'Allow che il framework manda già. Il consiglio è quello giusto, e non è scritto a mano nel nostro file: su /v1/models dice GET e su /v1/embeddings dice POST senza che nessuno li abbia elencati.
La barra in fondo: un 307 che non è un errore, ma che il vostro client deve saper leggere (1/10). Una rotta vera chiamata con una barra finale — POST /v1/embeddings/ invece di POST /v1/embeddings — non vi risponde 200 e non vi risponde con un nostro errore: risponde 307, corpo vuoto, con Location: verso la stessa rotta senza barra. Lo scrive il framework (redirect_slashes), non il nostro codice, e il 307 conserva metodo e corpo: se il vostro client segue i rimandi, non ve ne accorgete nemmeno. Se non li segue — e tanti li disattivano sulle POST, ed è una scelta prudente — ricevete uno stato che non è 200 e non ha error.code. Non va contata fra le «forme senza codice» di qui sopra: non è un errore, è la stessa risposta all'indirizzo giusto. Quello che vi serve sapere, tutto misurato:
- seguirlo è sicuro: il
Locationè assoluto e inhttps, quindi né la chiave né i vostri testi passano in chiaro, e il corpo del307è vuoto (0 byte: non vi torna indietro niente di quello che avete mandato); - ma i due rimandi non sono la stessa cosa, e questa riga vale più della precedente: il
307della barra lo scrive l'app dentro la sessione cifrata, quindi quando lo leggete niente è ancora uscito. Il nome dell'API però risponde anche sulla porta 80, e lì risponde308versohttps: quel rimando arriva dopo, quindi chi chiamahttp://api.agilev.agile.softwareha già mandato chiave e testi in chiaro il primo giro, e nessun rimando li riporta indietro. Chiamateci solo in https: i nostri quattro client di riferimento rifiutano orahttp://verso un indirizzo pubblico già alla costruzione, prima di aprire la connessione —loopbacke reti private restano ammessi senza dire niente, perché quel filo non passa da Internet, e per un nome vostro dentro una VPN c'è l'uscita esplicitain_chiaro_ammesso=True; - non consuma: la stessa chiamata conta 1 richiesta senza barra, 1 con la barra seguendo il rimando, 0 con la barra fermandosi al
307. Il rimando non si paga; - non porta le intestazioni
X-RateLimit-*: se regolate il ritmo suX-RateLimit-Remaining, su un307non leggete niente — un motivo in più per togliere la barra invece di seguire il rimando; - non succede su tutte le rotte allo stesso modo:
GET /health/ePOST /embed/non arrivano nemmeno all'app e ricevono il404 NOT_FOUNDdel bordo, che dice «rotta inesistente» di una rotta che esiste. Misurato dal vivo l'1/10 sulle otto rotte pubbliche: 5 rispondono307, due il404del bordo.
Cosa fare: non scrivete la barra finale. I nostri tre client di riferimento non possono metterla da soli (tagliano la barra dell'indirizzo di base) e dall'1/10 hanno il ramo 3xx, che restituisce REDIRECT con la cura invece di un codice «non so». Se usate un client vostro, vedete la regola 5 qui sotto.
Tutti e due i 404 li chiude il bordo, e nessuno dei due ha aspettato il deploy dell'app. Il 404 non lo scrive il nostro codice: lo scrive Caddy, e basta un caddy reload. Il 29/9 è andata in forma di contratto la risposta nei quattro punti in cui il bordo ferma una richiesta da sé (in linea dall'1/10 alle 02:00, e il giornale del bordo lo conferma: il primo corpo nostro è dell'1/10 alle 00:00:11Z). Il quinto punto, che non ferma una richiesta ma riscrive quella del motore, è stato scritto l'1/10 ma è andato in linea solo col caddy reload del 5/10 alle 06:40:01Z: per quattro giorni questa pagina ha detto «dall'1/10» mentre dal bordo uscivano altre 48 risposte {"detail":"Not Found"} — 5 a chi usa il servizio (il 2/10 su /v1/bootstrap, il 3/10 su /v1/collezioni, il 5/10 su /v1/richieste-licenza: un client che cercava l'indirizzo, cioè il caso che la voce dell'1/10 dichiarava coperto) e 43 alle nostre sonde, l'ultima alle 06:39:19Z, 42 secondi prima del reload. Misurato sul giornale del bordo in sola lettura. Come funziona: il bordo lascia passare /v1/* intero — e deve, se no una rotta nuova sotto /v1/ la fermerebbe lui — quindi /v1/embed (fra /embed e /v1/embeddings), /v1/embedding, /v1/keys, /v1/ arrivavano a FastAPI e tornavano {"detail":"Not Found"} senza code. Era il posto in cui un client cerca a tentoni, cioè proprio quello che la voce del 29/9 prometteva coperto. Tutti e cinque i punti rispondono lo stesso identico corpo (/metrics e /admin/* rispondono 404 apposta per non ammettere di esistere: un corpo diverso da quello di un percorso inventato direbbe che esistono — e dal 5/10 nemmeno si capisce, dalla risposta, se un percorso è arrivato all'app o si è fermato prima). Il messaggio è fisso, non ripete il percorso che avete chiesto e nomina tutte e otto le rotte pubbliche: niente di quello che mandate torna indietro nei vostri log. Misurato sulle stesse richieste: risposte con un code 0 su 20 → 13 su 20 (29/9) → 20 su 20 (la configurazione dell'1/10, al bordo dal 5/10), di cui 0 su 7 → 7 su 7 fra quelle a tentoni sotto /v1/; corpi distinti 1, echi della richiesta 0, e il 405 del metodo sbagliato ancora 2 su 2 un 405. Per voi non cambia nulla e non c'è niente da ricopiare: se il codice lo deducete dallo stato, le due forme si comportano già allo stesso modo.
{"error":{"code":"RATE_LIMIT","message":"massimo 300 richieste al minuto"}}
| HTTP | Codice | Cosa fare |
|---|---|---|
| 401 | KEY_MISSING | manca l'intestazione di autenticazione. |
| 401 | KEY_INVALID | la chiave non esiste: controllate di non aver copiato spazi. |
| 403 | KEY_REVOKED | la chiave è stata revocata: chiedetene una nuova. |
| 403 | KEY_EXPIRED | la chiave è scaduta: aveva una data di fine (tipicamente una chiave di prova) e quella data è passata. Se controllate le vostre chiavi da un vostro pannello: la data di fine sta in expires_at (GET /v1/usage) e non in uno stato — una chiave scaduta non cambia stato, smette semplicemente di essere accettata. È lo stesso difetto che abbiamo corretto tre volte in casa nostra fra il 29 e il 30/9, l'ultima in un punto dove consegnava una chiave nuova già scaduta (novità del 30/9). Riprovare non serve: serve una chiave nuova. |
| 403 | SCOPE_NOT_ALLOWED | la chiave non copre quell'operazione. |
| 400 | PERSONA_MISSING | non lo manda più nessuno dal 12/10/2026, e la riga resta qui perché qualcuno può averlo scritto nel proprio codice: per un giorno, l'11/10, una chiave di una persona digitale pretendeva il campo persona nel corpo e senza riceveva questo. Adesso quella persona la mette il motore. Tornerà il giorno in cui il campo diventerà obbligatorio per tutte le chiavi, e quel giorno ve lo diremo in anticipo invece di scoprirlo voi da un 400. |
| 403 | PERSONA_UNKNOWN | la persona che avete scritto non è quella di questa chiave: una chiave vale per la persona per cui è stata emessa, e nessun'altra. Lo ricevete anche se la vostra chiave non è di nessuna persona e mandate il campo lo stesso — lì non c'è nessuno a nome di cui calcolare, e una chiave di persona la emette chi amministra. «Sconosciuta» e non «non è la tua»: dirvi che esiste sarebbe già un dato di un'altra persona. |
| 403 | TENANT_UNKNOWN | dal 12/10/2026. Il tenant che avete scritto nel corpo non è quello di questa chiave: una chiave vale per il tenant per cui è stata emessa, e nessun altro. Il campo è facoltativo — se il corpo tace, il tenant è quello della chiave — quindi questo codice non lo riceve mai chi non lo scrive. «Sconosciuto» e non «non è il vostro»: dirvi che un altro cliente esiste sarebbe già un dato suo, la stessa ragione per cui fra clienti diversi rispondiamo 404 e non 403. |
| 403 | LICENSE_SUSPENDED | il contratto sotto cui sta questa chiave è sospeso. Non è un problema della chiave: tutte le chiavi dello stesso contratto ricevono questo codice nello stesso istante, e una chiave nuova sotto lo stesso contratto non funziona (non ve la emettiamo nemmeno). Si riattiva il contratto, e da quel momento le vostre chiavi riprendono senza che cambiate niente. |
| 403 | LICENSE_EXPIRED | il contratto è arrivato alla sua data di fine. Come sopra: riguarda tutte le chiavi del contratto insieme, e una chiave nuova non lo aggira. Si rinnova il contratto. |
| 403 | LICENSE_REVOKED | il contratto è stato revocato. Una chiave nuova sotto quel contratto non si emette più: serve un contratto nuovo. |
| 403 | LICENSE_NOT_FOUND | guasto nostro, non vostro: la vostra chiave punta a un contratto che non c'è più. Scriveteci: non c'è niente che possiate cambiare da parte vostra. |
| 429 | RATE_LIMIT | aspettate i secondi di Retry-After (dal contratto 1.2) e riprovate: il contatore riparte a ogni minuto — vale per le rotte con una chiave; sulla sola rotta pubblica (POST /v1/richieste-licenza) lo stesso code ha una finestra di un'ora e Retry-After dice i secondi all'ora nuova. |
| 429 | QUOTA_EXCEEDED | quota mensile esaurita: il messaggio dice quale dei tre tetti ha morso (la chiave, la licenza o l'azienda), Retry-After dice i secondi al mese nuovo (00:00Z del giorno 1), e per tornare su prima si chiede un aumento di quota. |
| 413 | BATCH_TOO_LARGE · TEXT_TOO_LONG | spezzate la richiesta. |
| 413 | BODY_TOO_LARGE | il corpo intero supera gli 8.000.000 byte del bordo. Lo scrive il bordo, non l'app: arriva prima dell'autenticazione e prima del motore, quindi non consuma né quota né richieste al minuto e non porta intestazioni X-RateLimit-*; in questo caso il nostro 413 BATCH_TOO_LARGE non lo vedete mai, perché la richiesta all'app non arriva. Il messaggio è fisso e non ripete niente del vostro corpo. Riprovare uguale non serve: mandate meno testi per volta. |
| 502 · 503 · 504 | SERVICE_UNAVAILABLE | il motore dietro il bordo non ha risposto: si sta riavviando — a ogni rilascio la salute del motore si aspetta fino a 180 s — oppure è sovraccarico. Lo scrive il bordo, non l'app: la richiesta non è stata eseguita, non consuma né quota né richieste al minuto, e si può ripetere identica. Lo stato è quello che arriva e non viene schiacciato in uno: un 503 può venire anche dall'app (MODEL_UNAVAILABLE), e il code dice quale dei due vi ha risposto. Niente Retry-After: il bordo non sa quanto durerà il fermo, e un numero inventato vi farebbe ritentare tutti insieme nell'istante sbagliato. Dal 9/10 alle 07:31:16Z questa risposta ha la nostra forma; prima di quel caddy reload arrivava con zero byte e senza Content-Type. |
| 400 | EMPTY_INPUT · BAD_INPUT | input vuoto o mal formato. |
| 400 | FIELD_WRONG_NAME | su una rotta /admin/* avete scritto il nome che la rotta sorella usa per la stessa cosa: il messaggio dice come si chiama qui. I campi non sono stati applicati e la chiamata è stata rifiutata, invece di nascere coi predefiniti. |
| 503 | MODEL_UNAVAILABLE | avete chiesto un modello che questa istanza non ha. Non ve ne diamo un altro al suo posto senza dirvelo. |
| 500 | INTERNAL | guasto nostro non previsto (dal contratto 1.2). Porta il type dell'eccezione e mai il suo messaggio, che potrebbe portarsi dietro un pezzo del vostro corpo: {"error":{"code":"INTERNAL","message":"guasto interno (NomeEccezione): …","type":"NomeEccezione"}}. Fino alla 1.1 un guasto imprevisto usciva come Internal Server Error in testo semplice, senza code. Riprovare una volta ha senso; se si ripete, mandateci stato e type — il traceback e il contatore agilev_imprevisto_totale{tipo=…} ce li abbiamo noi, voi non registrate il corpo. Porta anche le tre X-RateLimit-* quando il guasto arriva dopo il conto del minuto, cioè dentro una rotta con chiave: quel posto l'avete pagato, e il numero che vi resta è proprio quello che serve prima di riprovare. Se il guasto nasce prima del conto — dentro la porta dell'autenticazione — non le porta, perché non ha speso niente: non è lo stato a dirvelo, è quando la risposta nasce. |
| 422 | BODY_INVALID | corpo della richiesta non valido: manca un campo obbligatorio o è del tipo sbagliato (input al posto di texts su /embed, documents mancante su /v1/rerank). Ha la nostra forma dal 17/10 alle 03:37:52Z — prima era il corpo di FastAPI, senza code. Leggete detail[].loc e detail[].msg: vi dicono quale campo. Tre cose da sapere: arriva prima dell'autenticazione (con corpo sbagliato e chiave mancante ricevete 422, non 401), non consuma né quota né richieste al minuto e non porta intestazioni X-RateLimit-*, e non vi rimanda più indietro ciò che avete inviato — detail[].input non c'è più, e la risposta pesa gli stessi byte per un testo e per 64 (402 byte misurati al bordo, dove un lotto da 89.600 caratteri tornava come 89.909 byte). Riprovare uguale non serve: cambiate il corpo. |
| 405 | METHOD_NOT_ALLOWED | metodo HTTP sbagliato per quella rotta. Il code lo mette il bordo dal 9/10 alle 07:31:16Z (scritto nel file del bordo l'8/10), e il messaggio dice il metodo che quella rotta vuole; la risposta porta anche l'intestazione standard Allow con lo stesso metodo. Prima di quell'istante arrivava come {"detail":"Method Not Allowed"}, con detail stringa e senza code. Gli embedding e il riordino vogliono POST; /health, /v1/models e /v1/usage vogliono GET. Arriva prima dell'autenticazione e non consuma nulla. Non è teorico: il 13/9/2026 alle 01:26:32Z un cliente ha chiamato /v1/embeddings in GET e ha ricevuto questo, e il suo client non aveva modo di dirgli cosa fosse. |
| 404 | NOT_FOUND | rotta inesistente, in tutti i casi e con un corpo solo (dal 5/10): URL di base sbagliato, percorso fuori dall'API, o rotta inventata sotto /v1/. Il messaggio elenca tutte le rotte pubbliche, così chi sta cercando l'indirizzo lo trova lì. Riprovare non serve: è l'indirizzo. Fino al 29/9 era not found in testo semplice dal bordo, e fino al 5/10 alle 06:40Z {"detail":"Not Found"} senza code per i percorsi sotto /v1/ (misurato sul giornale del bordo: le ultime cinque a un cliente fra il 2 e il 5/10). |
| 499 | CLIENT_GONE | non lo ricevete mai: è ciò che registriamo quando chiudete la connessione prima che il calcolo cominci (timeout del vostro client). Dal contratto 1.2 quella richiesta non viene calcolata né contata; se chiudete a calcolo iniziato, ci fermiamo al passaggio successivo (i testi già calcolati restano in cache). Su /v1/rerank guardiamo se siete ancora collegati anche quando il calcolo è stato un passaggio solo, cioè prima di scrivere il consumo: un rerank fino a 16 documenti sta in un passaggio, e fra un passaggio e il successivo non c'è niente da controllare (corretto il 6/10, novità). |
400 KEY_EXPIRED invece di emettere una chiave nuova che eredita la scadenza passata, cioè nata morta — e lo scriviamo anche qui, non solo nelle novità, perché la causa è la regola della riga KEY_EXPIRED qui sopra: status e scadenza sono due cose diverse. Per NIS2, LG231, trpg, aiepod e core non cambia nulla: le vostre chiavi hanno expires_at: 0. L'11/10/2026 è cambiata di nuovo, e per la stessa ragione: l'emissione accetta una persona, cioè dichiara che la chiave che nasce è di qualcuno. La forma è un'etichetta di 1-32 caratteri fra lettere, cifre, _, ., - — mai un nome di persona fisica, mai un'e-mail — e una forma sbagliata è 400 BAD_PERSONA; se il confine non si può scrivere, la chiave non nasce (503 PERSONA_UNAVAILABLE) invece di nascere senza. La rotazione se lo porta dietro: una chiave ruotata resta della stessa persona. Lo scriviamo qui perché la conseguenza la vedete voi nella tabella degli errori: una chiave emessa così vale per quella persona e per nessun'altra. Il 12/10/2026 quella conseguenza si è alleggerita e non appesantita: una chiave di persona non vi chiede più niente nel corpo, perché la sua persona la mette il motore su ogni richiesta, e 400 PERSONA_MISSING non lo manda più nessuno.Quando riprovare, ve lo diciamo noi (dal contratto 1.2). Le risposte delle operazioni di calcolo (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) — le sole che chiamate voi; dal 20/10 le portano tutte le risposte autenticate, amministrazione compresa — portano tre intestazioni informative: X-RateLimit-Limit (richieste al minuto della vostra chiave), X-RateLimit-Remaining (quante ne restano in questo minuto) e X-RateLimit-Reset (secondi al minuto nuovo, da 1 a 60). Il 429 RATE_LIMIT porta in più Retry-After con lo stesso numero di secondi: la finestra è al minuto, quindi il valore è esatto e non c'è nulla da indovinare: aspettate quei secondi e riprovate (mai più di 60). Il 429 QUOTA_EXCEEDED porta Retry-After anche lui (dal 6/10), ma vuol dire un'altra cosa: sono i secondi che mancano al primo istante del mese nuovo in UTC — da 1 a 2.678.400 — cioè la distanza da quando la vostra quota riparte, non un tempo di attesa consigliato e non un invito a tenere la richiesta aperta. I numeri hanno la stessa intestazione e tre significati, e li distinguete dal code del corpo e dalla rotta: RATE_LIMIT su una rotta con chiave sono i secondi al minuto nuovo (mai più di 60), QUOTA_EXCEEDED i secondi al mese nuovo, e RATE_LIMIT sulla sola rotta pubblica — POST /v1/richieste-licenza, quella del modulo della landing — i secondi all'ora nuova in UTC, da 1 a 3.600 (dal 20/10/2026). Il terzo è il più facile da sbagliare proprio perché il code è lo stesso del primo: lì la finestra è l'ora e non il minuto, e un client che tagli Retry-After a 60 s busserebbe fino a 60 volte dentro la stessa finestra chiusa invece di una. Se la vostra regola è «onoro Retry-After e taglio a 60», guardate anche la rotta, non solo il code. Se vi serve tornare a lavorare prima, non aspettate: chiedete un aumento di quota col modulo della landing (POST /v1/richieste-licenza, pubblico e senza chiave). Il client di riferimento non ritenta su QUOTA_EXCEEDED e quel numero non lo legge. I rifiuti che arrivano prima del conto del minuto non portano intestazioni di rate, e la ragione è che non hanno speso niente: 401 KEY_MISSING, 401 KEY_INVALID e i sette 403 della chiave e del contratto (KEY_REVOKED, KEY_EXPIRED, SCOPE_NOT_ALLOWED, LICENSE_NOT_FOUND, LICENSE_SUSPENDED, LICENSE_EXPIRED, LICENSE_REVOKED). Due 403 invece le portano, e sono i soli due: TENANT_UNKNOWN e PERSONA_UNKNOWN — il tenant o la persona che avete scritto nel corpo non è quello della vostra chiave — perché arrivano dopo, quando il posto del minuto è già stato speso, e quel numero è vostro: leggetelo. Non è il numero di stato a dirvi se le intestazioni ci sono, è quando il rifiuto nasce: su un 403 guardate se ci sono invece di darlo per scontato. Misurato il 20/10/2026: sei richieste con una chiave da 6 al minuto, tre col tenant sbagliato — chi salta le intestazioni dei 403 vede spendere 3 posti su 6 e crede di averne 3 liberi quando ne ha 0, e la richiesta dopo si prende il 429 che quelle intestazioni esistono per evitare. X-RateLimit-Remaining serve a non prendere il 429: quando è 0, aspettate X-RateLimit-Reset secondi prima della richiesta successiva. Il client di riferimento lo fa da sé (contatore frenate). GET /v1/usage è fuori da questo conto: non porta le intestazioni di rate e, soprattutto, non consuma una delle vostre richieste al minuto — controllare i propri consumi è gratis e non vi avvicina al 429.
HTTP/1.1 429 Too Many Requests
Retry-After: 43
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 43
{"error":{"code":"RATE_LIMIT","message":"massimo 300 richieste al minuto"}}
401, 403, 400 e 413 riprovare non serve: cambiate la richiesta. Il client di riferimento onora già Retry-After.Se il client lo scrivete voi: sei regole
Misurate, non consigliate a occhio: le abbiamo trovate addosso ai nostri client, dopo che trpg le ha trovate sul suo (28/9). Pagina autonoma da ricopiare: gli errori visti dal vostro client.
erroredetailsono strutture, non stringhe. Concatenarle in un messaggio produceArrayin PHP (con avviso) e ilreprdi un dizionario in Python: da trpg ogni nostro errore diventava la parola «Array», eKEY_MISSING/KEY_INVALID/RATE_LIMITnon arrivavano mai a chi legge — e non in un log, nella risposta della loro API a un utente. Costruite il messaggio con tre pezzi: stato HTTP,error.code,error.message.- Il
422va gestito a parte: non hacode, quindi chi ramifica solo suerror.coderesta muto proprio sull'errore che si incontra per primo. Leggetedetail[].locedetail[].msg(vi dicono quale campo), maidetail[].input, che è l'eco dei vostri testi (vedi la riga422sopra). 200non vuol dire «i vettori sono tutti». Contate:datadeve avere tanti elementi quanti i testi inviati e gliindexdevono essere0…n-1; su/v1/rerank,resultsne hamin(top_n, documents)quandotop_nè positivo — altrimenti tutti, e la risposta lo dice inignored_values— e ogniindexsta nell'intervallo. Se ne arrivano meno e voi accoppiate con unozip, indicizzate il testo sbagliato col vettore sbagliato e non ve ne accorgete (a trpg, un200di forma inattesa tornava come successo con zero vettori eNULLal chiamante, in silenzio).- Una lista vuota non è una risposta. Trovata da trpg in casa propria il 28/9, tirando il filo di un nostro avviso, ed è la più cattiva delle quattro perché non produce nessun errore e nessun risultato: la loro ricerca scartava in silenzio (un
continuenudo, in due punti) ogni frammento con la dimensione diversa da quella della domanda, quindi un indice costruito con un modello e interrogato con un altro non dà errore, dà zero risultati e l'assistente risponde senza fonti. Misurato da loro in sola lettura: 0 risultati su 471 frammenti tutti scartati, nessuna traccia da nessuna parte. Quattro contromisure, tutte gratis. (a) All'avvio unGET /health(non vuole la chiave e non consuma nulla) e confrontatedimcon la dimensione del vostro indice — è l'unica delle quattro che vale sull'indice che avete già, perché la dimensione si misura e non serve che qualcuno l'abbia dichiarata. Il nome no: in/healthil campomodelè il modello sottostante (bge-m3), mentre/v1/modelse/v1/embeddingsusano il nome di listino (agilev-m3). Sono due vocabolari e confrontarli dà un disallineamento a ogni avvio con il servizio sano — l'inciampo che trpg ci ha segnalato il 28/9 dopo aver messo in opera la (a). La controprova misurata:/v1/embeddingsdiceagilev-m3,/embeddicebge-m3, e i vettori sono identici bit per bit. E la lunghezza si conta sui float: conoutput_dtypebinary/ubinaryun vettore da 1024 arriva come 128 valori (1024 bit impacchettati) e conencoding_format: base64come una stringa, quindilen(vettore) == dimregge solo sufloat. (b) Controllate ilmodeldella risposta, non quello della richiesta (/v1/embeddings→agilev-m3,/v1/rerank→agilev-rerank,/embed→bge-m3: il campo inviato oggi non è validato e/embednon lo guarda affatto) — e confrontatelo con quello che dichiara quella rotta, non con quello di un'altra: è il solo confronto di nomi che regge. (c) Mai uncontinuesu una dimensione che non combacia — contatelo e alzate la voce. (d) Scrivete il modello accanto al frammento nel momento in cui costruite l'indice: è il corollario che trpg ci ha mandato la sera del 28/9, e l'hanno pagato — 442 frammenti di un fornitore e 29 dei nostri, tutti a 1024 valori, nessun campo che dica quale, e l'informazione non è mai stata dichiarata da nessuna parte. Costa niente oggi; ricavarlo domani si può — è la contromisura (e), misurata, a una chiamata per frammento: qui avevamo scritto «è impossibile domani» ed era falso. (e) Se il modello non l'avete scritto, ricavatelo: rifate l'embedding del testo del frammento e calcolate il coseno col vettore salvato. Stesso testo e nostro modello danno coseno 1 (scarto massimo 2,2·10-12, e 3,9·10-12 attraverso il nostro deploy); un altro spazio da 1024 valori dà |coseno| ≤ 0,10; magazzinoint80,9998 e a un bit 0,75, testo ritoccato 0,9755 — separazione 7×, verdetto decidibile. Vale sull'indice che avete già: da quale fornitore viene un vettore già nel vostro indice. - Nessuna risposta deve uscire dal vostro client con codice «non so». È la regola del 29/9, e nasce dal contare quante forme può prendere una risposta nostra: cinque non hanno
error.code(vedi Errori: quattro contate il 29/9, più il413del corpo troppo grande trovato il 5/10), e in tutte e cinquedetailè una stringa, o il corpo non è nemmeno JSON, o non c'è corpo per niente — cioè cadono tutte nello stesso ramo del vostro lettore d'errore, quello che non sa cosa fare. Se lì mettete?, chi vi chiama riceve un allarme che non si può smistare né contare, ed è lo stesso difetto della regola 1 vestito da caso limite. La risposta non è indovinare dal corpo — quei corpi non sono nostri e nessuno ha promesso cosa ci metterà dentro — ma dedurre il codice dallo stato HTTP, che è l'unica parte garantita:404→ percorso sbagliato (colpa vostra, non ritentate),405→ metodo sbagliato (non ritentate),3xx→ un rimando, quasi sempre una barra finale di troppo: toglietela, oppure seguite ilLocation(è assoluto e in https, conserva metodo e corpo e non consuma) — aggiunto l'1/10,5xxsenza forma → il bordo non è arrivato al servizio o il servizio è caduto senza forma (ritentate), tutto il resto → un codice che dica «senza codice» e riporti lo stato. Sono cinque comportamenti diversi e con?erano uno solo. Secondo corollario: il corpo di quelle risposte non mettetelo nel messaggio. Una pagina d'errore di un proxy di mezzo può contenere qualunque cosa, e finirebbe nei vostri log etichettata come un errore di AgileV — è la stessa regola dell'eco del422, applicata a corpi che non sono nostri.
6. La configurazione che sparisce dentro un server web. Regola del 29/9, e non ce l'ha insegnata un errore: ce l'ha insegnata trpg misurando in casa propria. La stessa variabile d'ambiente non si legge allo stesso modo in tutte le stanze: sotto Apache $_SERVER['LLM_API_URL'] è null mentre getenv() e $_ENV portano il valore (misura loro); da riga di comando è l'opposto — getenv() e $_SERVER portano il valore e $_ENV è vuoto (misura nostra, sul nostro box); e php-fpm con clear_env = yes, che è il predefinito, svuota l'ambiente del processo e lascia solo $_SERVER (documentazione, non una misura nostra). Chi legge una sola sorgente sta scommettendo sulla stanza, e quando perde non c'è nessun errore. Per AgileV la variabile che sparisce è l'indirizzo, e il ripiego è la produzione pubblica: se avete puntato il client su un fronte privato, i vostri testi escono su Internet in silenzio. Quattro contromisure: (a) leggete l'ambiente da tutte le sorgenti, in ordine dichiarato; (b) un valore presente ma vuoto è un errore che nomina la variabile, mai un ripiego; (c) dite sempre da dove arriva l'indirizzo, e della chiave la sorgente e mai il valore; (d) se l'indirizzo deve essere privato, la guardia va scritta nel codice — una guardia accesa da una variabile d'ambiente sparisce insieme a ciò che deve proteggere. Dettagli e numeri: la pagina delle sei regole.
Nei nostri quattro client di riferimento (agilev_client.py, agilev_client.php, agilev_client.mjs, agilev_langchain.py) le prime cinque regole sono applicate (le prime quattro dal 28/9, la quinta dal 29/9; nel client di Node e nel freno dell'adattatore LangChain dal 9/10/2026): ricopiateli, nessuna firma è cambiata. Il 422 diventa BAD_BODY con corpo della richiesta non valido - body.input: Field required, e un data mutilato o assente diventa BAD_RESPONSE con attesi 2 elementi in data, ricevuti 1. Dal 29/9 le quattro risposte senza la nostra forma escono con NOT_FOUND (404), BAD_METHOD (405), UPSTREAM_ERROR (5xx senza forma, ritentabile) e NO_CODE (il fondo della tabella: sconosciuto sì, muto no), e il corpo non entra nel messaggio. Questi sei codici li mette il client, non il servizio: non cercateli nelle nostre risposte. Misura: bench/misura-errori-client.py, 15 scene × 3 client (i tre di allora; il client di Node ha le sue 18 prove e le sei scene del suo banco) — righe senza un codice nostro da 15 a 3 il 28/9 e da 12 a 0 il 29/9, righe con l'eco dei vostri testi 0, vettori disallineati in silenzio 0. Stesso risultato catturando i corpi dal codice 1.1 in produzione e dalla 1.2: potete ricopiare i client oggi, non dipende dal nostro deploy. Dal 29/9 le sei regole sono applicate in tutti e SEI gli esempi che pubblichiamo (i tre client di allora più ricerca_ibrida.py, ricerca_ibrida.php, qdrant_ibrido.py), e dal 9/10/2026 sono SETTE col client di Node: l'indirizzo non finisce più in silenzio sul pubblico, un valore vuoto è un errore, e i client dicono da dove l'hanno preso. Chi non configura niente ha esattamente il comportamento di prima. Misura: bench/misura-indirizzo-che-sparisce.py — celle in silenzio 4 → 0, client che spedivano il testo senza chiave 2 → 0, celle non controllabili senza fare una richiesta 4 → 0; 22 mutazioni su 22 viste.
Prestazioni, e la cosa più utile che potete fare
Misure reali su un frammento breve: l'elaborazione dura circa 100 ms, ma una richiesta vista dall'esterno ne impiega circa 370. La differenza non è il modello: è la stretta di mano TLS, e si paga a ogni connessione nuova, non a ogni richiesta.
Riusate la connessione e vi riprendete quei ~250 ms su tutte le chiamate dopo la prima:
# Python
import httpx
client = httpx.Client(base_url="https://api.agilev.agile.software",
headers={"Authorization": f"Bearer {CHIAVE}"}) # riusatelo!
# PHP (Guzzle tiene la connessione viva da solo, se riusate il client)
$http = new GuzzleHttp\Client(['base_uri' => 'https://api.agilev.agile.software']);
# Node, con https.request
const agent = new https.Agent({ keepAlive: true });
# Node >= 18, con fetch: il keep-alive c'è già, la regola è non spegnerlo.
# Tutto il resto (lotti, freno, Retry-After, timeout) sta nel client: /esempi/agilev_client.mjs
Per indicizzare grandi quantità, mandate lotti (fino a 128 testi) invece di richieste singole: il costo fisso per chiamata si divide su tutti i testi del lotto.
Un testo già visto di recente viene servito da una cache in RAM (sole impronte, mai testi) senza ricalcolo. La cache è separata per cliente (dal 7/10/2026): un testo che ha fatto calcolare un altro cliente, per voi è un testo nuovo — fra clienti diversi non si riusa niente, né i vettori né il tempo di risposta. Le chiavi dello stesso cliente, comprese quelle ruotate, condividono la cache come sempre. Quanti dei vostri testi sono arrivati da lì lo dicono usage.cached_texts in ogni risposta e cached_texts in GET /v1/usage, mese per mese.
Timeout del client e lotti
Quaranta richieste insieme: cosa succedeva, e cosa succede. Mandarne molte in parallelo non è solo inutile, fino al contratto 1.1 era dannoso, e il numero è netto: le richieste in volo occupano i posti di una fila che ne ha quaranta, e la quarantunesima aspetta — /health compresa, perché era una rotta come le altre. Misurato il 29/9 con 48 richieste insieme, cinque giri per braccio, in gabbia e senza toccare la produzione: prima, sonde della salute perse in 5 giri su 5 durante il riscaldamento e in 3 su 5 a servizio caldo (fino a 20 s di silenzio), e 500 in 5 giri su 5 (RuntimeError: Already borrowed, nostro, su un servizio sano: due strade tokenizzavano lo stesso oggetto insieme). Dopo la correzione, sulla stessa scena: 0 sonde perse su 883 e 0 risposte non-200 su 480. Dal 1.2 quaranta richieste insieme sono dunque innocue; restano inutili, e la ragione è il paragrafo qui sotto. In produzione non era mai scattato perché a settembre il massimo misurato è stato 3 richieste in volo (log del bordo, 1.084 richieste): il caso da temere non era il traffico di ogni giorno, era il deploy annunciato, con i client che si sbloccano tutti nello stesso istante.
Il servizio calcola una richiesta alla volta (è limitato dalla CPU): un lotto di 10 frammenti da 1.400 caratteri costa circa 11 s da solo, e ogni lotto che arriva mentre un altro è in corso aspetta che quello finisca per intero (misurato: 2 lotti in parallelo 21 s, 4 lotti 42 s). Per indicizzare, quindi: un lotto alla volta, non in parallelo, con un timeout di almeno 120 s. Un timeout corto (30 s) su lotti grandi produce il caso peggiore: il client chiude, ritenta lo stesso lotto, e la coda si allunga di richieste identiche.
Documenti lunghi: spezzateli prima di spedirli. Il limite è 32.000 caratteri per testo (413 TEXT_TOO_LONG) e 8.192 token, ma la misura giusta per la ricerca è molto più piccola: frammenti da ~1.500 caratteri (circa 350-400 token in italiano; è la misura con cui abbiamo fatto tutte le prove sul corpus normativo), tagliati dove il testo si taglia da solo (a capo, fine frase, poi fra le parole), con le ultime frasi intere del frammento precedente ripetute in testa al successivo (~150 caratteri) così un concetto a cavallo del taglio resta intero in almeno uno dei due, e con l'intestazione del documento ripetuta in ogni frammento («Art. 25-septies Rubrica»: è ciò che permette di ritrovare un articolo citato per numero). Il client di riferimento lo fa per voi: spezza(testo, massimo=1500, sovrapposizione=150, intestazione="…") in Python e AgileV::spezza(...) in PHP producono gli stessi frammenti (provato sui 153 articoli del corpus: 468 frammenti, nessuno oltre 1.500 caratteri o 400 token, ogni frase ritrovata intera; l'articolo più lungo, 55.928 caratteri, che intero sarebbe un 413, diventa 44 frammenti calcolati in 40 s con una sola richiesta). Misura del 22/9 sui 153 articoli: con 1.000, 1.500 e 2.000 caratteri l'ibrido dà nDCG@10 0,766 / 0,754 / 0,779, cioè una o due query su 69 di differenza, nessun vincitore netto; 2.000/200 è la misura migliore e la più economica su normativa lunga (362 vettori invece di 460, stesso tempo di calcolo: il costo è per carattere, non per frammento), quindi chi indicizza articoli di legge può passare massimo=2000, sovrapposizione=200.
Dal contratto 1.2 una richiesta chiusa dal client prima che il calcolo cominci non viene calcolata né contata (CLIENT_GONE nei nostri log, mai nei vostri consumi); una già iniziata non si porta più a termine a vuoto: il calcolo procede a passaggi (una richiesta lunga ne ha parecchi: 4 testi da 32.000 caratteri sono 4 passaggi da ~45 s) e fra un passaggio e l'altro controlliamo se siete ancora collegati; se no ci fermiamo lì, la richiesta non è contata e i testi dei passaggi già fatti sono in cache, quindi il tentativo successivo riparte da dove era invece che da zero (provato: 4 testi lunghi, client chiuso a 70 s, servizio fermo al passaggio dopo, ritentativo con 2 testi presi dalla cache). Su /v1/rerank il controllo non dipende dai passaggi: un rerank fino a 16 documenti è un passaggio solo (ogni coppia domanda-documento è tagliata a 1.024 token, quindi il numero dei passaggi dipende dai documenti e non dalla loro lunghezza), e fra un passaggio e il successivo non ci sarebbe niente da controllare; perciò prima di scrivere il consumo guardiamo una volta se siete ancora collegati (prima del 6/10 non lo facevamo, e un rerank in un passaggio solo chiuso dal vostro client veniva contato). Il modo più semplice per rispettare tutto questo è copiare il client di riferimento (esempi/agilev_client.py / .php / .mjs): al timeout dimezza il lotto invece di ripeterlo, ritenta solo dove serve, riusa la connessione.
Migrare da Voyage o OpenAI
Con la libreria OpenAI cambiano tre righe:
from openai import OpenAI
c = OpenAI(base_url="https://api.agilev.agile.software/v1", api_key=AGILEV_KEY)
v = c.embeddings.create(model="agilev-m3", input=["testo"]).data[0].embedding # 1024 numeri
voyage-3 ne ha 1024, voyage-3-lite 512, text-embedding-3-small 1536. Se il numero cambia — o anche se resta uguale ma il modello è diverso — l'indice va ricostruito da zero: vettori di modelli diversi non sono confrontabili, e mescolarli produce risultati silenziosamente sbagliati.input_type, truncation e output_dtype si scrivono con la stessa sintassi a cui siete abituati.
Usare i vettori in Qdrant
from qdrant_client import QdrantClient, models
client.create_collection(
"documenti",
vectors_config=models.VectorParams(size=1024, distance=models.Distance.COSINE),
)
I vettori sono già normalizzati, quindi coseno e prodotto scalare danno lo stesso ordinamento. Una ricetta che funziona bene: recuperate 30-50 candidati dall'indice, poi passateli a /v1/rerank e tenete i primi 3-5. Costa una chiamata in più e migliora sensibilmente ciò che finisce nel contesto del modello linguistico.
Quanto costa in tempo, perché il timeout va messo di conseguenza. Il rerank costa per carattere della coppia (domanda + documento), non per documento: misurato il 6/10 sui nostri contatori, ~2,1 ms per carattere. Quindi 30 candidati da 662 caratteri sono ~43 s, cioè dentro i 120 s che consigliamo e fuori da un timeout di 30 s — e il paragrafo «Timeout del client e lotti» parla di lotti di embedding, non di rerank. Le due leve, in ordine: accorciate i candidati (gli stessi 30 documenti tagliati a 200 caratteri costano ~13 s, e il reranker guarda comunque i primi ~1.024 token di ognuno) e alzate il timeout a 120 s. Tenerne 30-50 con un timeout di 30 s è il caso peggiore: il vostro client chiude, il nostro calcolo è già partito, e voi non vedete niente.
Da quale fornitore viene un vettore che è già nel vostro indice
Serve a chi ha un indice scritto in più riprese, o passato da un fornitore a un altro, senza un campo che dica quale modello ha scritto quale frammento. La prova: rifate l'embedding del testo del frammento e calcolate il coseno col vettore salvato. Stesso spazio → 1; spazio di un altro modello → 0, perché le sue coordinate sono scritte in una base arbitraria rispetto alla nostra. Funziona sull'indice che avete già, e non richiede che qualcuno abbia dichiarato niente.
| Coseno | Verdetto | Cosa fare |
|---|---|---|
1 − coseno ≤ 1e−6 | nostro, e di questo testo | niente: è il caso normale su un indice sano in float32 |
coseno ≥ 0,50 | nostro spazio | niente, ma se non è 1 sono cambiati il testo o il magazzino |
0,15 < coseno < 0,50 | da guardare | guardate il testo di quel frammento prima di concludere |
coseno ≤ 0,15 | altro spazio | quel frammento non l'abbiamo scritto noi |
Misurato il 28/9 col modello vero in gabbia di memoria su 64 frammenti nostri, zero richieste alla produzione — 13 scene, 0 KO: stesso testo 1,00000000 (anche scritto in un lotto diverso; scarto massimo dal coseno 1 2,2·10-12, e 3,9·10-12 perfino attraverso il nostro deploy 1.1 → 1.2, quindi il verdetto è lo stesso prima e dopo); magazzino float16 0,99999998 (tiene anche il verdetto esatto); magazzino int8 0,99983766; magazzino a un bit 0,75374678; testo ritoccato dopo 0,97554815; testo preparato dall'indicizzatore (titolo, prefisso, taglio) 0,75475971; altro frammento nostro 0,78578071; testo senza nulla in comune 0,23538498; altro spazio (rotazione ortogonale, permutazione delle coordinate, segni ribaltati, e con int8) fra −0,10105777 e +0,058. Fra i nostri e gli estranei ci sono 7 volte e in mezzo non c'è niente: il verdetto è decidibile.
Al posto di un fornitore esterno — che non usiamo e di cui non abbiamo i vettori — l'«altro spazio» è un'isometria del nostro: stessa dimensione, stessa norma, stessa geometria interna, base diversa. Un modello vero allenato per conto suo può solo essere meno allineato con noi di così, non più.
Quanti frammenti guardare, per accorgersi di una contaminazione con fiducia 95 %: 5 se gli estranei sono il 50 %, 14 al 20 %, 29 al 10 %, 59 al 5 %, 299 all'1 %. Il costo: ~0,26 s e i caratteri di un frammento da 400 caratteri; un indice da 471 frammenti si etichetta tutto in due minuti di servizio, una volta sola. Il tranello: se l'indicizzatore prepara il testo prima di mandarlo (titolo davanti, taglio, prefisso, minuscole), il vettore salvato è di quel testo e rifare l'embedding sul grezzo confronta due testi diversi — misurato, coseno 0,7548 nel caso peggiore: il verdetto sullo spazio resta «nostro», ma la soglia esatta 1e−6 non regge più su nessun frammento, e chi usa solo quella condanna un indice sano per intero. Rifate l'embedding passando per la stessa funzione dell'indicizzatore. Protocollo in sei passi, da riscrivere nel vostro linguaggio: la provenienza dei vettori.
Un deploy nostro muove i vettori che avete nell'indice?
Domanda giusta, e finora la risposta era «no» detto e non misurato. Misurata il 28/9 sul modello vero, per il passaggio 1.1 → 1.2.
La 1.2 divide le richieste grosse in più passaggi nel modello, a budget di token, per non finire in memoria esaurita: raggruppare i testi diversamente vuol dire padding diverso, e in virgola mobile gli ultimi bit possono cambiare. Le due strade sono state messe a confronto sullo stesso oggetto modello, così che l'unica variabile fosse il raggruppamento.
| La vostra richiesta | Cosa cambia col deploy |
|---|---|
| sta in un passaggio solo (fino a 16 testi, o testi corti) | niente: vettori identici bit per bit, coseno 1,00000000 |
| supera il budget e viene divisa (10 testi da 1.822 token → 2 passaggi) | 2 vettori su 10 si muovono; coseno peggiore 0,9999999999960902, scarto massimo su una componente 3,06·10-7 |
| riordino, 10 e 40 documenti | l'ordine non cambia; punteggi entro 4·10-11 |
Cosa farne. La soglia che vi abbiamo dato per fermarvi è 0,9999, e ora si sa quanto margine ha: il deploy ne consuma quattro cento milionesimi. La regola diventa quindi più forte, non più debole — se la vostra misura del «dopo» scende sotto 0,9999, non è il cambio di raggruppamento: è un guasto vero, ed è nostro. Fermatevi e scriveteci. E per rendere la vostra misura più sensibile, congelate il riferimento su frasi corte, poche per richiesta: la richiesta sta in un passaggio solo e il confronto prima/dopo torna esatto bit per bit.
Misurato anche il costo: i lotti a budget non costano tempo (65,2 s → 64,4 s sui testi lunghi, 130,2 s → 131,8 s sul riordino). Tengono giù la memoria a parità di velocità e, ora si sa, a parità di vettori.