2026-05-31 23:58:26 +09:00
|
|
|
# src/settings.py
|
|
|
|
|
"""Centralized settings and features management.
|
|
|
|
|
|
|
|
|
|
Single source of truth for reading/writing data/settings.json and data/features.json.
|
|
|
|
|
All modules should import from here instead of accessing files directly.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from src.constants import SETTINGS_FILE, FEATURES_FILE
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
|
|
|
|
|
# (every chat, every preprocess); without this it re-parses the JSON each call.
|
|
|
|
|
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
|
|
|
|
|
_CACHE_TTL = 2.0
|
|
|
|
|
_settings_cache: tuple[float, dict] | None = None
|
|
|
|
|
_features_cache: tuple[float, dict] | None = None
|
|
|
|
|
|
|
|
|
|
def _invalidate_caches():
|
|
|
|
|
global _settings_cache, _features_cache
|
|
|
|
|
_settings_cache = None
|
|
|
|
|
_features_cache = None
|
|
|
|
|
|
|
|
|
|
# ── Default values ──
|
|
|
|
|
|
|
|
|
|
DEFAULT_SETTINGS = {
|
2026-06-11 08:50:06 +09:00
|
|
|
# Agent email safety: when True, the MCP send_email / reply_to_email
|
|
|
|
|
# tools don't SMTP directly. They stage the composed message into the
|
|
|
|
|
# scheduled_emails table with status='agent_draft' and return a
|
|
|
|
|
# pending_id + the rendered email so the user can review and approve
|
|
|
|
|
# (or cancel) before it actually goes out. Default ON because models
|
|
|
|
|
# have been observed inventing signatures and sending to real
|
|
|
|
|
# recipients without confirmation.
|
|
|
|
|
"agent_email_confirm": True,
|
Settings overhaul + UI polish pass
Two months of iteration on the Settings panel, integration forms, and
small visual nudges across the app. Highlights:
Settings restructure
- Add Models: split into separate Local + API cards (no more in-card
tabs); each fuses Type/Provider with the URL input.
- Added Models: new dedicated sidebar tab, with Probe + Clear-offline
pulled into its header; Local/API sub-section icons accent-tinted.
- Search: Web Search and a new Deep Research card (Model + tuning),
with a cross-link to AI Defaults. Provider hints use real clickable
anchors; Web Search Test button shows a whirlpool spinner.
- AI Defaults: Image Generation card returns; Research Model card
carries only Endpoint+Model with a cross-link to Search; Vision /
Default / Utility fallbacks unified under one numbered-row design
matching Search's chain.
- API Permissions (was 'API Tokens'): per-row rename, inline
Permissions toggle that expands the scope-edit panel, in-field
copy icons (icon→check on success). Empty state accent-tinted.
- Integrations: + Add Integration drops a type-picker menu directly
under the button (drop-up on tight viewports); each integration
form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses
the same accent-outlined Save/Test/Cancel buttons right-aligned.
- Danger Zone: Wipe→Delete with trash icons; new 'Delete everything'
row at the bottom that loops every category.
AI Synthesis (Reminders)
- Persona dropdown sourced from PROMPT_TEMPLATES + custom preset.
- src/reminder_personas.py mirrors the five built-ins for the
server-side synthesis path.
- dispatch_reminder() reads reminder_llm_persona and uses the
persona's system prompt; empty/unknown falls back to warm-neutral.
Esc handling
- Kebab menus and the provider picker intercept Esc in capture phase
so dismissing a popup no longer closes the whole Settings modal.
Accent tinting
- Scoped CSS rule across data-settings-panel=ai/services/added-models/
search/integrations/reminders for card h2 icons + the Added Models
sub-section icons.
Codex/Claude integration form
- No more auto-creation on form open — explicit Create token button.
- New tokens start with every scope granted; existing tokens move out
of the integration form into the API Permissions card.
- Setup reveal: copy buttons inline inside the token + setup code
blocks; shorter subtitle wording.
Misc visual polish
- Save/Test/Cancel uniformly accent-outlined and right-aligned on
every integration form.
- Provider logos render inline next to the search fallback selects
and the Deep Research Search dropdown.
- Trash icons in fallback rows bumped to 20x20 so they fill the 32px
button.
- Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
|
|
|
"image_gen_enabled": False,
|
2026-05-31 23:58:26 +09:00
|
|
|
"image_model": "",
|
|
|
|
|
"image_quality": "medium",
|
|
|
|
|
"vision_model": "",
|
|
|
|
|
"vision_enabled": True,
|
|
|
|
|
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
|
|
|
|
"vision_model_fallbacks": [],
|
|
|
|
|
# Public base URL used to build clickable deep-links in outgoing alerts
|
|
|
|
|
# (e.g., urgency alert email). Example: "https://chat.example.com"
|
|
|
|
|
"app_public_url": "",
|
|
|
|
|
"tts_enabled": True,
|
|
|
|
|
"tts_provider": "disabled",
|
|
|
|
|
"tts_model": "tts-1",
|
|
|
|
|
"tts_voice": "alloy",
|
|
|
|
|
"tts_speed": "1",
|
|
|
|
|
"stt_enabled": False,
|
|
|
|
|
"stt_provider": "disabled",
|
|
|
|
|
"stt_model": "base",
|
|
|
|
|
"stt_language": "",
|
|
|
|
|
"search_provider": "searxng",
|
|
|
|
|
# Default fallback chain — when the primary provider fails or
|
|
|
|
|
# rate-limits, we try DuckDuckGo next. Free, no API key required, so
|
|
|
|
|
# safe to ship on by default for every user.
|
|
|
|
|
"search_fallback_chain": ["duckduckgo"],
|
|
|
|
|
"search_url": "",
|
|
|
|
|
"search_result_count": 5,
|
Apply SafeSearch by default across search providers (#763)
#718 reported Deep Research drifting into adult / spam URLs several
rounds into a benign session ("research about https://bhagathgoud.com/
and what he doing currently"). The reporter's log showed Japanese
adult sites being crawled even though the model was emitting normal
queries like "Bhagath Goud LinkedIn" and "site:bhagathgoud.com".
The model wasn't generating those URLs. Every provider call site
constructed its params dict without a SafeSearch parameter, so the
underlying HTTP backend (the duckduckgo-search library / DDG's HTML
endpoint in this case) was free to surface "related search" /
trending / spam recommendations that have nothing to do with the
user's query. Per provider:
- SearXNG: instance-dependent; many self-hosted instances default
to safesearch=0.
- Brave API: defaults to "off" for new API keys.
- duckduckgo-search lib: defaults to "moderate", which still lets
related-search recommendations and HTTP-backend fallback URLs
surface trending non-English spam topics.
- DDG HTML fallback (html.duckduckgo.com): no `kp` param, treated
as off.
- Google PSE: omitted `safe` is equivalent to off.
- Serper: omitted `safe` proxies to Google with safe off.
Since the bad URLs entered through the provider layer, not the
model, the provider params are the right place to gate this.
Changes:
- src/settings.py: new `search_safesearch` setting with default
"strict". Documented values ("strict" | "moderate" | "off") plus
a few aliases ("on", "high", "0/1/2", "disabled", ...) so a
hand-edited config doesn't silently fall through to off.
- src/search/providers.py:
- Add `_get_safesearch_level()` (canonical, normalizing) and
`_safesearch_for(provider)` (per-provider param translation).
- Thread the per-provider value into every params dict:
SearXNG JSON, SearXNG language/engines fallbacks, SearXNG HTML,
Brave, DDG library, DDG HTML fallback, Google PSE, Serper.
- Tavily is left untouched — its API has no SafeSearch knob and
its index already filters explicit content at ingest time.
Behavior change for existing installs: default is now "strict", so
explicit results get filtered across every supported provider
without any user action. Users who deliberately want unfiltered
results can set `search_safesearch` to "off" in Settings. No new
dependencies, no schema migrations.
Closes #718.
2026-06-02 08:04:32 +05:30
|
|
|
# SafeSearch level applied to every provider that exposes one.
|
|
|
|
|
# "strict" — block adult / explicit results (default; matches what users
|
|
|
|
|
# expect from a research tool and avoids unrelated NSFW URLs
|
|
|
|
|
# bleeding in via provider "related" / spam recommendations)
|
|
|
|
|
# "moderate" — provider-default behavior (filter explicit but allow
|
|
|
|
|
# suggestive content)
|
|
|
|
|
# "off" — disable filtering entirely (advanced users only)
|
|
|
|
|
#
|
|
|
|
|
# Providers that honor this setting (translated to each provider's native
|
|
|
|
|
# param in src/search/providers.py:_safesearch_for):
|
|
|
|
|
# SearXNG safesearch=0/1/2 (JSON API, HTML scrape, news fallback)
|
|
|
|
|
# Brave Search safesearch=off/moderate/strict
|
|
|
|
|
# DuckDuckGo safesearch=off/moderate/on (library + HTML kp param)
|
|
|
|
|
# Google PSE safe=active (omitted for "off"; PSE has no middle tier)
|
|
|
|
|
# Serper.dev safe=active (omitted for "off"; proxies Google's `safe`)
|
|
|
|
|
# Providers NOT touched: Tavily (no SafeSearch knob; filters at index time)
|
|
|
|
|
# and any custom backend reached via search_url — they keep whatever the
|
|
|
|
|
# backend itself decides, so operators stay in control of self-hosted /
|
|
|
|
|
# niche search instances.
|
|
|
|
|
"search_safesearch": "strict",
|
2026-05-31 23:58:26 +09:00
|
|
|
"brave_api_key": "",
|
|
|
|
|
"google_pse_key": "",
|
|
|
|
|
"google_pse_cx": "",
|
|
|
|
|
"tavily_api_key": "",
|
|
|
|
|
"serper_api_key": "",
|
|
|
|
|
"research_endpoint_id": "",
|
|
|
|
|
"research_model": "",
|
|
|
|
|
"research_search_provider": "",
|
|
|
|
|
"research_max_tokens": 16384,
|
2026-06-01 14:55:24 +09:00
|
|
|
"research_extraction_timeout_seconds": 90,
|
2026-06-04 14:23:17 -04:00
|
|
|
# Lightweight planning/query LLM calls happen before any search starts.
|
|
|
|
|
# Keep them separately tunable so slow local backends are not capped by
|
|
|
|
|
# the old 30s/60s per-call defaults.
|
|
|
|
|
"research_planning_timeout_seconds": 90,
|
|
|
|
|
"research_query_timeout_seconds": 90,
|
2026-06-01 14:55:24 +09:00
|
|
|
"research_extraction_concurrency": 3,
|
2026-06-02 07:53:32 +05:30
|
|
|
# Hard wall-clock cap on a single deep-research run. The previous 600s
|
|
|
|
|
# (10 min) default cut off slow local / edge LLMs mid-synthesis; 1800s
|
|
|
|
|
# (30 min) is comfortable for most local setups while still bounding
|
2026-06-02 13:57:57 +02:00
|
|
|
# runaway jobs. Set to 0 to disable the cap entirely (unlimited) — only
|
|
|
|
|
# for very long deep-research runs, since a stalled job then runs an
|
|
|
|
|
# unbounded model/API bill. Other values are bounded to [60, 86400].
|
|
|
|
|
# Tune via Settings or by editing data/settings.json.
|
2026-06-02 07:53:32 +05:30
|
|
|
"research_run_timeout_seconds": 1800,
|
2026-05-31 23:58:26 +09:00
|
|
|
"agent_max_tool_calls": 0,
|
2026-06-04 22:36:05 +02:00
|
|
|
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
|
fix(agent): don't let a materialized default budget defeat context-window scaling (#4122)
* fix(agent): don't let a materialized default budget defeat context scaling
#1230 scales agent_input_token_budget to the model's context window unless
the user explicitly set a budget, detected via is_setting_overridden(). But
the settings-save path materializes every DEFAULT_SETTINGS key into
settings.json (load_settings merges defaults; handlers persist the merged
dict), so the persisted default 6000 reads as "overridden" and the budget
code takes the min(6000, ctx) branch — silently re-capping long-context
models at 6000 for anyone who has ever saved a setting. This reintroduces
the exact regression #1170/#1230 set out to fix.
Add is_setting_customized() (saved value != default) and gate the scaling
on it instead of mere presence. A persisted default is not a user choice.
is_setting_overridden has exactly one consumer (this budget path), so the
change is contained. Tests cover the materialized-default regression, a
deliberately-chosen budget still being honoured, and the absent-key case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): rework context-budget fix per review (#4122)
Address RaresKeY's review:
P2 (explicitness): is_setting_customized treated a saved value equal to the
default as "not explicit", which ALSO blocked a user from deliberately pinning
the default budget. Reframe the default value itself as the AUTO sentinel —
agent_input_token_budget == DEFAULT_BUDGET means "scale to the model's context
window", any other value is an explicit cap. A materialized default still reads
as auto (fixing the original regression), and any non-default value the user
chooses is now honoured. Drop the now-unused is_setting_customized helper.
P2 (fallback context): auto-scaling trusted get_context_length() even when it
returned only the bare DEFAULT_CONTEXT fallback (no endpoint-reported / known
window), over-allocating on self-hosted/proxy setups. Add get_context_length_known()
(also returns whether the window was actually discovered); the budget block
passes 0 when unknown so auto-scaling stays conservative instead of inflating to
an unproven window.
hard_max stays auto-only — a deliberate explicit budget wins (#1190); kept that
contract and answered the reviewer's question rather than silently reversing it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(agent): lock the materialized-default budget regression (review on #4121)
Per WGlynn's review on the issue: add an end-to-end regression that saves an
UNRELATED setting (which makes the settings-save path materialize the budget
default into settings.json) and asserts the budget still auto-scales rather than
re-reading as an explicit 6000 cap — locking the exact reopening shut.
To make the test bite the production decision (not just re-derive it), extract
`budget_is_explicit()` into src/context_budget.py and use it from the agent loop.
It keys off value-vs-default (the default is the auto sentinel), NOT settings
presence — which is the whole point, since the save path materializes defaults.
Note: after this PR's rework, is_setting_overridden has ZERO production callers,
so the merged-dict materialization smell can't reach any setting through a
presence check today (WGlynn's durability concern).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): bind the budget context window to its own provenance (review #4122)
RaresKeY caught a correctness bug in the fallback-context guard: stream_agent_loop
kept only the `known` flag from get_context_length_known() and budgeted off the
passed-in `context_length`, which can come from a *different* lookup. Two failures:
- local endpoints are re-queried, so the passed value can be a stale DEFAULT_CONTEXT
fallback while the fresh probe proves the real (smaller) served context — we'd
scale off the stale value;
- callers that don't pass context_length (scheduled tasks, teacher escalation,
skill test runs, bg_monitor) were capped at 6000 even when a long window is
discoverable.
Extract budget_context_for_model() which returns the freshly-probed window when
known else 0, binding the flag to the value it proves; the agent loop uses it.
Regression tests cover the stale-fallback, no-arg-caller, and probe-error paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): fix stale budget comments + tighten to the contract (review #4122)
- settings.py: an explicit budget is clamped to the window only — hard_max is
auto-only (#1190); drop the incorrect "and to hard_max".
- is_setting_overridden docstring: drop the stale "adaptive budgets" example;
point value-sensitive callers at context_budget.budget_is_explicit.
- Tighten the budget-block comments to the contract (default = auto sentinel,
non-default = explicit cap, hard_max = auto-only ceiling).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): correct budget issue citations (#1190 → merged #1230/#1273)
The context-budget contract (auto-sentinel, explicit budgets honoured,
hard_max auto-only) merged via #1230 — #1190 was the earlier, closed,
superseded PR. Re-point the contract comments at #1230 (the live source,
already cited for the auto-sentinel two lines up in settings.py).
The configurable hard_max setting (`agent_input_token_hard_max`) was a
reviewer requirement first raised on #1190, omitted from the merged #1230,
and actually added in #1273 — credit #1273 for it and correct the test
comment's history (it previously implied this PR completed the requirement).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:17:28 +08:00
|
|
|
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
|
|
|
|
|
# "auto" sentinel: it means "scale the budget to the model's context window"
|
|
|
|
|
# (#1230) — so long-context models aren't capped at 6000. Set ANY OTHER value
|
|
|
|
|
# to enforce an explicit cap (clamped to the window only — hard_max does not
|
|
|
|
|
# apply to explicit budgets, #1230); set 0 to disable soft-trimming. The
|
|
|
|
|
# default is treated as auto because the settings-save path materializes
|
|
|
|
|
# defaults, so a persisted 6000 can't be told apart from a deliberate 6000 —
|
|
|
|
|
# to pin a budget near the default, use a nearby value (e.g. 5999).
|
2026-05-31 23:58:26 +09:00
|
|
|
"agent_input_token_budget": 6000,
|
fix(agent): don't let a materialized default budget defeat context-window scaling (#4122)
* fix(agent): don't let a materialized default budget defeat context scaling
#1230 scales agent_input_token_budget to the model's context window unless
the user explicitly set a budget, detected via is_setting_overridden(). But
the settings-save path materializes every DEFAULT_SETTINGS key into
settings.json (load_settings merges defaults; handlers persist the merged
dict), so the persisted default 6000 reads as "overridden" and the budget
code takes the min(6000, ctx) branch — silently re-capping long-context
models at 6000 for anyone who has ever saved a setting. This reintroduces
the exact regression #1170/#1230 set out to fix.
Add is_setting_customized() (saved value != default) and gate the scaling
on it instead of mere presence. A persisted default is not a user choice.
is_setting_overridden has exactly one consumer (this budget path), so the
change is contained. Tests cover the materialized-default regression, a
deliberately-chosen budget still being honoured, and the absent-key case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): rework context-budget fix per review (#4122)
Address RaresKeY's review:
P2 (explicitness): is_setting_customized treated a saved value equal to the
default as "not explicit", which ALSO blocked a user from deliberately pinning
the default budget. Reframe the default value itself as the AUTO sentinel —
agent_input_token_budget == DEFAULT_BUDGET means "scale to the model's context
window", any other value is an explicit cap. A materialized default still reads
as auto (fixing the original regression), and any non-default value the user
chooses is now honoured. Drop the now-unused is_setting_customized helper.
P2 (fallback context): auto-scaling trusted get_context_length() even when it
returned only the bare DEFAULT_CONTEXT fallback (no endpoint-reported / known
window), over-allocating on self-hosted/proxy setups. Add get_context_length_known()
(also returns whether the window was actually discovered); the budget block
passes 0 when unknown so auto-scaling stays conservative instead of inflating to
an unproven window.
hard_max stays auto-only — a deliberate explicit budget wins (#1190); kept that
contract and answered the reviewer's question rather than silently reversing it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(agent): lock the materialized-default budget regression (review on #4121)
Per WGlynn's review on the issue: add an end-to-end regression that saves an
UNRELATED setting (which makes the settings-save path materialize the budget
default into settings.json) and asserts the budget still auto-scales rather than
re-reading as an explicit 6000 cap — locking the exact reopening shut.
To make the test bite the production decision (not just re-derive it), extract
`budget_is_explicit()` into src/context_budget.py and use it from the agent loop.
It keys off value-vs-default (the default is the auto sentinel), NOT settings
presence — which is the whole point, since the save path materializes defaults.
Note: after this PR's rework, is_setting_overridden has ZERO production callers,
so the merged-dict materialization smell can't reach any setting through a
presence check today (WGlynn's durability concern).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): bind the budget context window to its own provenance (review #4122)
RaresKeY caught a correctness bug in the fallback-context guard: stream_agent_loop
kept only the `known` flag from get_context_length_known() and budgeted off the
passed-in `context_length`, which can come from a *different* lookup. Two failures:
- local endpoints are re-queried, so the passed value can be a stale DEFAULT_CONTEXT
fallback while the fresh probe proves the real (smaller) served context — we'd
scale off the stale value;
- callers that don't pass context_length (scheduled tasks, teacher escalation,
skill test runs, bg_monitor) were capped at 6000 even when a long window is
discoverable.
Extract budget_context_for_model() which returns the freshly-probed window when
known else 0, binding the flag to the value it proves; the agent loop uses it.
Regression tests cover the stale-fallback, no-arg-caller, and probe-error paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): fix stale budget comments + tighten to the contract (review #4122)
- settings.py: an explicit budget is clamped to the window only — hard_max is
auto-only (#1190); drop the incorrect "and to hard_max".
- is_setting_overridden docstring: drop the stale "adaptive budgets" example;
point value-sensitive callers at context_budget.budget_is_explicit.
- Tighten the budget-block comments to the contract (default = auto sentinel,
non-default = explicit cap, hard_max = auto-only ceiling).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): correct budget issue citations (#1190 → merged #1230/#1273)
The context-budget contract (auto-sentinel, explicit budgets honoured,
hard_max auto-only) merged via #1230 — #1190 was the earlier, closed,
superseded PR. Re-point the contract comments at #1230 (the live source,
already cited for the auto-sentinel two lines up in settings.py).
The configurable hard_max setting (`agent_input_token_hard_max`) was a
reviewer requirement first raised on #1190, omitted from the merged #1230,
and actually added in #1273 — credit #1273 for it and correct the test
comment's history (it previously implied this PR completed the requirement).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:17:28 +08:00
|
|
|
# Ceiling on the *auto-derived* input budget; a configurable setting since #1273
|
|
|
|
|
# (the merged #1230 left it a module constant). No effect on an explicit budget
|
|
|
|
|
# — a deliberate value is honoured (#1230). Default matches
|
|
|
|
|
# `src.context_budget.DEFAULT_HARD_MAX`; lower this for
|
|
|
|
|
# cost-paranoid setups, raise it on premium APIs with very large windows you
|
2026-06-02 11:36:57 -05:00
|
|
|
# want to actually use (e.g. 900_000 to fill a 1M-context model). See
|
fix(agent): don't let a materialized default budget defeat context-window scaling (#4122)
* fix(agent): don't let a materialized default budget defeat context scaling
#1230 scales agent_input_token_budget to the model's context window unless
the user explicitly set a budget, detected via is_setting_overridden(). But
the settings-save path materializes every DEFAULT_SETTINGS key into
settings.json (load_settings merges defaults; handlers persist the merged
dict), so the persisted default 6000 reads as "overridden" and the budget
code takes the min(6000, ctx) branch — silently re-capping long-context
models at 6000 for anyone who has ever saved a setting. This reintroduces
the exact regression #1170/#1230 set out to fix.
Add is_setting_customized() (saved value != default) and gate the scaling
on it instead of mere presence. A persisted default is not a user choice.
is_setting_overridden has exactly one consumer (this budget path), so the
change is contained. Tests cover the materialized-default regression, a
deliberately-chosen budget still being honoured, and the absent-key case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): rework context-budget fix per review (#4122)
Address RaresKeY's review:
P2 (explicitness): is_setting_customized treated a saved value equal to the
default as "not explicit", which ALSO blocked a user from deliberately pinning
the default budget. Reframe the default value itself as the AUTO sentinel —
agent_input_token_budget == DEFAULT_BUDGET means "scale to the model's context
window", any other value is an explicit cap. A materialized default still reads
as auto (fixing the original regression), and any non-default value the user
chooses is now honoured. Drop the now-unused is_setting_customized helper.
P2 (fallback context): auto-scaling trusted get_context_length() even when it
returned only the bare DEFAULT_CONTEXT fallback (no endpoint-reported / known
window), over-allocating on self-hosted/proxy setups. Add get_context_length_known()
(also returns whether the window was actually discovered); the budget block
passes 0 when unknown so auto-scaling stays conservative instead of inflating to
an unproven window.
hard_max stays auto-only — a deliberate explicit budget wins (#1190); kept that
contract and answered the reviewer's question rather than silently reversing it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(agent): lock the materialized-default budget regression (review on #4121)
Per WGlynn's review on the issue: add an end-to-end regression that saves an
UNRELATED setting (which makes the settings-save path materialize the budget
default into settings.json) and asserts the budget still auto-scales rather than
re-reading as an explicit 6000 cap — locking the exact reopening shut.
To make the test bite the production decision (not just re-derive it), extract
`budget_is_explicit()` into src/context_budget.py and use it from the agent loop.
It keys off value-vs-default (the default is the auto sentinel), NOT settings
presence — which is the whole point, since the save path materializes defaults.
Note: after this PR's rework, is_setting_overridden has ZERO production callers,
so the merged-dict materialization smell can't reach any setting through a
presence check today (WGlynn's durability concern).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): bind the budget context window to its own provenance (review #4122)
RaresKeY caught a correctness bug in the fallback-context guard: stream_agent_loop
kept only the `known` flag from get_context_length_known() and budgeted off the
passed-in `context_length`, which can come from a *different* lookup. Two failures:
- local endpoints are re-queried, so the passed value can be a stale DEFAULT_CONTEXT
fallback while the fresh probe proves the real (smaller) served context — we'd
scale off the stale value;
- callers that don't pass context_length (scheduled tasks, teacher escalation,
skill test runs, bg_monitor) were capped at 6000 even when a long window is
discoverable.
Extract budget_context_for_model() which returns the freshly-probed window when
known else 0, binding the flag to the value it proves; the agent loop uses it.
Regression tests cover the stale-fallback, no-arg-caller, and probe-error paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): fix stale budget comments + tighten to the contract (review #4122)
- settings.py: an explicit budget is clamped to the window only — hard_max is
auto-only (#1190); drop the incorrect "and to hard_max".
- is_setting_overridden docstring: drop the stale "adaptive budgets" example;
point value-sensitive callers at context_budget.budget_is_explicit.
- Tighten the budget-block comments to the contract (default = auto sentinel,
non-default = explicit cap, hard_max = auto-only ceiling).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): correct budget issue citations (#1190 → merged #1230/#1273)
The context-budget contract (auto-sentinel, explicit budgets honoured,
hard_max auto-only) merged via #1230 — #1190 was the earlier, closed,
superseded PR. Re-point the contract comments at #1230 (the live source,
already cited for the auto-sentinel two lines up in settings.py).
The configurable hard_max setting (`agent_input_token_hard_max`) was a
reviewer requirement first raised on #1190, omitted from the merged #1230,
and actually added in #1273 — credit #1273 for it and correct the test
comment's history (it previously implied this PR completed the requirement).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:17:28 +08:00
|
|
|
# `compute_input_token_budget`.
|
2026-06-02 11:36:57 -05:00
|
|
|
"agent_input_token_hard_max": 200_000,
|
2026-05-31 23:58:26 +09:00
|
|
|
"agent_stream_timeout_seconds": 300,
|
fix(tools): strict path confinement with sensitive-subpath deny list (#1072)
Rework read_file / write_file confinement after review feedback:
- Remove $HOME from default allow roots. Only project data/ and system
temp dirs are allowed out of the box.
- Add a sensitive-subpath deny list (.ssh, .gnupg, shell rc files,
.env, .netrc, SSH key filenames). Checked BEFORE allowlist so it
blocks even when a broader root is configured.
- Add "tool_path_extra_roots" setting for opt-in broader access.
- Sensitive subpaths remain blocked regardless of configured roots.
Tests: 24 cases covering /etc/shadow, ~/.ssh/authorized_keys,
symlink into .ssh, traversal, shell rc files, key filenames,
extra roots, and dispatch-level end-to-end.
2026-06-02 15:13:30 +01:00
|
|
|
# Extra directory roots that read_file / write_file may access, in
|
|
|
|
|
# addition to the built-in project data/ and system temp dirs. Each
|
|
|
|
|
# entry is an absolute path. Sensitive subpaths (.ssh, .gnupg, shell
|
|
|
|
|
# rc files, SSH key files) are always blocked regardless of roots.
|
|
|
|
|
"tool_path_extra_roots": [],
|
2026-05-31 23:58:26 +09:00
|
|
|
"task_endpoint_id": "",
|
|
|
|
|
"task_model": "",
|
|
|
|
|
"default_endpoint_id": "",
|
|
|
|
|
"default_model": "",
|
|
|
|
|
# Ordered fallback chain for the default chat model. Each entry is
|
|
|
|
|
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
|
|
|
|
|
# before producing output (endpoint offline / errors), the chat
|
|
|
|
|
# dispatch retries the next entry in order.
|
|
|
|
|
"default_model_fallbacks": [],
|
|
|
|
|
"utility_endpoint_id": "",
|
|
|
|
|
"utility_model": "",
|
|
|
|
|
# Ordered fallback chain for the Utility model (summarization, naming,
|
|
|
|
|
# tidy actions, etc.).
|
|
|
|
|
"utility_model_fallbacks": [],
|
|
|
|
|
"teacher_model": "",
|
|
|
|
|
"teacher_enabled": False,
|
|
|
|
|
# Skills: minimum self-reported confidence for an auto-written (LLM-authored)
|
|
|
|
|
# DRAFT skill to be injected into the agent prompt. Published skills always
|
|
|
|
|
# qualify. Keeps low-confidence auto-skills out of context until they're
|
|
|
|
|
# vetted/published. 0 disables the gate.
|
|
|
|
|
"skill_autosave_min_confidence": 0.85,
|
|
|
|
|
# Max relevant skills injected into the prompt for one request. The skills
|
|
|
|
|
# library can grow beyond this; cleanup/retirement is an explicit review flow.
|
|
|
|
|
"skill_max_injected": 3,
|
|
|
|
|
# Reminders
|
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
|
|
|
"reminder_channel": "browser", # "browser" | "email" | "ntfy" | "webhook"
|
2026-05-31 23:58:26 +09:00
|
|
|
"reminder_llm_synthesis": False,
|
Settings overhaul + UI polish pass
Two months of iteration on the Settings panel, integration forms, and
small visual nudges across the app. Highlights:
Settings restructure
- Add Models: split into separate Local + API cards (no more in-card
tabs); each fuses Type/Provider with the URL input.
- Added Models: new dedicated sidebar tab, with Probe + Clear-offline
pulled into its header; Local/API sub-section icons accent-tinted.
- Search: Web Search and a new Deep Research card (Model + tuning),
with a cross-link to AI Defaults. Provider hints use real clickable
anchors; Web Search Test button shows a whirlpool spinner.
- AI Defaults: Image Generation card returns; Research Model card
carries only Endpoint+Model with a cross-link to Search; Vision /
Default / Utility fallbacks unified under one numbered-row design
matching Search's chain.
- API Permissions (was 'API Tokens'): per-row rename, inline
Permissions toggle that expands the scope-edit panel, in-field
copy icons (icon→check on success). Empty state accent-tinted.
- Integrations: + Add Integration drops a type-picker menu directly
under the button (drop-up on tight viewports); each integration
form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses
the same accent-outlined Save/Test/Cancel buttons right-aligned.
- Danger Zone: Wipe→Delete with trash icons; new 'Delete everything'
row at the bottom that loops every category.
AI Synthesis (Reminders)
- Persona dropdown sourced from PROMPT_TEMPLATES + custom preset.
- src/reminder_personas.py mirrors the five built-ins for the
server-side synthesis path.
- dispatch_reminder() reads reminder_llm_persona and uses the
persona's system prompt; empty/unknown falls back to warm-neutral.
Esc handling
- Kebab menus and the provider picker intercept Esc in capture phase
so dismissing a popup no longer closes the whole Settings modal.
Accent tinting
- Scoped CSS rule across data-settings-panel=ai/services/added-models/
search/integrations/reminders for card h2 icons + the Added Models
sub-section icons.
Codex/Claude integration form
- No more auto-creation on form open — explicit Create token button.
- New tokens start with every scope granted; existing tokens move out
of the integration form into the API Permissions card.
- Setup reveal: copy buttons inline inside the token + setup code
blocks; shorter subtitle wording.
Misc visual polish
- Save/Test/Cancel uniformly accent-outlined and right-aligned on
every integration form.
- Provider logos render inline next to the search fallback selects
and the Deep Research Search dropdown.
- Trash icons in fallback rows bumped to 20x20 so they fill the 32px
button.
- Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
|
|
|
"reminder_llm_persona": "",
|
2026-05-31 23:58:26 +09:00
|
|
|
"reminder_ntfy_topic": "Reminders",
|
|
|
|
|
"reminder_email_to": "",
|
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
|
|
|
# Generic outbound webhook channel: pick any saved Integration as the
|
|
|
|
|
# target and supply a JSON payload template. Use {{title}} and {{message}}
|
|
|
|
|
# as placeholders — they are JSON-escaped before substitution, so the
|
|
|
|
|
# rendered string is always valid JSON. Works with Discord, Slack, Teams,
|
|
|
|
|
# ntfy (JSON mode), or any service that accepts a POST with a JSON body.
|
|
|
|
|
"reminder_webhook_integration_id": "",
|
|
|
|
|
"reminder_webhook_payload_template": "",
|
2026-05-31 23:58:26 +09:00
|
|
|
# Email triage scanner rules. Running/paused state and schedule live in
|
|
|
|
|
# Tasks via the built-in `check_email_urgency` task.
|
|
|
|
|
"urgent_email_prompt": (
|
|
|
|
|
"Flag as urgent: explicit deadlines, time-sensitive requests, "
|
|
|
|
|
"work-blocking issues, messages from people I report to, or anything "
|
|
|
|
|
"where a delayed reply costs money/trust. Someone waiting outside, "
|
|
|
|
|
"at the door, locked out, or unable to get in is urgent now. "
|
|
|
|
|
"Newsletters, marketing, automated digests, and FYI-only updates are "
|
|
|
|
|
"NOT urgent."
|
|
|
|
|
),
|
|
|
|
|
# Keyboard shortcuts (action: key combination)
|
|
|
|
|
"keybinds": {
|
|
|
|
|
"search": "ctrl+k",
|
|
|
|
|
"toggle_sidebar": "ctrl+b",
|
|
|
|
|
"new_session": "ctrl+alt+n",
|
|
|
|
|
"star_session": "ctrl+alt+s",
|
|
|
|
|
"delete_session": "ctrl+alt+d",
|
|
|
|
|
"admin_panel": "ctrl+shift+u",
|
|
|
|
|
"cancel": "escape",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DEFAULT_FEATURES = {
|
|
|
|
|
"web_search": True,
|
2026-06-01 14:57:28 +07:00
|
|
|
"web_fetch": True,
|
2026-05-31 23:58:26 +09:00
|
|
|
"deep_research": False,
|
|
|
|
|
"memory": True,
|
|
|
|
|
"document_editor": True,
|
|
|
|
|
"rag": True,
|
|
|
|
|
"sensitive_filter": True,
|
|
|
|
|
"gallery": True,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Settings (data/settings.json) ──
|
|
|
|
|
|
|
|
|
|
def load_settings() -> dict:
|
|
|
|
|
"""Load settings merged with defaults. Always returns a complete dict."""
|
|
|
|
|
global _settings_cache
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
if _settings_cache and (now - _settings_cache[0]) < _CACHE_TTL:
|
|
|
|
|
return _settings_cache[1]
|
|
|
|
|
try:
|
2026-06-01 15:09:47 +09:00
|
|
|
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
2026-05-31 23:58:26 +09:00
|
|
|
saved = json.load(f)
|
2026-06-02 21:53:05 +03:00
|
|
|
if not isinstance(saved, dict):
|
|
|
|
|
raise ValueError("settings must be an object")
|
2026-05-31 23:58:26 +09:00
|
|
|
merged = {**DEFAULT_SETTINGS, **saved}
|
2026-06-03 02:23:27 -03:00
|
|
|
except (FileNotFoundError, PermissionError, json.JSONDecodeError, ValueError):
|
2026-05-31 23:58:26 +09:00
|
|
|
merged = dict(DEFAULT_SETTINGS)
|
|
|
|
|
_settings_cache = (now, merged)
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_settings(settings: dict):
|
|
|
|
|
"""Persist settings to disk (atomic; see core.atomic_io)."""
|
|
|
|
|
from core.atomic_io import atomic_write_json
|
|
|
|
|
atomic_write_json(SETTINGS_FILE, settings, indent=2)
|
|
|
|
|
_invalidate_caches()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_setting(key: str, default: Any = None) -> Any:
|
|
|
|
|
"""Read a single setting value."""
|
|
|
|
|
return load_settings().get(key, default)
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 23:13:53 +08:00
|
|
|
def is_setting_overridden(key: str) -> bool:
|
|
|
|
|
"""True if ``key`` is explicitly present in the saved settings file.
|
|
|
|
|
|
|
|
|
|
``load_settings`` merges DEFAULT_SETTINGS with the saved file, so a value
|
|
|
|
|
equal to its default is indistinguishable from "never set" via get_setting.
|
fix(agent): don't let a materialized default budget defeat context-window scaling (#4122)
* fix(agent): don't let a materialized default budget defeat context scaling
#1230 scales agent_input_token_budget to the model's context window unless
the user explicitly set a budget, detected via is_setting_overridden(). But
the settings-save path materializes every DEFAULT_SETTINGS key into
settings.json (load_settings merges defaults; handlers persist the merged
dict), so the persisted default 6000 reads as "overridden" and the budget
code takes the min(6000, ctx) branch — silently re-capping long-context
models at 6000 for anyone who has ever saved a setting. This reintroduces
the exact regression #1170/#1230 set out to fix.
Add is_setting_customized() (saved value != default) and gate the scaling
on it instead of mere presence. A persisted default is not a user choice.
is_setting_overridden has exactly one consumer (this budget path), so the
change is contained. Tests cover the materialized-default regression, a
deliberately-chosen budget still being honoured, and the absent-key case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): rework context-budget fix per review (#4122)
Address RaresKeY's review:
P2 (explicitness): is_setting_customized treated a saved value equal to the
default as "not explicit", which ALSO blocked a user from deliberately pinning
the default budget. Reframe the default value itself as the AUTO sentinel —
agent_input_token_budget == DEFAULT_BUDGET means "scale to the model's context
window", any other value is an explicit cap. A materialized default still reads
as auto (fixing the original regression), and any non-default value the user
chooses is now honoured. Drop the now-unused is_setting_customized helper.
P2 (fallback context): auto-scaling trusted get_context_length() even when it
returned only the bare DEFAULT_CONTEXT fallback (no endpoint-reported / known
window), over-allocating on self-hosted/proxy setups. Add get_context_length_known()
(also returns whether the window was actually discovered); the budget block
passes 0 when unknown so auto-scaling stays conservative instead of inflating to
an unproven window.
hard_max stays auto-only — a deliberate explicit budget wins (#1190); kept that
contract and answered the reviewer's question rather than silently reversing it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(agent): lock the materialized-default budget regression (review on #4121)
Per WGlynn's review on the issue: add an end-to-end regression that saves an
UNRELATED setting (which makes the settings-save path materialize the budget
default into settings.json) and asserts the budget still auto-scales rather than
re-reading as an explicit 6000 cap — locking the exact reopening shut.
To make the test bite the production decision (not just re-derive it), extract
`budget_is_explicit()` into src/context_budget.py and use it from the agent loop.
It keys off value-vs-default (the default is the auto sentinel), NOT settings
presence — which is the whole point, since the save path materializes defaults.
Note: after this PR's rework, is_setting_overridden has ZERO production callers,
so the merged-dict materialization smell can't reach any setting through a
presence check today (WGlynn's durability concern).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agent): bind the budget context window to its own provenance (review #4122)
RaresKeY caught a correctness bug in the fallback-context guard: stream_agent_loop
kept only the `known` flag from get_context_length_known() and budgeted off the
passed-in `context_length`, which can come from a *different* lookup. Two failures:
- local endpoints are re-queried, so the passed value can be a stale DEFAULT_CONTEXT
fallback while the fresh probe proves the real (smaller) served context — we'd
scale off the stale value;
- callers that don't pass context_length (scheduled tasks, teacher escalation,
skill test runs, bg_monitor) were capped at 6000 even when a long window is
discoverable.
Extract budget_context_for_model() which returns the freshly-probed window when
known else 0, binding the flag to the value it proves; the agent loop uses it.
Regression tests cover the stale-fallback, no-arg-caller, and probe-error paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): fix stale budget comments + tighten to the contract (review #4122)
- settings.py: an explicit budget is clamped to the window only — hard_max is
auto-only (#1190); drop the incorrect "and to hard_max".
- is_setting_overridden docstring: drop the stale "adaptive budgets" example;
point value-sensitive callers at context_budget.budget_is_explicit.
- Tighten the budget-block comments to the contract (default = auto sentinel,
non-default = explicit cap, hard_max = auto-only ceiling).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): correct budget issue citations (#1190 → merged #1230/#1273)
The context-budget contract (auto-sentinel, explicit budgets honoured,
hard_max auto-only) merged via #1230 — #1190 was the earlier, closed,
superseded PR. Re-point the contract comments at #1230 (the live source,
already cited for the auto-sentinel two lines up in settings.py).
The configurable hard_max setting (`agent_input_token_hard_max`) was a
reviewer requirement first raised on #1190, omitted from the merged #1230,
and actually added in #1273 — credit #1273 for it and correct the test
comment's history (it previously implied this PR completed the requirement).
Comment/docstring-only; no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:17:28 +08:00
|
|
|
Callers that must distinguish an explicit user choice from a default read
|
|
|
|
|
the raw saved file via this. (Note: a materialized default is also "present",
|
|
|
|
|
so value-sensitive callers should compare against the default — see
|
|
|
|
|
``context_budget.budget_is_explicit``.)
|
2026-06-02 23:13:53 +08:00
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
2026-06-02 21:53:05 +03:00
|
|
|
saved = json.load(f)
|
|
|
|
|
return isinstance(saved, dict) and key in saved
|
2026-06-02 23:13:53 +08:00
|
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
# Per-user settings (user prefs override the global admin default). Used for
|
|
|
|
|
# keys that a user is allowed to choose individually — currently the vision
|
|
|
|
|
# model + image-generation model. The owner argument is the authed username
|
|
|
|
|
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
|
|
|
|
_PER_USER_KEYS = {
|
|
|
|
|
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
|
|
|
|
"image_model", "image_gen_enabled", "image_quality",
|
|
|
|
|
# Default chat endpoint / model — without per-user resolution every new
|
|
|
|
|
# account inherited whatever the most-recent admin picked, which then
|
|
|
|
|
# got injected into the chat composer on first open.
|
|
|
|
|
"default_endpoint_id", "default_model", "default_model_fallbacks",
|
|
|
|
|
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
|
|
|
|
"research_endpoint_id", "research_model",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
|
|
|
|
|
"""Resolve `key` from the caller's per-user prefs first, falling back to
|
|
|
|
|
the global setting. Only the small whitelist in `_PER_USER_KEYS` is
|
|
|
|
|
eligible — for any other key this is equivalent to `get_setting(key)`.
|
|
|
|
|
|
|
|
|
|
Falls back gracefully if the prefs module can't be imported (cycle/early
|
|
|
|
|
boot) — admin-global settings keep working.
|
|
|
|
|
"""
|
|
|
|
|
if owner and key in _PER_USER_KEYS:
|
|
|
|
|
try:
|
|
|
|
|
from routes.prefs_routes import _load_for_user
|
|
|
|
|
prefs = _load_for_user(owner) or {}
|
|
|
|
|
if key in prefs and prefs[key] not in (None, ""):
|
|
|
|
|
return prefs[key]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return get_setting(key, default)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Features (data/features.json) ──
|
|
|
|
|
|
|
|
|
|
def load_features() -> dict:
|
|
|
|
|
"""Load feature flags merged with defaults."""
|
|
|
|
|
global _features_cache
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
if _features_cache and (now - _features_cache[0]) < _CACHE_TTL:
|
|
|
|
|
return _features_cache[1]
|
|
|
|
|
try:
|
2026-06-01 15:09:47 +09:00
|
|
|
with open(FEATURES_FILE, "r", encoding="utf-8") as f:
|
2026-05-31 23:58:26 +09:00
|
|
|
saved = json.load(f)
|
2026-06-02 21:53:05 +03:00
|
|
|
if not isinstance(saved, dict):
|
|
|
|
|
raise ValueError("features must be an object")
|
2026-05-31 23:58:26 +09:00
|
|
|
merged = {**DEFAULT_FEATURES, **saved}
|
2026-06-11 23:20:10 +03:00
|
|
|
except (FileNotFoundError, PermissionError, json.JSONDecodeError, ValueError):
|
2026-05-31 23:58:26 +09:00
|
|
|
merged = dict(DEFAULT_FEATURES)
|
|
|
|
|
_features_cache = (now, merged)
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_features(features: dict):
|
|
|
|
|
"""Persist feature flags to disk (atomic)."""
|
|
|
|
|
from core.atomic_io import atomic_write_json
|
|
|
|
|
atomic_write_json(FEATURES_FILE, features, indent=2)
|
|
|
|
|
_invalidate_caches()
|