AgentCanvas / Pages / Developer Guide / Design Docs / Operations / LLM/VLM Configuration System
2026-06-17
Partially superseded (2026-07-03). The provider/credential/profile surface was redesigned: API keys now resolve keys file (~/.agentcanvas/.keys) β†’ env var, provider endpoints moved to /api/providers, sampling params became two-state with a parameter rulebook, and nodes can pin (provider, model) directly. The current behaviour is documented in LLM Call Node; this page's registry/CLI/protocol sections remain accurate.

The LLM/VLM configuration system lets any canvas node call any of 24 providers β€” OpenAI, Anthropic, Google, Ollama, and 20+ more β€” through one litellm-backed async API. A profile names a provider + model; API keys never live in the profile, they're read from the provider's standard env var at call time. Each node can pick its own profile, so a single graph can reason with GPT-4o, caption with Claude, and run cheap inference on local Ollama. Designed in ADR-platform-003, hardened in ADR-platform-006.

How a node gets its model β€” the whole call path. (Β§3 zooms into the resolve config step; Β§12 into the usage hook.)

LLMCallNode .forward() builtin_nodes.py the caller get_llm_config call.py ProfileStore.get ← profiles.json (no keys) resolve_provider_config ← PROVIDER_REGISTRY defaults get_provider_api_key ← os.environ[env_var] LLMConfig keyΒ·modelΒ·base_urlΒ·type get_llm_config(profile) llm_complete / vlm_complete Β· call.py litellm.acompletion β†’ provider HTTP resolved config per-node log (_fire_node) usage: _accumulate_usage β†’ _current_node_usage fallback: no profile / unset key β†’ get_llm_config()=None β†’ mock reply, litellm skipped

1. What it is, and the one rule

Canvas nodes β€” chiefly LLMCallNode β€” need to reach an LLM/VLM without hard-coding a provider SDK. This system is that indirection: litellm is the single abstraction layer (no per-provider SDKs; httpx appears only in key_validator.py for the Ollama health check). Four design choices shape it:

2. The smallest setup

Two steps put a live model behind every llmCall node β€” set the provider's env var, then create and activate a profile:

# 1. the key lives in the environment, never in a file
export OPENAI_API_KEY=sk-...

# 2. create a profile (provider + model) and make it the active default
python -m agentcanvas.backend.app config set openai --provider openai --model gpt-4o
python -m agentcanvas.backend.app config activate openai

Now an llmCall node with a blank profile field resolves to the active openai profile; a node that sets profile = "anthropic" uses that one instead (Β§9). No key ever touches profiles.json.

3. The resolution chain at a glance

Every LLM call resolves its config in two stages: (1) load the named (or active) profile from profiles.json, (2) read the API key from the provider's standard env var. ADR-platform-006 removed the previous AGENTCANVAS_* / VLM_* global env-var fallback chain β€” keys are now per provider, never on disk.

get_llm_config(name) β‘  ProfileStore.get named, else the active profile β†’ LLMProfile β‘‘ get_provider_api_key os.environ[ProviderDef.env_var] empty β†’ mock-mode fall-through result: LLMConfig(key, base_url, model, api_type) β€” or None (mock) when the key is empty and the provider isn't Ollama

The merge of profile fields + registry defaults happens inside resolve_provider_config() in providers.py: empty base_url / api_type / model on the profile inherit from PROVIDER_REGISTRY[provider]. litellm_prefix always comes from the registry (not user-overridable). If api_key ends up empty and the provider is not Ollama, get_llm_config() returns None (mock mode).

Special cases:

3.1 Resolution Code Path

Inside a node's forward(), one call resolves everything β€” get_llm_config(profile_name) (call.py Β· get_llm_config):

  1. ProfileStore.get(name) β€” disk read with an mtime cache (TTL 2 s).
  2. resolve_provider_config(profile) β€” merge profile + registry defaults, calling get_provider_api_key(provider) = os.environ[ProviderDef.env_var].
  3. If api_key == "" and api_type != "ollama" β†’ return None (mock mode).
  4. Otherwise β†’ LLMConfig(api_key, base_url, model, api_type, litellm_prefix).

Source: call.py Β· get_llm_config, providers.py Β· get_provider_api_key, providers.py Β· resolve_provider_config.

4. Provider Registry

providers.py defines 24 pre-configured providers. Each entry supplies default base_url, api_type, and default_model, so users only need to provide an API key.

4.1 Supported Providers

Protocol Providers API Type
OpenAI-compatible OpenAI, OpenRouter, Together AI, Moonshot, Mistral, xAI (Grok), NVIDIA NIM, Hugging Face, DeepSeek, Baidu Qianfan, Alibaba ModelStudio, Volcengine (ByteDance), BytePlus, Venice AI, MiniMax, vLLM, SGLang openai
Anthropic Anthropic, Kimi Coding, Xiaomi MiMo, Synthetic anthropic
Google Google Gemini google
Ollama Ollama (local) ollama
Custom User-defined (any base URL + API type) configurable

Total: 24 providers (count confirmed in PROVIDER_REGISTRY in app/llm/providers.py).

4.2 Registry Entry Structure

@dataclass(frozen=True)
class ProviderDef:
    label: str            # Human-readable name (e.g. "OpenAI")
    base_url: str         # Default API endpoint
    api_type: str         # Protocol: "openai" | "anthropic" | "google" | "ollama"
    default_model: str    # Suggested model (e.g. "gpt-4o")
    litellm_prefix: str   # litellm model prefix (e.g. "openai", "gemini", "anthropic")
    env_var: str          # Standard env-var name for the API key (e.g. "OPENAI_API_KEY")
                          # Empty ("") for Ollama β€” local server, no key needed

When creating a profile with provider="openai", the registry fills in https://api.openai.com/v1, openai, and gpt-4o automatically. The profile can override base_url / api_type / model; litellm_prefix and env_var always come from the registry. The runtime reads os.environ[env_var] at call time (ADR-platform-006) β€” there is no on-disk slot for the key.

Source: app/llm/providers.py

5. Profile Store

Profiles are persisted in agentcanvas/backend/profiles.json (checked in alongside requirements.txt; override location via AGENTCANVAS_PROFILES_FILE=<absolute-path>). The ProfileStore class manages CRUD with thread-safe locking and mtime-based cache invalidation. The file is git-safe by construction (no secrets) β€” see ADR-platform-006.

5.1 File Format

{
  "schema_version": 1,
  "active": "openai",
  "profiles": {
    "openai": {
      "provider": "openai",
      "model": "gpt-4o",
      "base_url": "",
      "api_type": ""
    },
    "anthropic": {
      "provider": "anthropic",
      "model": "claude-sonnet-4-6",
      "base_url": "",
      "api_type": ""
    }
  }
}

5.2 Cache Invalidation

ProfileStore avoids re-reading the file on every request:

  1. In-memory cache of ProfileData
  2. mtime check gated to at most once every 2 seconds (_mtime_check_interval)
  3. If file mtime changed, cache is invalidated and re-read on next access

This means CLI changes or manual file edits are picked up by the running server within ~2 seconds, with no restart required.

5.3 Profile Data Model

@dataclass
class LLMProfile:
    """Non-secret per-profile configuration. API keys are NEVER stored here."""
    provider: str      # Key into PROVIDER_REGISTRY (e.g. "openai")
    model: str         # Model name (e.g. "gpt-4o")
    base_url: str = "" # "" = use registry default
    api_type: str = "" # "" = use registry default

LLMProfile is the on-disk shape (no api_key field β€” ADR-platform-006). At call time, resolve_provider_config() merges it with registry defaults, looks up the env-var key via get_provider_api_key(provider), and resolves litellm_prefix, producing an LLMConfig:

@dataclass
class LLMConfig:
    api_key: str
    base_url: str
    model: str
    api_type: str         # "openai" | "anthropic" | "google" | "ollama"
    litellm_prefix: str   # litellm model prefix (e.g. "openai", "gemini", "ollama_chat")

Source: app/llm/profiles.py, app/llm/call.py

6. Environment Variables

API keys are the only piece of config that comes from env vars β€” and they are per provider, named after each vendor's de-facto SDK convention. Profile metadata (provider / model / base_url / api_type) lives in profiles.json; there is no global AGENTCANVAS_* or VLM_* env-var fallback for those fields any more (removed in ADR-platform-006).

6.1 Per-Provider Env Vars

Each entry in PROVIDER_REGISTRY declares an env_var field β€” the env var the runtime reads for that provider's key. Common ones:

Provider Env var
OpenAIOPENAI_API_KEY
Anthropic / Kimi Coding / Xiaomi MiMo / SyntheticANTHROPIC_API_KEY / MOONSHOT_API_KEY / XIAOMI_API_KEY / SYNTHETIC_API_KEY
Google GeminiGEMINI_API_KEY
DeepSeekDEEPSEEK_API_KEY
OpenRouterOPENROUTER_API_KEY
Together AITOGETHERAI_API_KEY
Moonshot AIMOONSHOT_API_KEY
Mistral AIMISTRAL_API_KEY
xAI (Grok)XAI_API_KEY
NVIDIA NIMNVIDIA_NIM_API_KEY
Hugging FaceHF_TOKEN
Baidu Qianfan / Alibaba ModelStudio / Volcengine / BytePlus / Venice / MiniMaxQIANFAN_API_KEY / DASHSCOPE_API_KEY / ARK_API_KEY / BYTEPLUS_API_KEY / VENICE_API_KEY / MINIMAX_API_KEY
vLLM / SGLang (self-hosted)VLLM_API_KEY / SGLANG_API_KEY
Ollama (local server)β€” (no key needed; env_var="")
CustomAGENTCANVAS_API_KEY (the only catch-all)

Full mapping in providers.py Β· PROVIDER_REGISTRY. To see what's set on a given machine: python -m agentcanvas.backend.app config env.

6.2 Configuring a Provider

One-liner for a typical setup:

# 1. Create the profile (no key, just metadata)
python -m agentcanvas.backend.app config set openai --provider openai --model gpt-4o

# 2. Export the key in your shell (e.g. ~/.bashrc)
export OPENAI_API_KEY=sk-...

# 3. Activate the profile
python -m agentcanvas.backend.app config activate openai

Note: agentcanvas/backend/.env.example in the repo still shows the legacy AGENTCANVAS_API_KEY/MODEL/BASE_URL/API_TYPE and VLM_* envs. Those credential/model names are dead β€” never read by call.py β€” so use the per-provider names above instead. (Two unrelated AGENTCANVAS_* vars are still live and are not credentials: AGENTCANVAS_STRICT_ERRORS (Β§11.5) and AGENTCANVAS_PROFILES_FILE (profile-store path override). File is queued for cleanup.)

6.3 Mock Mode

get_llm_config() returns None whenever it can't resolve a usable config: no active profile, profile name unknown, or the provider's env var is unset (Ollama excepted). Calling nodes treat None as a mock β€” they emit a placeholder string and continue, so a graph won't crash for missing keys; it will just produce mock outputs visibly in the canvas.

7. CLI Tool

A standalone CLI for profile management that works without the FastAPI server running. Changes are picked up by the running server via mtime invalidation.

7.1 Commands

8 commands total. All accept --json for machine-readable output; show and env also accept --show-keys to unmask values pulled from env vars.

# Entry point (two forms, both work)
python -m agentcanvas.backend.app.cli config <command>
python -m agentcanvas.backend.app config <command>

# List all profiles
config list [--json]

# Show a single profile (resolved against env)
config show <name> [--show-keys] [--json]

# Create or update a profile (metadata only β€” no --api-key flag)
config set <name> [--provider <id>] [--model <model>] [--base-url <url>] [--api-type <type>]
# On create, prints: "Set the API key via env: export OPENAI_API_KEY=<your-key>"

# Delete a profile
config delete <name>

# Set the active (default) profile; omit name to clear
config activate [<name>]

# Test API key connectivity (reads key from env var, calls litellm validate)
config test [<name>]

# List all 24 supported providers (with their env_var names)
config providers [--json]

# Show which provider env vars are set on this machine + active profile
config env [--show-keys] [--json]

API-key entry is intentionally not a CLI step (no --api-key flag, no getpass prompt). The key is read from the provider's env var (OPENAI_API_KEY, …) at call time β€” set it in your shell profile or via your secret manager, then run any command above. ADR-platform-006 keeps the CLI write path entirely free of secrets.

7.2 Example Workflow

# 1. Export keys in your shell (~/.bashrc / ~/.zshrc / .envrc)
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...

# 2. Create profile metadata (one per provider/model pair you want to pick from)
python -m agentcanvas.backend.app config set openai --provider openai --model gpt-4o
python -m agentcanvas.backend.app config set anthropic --provider anthropic --model claude-sonnet-4-6
python -m agentcanvas.backend.app config set ollama --provider ollama --model llama3.2

# 3. Activate OpenAI as default for nodes that don't specify a profile
python -m agentcanvas.backend.app config activate openai

# 4. Verify the key actually works (round-trips to the provider)
python -m agentcanvas.backend.app config test

# 5. Sanity check which env vars are picked up
python -m agentcanvas.backend.app config env

Source: app/llm/cli.py

8. REST API

Profile management is exposed via FastAPI endpoints under /api/profiles.

8.1 Endpoints

Method Path Description
GET /api/profiles/ List all profiles + active + provider registry
POST /api/profiles/ Create a new profile
PUT /api/profiles/{name} Update an existing profile
DELETE /api/profiles/{name} Delete a profile
POST /api/profiles/activate Set the active profile
PUT /api/profiles/batch Batch upsert (used by frontend Settings Modal)
GET /api/profiles/{name}/models Fetch the provider's available models (calls the provider API)

8.2 Batch Upsert

The frontend sends a single PUT /api/profiles/batch call with all changes:

{
  "keys": { "openai": "sk-...", "anthropic": "sk-ant-..." },
  "active": "openai",
  "active_model": "gpt-4o",
  "overrides": {
    "ollama": { "base_url": "http://localhost:11434", "model": "llama3.2" },
    "custom": { "base_url": "https://my-api.com/v1", "model": "my-model", "api_type": "openai" }
  }
}

keys is accepted but ignored (kept in the request shape only for backward compat with older frontends β€” see BatchUpsertBody docstring in api/profiles.py Β· BatchUpsertBody). Real API keys live in env vars. The endpoint does create empty profile metadata for each provider listed in keys so the UI's per-provider config slot exists, applies overrides to base_url/model/api_type, and sets the active profile β€” all atomically.

8.3 Security

Source: app/api/platform/profiles.py

9. Per-Node Model Selection

Individual canvas nodes can override the default profile. LLMCallNode is the only node that carries a profile config field (builtin_nodes.py Β· LLMCallNode):

ConfigField("profile", "select", "Model", default="",
            options=[{"value": "__DYNAMIC_PROFILES__", "label": ""}])

The __DYNAMIC_PROFILES__ sentinel in options is the load-bearing part: the frontend swaps it for the live list of configured profiles when it renders the dropdown. There is no static option list and no description kwarg.

At execution time:

profile_name = self.config.get("profile", "")
llm_config = get_llm_config(profile_name)
# "" β†’ active profile β†’ env fallback β†’ None (mock)
# "anthropic" β†’ that specific profile

This enables mixed-model graphs: one node reasons with GPT-4o, another captions with Claude, a third does cheap inference with local Ollama.

The frontend renders this as a dropdown in the node's config panel. The dropdown is populated from the list of configured profiles (providers with saved keys + Ollama). A profiles-changed window event refreshes the dropdown when settings are saved.

Source: app/agent_loop/builtin_nodes.py (search for profile ConfigField). The legacy app/agent_loop/graph_executor.py referenced in older docs has been removed; node handlers live in builtin_nodes.py and the executor in graph_executor.py.

10. Frontend Settings Modal

SettingsModal.tsx provides the GUI configuration surface.

10.1 Layout

Settings [Γ—] Configured Models β˜… marks the default Β· each row has a per-model override field API Keys 5 top providers (OpenAI ●●●● saved …) Β· β–Έ 19 more + Custom Provider: key Β· base-url Β· model Β· api-type App Settings Max Steps Β· SLAM mode Cancel πŸ’Ύ Save

10.2 Behavior

Source: agentcanvas/frontend/src/components/SettingsModal.tsx

11. API Call Protocols

app/llm/call.py uses litellm.acompletion (no individual provider SDKs). Both text-only (llm_complete) and multimodal (vlm_complete) variants are supported, each with a multi-sample *_n counterpart (llm_complete_n / vlm_complete_n) for the OpenAI n parameter. litellm translates the unified OpenAI-format message structure to provider-native wire formats internally.

11.1 Protocol Dispatch

api_type (from LLMConfig) selects the litellm model prefix via API_TYPE_TO_LITELLM_PREFIX:

api_type litellm prefix Examples
openai openai openai/gpt-4o, openai/gpt-4.1
anthropic anthropic anthropic/claude-sonnet-4-6
google gemini gemini/gemini-2.5-flash
ollama ollama_chat ollama_chat/llama3.2

The litellm model string is built as {prefix}/{model} and passed to litellm.acompletion(). litellm handles auth headers, endpoint routing, and protocol translation.

11.2 Image Handling (vlm_complete)

vlm_complete takes a list of base64-encoded PNG/JPEG strings and builds an OpenAI-format multimodal content array (image_url blocks). litellm translates these to provider-native formats (Anthropic source.type:base64, Google inline_data, Ollama images array) automatically.

An optional image_labels list can interleave per-image text captions before each image (used by MapGPT's gpt_infer interleave shape).

An optional detail argument ("low" / "high" / "auto", default "low") is forwarded into each image_url block as OpenAI's resolution hint: "low" caps every image at a single 512px tile (token-cheap), "high" enables multi-tile high-resolution analysis for fine spatial grounding. vlm_complete_n threads the same hint to every candidate. The builtin llmCall node surfaces this as a second config field β€” image_detail (select: low / high / auto, default low, builtin_nodes.py Β· LLMCallNode) β€” so a node whose vision task needs fine grounding (e.g. a viewpoint-picking navigator) can be set to high without touching code.

If images is empty, vlm_complete falls back to llm_complete (text-only path).

Multi-sampling. vlm_complete_n(..., n) returns up to n candidate completions β€” the vision counterpart to llm_complete_n, used for self-consistency / multi-candidate planning. It first attempts provider-native n (the image prompt is then billed once for all samples); providers that ignore n for vision requests β€” notably Anthropic β€” collapse the response to a single choice, which is detected and the shortfall is filled with concurrent single-sample vlm_complete calls. The builtin llmCall node routes its image path through vlm_complete_n whenever config.n > 1 and exposes the candidates on the responses port.

11.3 Timeouts

Call type Timeout
Text-only (non-Ollama) 60s
Text-only (Ollama) 120s
Multimodal (non-Ollama) 90s
Multimodal (Ollama) 120s

11.4 Public API

from app.llm import get_llm_config, llm_complete, vlm_complete

config = get_llm_config("openai")  # or "" for active profile

# Text-only
response = await llm_complete(config, messages, system_prompt="...", max_tokens=1024)

# Multimodal (text + list of base64 images, optional per-image labels)
response = await vlm_complete(
    config,
    prompt="Describe these images",
    images=[base64_png_1, base64_png_2],
    image_labels=["Front view", "Side view"],  # optional
)

Temperature defaults are asymmetric and unstated at the call site. llm_complete / llm_complete_n default temperature=0.7; vlm_complete / vlm_complete_n default temperature=0.3 (vision is run cooler). max_tokens has no default β€” pass it explicitly. Both are forwarded verbatim to litellm.acompletion with no per-model adjustment (see Β§11.5).

Source: app/llm/call.py

11.5 Strict errors & the gpt-5 sharp edge

By default every helper degrades gracefully: a litellm exception, timeout, or unparseable response makes llm_complete return None and the *_n variants return [], so a misconfigured node renders a placeholder rather than crashing the run. Setting the env var AGENTCANVAS_STRICT_ERRORS (to 1/true/yes/on, read fresh on every call via call.py Β· _strict_errors_enabled β€” deliberately uncached, so env-worker subprocesses inherit the backend's launch-time setting) flips this: every helper re-raises instead. Eval and smoke runs set it so a silent empty response can't masquerade as a completed episode.

Sharp edge β€” no gpt-5 / reasoning-model special-casing exists. call.py sends temperature unconditionally (default 0.7). OpenAI reasoning models (the gpt-5 family) reject any temperature other than 1.0 and return an empty / errored response. The shipped profiles.json currently ships active: "gpt-5-nano" β€” so out of the box, a default llmCall hits this and silently produces nothing (or raises under strict errors). Until the code grows a reasoning-model branch, the workaround is to either point the active profile at a non-reasoning model (gpt-4o, gpt-4.1) or override the model on the node. Tracked alongside memory project_gpt5_llmcall_params.

12. Per-Node Usage Hook (ADR-observability-005)

Token usage and dollar cost are tracked per node firing, not per nodeset and not globally. Nodesets never need to plumb usage manually β€” the wiring is one ContextVar in call.py and one set/reset pair around forward() in the executor.

# call.py Β· _current_node_usage
_current_node_usage: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
    "_current_node_usage", default=None
)

# Every llm_complete / llm_complete_n / vlm_complete call inside a forward()
# adds its response usage into this bucket via _accumulate_usage():
#   bucket["calls"]              += 1
#   bucket["prompt_tokens"]      += response.usage.prompt_tokens
#   bucket["completion_tokens"]  += response.usage.completion_tokens
#   bucket["total_tokens"]       += response.usage.total_tokens
#   bucket["cached_tokens"]      += response.usage.prompt_tokens_details.cached_tokens
#   bucket["usd_cost"]           += litellm.completion_cost(response)
#   bucket["model"]               = first non-empty response.model
# graph_executor.py Β· _fire_node
usage_bucket: dict = {}
usage_token = _current_node_usage.set(usage_bucket)
try:
    result = await node.forward(inputs, ctx)
finally:
    _current_node_usage.reset(usage_token)
# bucket now holds aggregate usage for this firing; executor emits one
# log entry per node with the bucket attached.

Properties of this design:

Source: call.py Β· _current_node_usage + call.py Β· _accumulate_usage (bucket + accumulator), graph_executor.py Β· _fire_node (set/reset around forward()), call.py Β· _extract_usage (response β†’ usage dict).

13. Key Files

File Purpose
app/llm/call.py LLMConfig, get_llm_config(), llm_complete()/vlm_complete() + _n multi-sample variants, _current_node_usage ContextVar, litellm dispatch
app/llm/providers.py PROVIDER_REGISTRY (24 providers, each with an env_var), ProviderDef, get_provider_api_key(), resolve_provider_config()
app/llm/profiles.py ProfileStore, LLMProfile (no api_key field), ProfileData, mtime cache; legacy api_key entries dropped on load
app/llm/cli.py Standalone CLI (8 commands, no server required); no --api-key flag (ADR-platform-006)
app/llm/key_validator.py API key validation for config test (litellm + Ollama health check)
app/llm/__init__.py Re-exports: get_llm_config, llm_complete, llm_complete_n, vlm_complete, vlm_complete_n, LLMConfig, LLMProfile, PROVIDER_REGISTRY, resolve_provider_config, get_profile_store
app/api/platform/profiles.py REST endpoints (/api/profiles/); BatchUpsertBody.keys accepted-but-ignored
app/agent_loop/graph_executor.py Per-node usage bucket set/reset around forward() (_fire_node); emits one usage log entry per node firing (ADR-observability-005)
frontend/src/components/SettingsModal.tsx GUI settings modal β€” calls PUT /api/profiles/batch; keys field in payload is silently ignored server-side
agentcanvas/backend/profiles.json Default profile store location (git-safe; override via AGENTCANVAS_PROFILES_FILE)
agentcanvas/backend/.env.example Env template β€” the AGENTCANVAS_* credential / VLM_* envs it lists are dead; use per-provider env vars instead. (AGENTCANVAS_STRICT_ERRORS and AGENTCANVAS_PROFILES_FILE remain live, non-credential.)
AgentCanvas docs