The LLM client
createLlm is a dependency-free client for Ollama and any
OpenAI-compatible endpoint. Everything goes through fetch, which the
extension host has had natively since VS Code 1.82 (Node 18) — nothing is added
to your package.json.
import { createLlm } from '../shared/vsceasy';
const llm = createLlm({ provider: 'ollama', model: 'qwen2.5-coder:7b' });const text = await llm.chat([{ role: 'user', content: 'hi' }]);Options
Section titled “Options”| Option | Default | Notes |
|---|---|---|
provider | 'ollama' | 'ollama' or 'openai' (any OpenAI-compatible server). |
baseUrl | http://localhost:11434 / https://api.openai.com/v1 | Per provider. |
model | — | e.g. qwen2.5-coder:7b, gpt-4o-mini. Empty string means auto (see below). |
apiKey | — | Sent as Authorization: Bearer …. Ignored by a plain Ollama. |
temperature | provider default | |
maxTokens | provider default | num_predict on Ollama, max_tokens on OpenAI. |
timeoutMs | 60_000 | Aborts the request. |
What the client can do
Section titled “What the client can do”// full chat turn → assistant contentawait llm.chat([{ role: 'system', content: 'Be terse.' }, { role: 'user', content: 'why?' }]);
// single-prompt shorthandawait llm.complete('Explain closures', { system: 'You are a tutor.', maxTokens: 200 });
// strict JSON, parsed and typedconst plan = await llm.json<{ steps: string[] }>([{ role: 'user', content: 'plan as JSON' }]);
// streaming — passing onToken switches the request to stream modeawait llm.chat(messages, { onToken: (chunk) => append(chunk) });
// what's installed on the endpointconst models = await llm.models(); // [{ name, size? }]
// reachability probe, never throwsconst status = await llm.ping(); // { ok, model?, warning?, error? }json() tolerates the ```json fences and stray prose small local
models still emit even in JSON mode: it retries the raw text, the fenced block,
and finally the outermost {…} / […] before throwing.
Cancel a call with an AbortSignal — useful in an inline-completion provider,
where the user has usually typed on already:
const ctl = new AbortController();const p = llm.complete(prompt, { signal: ctl.signal, timeoutMs: 8_000 });ctl.abort(); // rejects with "Request aborted"Model resolution (Ollama)
Section titled “Model resolution (Ollama)”Ollama addresses models by their full name:tag. A configured
qwen2.5-coder does not match an installed qwen2.5-coder:0.5b — the request
404s. The client resolves the configured name against what is actually installed:
- Exact match wins.
- A name without a tag takes the first installed tag of that model.
- An empty
modelmeans auto — pick something usable. - Nothing close → fall back to an installed model rather than 404ing.
The fallback prefers a coding model, then any general chat model, and never
picks an embedding model (it can’t chat) or a :cloud alias (it needs
credentials). Resolution happens once per client and is cached.
await llm.resolveModel(); // the name requests will really useawait llm.ping();// → { ok: true, model: 'qwen2.5-coder:0.5b',// warning: '"qwen2.5-coder" is not installed — using "qwen2.5-coder:0.5b".' }ping() deliberately checks the model, not just the server: reaching the
endpoint isn’t enough if every call is about to 404.
Reasoning models: think
Section titled “Reasoning models: think”await llm.chat(messages, { think: true, maxTokens: 2048 });think is off by default. Ollama counts hidden reasoning against
num_predict, so a thinking model given a modest budget burns all of it and
returns empty content. When that happens the client throws a specific error
instead of silently returning '':
The model used its entire token budget on internal reasoning and produced no answer. Raise maxTokens … or choose a non-reasoning model.
Turn think on only for tasks where the deliberation is worth the tokens, and
raise maxTokens with it.
User-configurable: initLlm + useLlm
Section titled “User-configurable: initLlm + useLlm”Hard-coding the host and model is fine for a prototype. To let the user choose, build the shared client from settings on activate:
import { bootstrap, initLlm } from '../shared/vsceasy';import { registry } from './_registry';
export const activate = bootstrap(registry, { // Pass the settings prefix explicitly when you know it. onActivate: [(ctx) => initLlm(ctx, undefined, 'myExt')],});Then anywhere else:
import { useLlm } from '../shared/vsceasy';
const text = await useLlm().complete('…');initLlm reads <section>.llm.* and rebuilds the client whenever those
settings change, so switching model in the Settings UI takes effect without a
reload. useLlm() throws if called before activate.
Settings to declare
Section titled “Settings to declare”Put them in contributes.extra.json
— gen merges that file into package.json#contributes:
{ "configuration": { "title": "My Extension", "properties": { "myExt.llm.provider": { "type": "string", "enum": ["ollama", "openai"], "default": "ollama" }, "myExt.llm.baseUrl": { "type": "string", "default": "http://localhost:11434" }, "myExt.llm.model": { "type": "string", "default": "", "markdownDescription": "Empty = auto-select an installed model." }, "myExt.llm.apiKey": { "type": "string", "default": "" }, "myExt.llm.temperature": { "type": "number" }, "myExt.llm.timeoutMs": { "type": "number", "default": 60000 } } }}Using it for ghost text
Section titled “Using it for ghost text”The natural home for an LLM is an inline completion provider, where delayMs
and cacheMs keep you from hammering it:
import { defineInlineCompletion, useLlm } from '../shared/vsceasy';
export default defineInlineCompletion({ selector: 'typescript', delayMs: 900, cacheMs: 15_000, provide: async (ctx) => { const text = await useLlm().complete(`Continue:\n${ctx.linePrefix}`, { maxTokens: 64 }); if (ctx.token.isCancellationRequested) return null; // the user typed on return { text }; },});See Editor surface for the rest of the providers, and Code Trainer for a full extension built on this client.