Non-obvious facts about providers, the pipeline, and VSCode integration. Append new gotchas here as you discover them; do not delete entries unless the underlying behavior changed.
The default provider: "auto" walks AUTO_CHAIN = ["pollinations", "duckduckgo", "huggingface"] in orchestrator.ts, returning the first success. Every leg is keyless — auto mode is guaranteed not to prompt for credentials. Ordering rationale:
When all three legs fail, the orchestrator throws a single error that lists every leg's failure reason. The webview surfaces this verbatim — useful for debugging because typically only one or two legs go down at a time. Providers that require a key (mistral, openai, anthropic, groq) are never in the auto chain; the user must select them explicitly after pasting a key.
https://text.pollinations.ai/openai (NOT /v1/chat/completions). Schema is otherwise pure OpenAI.Content-Type can be application/json (full OpenAI envelope) OR text/plain (just the model output). The provider handles both shapes.openai-large — Pollinations uses friendly names, not OpenAI's. Other useful values: openai (gpt-4o-mini), mistral, deepseek, qwen-coder.GET https://duckduckgo.com/duckchat/v1/status with header x-vqd-accept: 1; response header x-vqd-4 is the session token.POST https://duckduckgo.com/duckchat/v1/chat with x-vqd-4: <token>.data: {"message":"..."}\n lines); concat all message fields, ignore [DONE].system message; we concatenate system + user into a single user turn.gpt-4o-mini, claude-3-haiku-20240307, meta-llama/Llama-3.3-70B-Instruct-Turbo, mistralai/Mistral-Small-24B-Instruct-2501, o3-mini. Map friendly names ↔ full names in duckduckgo.ts's MODELS dict.https://router.huggingface.co/v1/chat/completions. The router fronts many backends (Inference Providers, Together, Fireworks, etc.) and picks one per model.huggingface.co/settings/tokens) via Authorization: Bearer ... bumps quota and unlocks gated models.Qwen/Qwen2.5-7B-Instruct — small, fast, good enough for short structured JSON. Override via gitCommitSuggestion.model: try meta-llama/Llama-3.3-70B-Instruct for higher quality.response_format: json_object compatibility; if you see "json_object not supported" in errors, switch model.The extension ships no API keys. Providers split into two categories:
auto chain only uses these.Earlier in the project we shipped a hardcoded shared Mistral key as a convenience. That was reversed: shared keys get burned or violate ToS once the extension hits real usage.
mistral-small-latest (default), open-mistral-7b, open-mixtral-8x7b. Premier models (large-latest etc.) require a paid plan./v1/chat/completions schema with Authorization: Bearer <key>. response_format: { type: "json_object" } works./v1/messages, not /chat/completions. The OpenAI-compatible class does not work here.system field, not in the messages array.x-api-key: <key> plus anthropic-version: 2023-06-01.{ content: [{ type: "text", text: "..." }] }. Filter for type === "text" before reading text.max_tokens is required. The extension uses 1500 — enough for 4-5 commit suggestions in JSON./api/chat. stream: false is required, otherwise the response is a sequence of NDJSON objects rather than one JSON body.format: "json" nudges the local model into emitting valid JSON. Without it, small models (7B–8B) frequently fail JSON parsing.http://localhost:11434. Override with gitCommitSuggestion.ollamaBaseUrl (e.g. remote box on LAN).https://api.groq.com/openai/v1.llama-3.1-8b-instant is fast and good enough for commit messages; llama-3.3-70b-versatile is better but slower.gitCommitSuggestion.enableUnofficialProviders and shows a modal warning before first use.
src/providers/g4f.ts ships a small list of community endpoints; if all fail, the orchestrator falls back to Mistral (so you still need a Mistral key configured for fallback to work).FALLBACK_ENDPOINTS in src/providers/g4f.ts. Don't put endpoints in config/providers.yml if they require auth — that file is committed.fetch() never times out on its own. If an upstream LB accepts the TCP connection then never sends a response (Pollinations does this occasionally when rate-limited; Cloudflare-fronted providers do it during incidents), await resp.text() hangs forever. The webview shows a spinning loader with no exit condition — exactly the bug 0.1.1 was supposed to kill, just via a different path. Always wrap provider calls in an AbortController tied to a setTimeout; the timeout-fired abort must be distinguishable from a user-initiated Stop so the chain can surface "Timed out after Ns" instead of treating it as a silent cancel.
withTimeout() in src/pipeline/orchestrator.ts. It creates a child controller that aborts on either parent-signal abort OR a timer; timedOut() lets the catch branch convert a child-AbortError into a ProviderError("Timed out after Ns") while still re-throwing user aborts as AbortError so they short-circuit the chain (cancellation isn't a failure).dispose() in finally. Forgetting to clear the timer leaks the handle and (with a parent signal) leaks an abort listener per call.throw new ProviderError(this.id, `HTTP ${resp.status}: ${text.slice(0, 400)}`) is a footgun. When upstream load balancers (Cloudflare, nginx) return 502/503, the body is a full HTML page with <!DOCTYPE> + meta + inline CSS. The orchestrator aggregates these into "all auto-chain providers failed:\n…" and dumps the wall of HTML into the webview banner. Route every HTTP error body through summarizeErrorBody() in src/providers/base.ts — it preserves the first 400 chars of JSON bodies (where upstream API errors put their useful message) but tag-strips and 120-caps HTML/plain text.
{ or [ on the trimmed body — anything else is treated as opaque. Don't try to be cleverer.git diff --cached from a non-repo cwd falls through to git diff --no-index mode, which then complains about unknown options (including --cached/--staged!) and prints a misleading "usage: git diff --no-index" block. This is the symptom users will report; the cause is "no repo at the cwd we passed", not a git-version issue.workspaceFolders[0] looking for a .git entry (directory or file — submodules and worktrees use a .git file). See findRepoRoot in src/utils/git.ts. We deliberately do NOT use git rev-parse --show-toplevel because it raises the same misleading no-index error when the cwd isn't a repo — we want a clean "not a git repo" message instead.--cached not --staged in git diff. --staged was added in Git 2.18 (2018); --cached has worked since 1.6 (2008). The two are aliases. There's no upside to requiring the newer spelling.chars / 4 heuristic. The real tokenizer the provider uses will differ — pad your maxDiffTokens by 20% if you hit context-overflow errors.Binary files differ) take negligible tokens and are always kept whole.response_format: json_object. The parser strips ```json / ``` wrappers as the first step.{ "suggestions": [...] } instead of a bare array. The parser handles both shapes."feature", "bugfix"), the parser falls back to chore rather than failing. This is intentional — partial output is better than nothing.repository.inputBox.value. Setting it triggers the SCM view to update — no manual refresh needed.api.getRepository(uri) picks the one whose root contains the workspace folder. We fall back to repositories[0] if no match.\n.context.secrets (encrypted, per-machine, never synced). Never via workspace.getConfiguration().update() — that writes to settings.json, which gets committed to dotfile repos.gitCommitSuggestion.apiKey.<provider> (see SECRET_KEY in extension.ts). Don't change this without a migration step, or existing users will lose their keys silently.onStartupFinished is used so the status-bar lightbulb appears for every workspace. Activating on a command only would mean the button doesn't show until first invocation.npm: compile in .vscode/tasks.json uses type: "process" with absolute paths to node and tsc.js, NOT type: "shell". Earlier attempts with shell tasks failed intermittently with exit 127 ("command not found"), even after adding an env PATH override. Root cause on macOS: the task shell sources /etc/zshenv → path_helper and .zprofile, which can rebuild PATH in unpredictable order. When nvm is lazy-loaded in .zshrc (a common setup), node exists only as a shell function that a non-interactive task shell never triggers, so /usr/bin/env node inside the tsc shebang resolves to nothing. type: "process" bypasses all of that — VS Code execs the binary directly, no shell startup, no shebang resolution.
v22.17.1 in the path. If a contributor upgrades node and removes that version, the task breaks; update both compile and watch task paths in .vscode/tasks.json. This is the conscious trade-off for robustness — depending on shell PATH was more fragile.preLaunchTask 'npm: compile' terminated with exit code 127" instead of launching the Extension Host. Reproduce manually with "$HOME/.nvm/versions/node/v22.17.1/bin/node" "$PWD/node_modules/typescript/bin/tsc" -p . to confirm the absolute-path invocation works before suspecting anything else.views.scm must NOT have a "when" clause. Context keys like scmProvider == git apply to menu contributions only (menus.scm/title, menus.view/title). On view contributions, an unmatched when silently hides the entire view — symptom: "Commit Suggestions doesn't appear in my SCM sidebar". Earlier in this project the view was hidden by exactly this mistake for several commits.
vscode.commands.executeCommand("setContext", "myKey", value) from activate() and referencing "when": "myKey" — but that needs the custom key, not built-in SCM context keys.WebviewView inside a dedicated Activity Bar container gitCommitSuggestion (see "viewsContainers.activitybar" and "views.gitCommitSuggestion" in package.json). It is NOT under the SCM sidebar — it lives in its own pane, which is why moving the user between the two requires explicit focus commands (see "Cross-view navigation" below). An earlier revision of the project hosted it under views.scm; this note is kept because the container choice affects every view-focus / lightbulb wiring decision.CommitSuggestionViewProvider.renderHtml with a strict CSP: default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-...'. No external assets, no require/import inside the webview — VS Code's webview sandbox disallows both.view.webview.postMessage({ type: "state", state }); webview → extension uses vscode.postMessage(...) via the acquireVsCodeApi() handle. There is no shared state and no synchronous bridge — every UI update is a full state replacement.Suggestion[] in a suggestionsRef closure; on { type: "use", index } it looks up by index. This avoids round-tripping the diff/LLM-output back into the webview's JSON-serialized world.--vscode-foreground, --vscode-button-background, etc.) so the view automatically matches Light/Dark/High-Contrast themes — never hard-code colours.resolveWebviewView only fires when the user expands the view. The first { type: "ready" } message from the webview triggers an initial state post so the saved state (if any) is restored.vscode.commands.executeCommand("gitCommitSuggestion.view.focus"). The pattern is <viewId>.focus — VS Code auto-generates this command for every view declared under contributes.views. DO NOT use workbench.view.<containerId>: that pattern only exists for built-in containers (scm, explorer, debug, extensions, …). For extension-contributed activity-bar containers, the auto-generated command is workbench.view.extension.<containerId> (note the extra .extension), and the safest reliable handle is the per-view <viewId>.focus form. An earlier revision of this code used workbench.view.gitCommitSuggestion and threw "command not found" at runtime; the per-view focus form fixed it.vscode.commands.executeCommand("workbench.view.scm"). This is the built-in SCM container and is always available regardless of which Activity Bar containers the extension contributes.resolveWebviewView will fire as a result, and any state changes the extension makes in the meantime (e.g. setLoading() inside runSuggest) are buffered — post() short-circuits when this.view is undefined and the state is replayed once the webview sends its first { type: "ready" }.scm/title (Source Control title bar) runs gitCommitSuggestion.openPanel — a pure-navigation command that only focuses workbench.view.gitCommitSuggestion and does not call the LLM. The user-facing reason: the SCM toolbar is high-traffic, and an LLM call per accidental tap is unacceptable. The user must click "Suggest" inside the panel to spend tokens. The other entry points (status bar item, command palette "Suggest Commit Message", view-title button inside the panel itself) keep using gitCommitSuggestion.suggest, which focuses the panel and immediately runs the suggestion flow through the webview source path.pickSuggestion, source === "command" branch in runSuggest) is currently unused — every entry point now drives the webview — but the function is kept around in case a future entry point wants a non-panel UX.writeToScmInput succeeds, applySuggestion executes workbench.view.scm so the user lands back on Source Control and can see the populated input box. Without this, "Use this" silently writes the message and leaves the user still looking at the Commit Suggestion panel, which makes the write feel like a no-op.repository.state.onDidChange event fires for many reasons (index changed, worktree changed, branch changed, HEAD changed). To detect a commit specifically, snapshot repository.state.HEAD?.commit at attach time and compare on every event — only fire downstream side effects when the SHA actually changes. This catches new commits, amends, resets, and checkouts; it does NOT mistake an index/worktree poke for a commit. See watchCommits() in src/ui/scm-writer.ts. The watcher also subscribes to api.onDidOpenRepository so repos opened after activation are attached too.suggestionsRef.current and calls view.clearSuggestions(). The clear is guarded by a length check — if the panel was already empty (idle or error state), we skip the repost to avoid clobbering an existing error banner with a fresh idle state.state.status === "loading": render() swaps its label (suggestBtn) and a closure-scoped currentStatus drives the click handler — loading sends { type: "stop" }, anything else sends { type: "suggest" }. The button has no data-i18n attribute precisely because its label is computed per-render; applyI18n() would otherwise overwrite the dynamic "Stop" label on every settings sync.GenerateArgs.signal?: AbortSignal in providers/base.ts is forwarded to every provider's fetch(). Each provider's network-error catch re-throws AbortError as-is rather than wrapping it in ProviderError — otherwise the orchestrator would treat cancellation as a generic network failure and burn the next auto-chain leg. The orchestrator also short-circuits its auto-chain loop on signal?.aborted for the same reason.extension.ts holds inFlightRef: { current?: AbortController }. runSuggest() aborts any pre-existing controller before installing its own (double-click protection), and clears the ref in finally only if inFlightRef.current === controller — a newer run may have already replaced it. The onStop callback is fire-and-forget inFlightRef.current?.abort(); runSuggest's catch sees controller.signal.aborted and calls view.setIdle() silently (no error toast — cancellation is not an error).Layout mirrors index-crawl:
config/*.yml is the source of truth for anything declarative (endpoints, models, commit types, prompts). Code reads YAML at activation; do not hardcode.src/models/ holds zod schemas; everything that crosses a boundary (LLM → us, settings → us, YAML → us) is validated.src/providers/ is the "sources" layer (one class per backend). The orchestrator never sees provider-specific HTTP details.src/pipeline/ is the orchestration layer — pure functions where possible.docs/*.html instead of *.md on purpose: matches index-crawl's convention so AI agents reading both repos have the same expectation.