ADR 0041: Providers, models and efforts are chosen in the console
- Status: accepted; implemented 2026-10-04; amended by ADR 46: Jev is a provider, and the judge's settings are
[judge] - Date: 2026-10-04
- Amends: 8 (effort controls, deferred there), 40 (the picker it left unbuilt; a switch while a turn runs; the fallback for a model that is gone)
Context
ADR 40 made a conversation's model a choice and built the wire, the log and
the session, not a picker. The console still had one form, "Change the main
model": one server, one model, no effort, no way to see which providers could
be used and which could not, and a dialect that only the file could set. A
person could not choose how hard a reasoning model thinks at all: ADR 8 had
deferred effort controls "until a concrete incompatibility needs them", and a
provider that refuses a field it does not know (OpenAI refuses
reasoning_effort for a model that is not a reasoning model) is that
incompatibility.
Decision
- An effort is a word config.toml declares for a model.
efforts = ["low", "medium", "high"]in a[models.NAME](or[control]) lists the words the model's provider accepts, andeffort = "medium"is the one a conversation starts with when none is chosen. Nothing is inferred from a model's name or a server's address (ADR 8 says why for dialects, and it holds here): a model that declares none takes no effort setting, is offered none and is sent none. The word is passed on as it is:reasoning_effortto a chat-completions server,thinkto Ollama (offandonare its booleans, any other word the level a model such as gpt-oss takes).[control]takes its efforts from the entry for the same model at the same address (Config.Efforts), so a model's efforts are listed once, and a copy cannot go stale beside its entry.llm.Request.Effortis omitted when empty, so a request without one hashes as it did;llm.reqrecords the effort it asked for and replay rebuilds it, so every request still verifies. - A conversation's selection is a model and an effort.
session.openandsession.model.settakeeffortbesidename, orserverandmodel. Empty is the model's own default effort. An effort the model does not take is refused with the words it takes, never dropped: a person who asked for one is not answered with another. The choice is insession.model(effort), a conversation opened asking an effort records it, and resuming restores it. A change of effort alone sizes nothing again and checks nothing. - A choice made while a response is running waits for it (amends ADR 40,
which refused it). The turn under way has asked its model, so the choice is
queued: recorded at once as
session.modelwithqueuedand no window recipe, reported asqueuedin the state, and applied when the turn ends, under the lock that ends it, so no turn can start on the model it replaces. A later choice replaces an earlier one that has not applied. If it cannot apply (the conversation outgrew the target's context, config.toml no longer offers it) the conversation keeps what it had, and the state (notice) and the log (a note, then asession.modelthat says again what the next turn asks) say why. A task closed meanwhile has the choice in its log, and resuming applies it. An attachment being added, and a run, still refuse. - A model that cannot be had is never replaced by another (amends ADR 40,
which fell back to
[control]). A conversation whose recorded choice can no longer be resolved (its model left the list, or the model no longer takes its effort) opens, reportsunavailablewith what it was left on, and asks no turn until a model is chosen for it. It would otherwise send a conversation to a provider nobody chose for it. - Providers are set up in Settings. A provider is a
[servers.NAME]:settings.provider.writeadds one or changes its address, kind (Ollama, or an OpenAI-compatible server) and dialect, which the person chooses and nothing infers, and its key;settings.provider.removeremoves one no model is on. A key goes to the keychain with one binding row, assettings.control.writealways did it (ADR 5, ADR 6): replaced in place when the provider has one, over https unless the server is on this computer, never in config.toml, in an answer or in the page. A key follows a change of path or port and never a change of host: a new host gets none until one is typed for it. - Readiness says what to fix, and a test can prove it.
models.statusreports each model asready,missing,unreachable,unconfigured,needs_key,lockedorunbound, with the sentence that names the next step; the key's state is read from the keychain and the binding table before the server is asked, since that is what is to be fixed, andfreshasks again at once.models.testsends one short request to a model with its effort, through the same binding check a task's request passes, and says what came back:ok,rejected(the key),no_model,refused(an effort the provider does not accept, say),limited,unreachable. It sends no task content. A request that fails in a task carries the same advice (llm.Hint), and a refusal sending again cannot fix (400, 401, 403, 404, 422) is not retried: the turn says so at once instead of after six seconds. - The models a person chooses between are the list.
[control]and the[models.NAME]tables, shown aschoiceswith the efforts each takes.settings.model.writeand.removechange an entry (its provider, id, efforts and starting effort; its other settings stay while it is the same model),settings.control.writetakesnameandeffortto make an entry the default, and choosing another default keeps the one it replaces in the list. The same model listed twice is shown once, as the default. - The pickers. The New task window and a conversation share one picker:
a model select grouped by provider, with the readiness of each in its
label, and an effort select of the efforts that model takes, or "Not
configurable". Moving to a model keeps the effort if the new model takes it,
else takes the new model's own default. A selection that cannot be had is
shown as it is with why, not replaced. New task: it starts on the default
model and effort of Settings; a choice made there is kept, in this tab and
across a reload, until the task starts or the default is picked again, and
never changes the default; a choice left alone sends nothing, as a task
always started. A conversation: changing either select is a
session.model.set; the lines under it say that a change takes effect from the next message, and, while a response runs, that the response keeps what it has and the choice waits. - It is this computer's act (ADR 30, ADR 40). The provider and model
writes,
models.test, and a model or effort atsession.openorsession.model.setare refused to a paired device, which sees the model a task has and the default new tasks start on, and cannot change either.
Consequences
- A model's efforts are what the person declared. Settings offers shortcuts
(low · medium · high, minimal added, off · on for Ollama) and any other
words; it does not read what a provider reports (Ollama's
thinkingcapability, OpenRouter'ssupported_parameters), so the list can say less than the provider allows. Reading them is a later, additive change. - A model whose own default effort is a word cannot be left to its provider from a picker; clearing the default in Settings does that.
- Changing the effort changes the request body, not the messages' prefix (ADR 9); a provider that keys its prompt cache on reasoning settings starts it again.
- A model that gives its own address in config.toml, as every model did before servers, is listed and used, and its provider is shown read-only: its efforts are set in the file, and saving a default model on a provider moves it to one.
- Not built: a picker in the TUI or line mode, more than one choice queued, a
per-request effort for
(model-turn "NAME")beyond the entry's own, a provider catalogue beyond the presets Settings fills a form with, and remembering the New task window's choice across an app restart (the window's console is on a new loopback port each launch, ADR 30, so the browser remembers nothing; new tasks start on the default).
Amendment 2026-10-05: a model's settings are its own
Session 20261005T035352Z-5a76f6e4, gpt-6.1-sol at api.openai.com, kept
the window at its default size ("openai: model list: 401 Unauthorized") and
priced none of its 21 replies; session 20261004T182412Z-747045aa, on
Cerebras, did the same with a 403. Neither can be fixed by asking better:
OpenAI's and Cerebras' model objects are id, object, created and
owned_by (OpenAI's also shutdown_date), with no context length and no
price, and neither API reports what a request cost. OpenRouter's list says
both, and its replies carry their cost, which is why the same models worked
there. So a model's context and prices are said by the person, as its efforts
are. Settings could say them only for the default, as [control]'s own
"Default model tuning": a copy that a model chosen in a picker did not have,
that choosing another default replaced, and that went stale beside the
model's entry. Whether a model takes images was set the same way.
- A model's settings are its entry's.
num_ctx,max_output,output_field,keep_alive,temperature,seed,vision,cache_key,cache_controland theprice_*entries of a[models.NAME]are that model's.settings.model.writetakes them assettings, all of them (one left out is unset;settingsleft out keeps the entry's own), and each choice insettings.readreports them. Settings › Models edits them in each model's form: its context length, its prices and whether it takes images, the rest under More settings. The Default model tuning section and thecontrol.*names insettings.writeare gone. - The default names its entry.
[control]keeps which model it is and the effort it starts at; each setting it leaves unset comes from the first[models.NAME]for the same model at the same address (Config.Tuned), as its efforts do (Config.Efforts). What a hand-written[control]sets itself still stands. Choosing a default by name writes only the choice (Model.Choice); saving the model that is the default moves what[control]held to its entry, filling only what the entry leaves unset, and leaves[control]without; choosing another default fills the old one's entry the same way, so nothing set is lost. A model chosen by server and id gets its entry's settings too. An entry borrows nothing from another entry for the same model: two entries for one model, such as one with a smaller context, are two sets of settings. - Nothing is inferred from a model's name, as before: nodwyn has no table
of context lengths or prices. A conversation on a model whose context
nobody said keeps the default window, and its note now says to set the
model's context length in Settings › Models (it named
[control], which was wrong for any other model), and the note a spend limit leaves for a request of unknown cost says the same of prices.
Not built: reading a context length or a price from a catalogue other than the model's own server's list (OpenRouter's lists OpenAI's models, but asking it for a model on another server would send a request nobody configured).
Amendment 2026-10-05: change notices follow a choice
The conversation picker showed that the running response kept its model and that a change would apply to the next message even when nobody had changed anything. That hypothetical notice was noise. Timing notices now describe actual model or effort choices: a queued choice names both the running and chosen selections, and a choice between messages says when it applies. An unchanged running response shows no change notice; warnings and errors remain.
Amendment 2026-10-05: a model's list is asked with its key, and its reply cap comes out of its context
Session 20261005T042752Z-871dfe24, gpt-6.1-sol at api.openai.com after
the amendment that made a model's settings its own, still noted "openai: model list: 401 Unauthorized": the
request for a server's model list carried no key, on the reasoning that the
lists that report a context length are public. OpenAI and Cerebras list their
models only to a key, so the list was refused there, and the console's model
picker offered nothing for them either. The key is the host's to send: it is
injected by Go after the same binding check every request with it has, and a
model's list goes to the host its requests already go to. The reason it was
not sent was the binding: model keys were bound for POST alone, so a GET
with the key would have been refused.
The same session's settings made the reply cap a puzzle. A model's context
length was up front in its form, its reply cap folded under More settings
with "the window's reserve" for a default, and Settings › Context window had
a "Kept for each reply" of its own; the window grew with a large context but
never shrank, so a 32768-token model kept the 61440-token high water and was
sent requests it could not hold, unless [window] was changed for every
model at once, which config validation demanded of [control] alone.
- A model key is bound for its list. Saving a provider's key binds it for
POSTandGETat the provider's host, as a bearer header. At start the daemon gives each key that config.toml names for a chat-completions server, and that the binding table already allows toPOSTto that server's host, theGETrow it lacks; it adds no host, position or credential the table did not allow.llm.OpenAI.Modelsasks/modelsthrough the authorized path chat requests take, so the window's context length andmodels.offeredare asked with the key, and the egress check is recorded as any other. A key whose binding refusesGETis not sent, and the window's note says why.models.statusstill probes without a key: it reads nothing from the list and only asks whether the server answers. - The reply cap is the model's, and comes out of its context. A model's
cap is its
max_output, elsewindow.output_reserve, but no more than a quarter of a context too small for the window and that reply (Window.ReplyCap; a context that holds both, such as the one an Ollama model withoutnum_ctxis asked for, keeps the whole reserve);max_outputmust be belownum_ctx. The window is sized for the context less that cap (Window.Sized): it grows as before, and it now shrinks, set water marks included, when the context cannot hold it. The validation thatwindow.high_water + window.output_reservefit[control]'snum_ctxis gone with the reason for it. An Ollama model withoutnum_ctxis asked for the high water plus its own cap. A cap that differs from the window's default goes with each request (llm.reqmax_output), so it replays. - Settings say it that way. The model form has the reply cap beside the
context length, its default spelled as a number, and a line saying what a
conversation on the model gets as both are typed; Settings › Context window
calls
output_reservethe Default reply cap. The note a cut reply leaves names "the reply cap of model NAME".
Not built, and not decided: a bundled catalogue of providers, models, context
lengths and prices (models.dev, LiteLLM's map), which would size and price
OpenAI's and Cerebras' models without the person saying it. OpenAI's model
object, asked with the key, is still id, object, created, owned_by
and shutdown_date, so their models are sized and priced only from their
entries until then.
Amendment 2026-10-05: what providers publish fills what an entry leaves unset
Even with the key sent, OpenAI's model object is id, object, created,
owned_by and shutdown_date, so gpt-6.1-sol at api.openai.com still kept
a window of no particular size unless someone typed 1,050,000, still capped
replies at 16384 for a model that writes 128,000 (OpenAI recommends room for
at least 25,000 when reasoning), offered efforts only if someone typed them
(and the ones typed for gpt-6-luna lacked OpenAI's none), and priced
nothing. Every agent that works with these providers has the same gap; they
fill it the same way: a community catalogue shipped with the program, its own
settings winning (LiteLLM's model_prices_and_context_window.json, which
Aider uses; models.dev, which opencode uses).
- The catalogue is models.dev's, read into
internal/catalogueas a snapshot built into nodwyn: per provider, per model id, the context, the longest reply, theeffortreasoning options (a toggle or a token budget is not a word a request names), whether it reads images, and the prices per million tokens, with cache reads, cache writes and a context tier.go generate ./internal/catalogueregenerates it (gen, which also reads a saved copy); nothing in the build or the tests asks models.dev, and a regeneration's diff shows each provider's changes, one model to a line. - A model is found by its server's host and its exact id. The hosts are
a table in nodwyn's source (
catalogue.Providers: OpenAI, OpenRouter, Cerebras, Groq, DeepSeek, Mistral, xAI, Together, Fireworks, DeepInfra, Moonshot, Z.ai, Ollama Cloud); models.dev never supplies an address, so the catalogue describes models and decides nothing about where a request goes. Nothing is inferred from a model's name: an id the provider does not list is not described. - An entry's settings win; the catalogue fills what it leaves unset
(
Config.WithPublished,Config.Efforts): the context length (asked of the catalogue before a server's model list), the reply cap (the published longest reply, at most a quarter of the context, so a model whose limit is its whole context still leaves the conversation most of it), the efforts, that a model reading no images takes none, and the prices, cache writes and a prompt past a tier priced at their own rates (llm.Rates.CacheWrite,Tiers). Prices typed in an entry still price both as input. An effort is checked against the published efforts when the entry lists none. - Settings say where a number comes from. Each choice in
settings.readcarriespublished;models.publishedanswers for a model not yet in the list. The model form shows published values as placeholders, offers As published for its efforts (the entry then lists none, so the provider's apply as the catalogue changes), says when typed efforts differ from the published ones, and the row says as published. The effort labels readxhighas Extra high.
Not built: refreshing the catalogue while nodwyn runs (a daily ETag'd fetch of models.dev into the data directory, taking only numbers and models for the providers above), and listing the catalogue's providers in Settings before any key is added for them.
Amendment 2026-10-05: the judge's model settings sit with the models'
A task on gpt-6.1-sol showed "Judge … 4 calls · 3,267 tokens · not priced":
[jev] had no prices, though typesafe.ai publishes jev's ("$42 per billion
input tokens"), and the judge's model, prices and wait sat apart from every
other model's settings, under Judge.
- The judge is priced as published when
[jev]sets no prices (Config.JevPrices):internal/cataloguekeeps a small entry of its own for models models.dev does not list, by host, each citing its provider's page;api.typesafe.ai'sjev-latestis $0.042 per million input tokens and nothing for output.settings.readcarries it asjev_published. - Settings › Models holds the judge's model settings, after the models': its model, its prices with the published ones as placeholders, and its wait. Settings › Judge keeps whether it is available, what it sees and the probability at which it allows a command, and points to Models for the rest.
- The side panel's Context meter shows from the start of a task, empty until the conversation's first request: the window is known when the task opens, while the first requests are the planner's in a context of its own.
Amendment 2026-10-05: the images choice shows what applies
Settings' Takes images showed Yes for a model whose entry said nothing,
and saved only No, while the catalogue said some of those models read no
images: the form said Yes, the row said takes no images, and choosing Yes
saved nothing, so the catalogue's word could not be overridden from
Settings. The choice now shows what applies (the entry's answer, else what
the provider publishes, else Yes) and saves an answer only when one is
chosen, Yes included (vision = true), so an entry still follows the
catalogue until a person answers.
Amendment 2026-10-05: a price set alone is kept
A model's rates were its entry's when it set both input and output, else
the published ones whole, and the judge's the same (Config.JevPrices). A
price typed alone in Settings, whose empty fields say they are the published
price, was dropped: an output price typed alone left requests priced at the
published output price, and on a model nothing is published of, priced
nothing. Each price is now the entry's when set,
else the published one (Config.Rates, Config.JevPrices), and the row
shows the pair that applies. The published cache-write price and long-prompt
tiers still apply only while the entry types no price, since a price typed
is the one meant whatever the prompt's length; Settings' hint says so.
Amendment 2026-10-05: the fallback reply cap
Settings › Context window called output_reserve the Default reply cap,
"for a model whose own reply cap is empty", though a model with an empty
cap first has the one its provider publishes (above). It is now the
Fallback reply cap, for a model with none of its own and none published,
and the model form's hint names it.
Amendment 2026-10-05: Anthropic and Baseten are providers to choose
Settings offered presets for Ollama, OpenAI, OpenRouter and Cerebras, and no way to use Claude on Anthropic's own API or a model on Baseten.
- Anthropic is a kind beside Ollama and OpenAI-compatible (ADR 8, amended
2026-10-05): its preset fills in
https://api.anthropic.com/v1, the form offers no dialect, and the key is stored withPOSTandGETrows atheader:x-api-keyinstead of a bearer header.settings.provider.writeandsettings.control.writetakeprovider: "anthropic"; readiness, the test request and the model list use the key where Anthropic reads it. The first-run form offers it too. The model form offers an Anthropic model no seed, no reply cap field and no session key (it has none of them), says temperature is 0 to 1, offers the efforts Claude takes (low to max) among its shortcuts, and starts a model added there with the prompt cache's five minute ask chosen. The catalogue describes Anthropic's models atapi.anthropic.com: context, reply cap, efforts, whether it reads images, and the prices, cache reads and writes included. - Baseten is a preset of the OpenAI-compatible kind at
https://inference.baseten.co/v1on chat completions with a bearer key, which is how its Model APIs are documented, and the catalogue describes its models atinference.baseten.co. Nothing else about it is special: its efforts are whatever models.dev publishes, and its models list (GET /models) is asked with the key like any other. - The snapshot was regenerated (
go generate ./internal/catalogue): it adds both providers, and refreshes the others' entries to models.dev as of today (a handful of OpenRouter prices moved). - Not built: reading a model's efforts or image support from Anthropic's model
list (
capabilities), which it reports and the catalogue also publishes; Opus 4.5's effort with a thinking budget; Anthropic through Bedrock or Vertex, which sign requests differently.
Amendment 2026-10-06: servers are keyed by name, not by address
A [servers.NAME] is known by its name. Two can share an address, which is
how an API-key server and a ChatGPT plan server both sit at
https://api.openai.com/v1; settings.provider.write no longer refuses a
second server at an address that has one, only a name already in use is
changed rather than added. A request without name still finds the server by
its address when exactly one is there, and is refused, naming them, when
several are.
- A model on a server names it (
server), and everything keyed by that server follows the name:Config.SameModel,TunedandEffortscompare the server name when both models have one (provider and address only for an inline model), the probe cache and the window-size cache include it, andsettings.control.writekeeps the server the main model names when the address is given again. - The console's Add a provider form takes a name and shows the server by it; a second provider can be added at an address in use.
- Not changed: the catalogue still describes a model by its address's host,
so both servers at OpenAI publish the same prices and efforts. The log's
servername (ADR 2) was already the name, and still is.
Amendment 2026-10-05: a model nobody's server describes is sized by its name
The catalogue (amended above) sizes a model found at its server's host by its exact id. A server it does not describe, whose own model list does not say either, still left the window at its default 61,440 tokens: gpt-6.1-sol and gpt-6-luna, 1M-token models, ran in it on a ChatGPT plan whose list answered 401 (sessions 20261005T035352Z-5a76f6e4, 20261005T044539Z-3e943263, two compactions in 48 seconds), and qwen-3.8-27b on Cerebras compacted 15 times before the catalogue existed (20261004T182412Z-747045aa).
- When the entry, the catalogue at the server's host and the server's model
list all leave the context unknown, the window is sized by what the
catalogue's providers publish for a model of that name
(
catalogue.Like): its id with avendor/prefix set aside, lower-cased, letters and digits only, soqwen-3.8-27b,qwen/qwen3.8-27bandQwen/Qwen3.8-27Bare one model. Several providers may publish it; their smallest context and reply cap are taken, since the model holds that much on any of them. The note says whose. Prices and efforts are never taken this way: they are each provider's. - A model nobody publishes keeps the default window, and the session's model
state says why (
context_unknowninsession.info); the console says so above the task's conversation, with a button to Settings, rather than only in a log note nobody reads.
Amendment 2026-10-06: the first run sets a model up as Settings does
The page that welcomes a person was still ADR 30's: one form, "Let's connect
nodwyn to a model", with an Ollama, OpenAI-compatible and Anthropic choice, an
address, a key and a model's name, written by settings.control.write with a
provider and an address in it. It knew none of what this ADR built: no
presets (Baseten, OpenRouter, Cerebras, OpenAI's plan), no list of what the
provider offers to its key, no efforts, nothing published, and a provider and
its default model saved as one act. A person's first model was chosen one way
and every later one another.
- The first run draws Settings' forms. With no default model and the
provider, model and control writes available (this computer's, as before),
Home draws the provider form of Settings › Models, then the model form on
the provider saved, from
models.js: the same fields, state and checks, so there is one way to set a model up. The provider form starts on Ollama, which needs no key, as the old page did; its Cancel appears once a provider exists, and the model form's Use another provider returns to it. - Saving the model makes it the default. Save and use it is
settings.model.writeand thensettings.control.writeby the entry's name with the effort chosen, sent even when empty, as Settings sends the default's. The context length, prices and the rest of its settings are left to Settings › Models: a model answers without them, and its provider's published values apply until a person says otherwise (amended 2026-10-05). - A key's keychain is made beside the form. A provider that wants a key is
not saved without a keychain (
settings.provider.writerefuses), and the page used to give the key field and no keychain. The provider form now has the keychain form under it, and what was typed, held in the module's state, is still there once the keychain exists. - The old form is gone from the console (
modelForminsetup.js, which only Home used).settings.control.writestill takesprovider,url,modelandapi_key, for clients that name a server and a model together; the console no longer sends them.
Not built: choosing among models already in a hand-written config.toml that names models and no default (the model form's provider and model are asked for, and a model already listed is changed, not listed twice), and the first run for a paired device, which cannot change providers.
Amendment 2026-10-08: the TUI's model command
The TUI offers what the console's picker offers, as commands. :model
shows the conversation's model, its effort and the efforts it takes, a
queued choice, a notice and an unavailable model, with the choices.
:model NAME [EFFORT] is session.model.set, or core's SetModel in local
mode. A choice made while a turn runs is queued and said so in the
conversation, and each session.model record is a line there. The list
of choices is one function, config.ModelChoices. The daemon's
settings.read choices and the local TUI both use it, so the two cannot
offer different models. A model named by its server and id is not offered,
since the picker offers only names.
Not built: a model chosen as a TUI session opens (--model NAME; it opens on
[control], and :model before the first message moves it), and a command
in line mode.
Amendment 2026-10-08: Settings is tabs, and providers carry their models (ADR 47)
Settings › Models is a tab of its own. Each provider is a card with the models on it listed under it and Add a model there, rather than a list of providers above a list of models; the default for new tasks is Model · Effort · Judge. Jev is no longer a preset or a kind of model provider: it is added under Settings › Judge, since it carries no model. A provider form that needs a key while the keychain is locked shows the keychain's form beside it.
Amendment 2026-10-12: a conversation is matched by the model it talks to
A conversation opened on a [models.NAME] entry was said to be gone once
that entry became the default: the picker said "127-0-0-1-fast is no longer in
your list of models" and offered to start nothing, while the daemon went on
resolving the name and sending to that model. session.info names the entry
the conversation was opened on, and the entry is listed once, as control
(above): the console matched a selection to a choice by name alone, so a model
that had merely changed its name in the list looked lost.
- A choice is matched to a selection by the model it is, not by the name it
had. A conversation knows the model it talks to and where (the server
session.inforeports), and a listed choice naming that model on that server is what the conversation is, whatever it is called now. A choice kept for the next task names no model of its own and is matched by its name; a name the choices do not list is looked up in the daemon's registry (models.status, which holds every entry), so an entry listed under another name is not called gone. A name in neither is gone, as before: a model a person removes still says so. - The efforts are the conversation's own. A choice matched to a
conversation by name or model need not take the same efforts when two
entries name one model (
Config.ModelChoicesskips only the default's double), so the daemon's word on the conversation's model is what its effort select and its refusal of an effort the model does not take use.
Not built: matching a conversation to a choice when the conversation's model
has no server name and the choice's does (an entry on a named server whose
address and provider match an inline [control]), which session.info says
nothing of.
Amendment 2026-10-16: the default model takes no provider and no key
settings.control.write took provider, url and api_key as well as a
server and a model, and saved the server and its key itself. It was a second
way to save a keyed provider that the console never used, and it differed from
settings.provider.write where it mattered: with a key it always made a new
credential class (model-host, model-host-2, ...) and never replaced the
key in place, so rotating a key through it left the old one stored and bound
for the host with nothing using it. It also wrote config.toml outside the
lock the sessions take when they save the same file.
- Providers and keys are saved by
settings.provider.writealone.settings.control.writeis{name, effort?}or{server, model, effort?}: it chooses a model, and refusesprovider,urlandapi_keyas unknown parameters. The console already sent only{name, effort}. - Every
config.tomlwriter goes through one function (updateConfig): the lock, the re-read, the chmod, and an error mapping that gives a lock or an I/O failure -32000, not -32602.
A model's fields now say what they are. [control] can still restate a model
that also has a [models.NAME] entry, and the copies made of it (Tuned and
WithSettings take the entry's settings, Choice keeps which model it is,
Efforts and LabelsOf look the entry up) each kept a list of fields by hand,
so a new field missed in one of them was ignored by the default model until it
was set twice. Each field of config.Model carries a scope tag (identity,
choice, setting or entry), WithSettings and Choice read it, the first
control write uses WithSettings for what it keeps, and
TestEveryModelFieldHasAScope fails for a field without one.
Not built: making [control] a pointer to a model (default = "name") so that
a model has one entry. Existing config.toml files restate the model in
[control] and would need a migration of the file; the scopes remove the
failure that motivated it (a field in one list and not another) without one.
Amendment 2026-10-17: a relay's report that its provider failed is retried
The Decision's readiness clause makes a 404 permanent: the server has no
such model, and sending again cannot fix that. A relay answers for someone
else, though. In an unattended lean session on 2026-10-17 at 21:49 (Muse
through OpenRouter's OpenAI-compatible endpoint, by way of a local proxy), one
model request was answered 404 Not Found with
{"error":{"message":"Provider returned error","code":404,"metadata":{"raw":"{\"error\":{\"code\":\"model_not_found\",\"message\":\"The requested model was ...\"}}",...}}}.
It was the only request of the evening that failed, among thousands to the
same model before and after it, and the session ended on it at once: the note
model request failed: openai: 404 Not Found: … with the hint that the server
has no such model, turn.end with outcome error, and run.state "turn
ended: error: openai: 404 Not Found". The retries (two, two and four seconds
apart, ADR 18 amended 2026-10-05) never ran, because the 404 was permanent.
- A relayed provider failure is not the request's. A refusal whose body
is OpenRouter's envelope for its provider's failure, an
errorobject whosemessageis "Provider returned error" (read as JSON, or by those words where the 4 KiB cut broke the JSON), is retried when its status is 404, as a 5xx is (llm.permanentRefusal,relayedFailure). A relayed 400, 401, 402, 403 or 422 is still permanent: the request, the key or the account was refused upstream and would be again. A relayed 429 or 5xx was retried already. A relay's own refusal, such as OpenRouter's 404 "No endpoints found that support tool use.", is not the envelope, and a provider's own 404 (OpenAI'smodel_not_found, Ollama's "model not found") stays permanent, with the hint to check the model's id. - A request over the model's context is permanent whatever its status
(
overContext:context_length_exceeded, "maximum context length", "exceeds the context window", "prompt is too long",exceed_context_size_error, "exceeds the available context size", a relayed provider's words included), so the envelope cannot make one retried, and neither can a server that answers one with a 5xx. - The rest is as it was, in every provider (all of them refuse through
llm.send) and both profiles: 429, 529, the 5xx statuses, a timeout and a connection refused, reset or closed before an answer are sent again up tollm.Retries(2) more times, after 2 s and then 4 s, or after the server'sRetry-Afterwhen it is longer, up tollm.MaxRetryAfter(a minute; a longer one ends the request at once). The waits are bounded by those, and each attempt byloop.request_timeout. A wait ends when the turn's context does, so a cancel, an abort or a run's or turn's wall limit stops it and nothing is sent after that; the turn then ends as that stop says, not as the provider's error. Each retry is a note before its wait,model request failed, retrying (1/2): openai: 404 Not Found: {…Provider returned error…}, and a request that fails every time ends the turn with the last error and, for a relayed 404, the hint "The provider the server routed the request to failed, each time it was sent: send the message again later, or choose another model".
Tests: internal/llm TestARelayedProviderFailureIsSentAgain (the
session's envelope once, then the reply), ...IsRetriedUpToTheBound,
...WaitsNoLongerThanTheCaller (a deadline and a cancel during the wait),
TestRefusalsThatWouldFailAgainAreNotRetried (401, a provider's own 404, a
relay's own 404, context length as 400, 500 and inside the envelope, relayed
400, 401 and 403) and TestTransientFailuresAreSentAgain (429, 500, 502
relayed, 503, 504, 529, a reset, an EOF and a refused connection, which were
retried before too); internal/core
TestARelayedProviderFailureDoesNotEndTheSession, in the lean and the full
profile, ends with the session's error on the unchanged code.
Not built: other relays' envelopes (LiteLLM's, a gateway's), which no session
log has shown; more retries or longer waits for a relayed failure, since the
one in the evidence was a single request; the wait in the retry's note; and
Settings' model test (models.test), which still reports a relayed 404 as
no_model, sending one request and no retries.
Amendment 2026-10-23: a form opens where it was asked for
Settings › Models drew the provider form, the keychain's form beside it and
the model form below every provider card, so with a few providers the form a
button opened was out of sight. Each now opens where its button was: a
provider's Edit in its card, under its buttons, with the keychain's form beside
it in that card (still a form of its own, not inside the provider form); Add
a model on a provider in that card, where its button was, with the provider
fixed, since the card says which; a model's Edit under that model; Add a
provider and the Add a model under the list below the list, where their
buttons are, so they are in sight however long it is. A card's own Edit or
Add a model is hidden while its form is open there. The first run's forms
(#first-model) are unchanged.
Tests: the models browser check finds each form in its card or under its
model (it waited in vain for `#provider-127-0-0-1 .provider-models >
model-form, and then for#provider-127-0-0-1 > #provider-form`, on the
code before), the keychain's form in #provider-openai outside the provider
form, and Add a provider's form below the list and on screen in a 500-pixel
window.
Amendment 2026-10-27: a new task starts on the choices made last
The person asked for the New task window to start on what they picked last: Runs on, the model and effort, the judge, "all of the settings I pick". Until now a choice was kept only until the task it was for started, in this browser's storage (Runs on, the model, the judge), or not at all (the helpers, ADR 27 amended 2026-10-09; the limits, ticked again for each task, ADR 49 §2; In a worktree, kept for one tab), so every task began on Settings' defaults and the choices had to be made again.
Decision:
- What is kept. Runs on (ADR 54) and, for each host, the model and effort, the judge (none is a choice of its own, apart from Settings' default), the helpers ticked, whether the limits are off, and, per folder, whether the task opens in a worktree. Each is kept as it is made, and is no longer dropped when the task starts: the next task starts on it. Picking Settings' default model or judge again keeps none, so the next task follows the default if it changes; a choice still never changes the default, and the model, effort and judge are still sent only when they differ from it. A kept model removed since is said to be gone, as before.
- Where. This computer's daemon keeps them, as it keeps the folder
picked last (ADR 22, amended 2026-10-04):
newtask.lastreads them ({}) or replaces them ({"choices"}), innew-task.tomlin the config directory. A browser's storage is one origin's in one browser, so the app's window, another browser or a console on another port would start on the defaults. A linked host's choices are kept here too, under its name: they are this person's, and that host checks them when the task opens. A daemon without the method leaves them to the browser's storage, undernodwyn-new-task; the earlier keys (nodwyn-runs-on,nodwyn-new-task-model,nodwyn-new-task-judge) are no longer read. - Who writes the file. The daemon, on a
newtask.lastfrom this computer's console or a device signed in with the person's passkey (the bridge's person tier, asworkspace.active); the daemon refuses a replacement to an actor relayed as a paired device, and the person may edit it by hand. The config directory is private to the guard, so neither a model nor a repository can write it. It names choices, never a policy:session.openandworktree.opencheck each again (a model, effort, judge, helper or the limits off is refused to a paired device as before), and it is bounded (64 hosts, 64 helpers and 256 worktree choices a host, 512 bytes a name, absolute folders) so a page cannot grow it without end.
Tests: TestNewTaskChoicesSurviveRestart (a record with no judge, an
effort, helpers, the limits off and a worktree choice is read back after a
daemon restart; an absent judge stays absent; a relative folder, a host
given twice and an effort without its model are refused; a paired device
cannot replace it); the bridge grants newtask.last to the person's device
and refuses it to a paired one (TestRemoteDevicesPairByInvitationAndCannotWidenPolicy);
the models browser check (the next task starts on the model, effort and
helpers chosen last, and on the effort and the judge chosen before an app
restart, none of it in the browser's storage), tasks (Limits unticked
stays unticked for the next task) and hosts (Runs on is kept by the
daemon). On the code before, models failed with "the next task did not
start on the model chosen last", tasks with "Home did not keep Limits
unticked for the next task" and hosts with Runs on in the browser's
storage.
Not built: the TUI and line mode keep no choice for the next task, as they offer none at open (docs/clients.md).
Later the same day a task's limits became its own, none unless set (ADR 49,
amended 2026-10-27): Home has no Limits box, so whether the limits are
off is no longer kept (no_limits an older new-task.toml holds is
ignored), and the tasks check covers the task's own limits instead.