//! Advanced config sections (proxy, memory subsystems, custom_aliases, setup, llm).
//! Split out of `schema/mod.rs`; `use super::*` re-imports helpers + `SectionSchema`.
#[allow(clippy::wildcard_imports)]
use super::*;
use std::collections::BTreeMap;
pub(super) fn build(sections: &mut BTreeMap<String, SectionSchema>) {
let cfg = crate::core::config::Config::default();
let mut proxy = BTreeMap::new();
proxy.insert(
"anthropic_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.anthropic_upstream),
"Custom upstream URL for Anthropic API proxy",
),
);
proxy.insert(
"openai_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.openai_upstream),
"Custom upstream URL for OpenAI API proxy",
),
);
proxy.insert(
"chatgpt_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.chatgpt_upstream),
"Custom upstream URL for ChatGPT/Codex subscription API proxy",
),
);
proxy.insert(
"gemini_upstream".into(),
key(
"string?",
serde_json::json!(cfg.proxy.gemini_upstream),
"Custom upstream URL for Gemini API proxy",
),
);
proxy.insert(
"history_mode".into(),
key_enum_with_env(
&["cache-aware", "rolling", "off"],
"cache-aware",
"History pruning strategy. cache-aware: frozen boundaries that keep provider prompt caches valid (default). rolling: legacy moving window (max raw savings, breaks prompt caching). off: never prune",
"LEAN_CTX_PROXY_HISTORY_MODE",
),
);
proxy.insert(
"allow_insecure_http_upstream".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.allow_insecure_http_upstream.unwrap_or(false)),
"Allow a non-loopback plaintext http:// upstream (trusted local network only, e.g. http://host.docker.internal:2455 in front of codex-lb). Opt-in; default false",
"LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM",
),
);
proxy.insert(
"allow_custom_upstream".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.allow_custom_upstream.unwrap_or(false)),
"Allow a custom (non-allowlisted) HTTPS upstream host, e.g. a corporate gateway in front of the provider API. Opt-in; default false. Unlike the env var, this config flag reaches the managed (service-spawned) proxy started by `proxy enable`/`restart` (#590)",
"LEAN_CTX_ALLOW_CUSTOM_UPSTREAM",
),
);
proxy.insert(
"meter_openai_usage".into(),
key(
"bool",
serde_json::json!(cfg.proxy.meters_openai_usage()),
"Inject stream_options.include_usage into streamed OpenAI Chat Completions so the final chunk reports real token usage for the measured spend meter. Default true",
),
);
proxy.insert(
"cost_response_header".into(),
key(
"string",
serde_json::json!(cfg.proxy.cost_response_header()),
"Extra response header carrying the upstream gateway's billed USD for the turn (e.g. a corporate gateway's cost header). LiteLLM's x-litellm-response-cost is always recognized. Measured header costs beat table estimates; body-reported costs (OpenRouter usage.cost) beat headers",
),
);
proxy.insert(
"cold_prefix_repack".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.repacks_cold_prefix()),
"Opt-in big-gap cold-prefix repack (#480): on a session-resume request the proxy may predict (from idle time vs the provider cache TTL) that the client-cached prefix has already expired, then prune that now-cold prefix to re-seed a leaner cache and keep applying the same deterministic compression on later turns so warm follow-ups hit it (sticky; baselines persist across restarts, #499). A wrong guess re-bills cache reads as writes (~12x), so default false",
"LEAN_CTX_PROXY_COLD_PREFIX_REPACK",
),
);
proxy.insert(
"live_compress".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.live_compresses()),
"Live-compress non-protected tool_result content on the wire (#481). Default true. Set false for a meter-only proxy — real billed/cache token metering with zero request rewriting (combine with history_mode = \"off\" and no role_aggressiveness for a byte-unchanged body)",
"LEAN_CTX_PROXY_LIVE_COMPRESS",
),
);
proxy.insert(
"live_compress_exclude".into(),
key(
"string[]",
serde_json::json!(cfg.proxy.live_compress_exclude_patterns()),
"Tool-name patterns (case-insensitive substring) whose tool_result is never live-compressed — treated as protected, like a file read (#481). Unset protects Serena's code-reading tools; set an explicit list to narrow it, or [] to disable",
),
);
proxy.insert(
"compress_protect".into(),
key(
"string[]",
serde_json::json!(cfg.proxy.compress_protect.clone().unwrap_or_default()),
"File-path globs whose reads are never compressed (#1150): a matching path is returned verbatim (full) by the read tools, for files where exact bytes matter more than token savings (golden snapshots, byte-asserted fixtures, security-sensitive configs). Globs (*/**/?) match the path and its file name, so *.snap, **/golden/**, tests/fixtures/* all work. Empty (default) protects nothing — the lossless crushers and beneficial gate already keep compression safe; this is an explicit escape hatch",
),
);
proxy.insert(
"ccr_inband".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.ccr_inband_enabled()),
"Opt-in in-band CCR retrieval for a remote proxy with no shared filesystem (#493). When on, a lossy stub advertises a compact <lc_expand:HASH> marker instead of a local tee path; when the model echoes that marker, the proxy splices the verbatim original (from its local tee store) back inline next turn — one turn of latency, no MCP/filesystem on the agent host. The splice is a strict no-op on marker-less turns, so it never perturbs the provider cache prefix unless the model asked to expand. Default false",
"LEAN_CTX_PROXY_CCR_INBAND",
),
);
proxy.insert(
"counterfactual_metering".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.counterfactual_metering_enabled()),
"Opt-in counterfactual savings metering (#701): each rewritten Anthropic /v1/messages request fires a free count_tokens probe with the original, uncompressed body, concurrently with the real forward. The provider-counted answer is paired with the same response's billed usage — provider-authoritative savings receipts ('would have cost N, billed M') instead of local tokenizer estimates, shown as verified_savings on /status. The probe never mutates or delays the forwarded request; failures degrade to the estimate. Default false (one extra free HTTP call per compressed request)",
"LEAN_CTX_PROXY_COUNTERFACTUAL",
),
);
proxy.insert(
"cache_breakpoint".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_breakpoint_enabled()),
"Opt-in active prompt-cache breakpoint injection for Anthropic (#939). When on and the client set no cache_control of its own, the proxy adds one cache_control: {type:ephemeral} marker to the system field so an otherwise-uncached, stable system prompt bills later turns at the cached rate (the win a raw API client leaves on the table). Anthropic-only: OpenAI/Gemini cache prefixes automatically and ignore the marker, so those paths stay byte-unchanged. Deterministic, never adds a second breakpoint, and skipped below Anthropic's minimum cacheable size. Default false",
"LEAN_CTX_PROXY_CACHE_BREAKPOINT",
),
);
proxy.insert(
"cache_aligner".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_aligner_enabled()),
"Cache-aligner volatile-field telemetry (#940), on by default. The proxy scans each unanchored Anthropic system prompt for volatile, cache-busting fields (ISO dates/datetimes, UUIDs, git SHAs) and reports how many it found on /status cache_safety (volatile_system_requests, volatile_fields_detected) - purely to quantify how much prompt-cache the client leaks. Measurement only: the request body is never mutated, so it is strictly cache-safe, which is why it ships on for every proxy (#986 premium defaults). The deterministic scan is the precursor to the opt-in tail-relocate below. Set false to opt out of the per-request scan. Default true",
"LEAN_CTX_PROXY_CACHE_ALIGNER",
),
);
proxy.insert(
"cache_align_relocate".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_align_relocate_enabled()),
"Opt-in active cache-aligner relocate (#974). When on, the proxy rewrites an unanchored Anthropic system prompt into a stable block (volatile values - ISO dates/datetimes, UUIDs, git SHAs - replaced by constant placeholders) carrying the cache_control breakpoint, plus an uncached trailing block that re-states the relocated values. The cacheable prefix then stays byte-stable turn-to-turn and finally caches; only the small tail is reprocessed. Anthropic-only, Treatment-arm, gated on a client that anchored nothing and on Anthropic's minimum cacheable size. Deterministic (#498) and idempotent. The cache_aligner telemetry is the precursor that quantifies the saving. Default false",
"LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE",
),
);
proxy.insert(
"cache_policy".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.cache_policy_enabled()),
"Cache-economics (#986), on by default. Enables prompt-cache miss attribution telemetry (per turn, classify the outcome as cold start / warm reuse / TTL lapse / prefix change and report cumulative gauges on /status cache_attribution) plus a net-cost gate on the cold-prefix repack that skips re-seeding prefixes too small to be cached (below Anthropic's ~1024-token minimum). The telemetry never mutates the body and the gate only makes repacking more conservative, so it can never bust a cache that would otherwise have been kept - both halves are strictly safe, so every proxy gets them out of the box (#986 premium defaults). Set false to opt out (drops the /status attribution gauges and the per-request prefix hash). Default true",
"LEAN_CTX_PROXY_CACHE_POLICY",
),
);
proxy.insert(
"effort".into(),
key_enum_with_env(
&["off", "minimal", "low", "medium", "high"],
"off",
"Cache-safe cross-provider reasoning-effort control (#834). off (default) = no-op. minimal|low|medium|high pins the model's reasoning depth across providers: lean-ctx translates it to OpenAI reasoning_effort / reasoning.effort, Anthropic output_config.effort, and Gemini thinkingConfig (thinkingLevel on 3.x, thinkingBudget on 2.5 pro/flash), only on models that accept it and only when the client didn't set its own value. The level is a constant, so it never breaks the provider prompt cache (unlike per-turn effort routing). Anthropic is dialed only when the client already requested adaptive thinking",
"LEAN_CTX_PROXY_EFFORT",
),
);
proxy.insert(
"prose_ranker".into(),
key_enum_with_env(
&["auto", "extractive", "truncate"],
"auto",
"How the proxy squeezes prose it must shrink (#895). auto (default) and extractive use embedding-based extractive ranking — keeping the most central sentences instead of just the prefix — when the local embedding engine is available, else fall back to truncation; truncate keeps the original deterministic FIFO squeeze and never loads the engine. Wire rewrites are memoized per content so the engine's cold→warm transition never changes an already-emitted frozen-region rewrite (cache-safe, #448/#498)",
"LEAN_CTX_PROXY_PROSE_RANKER",
),
);
proxy.insert(
"output_holdout".into(),
key_with_env(
"f64",
serde_json::json!(cfg.proxy.output_holdout_fraction()),
"Fraction 0.0-1.0 of conversations placed in the output-savings control arm (#895). 0 (default) = no holdout (every conversation is output-shaped). When > 0, a deterministic cohort = blake3(system + first user message) puts ~this fraction in a control arm that skips output-shaping (effort control + verbosity steer) but is still metered, yielding an honest measured output-token reduction (lean-ctx output-savings). The cohort is a pure function of conversation identity, so a conversation keeps one arm across all turns - cache-safe",
"LEAN_CTX_PROXY_OUTPUT_HOLDOUT",
),
);
proxy.insert(
"verbosity_steer".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.verbosity_steer_enabled()),
"Opt-in cache-safe wire verbosity steer (#895). When true, the proxy appends a single constant 'be concise' instruction to the last user turn of each request - output-shaping for raw API clients that do not load lean-ctx rules. The suffix is constant and appended strictly after the last cache_control breakpoint (a new trailing text block, never modifying a cache-anchored block), so the provider prompt-cache prefix stays byte-stable. Under an output_holdout the control arm skips it so its effect is measured. Default false",
"LEAN_CTX_PROXY_VERBOSITY_STEER",
),
);
proxy.insert(
"codex_chatgpt_proxy".into(),
key_with_env(
"bool",
serde_json::json!(cfg.proxy.codex_chatgpt_proxy_enabled()),
"Opt-in routing of a Codex ChatGPT-subscription login through the proxy for model-turn compression (#603/#616). Default false leaves Codex native (history visible, cloud/remote intact, no #597). When true, setup pins model_provider = leanctx-chatgpt + chatgpt_base_url + a [model_providers.leanctx-chatgpt] block, so model turns route through /backend-api/codex/responses (the proxy strips the responses-lite marker so every model incl. gpt-5.5 works); pinning a provider scopes Codex history to it (#597), hence opt-in. Toggle durably with `lean-ctx proxy codex-chatgpt on|off|status`. Default false",
"LEAN_CTX_CODEX_CHATGPT_PROXY",
),
);
sections.insert(
"proxy".into(),
SectionSchema {
description: "Proxy upstream configuration for API routing".into(),
keys: proxy,
},
);
let mut role_aggr = BTreeMap::new();
role_aggr.insert(
"system".into(),
key_with_env(
"f64",
serde_json::json!(cfg.proxy.role_aggressiveness.system),
"Opt-in prose compression intensity (0.0–1.0) for system prompts in the proxy's frozen request region. Unset = leave untouched. Higher = more aggressive. Cache-safe (deterministic, never touches the client-cached prefix)",
"LEAN_CTX_PROXY_SYSTEM_AGGR",
),
);
role_aggr.insert(
"user".into(),
key_with_env(
"f64",
serde_json::json!(cfg.proxy.role_aggressiveness.user),
"Opt-in prose compression intensity (0.0–1.0) for free-text user turns (never tool results) in the proxy's frozen request region. Unset = leave untouched",
"LEAN_CTX_PROXY_USER_AGGR",
),
);
sections.insert(
"proxy.role_aggressiveness".into(),
SectionSchema {
description: "Opt-in per-role prose compression for the proxy's frozen request region (#710). Assistant turns are always passed through verbatim".into(),
keys: role_aggr,
},
);
let mut cost = BTreeMap::new();
cost.insert(
"default_model".into(),
key(
"string?",
serde_json::json!(cfg.cost.default_model),
"Fallback pricing model for MCP-only IDEs whose real model lean-ctx cannot observe (Cursor, Copilot, Windsurf, …). Unset → blended heuristic. Per-IDE overrides live in [cost.models]",
),
);
cost.insert(
"prices".into(),
key(
"table?",
serde_json::json!(cfg.cost.prices.keys().collect::<Vec<_>>()),
"Operator price overrides per model, USD per million tokens: [cost.prices.\"<model>\"] with input_per_m / output_per_m / cache_write_per_m / cache_read_per_m. For negotiated enterprise rates (committed-use discounts, Azure PTU, zero-rated internal models); overrides embedded and live catalog rows, only a provider-measured bill beats it",
),
);
sections.insert(
"cost".into(),
SectionSchema {
description: "Model declaration for measured-vs-estimated cost reporting".into(),
keys: cost,
},
);
let mem = &cfg.memory;
let mut mem_knowledge = BTreeMap::new();
mem_knowledge.insert(
"max_facts".into(),
key(
"usize",
serde_json::json!(mem.knowledge.max_facts),
"Maximum number of knowledge facts stored per project",
),
);
mem_knowledge.insert(
"max_patterns".into(),
key(
"usize",
serde_json::json!(mem.knowledge.max_patterns),
"Maximum number of patterns stored",
),
);
mem_knowledge.insert(
"max_history".into(),
key(
"usize",
serde_json::json!(mem.knowledge.max_history),
"Maximum history entries retained",
),
);
mem_knowledge.insert(
"contradiction_threshold".into(),
key(
"f32",
clean_f32(mem.knowledge.contradiction_threshold),
"Confidence threshold for contradiction detection",
),
);
mem_knowledge.insert(
"recall_facts_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.recall_facts_limit),
"Maximum facts returned per recall query",
),
);
mem_knowledge.insert(
"rooms_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.rooms_limit),
"Maximum number of rooms returned",
),
);
mem_knowledge.insert(
"timeline_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.timeline_limit),
"Maximum number of timeline entries returned",
),
);
mem_knowledge.insert(
"relations_limit".into(),
key(
"usize",
serde_json::json!(mem.knowledge.relations_limit),
"Maximum number of relations returned",
),
);
sections.insert(
"memory.knowledge".into(),
SectionSchema {
description: "Knowledge memory budgets (facts, patterns, gotchas)".into(),
keys: mem_knowledge,
},
);
let mut mem_episodic = BTreeMap::new();
mem_episodic.insert(
"max_episodes".into(),
key(
"usize",
serde_json::json!(mem.episodic.max_episodes),
"Maximum number of episodes retained",
),
);
mem_episodic.insert(
"max_actions_per_episode".into(),
key(
"usize",
serde_json::json!(mem.episodic.max_actions_per_episode),
"Maximum actions tracked per episode",
),
);
mem_episodic.insert(
"summary_max_chars".into(),
key(
"usize",
serde_json::json!(mem.episodic.summary_max_chars),
"Maximum characters in episode summary",
),
);
sections.insert(
"memory.episodic".into(),
SectionSchema {
description: "Episodic memory budgets (session episodes)".into(),
keys: mem_episodic,
},
);
let mut mem_procedural = BTreeMap::new();
mem_procedural.insert(
"max_procedures".into(),
key(
"usize",
serde_json::json!(mem.procedural.max_procedures),
"Maximum number of learned procedures stored",
),
);
mem_procedural.insert(
"min_repetitions".into(),
key(
"usize",
serde_json::json!(mem.procedural.min_repetitions),
"Minimum repetitions before a pattern is stored",
),
);
mem_procedural.insert(
"min_sequence_len".into(),
key(
"usize",
serde_json::json!(mem.procedural.min_sequence_len),
"Minimum sequence length for procedure detection",
),
);
mem_procedural.insert(
"max_window_size".into(),
key(
"usize",
serde_json::json!(mem.procedural.max_window_size),
"Maximum window size for pattern analysis",
),
);
sections.insert(
"memory.procedural".into(),
SectionSchema {
description: "Procedural memory budgets (learned patterns)".into(),
keys: mem_procedural,
},
);
let mut mem_lifecycle = BTreeMap::new();
mem_lifecycle.insert(
"decay_rate".into(),
key(
"f32",
clean_f32(mem.lifecycle.decay_rate),
"Rate at which knowledge confidence decays over time",
),
);
mem_lifecycle.insert(
"low_confidence_threshold".into(),
key(
"f32",
clean_f32(mem.lifecycle.low_confidence_threshold),
"Threshold below which facts are considered low-confidence",
),
);
mem_lifecycle.insert(
"stale_days".into(),
key(
"i64",
serde_json::json!(mem.lifecycle.stale_days),
"Days after which unused facts are considered stale",
),
);
mem_lifecycle.insert(
"similarity_threshold".into(),
key(
"f32",
clean_f32(mem.lifecycle.similarity_threshold),
"Similarity threshold for deduplication",
),
);
mem_lifecycle.insert(
"forgetting_model".into(),
key(
"string",
serde_json::json!(mem.lifecycle.forgetting_model),
"Forgetting curve: ebbinghaus (default, exponential + spacing) or linear",
),
);
mem_lifecycle.insert(
"base_stability_days".into(),
key(
"f32",
clean_f32(mem.lifecycle.base_stability_days),
"Characteristic memory stability (days) for the Ebbinghaus curve",
),
);
mem_lifecycle.insert(
"archetype_aware_decay".into(),
key(
"bool",
serde_json::json!(mem.lifecycle.archetype_aware_decay),
"Scale Ebbinghaus stability by fact archetype so structural evidence decays slower than inference (default false)",
),
);
mem_lifecycle.insert(
"reclaim_headroom_pct".into(),
key_with_env(
"f32",
clean_f32(mem.lifecycle.reclaim_headroom_pct),
"Proactive headroom on a capacity reclaim: settle a full store at 1 - this fraction (0.25 = 75%) instead of churning at the cap. Lossless — the reclaimed tail is archived and restorable",
"LEAN_CTX_LIFECYCLE_RECLAIM_HEADROOM_PCT",
),
);
mem_lifecycle.insert(
"reclaim_enabled".into(),
key_with_env(
"bool",
serde_json::json!(mem.lifecycle.reclaim_enabled),
"Master switch for the proactive capacity reclaim (#995). false trims only the overflow (escape hatch, no headroom); eviction stays lossless either way",
"LEAN_CTX_LIFECYCLE_RECLAIM_ENABLED",
),
);
sections.insert(
"memory.lifecycle".into(),
SectionSchema {
description: "Knowledge lifecycle policy (decay, staleness, dedup)".into(),
keys: mem_lifecycle,
},
);
let mut mem_gotcha = BTreeMap::new();
mem_gotcha.insert(
"max_gotchas_per_project".into(),
key(
"usize",
serde_json::json!(mem.gotcha.max_gotchas_per_project),
"Maximum gotchas stored per project",
),
);
mem_gotcha.insert(
"retrieval_budget_per_room".into(),
key(
"usize",
serde_json::json!(mem.gotcha.retrieval_budget_per_room),
"Maximum gotchas retrieved per room per query",
),
);
mem_gotcha.insert(
"default_decay_rate".into(),
key(
"f32",
clean_f32(mem.gotcha.default_decay_rate),
"Default decay rate for gotcha importance",
),
);
sections.insert(
"memory.gotcha".into(),
SectionSchema {
description: "Gotcha memory settings (project-specific warnings and pitfalls)".into(),
keys: mem_gotcha,
},
);
let mut mem_embeddings = BTreeMap::new();
mem_embeddings.insert(
"max_facts".into(),
key(
"usize",
serde_json::json!(mem.embeddings.max_facts),
"Maximum number of embedding facts stored",
),
);
sections.insert(
"memory.embeddings".into(),
SectionSchema {
description: "Embeddings memory settings for semantic search".into(),
keys: mem_embeddings,
},
);
let mut aliases = BTreeMap::new();
aliases.insert(
"command".into(),
key(
"string",
serde_json::json!(""),
"The command pattern to match (e.g. 'deploy')",
),
);
aliases.insert(
"alias".into(),
key(
"string",
serde_json::json!(""),
"The alias definition to execute",
),
);
sections.insert("custom_aliases".into(), SectionSchema {
description: "Custom command aliases (array of {command, alias} entries). Note: field names are 'command' and 'alias' (not 'name')".into(),
keys: aliases,
});
if let Some(root_section) = sections.get_mut("root") {
root_section.keys.insert(
"custom_aliases".into(),
key(
"array",
serde_json::json!([]),
"Custom command aliases (array of {command, alias} entries)",
),
);
}
let mut setup_keys = BTreeMap::new();
setup_keys.insert(
"auto_inject_rules".into(),
key(
"bool?",
serde_json::json!(null),
"Inject agent rule files during setup/update. null=auto (inject if already present), true=always, false=never",
),
);
setup_keys.insert(
"auto_inject_skills".into(),
key(
"bool?",
serde_json::json!(null),
"Install SKILL.md files during setup/update. null=auto (install if rules present), true=always, false=never",
),
);
setup_keys.insert(
"auto_update_mcp".into(),
key(
"bool",
serde_json::json!(true),
"Register lean-ctx MCP server in editor configs during setup/update",
),
);
sections.insert(
"setup".into(),
SectionSchema {
description: "Controls what lean-ctx injects during setup and updates. Fresh installs default to non-invasive (rules/skills off, MCP on).".into(),
keys: setup_keys,
},
);
let mut llm_keys = BTreeMap::new();
llm_keys.insert(
"enabled".into(),
key(
"bool",
serde_json::json!(false),
"Enable optional LLM enhancements (query expansion, contradiction explanation)",
),
);
llm_keys.insert(
"backend".into(),
key_enum(
&["ollama", "openrouter", "anthropic"],
"ollama",
"LLM backend provider",
),
);
llm_keys.insert(
"model".into(),
key(
"string",
serde_json::json!("llama3.2"),
"Model name for the selected backend",
),
);
llm_keys.insert(
"api_key".into(),
key(
"string",
serde_json::json!(""),
"API key for OpenRouter or Anthropic backends",
),
);
llm_keys.insert(
"timeout_secs".into(),
key(
"u64",
serde_json::json!(10),
"HTTP timeout for LLM requests",
),
);
sections.insert("llm".into(), SectionSchema {
description: "Optional LLM enhancement settings (query expansion, contradiction explanation). Deterministic fallback when disabled or unreachable.".into(),
keys: llm_keys,
});
}