Seven ports and a name: what the first run looks for, and what Test provider sends
twinny used to write three Ollama providers on start whether or not Ollama existed. Now it asks seven local addresses for a model list at once, picks a model for each job from the name alone, and leaves a job unset rather than guess wrong. What the probe sends for chat, autocomplete and embeddings, why it stops after one chunk, and how a thrown error becomes a sentence you can act on. From src/extension/providers and src/common/provider-discovery.ts.
The comment at the top of src/extension/providers/setup.ts says what the problem was: twinny used to write three Ollama providers on start whether or not Ollama existed, so the first thing a new user saw was an error. The replacement is two small modules and a probe. This post reads them: setup.ts and discovery.ts in the extension, the pure decisions in src/common/provider-discovery.ts and model-pick.ts, and the Test provider button in probe.ts.
Seven addresses, asked at once
With nothing configured, discoverLocalServers asks every address on a fixed list for its models in parallel. The list is in order of preference: Ollama on 11434, LM Studio on 1234, llama.cpp on 8080, Oobabooga on 5000, LiteLLM on 4000, Open WebUI on 3000, and a generic OpenAI-compatible server, also on 8080. The generic entry goes last because its port is llama.cpp’s; when both “answer” it is the same server twice, and dedupeServers keeps the first by address.
The one address that is not fixed is Ollama’s. twinny.ollamaHostname and twinny.ollamaApiPort are read into an extra candidate: if it matches the default address it replaces the built-in entry in place; if it is a custom port it goes to the front, so a non-default Ollama is tried in addition to the usual one, not instead of it. A hostname of 0.0.0.0 becomes localhost, because that is where Ollama listens, not an address to call.
“Asks for its models” is itself more than one request. The HTTP adapter’s models() tries every listing route it knows for that server kind at once, /api/tags and /v1/models for Ollama, and takes the first that returns a non-empty list, so an Ollama behind a proxy that only speaks the OpenAI route still lists. The names are de-duplicated and sorted before they come back. That sort matters in the next step.
Every probe resolves; a candidate that throws becomes nothing, and the first server in preference order is used.
A job left unset
A found server becomes up to three providers, one per job: chat, autocomplete, embeddings. providersForServer asks pickModelStrict for a model per job, and the word strict is the point. The provider form uses pickModel, which has to fill a box and so falls back to the first model that is not obviously wrong. Unattended setup uses the strict version, which returns nothing unless a model looks right, and a job with no match is left out. Unset beats pointing autocomplete at an instruct model that will babble, or embeddings at a model with no vectors.
“Looks right” is a name test. Embeddings: embed, minilm, bge, e5 or nomic. Autocomplete: code, coder, fim or a known family (starcoder, codestral, codegemma, stable-code), and not an embedding model. Chat prefers a name with instruct, chat or an it ending, then anything that is not a code model, then whatever is left. Autocomplete takes the first match, and because the list arrived sorted, that is the first in alphabetical order. With the quick start’s two models, qwen2.5-coder:1.5b-base sorts before qwen2.5-coder:7b-instruct and the base model wins; with deepseek-coder:6.7b-instruct also installed, it does not. The docs say to check which model autocomplete got, and this is why.
Each provider gets the server’s defaults for its job (/v1 for chat on Ollama, /api/generate for FIM, /api/embed for embeddings), a label like Ollama FIM, and for autocomplete the FIM template set to automatic, so the template is chosen from the model name later. A notification says what was set up, with a Change providers button.
If nothing answered, the notice is shown once, recorded in global state, and never again; the Providers tab’s welcome screen has Search again, which calls the same discoverLocalServers and offers each server that answered. Two smaller things keep this from racing the UI. whenProviderSetupDone is a promise that anything reading the provider list at start waits on, so a sidebar VS Code restores open never shows an empty list that discovery is about to fill. And repairActive runs after setup, when the provider manager starts, and after an import: if a job’s active provider no longer exists, the first provider of that type takes its place.
What Test provider actually sends
The button on a provider’s card goes through the same inference layer the feature will use, so a passing test exercises the adapter, the path and the model, not just the port. What it sends depends on the job:
- Chat:
Say hi., withmaxTokens: 8. - Autocomplete: the prompt
def add(a, b):\n return,maxTokens: 8,temperature: 0. The stream is read until the first chunk with text and then cancelled, so the server does not keep generating for a probe. Whether a real token came back is what tells you the model and template are wired up. - Embeddings: the input
hello; the result is the vector’s dimension count.
The whole thing has a 20-second deadline. On success the badge shows the latency and the first 60 characters of what came back. For a gateway provider it also asks the gateway who it took the test for and shows as <key name> or as shared token, since the key is the only thing that tells developers apart; an older gateway without that route is ignored and the test still passes. A failure is a result with an error string, never a rejection, because the caller is a form that wants to show the reason.
How an error gets its sentence
The reason comes from two files. inference/errors.ts decides what kind of failure it was, in a fixed order: a TimeoutError is a timeout; an abort is cancelled; ECONNREFUSED, ENOTFOUND, fetch failed, socket hang up and a handful of others mean the provider is unavailable; 401 or 403, or “api key” or “unauthorised” in the text, is authentication; a 404 with “model” in the message is a missing model; 429 is rate limiting; “timed out”, 408 or 504 is a timeout; anything 500 and up is the provider again; the rest is an inference failure. The status is read from the error object, or failing that from a known status code (401, 404, 429, 503 and a few more) in the message. Every message in the cause chain, up to five deep, is joined with | so nothing the server said is lost.
providers/errors.ts only puts words to the kind. Unavailable becomes Could not connect to Ollama at http://localhost:11434/v1. Check that the server is running and that the hostname, port and protocol in the provider settings are correct, or, for a 5xx, returned a server error (503). Check the server logs. A missing model says to check the name or pull it first; a timeout says the model may still be loading; a 404 that is not about a model points at the API path. The raw text follows on its own line in every case. One kind skips the template: a device error, where the local gateway already wrote a sentence about the device and “check the hostname” would point at the wrong thing. A gateway refusal gets its own sentence, which says to enter a current key. The Test badge shows the plain variant, markdown stripped and the two lines joined by a dash.
The user-facing version, including the welcome screen and the form’s Will call line, is at docs.twinny.dev/providers/overview; the error table is in troubleshooting.