From a998b2c714d4c8a42eae3d2a3ac7ac43a351e029 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 23 Sep 2026 14:12:57 -0400 Subject: [PATCH] Read style LLM credentials from docassemble Configuration dayamlchecker only looks for an API key in its CLI flags or the OPENAI_API_KEY environment variable, so "Style check + AI" reported a missing key on servers configured with an `open ai:` block or the older `openai api key`. Resolve key, base URL and model from the Configuration with the same precedence as ALToolbox.llms and pass them through RuntimeOptions. Co-Authored-By: Claude Opus 5.5 --- docassemble/ALDashboard/interview_linter.py | 41 +++++++++++++++++++ .../ALDashboard/test/test_interview_linter.py | 36 ++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/docassemble/ALDashboard/interview_linter.py b/docassemble/ALDashboard/interview_linter.py index 4dd4afd..109e0a4 100644 --- a/docassemble/ALDashboard/interview_linter.py +++ b/docassemble/ALDashboard/interview_linter.py @@ -46,6 +46,13 @@ def log(*pargs: Any, **kwargs: Any) -> None: # type: ignore except Exception: user_info = None # type: ignore +try: + from docassemble.base.util import get_config +except Exception: + + def get_config(key: str, default: Any = None) -> Any: # type: ignore + return default + def _resolve_current_user_id() -> Optional[int]: try: @@ -585,13 +592,47 @@ def _dayaml_runtime_options( ) -> Any: if _DAYamlRuntimeOptions is None: return None + llm_settings = _style_llm_settings() if include_style_llm else {} return _DAYamlRuntimeOptions( accessibility_error_on_widgets=frozenset({"combobox"}), style_enabled=include_style or include_style_llm, style_include_llm=include_style_llm, + style_openai_api_key=llm_settings.get("style_openai_api_key"), + style_openai_base_url=llm_settings.get("style_openai_base_url"), + style_openai_model=llm_settings.get("style_openai_model"), ) +def _style_llm_settings() -> Dict[str, Optional[str]]: + """Read the OpenAI-compatible credentials from the docassemble Configuration. + + dayamlchecker only looks at its own flags and the OPENAI_* environment + variables, so a server configured the docassemble way (an ``open ai:`` + block, or the older top-level ``openai api key``) would otherwise report a + missing API key. The precedence matches docassemble.ALToolbox.llms: an + OPENAI_API_KEY in the environment wins, then ``open ai: key``, then + ``openai api key``. + """ + open_ai_config = get_config("open ai", {}) or {} + if not isinstance(open_ai_config, Mapping): + open_ai_config = {} + api_key = None + if not os.getenv("OPENAI_API_KEY"): + api_key = open_ai_config.get("key") or get_config("openai api key") + base_url = open_ai_config.get("base url") or get_config("openai base url") + model = ( + open_ai_config.get("default small model") + or get_config("openai default small model") + or open_ai_config.get("default model") + or get_config("openai default model") + ) + return { + "style_openai_api_key": _stringify(api_key) or None, + "style_openai_base_url": _stringify(base_url) or None, + "style_openai_model": _stringify(model) or None, + } + + def _collect_dayamlchecker_findings( content: str, *, diff --git a/docassemble/ALDashboard/test/test_interview_linter.py b/docassemble/ALDashboard/test/test_interview_linter.py index fa9ed72..3c47eab 100644 --- a/docassemble/ALDashboard/test/test_interview_linter.py +++ b/docassemble/ALDashboard/test/test_interview_linter.py @@ -4,6 +4,7 @@ import tempfile import os +from docassemble.ALDashboard import interview_linter from docassemble.ALDashboard.interview_linter import ( get_all_text, get_misspelled_words, @@ -181,6 +182,41 @@ def _findings(self, yaml_content, lint_mode="full"): class TestInterviewLinterStyleDelegation(unittest.TestCase): + def test_style_llm_credentials_come_from_open_ai_config(self): + config = { + "open ai": { + "key": "sk-config", + "base url": "https://llm.example.com/v1", + "default model": "small-model", + } + } + with ( + patch( + "docassemble.ALDashboard.interview_linter.get_config", + side_effect=lambda key, default=None: config.get(key, default), + ), + patch.dict("os.environ", {}, clear=False), + ): + os.environ.pop("OPENAI_API_KEY", None) + options = interview_linter._dayaml_runtime_options(include_style_llm=True) + self.assertEqual(options.style_openai_api_key, "sk-config") + self.assertEqual(options.style_openai_base_url, "https://llm.example.com/v1") + self.assertEqual(options.style_openai_model, "small-model") + + def test_style_llm_credentials_accept_legacy_openai_api_key(self): + config = {"openai api key": "sk-legacy"} + with ( + patch( + "docassemble.ALDashboard.interview_linter.get_config", + side_effect=lambda key, default=None: config.get(key, default), + ), + patch.dict("os.environ", {}, clear=False), + ): + os.environ.pop("OPENAI_API_KEY", None) + options = interview_linter._dayaml_runtime_options(include_style_llm=True) + self.assertEqual(options.style_openai_api_key, "sk-legacy") + self.assertIsNone(options.style_openai_base_url) + @patch("docassemble.ALDashboard.interview_linter._collect_dayamlchecker_findings") def test_ai_style_checks_are_delegated_to_dayamlchecker(self, mock_collect): mock_collect.side_effect = [[FakeDAYamlFinding()], []]