Providers
A provider is the service that runs the model. efrd uses one provider for all new
conversations. [model] provider in config.toml sets it:
| Provider | Models | Login |
|---|---|---|
openai-subscription (the default) | OpenAI models on your ChatGPT plan | efr login openai in a browser |
openai-api | OpenAI models, billed to an OpenAI API key | efr login openai-api with a key |
anthropic-api | Anthropic's Claude models, billed to an Anthropic API key | efr login anthropic with a key |
This file tells how to log in with an API key, how to change the provider, which
settings the Claude models have, and how the prompt cache works. config.md
lists every key.
Log in with an API key
Make a key in the Claude Console (Anthropic) or in the dashboard of the OpenAI platform. Then give it to efr in one of three ways:
efr login anthropic # type the key at a prompt that does not show it
printenv ANTHROPIC_API_KEY | efr login anthropic
efr login anthropic --from-env # read ANTHROPIC_API_KEY of this shell
efr login openai-api works the same, with OPENAI_API_KEY for --from-env.
efr login anthropic-api is the same as efr login anthropic.
- efr never takes a key as an argument. Other users can read the arguments of a process, and the shell keeps them in its history.
- efrd checks the key with one request that runs no model. For Anthropic, this is
GET /v1/models?limit=1. When the API refuses the key, efr shows the message of the API and keeps nothing. - After the check, efrd keeps the key in
secrets/of its data root (~/.local/share/efr/secrets/by default), in a file that only you can read. The permission rules of efr deny this directory to the tools of the agent. --no-checkkeeps the key without the check, for a computer that is offline.efr statusshows each provider with its login. A key shows only as its start and its last four characters, such assk-ant-...a1b2.(active)marks the provider of new conversations. When the provider refused the key (a 401 on a model call or on the fetch of the model list), the line says when, such askey sk-ant-...a1b2 refused at 2026-10-09T12:03:00Z (5m 0s ago). The mark goes away when a request of the same kind with the key works again, or after a new login. The model calls decide: after a model call that worked, a refused fetch of the model list does not show.- efrd keeps these refusals in memory only. After a restart of efrd, and until the
first request with the key gets an answer, the line says
key sk-ant-...a1b2 not checked since efrd started. A model call, a fetch of the model list or a login with the check checks the key. efr logout anthropicdeletes the key from efrd. The key stays valid at Anthropic: revoke it in the Claude Console. When the provider is the one of new conversations, efr warns that it stays in[model] provider, so its turns fail until you log in again.- OpenAI bills an
openai-apikey per token, apart from a ChatGPT plan. efr says so afterefr login openai-apiand afterefr config set model.provider openai-api. - A key can expire, such as a Console key with an expiry date. Then every request
fails as
unauthorized. Make a new key and log in again.
Keep the key out of the environment of efrd
Do not put an API key in the environment of the efrd service: not in
~/.config/environment.d, not with systemctl --user set-environment and not in the
unit file. efrd does not read ANTHROPIC_API_KEY or OPENAI_API_KEY from its
environment. But efrd gives its whole environment to the hidden shells. In the
manual and cautious modes, a command of the agent can then read the key with
env. Only the auto sandbox removes names such as *API_KEY*. Use efr login.
Change the provider
A login does not change the provider of new conversations. After the login, efr tells you what to do when the provider is not the active one:
efr config set model.provider anthropic-api
systemctl --user restart efrd
[model] provider needs a restart of efrd. efr models then lists the models of the
new provider, and its last line names the provider, such as models: anthropic-api, from the backend, fetched 5m ago. A terminal that chose a model with ,model keeps
that choice: choose a model of the new provider, or clear the choice with ,model default.
Also change [model] name when it names a model of the other company, such as
gpt-5.5 with anthropic-api, or remove it. Else every prompt that names no model of
its own fails with this cause and fix. efr config check warns about such a name, and
efrd logs a warning at start and after a reload. A model id in the list of models of
the new provider ([anthropic] models or [openai] models) is not of the other
company.
Claude models
efr has no built-in list of Claude models. The models, their context windows, their
output limits and their efforts come from the model list of the API (GET /v1/models, only the models that are active).
- efrd gets the list at start, after each login and then every hour. A failed request tries again after 15 s, 30 s, 1 min and 2 min, then every 5 min.
- efrd keeps the list in
anthropic_model_catalog.jsonin its state root (~/.local/state/efr/by default). A start without network uses that file. - Before the first list (the first start, or a start before the network is up), a
prompt waits for one request of the list, for at most 60 s.
efr modelsthen saysno list yet; efrd fetches it before the next prompt. - The default model is
claude-opus-5-5when the API lists it.[model] namesets another model, such asclaude-sonnet-5-5.,modelsets one for a terminal. - The default effort is
medium, as in Claude Code.[model] effortand,effortset another one. efr sends the effort to each model that lists it. A model that takes no effort gets none. - The context window is the window of the list: 1M tokens on the current models.
efr compacts at
[compaction] auto_atof the window (76% by default). - efr sends the signed thinking of each answer back unchanged, so the model keeps its
reasoning through a tool loop. While the model thinks, the status row shows
thinkingand the title of the summary of its thinking. - Claude models change files with the
edittool. OpenAI models useapply_patch. - Without a key and without a list, a prompt fails as
unauthorizedand sayslog in with efr login anthropic. When the request of the list failed, the prompt fails withefr could not fetch Claude's model listand the cause, such as the message of the API for a refused key or a request without an answer. A model whose output limit the config gives ([anthropic] modelsor[model] max_output_tokens) runs without a list.
The [anthropic] keys
| Key | What it does |
|---|---|
models | Adds a model that the API does not list yet, or lowers the window or the output limit of a listed model. An entry is a model id or a table { id, context_window, max_output_tokens }. Applies to the next turn. |
cache_ttl | How long the prompt cache keeps what a request writes: auto (the default), 5m or 1h. See "The prompt cache" below. Needs a restart. |
workspace_id | The workspace of your key, sent as the anthropic-workspace-id header of every request and of the login check. Needs a restart. |
base_url | Replaces https://api.anthropic.com/v1, such as for a proxy. Needs a restart. |
A smaller window costs less
A large window lets a conversation grow, and each call sends the whole conversation. Cached input is cheap, but it is not free. To compact sooner, lower the window:
[anthropic]
models = [{ id = "claude-opus-5-5", context_window = 272000 }]
Claude Haiku 5.5 has a price tier: when a request is longer than 100k tokens, the whole request costs 5 times as much. We recommend a window of 100k for Haiku, so that efr compacts before the tier:
[anthropic]
models = [{ id = "claude-haiku-5-5", context_window = 100000 }]
efr config check notes a window above the largest window of its model. efrd then
uses the largest window.
The workspace id
A key that belongs to one workspace needs no workspace id. A key that is not scoped
to one workspace, such as a key of a personal or a service account, needs one. Without
it, the API refuses every request with a 400 that names anthropic-workspace-id, and
the login check shows that message. You can:
- make a new key in the Console that is scoped to one workspace (the simpler way); or
- set the id of the workspace, then log in again:
efr config set anthropic.workspace_id wrkspc_01AbCd
systemctl --user restart efrd
The workspace id is not a secret, so it can be in a dotfiles repository.
The reviewer
In auto, a reviewer model decides most actions that leave the sandbox
(docs/sandbox.md, "Exits"). efr asks the models in this order:
classifier.modelwhen it is set, else the small model of the turn's provider:gpt-6-lunaforopenai-subscriptionandopenai-api,claude-haiku-5-5foranthropic-api.- The turn's model at the effort
classifier.effort(defaultlow), unlessclassifier.fallback_to_turn_model = false. - You.
efr goes to the next step when a model is not in your model list, refuses, takes more
than classifier.timeout_secs (default 20) or gives an answer that does not fit. An
answer with an empty reason or a placeholder reason, such as x, does not fit. The
first model then gets the same request once more before efr goes to the next step. A
model that is missing or refuses is skipped for the rest of the conversation. A
provider that refuses the login fails for every model, so the question then goes to
you at once. efr history --verbose shows the model, the step and the skipped models
of each review, and asked again for a real reason when the first model got its
request a second time.
Each review is one more request to your provider. With an API key, you pay for it per
token; with a subscription, it counts against the limits of your plan. A review sends
only the facts of one action and your messages, with no tools and no history, so it is
small. Within a turn, the same action gets the same verdict without a second request.
classifier.enabled = false sends no review and asks you about every action that
leaves the sandbox.
A review sends no prompt cache key, so an OpenAI review goes over HTTP and never touches the WebSocket of the turn. A Claude review marks only its fixed prompt for the cache, for five minutes.
The prompt cache
The provider keeps the start of each request in a cache. The next request that starts with the same text reads it for a small part of the price. efr keeps the history of a conversation append-only, so each request starts with the request before it and its answer.
The end-of-turn line shows the part of the turn's input that came from the cache,
such as done in 42s, ctx 43% (89k/207k), 1.1k out, cache 91%. A write to the cache
counts as a miss. The line shows it only when the turn's input is 2048 tokens or
more: below 512 tokens (Claude) or 1024 tokens (OpenAI), no cache entry forms.
OpenAI caches each request without any setting. Claude caches only where the request puts a marker, and each marker keeps its entry for five minutes or for one hour.
cache_ttl
A write for five minutes costs 1.25 times the input price. A write for one hour costs 2 times. A read costs 0.1 times or less. Each read starts the time to live again. A pause longer than the time to live loses the entry, and the next request writes it again.
| Value | What it does |
|---|---|
auto (default) | efr's choice. Today it is the same as 1h: an entry for one hour cannot build on an entry for five minutes, so one hour for the conversation and five minutes for each tool loop would write each tool loop twice. A pause up to one hour, between two prompts or while efr waits for your approval, loses nothing. |
5m | Everything for five minutes. This costs less when you send the next prompt within five minutes. A longer pause writes the whole conversation again. |
1h | Everything for one hour. Each write costs more, and a pause up to one hour loses nothing. |
A summary call of a compaction uses the same time as the other calls, so it reads the cache of the conversation.
What makes the cache miss
| Event | Claude | OpenAI |
|---|---|---|
A change of the model (,model) | The whole cache: each model has its own | The whole cache |
A change of the effort (,effort) | The conversation; the system prompt and the tools stay | Can miss |
A new [model] system_prompt | The system prompt and the conversation | The whole cache |
| A compaction | The conversation; the system prompt and the tools stay | The whole cache |
| A pause longer than the time to live | The whole cache | The whole cache (OpenAI keeps an entry for 30 minutes or more) |
| A restart of efrd | Nothing | Nothing |
efr history --verbose shows the tokens of each turn on one muted line, such as
18.2k in, 1.1k out, cache 91%, wrote 1.2k (1h): the input, the output, the part of
the input that came from the cache, and the cache writes, with (1h) when they were
for one hour ((800 1h) when only a part was). An interrupted or failed turn that
reported its tokens shows the line too.
Measure the time to live
With log = "info,efr_conversation::usage=debug" in config.toml (the key applies at
once, with no restart), the log of efrd shows one line for each model call of a turn
that reported its tokens, such as usage gap_ms=95000 input=18200 output=1100 read=16600 write=1200 write_1h=1200. The line holds numbers only, never the text of a
prompt or an answer:
gap_ms: the time since the start of the conversation's call before, ornonefor the first call after a start of efrd.inputandoutput: the tokens of the call;read: the part of the input that came from the cache;write: the cache writes;write_1h: the part of the writes for one hour.
Read the lines with journalctl --user -u efrd --since -7d | grep 'usage gap_ms=', and
set log back when you have enough of them. With log = "info,efr_=debug", the log
also shows:
gap_mson every line of a call: the time since the start of the conversation's call before. The first call after a start of efrd has none.- For Claude, one line with the markers of the request, such as
cache_ttl=auto markers=S1h,A1h,P1h,T1h: S is the system prompt and the tools, A an earlier point in the conversation (the start of a turn, or about every 20,000 tokens of a tool loop), P the end of the call before and T the end of this request.
The turn_completed event of each turn holds its token counts: the cached input
(cached_input_tokens), the cache writes (cache_write_tokens) and the writes for
one hour (cache_write_1h_tokens). Together, these show which cache_ttl costs less
for the way you work.