Configuration
Single source of truth for every valem.* property. Other docs link here rather than
listing partial, divergent subsets.
- Core runtime
- Security / auth
- Effects (egress + pluggable kinds)
- Model composition & references
- Persistence (model spec + state)
- Blob storage
- Observability (Actuator + Micrometer)
- LLM integration
- 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:
- An explicit JVM flag —
-Dvalem.limits.max-array-index=2000000. Left untouched by the bridge. - The environment variable Boot’s relaxed binding maps to the property —
VALEM_LIMITS_EXPRESSION_CACHE_SIZE,VALEM_LIMITS_MAX_ARRAY_INDEX, and so on. 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
500suits 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
250suits 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.keygate (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 reached — http and mcp wired; filesystem reserved), repo-class (its class — local 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 *-type → storage.type → filesystem 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-type → s3 if storage.blob=s3 → legacy
blob-store (memory/filesystem) → the DB backend if storage.type is postgres/mongodb
→ memory. (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: anthropic→claude-sonnet-4-6, openai→gpt-4o, mistral→mistral-large-latest, groq→openai/gpt-oss-120b, gemini→gemini-2.5-flash, cerebras→llama-3.3-70b, ollama→llama3.1, openrouter→nvidia/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.provider → VALEM_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.