Who this is for: whoever holds the provider key an instance runs on, and
whoever has to answer "which vendor, at which address" before a body leaves.
You need: an instance with a revetly.toml, and revetly doctor to read
the two egress lines back. Not here: what is withheld from the provider
altogether ([egress] in engine-config-surface.md),
and how extraction is scored on a seeded corpus
(scoring-extraction.md).
Extraction sends every artifact body an entities-tier pass reads to an LLM
provider, verbatim, and embeds every body — and every search_context query —
at an embedding provider. Those are the two sinks outside this instance's
perimeter ([egress] says what is withheld from them; this page says where the
rest goes). Both are declared in revetly.toml, instance-wide, and the engine
refuses to start on an address the file does not state.
[llm]
provider = "openai" # openai | openai-compatible | anthropic | openrouter | cloudflare | none | a registered name
# endpoint = "" # empty: the provider's own address
# region = "" # a label the doctor prints — "eu", "us-east" — never validated
# key_env = "" # empty: the provider's own variable (OPENAI_API_KEY)
# workspace = "" # the workspace the key acts in, where the provider asks (anthropic)
# account = "" # the account a per-account address belongs to (cloudflare)
# model = "" # empty: the engine's pin for the provider
# small_model = "" # the call-count-heavy tier; empty: the provider's pin
# [llm.headers] # header -> the VARIABLE holding its value, never the value
# [llm.params] # extra JSON fields merged into every body this sink sends
[embedding]
# provider = "" # empty: follows [llm] when that provider embeds
# endpoint = ""
# key_env = ""
# account = ""
# model = "" # empty: the provider's pin (text-embedding-3-small on openai)
# [embedding.headers] # empty: follows [llm.headers] on the same provider
# [embedding.params] # empty: follows [llm.params] on the same provider
revetly doctor prints one egress line per sink — provider, endpoint and
whose declaration named it, region, model — and context_health carries the
same under egress. revetly status prints both lines at the end.
openai — the provider every instance ran on before there was a declaration, unchanged.
Responses-API structured output, embeddings and rerank on one key. Pins:
gpt-4.1-mini, small model gpt-4.1-nano, text-embedding-3-small. Key from
OPENAI_API_KEY. Address https://api.openai.com/v1.
anthropic — graphiti-core's AnthropicClient: tool-use structured
output, one model for every call (no small tier). Pin claude-sonnet-5 — the
middle tier, for the reason gpt-4.1-mini is the OpenAI pin: an extraction pass
is hundreds of small structured calls, and the tier below re-asks more often
than it saves. Key from ANTHROPIC_API_KEY, always passed explicitly: the SDK
would otherwise fall back to a local ant auth login profile, which is a Claude
Code subscription credential — not licensed to carry extraction, and without
the structured error contract the retry authority classifies on. Anthropic
has no embeddings API, so an instance on it declares [embedding]:
[llm]
provider = "anthropic"
[embedding]
provider = "openai" # text-embedding-3-small on OPENAI_API_KEY, $0.02/M
Two vendors receive bodies then, and the doctor's two egress lines say so. That is the honest shape of an Anthropic instance today; a Voyage embedding is reachable through the entry-point door below and is not shipped. One more limit of the library's client: it forces the tool call, which the current models accept and Claude Fable 5.1 refuses — so that model is not one this engine can extract with yet.
When an Anthropic key needs a workspace. A key the console creates against
your identity is identity-linked, and the API refuses every call from it
without an anthropic-workspace-id header: 400 anthropic-workspace-id is required when authenticating with an identity-linked API key. A
workspace-bound key — created inside a workspace in the console — needs
nothing. Declare the workspace and the provider sends the header on every
request, the doctor's probe included:
[llm]
provider = "anthropic"
workspace = "wrkspc_..." # the id the console shows beside the key
Both revetly doctor and a pass classify that refusal rather than passing it
through as an anonymous 400: the line names the header and the fix — declare
the workspace, or use a workspace-bound key. Providers with no such notion
ignore the key.
openai-compatible — any /v1 endpoint speaking OpenAI's chat-completions
and embeddings shape: Azure, vLLM, Ollama, Gemini's compatible endpoint, a
named AI Gateway. No defaults at all — the endpoint and the models are yours:
[llm]
provider = "openai-compatible"
endpoint = "http://localhost:11434/v1" # Ollama
model = "qwen3:8b"
key_env = "OLLAMA_API_KEY" # any non-empty value; Ollama ignores it
[embedding]
model = "nomic-embed-text" # follows [llm]'s endpoint and key
What the endpoint must serve: POST /chat/completions accepting a
response_format of json_schema (graphiti-core's generic client asks for it
and strips a ```json fence if the model adds one); POST /embeddings; and,
for the doctor's free probe, GET /models — an endpoint without a listing gets
a SKIP line rather than a failure, and the first extraction call tells. An
endpoint that serves no embeddings needs [embedding] provider pointed at
another vendor. Exhausted credits reported as HTTP 402 are terminal, like OpenAI's
insufficient_quota.
Adherence to the schema is best-effort on this path, which is why the re-ask
loop fires more here and why extraction quality on a compatible endpoint is a
number to measure, not a claim: revetly eval run scores it against a seeded
corpus before a real one is trusted to it.
Both are openai-compatible underneath — same generic client, same best-effort
adherence, same reason to run the eval before trusting a corpus to either. What
a preset carries is the part you would otherwise assemble by hand: the address,
the variable the key lives in, and the failures that vendor has and OpenAI does
not.
openrouter — one key across hundreds of models, and both sinks on it:
the router serves /embeddings beside /chat/completions, so an instance here
declares no second vendor. Neither model is pinned; the router names thousands
and prices each on a sheet of its own.
[llm]
provider = "openrouter"
model = "anthropic/claude-sonnet-5"
[embedding]
model = "openai/text-embedding-3-small" # follows [llm]'s endpoint and key
Key from OPENROUTER_API_KEY, address https://openrouter.ai/api/v1. Two
failures are the router's own. Exhausted credit is 402 and terminal. And a
mid-generation error arrives as HTTP 200 with the error in the body — the
provider's documented behaviour, which no SDK looks at and which would
otherwise read as an extraction that found nothing; this provider raises it as
the error it is, so the classification and the retry budget see it. Its 503
("no provider met your routing") is the one 5xx whose Retry-After this engine
obeys, and only when the response came from the router itself.
Nothing here is priced by the engine's sheet. A routed model bills at whatever
the sub-provider charged, and the same name can bill two ways on two
consecutive calls — so every model is reported unpriced unless
[pricing.models] names it. That is a smaller claim than a wrong number.
cloudflare — the AI REST API, one address per account: Workers AI's own
models (@cf/…) and third-party ones routed through the account's gateway
(openai/…, anthropic/…) on a single Cloudflare API token.
[llm]
provider = "cloudflare"
account = "8f0…" # wrangler whoami prints it
model = "@cf/meta/llama-3.1-8b-instruct"
# [embedding] follows: @cf/baai/bge-m3 at the same address, on the same token
Key from CLOUDFLARE_API_TOKEN, which needs Account → Workers AI → Read —
the doctor says so on a 403 rather than reporting the model as unavailable. The
engine's default address carries {account}, and an address still carrying it
at load is refused rather than sent. Whether this base answers GET /models at
all is unverified — Workers AI documents /chat/completions and /embeddings
on this prefix and says nothing either way about a catalogue, and no Cloudflare
token was on hand to ask. Where it does not, the probes answer SKIP with the
reason rather than a failure, and the first pass is what judges the model.
Two facts to be clear-eyed about. Cloudflare's own models are priced by the
engine's cloudflare sheet (published unit pricing, dated); a routed
third-party model bills through Unified Billing at that vendor's rate and is
reported unpriced. And a routed model means the body reaches OpenAI or
Anthropic through Cloudflare while the egress line names Cloudflare — the
model prefix is the only signal that says otherwise, and it is stated here
rather than left to imply itself.
A named AI Gateway is not a preset. It lives at
gateway.ai.cloudflare.com/v1/<account>/<gateway>/compat, the key in
Authorization is the upstream provider's, and Cloudflare's own token rides in
cf-aig-authorization when the gateway is authenticated — two credentials and
somebody's gateway id, which is a declaration rather than a default:
[llm]
provider = "openai-compatible"
endpoint = "https://gateway.ai.cloudflare.com/v1/8f0…/my-gateway/compat"
model = "openai/gpt-4.1-mini"
key_env = "OPENAI_API_KEY" # the upstream provider's key
[llm.headers]
cf-aig-authorization = "CF_AIG_TOKEN" # the NAME of the variable, not the token
On the REST API preset the same seam names a gateway other than the account
default: cf-aig-gateway-id = "CF_AIG_GATEWAY".
[llm]
provider = "none"
[embedding]
provider = "none"
Both sinks, declared together, are a lite instance: no vendor is ever called,
no key is asked for, and neither sink's egress line names an address —
revetly doctor prints none for both. Declaring none on one sink and a
real provider on the other is refused, naming both keys: a follow rule would
otherwise let [llm] provider = "none" alone carry the embedding sink to
none with it, silently, and that is exactly the half-declaration this engine
will not start on.
On the configuration page the two are one gesture. Choosing No provider (lite instance) for the LLM provider carries the embedding provider into the same save, and the page says what it carried before a byte is written. Choosing a real provider there clears the embedding one again, so it follows the LLM provider as it does on any full instance.
Every kind is written at the index tier under this declaration, whatever
[extraction.tiers] declares for it — revetly manual show extraction-policy has the rest of what that means — and revetly ingest --dry-run prices the pass at zero calls, zero tokens, zero cost, and says so
in the quote's own words, in place of the would spend and would buy lines
a provider quote prints rather than printing them at zero: nothing there names
LLM extraction, a duration per episode or a count of concurrent LLM calls,
none of which describe a pass that calls nobody, and the pass report that
follows says the same. Every pass leaves a row in the spend ledger whatever
became of it — a lite
pass, a pass with nothing left to send, and a pass that died partway, which
records what it had spent when it died and the phase it stopped in. revetly status and context_health read those rows, and a failed one is marked so
nobody adds a part-bill to the passes that finished.
Where the graph library needs a client or a vector to satisfy its own shape,
the engine hands in a stand-in of its own: a client that refuses if it is ever
asked to generate anything, and a fixed-width vector marked as a stand-in on
the node or edge it sits on, never served by any read.
A lite instance answers drift, deltas, binding, the backlog, an
artifact's own branch and the authoring bundle exactly as a full instance
does — none of those reads a vector — and it searches through the keyword leg
alone, with method reading keyword (english stemming). It answers with no
concepts, no inferred links and no prose-only statements — a decision, a
constraint or an assumption a conversation states without ever becoming its
own note — each reported absent under its own key with the reason lite,
never silently empty. revetly eval run is refused by name on a lite
instance, because it scores what a model extracts and there is none; revetly eval check still runs, and says above its misses that nothing it scored was
written by one.
Naming a real provider on both sinks is what turns a lite instance into a full
one: the next revetly ingest --dry-run lists and prices every artifact the
tier path would move, and quotes the stand-in vectors it would re-embed as a
line of its own — nothing is sent until that pass runs, and the pass re-embeds
the stand-ins before it reads a single episode. The quote counts those marks
off the graph itself, the same count the pass is billed against, wherever the
dry run can reach a graph client to ask; only where none is reachable does it
fall back to a projection off the ledger, which is a different question and
can land either side of the true count, and it says which of the two counts it
used. Once a warming pass has cleared every mark, the next dry run
quotes zero stand-ins. revetly doctor names a cold
vector space if any stand-in survives a pass. A read taken in between — a
provider declared, the pass not yet run — answers off the keyword leg alone and
says the vector space is cold, marking the shelves it could not fill with that
reason and naming revetly ingest as the pass that warms it. What tells such a
read it is in that window is the stand-in mark itself — the one every unmeasured
vector carries and the warming pass clears — so the window is visible whatever
width the newly declared model returns, including one that matches the stand-in's. Declaring none again over a
corpus a real provider already extracted sends nothing and retires nothing;
revetly forget is still the priced gesture that takes an extraction back.
What a model already wrote on such a corpus stays in the graph and stops being
served. search_context ranks no concepts and serves no inferred fact,
branch states its inferred edges absent, and get_artifact cuts an
artifact's links to the stated head deterministic_links counts, stating the
rest absent under inferred — one answer from all three reads, each carrying
the reason lite. The edges themselves are untouched: name a provider again
and the next read serves every one of them, which is what keeps declaring
none a gesture that is not revetly forget.
Two tables, on either sink.
[llm.headers] is header = "VARIABLE" — the name of the environment
variable holding the value, never the value. The engine reads it when it builds
the client, attaches it to every call that sink makes (the doctor's probe
included, or a gateway that refuses an unheadered call would be reported green
and fail on the first episode), and prints the header's name and its variable,
never the value. A variable named here and unset is a named stop when the
client is built, in the shape a missing key already stops a pass — and
revetly doctor reports it as a line rather than dying, so a token pasted
later is a token that works. Authorization, api-key, x-api-key and
anthropic-workspace-id are refused by name: the engine sets those itself from
key_env and workspace, and a second opinion about which credential a body
leaves under is exactly what this file exists to prevent.
[llm.params] is extra JSON merged into every request body that sink
sends. It exists for one case this project cares about more than most —
OpenRouter's routing controls are body fields with no header form, so without
this the only place to state them is an account setting outside the file the
engine reads:
[llm.params]
provider = { data_collection = "deny", zdr = true, allow_fallbacks = false }
data_collection = "deny" routes only to sub-providers that do not store user
data; zdr = true only to zero-retention endpoints. The engine states what it
sent and cannot verify the endpoint honoured it — that is a claim about
somebody else's infrastructure, and this file makes none. Fields the client
writes itself (model, messages, input, response_format, stream) are
refused at load.
An embedding sink that declares neither follows the LLM sink's when it is the same provider: one gateway, one set of headers, and no second declaration to keep in step.
The engine passes the declared endpoint explicitly to every client it builds.
The SDK's own variable — OPENAI_BASE_URL, ANTHROPIC_BASE_URL — is therefore
never needed, and one found in the process environment or the instance .env
with a value that differs from the declaration stops the engine before a client
is built, naming both values. A leftover proxy variable in a container is how
every body quietly goes somewhere nobody declared; the engine refuses that
rather than obeys it. Declare the address you mean as [llm] endpoint, or
remove the variable. revetly doctor's endpoint line says which.
region is a label. The engine cannot know where an address resolves, so it
prints what the operator stated and validates nothing.
[llm] model is what extraction runs against; REVETLY_LLM_MODEL in the
environment outranks it, the rule every engine pin follows: reaching for the
variable is a decision. small_model is the tier Graphiti routes dedupe,
attribute and date calls to — most of the call count — and only the openai
client routes one. [embedding] model is the vector space.
Changing the embedding model is not a setting change. Every stored vector
is in the old model's space; the corpus has to be re-embedded, which is a corpus
revision and a paid revetly migrate. Changing only the extraction model
touches nothing stored. The recommendation stands: swap the extraction
provider first, keep the embeddings.
The three fields above name a model, and all three were a text box somebody
typed into from memory. That was not laziness: this engine has no idea what any
given endpoint runs, and a list it invented would be wrong on the first instance
pointed at somebody's own server. It does not need one. The provider knows, and
Provider.list_models is where it answers — the free catalogue call behind those
three fields, made by the configuration page when somebody opens one of them.
Not during a pass, not on a first paint, not by walking the surface.
And by the probe, on the three providers that pin no model. A listing has a
second caller, and it is not a person opening anything. openai-compatible,
openrouter and cloudflare cannot read a 404 on /models/{id} as a verdict
about the model, because plenty of /v1 endpoints serve chat completions and no
model route at all, so the catalogue is asked to settle which of the two
happened. Those providers declare it with probe_lists_models, and the engine's
egress account says so in the same sentence it names the address in. What it
costs a reader: a model that does not resolve costs two calls rather than one,
once for the model and once for the catalogue. A press of Test checks the one
model its card is about and so reaches the vendor twice; revetly doctor checks
the extraction model and the embedding model, so an instance where neither
resolves reaches the vendor four times on one run. Every one of those calls is
free and none of them carries a body.
Three answers, and they are three rather than two on purpose. Listed is
whatever the vendor holds right now. Nothing to offer is an endpoint that
answered and named nothing — an account with no models on it, which is a true
fact about that account and not a fault. Could not ask is an endpoint that did
not answer, or that serves no catalogue at all. A failure is therefore never an
empty list and never an exception: an empty list is what an account with nothing
in it honestly says, so a page handed () for both cannot tell a real answer
from a swallowed one, and an exception would turn an endpoint that is merely
slow to refuse into a page that will not load.
Could not ask carries a sentence in the reader's terms and, where the source
knows one, the way out — through the same fix seam a red doctor line reads.
A permanent absence of catalogue carries no fix, and that absence is the only
signal there is that asking again is pointless. A rate limit, an unset key and
a refused token all carry one and are all worth another go; an endpoint that
serves no /models carries nothing beside its address, because there is nothing
anybody can do about it and offering a retry would be a button that cannot work.
A provider with nothing to say declines by name. The default on the ABC
answers could not ask naming the provider — exactly as probe answers SKIP
where an endpoint serves no free lookup — so what lands beside the field is a
fact about that vendor rather than a page that has gone quiet.
Which failure falls into which answer. Only one of these is nothing to offer, and it is the narrow one:
| what came back | the answer | a retry beside it |
|---|---|---|
| a catalogue | listed | yes, to ask again |
data: [] — the endpoint answered and named no models |
nothing to offer | no, it answered |
no /models route at all (404) |
could not ask | no: nothing would change |
| the key refused, a 403 on the listing, a rate limit, an unset key | could not ask | yes |
| the endpoint did not answer, or the address cannot be asked | could not ask | yes |
200, and the body carries no data |
could not ask | yes |
200, and data is not the list of models the route promised |
could not ask | yes |
| 200, and the vendor carried an error inside it | could not ask | yes |
| the probe's lookup: 200, and the body is not a model naming the model asked for | could not ask — a failed provider or embedding model line, the model not checked |
yes |
The last three are one distinction, and it was wrong here for a while. A body with
no models in it was read as Offer.of(()), so an endpoint that never listed
anything painted as one running nothing, with nothing to press. The live case is
OpenRouter, which documents answering 200 with the error in the body; the engine
already knew, because response_guards carries a guard for exactly that, but it
was installed on the pass's client alone. It is installed wherever a provider
builds a client now, so a listing body and a pass body are read the same way.
A captive portal is the other case, and it is why none of these sentences quotes
the SDK: a page of HTML at a 200 leaves the openai SDK's own parser raising
'str' object has no attribute '_set_private_attributes', which names nothing a
reader can act on. Where the words are the vendor's — a status it set, a message
its SDK carried off the response — they are passed through untouched.
The probe reads a 200 the same way, and for a while it did not. Every probe
on openai, openai-compatible, openrouter and cloudflare is one free
lookup of /models/<model>, and the SDK raises on a status and never on a body:
a sign-in page came back as the text it was, an object with no id as a model
with no name, and the doctor printed key accepted and '<model>' resolves
off an answer nobody read. A 200 is an answer to the lookup only when it is a
model object whose id names the model asked for, or names the same model the
way an endpoint aliases it: with a tag after it (llama3:latest), a namespace
one side carries (models/gemini-2.0-flash), or as the snapshot an alias points
at (gpt-4o as gpt-4o-2024-08-06, Anthropic's claude-opus-4-0 as
claude-opus-4-20250514, mistral-large-latest as mistral-large-2411),
because an endpoint that aliases a name is a working endpoint. What tells a
snapshot from a sibling is whether what differs is a date or a word:
gpt-4o-mini answering for gpt-4o is some other model, and could not ask.
Anything else, a body the SDK could not parse included, is
could not ask: the line fails with <endpoint> answered, and what came back was not a model and a retry, and the model is not checked. It fails rather than
warns because a pass against a sign-in page sends every body to nowhere.
Anthropic's probe reads its answer the same way: its SDK does not raise on a
page of HTML either.
A listing never raises, and that is the contract everything else rests on.
Option.offered reads an exception as could not ask with the exception's type
in place of a sentence, which is a worse line beside a field than one somebody
wrote. So the client is built where a failure becomes an Offer rather than
above it: an address the SDK's own URL parser refuses is could not ask naming
the address, and nothing is sent. The probe's own listing has a backstop of its
own, because that one reaches revetly doctor, where an exception is not a red
line but a report that stopped.
What the five shipped ones do.
openai lists. GET /v1/models is the same free, unbilled route the
probe's lookup rides, asked without a model name, and it answers with
everything the key is entitled to. One page, because that route answers whole;
sorted and de-duplicated, because the control it feeds is a filter over
hundreds of names and hundreds of names in an endpoint's own order is a list
nobody can find anything in.openai-compatible inherits that implementation and reaches the address
and the key this instance declared, because both are resolved by the caller
and handed in — which is why the listing lives in one place rather than four.
An endpoint serving chat completions and no catalogue answers 404, which
becomes could not ask with the address named and no fix beside it, and the
field stays the text box it was. It is also the listing the doctor's own probe
has always needed: the probe fetched one to settle whether a 404 meant "no
such model" or "no such route", read a single name out of it and dropped the
rest. One fetch, two readers now — so the page can offer the names the
endpoint had just recited.anthropic lists. GET /v1/models, on the client this provider already
builds for the free lookup, under the same headers a pass and a probe send —
anthropic-workspace-id included, because an identity-linked key is refused
on that route exactly as it is refused on /v1/messages, and reporting that
refusal as "no models" would send somebody hunting a typo in a model name. It
asks for a thousand at a time: the API pages this route at twenty by default,
and what the field wants is the catalogue rather than its first screen.openrouter inherits the compatible listing, which is most of what makes
that field usable at all — the router pins no model and names thousands, so
typing one from memory is exactly the case this seam exists for. It also
inherits the guard: this router answers an error inside a 200 on any route,
the catalogue included, so a listing that meets one says which error at which
status and offers to ask again.cloudflare was never exercised against a real account. The compatible
listing is inherited, which points GET /models at
.../accounts/<id>/ai/v1/models. Workers AI's OpenAI-compatible surface
documents /chat/completions and /embeddings on that prefix and says
nothing either way about a catalogue, and no Cloudflare token was on hand when
this was written — so this one is proven against a recorded transport and
nothing else. It is built for the answer that costs nothing: an address
serving no listing degrades to could not ask with the address named, the
field stays typeable, and a model typed by hand still saves. A 403 there
carries the same repair sentence the probe's does — the token needs
Account → Workers AI → Read — and it is a 403 because the vendor set that
status, not because the rendered sentence has those three characters in it.
This provider's endpoint carries the account id, every could not ask
sentence names the endpoint, and one account id in a few hundred holds 403
somewhere in its 32 hex characters: reading the sentence turned a 404 and a
connection failure into a repair line about a permission nobody was refused,
and carried it into the probe's SKIP line as well.What a listing costs, and what it sends. Nothing is billed by token; this is a catalogue route and not a completion. But it is a call to a vendor, made under the credential somebody has just pasted, before any pass has been priced — and what it sends is the part worth stating outright. No body of anybody's work leaves on it. It asks what is available and reads back names.
The reader is owed the call all the same, which is the promise the two egress
lines above keep about the two addresses bodies do go to. revetly doctor
prints it as egress (model listing) beside the extraction and embedding lines
— one line where both sinks would say the same sentence, two where an instance
declares a second vendor for embeddings — and context_health's egress
carries the same under listing. Each of those lines says when a listing is
made at that address, in the engine's own words: on every provider when somebody
opens a model field, and on a provider whose probe lists, when a probe has to
tell an absent model from an endpoint that serves no catalogue at all. Named the
way everything on that surface is named: the address, the credential by the
name of the variable holding it, and any gateway header by the name of its
variable. Never a value. A listing sends those headers for the same reason a
probe does — an endpoint that refuses an unheadered call would otherwise be
reported as an endpoint with no models rather than as one nobody said the
password to.
The pin is always on the list. Each of the three fields folds the provider's
own default for that field into what it offers, so the model an instance would
run by leaving the field alone is always one of the values on offer — including
where the vendor could not be reached at all, where the pin rides in the list
beside the sentence rather than in place of it. That is what makes Cloudflare's
embedding field certain whatever its account ever answers: @cf/baai/bge-m3 is
the spec's pin, and it is what this instance would embed with. A provider that
pins nothing for a field — every compatible endpoint, the router — hands back
its own answer untouched.
And a list is an offer, never a gate. Nothing here validates, narrows or refuses what saves: a name absent from a fetched list is a name somebody meant, because an endpoint may have been catalogued before a model was published and an account may reach one through a route the catalogue does not describe. What this engine accepts is unchanged by any of it: the listing informs the field and rules on nothing.
Rates live in the engine's providers/rates.toml, one sheet per provider with
its own as_of date; [pricing.models] outranks them; every money line says
which answered; a model no sheet prices is reported unpriced, never free. The
retry authority (revetly manual show failure-and-staleness) is installed on
the LLM path of every provider and on the embedder too, so
an embedding 429 retries within the same budget rather than failing the pass.
Two doors beside the built-ins. An installed package registers an entry point
in the revetly.providers group whose object is a revetly.providers.Provider
(or a callable returning one); the instance names it as [llm] provider. Or a
Python file in the instance:
[llm]
provider = "voyage-house"
module = "providers/voyage_house.py" # exposes `provider`, a Provider instance
A provider declares a ProviderSpec — key variable, default endpoint, the SDK's
base-URL variable, model pins, whether it embeds, which SDK flavour it rides —
and supplies make_llm_client, make_embedder (or None), make_reranker,
classify for its SDK's terminal errors, read_usage for its response shape,
and the two probes. list_models is optional in the way the probes are:
implement it where the endpoint serves a catalogue, and leave it alone where it
does not — the default declines by name, which is a better sentence beside a
model field than a page that went quiet. revetly doctor's egress lines name
the door each in-force provider came through.