Manual 0.8.0

Which provider, and where every body goes

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.

The three general ones

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.

The two presets

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".

Declaring no provider at all

[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.

Headers, and the body

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 endpoint rule

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.

Models, per task

[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.

What the vendor says it runs

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.

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.

Price and retry

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.

A fourth provider

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.