Configuration

Single source of truth for every valem.* property. Other docs link here rather than listing partial, divergent subsets.


  1. Core runtime
    1. Core safety limits
    2. Sizing the expression cache (and why it is a memory setting)
    3. Sizing the expression cache (and why it is a memory setting)
  2. Security / auth
  3. Effects (egress + pluggable kinds)
  4. Model composition & references
  5. Persistence (model spec + state)
  6. Blob storage
  7. Observability (Actuator + Micrometer)
  8. LLM integration
  9. Environment-variable equivalents

Core runtime

Property Default Description
valem.mutation-queue-size 10 Max concurrent mutations per model (executing + waiting); excess returns HTTP 429.
valem.max-models 1000 Max number of models the registry will hold; excess POST /models returns HTTP 429.
valem.history.max-entries 50 Retained per-model temporal-history snapshots — the source for GET /models/{id}/history and point-in-time ?at= reads. 0 disables temporal history. JVM system property (see below), not a Spring property.

Core safety limits

These bound resource use inside valem-core. Set them in application.yml like any other valem.* property — CoreLimitsEnvironmentPostProcessor publishes the resolved values as JVM system properties during environment preparation, before any bean (and so before any core class) is loaded, because the core module has no Spring and reads them with Integer.getInteger in a static initialiser. They apply process-wide.

Three ways to set one, in precedence order:

  1. An explicit JVM flag — -Dvalem.limits.max-array-index=2000000. Left untouched by the bridge.
  2. The environment variable Boot’s relaxed binding maps to the property — VALEM_LIMITS_EXPRESSION_CACHE_SIZE, VALEM_LIMITS_MAX_ARRAY_INDEX, and so on.
  3. application.yml, which is where the shipped defaults live.

A malformed value is ignored rather than thrown (this is read in a static initialiser — throwing would fail every model in the process), so a bad entry degrades to the built-in default.

In a Spring-less embedding — the MCP server over stdio, the console — only options 1 and 2 apply; valem.limits.expression-cache-size reads VALEM_LIMITS_EXPRESSION_CACHE_SIZE directly for exactly this reason, and the remaining limits are -D-only there.

Property Default Description
valem.limits.max-array-index 1000000 Hard ceiling on the array index a single write may target, capping the null-padding one mutation can force. A write beyond it is rejected with a typed StateLimitExceededException → HTTP 422, before any allocation. Covers live mutate, defaults, and mutation-log replay.
valem.limits.regex-max-input 100000 Max input-string length a schema pattern keyword will validate; longer values are rejected up front rather than fed to the regex engine (ReDoS amplification guard).
valem.limits.regex-timeout-ms 1000 Wall-clock budget for a single pattern match; a catastrophic-backtracking match is aborted past the deadline instead of pinning a CPU under the model lock.
valem.limits.expression-timeout-ms 2000 Wall-clock budget for one expression evaluation; 0 disables. Derivations, constraints and effect triggers evaluate inside the model lock, so a non-terminating expression holds the lock rather than merely failing a request — where Loom gives parallelism 1 that can wedge the service. Exceeding it fails that one field (null value + an error trace), exactly like any other evaluation error. Real expressions run in single-digit milliseconds, but the deadline is wall-clock and keeps advancing while a thread is descheduled, so a pathologically loaded host can time out a normally-fast expression — raise it, or set 0, if that happens. Resolved per compilation, so a change takes effect for expressions compiled afterwards without a restart. Also reads VALEM_LIMITS_EXPRESSION_TIMEOUT_MS.
valem.limits.library-cache-size 64 Max compiled-library entries (bounded LRU, shared process-wide, content-addressed on definition + constants + signatures). Values below 4 are floored. Also reads VALEM_LIMITS_LIBRARY_CACHE_SIZE.
valem.limits.library-max-chars 20000 Max characters in one library definition expression.
valem.limits.library-max-exports 64 Above this, a library warns that it is doing too much.
valem.limits.library-max-layers 8 Max library layers (own + inherited) on one model.
valem.limits.expression-cache-size 500 Max compiled-JSONata-expression entries (bounded LRU, shared process-wide). Values below 64 are floored. Eviction is safe: an evicted expression recompiles on next use. This bound decides the process’s Metaspace footprint — see the note below.
valem.history.max-entries 50 (Listed above.) Retained temporal-history snapshots per model. Not bridged — this one is still -D-only, in every deployment.

Sizing the expression cache (and why it is a memory setting)

Each cached expression pins a generated Java class and its classloader in Metaspace — the JSONata engine compiles every distinct expression to real bytecode. Metaspace is native memory: a container heap cap such as -XX:MaxRAMPercentage does not cover it, and it is unbounded by default.

That combination has a specific failure mode worth recognising. A long-lived server whose callers supply a stream of novel expressions — an agent authoring specs over MCP is the clearest case — keeps minting classes, so the live set climbs toward this bound and native memory grows with it. On a memory-constrained host the platform then OOM-kills the container, which presents as an unexplained restart (exit code 137) with no Java OutOfMemoryError in the logs, because the heap was never the constraint. The visible symptoms are unrelated: client-abort stack traces from connections dropping as the process dies.

Two mitigations, both worth applying together on a hosted deployment:

  • Keep this bound modest (the default 500 suits a small instance). Raise it where memory is ample — a larger cache trades memory for CPU, since an evicted expression is recompiled on next use.
  • Run with an explicit -XX:MaxMetaspaceSize. This converts a silent container kill into a visible, catchable JVM error and prompts class unloading at the high-water mark rather than letting the footprint ratchet upward.

Sizing the expression cache (and why it is a memory setting)

Each cached expression pins a generated Java class and its classloader in Metaspace — the JSONata engine compiles every distinct expression to real bytecode. Metaspace is native memory: a container heap cap such as -XX:MaxRAMPercentage does not cover it, and it is unbounded by default.

That combination has a specific failure mode worth recognising. A long-lived server whose callers supply a stream of novel expressions — an agent authoring specs over MCP is the clearest case — keeps minting classes, so the live set climbs toward this bound and native memory grows with it. On a memory-constrained host the platform then OOM-kills the container, which presents as an unexplained restart (exit code 137) with no Java OutOfMemoryError in the logs, because the heap was never the constraint. The visible symptoms are unrelated: client-abort stack traces from connections dropping as the process dies.

Two mitigations, both worth applying together on a hosted deployment:

  • Keep this bound modest (the default 250 suits a small instance). Raise it where memory is ample — a larger cache trades memory for CPU, since an evicted expression is recompiled on next use.
  • Run with an explicit -XX:MaxMetaspaceSize. This converts a silent container kill into a visible, catchable JVM error and prompts class unloading at the high-water mark rather than letting the footprint ratchet upward.

Security / auth

Property Default Description
valem.api.key (unset) When set, every request must carry Authorization: Bearer <key>; blank = open/dev mode (all requests permitted, warning logged). Compared constant-time. The same key authenticates the WebSocket handshake via ?token=<key>. See security-model.md.
valem.security.csp default-src 'none'; frame-ancestors 'none' The Content-Security-Policy response header directives. The default is correct for valem-api used headless but blocks a bundled UI’s own same-origin script/stylesheet/WebSocket loads — a deployable that serves a browser UI (e.g. valem-web) overrides it, e.g. default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'.
valem.websocket.allowed-origins (unset = same-origin) Comma-separated allowlist of origins permitted to open the /models/{id}/subscribe WebSocket handshake. Unset = same-origin only; set * only for development.
valem.mcp.allowed-origins (unset = open) Comma-separated allowlist of browser Origins permitted to call the Streamable-HTTP MCP endpoint (/mcp), for DNS-rebinding protection. Empty = open (requests with no Origin — typical non-browser MCP clients — always pass); set it in production to restrict browser origins. See mcp-server.md.
valem.rate-limit.enabled false Enable the optional per-IP sliding-window rate-limit filter. Off = no behaviour change.
valem.rate-limit.requests 100 Requests allowed per window per client IP (when enabled).
valem.rate-limit.window-seconds 60 Sliding-window length in seconds (when enabled). Over-limit requests get HTTP 429 + Retry-After.
valem.rate-limit.trust-forwarded-for false When true, key the rate limiter on the first X-Forwarded-For hop instead of the socket peer. Enable only behind a trusted proxy that sets the header — otherwise a client can spoof it to evade limiting. Default off means a proxied deployment keys on the proxy address until you opt in.

There is no per-field authorization. Any caller past the valem.api.key gate (or any caller in open mode) may read/mutate/evolve every field of every model.

Effects (egress + pluggable kinds)

Property Default Description
valem.effects.allow-private-ips false Relax the built-in server effect’s EgressGuard to permit loopback/private/link-local hosts. Local dev / IT stubs only — does not apply to plugin EffectExecutor kinds, which enforce their own (if any) egress rules. This is an address-only relaxation: it never widens the URL scheme, so cleartext http still needs allow-insecure-http.
valem.effects.allow-insecure-http false Permit cleartext http egress for the built-in server effect. Independent of allow-private-ips; enabling private-IP access alone no longer implies http. A dev pointing at a plain-http loopback stub sets both.
valem.effects.allowed-hosts (blank = any public host) Comma-separated allowlist of destination hostnames the built-in server effect may call. Blank imposes no host restriction beyond the SSRF address checks.
valem.effects.max-response-bytes 1048576 (1 MB) Max response size the built-in server effect will fold back. Not enforced for plugin kinds.
valem.effects.kinds.enabled (unset = all) Comma-separated allowlist of active effect executor kinds — built-in (caller/server/llm/timer) and any EffectKind/EffectExecutor discovered via ServiceLoader (e.g. valem-effects-noop). A spec selecting an unlisted/unknown kind is rejected at validation. Unset/empty = every discovered kind enabled.

See security-model.md for the egress-guard scope caveat on plugin kinds.

Model composition & references

Properties for links between models, branching from templates, and inherited-effect approval.

Property Default Description
valem.composition.repositories (empty; local is implicit) Priority-ordered chain of additional repositories resolved after the implicit in-process local repo. Each entry: id, transport (how it is reachedhttp and mcp wired; filesystem reserved), repo-class (its classlocal or web; orthogonal to transport, inferred per transport when unset: http→web, filesystem→local, mcp→web), locator (base URL for http; a launch command such as java -jar valem-mcp.jar for mcp), optional credential (bearer token for a private http repo), optional trusted. Class (not transport) drives reference-locality and promote targets: only a web-class repo is a valid promote target and satisfies the reference-locality closure. An mcp repo speaks JSON-RPC 2.0 over the launched process’s stdio (the same protocol as valem-mcp).
valem.composition.lazy-binding false Allow a link target.ref whose target model is not yet registered (bind/validate at first fire). Off = a create/evolve with a link to an unknown model is rejected 422 (UnresolvedLinkTarget). Enable for out-of-order or peer (A⇄B) creation.
valem.authz.inherited-effects approve Policy for an effect inherited by branching a different owner’s template (an ownership boundary crossed via lineage): approve (quarantine + require per-effect approval — the default), allow (trust inherited effects), deny (never run inherited cross-owner effects). Same-owner / branch-authored effects always run; caller (pure) effects are never gated. A quarantined effect is inert with statusPath phase blocked / effect_approval_required; approve via POST /models/{id}/effects/{effectId}/approve.

Persistence (model spec + state)

Property Default Description
valem.persistence-dir (unset) Shortcut that enables filesystem persistence for spec + state. Specs → {dir}/{id}/spec.json; baseline snapshot + incremental mutation log under {dir}/{id}/. Models reloaded on startup. Unset (and no storage.type) = in-memory only (lost on restart).
valem.storage.type (unset) Single backend for both spec and state: memory, filesystem, postgres (alias postgresql), mongodb (alias mongo), or redis.
valem.storage.spec-type (falls back to storage.type) Backend for the model spec alone (per-concern override).
valem.storage.state-type (falls back to storage.type) Backend for runtime state alone (per-concern override).
valem.storage.compaction-threshold 100 Mutation-log length that triggers compaction into a new baseline snapshot.
valem.storage.jdbc.pool-size 8 Maximum size of the pooled HikariDataSource synthesized for a DB backend when no DataSource bean is supplied. The pool is lazy (initializationFailTimeout=-1, minimumIdle=0) and closed on shutdown.
valem.storage.audit-type (resolved, see below) Backend for the durable, append-only audit trail: none, memory (retained, non-durable), filesystem ({id}/audit.jsonl), postgres (ss_audit table), or mongodb (ss_audit collection). Unset → follows the state backend when it is filesystem/postgres/mongodb, else none.

Each storage concern — spec, state, blob — selects its backend independently. When spec and state resolve to the same backend a single store instance backs both; when they differ a CompositeModelStore wires the two halves (e.g. spec in Postgres, state in Redis). Resolution order for spec/state: per-concern *-typestorage.typefilesystem if persistence-dir is set → memory. Backend connection settings reuse the standard Spring keys (spring.datasource.*, spring.data.mongodb.*, spring.data.redis.url); if no DataSource bean is present, a pooled HikariDataSource is synthesized from spring.datasource.url/username/password (sized by valem.storage.jdbc.pool-size). Spring Boot’s DataSourceAutoConfiguration / JdbcTemplateAutoConfiguration are excluded so putting HikariCP on the classpath does not force a DataSource bean in memory-only deployments.

À-la-carte adapters (the jar is the enablement). Each backend beyond memory/filesystem is a separate adapter jar — valem-persistence-postgres, -mongo, -redis, -s3 — discovered at runtime through the PersistenceProvider ServiceLoader SPI (each provider owns and shares one client across its spec/state/blob/audit concerns). The default valem-web deployable ships only memory + filesystem, so enabling another backend is add its adapter jar to the classpath + set the valem.storage.* property — no recompile. Selecting a backend whose adapter jar is absent aborts startup with a message naming the concern, the type, and the jar to add (e.g. spec backend 'postgres' … Add the valem-persistence-postgres adapter jar) — there is no silent fallback to another backend.

A failure to reconstruct a model’s state on startup (corrupt snapshot or mutation log) loads the model spec-only (state reset; derived fields recompute on first access) rather than dropping the whole model.

Durable audit trail. Independently of spec/state, an AuditStore retains one append-only AuditRecord per committed mutation cycle — the what/when/why/what-it-triggered (mutations, re-evaluated derivations, derivation/constraint traces, flagged constraints, dispatched effects, and the source: client | patch | foldback). This is the durable, queryable superset of the bounded in-memory explain ring buffer, queried via GET /models/{id}/audit. It is never compacted (unlike the state mutation log). none disables retention (the default when state is ephemeral); filesystem writes {persistence-dir}/{id}/audit.jsonl and survives restarts; postgres/mongodb store one hash-chained row/document per cycle in ss_audit (apply the Postgres DDL in db/migration/V1__init.sql). The trail is tamper-evident — every record is SHA-256 hash-chained to its predecessor, and GET /models/{id}/audit/verify reports the first altered/reordered/deleted record.

Blob storage

Property Default Description
valem.storage.blob-type (resolved, see below) Explicit blob backend: memory, filesystem, postgres, mongodb, or s3.
valem.blob-store memory Legacy blob selector: memory (in-process ConcurrentHashMap, SHA-256 keyed, lost on restart) or filesystem.
valem.blob-store-path ~/.valem/blobs Directory for filesystem blob store.
valem.storage.blob (unset) Set to s3 to use the S3 blob backend (with valem.storage.s3.bucket/region/endpoint/access-key/secret-key).
valem.blob.max-bytes 52428800 (50 MB) Per-blob upload cap enforced by BlobController; oversized upload → HTTP 413.
valem.blob.max-total-bytes 536870912 (512 MB) Total-bytes ceiling for the in-memory blob store; over-budget upload is rejected with HTTP 413 (no eviction). A non-positive value opts into unbounded (a startup warning is logged).
spring.servlet.multipart.max-file-size 50MB Servlet multipart per-part cap (set explicitly, not left to defaults).
spring.servlet.multipart.max-request-size 55MB Servlet multipart whole-request cap.

Blob backend resolution order: storage.blob-types3 if storage.blob=s3 → legacy blob-store (memory/filesystem) → the DB backend if storage.type is postgres/mongodbmemory. (redis has no blob backend; a redis storage.type leaves blobs on memory.)

Observability (Actuator + Micrometer)

Spring Boot Actuator is enabled with a Micrometer meter registry and a Prometheus scrape endpoint.

Endpoint Auth Purpose
GET /actuator/health open (bypasses the API key) Liveness/readiness for orchestrators
GET /actuator/info open Build/app info
GET /actuator/metrics[/{name}] API key when configured Micrometer meters
GET /actuator/prometheus API key when configured Prometheus scrape (OpenMetrics)

Valem-specific meters:

Meter Type Tags Meaning
valem.mutation.duration timer outcome = success/flagged/rollback/schema_violation/error Per-mutation latency + count by outcome
valem.effects.dispatched counter Effect requests emitted by committed mutations
valem.audit.records counter source = client/patch/foldback Durable audit records appended
valem.effect.duration timer kind = server/llm/timer, outcome = success/failure Effect-executor latency + success/failure breakdown
valem.models.created counter Models created via POST /models
valem.models.registered gauge Models currently registered

Exposure is controlled by the standard management.endpoints.web.exposure.include (default health,info,metrics,prometheus). Only health/info are reachable without the API key; the rest sit behind the same gate as the model API.

LLM integration

Property Default Description
valem.llm.provider anthropic One of anthropic, openai, ollama, openrouter, groq, mistral, gemini, cerebras.
valem.llm.api-key (unset) API key for the provider; not required for ollama.
valem.llm.model (provider-appropriate default) Model name sent to the provider. When unset, a sensible default is chosen for the provider: anthropicclaude-sonnet-4-6, openaigpt-4o, mistralmistral-large-latest, groqopenai/gpt-oss-120b, geminigemini-2.5-flash, cerebrasllama-3.3-70b, ollamallama3.1, openrouternvidia/nemotron-3-super-120b-a12b:free. Override per deployment.
valem.llm.max-tokens 8192 Max tokens for LLM responses. On a truncated response the first retry transiently raises this to min(2 × max-tokens, max-tokens-hard) on the same prompt (keeping the work) before falling back to the “smaller spec” prompt on a second truncation.
valem.llm.max-tokens-hard 16384 Ceiling for the adaptive truncation retry above.
valem.llm.reasoning-effort (unset — field omitted) reasoning_effort sent to an OpenAI-compatible provider, verbatim (none/low/medium/high on Gemini and Groq’s gpt-oss). Leave unset for a non-reasoning model: a provider that does not know the field answers 400, which is indistinguishable from a dead key. See the reasoning-model note below for why it matters. Ignored by Anthropic.
valem.llm.base-url (provider default) Override the endpoint (Ollama, proxies, OpenAI-compatible servers).
valem.llm.max-retries 6 Base validation-retry attempts in SpecGenerator (always attempted). Each repair attempt re-offers the local eval_jsonata tool (its budget replenished) so a fix can be re-tested in place; network tools stay on the first attempt only.
valem.llm.max-retries-hard 10 Ceiling for a converging hard spec — extra attempts past the base budget are granted only while the validation-error count keeps dropping.
valem.llm.repair-temperature 0.2 Sampling temperature on the first repair attempt. Set slightly above generation-temperature on purpose: after a deterministic first attempt failed, a little randomness helps the model escape the rut and try a different fix rather than re-emitting the same output.
valem.llm.repair-temperature-step 0.15 Amount the repair temperature rises on each subsequent repair attempt (repair-temperature + (n−1)·step), so a model that keeps re-emitting the same failing output diverges instead of repeating it. 0 = flat repair temperature.
valem.llm.repair-temperature-max 0.8 Ceiling the escalating repair temperature is clamped to (kept below 1.0 so retries stay structured, not incoherent).
valem.llm.generation-temperature 0.0 Sampling temperature on the initial attempt. Low (~0) makes the first attempt deterministic and structured rather than creative.
valem.llm.structured-output.enabled true Send the ModelSpec/SpecEvolution JSON Schema so the output shape is provider-enforced. OpenAI-compatible providers use it as response_format (json_schema mode); Anthropic uses a forced submit_spec tool whose input_schema is the schema (forced when no grounding tools are configured; offered but not forced alongside web_search/eval_jsonata). Non-strict (a spec embeds an arbitrary JSON Schema, which strict mode cannot represent).
valem.llm.prompt-cache.enabled true Anthropic only: send the (stable) system context as a block array carrying an ephemeral cache_control breakpoint, so retries and tool-loop turns re-read the tools+system prefix at ~10% of input price. Set false if a proxy chokes on a block-array system (it is then sent as a plain string).
valem.llm.tool-loop.max-iterations 40 Hard ceiling on tool-use round-trips per generation before one final tools-withheld request forces the answer (prevents an unbounded loop when a model keeps invoking exhausted tools). Keep it above the combined tool budget (web-fetch.max-calls + web-search.max-calls + eval-tool.max-calls).
valem.llm.structured-output schema How much response_format constraint to ask an OpenAI-compatible provider for: schema (json_schema when a response schema is supplied, else json_object), json (never json_schema), or none (omit response_format). Support is a ladder, not a yes/no — most hosted providers accept json_object, fewer accept json_schema, and some local servers (llama.cpp, older vLLM/LM Studio) reject the field outright, answering 400. Turn it down if a provider that should work returns 400. Safe to do: the generator recovers JSON from prose, so the cost is a higher retry rate rather than a broken pipeline. Ignored by Anthropic, which has no response_format.
valem.llm.mock false Use MockLlmClient (no real call; canned response).
valem.llm.web-fetch.enabled true Enable web tools (web_fetch + web_search) during generation. See SSRF notes in security-model.md.
valem.llm.web-fetch.max-calls 5 Max fetches per generate() session (shared across retries).
valem.llm.web-fetch.max-chars 8000 Max plain-text characters per fetched page.
valem.llm.web-search.enabled true Offer the web_search tool alongside web_fetch (so the model finds authoritative URLs instead of guessing). Set false for fetch-only. Gated by web-fetch.enabled.
valem.llm.web-search.provider duckduckgo Which SearchBackend answers web_search: duckduckgo (keyless HTML scraping — free, but datacenter/hosting-provider IPs can get silently challenge-blocked, which parses as zero results), brave (the Brave Search API, needs web-search.api-key; recommended for a public-facing deployment), or tavily (the Tavily Search API, also needs web-search.api-key).
valem.llm.web-search.api-key (unset) API key for the selected web-search.provider, when it needs one (e.g. brave, tavily). Ignored by duckduckgo.
valem.llm.web-search.max-calls 3 Max searches per generate() session (independent of the fetch budget).
valem.llm.web-search.max-results 5 Max results returned per search.
valem.llm.eval-tool.enabled true Offer the eval_jsonata tool so the model can test a candidate expression against a sample input (local; no network) and fix it in place before committing. Set false to drop it.
valem.llm.eval-tool.max-calls 25 Max eval_jsonata evaluations per attempt (replenished each generation attempt so a repair can re-test its fixes; local, no network).
valem.llm.domain-guidance.enabled true Offer the get_domain_guidance tool: the model reads the domain description in any language, picks matching topic ids from a fixed menu, and gets vetted “hard shape” instructions back (amortization schedules, progressive tax/fee charges, classification, date math, percentages, unit conversion, compound growth, weighted averages, eligibility, proration, …). Replaces keyword matching; the resolved guidance is re-injected into repair prompts.
valem.llm.domain-guidance.topics-file (unset) Path to an operator JSON file that extends or overrides the builtin topics (valem-core resource valem/domain-guidance-topics.json). Array of { "id", "description", "instructions" } (or "instructionLines": [ … ]); merged by id — a matching id replaces the builtin topic, a new id is added. A missing/malformed file is ignored (builtin topics still load). The MCP server honours the same property, or the VALEM_DOMAIN_GUIDANCE_TOPICS_FILE env var.
valem.llm.max-concurrent-requests 1 (shipped in application.yml; unset = 0) Cap on simultaneous LLM calls. 0 = unlimited; 1 = fully serialised app-wide. Use a low value to avoid HTTP 429s on throttled keys when multiple generations overlap (limits concurrency, not raw request rate).
valem.llm.log.capture-content true When true, LlmInteractionLog records the full prompt/response text of each LLM call (visible via GET /llm/interactions). Set false to keep only metadata (provider, model, timing, token counts) and redact the content.

Provider base-URL defaults: Anthropic https://api.anthropic.com/v1/messages; OpenAI https://api.openai.com/v1; Ollama http://localhost:11434/v1; OpenRouter https://openrouter.ai/api/v1; Groq https://api.groq.com/openai/v1; Mistral https://api.mistral.ai/v1; Gemini https://generativelanguage.googleapis.com/v1beta/openai/; Cerebras https://api.cerebras.ai/v1.

Groq and Gemini: response_format and tools are mutually exclusive — handled automatically, no configuration needed. Groq answers 400 "json mode cannot be combined with tool/function calling" to any response_format (json_object and json_schema alike, on every model) once the request also carries tools; Gemini’s OpenAI-compatible endpoint answers 400 "Function calling with a response mime type: 'application/json' is unsupported" to the same combination. The spec-generation tool loop is on by default, so that would be the very first call a correctly configured key makes. The client therefore omits response_format on tool-carrying requests to those two providers; plain completions and the tool loop’s final tools-withheld answer keep whatever structured-output rung is configured. This is a fixed provider rule rather than a per-deployment capability, so it lives in the provider table (LlmClientFactory.combinesResponseFormatWithTools) alongside the base URLs above — unlike valem.llm.structured-output, which exists because local server support genuinely varies.

Reasoning models spend your token budget thinking. A model that reasons before answering (Gemini 2.5/3.x, Groq’s openai/gpt-oss-*) bills its chain of thought against the same max-tokens as the answer, and returns the thinking in a separate field — so a budget the thinking exhausts comes back as finish_reason: "length" with empty content, which reaches generation as “Response was not valid JSON: No content to map due to end-of-input”. Measured on one spec generation with gemini-2.5-flash: at max-tokens=4096 with the provider’s default effort, 3,095 tokens went to thinking and 984 to the answer, truncating the spec; with valem.llm.reasoning-effort=low and max-tokens=8192, ~800 tokens went to thinking and the spec completed in one call. Set both for a reasoning model. (The generator also recovers on its own — an empty or truncated response retries the same prompt at min(2 × max-tokens, max-tokens-hard) — but that costs an extra call per generation.)

Free tiers run out in different units, and the unit decides the fallback order. Two measured examples, because “free tier” says nothing useful on its own: Groq caps tokens per minute (8,000 on its free tier) and admits a request on prompt + max_tokens before the model reads it — so a long system prompt is refused outright, every time, and no max-tokens value helps. Gemini caps requests per day (20 per model on its free tier: GenerateRequestsPerDayPerProjectPerModel- FreeTier) — generous per call, but a spec generation costs 4–10 calls, so the key is spent after a handful of them. A per-minute limit is something a retry rides out; a per-day limit is not. Prefer the per-minute-limited provider as the primary and keep the per-day one as the fallback, with a daily-call-limit matching its real ceiling so it steps aside silently instead of making every later call pay a full backoff before failing over.

Model defaults go stale. Providers retire model ids, and a retired default answers model_not_found — which looks like a broken key rather than a stale constant. The built-in defaults are starting points only; set valem.llm.model explicitly for any deployment you care about, and check the provider’s own model list first (for an OpenAI-compatible provider, GET {base-url}/models).

LLM beans are created only when valem.llm.mock=true, valem.llm.api-key is non-blank, or the provider is ollama. Otherwise /models/generate* returns 503.


Environment-variable equivalents

Spring relaxed binding maps valem.llm.providerVALEM_LLM_PROVIDER, etc. There is no provider-specific environment-variable fallback (e.g. no implicit ANTHROPIC_API_KEY read) — valem.llm.api-key is the only key source, itself settable via VALEM_LLM_API_KEY. See ../getting-started/quickstart.md for examples.