From 0f32ba036f928937a4ccf42ee02d226a139c7fa0 Mon Sep 17 00:00:00 2001 From: Quant Forge contributors Date: Thu, 16 Jul 2026 20:48:55 -0700 Subject: [PATCH 1/4] feat(web): runtime LLM provider switching + API key intake from the frontend New POST /api/settings/llm (control-token gated like every POST): - registers/updates a provider at runtime: YAML-configured entries and the built-in presets (deepseek/openai/glm/claude/minimax/openai_compatible) are both offered, so enabling a provider no longer requires a config edit; - optional api_key is injected into the server process environment only (the client resolves keys from env per call, so it takes effect on the next request); the value is never persisted, logged, or echoed back -- durable keys keep the documented gitignored configs/*.local.env path; - activate switches the active default provider; the routing closure swaps its frozen config snapshot atomically and hands the replacement to the sidecar tool registry so every surface agrees; - web-writable base_url is scheme-restricted to http(s) (urlopen would otherwise accept file://). Frontend: the previously inert #llm-api-key input (not-submitted policy) becomes the intake path -- password field, cleared on save, never stored client-side; preset entries surface model/base_url inputs when the preset has no default; runtime strip re-hydrates from the save response so loopback binds (which never re-fetch /api/status) stay correct. Tests: tests/test_web_llm_settings.py pins the security contract (no key in any response, no key on workspace disk, 401 without token, 400 taxonomy, alias mapping, preset metadata-only). Workbench pins updated to the new data-secret-policy=runtime-memory-only stance; integration docs updated. --- docs/full_integration_test_prompt.md | 6 +- docs/integration_workflow.md | 7 +- src/quant_forge/apps/web/api.py | 153 +++++++++++++- src/quant_forge/apps/web/html.py | 20 +- src/quant_forge/apps/web/routing.py | 24 +++ src/quant_forge/apps/web/server.py | 1 + src/quant_forge/apps/web/static/app.js | 131 +++++++++--- src/quant_forge/apps/web/tools.py | 11 ++ src/quant_forge/config.py | 52 +++++ src/quant_forge/llm_client.py | 27 +++ tests/test_web_llm_settings.py | 263 +++++++++++++++++++++++++ tests/test_web_workbench.py | 14 +- 12 files changed, 672 insertions(+), 37 deletions(-) create mode 100644 tests/test_web_llm_settings.py diff --git a/docs/full_integration_test_prompt.md b/docs/full_integration_test_prompt.md index 3478360..c3315fd 100644 --- a/docs/full_integration_test_prompt.md +++ b/docs/full_integration_test_prompt.md @@ -152,8 +152,10 @@ provider 已识别、key env 已继承但真实 key 未打印、各数据/因子 - LLM API Key 控件(`#llm-api-key-mode`): - “配置文件 / 环境变量加载”模式 → 输入框 `#llm-api-key` 置灰(disabled),**不显示 真实 key**; - - “手动输入(仅前端联调)”模式 → 输入框才可编辑;手动输入内容**仅前端使用、不持久化**, - 绝不写入配置、日志、artifact 或报告(`data-secret-policy="not-submitted"`)。 + - “前端输入(注入本次运行)”模式 → 输入框才可编辑;点「保存并启用」(`#llm-settings-save`) + 经 `POST /api/settings/llm` 注入服务进程环境变量,**仅存内存、不落盘、任何响应不回显**, + 重启后失效(`data-secret-policy="runtime-memory-only"`);内置预设(openai/glm/claude/ + minimax/openai_compatible)可在此注册启用,缺省 model 的预设需补填模型名。 - 控制令牌门控 UX(token-gating):需要令牌时页面提示明确,粘贴/预置后功能解锁。 ### 4.2 单因子研究 / single-factor module(真实 DeepSeek) diff --git a/docs/integration_workflow.md b/docs/integration_workflow.md index 738f622..ffb08c6 100644 --- a/docs/integration_workflow.md +++ b/docs/integration_workflow.md @@ -334,8 +334,11 @@ API/HTTP 调用只允许核对后端 contract、artifact 路径、日期窗口 - 默认模式应为“配置文件 / 环境变量加载”。 - 如果 provider key 已通过配置和环境变量加载,password 输入框必须置灰。 - 页面不得显示真实 API key,只能显示环境变量名或状态说明。 - - 切换到“手动输入(仅前端联调)”时,输入框才可编辑。 - - 手动输入的内容不得进入 parse/validate 请求体、日志、artifact 或报告;正式调用仍使用后端配置的环境变量。 + - 切换到“前端输入(注入本次运行)”时,输入框才可编辑;点击“保存并启用”后密钥经 + `POST /api/settings/llm`(受控制令牌门控)注入服务进程环境变量,即时生效。 + - 密钥仅存在于运行中进程的内存,不写入磁盘、配置、日志、artifact 或报告,任何响应 + (含 `/api/status`)都不得回显密钥值;重启后失效,持久化仍走 `configs/*.local.env`。 + - 手动输入的密钥不得进入 parse/validate 请求体;正式调用仍从后端环境变量读取。 5. 输入自然语言因子观点,点击“解析因子”。 6. 解析完成后应只展示 factor 草稿和待确认参数,不应立即执行评价/回测。 7. 检查评测参数区是否自动填入默认值: diff --git a/src/quant_forge/apps/web/api.py b/src/quant_forge/apps/web/api.py index 3ff4d45..2fd30ae 100644 --- a/src/quant_forge/apps/web/api.py +++ b/src/quant_forge/apps/web/api.py @@ -18,7 +18,7 @@ import re import threading from typing import Any -from urllib.parse import parse_qs, unquote +from urllib.parse import parse_qs, unquote, urlparse from quant_forge.apps.web.jobs import _IdeaValidationSettings, _WebJobCancelled, _client_error_message from quant_forge.apps.web.markdown import extract_markdown_title, render_markdown_html @@ -29,7 +29,12 @@ _staggered_warnings_count, ) from quant_forge.backtesting.service import EXTERNAL_OOS_ROLE -from quant_forge.config import QuantForgeConfig, simulation_profile_from_mapping, validate_llm_runtime +from quant_forge.config import ( + QuantForgeConfig, + llm_settings_with_provider_update, + simulation_profile_from_mapping, + validate_llm_runtime, +) from quant_forge.core.contracts import ( BacktestResult, EvaluationResult, @@ -2435,13 +2440,37 @@ def _paths_payload(config: QuantForgeConfig) -> dict[str, str]: def _llm_provider_options(config: QuantForgeConfig) -> tuple[dict[str, str], ...]: + from quant_forge.llm_client import builtin_provider_presets + options: list[dict[str, str]] = [] + configured_names: set[str] = set() for option in config.llm.public_provider_options(): + configured_names.add(option["provider"]) runtime_ready, runtime_error = _llm_runtime_status(config, option["provider"]) enriched = dict(option) + enriched["configured"] = "true" enriched["runtime_ready"] = "true" if runtime_ready else "false" enriched["runtime_error"] = runtime_error options.append(enriched) + # Built-in presets not declared in YAML: offered so the frontend can + # register them at runtime via /api/settings/llm without a config edit. + # Only metadata travels here — the env var NAME, never key material. + for preset in builtin_provider_presets(): + if preset["provider"] in configured_names: + continue + key_present = bool(os.environ.get(preset["api_key_env"], "")) + options.append( + { + **preset, + "configured": "false", + "runtime_ready": "false", + "runtime_error": ( + "preset is not enabled yet; save LLM settings to enable it" + if key_present + else "preset is not enabled yet; save LLM settings with an API key to enable it" + ), + } + ) return tuple(options) @@ -2457,6 +2486,126 @@ def _active_llm(config: QuantForgeConfig) -> Any: return config.llm.select_provider() +_LLM_API_KEY_MAX_CHARS = 512 + + +def apply_llm_settings_update( + config: QuantForgeConfig, payload: dict[str, Any] +) -> tuple[QuantForgeConfig, dict[str, Any]]: + """Apply a runtime LLM settings update from ``POST /api/settings/llm``. + + Semantics (all in-process, nothing is written to disk): + + * ``provider`` (required) — a YAML-configured provider or a built-in + preset; presets are registered into the runtime config on first save. + * ``model`` / ``base_url`` (optional) — override or complete the entry; + the merged entry passes the same validation as YAML-loaded providers. + * ``api_key`` (optional) — injected into ``os.environ[api_key_env]`` so + the very next LLM call picks it up (the client reads the key from the + environment per call). The key value is never echoed, logged, or + persisted; a restart clears it. For a durable key, users keep the + documented path: a git-ignored ``configs/*.local.env``. + * ``activate`` (default true) — make this provider the active default + (used by RD optimization and the sidecar readiness probe; parse jobs + already choose per request). + + Returns the replacement frozen config plus a response payload shaped like + the ``llm`` section of ``/api/status`` so the frontend can re-hydrate from + the response alone (loopback binds never re-fetch ``/api/status``). + """ + + from quant_forge.llm_client import builtin_provider_presets, canonical_provider_name + + provider_raw = str(payload.get("provider", "") or "").strip() + if not provider_raw: + raise ValueError("provider is required") + name = canonical_provider_name(provider_raw) + if name in {"rule", "deterministic"}: + raise ValueError("provider 'rule' is built in and cannot be edited") + + model = str(payload.get("model", "") or "").strip() + base_url = str(payload.get("base_url", "") or "").strip() + activate = _bool_parameter(payload.get("activate", True), "activate") + + presets = {preset["provider"]: preset for preset in builtin_provider_presets()} + api_key_env = "" + if name not in config.llm.providers: + preset = presets.get(name) + if preset is None: + known = ", ".join(sorted(set(config.llm.providers) | set(presets))) + raise ValueError(f"unknown LLM provider: {provider_raw}. Known providers: {known}.") + model = model or preset["model"] + base_url = base_url or preset["base_url"] + api_key_env = preset["api_key_env"] + + if base_url: + _require_http_base_url(base_url) + + new_llm = llm_settings_with_provider_update( + config.llm, + provider=name, + model=model, + base_url=base_url, + api_key_env=api_key_env, + activate=activate, + ) + selected = new_llm.select_provider(name) + + key_updated = False + api_key = payload.get("api_key") + if api_key is not None and str(api_key) != "": + credential = str(api_key) + _validate_api_key_shape(credential) + env_name = str(selected.api_key_env).strip() + if not env_name: + raise ValueError( + f"provider {name} does not declare api_key_env; cannot accept an API key" + ) + # In-process only, by design: the LLM client resolves the key from the + # environment on every call, so this takes effect immediately and + # vanishes on restart. Never logged, never in any response payload. + os.environ[env_name] = credential + key_updated = True + + new_config = replace(config, llm=new_llm) + active = new_config.llm.select_provider() + response = { + "ok": True, + "key_updated": key_updated, + "llm": { + "provider": active.provider, + "model": active.model, + "api_key_env": active.api_key_env, + "providers": _llm_provider_options(new_config), + }, + } + return new_config, response + + +def _validate_api_key_shape(credential: str) -> None: + """Shape-only checks; error text never includes the submitted value.""" + + if len(credential) > _LLM_API_KEY_MAX_CHARS: + raise ValueError(f"api_key is too long (max {_LLM_API_KEY_MAX_CHARS} characters)") + if any(character.isspace() for character in credential): + raise ValueError("api_key must not contain whitespace") + if any(not character.isprintable() for character in credential): + raise ValueError("api_key must not contain non-printable characters") + + +def _require_http_base_url(base_url: str) -> None: + """The web write path only accepts http(s) endpoints. + + ``urllib.request.urlopen`` would happily open other schemes (``file://`` + included), so a runtime-writable base_url must be scheme-restricted here + even though YAML-configured values are the host owner's own business. + """ + + scheme = urlparse(base_url).scheme.lower() + if scheme not in {"http", "https"}: + raise ValueError("base_url must start with http:// or https://") + + def _rd_status_payload(config: QuantForgeConfig, rd_config: ResearchLoopConfig) -> dict[str, str]: active_llm = _active_llm(config) return { diff --git a/src/quant_forge/apps/web/html.py b/src/quant_forge/apps/web/html.py index 89a579d..906c139 100644 --- a/src/quant_forge/apps/web/html.py +++ b/src/quant_forge/apps/web/html.py @@ -44,8 +44,14 @@ def _provider_options_script_payload(options: tuple[dict[str, str], ...]) -> tup return tuple( { "provider": option.get("provider", ""), + "model": option.get("model", ""), + # boolean flag only: a real URL literal would break the D8 + # no-external-reference sweep on the served page, and the + # frontend only needs to know whether base_url must be typed in. + "hasBaseUrl": "true" if option.get("base_url", "") else "false", "apiKeyEnv": option.get("api_key_env", ""), "runtimeReady": option.get("runtime_ready", "false"), + "configured": option.get("configured", "true"), } for option in options ) @@ -65,6 +71,8 @@ def _page_config_json( def _provider_readiness_label(option: dict[str, str]) -> str: + if option.get("configured") == "false": + return " · 预设未启用" if option.get("runtime_ready") == "true": return " · env " + option["api_key_env"] if option["api_key_env"] else " · no auth" api_key_env = option.get("api_key_env", "") @@ -469,6 +477,9 @@ def _index_html( background: #fff6f6; }} button:disabled {{ opacity: .55; cursor: wait; }} + /* label{{display:block}} / button width rules above would defeat the + [hidden] UA default on the runtime LLM settings controls. */ + label[hidden], input[hidden], button[hidden] {{ display: none; }} code {{ background: #eef5ef; border: 1px solid var(--line); @@ -1165,9 +1176,14 @@ def _index_html( - + + + + + +