CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Canceled after 31s
The other half of the bargain in milestone 416 step 4. The model moves these dials; this is what makes that reviewable rather than merely automatic. THE HOLE THIS CLOSES Every retrieval floor is an ordinary settings key, and `/api/settings` accepts any key at all. A floor written through it landed correctly and recorded nothing — a tuning history with holes in it, which is worse than no history because it reads as complete. So the generic endpoint now routes registry-owned keys through `set_dial` instead of writing them as plain rows. ROUTED, not refused: refusing would only work for callers that had been updated, while this way the form, a script, and an old client all leave the trail, and there is no version of "forgot to use the other endpoint". Clearing a control is written as an explicit set back to the shipped default, because the operator reverting something is the single most important move this history can record. `set_dial` now also refuses to record a no-op. The Settings form re-sends every field on every save, so without that one press of Save would write six rows saying the operator set six dials to the numbers they were already on — and a history nobody can skim is one nobody reads. WHAT THE OPERATOR GETS `/api/retrieval/surfaces`, `/surfaces/<name>` and `/tuning-history`, with `actor` fixed server-side rather than taken from the payload: a payload-supplied actor would let a model claim to be the operator, and "did I do this, or did the session?" is the first question this list is asked. In Settings: the five missing BUDGETS (until now only auto-inject had one, so the only control over a noisy surface was to raise its bar — which discards that surface's best candidates along with its worst), and a "What has been tuned" panel showing each change, who made it, and the reason given. The operator's own changes are marked. The MCP tool demands a reason; these endpoints do not. That asymmetry is deliberate and stated in routes/retrieval.py: the requirement exists to make the MODEL read the records before moving a number on someone else's behalf, and the operator is that someone — a mandatory justification box on every control would be friction charged to the one participant who owes no explanation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
214 lines
8.4 KiB
Python
214 lines
8.4 KiB
Python
"""User settings + integrations (SearXNG status).
|
|
|
|
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
|
|
hooks were removed in Phase 8 alongside the chat/journal subsystems.
|
|
"""
|
|
import logging
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
|
|
from scribe.auth import login_required, get_current_user_id
|
|
from scribe.config import Config
|
|
from scribe.services.retrieval_surfaces import dial_for_key, get_surface
|
|
from scribe.services.retrieval_tuning import set_dial
|
|
from scribe.services.settings import (
|
|
SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
|
|
|
# Keys whose values are credentials. The admin endpoints that own them mask on
|
|
# read and skip the mask on write; this generic KV surface has to apply the
|
|
# same treatment, or it silently un-masks what those endpoints masked — the
|
|
# rows live on the admin's own user_id, so the plain GET returned them raw.
|
|
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
|
|
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
|
|
|
|
# What the tuning history records for a dial changed through this form. The MCP
|
|
# tool refuses a blank reason; the operator is not asked for one, because the
|
|
# requirement exists to make the MODEL read the records before moving a number
|
|
# on someone else's behalf — and the operator is that someone. See
|
|
# routes/retrieval.py, which states the asymmetry in full.
|
|
_SETTINGS_FORM_REASON = "Changed in Settings by the operator."
|
|
|
|
|
|
def _masked(settings: dict) -> dict:
|
|
return {
|
|
k: (SECRET_MASK if k in _SECRET_KEYS and v else v)
|
|
for k, v in settings.items()
|
|
}
|
|
|
|
|
|
@settings_bp.route("", methods=["GET"])
|
|
@login_required
|
|
async def get_settings_route():
|
|
uid = get_current_user_id()
|
|
settings = await get_all_settings(uid)
|
|
return jsonify(_masked(settings))
|
|
|
|
|
|
@settings_bp.route("", methods=["PUT"])
|
|
@login_required
|
|
async def update_settings_route():
|
|
uid = get_current_user_id()
|
|
data = await request.get_json()
|
|
if not isinstance(data, dict):
|
|
return jsonify({"error": "Expected a JSON object"}), 400
|
|
|
|
to_save = {}
|
|
for k, v in data.items():
|
|
str_v = str(v)
|
|
# A retrieval dial is never written as a plain settings row, wherever
|
|
# the request came from (#4102). The value would land correctly and the
|
|
# tuning history would say nothing happened — and since milestone 416
|
|
# those dials are moved by the model on the operator's behalf, a
|
|
# history with holes in it is worse than none: it reads as complete.
|
|
#
|
|
# Routed rather than refused on purpose. Refusing would work only for
|
|
# callers that had been updated; this way every caller that ever writes
|
|
# one of these keys — this form, a script, an old client — leaves the
|
|
# trail, and there is no version of "forgot to use the other endpoint".
|
|
dial = dial_for_key(k)
|
|
if dial:
|
|
surface, which = dial
|
|
s = get_surface(surface)
|
|
# A CLEARED control means "back to the shipped starting point", and
|
|
# that is a change like any other — it is the operator reverting
|
|
# something, which is the single most important move this history
|
|
# can record. So it is written as an explicit set to the default
|
|
# rather than deleted, which would leave the same value behind and
|
|
# no record of anyone having chosen it.
|
|
default = s.floor_default if which == "floor" else s.budget_default
|
|
try:
|
|
await set_dial(uid, surface, which, float(str_v or default),
|
|
reason=_SETTINGS_FORM_REASON, actor="human")
|
|
except (TypeError, ValueError) as e:
|
|
return jsonify({"error": f"{k}: {e}"}), 400
|
|
continue
|
|
# A masked secret round-tripping through a client is "unchanged", not
|
|
# a request to store the mask over the real credential.
|
|
if k in _SECRET_KEYS and str_v == SECRET_MASK:
|
|
continue
|
|
if not str_v:
|
|
await delete_setting(uid, k)
|
|
else:
|
|
to_save[k] = str_v
|
|
|
|
if to_save:
|
|
await set_settings_batch(uid, to_save)
|
|
|
|
settings = await get_all_settings(uid)
|
|
return jsonify(_masked(settings))
|
|
|
|
|
|
@settings_bp.route("/search", methods=["GET"])
|
|
@login_required
|
|
async def test_search():
|
|
"""Report SearXNG configuration status (used by the Integrations tab)."""
|
|
if not Config.searxng_enabled():
|
|
return jsonify({"configured": False, "results": [], "searxng_url": ""})
|
|
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
|
|
|
|
|
|
# --- forge connections (#2778) ------------------------------------------------
|
|
# The user's keyring: read-only forge credentials, one per host, resolved by
|
|
# repo host for every server-side forge read on the user's projects. Strictly
|
|
# own-rows — a connection is a credential, and there is no admin view of
|
|
# another user's keyring. Tokens never leave the server: the model's to_dict
|
|
# omits them, and the routes never echo the submitted value back.
|
|
|
|
|
|
@settings_bp.route("/forge-connections", methods=["GET"])
|
|
@login_required
|
|
async def list_forge_connections_route():
|
|
from scribe.services.forge import FORGE_KINDS
|
|
from scribe.services.forge_connections import list_connections
|
|
|
|
uid = get_current_user_id()
|
|
rows = await list_connections(uid)
|
|
return jsonify({
|
|
"connections": [r.to_dict() for r in rows],
|
|
"kinds": list(FORGE_KINDS),
|
|
})
|
|
|
|
|
|
@settings_bp.route("/forge-connections", methods=["POST"])
|
|
@login_required
|
|
async def create_forge_connection_route():
|
|
from scribe.services.forge_connections import create_connection
|
|
|
|
uid = get_current_user_id()
|
|
data = await request.get_json() or {}
|
|
try:
|
|
row = await create_connection(
|
|
uid,
|
|
kind=str(data.get("kind", "")),
|
|
base_url=str(data.get("base_url", "")),
|
|
token=str(data.get("token", "")),
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
return jsonify(row.to_dict()), 201
|
|
|
|
|
|
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["PUT"])
|
|
@login_required
|
|
async def update_forge_connection_route(connection_id: int):
|
|
from scribe.services.forge_connections import update_connection
|
|
|
|
uid = get_current_user_id()
|
|
data = await request.get_json() or {}
|
|
token = str(data.get("token", ""))
|
|
# The mask coming back means "unchanged" — the form round-trips what the
|
|
# list showed, and storing the mask would silently break the connection.
|
|
if token == SECRET_MASK:
|
|
token = ""
|
|
try:
|
|
row = await update_connection(
|
|
uid, connection_id,
|
|
kind=str(data.get("kind", "")),
|
|
base_url=str(data.get("base_url", "")),
|
|
token=token,
|
|
)
|
|
except ValueError as exc:
|
|
return jsonify({"error": str(exc)}), 400
|
|
if row is None:
|
|
return jsonify({"error": "Connection not found"}), 404
|
|
return jsonify(row.to_dict())
|
|
|
|
|
|
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["DELETE"])
|
|
@login_required
|
|
async def delete_forge_connection_route(connection_id: int):
|
|
from scribe.services.forge_connections import delete_connection
|
|
|
|
uid = get_current_user_id()
|
|
if not await delete_connection(uid, connection_id):
|
|
return jsonify({"error": "Connection not found"}), 404
|
|
return "", 204
|
|
|
|
|
|
@settings_bp.route("/forge-connections/<int:connection_id>/test", methods=["POST"])
|
|
@login_required
|
|
async def test_forge_connection_route(connection_id: int):
|
|
"""Probe the SAVED connection: reachability and token acceptance in one
|
|
press, so a misconfiguration is visible now rather than as silent
|
|
fallbacks later (#2663's lesson, applied per keyring row)."""
|
|
from scribe.services.forge import ForgeError, build_adapter
|
|
from scribe.services.forge_connections import get_connection
|
|
|
|
uid = get_current_user_id()
|
|
row = await get_connection(uid, connection_id)
|
|
if row is None:
|
|
return jsonify({"error": "Connection not found"}), 404
|
|
adapter = build_adapter(row.kind, row.base_url, row.token)
|
|
if adapter is None:
|
|
return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400
|
|
try:
|
|
return jsonify(await adapter.check())
|
|
except ForgeError as exc:
|
|
return jsonify({"error": str(exc)}), 502
|