"""Configuration editor page.""" import copy import os from typing import Any, Dict import streamlit as st import toml from ui.config import get_default_config, load_config, save_config # --------------------------------------------------------------------------- # Constants shared with the main app # --------------------------------------------------------------------------- WORKFLOW_ALIASES: Dict[str, str] = { "python_repl": "python_relp", "graspa_agent": "graspa", } WORKFLOW_OPTIONS: list[str] = [ "single_agent", "multi_agent", "python_relp", "graspa", "mock_agent", ] def normalize_workflow_name(value: str) -> str: """Normalize workflow aliases to internal workflow names. Parameters ---------- value : str Workflow name or alias from configuration/UI state. Returns ------- str Canonical workflow name. """ if not value: return value return WORKFLOW_ALIASES.get(value, value) def get_model_options(config: Dict[str, Any]) -> list: """Return model options for the configuration UI. Parameters ---------- config : dict[str, Any] Nested UI configuration dictionary. Returns ------- list Model names shown in the model selector. """ from chemgraph.utils.config_utils import get_model_options_for_nested_config return get_model_options_for_nested_config(config) # --------------------------------------------------------------------------- # Page entry point # --------------------------------------------------------------------------- def render() -> None: """Render the Configuration page.""" st.title("\u2699\ufe0f Configuration") st.markdown( """ Edit and manage your ChemGraph configuration settings. Changes only take effect when you click **Save Configuration**. """ ) # Ensure config exists in session state if "config" not in st.session_state: st.session_state.config = load_config() # Work on a draft copy so widgets never mutate the live config. # The draft is written back to st.session_state.config only on Save. if "_config_draft" not in st.session_state: st.session_state._config_draft = copy.deepcopy(st.session_state.config) draft = st.session_state._config_draft # ----- Tabs ----- tab1, tab2, tab3 = st.tabs( ["\U0001f527 General Settings", "\U0001f517 API Settings", "\U0001f4dd Raw TOML"] ) with tab1: _render_general_settings(draft) with tab2: _render_api_settings(draft) with tab3: _render_raw_toml(draft) # ----- Action buttons ----- _render_action_buttons(draft) # ----- Summary ----- _render_config_summary(draft) # --------------------------------------------------------------------------- # Internal renderers # --------------------------------------------------------------------------- def _render_general_settings(config: dict) -> None: """Render and update general configuration widgets. Parameters ---------- config : dict Mutable draft configuration dictionary. """ st.subheader("General Settings") col1, col2 = st.columns(2) with col1: st.write("**Model & Workflow**") model_options = get_model_options(config) config["general"]["model"] = st.selectbox( "Model", model_options, index=( model_options.index(config["general"]["model"]) if config["general"]["model"] in model_options else 0 ), key="config_model", ) config_custom_model = st.text_input( "Custom model ID (optional)", value="", key="config_custom_model", help="Enter any provider/model identifier not listed above.", ).strip() if config_custom_model: config["general"]["model"] = config_custom_model config["general"]["workflow"] = normalize_workflow_name( config["general"]["workflow"] ) config["general"]["workflow"] = st.selectbox( "Workflow", WORKFLOW_OPTIONS, index=( WORKFLOW_OPTIONS.index(config["general"]["workflow"]) if config["general"]["workflow"] in WORKFLOW_OPTIONS else 0 ), key="config_workflow", ) config["general"]["output"] = st.selectbox( "Output Format", ["state", "last_message"], index=( ["state", "last_message"].index(config["general"]["output"]) if config["general"]["output"] in ["state", "last_message"] else 0 ), key="config_output", ) config["general"]["structured"] = st.checkbox( "Structured Output", value=config["general"]["structured"], key="config_structured", ) config["general"]["report"] = st.checkbox( "Generate Report", value=config["general"]["report"], key="config_report", ) config["general"]["human_supervised"] = st.checkbox( "Human Supervised", value=config["general"].get("human_supervised", False), key="config_human_supervised", help="Enable the ask_human tool so the agent can pause and request human input.", ) config["general"]["verbose"] = st.checkbox( "Verbose Output", value=config["general"]["verbose"], key="config_verbose", ) with col2: st.write("**Execution Settings**") config["general"]["thread"] = st.number_input( "Thread ID", min_value=1, max_value=1000, value=config["general"]["thread"], key="config_thread", ) config["general"]["recursion_limit"] = st.number_input( "Recursion Limit", min_value=1, max_value=100, value=config["general"]["recursion_limit"], key="config_recursion", ) st.subheader("Chemistry Settings") col3, col4 = st.columns(2) with col3: st.write("**Optimization**") config["chemistry"]["optimization"]["method"] = st.selectbox( "Method", ["BFGS", "L-BFGS-B", "CG", "Newton-CG"], index=( ["BFGS", "L-BFGS-B", "CG", "Newton-CG"].index( config["chemistry"]["optimization"]["method"] ) if config["chemistry"]["optimization"]["method"] in ["BFGS", "L-BFGS-B", "CG", "Newton-CG"] else 0 ), key="config_opt_method", ) config["chemistry"]["optimization"]["fmax"] = st.number_input( "Force Max (eV/\u00c5)", min_value=0.001, max_value=1.0, value=config["chemistry"]["optimization"]["fmax"], format="%.3f", key="config_fmax", ) config["chemistry"]["optimization"]["steps"] = st.number_input( "Max Steps", min_value=1, max_value=1000, value=config["chemistry"]["optimization"]["steps"], key="config_steps", ) with col4: st.write("**Calculators**") calc_options = [ "mace_mp", "mace_off", "mace_anicc", "fairchem", "aimnet2", "emt", "tblite", "orca", "nwchem", ] config["chemistry"]["calculators"]["default"] = st.selectbox( "Default Calculator", calc_options, index=( calc_options.index(config["chemistry"]["calculators"]["default"]) if config["chemistry"]["calculators"]["default"] in calc_options else 0 ), key="config_calc_default", ) config["chemistry"]["calculators"]["fallback"] = st.selectbox( "Fallback Calculator", calc_options, index=( calc_options.index(config["chemistry"]["calculators"]["fallback"]) if config["chemistry"]["calculators"]["fallback"] in calc_options else 1 ), key="config_calc_fallback", ) def _render_api_settings(config: dict) -> None: """Render and update API configuration widgets. Parameters ---------- config : dict Mutable draft configuration dictionary. """ st.subheader("API Settings") st.markdown("**API Keys (Session Only)**") st.caption( "Keys entered here are applied to this Streamlit session via environment " "variables and are not saved to config.toml." ) st.warning( "**Shared deployments:** API keys are set as process-wide environment " "variables. On multi-user Streamlit servers, keys set here may be " "visible to other sessions in the same process. For shared " "deployments, configure keys via server-side environment variables " "instead.", icon="\u26a0\ufe0f", ) key_col1, key_col2, key_col3 = st.columns(3) with key_col1: openai_api_key = st.text_input( "OpenAI API Key", value=st.session_state.get("ui_openai_api_key", ""), type="password", key="ui_openai_api_key_input", ) anthropic_api_key = st.text_input( "Anthropic API Key", value=st.session_state.get("ui_anthropic_api_key", ""), type="password", key="ui_anthropic_api_key_input", ) with key_col2: gemini_api_key = st.text_input( "Gemini API Key", value=st.session_state.get("ui_gemini_api_key", ""), type="password", key="ui_gemini_api_key_input", ) groq_api_key = st.text_input( "Groq API Key", value=st.session_state.get("ui_groq_api_key", ""), type="password", key="ui_groq_api_key_input", ) with key_col3: alcf_access_token = st.text_input( "ALCF Access Token", value=st.session_state.get("ui_alcf_access_token", ""), type="password", key="ui_alcf_access_token_input", help=( "Globus OAuth access token for ALCF inference endpoints. " "See https://docs.alcf.anl.gov/services/inference-endpoints/#api-access" ), ) key_env_map = { "OPENAI_API_KEY": openai_api_key, "ANTHROPIC_API_KEY": anthropic_api_key, "GEMINI_API_KEY": gemini_api_key, "GROQ_API_KEY": groq_api_key, "ALCF_ACCESS_TOKEN": alcf_access_token, } action_col1, action_col2 = st.columns(2) with action_col1: if st.button("Apply API Keys", key="apply_api_keys"): applied = [] for env_name, key_value in key_env_map.items(): clean_value = key_value.strip() if clean_value: os.environ[env_name] = clean_value st.session_state[f"ui_{env_name.lower()}"] = clean_value applied.append(env_name) if applied: st.success(f"\u2705 Applied keys for: {', '.join(applied)}") else: st.info("No API keys entered.") with action_col2: if st.button("Clear Session API Keys", key="clear_api_keys"): for env_name in key_env_map: os.environ.pop(env_name, None) st.session_state.pop(f"ui_{env_name.lower()}", None) st.session_state.pop(f"ui_{env_name.lower()}_input", None) st.success("\u2705 Cleared session API keys.") st.rerun() st.markdown("---") api_tabs = st.tabs(["OpenAI", "Anthropic", "Google", "ALCF", "Local"]) with api_tabs[0]: config["api"]["openai"]["base_url"] = st.text_input( "Base URL", value=config["api"]["openai"]["base_url"], key="config_openai_url", ) config["api"]["openai"]["argo_user"] = st.text_input( "Argo User (optional)", value=config["api"]["openai"].get("argo_user", ""), key="config_openai_argo_user", help="ANL domain username for Argo requests. If blank, ChemGraph falls back to ARGO_USER env var.", ) config["api"]["openai"]["timeout"] = st.number_input( "Timeout (seconds)", min_value=1, max_value=300, value=config["api"]["openai"]["timeout"], key="config_openai_timeout", ) with api_tabs[1]: config["api"]["anthropic"]["base_url"] = st.text_input( "Base URL", value=config["api"]["anthropic"]["base_url"], key="config_anthropic_url", ) config["api"]["anthropic"]["timeout"] = st.number_input( "Timeout (seconds)", min_value=1, max_value=300, value=config["api"]["anthropic"]["timeout"], key="config_anthropic_timeout", ) with api_tabs[2]: config["api"]["google"]["base_url"] = st.text_input( "Base URL", value=config["api"]["google"]["base_url"], key="config_google_url", ) config["api"]["google"]["timeout"] = st.number_input( "Timeout (seconds)", min_value=1, max_value=300, value=config["api"]["google"]["timeout"], key="config_google_timeout", ) with api_tabs[3]: alcf_section = config["api"].setdefault("alcf", {}) alcf_section["base_url"] = st.text_input( "Base URL", value=alcf_section.get( "base_url", "https://inference-api.alcf.anl.gov/resource_server/sophia/vllm/v1", ), key="config_alcf_url", help="ALCF inference endpoint (vLLM-compatible). Default points to the Sophia cluster.", ) alcf_section["timeout"] = st.number_input( "Timeout (seconds)", min_value=1, max_value=300, value=alcf_section.get("timeout", 30), key="config_alcf_timeout", ) st.caption( "ALCF uses Globus OAuth for authentication. Set the **ALCF Access Token** " "in the API Keys section above, or export `ALCF_ACCESS_TOKEN` in your shell." ) with api_tabs[4]: config["api"]["local"]["base_url"] = st.text_input( "Base URL", value=config["api"]["local"]["base_url"], key="config_local_url", ) config["api"]["local"]["timeout"] = st.number_input( "Timeout (seconds)", min_value=1, max_value=300, value=config["api"]["local"]["timeout"], key="config_local_timeout", ) def _render_raw_toml(config: dict) -> None: """Render raw TOML editor for the draft configuration. Parameters ---------- config : dict Mutable draft configuration dictionary. """ st.subheader("Raw TOML Configuration") st.markdown( """ Edit the raw TOML configuration directly. Be careful with syntax! """ ) try: config_text = toml.dumps(config) except Exception as e: st.error(f"Error serializing config: {e}") config_text = "" edited_config = st.text_area( "TOML Content", value=config_text, height=400, key="config_raw_toml" ) if st.button("\U0001f4dd Update from TOML", key="update_from_toml"): try: new_config = toml.loads(edited_config) # Update the draft, not the live config. The user must still # click "Save Configuration" to persist and apply the changes. st.session_state._config_draft = new_config st.success( "\u2705 Draft updated from TOML. " "Click **Save Configuration** to apply." ) st.rerun() except Exception as e: st.error(f"\u274c Invalid TOML syntax: {e}") def _render_action_buttons(config: dict) -> None: """Render save/reload/reset/download configuration actions. Parameters ---------- config : dict Mutable draft configuration dictionary. """ st.markdown("---") col1, col2, col3, col4 = st.columns(4) with col1: if st.button("\U0001f4be Save Configuration", type="primary"): # Apply the draft to the live session config, then persist to disk. st.session_state.config = copy.deepcopy(config) if save_config(st.session_state.config): st.success("\u2705 Configuration saved to config.toml!") else: st.error("\u274c Failed to save configuration") with col2: if st.button("\U0001f504 Reload Configuration"): st.session_state.config = load_config() st.session_state._config_draft = copy.deepcopy(st.session_state.config) st.success("\u2705 Configuration reloaded!") st.rerun() with col3: if st.button("\U0001f5d1\ufe0f Reset to Defaults"): st.session_state.config = get_default_config() st.session_state._config_draft = copy.deepcopy(st.session_state.config) st.success("\u2705 Configuration reset to defaults!") st.rerun() with col4: try: config_download = toml.dumps(config) st.download_button( "\U0001f4e5 Download TOML", config_download, "config.toml", mime="application/toml", ) except Exception as e: st.error(f"Error preparing download: {e}") def _render_config_summary(config: dict) -> None: """Render a compact summary of the draft configuration. Parameters ---------- config : dict Draft configuration dictionary. """ with st.expander("\U0001f4ca Configuration Summary", expanded=False): st.write("**Current Configuration:**") st.write(f"- Model: {config['general']['model']}") st.write(f"- Workflow: {config['general']['workflow']}") st.write("- Temperature: 0.0 (optimized for tool calling)") st.write("- Max Tokens: 4000") st.write( f"- Default Calculator: {config['chemistry']['calculators']['default']}" ) st.write("**Environment Variables:**") api_keys = { "OPENAI_API_KEY": "OpenAI", "ANTHROPIC_API_KEY": "Anthropic", "GEMINI_API_KEY": "Google", "GROQ_API_KEY": "Groq", "ALCF_ACCESS_TOKEN": "ALCF", } for env_var, provider in api_keys.items(): if os.getenv(env_var): st.write(f"- {provider}: \u2705 Set") else: st.write(f"- {provider}: \u274c Not set")