API documentation

Everything you need to integrate AgileV: endpoints, parameters, errors, limits and working examples. If something is unclear or does not behave the way it is written here, tell us: that is a defect in the documentation.

On this page

Basics and authentication

Base URL: https://api.agilev.agile.software

Every authenticated request carries the key in a header. These are equivalent:

Authorization: Bearer agv_…
X-Api-Key: agv_…

Every key has a scope (embed, rerank), a per-minute limit and a monthly character quota. See your own usage with GET /v1/usage: a key only ever sees its own.

And where the key comes from. You ask for a licence — the form is on the AgileV page, and the three plans with their quota and per-minute limit are under Plans — and keys sit underneath the licence: you can rotate a key without touching the contract, and suspend a contract without changing the key. If you would rather call the form from a program, the endpoint is POST /v1/richieste-licenza.

A key can belong to a digital person, and then every request says on whose behalf it computes (since 11/10/2026). It is something extra, not a change: today's keys belong to no person and have nothing to do — same body, same vectors, same cache, same invoice. When instead a key is issued for a person (the case of the store's digital people), that person is set by the engine on every request: you need not write anything in the body, and you may send the persona field only to name its own — a name other than its own is 403 PERSONA_UNKNOWN (since 12/10/2026: until the 11th the field was required and without it you got 400 PERSONA_MISSING). And the other way round: a key that belongs to no person and sends that field gets 403 PERSONA_UNKNOWN instead of a 200, because computing, caching and billing on behalf of a subject nobody has declared is exactly what this rule exists to prevent.

Texts are not kept. AgileV processes and forgets: no content ever ends up in a log, a database or a file. All that is left of your traffic is numeric counters (requests, texts, characters, milliseconds) per key.

Endpoints

GET/health — service status

No key needed. loaded says whether the models are in memory; right after a restart it can be false for twenty seconds or so. contract is the version of the contract in execution, novita where to read what changed: compare them with your last reading to know whether there is anything to assess.

/health answers even when the service is busy, and that is a new promise of contract 1.2 (29/9): this endpoint does not go through the queue the compute requests sit in, so it counts as a liveness check under load too. That was not true before, and not by a little: with 48 compute requests at once /health did not answer at all for tens of seconds (measured on service 1.1: 10 s probes all timed out, 20 s of silence in a row in the worst case, in 5 runs out of 5), so anyone using it to see whether the service was alive read «dead» on a service that was working. If you have monitoring calling /health, from 1.2 on you can believe it; and if it answers ok:true while your requests are slow, that is not a fault: it is the queue, and the queue is read in the timings, not in the health.

{"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 says whether the service is queueing, and ok never changes because of it (29/9). Compute requests take a seat in a queue of 40: a seat is held by a request that is working or waiting its turn on the model. Four names: libera (free, below 80 % of the seats), carica (loaded — above that, but a seat is left), satura (full: no free seat, or someone is already waiting, so a newcomer waits), indecidibile (undecidable: we could not read the counter — which means we do not know, not everything is fine). Next to the name there are the numbers — posti (seats), occupati (taken), in_attesa (waiting) — so you can redo the arithmetic yourself. Measured with 48 simultaneous requests: 95 readings out of 95 carry the field, occupati 40, in_attesa 8; on an idle service, libera with 0 taken.

How to read it, and how not to: satura is not a fault — ok stays true and the status stays 200, by design, so if monitoring or a grep decides «alive/dead» from ok, keep reading that: a queueing service must not be restarted. It is there to let you read your own timings (with a full queue, the time you measure is waiting, not computing); the real brake is still the 429 with Retry-After. It is a snapshot, not an average. And a missing field is not libera: a service older than this contract does not send it at all, and then your check must say «I do not know».

GET/v1/models — available models

No key needed. Lists only the models actually available on this instance.

{"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 says the model can also return the lexical weights (return_sparse, contract 1.2).

The model field — what it does today (and what will change, with notice)

Today no model name is rejected: the field is recorded, not validated. Measured on 28/9 over 36 cases (4 endpoints × 9 names), on the 1.1 code in production and on 1.2, with identical outcomes on both: zero 400. Deploying 1.2 does not change a single line of this table.

Name sent/v1/embeddings, /v1/tokenize/v1/rerank/embed
agilev-m3recognisedserved, counted as unknownignored
agilev-rerankserved, counted as unknownrecognisedignored
bge-m3recognised (alias)served, counted as unknownignored
agilev-m3-fastrecognised, but today 503 MODEL_UNAVAILABLE on /v1/embeddings (on /v1/tokenize it answers 200: tokens are counted with the tokenizer, which is the same for both variants)served, counted as unknownignored
anything else (voyage-3, modello-che-non-esiste…)served, counted as unknownserved, counted as unknownignored
"", null, field absentrecognised: the default value appliesrecognised: the default value appliesignored
An empty name is not an error, and that is exactly why it deserves watching. "", null and the missing field all count as «default» and are not counted anywhere. If the model name reaches you from an environment variable and that variable is empty, today you will not notice: the check that holds is the model of the response, which always declares the model used — /v1/rerank always answers "model":"agilev-rerank", /v1/embeddings "model":"agilev-m3", /embed "model":"bge-m3". Compare that, not what you sent.

/embed does not look at the field at all: the historical format only has texts, and a model in the body is discarded without even being counted. A request with an unknown name is served and billed all the same: the validation is of form, not of substance.

How to know whether this concerns you, without asking us (since 29/9/2026): GET /v1/usage carries the model_unknown field — how many times your key sent a name we do not recognise, by month and by endpoint. It is free and a machine check can watch it. Empty is the normal answer.

When it will become 400 MODEL_UNKNOWN. Not soon: on 29/9 at 03:2x, read from the production counters, 4 of the 7 successful rerank requests since the restart of 27/9 carried an unrecognised name (on 28/9 it was 3 out of 6). Before anything changes, all four of these things will happen, in this order: (1) the unknown-name counter stays at zero steadily — and until 29/9 that sentence was not even measurable, because the counter lived in memory and restarted from zero at every restart: «steadily at zero» could be obtained by restarting. The per-key count is now on disk; (2) the counter becomes attributable — knowing that someone is getting it wrong without knowing whom to warn is not enough. Done on 29/9/2026: the service records, per key and per endpoint, how many requests carried a name it does not recognise (numbers only, never the name received, which is your text); (3) the entry appears in Release notes and a direct message reaches every key holder, with at least 30 days between the notice and the change; (4) the change ships in a deploy on its own, never together with anything else.

POST/v1/embeddings — vectors (OpenAI compatible)

Scope embed. The vectors are normalised: the dot product is already the cosine.

FieldTypeDefaultDescription
inputstring or list of strings—the text or texts to transform. Required.
modelstringagilev-m3model to use.
output_dtypestringfloatfloat, int8, uint8, binary, ubinary. See formats.
encoding_formatstringfloatfloat (list of numbers) or base64 of the raw bytes: a much smaller payload.
truncationbooleantruewith true a text beyond 8192 tokens is cut; with false the request is rejected with 413.
input_typestringabsentquery or document. Accepted for compatibility: here it does not change the result, see the note below.
return_sparsebooleanfalsefrom contract 1.2. With true every element of data additionally carries sparse_embedding (indices, values): the lexical weights of bge-m3, for hybrid search. agilev-m3 only. See lexical weights.
personastringabsentsince 11/10/2026. On whose behalf the computation is made. Required for nobody (since 12/10/2026): if your key belongs to a digital person and you write nothing, the person is its own and the engine sets it; if you do write it, it must be that one, and a different name is 403 PERSONA_UNKNOWN. If your key belongs to no person — every key issued up to today — the field is not sent: sending it is 403 PERSONA_UNKNOWN, not an ignored field. A label of 1-32 characters among letters, digits, _, ., -, the first one a letter or a digit.
tenantstringabsentsince 12/10/2026. Which customer the computation is for. Optional, and it will not become required without a date told beforehand: if you do not write it, the tenant is your key's and the engine sets it — that is what has always happened, and no request that today gets a 200 changes. If you do write it, it can only be your own: another name is 403 TENANT_UNKNOWN. Written right it is a declaration we verify, so it does not end up in ignored_fields, and it changes nothing of what you receive: same vectors, same cache, same invoice. It holds on the same four compute endpoints as persona.
A field we do not know gets dropped — and from today we tell you. If the request carries a field the route does not declare, the field is not applied and the response lists it in ignored_fields (a list of names, present only when there is at least one: a healthy response never carries it). The status does not change: an otherwise valid request stays 200/201. Before today that field was dropped silently, and whoever got the name wrong received a response different from the one they asked for, with no line saying so.
written like thiscomes fromhere
dimensionsOpenAI (text-embedding-3-*)does not exist: the dimensions are 1024 and are not chosen. Truncating a bge-m3 vector is not something the model provides for: if your index wants 512 or 1536, the index gets rebuilt, not the vector shortened.
top_kCohere (and adapters written for them)it is called top_n on /v1/rerank. With the wrong name you receive all the documents, not the top k.
truncateVoyageit is called truncation (boolean).

persona is not one of these: it is a field the route applies, and that is why it is refused instead of ignored. It holds on the four compute endpoints — /v1/embeddings, /v1/rerank, /v1/tokenize and /embed too. If your key belongs to no person, nothing changes: do not send the field and everything stays as it was, cache included. If it belongs to a digital person, you still need do nothing: the person the key was issued for is set by the engine on every request, and if you do write the field it must name that one — a key is good for that person and no other. The 403 says PERSONA_UNKNOWN and not "it is not yours", because telling you that a person exists but belongs to someone else would already be another person's data.

And tenant has the same nature (since 12/10/2026): it too is a field the four compute endpoints apply, and that is why it is refused instead of ignored. The difference from persona is that every key has a tenant, so anyone can write the field — but only to name their own: another name is 403 TENANT_UNKNOWN, "unknown" and not "not yours" for the same reason as PERSONA_UNKNOWN. If you do not write it, nothing changes: the tenant is your key's and the engine sets it, today as yesterday. Do not confuse it with the tenant of the administration routes below: there the field was already in place and holds as before.

All these refusals are counted like the others: you find them in GET /v1/usage, field rejected.

On the administration routes (/admin/*) an extra field is not merely listed: if it is the name the sibling route uses for the same thing — rate_per_min/limite_al_minuto, quota_chars_month/quota_mensile, tenant_id/tenant — the call is refused with 400 FIELD_WRONG_NAME and the message says what the field is called on that route. The reason is that there the damage was worse than the silence: a key requested with limite_al_minuto: 300 was born at 120, and one requested with tenant_id was born in our tenant, that is outside the customer's invoice.

Honesty about input_type. Other providers use this field to encode questions and documents differently. bge-m3 is trained without instructions and produces the same vector in both cases: we accept the field so that whoever arrives from Voyage does not have to change code, but it does not improve quality. We would rather say so than let you believe otherwise.
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}}

The order of data always follows the order of input, and index confirms it. usage.prompt_tokens is the tokenizer's real count; usage.chars is the unit the quota is measured in.

usage.cached_texts (since 7/10/2026): how many texts of this request were already in our cache, that is did not go through the model. They are billed like all the others — quota and price are measured on the characters you send us, usage.chars does not change — and the field is there so that you can notice. The cache is separate per customer: "already seen" means seen from you.

Lexical weights (return_sparse, contract 1.2)

Why they exist: on texts with numbered articles the dense vector alone confuses «25-bis» with «25-ter» or «25-undevicies» (on the Italian legislative decree 231/2001 the cited article comes first in 1 case out of 28). Lexical weights count tokens, and they resolve citations; mixed with the dense vector (hybrid search) overall quality rises from 0.63 to 0.76 nDCG@10 on our test corpus (public legislation). The details are in the release notes.

{"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]}}

POST/v1/rerank — reordering by relevance

Scope rerank. A dedicated model (a cross-encoder) compares the question with each document: more precise than vector similarity alone, but to be used on few candidates because it costs more.

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}}

The results arrive already sorted, most relevant first; index refers to the position in the documents array you sent. With top_n you only receive the first few.

The query is a text like the documents: same ceiling of 32,000 characters (413 TEXT_TOO_LONG, and the message says which of the two fields went over), and it is paid once per document. The count, and the maximum a request can cost you, are in Limits.

top_n is applied only when it is positive. 0, absent or negative all mean «no limit»: you receive every document, re-ordered, and when the value was there and could not be applied the response says so in ignored_values: ["top_n"]. This is the case of whoever computes it — top_n = how_many_I_need - how_many_I_have — and reaches zero or below: before, a -1 cut from the tail (out of 4 documents 3 came back, with the worst one thrown away) and a -4 returned zero, always with 200 and always with every document billed. Documents are paid for one by one: do the cut yourself on the scores, which all arrive.

The required fields are query and documents: input does not exist here, and a request without documents receives 422. model, on the other hand, makes nothing fail today: {"model":"agilev-m3", …} on /v1/rerank works and answers "model":"agilev-rerank", because the name is only counted. If a rerank does not go through, the model name is not the cause: look at documents. Measured on 28/9 on the 1.1 code in production and on 1.2.

POST/v1/tokenize — counting tokens before sending

Scope embed. Does not consume quota. It is there to know in advance whether a text will be cut.

{"model":"agilev-m3","max_tokens":8192,
 "data":[{"index":0,"tokens":11,"characters":45,"truncated":false}],
 "usage":{"total_tokens":11}}

POST/embed — the historical format

Scope embed. A frozen contract for the earliest integrators: {"texts":[…]} → {"vectors":[[…]],"model":"bge-m3","dim":1024}. Always float at 1024 dimensions, ignores the newer parameters. For everything else use /v1/embeddings.

The body is texts, not input: this endpoint does not speak OpenAI. {"model":"agilev-m3","input":[…]} on /embed answers 422 (missing texts field), and in the same way {"texts":[…]} on /v1/embeddings answers 422 (missing input field). It is not a fault and it does not change with the contract: measured on 28/9 on the 1.1 code in production and on 1.2. A new client should use /v1/embeddings with the OpenAI body; /embed stays for whoever integrated AgileVoyage.

GET/v1/usage — your usage

Requests, texts, characters and milliseconds by month and operation, plus the key's limits. A key sees only its own usage.

cached_texts (since 7/10/2026): how many of those texts, in that month, our cache served — they did not go through the model and they are billed like all the others. It is the number to look at before asking us for a higher quota: if it is nearly equal to texts, you are sending the same texts many times over.

On a revoked key it answers 403 KEY_REVOKED, on an expired key 403 KEY_EXPIRED. The expires_at field says when the key ends (epoch seconds UTC; 0 = never expires): it is the way to notice that a trial key is about to end before it stops answering.

last_at and rejected: "are we still talking?", from both sides (since 10/10/2026). last_at is the instant of the key's last successful call, on any operation (epoch UTC seconds; 0 = it has never made one), and the same field is on every usage row, month by month. rejected is the other half of the same question: one row per month, operation and code, with how many times we turned you away and when the last one was — the 403s (KEY_REVOKED, KEY_EXPIRED, SCOPE_NOT_ALLOWED and the contract codes) and the 429s (RATE_LIMIT, QUOTA_EXCEEDED). An empty list is the normal answer; both fields are free like the rest of this route, and whoever does not read them sees nothing change.

"last_at":1791620771,
"rejected":[{"month":"2026-10","op":"embed","code":"RATE_LIMIT","requests":245,"last_at":1791619502}]

They are there because the month's numbers do not tell you whether an integration has stopped: a row that does not grow is identical to a customer who had no work that month, and a deployment that starts at night with the wrong key sees its 403s in the instant it takes them and then nothing — until 10/10 those refusals lived in RAM, aggregated per route and zeroed at every restart of the service. Four limits, stated before you rely on it. The 401s (KEY_MISSING, KEY_INVALID) are not here and will not be: they come from a key that is not in our store, so there is no key to charge them to, and the fingerprint of a secret that is not ours is not data we want to keep — a key wrong by one character is visible only on your side. The numbers start on 10/10/2026 and are not retroactive: months already written have last_at at 0 until the first successful call arrives. On a revoked or expired key this route answers 403 like all the others, so the key you would want to investigate is exactly the one that no longer opens for you: refusals are per key and in that case they stay visible only to us — ask us for them; what you can read with your own key are the 429s and the 403 SCOPE_NOT_ALLOWED, because there the key stays good. The lists are capped: last 24 months for usage, 48 most recent rows for rejected.

Trial keys (scope prova). To let people try AgileV there are keys with the prova scope: they cover /v1/embeddings, /embed, /v1/rerank and /v1/tokenize — that is, the service — and they do not cover /metrics (those are our own numbers, aggregated over all customers) nor key administration. They always have an expiry: a trial key without an end date is not accepted, and the rule lives in the service, not only in whoever issues it. Once the date has passed, every request, GET /v1/usage included, answers 403 KEY_EXPIRED.

Under, never over (since 29/9/2026). If our usage store has a fault — full disk, locked file — your request is served anyway and that usage row is lost. So the numbers here, and the invoice that comes out of them, can be lower than the truth; they will never be higher. For the same reason, during such a fault a quota-limited key can overshoot: we would rather serve you and lose the count than deny you an answer over a number that matters to us. Before 29/9 it went the other way round: the answer, already computed, was thrown away and you received a 500. During a prolonged fault the waiting is paid by one request only: at the first fault we stop trying to write for 30 seconds, and when that expires one single request tries again to see whether the store is back (measured on 29/9: without this rule it was 8 requests out of 8 stalling for 10 s at every wake-up, now 1 out of 8).

model_unknown: the list that should stay empty (since 29/9/2026). How many times your key sent us a model field we do not recognise, by month and by endpoint, with the time of the last occurrence (last_at, Unix seconds UTC):

"model_unknown":[{"month":"2026-09","op":"rerank","requests":4,"last_at":1790651460}]

Today those names are served anyway and nothing happens to you (see The model field); the day 400 MODEL_UNKNOWN arrives, they would be errors. It was born from a customer's question (28/9): we send the model name from an environment variable, we want to notice before our own customers do. Looking at it does not consume quota or per-minute requests, so it can go into an automated check: if the list is not empty, your configuration is sending a name we do not know — the valid names are those of /v1/models.

Four limits, stated before you rely on it. An empty list does not mean the configuration is right: "", null and the missing field count as default and are not counted (if your environment variable is empty, it does not show up here — it shows up in the model of the response). And /embed does not look at the field at all: whoever uses the historical format will always have an empty list. Third limit, since 29/9, and trpg told us after measuring it in house: the list is per key. If one day your installations have a key of their own, the names they send do not appear under yours. And the first limit in reverse, for anyone with a fallback in their code: if your empty variable triggers a ?: 'another-provider-name', that name is counted — so for you the list is more useful, not less. Fourth limit, since 29/9, and trpg measured this one too in their own house: if your code reads configuration from $_SERVER instead of getenv(), under a web server that value can be absent (in their container $_SERVER['LLM_API_URL'] is null under Apache, while getenv() and $_ENV carry the value) — and then there is no fallback to count: there is a configuration that disappears when the code runs inside the web server and works in every command-line test. From here it looks like a missing name, or like a key that does not appear at all in the list. The shape to recognise: the same key alternating the right name and no row at all, without anybody having touched anything.

POST/v1/richieste-licenza — requesting a licence

Public and keyless: it is the form through which a licence is requested, and whoever fills it in has, by definition, nothing yet. It is the only endpoint that writes something without authentication, so it has a per-address limit: at most 5 requests in an hour, then 429 RATE_LIMIT. The ready-made form is on the AgileV page; here is the endpoint, for whoever wants to call it from a program.

It answers in the form it is spoken to: with application/x-www-form-urlencoded (a <form method="post"> without enctype) it returns a page with the request number; with application/json it returns 201 and {"n":<number>,"messaggio":"Richiesta registrata"}. With ?lang=en it answers in English.

FieldWhat it isMaximum
chiwho is asking (company or person, however you prefer to write it)120 characters
contattohow we get back to you120 characters
pianoone of prova, standard, enterprise — see the three plans—
usowhat you need it for, briefly500 characters

All four are required. The errors: 400 BAD_BODY (a body that is neither a form nor JSON), 400 CAMPO_MANCANTE, 413 CAMPO_TROPPO_LUNGO, 400 BAD_PLAN, 429 RATE_LIMIT and 503 NON_SALVATA. The message says which field, and repeats nothing of what you wrote.

This endpoint's 429 says when to come back, and it is not the minute (since 20/10/2026). The limit here is 5 requests in an hour from the same address, and the response carries Retry-After with the seconds left to the new hour in UTC, from 1 to 3,600 — the exact instant the counter reopens, not an estimate. It carries it in both shapes: the JSON response and the page of the landing page's <form>. Mind one thing, because the code is the same as on the endpoints with a key but the window is not: if your client caps Retry-After at 60 s — the right rule there, and the one our reference client applies — here it would knock up to 60 times inside the same closed hour instead of once. Honour the number in full.

The 503 is the only place where we do not swallow a storage fault. On /v1/embeddings a request is served even if the usage counter will not be written (under, never over): there, the service had already been given. Here the write is the service, and a «registered as no. 0» on top of a request that does not exist would be a person waiting for an answer nobody will give. If a 503 arrives, try again: it was not taken.

Vector formats

A float vector takes 4 KB. If you index millions of chunks, your vector database's memory becomes the main cost item: you can ask for the vectors already compressed.

output_dtypeSizeSpaceWhen to use it
float1024 × float32 = 4096 bytes100%default: maximum fidelity.
int8 / uint81024 bytes25%an excellent trade-off: the cosine against the float vector stays ≥ 0.997 in our measurements.
binary / ubinary128 bytes3%huge indexes: you search with Hamming distance and then reorder with the full vectors or with the reranker.

int8 quantisation uses a fixed and documented calibration, not an adaptive one: vectors requested at different times stay comparable with each other. It will not change under the name agilev-m3.

Do not mix different formats in the same collection. And never compare vectors produced by different models: an index built with one model has to be rebuilt if you change model.

Limits

LimitValueIf you exceed it
Texts per request128413 BATCH_TOO_LARGE
Characters per single text (the query of /v1/rerank too)32,000413 TEXT_TOO_LONG
Tokens per single text8192cut, or 413 with truncation:false
Request body8,000,000 bytes (8 MB, which is not 8 MiB)413 BODY_TOO_LARGE, from the edge
Requests per minuteaccording to the key, and according to the licence if there is one429 RATE_LIMIT
Characters per monthaccording to the key, and according to the licence if there is one, and according to the company if a cap has been set for it429 QUOTA_EXCEEDED
Requests at the same time (same key)no declared limitno error: they queue up

The first two limits are not reachable together with multibyte text, and the body ceiling comes first. 128 texts of 32,000 characters are within everything written above, but in UTF-8 a Chinese, Russian or Greek character weighs 2 or 3 bytes: with CJK text that request is 24,576,545 bytes, i.e. 3.07 times the body ceiling, and 42 texts out of 128 are enough to exceed it (measured to the byte in a cage). Anyone indexing in one of those languages hits the body ceiling while staying within the two published limits: the 413 they receive is written by the edge and its code is BODY_TOO_LARGE, not BATCH_TOO_LARGE. The calculation to do before sending is in bytes (len(json.dumps(body).encode("utf-8"))), not in characters; and the ceiling is 8,000,000, not 8 MiB — whoever sizes batches at 8,388,608 bytes hits it by 388,608 bytes. The reference client sends batches of 64 and does not get there even in Chinese.

How much a /v1/rerank request can cost you at most. The count on that route is len(query) × number of documents + sum of the documents: the query is paid once per document, because the cross-encoder reads the (query, document) pair once per document. The query was for months the only field of the contract without the ceiling of the row above, and it was exactly the one being multiplied: a query of 200,000 characters with 128 documents passed with 200 and counted 25,601,280 characters, and the largest body the edge accepts counted 1,023,750,400, i.e. 249.94 times the 4,096,000 you compute from the first two rows. With the same ceiling as a document the maximum of a rerank request is 8,192,000 characters — twice the implicit ceiling and not two hundred and fifty times it: its own for the documents, and one for the repeated query. If you exceed it you get 413 TEXT_TOO_LONG, and the message says which of the two fields (query or documents) went over, because on this route that 413 has two origins. No client has ever got there: the heaviest user of rerank has an average query under one hundred characters, measured from our counters.

The two middle rows have two numbers, not one, when your key sits under a licence (the level that arrived on 30/9: one contract, N keys underneath). The monthly quota and the per-minute limit are numbers of the contract, and the keys underneath consume them together: a licence of 5 per minute serves 5 requests a minute, whether there is one key underneath or ten. The rate_per_min of the individual key remains a ceiling of its own and is not widened by the contract; it is enough for one of the two to bite, and the error message says which. Keys without a licence (all those issued earlier) have a single number, their own.

The month is cut at 00:00Z, and we tell you so. The monthly quota restarts when the YYYY-MM label of the UTC clock changes, that is at the first instant of the new month: 00:00Z on day 1, which in Rome is 01:00 (02:00 with daylight saving), not your midnight. The same clock cuts the invoice and the labels that GET /v1/usage returns to you. It does not depend on our box's time zone nor on yours: if you have jobs that consume quota at the start of the month, that is the instant to wait for.

The last row is the one that gets misread. The per-minute limit does not limit simultaneous requests: a key of 120 per minute can have forty in flight at the same instant without taking a 429. You gain nothing by doing it — computation is one at a time, see Performance — and up to contract 1.1 you lost by it: the measured numbers are there.

Errors

Every error has the same body, with a stable code you can branch on.

The exceptions were four, not one; then, measuring in a cage, we found two more, and today none remains open. The 422 of the invalid body was the last one, and we closed it ourselves on 17/10 at 03:37:52Z: it has error.code BODY_INVALID, and it no longer sends back the texts you sent us. The three before it — the 405, the 413 of the oversized body and the three 50x of the engine that does not answer — were closed on 9/10 at 07:31Z, with the release that put our edge file at the edge. The «don't know» branch of your error reader, today, is reached by no response of ours. On 29/9 this page said «one single exception, the 422», and that was not true: there were four, and two of them had already happened to real customers. They are all responses AgileV does not write — they are written by the framework beneath us or by the edge in front of us, before our code sees the request. They have no error.code, they have detail as a string (or they are not even JSON), and they all fall into the same branch of your error reader. Two of the four were closed by the edge, on 29/9 and on 5/10. But one we had not counted, and we found it ourselves on 5/10 measuring in a cage: the 413 the edge writes when the body exceeds 8,000,000 bytes came out with zero bytes of body, with no Content-Type. And a sixth, found in a cage on 8/10: the 502/503/504 the edge writes when the engine behind it does not answer — a restart, a release, or too much work at once — came out with zero bytes too, and no Content-Type. It is the answer anyone gets who sends a request while the engine is restarting, that is at every release, and it is not theoretical: on 15/9 at 12:43:55Z a client received one two seconds after a restart of ours, and from their side it was an error with no name. That one too is cured in our edge file — code SERVICE_UNAVAILABLE, with the status that arrives — and it went live with that same release, on 9/10 at 07:31:16Z. And on 8/10, in the same pass over the same family, the 405 for the wrong method — the only one of the four that this page said would stay — took our shape in the edge file too: code METHOD_NOT_ALLOWED, and inside the message the right method for that endpoint, read from the Allow header the framework already sends (and which the response still carries). So today no shape without a code remains open: the other three — the 405, that 413 and those three 50x — have been at the public edge since 9/10 at 07:31:16Z, and the 422 we closed ourselves, in the app, on 17/10 at 03:37:52Z. The 413 is cured in our edge file and reaches you — same shape as the others, code BODY_TOO_LARGE — since 9/10 at 07:31:16Z: we did not give the date before the reload, because last time we did and in between another 48 responses without a code went out (the row below). The instant is the measured one, and it is in this row's marker.
WhenWhat you receiveWho writes it
invalid request body422 with {"detail":[{…}]} — see the row at the bottom of the tableFastAPI
wrong HTTP method (GET on /v1/embeddings)405 with {"detail":"Method Not Allowed"} — closed on 9/10 at 07:31Z: it is now {"error":{"code":"METHOD_NOT_ALLOWED",…}}, the very row that until yesterday promised that code «with the next caddy reload»FastAPI, rewritten by the edge
body over 8,000,000 bytes413 with zero bytes of body, no Content-Type, Connection: close — closed on 9/10 at 07:31Z: it is now {"error":{"code":"BODY_TOO_LARGE",…}}, the very row that until yesterday promised that code «with the next caddy reload»the edge (Caddy)
the engine behind the edge does not answer (restart, release, overload)502/503/504 with zero bytes of body, no Content-Type — closed on 9/10 at 07:31Z: it is now {"error":{"code":"SERVICE_UNAVAILABLE",…}}, the very row that until yesterday promised that code «with the next caddy reload»the edge (Caddy)
endpoint that does not exist (/v1/inventata)closed on 5/10 at 06:40Z (written on 1/10, live with the caddy reload of 5/10): it used to be 404 with {"detail":"Not Found"} from the app, now it is {"error":{"code":"NOT_FOUND",…}}FastAPI, rewritten by the edge
wrong base URL, or a path outside the APIclosed on 29/9 (live since 1/10, 02:00 Rome time): it used to be 404 with not found in plain text, not even JSONthe edge (Caddy)
Up to 1.1 there was a fifth one as well: the 500 came out as plain text. From contract 1.2 it has our shape (INTERNAL). What to do with them in your client: rule 5 — you put the code in yourself, deducing it from the HTTP status and never from the body. Giving the 422 our shape is something we did on 17/10 at 03:37:52Z, and it was not a change of contract: error.code is added next to detail, which stays where it was, and the contract remained 1.2. Of the 405 this page said it would stay, because «endpoint does not exist» on an endpoint that does exist would be false advice: that reason is true, and we are not giving you that false advice now either — the 405 does not reuse the body of the 404. But it ruled out one cure, not the cure: since 8/10 it has a code of its own, METHOD_NOT_ALLOWED, and the message says which method that endpoint wants, taken from the Allow the framework already sends. The advice is the right one, and it is not hand-written in our file: on /v1/models it says GET and on /v1/embeddings it says POST without anybody having listed them.

The trailing slash: a 307 that is not an error, but that your client must be able to read (1/10). A real endpoint called with a trailing slash — POST /v1/embeddings/ instead of POST /v1/embeddings — does not answer 200 and does not answer with an error of ours: it answers 307, empty body, with Location: pointing at the same endpoint without the slash. It is written by the framework (redirect_slashes), not by our code, and the 307 preserves method and body: if your client follows redirects, you will not even notice. If it does not — and many disable them on POSTs, which is a prudent choice — you receive a status that is not 200 and has no error.code. It should not be counted among the «shapes without a code» above: it is not an error, it is the same answer at the right address. What you need to know, all of it measured:

  • following it is safe: the Location is absolute and in https, so neither the key nor your texts travel in the clear, and the body of the 307 is empty (0 bytes: nothing of what you sent comes back to you);
  • but the two redirects are not the same thing, and this line matters more than the previous one: the slash 307 is written by the app inside the encrypted session, so when you read it nothing has left yet. The API name, however, also answers on port 80, and there it answers 308 towards https: that redirect arrives afterwards, so whoever calls http://api.agilev.agile.software has already sent key and texts in the clear on the first trip, and no redirect brings them back. Call us https only: our four reference clients now refuse http:// towards a public address at construction time, before opening the connection — loopback and private networks stay allowed silently, because that wire does not go through the Internet, and for a name of yours inside a VPN there is the explicit opt-out in_chiaro_ammesso=True;
  • it does not consume: the same call counts 1 request without the slash, 1 with the slash following the redirect, 0 with the slash stopping at the 307. The redirect is not billed;
  • it does not carry the X-RateLimit-* headers: if you pace yourself on X-RateLimit-Remaining, on a 307 you read nothing — one more reason to drop the slash instead of following the redirect;
  • it does not happen on every endpoint in the same way: GET /health/ and POST /embed/ do not even reach the app and receive the edge's 404 NOT_FOUND, which says «endpoint does not exist» about an endpoint that does. Measured live on 1/10 on the eight public endpoints: 5 answer 307, two the edge's 404.

What to do: do not write the trailing slash. Our four reference clients cannot add it by themselves (they strip the slash from the base address) and since 1/10 they have the 3xx branch, which returns REDIRECT with the remedy instead of an «I do not know» code. If you use a client of your own, see rule 5 below.

Both 404s are closed by the edge, and neither of them waited for an app deploy. The 404 is not written by our code: it is written by Caddy, and a caddy reload is enough. On 29/9 the answer took contract shape in the four points where the edge stops a request by itself (live since 1/10 at 02:00, and the edge log confirms it: our first body is from 1/10 at 00:00:11Z). The fifth point, which does not stop a request but rewrites the engine's, was written on 1/10 but only went live with the caddy reload of 5/10 at 06:40:01Z: for four days this page said «since 1/10» while another 48 {"detail":"Not Found"} responses were leaving the edge — 5 to someone who uses the service (on 2/10 on /v1/bootstrap, on 3/10 on /v1/collezioni, on 5/10 on /v1/richieste-licenza: a client looking for the address, that is the very case the 1/10 release note declared covered) and 43 to our own probes, the last one at 06:39:19Z, 42 seconds before the reload. Measured on the edge log, read-only. How it works: the edge lets the whole of /v1/* through — and it must, otherwise it would block a new endpoint under /v1/ — so /v1/embed (between /embed and /v1/embeddings), /v1/embedding, /v1/keys, /v1/ reached FastAPI and came back as {"detail":"Not Found"} without a code. That was precisely the place where a client gropes around, which is exactly what the 29/9 entry promised was covered. All five points answer the very same body (/metrics and /admin/* answer 404 on purpose, so as not to admit they exist: a body different from that of an invented path would say they do — and since 5/10 you cannot even tell, from the answer, whether a path reached the app or stopped earlier). The message is fixed, it does not repeat the path you asked for and it names all eight public endpoints: nothing of what you send comes back into your logs. Measured on the same requests: responses with a code 0 out of 20 → 13 out of 20 (29/9) → 20 out of 20 (the 1/10 configuration, at the edge since 5/10), of which 0 out of 7 → 7 out of 7 among the groping ones under /v1/; distinct bodies 1, echoes of the request 0, and the 405 for the wrong method still 2 out of 2 a 405. For you nothing changes and there is nothing to copy again: if you deduce the code from the status, the two shapes already behave the same way.

{"error":{"code":"RATE_LIMIT","message":"massimo 300 richieste al minuto"}}
HTTPCodeWhat to do
401KEY_MISSINGthe authentication header is missing.
401KEY_INVALIDthe key does not exist: check you did not copy any spaces.
403KEY_REVOKEDthe key has been revoked: ask for a new one.
403KEY_EXPIREDthe key has expired: it had an end date (typically a trial key) and that date has passed. If you watch your keys from a panel of your own: the end date is in expires_at (GET /v1/usage) and not in a status — an expired key does not change status, it simply stops being accepted. It is the same defect we fixed three times in our own house between 29 and 30/9, the last time in a place where it handed out a new key that was already expired (release note of 30/9). Retrying is useless: you need a new key.
403SCOPE_NOT_ALLOWEDthe key does not cover that operation.
400PERSONA_MISSINGnobody sends it any more since 12/10/2026, and the row stays here because someone may have written it into their own code: for one day, the 11th, a key of a digital person required the persona field in the body and without it you got this. Now the engine sets that person. It will come back the day the field becomes required for every key, and we will tell you that day in advance rather than have you discover it from a 400.
403PERSONA_UNKNOWNthe persona you wrote is not this key's: a key is good for the person it was issued for, and no other. You also get it if your key belongs to no person and you send the field anyway — there is nobody there to compute on behalf of, and a person's key is issued by whoever administers. "Unknown" and not "not yours": telling you it exists would already be another person's data.
403TENANT_UNKNOWNsince 12/10/2026. The tenant you wrote in the body is not this key's: a key is good for the tenant it was issued for, and no other. The field is optional — if the body says nothing, the tenant is the key's — so whoever does not write it never gets this code. "Unknown" and not "not yours": telling you that another customer exists would already be that customer's data, the same reason why between different customers we answer 404 and not 403.
403LICENSE_SUSPENDEDthe contract this key belongs to is suspended. It is not a problem with the key: every key of the same contract gets this code at the same instant, and a new key under the same contract will not work (we will not even issue one). The contract is reactivated, and from that moment your keys resume with nothing to change on your side.
403LICENSE_EXPIREDthe contract has reached its end date. As above: it concerns all the keys of the contract together, and a new key does not get around it. The contract is renewed.
403LICENSE_REVOKEDthe contract has been revoked. A new key under that contract is no longer issued: a new contract is needed.
403LICENSE_NOT_FOUNDour fault, not yours: your key points to a contract that no longer exists. Write to us: there is nothing you can change on your side.
429RATE_LIMITwait the seconds in Retry-After (from contract 1.2) and try again: the counter restarts every minute — this holds for the endpoints with a key; on the only public endpoint (POST /v1/richieste-licenza) the same code has a window of one hour and Retry-After gives the seconds to the new hour.
429QUOTA_EXCEEDEDmonthly quota exhausted: the message says which of the three caps bit (the key, the licence or the company), Retry-After gives the seconds to the new month (00:00Z on day 1), and to get back sooner you ask for a quota increase.
413BATCH_TOO_LARGE · TEXT_TOO_LONGsplit the request.
413BODY_TOO_LARGEthe whole body exceeds the edge's 8,000,000 bytes. It is written by the edge, not by the app: it arrives before authentication and before the engine, so it consumes neither quota nor requests per minute and carries no X-RateLimit-* headers; in this case you never see our 413 BATCH_TOO_LARGE, because the request never reaches the app. The message is fixed and repeats nothing of your body. Retrying the same request is pointless: send fewer texts at a time.
502 · 503 · 504SERVICE_UNAVAILABLEthe engine behind the edge did not answer: it is restarting — at every release the engine's health is awaited for up to 180 s — or it is overloaded. It is written by the edge, not by the app: the request was not executed, it consumes neither quota nor requests per minute, and it can be repeated identically. The status is the one that arrives and is not squashed into one: a 503 can also come from the app (MODEL_UNAVAILABLE), and the code tells you which of the two answered you. No Retry-After: the edge does not know how long the stop will last, and an invented number would make you all retry together at the wrong instant. Since 9/10 at 07:31:16Z this answer has our shape; before that caddy reload it arrived with zero bytes and no Content-Type.
400EMPTY_INPUT · BAD_INPUTempty or malformed input.
400FIELD_WRONG_NAMEon an /admin/* route you wrote the name the sibling route uses for the same thing: the message says what it is called here. The fields were not applied and the call was refused, instead of being born with the defaults.
503MODEL_UNAVAILABLEyou asked for a model this instance does not have. We do not give you another one in its place without telling you.
500INTERNALan unforeseen fault of ours (from contract 1.2). It carries the exception's type and never its message, which could drag along a piece of your body: {"error":{"code":"INTERNAL","message":"guasto interno (ExceptionName): …","type":"ExceptionName"}}. Up to 1.1 an unforeseen fault came out as Internal Server Error in plain text, with no code. Retrying once makes sense; if it repeats, send us the status and the type — the traceback and the agilev_imprevisto_totale{tipo=…} counter are ours, you do not record the body. It also carries the three X-RateLimit-* when the fault arrives after the minute has been counted, that is inside an endpoint with a key: you paid for that slot, and the number you have left is exactly what you need before retrying. If the fault is born before the count — inside the authentication door — it does not carry them, because nothing was spent: it is not the status that tells you, it is when the response is born.
422BODY_INVALIDinvalid request body: a required field is missing or is of the wrong type (input instead of texts on /embed, documents missing on /v1/rerank). It has our shape since 17/10 at 03:37:52Z — before it was the FastAPI body, with no code. Read detail[].loc and detail[].msg: they tell you which field. Three things to know: it arrives before authentication (with a wrong body and a missing key you receive 422, not 401), it consumes neither quota nor requests per minute and carries no X-RateLimit-* headers, and it no longer sends back what you sent — detail[].input is gone, and the response weighs the same bytes for one text and for 64 (402 bytes measured at the edge, where a batch of 89,600 characters came back as 89,909 bytes). Retrying the same thing does not help: change the body.
405METHOD_NOT_ALLOWEDwrong HTTP method for that endpoint. The code is put there by the edge since 9/10 at 07:31:16Z (written in the edge file on 8/10), and the message states the method that endpoint wants; the response also carries the standard Allow header with the same method. Before that instant it arrived as {"detail":"Method Not Allowed"}, with detail a string and no code. Embeddings and reranking want POST; /health, /v1/models and /v1/usage want GET. It arrives before authentication and consumes nothing. This is not theoretical: on 13/9/2026 at 01:26:32Z a customer called /v1/embeddings with GET and received this, and their client had no way of telling them what it was.
404NOT_FOUNDendpoint does not exist, in every case and with a single body (since 5/10): wrong base URL, a path outside the API, or an invented endpoint under /v1/. The message lists all the public endpoints, so whoever is looking for the address finds it there. Retrying is useless: it is the address. Up to 29/9 it was not found in plain text from the edge, and up to 5/10 at 06:40Z {"detail":"Not Found"} without a code for paths under /v1/ (measured on the edge log: the last five to a customer between 2/10 and 5/10).
499CLIENT_GONEyou never receive it: it is what we record when you close the connection before the computation begins (your client's timeout). From contract 1.2 that request is neither computed nor counted; if you close once computation has started, we stop at the next pass (the texts already computed stay in cache). On /v1/rerank we check whether you are still connected even when the computation was a single pass, that is before writing the usage row: a rerank of up to 16 documents is one pass, and between one pass and the next there is nothing to check (fixed on 6/10, what's new).
What does not raise the contract version. Key administration (issuing, listing, revoking, rotating) is not part of this contract: it is not reachable from the Internet, you do not call it, and it changes without touching the version number. On 30/9/2026 it changed — rotating an already expired key is now refused with 400 KEY_EXPIRED instead of issuing a new key that inherits the past expiry, that is, born dead — and we write it here too, not only in the release notes, because the cause is the rule of the KEY_EXPIRED row above: status and expiry are two different things. For NIS2, LG231, trpg, aiepod and core nothing changes: your keys have expires_at: 0. On 11/10/2026 it changed again, and for the same reason: issuing accepts a persona, that is, it declares that the key being born belongs to someone. The form is a label of 1-32 characters among letters, digits, _, ., - — never a natural person's name, never an e-mail — and a wrong form is 400 BAD_PERSONA; if the boundary cannot be written, the key is not born (503 PERSONA_UNAVAILABLE) instead of being born without it. Rotation carries it along: a rotated key stays the same person's. We write it here because the consequence is one you see in the error table: a key issued that way is good for that person and no other. On 12/10/2026 that consequence got lighter, not heavier: a key of a person asks nothing of you in the body, because its person is set by the engine on every request, and nobody sends 400 PERSONA_MISSING any more.

When to retry, we tell you (from contract 1.2). The responses of the compute operations (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) — the only ones you call; since 20/10 they are carried by every authenticated response, administration included — carry three informative headers: X-RateLimit-Limit (your key's requests per minute), X-RateLimit-Remaining (how many are left in this minute) and X-RateLimit-Reset (seconds to the new minute, from 1 to 60). The 429 RATE_LIMIT additionally carries Retry-After with the same number of seconds: the window is the minute, so the value is exact and there is nothing to guess — wait those seconds and try again (never more than 60). The 429 QUOTA_EXCEEDED carries Retry-After too (since 6/10), but it means something else: it is the seconds left until the first instant of the new month in UTC — from 1 to 2,678,400 — that is the distance from when your quota restarts, not a recommended wait and not an invitation to keep the request open. The numbers share one header name and have three meanings, and you tell them apart from the code in the body and from the endpoint: RATE_LIMIT on an endpoint with a key is the seconds to the new minute (never more than 60), QUOTA_EXCEEDED the seconds to the new month, and RATE_LIMIT on the only public endpoint — POST /v1/richieste-licenza, the one behind the landing page form — the seconds to the new hour in UTC, from 1 to 3,600 (since 20/10/2026). The third is the easiest to get wrong precisely because the code is the same as the first: there the window is the hour and not the minute, and a client capping Retry-After at 60 s would knock up to 60 times inside the same closed window instead of once. If your rule is «honour Retry-After, cap at 60», look at the endpoint too, not just the code. If you need to work again sooner, do not wait: ask for a quota increase with the landing page form (POST /v1/richieste-licenza, public and without a key). The reference client does not retry on QUOTA_EXCEEDED and does not read that number. Refusals that arrive before the minute is counted carry no rate headers, and the reason is that they have spent nothing: 401 KEY_MISSING, 401 KEY_INVALID and the seven 403s of the key and of the contract (KEY_REVOKED, KEY_EXPIRED, SCOPE_NOT_ALLOWED, LICENSE_NOT_FOUND, LICENSE_SUSPENDED, LICENSE_EXPIRED, LICENSE_REVOKED). Two 403s do carry them, and they are the only two: TENANT_UNKNOWN and PERSONA_UNKNOWN — the tenant or the persona you wrote in the body is not the one of your key — because they arrive after, when the minute slot has already been spent, and that number is yours: read it. It is not the status code that tells you whether the headers are there, it is when the refusal is born: on a 403, look instead of assuming. Measured on 20/10/2026: six requests with a key of 6 per minute, three of them with the wrong tenant — a client that skips the headers on 403 sees 3 slots spent out of 6 and believes it has 3 left when it has 0, and the next request takes the 429 those headers exist to avoid. X-RateLimit-Remaining is there so that you do not take the 429: when it is 0, wait X-RateLimit-Reset seconds before the next request. The reference client does it by itself (frenate counter). GET /v1/usage is outside this count: it carries no rate headers and, above all, it does not consume one of your requests per minute — checking your own usage is free and does not bring you closer to the 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"}}
On 401, 403, 400 and 413 retrying is useless: change the request. The reference client already honours Retry-After.

If you write the client yourself: six rules

Measured, not advised by eye: we found them on our own clients, after trpg found them on theirs (28/9). A standalone page to copy from: the errors seen from your client.

  1. error and detail are structures, not strings. Concatenating them into a message produces Array in PHP (with a warning) and the repr of a dictionary in Python: at trpg every error of ours became the word «Array», and KEY_MISSING/KEY_INVALID/RATE_LIMIT never reached the reader — and not in a log, in the response of their API to a user. Build the message from three pieces: HTTP status, error.code, error.message.
  2. The 422 must be handled separately: it has no code, so whoever branches only on error.code stays silent on exactly the error you meet first. Read detail[].loc and detail[].msg (they tell you which field), never detail[].input, which is the echo of your texts (see the 422 row above).
  3. 200 does not mean «all the vectors are there». Count: data must have as many elements as the texts you sent and the index values must be 0…n-1; on /v1/rerank, results has min(top_n, documents) of them when top_n is positive — otherwise all of them, and the response says so in ignored_values — and every index is inside the range. If fewer arrive and you pair them with a zip, you index the wrong text with the wrong vector and never notice (at trpg, a 200 of unexpected shape came back as a success with zero vectors and NULL to the caller, silently).
  4. An empty list is not an answer. Found by trpg in their own house on 28/9, pulling the thread of a warning of ours, and it is the nastiest of the four because it produces no error and no result: their search silently discarded (a bare continue, in two places) every chunk whose dimension differed from the question's, so an index built with one model and queried with another does not give an error, it gives zero results and the assistant answers with no sources. Measured by them read-only: 0 results out of 471 chunks, all discarded, no trace anywhere. Four countermeasures, all free. (a) At start-up a GET /health (it needs no key and consumes nothing) and compare dim with the dimension of your index — it is the only one of the four that holds on the index you already have, because the dimension is measured and does not require anyone to have declared it. The name, no: in /health the model field is the underlying model (bge-m3), while /v1/models and /v1/embeddings use the catalogue name (agilev-m3). They are two vocabularies and comparing them gives a mismatch at every start-up on a healthy service — the stumble trpg reported to us on 28/9 after putting (a) into operation. The measured counter-proof: /v1/embeddings says agilev-m3, /embed says bge-m3, and the vectors are bit-for-bit identical. And the length is counted on the floats: with output_dtype binary/ubinary a 1024 vector arrives as 128 values (1024 bits packed) and with encoding_format: base64 as a string, so len(vector) == dim only holds on float. (b) Check the model of the response, not the one in the request (/v1/embeddings → agilev-m3, /v1/rerank → agilev-rerank, /embed → bge-m3: the field you send is not validated today and /embed does not look at it at all) — and compare it with what that endpoint declares, not with another's: it is the only name comparison that holds. (c) Never a continue on a dimension that does not match — count it and speak up. (d) Write the model next to the chunk at the moment you build the index: it is the corollary trpg sent us on the evening of 28/9, and they paid for it — 442 chunks from one provider and 29 from ours, all at 1024 values, no field saying which, and the information had never been declared anywhere. It costs nothing today; recovering it tomorrow is possible — that is countermeasure (e), measured, at one call per chunk: here we had written «it is impossible tomorrow» and that was false. (e) If you did not write the model down, derive it: re-embed the chunk's text and compute the cosine with the stored vector. The same text and our model give cosine 1 (maximum deviation 2.2·10-12, and 3.9·10-12 across our own deploy); another space of 1024 values gives |cosine| ≤ 0.10; int8 storage 0.9998 and one-bit 0.75, edited text 0.9755 — separation 7×, verdict decidable. It holds on the index you already have: which provider a vector already in your index comes from.
  5. No response may leave your client with an «I do not know» code. It is the rule of 29/9, and it comes from counting how many shapes a response of ours can take: five have no error.code (see Errors: four counted on 29/9, plus the 413 of the oversized body found on 5/10), and in all five detail is a string, or the body is not even JSON, or there is no body at all — that is, they all fall into the same branch of your error reader, the one that does not know what to do. If you put ? there, whoever calls you receives an alarm that cannot be routed or counted, and it is the same defect as rule 1 dressed up as an edge case. The answer is not to guess from the body — those bodies are not ours and nobody promised what would be put inside them — but to deduce the code from the HTTP status, which is the only guaranteed part: 404 → wrong path (your fault, do not retry), 405 → wrong method (do not retry), 3xx → a redirect, nearly always a trailing slash too many: drop it, or follow the Location (it is absolute and in https, it preserves method and body and does not consume) — added on 1/10, 5xx without shape → the edge did not reach the service or the service fell over shapelessly (retry), everything else → a code that says «without a code» and reports the status. That is five different behaviours, and with ? they were one. Second corollary: do not put the body of those responses into the message. An error page from a proxy in the middle can contain anything, and it would end up in your logs labelled as an AgileV error — it is the same rule as the 422 echo, applied to bodies that are not ours.

6. The configuration that disappears inside a web server. A rule from 29/9, and it was not an error that taught it to us: trpg taught it to us by measuring in their own house. The same environment variable is not read the same way in every room: under Apache $_SERVER['LLM_API_URL'] is null while getenv() and $_ENV carry the value (their measurement); from the command line it is the opposite — getenv() and $_SERVER carry the value and $_ENV is empty (our measurement, on our own box); and php-fpm with clear_env = yes, which is the default, empties the process environment and leaves only $_SERVER (documentation, not a measurement of ours). Whoever reads one single source is betting on the room, and when they lose there is no error at all. For AgileV the variable that disappears is the address, and the fallback is public production: if you pointed your client at a private front end, your texts leave onto the Internet silently. Four countermeasures: (a) read the environment from all the sources, in a declared order; (b) a value that is present but empty is an error that names the variable, never a fallback; (c) always say where the address came from, and for the key the source and never the value; (d) if the address must be private, the guard belongs in the code — a guard switched on by an environment variable disappears together with what it is supposed to protect. Details and numbers: the page of the six rules.

In our four reference clients (agilev_client.py, agilev_client.php, agilev_client.mjs, agilev_langchain.py) the first five rules are applied (the first four since 28/9, the fifth since 29/9; in the Node client and in the LangChain adapter's brake since 9/10/2026): copy them, no signature has changed. The 422 becomes BAD_BODY with corpo della richiesta non valido - body.input: Field required, and a mutilated or missing data becomes BAD_RESPONSE with attesi 2 elementi in data, ricevuti 1. Since 29/9 the four responses without our shape come out as NOT_FOUND (404), BAD_METHOD (405), UPSTREAM_ERROR (5xx without shape, retryable) and NO_CODE (the bottom of the table: unknown yes, mute no), and the body does not enter the message. These six codes are put there by the client, not by the service: do not look for them in our responses. Measurement: bench/misura-errori-client.py, 15 scenes × 3 clients (the three of that time; the Node client has its own 18 tests and the six scenes of its bench) — lines without a code of ours from 15 to 3 on 28/9 and from 12 to 0 on 29/9, lines with an echo of your texts 0, silently misaligned vectors 0. The same result capturing the bodies from the 1.1 code in production and from 1.2: you can copy the clients today, it does not depend on our deploy. Since 29/9 the six rules are applied in all SIX examples we publish (the three clients of that time plus ricerca_ibrida.py, ricerca_ibrida.php, qdrant_ibrido.py), and since 9/10/2026 they are SEVEN with the Node client: the address no longer ends up silently on the public front, an empty value is an error, and the clients say where they took it from. Whoever configures nothing has exactly the behaviour of before. Measurement: bench/misura-indirizzo-che-sparisce.py — silent cells 4 → 0, clients that sent the text without a key 2 → 0, cells not checkable without making a request 4 → 0; 22 mutations out of 22 caught.

Performance, and the most useful thing you can do

Real measurements on a short chunk: processing takes about 100 ms, but a request seen from outside takes about 370. The difference is not the model: it is the TLS handshake, and you pay it on every new connection, not on every request.

Reuse the connection and you get those ~250 ms back on every call after the first:

# Python
import httpx
client = httpx.Client(base_url="https://api.agilev.agile.software",
                      headers={"Authorization": f"Bearer {KEY}"})   # reuse it!

# PHP (Guzzle keeps the connection alive by itself, if you reuse the client)
$http = new GuzzleHttp\Client(['base_uri' => 'https://api.agilev.agile.software']);

# Node, with https.request
const agent = new https.Agent({ keepAlive: true });
# Node >= 18, with fetch: keep-alive is already on, the rule is not to turn it off.
# Everything else (batches, brake, Retry-After, timeout) is in the client: /esempi/agilev_client.mjs

To index large quantities, send batches (up to 128 texts) instead of single requests: the fixed per-call cost is divided across all the texts in the batch.

A text seen recently is served from an in-RAM cache (fingerprints only, never texts) without recomputation. The cache is separate per customer (since 7/10/2026): a text that another customer had computed is, for you, a new text — nothing is reused across different customers, neither the vectors nor the response time. Keys belonging to the same customer, rotated ones included, share the cache as before. How many of your texts came from there is told by usage.cached_texts in every response and by cached_texts in GET /v1/usage, month by month.

Client timeout and batches

Forty simultaneous requests: what used to happen, and what happens now. Sending many in parallel is not merely useless; up to contract 1.1 it was harmful, and the number is clean: requests in flight occupy the seats of a queue that has forty, and the forty-first waits — /health included, because it was an endpoint like the others. Measured on 29/9 with 48 simultaneous requests, five runs per arm, in a cage and without touching production: before, health probes lost in 5 runs out of 5 during warm-up and in 3 out of 5 on a warm service (up to 20 s of silence), and 500s in 5 runs out of 5 (RuntimeError: Already borrowed, ours, on a healthy service: two paths tokenising the same object at once). After the fix, on the same scene: 0 probes lost out of 883 and 0 non-200 responses out of 480. From 1.2 onwards, forty simultaneous requests are therefore harmless; they remain useless, and the reason is the paragraph below. In production it had never triggered, because in September the maximum measured was 3 requests in flight (edge logs, 1,084 requests): the case to fear was not everyday traffic, it was the announced deploy, with all the clients unblocking at the same instant.

The service computes one request at a time (it is CPU-bound): a batch of 10 chunks of 1,400 characters costs about 11 s on its own, and every batch that arrives while another is in progress waits for that one to finish entirely (measured: 2 batches in parallel 21 s, 4 batches 42 s). So, to index: one batch at a time, not in parallel, with a timeout of at least 120 s. A short timeout (30 s) on large batches produces the worst case: the client closes, retries the same batch, and the queue grows with identical requests.

Long documents: split them before sending. The limit is 32,000 characters per text (413 TEXT_TOO_LONG) and 8,192 tokens, but the right size for search is much smaller: chunks of ~1,500 characters (around 350-400 tokens in Italian; it is the size we ran all the tests on the legislative corpus with), cut where the text cuts itself (newline, end of sentence, then between words), with the last whole sentences of the previous chunk repeated at the head of the next (~150 characters) so that a concept straddling the cut stays whole in at least one of the two, and with the document heading repeated in every chunk («Art. 25-septies Rubrica»: it is what makes it possible to find an article cited by number again). The reference client does it for you: spezza(testo, massimo=1500, sovrapposizione=150, intestazione="…") in Python, AgileV::spezza(...) in PHP and spezza(...) in Node produce the same chunks (tested on the 153 articles of the corpus: 468 chunks, none beyond 1,500 characters or 400 tokens, every sentence found whole; the longest article, 55,928 characters, which whole would be a 413, becomes 44 chunks computed in 40 s with a single request). Measurement of 22/9 on the 153 articles: with 1,000, 1,500 and 2,000 characters the hybrid gives nDCG@10 0.766 / 0.754 / 0.779, that is one or two queries out of 69 of difference, no clear winner; 2,000/200 is the best and the cheapest size on long legislation (362 vectors instead of 460, same computation time: the cost is per character, not per chunk), so whoever indexes articles of law can pass massimo=2000, sovrapposizione=200.

From contract 1.2 a request closed by the client before computation begins is neither computed nor counted (CLIENT_GONE in our logs, never in your usage); one already started is no longer carried through for nothing: computation proceeds in passes (a long request has several: 4 texts of 32,000 characters are 4 passes of ~45 s) and between one pass and the next we check whether you are still connected; if not we stop there, the request is not counted and the texts of the passes already done are in cache, so the next attempt restarts from where it was instead of from zero (tested: 4 long texts, client closed at 70 s, service stopped at the next pass, retry with 2 texts taken from the cache). On /v1/rerank the check does not depend on the passes: a rerank of up to 16 documents is a single pass (each query-document pair is cut to 1,024 tokens, so the number of passes depends on the documents and not on their length), and between one pass and the next there would be nothing to check; therefore before writing the usage row we check once whether you are still connected (before 6/10 we did not, and a single-pass rerank closed by your client was counted). The simplest way to respect all this is to copy the reference client (esempi/agilev_client.py / .php / .mjs): on a timeout it halves the batch instead of repeating it, it retries only where it should, it reuses the connection.

Migrating from Voyage or OpenAI

With the OpenAI library three lines change:

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=["text"]).data[0].embedding   # 1024 numbers
Mind the dimensions. AgileV produces 1024 dimensions. voyage-3 has 1024, voyage-3-lite 512, text-embedding-3-small 1536. If the number changes — or even if it stays the same but the model is different — the index must be rebuilt from scratch: vectors from different models are not comparable, and mixing them produces silently wrong results.

input_type, truncation and output_dtype are written with the same syntax you are used to.

Using the vectors in Qdrant

from qdrant_client import QdrantClient, models
client.create_collection(
    "documenti",
    vectors_config=models.VectorParams(size=1024, distance=models.Distance.COSINE),
)

The vectors are already normalised, so cosine and dot product give the same ordering. A recipe that works well: retrieve 30-50 candidates from the index, then pass them to /v1/rerank and keep the first 3-5. It costs one more call and noticeably improves what ends up in the language model's context.

What it costs in time, because the timeout must follow from it. Rerank costs per character of the pair (query + document), not per document: measured on 6/10 from our counters, ~2.1 ms per character. So 30 candidates of 662 characters are ~43 s, that is inside the 120 s we recommend and outside a 30 s timeout — and the «Client timeout and batches» paragraph is about embedding batches, not rerank. The two levers, in order: shorten the candidates (the same 30 documents cut to 200 characters cost ~13 s, and the reranker reads the first ~1,024 tokens of each one anyway) and raise the timeout to 120 s. Keeping 30-50 of them with a 30 s timeout is the worst case: your client closes, our computation has already started, and you see nothing.

Which provider a vector already in your index comes from

This is for whoever has an index written in several goes, or moved from one provider to another, without a field saying which model wrote which chunk. The test: re-embed the chunk's text and compute the cosine with the stored vector. Same space → 1; another model's space → 0, because its coordinates are written in an arbitrary basis with respect to ours. It works on the index you already have, and it does not require anyone to have declared anything.

CosineVerdictWhat to do
1 − cosine ≤ 1e−6ours, and of this textnothing: it is the normal case on a healthy float32 index
cosine ≥ 0.50our spacenothing, but if it is not 1 then either the text or the storage changed
0.15 < cosine < 0.50worth a looklook at the text of that chunk before concluding
cosine ≤ 0.15another spacewe did not write that chunk

Measured on 28/9 with the real model in a memory cage on 64 chunks of ours, zero requests to production — 13 scenes, 0 KO: same text 1.00000000 (even written in a different batch; maximum deviation from cosine 1 2.2·10-12, and 3.9·10-12 even across our 1.1 → 1.2 deploy, so the verdict is the same before and after); float16 storage 0.99999998 (it even holds the exact verdict); int8 storage 0.99983766; one-bit storage 0.75374678; text edited afterwards 0.97554815; text prepared by the indexer (title, prefix, cut) 0.75475971; another chunk of ours 0.78578071; text with nothing in common 0.23538498; another space (orthogonal rotation, permutation of the coordinates, flipped signs, and with int8) between −0.10105777 and +0.058. Between ours and the strangers there is a factor of 7 and nothing in between: the verdict is decidable.

In place of an external provider — which we do not use and whose vectors we do not have — the «other space» is an isometry of ours: same dimension, same norm, same internal geometry, different basis. A real model trained on its own can only be less aligned with us than that, not more.

How many chunks to look at, to notice a contamination with 95 % confidence: 5 if the strangers are 50 %, 14 at 20 %, 29 at 10 %, 59 at 5 %, 299 at 1 %. The cost: ~0.26 s and the characters of a 400-character chunk; an index of 471 chunks is labelled entirely in two minutes of service, once only. The trap: if the indexer prepares the text before sending it (title in front, cut, prefix, lower-casing), the stored vector is of that text, and re-embedding the raw text compares two different texts — measured, cosine 0.7548 in the worst case: the verdict on the space stays «ours», but the exact 1e−6 threshold no longer holds on any chunk, and whoever uses only that condemns a healthy index in its entirety. Re-embed through the same function as the indexer. A six-step protocol, to rewrite in your own language: the provenance of vectors.

Does a deploy of ours move the vectors in your index?

A fair question, and until now the answer was «no», said and not measured. Measured on 28/9 on the real model, for the 1.1 → 1.2 transition.

1.2 splits large requests into several passes through the model, on a token budget, so as not to run out of memory: grouping the texts differently means different padding, and in floating point the last bits can change. The two paths were compared on the same model object, so that the only variable was the grouping.

Your requestWhat changes with the deploy
fits in a single pass (up to 16 texts, or short texts)nothing: bit-for-bit identical vectors, cosine 1.00000000
exceeds the budget and is split (10 texts of 1,822 tokens → 2 passes)2 vectors out of 10 move; worst cosine 0.9999999999960902, maximum deviation on a component 3.06·10-7
reranking, 10 and 40 documentsthe order does not change; scores within 4·10-11

What to do with it. The threshold we gave you to stop at is 0.9999, and now we know how much margin it has: the deploy consumes four hundred-millionths of it. The rule therefore becomes stronger, not weaker — if your «after» measurement drops below 0.9999, it is not the change of grouping: it is a real fault, and it is ours. Stop and write to us. And to make your measurement more sensitive, freeze the reference on short sentences, few per request: the request fits in a single pass and the before/after comparison is exact, bit for bit.

The cost was measured too: budgeted batches cost no time (65.2 s → 64.4 s on the long texts, 130.2 s → 131.8 s on the reranking). They keep memory down at the same speed and, now we know, with the same vectors.