From 0a1b5ee89b2096b032334aac637a6f82637449a1 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 30 Sep 2025 12:01:45 -0500 Subject: [PATCH 01/68] add crude keyvault base impl --- application/single_app/config.py | 7 +- .../single_app/functions_appinsights.py | 18 +- .../single_app/functions_global_agents.py | 23 ++ application/single_app/functions_keyvault.py | 252 ++++++++++++++++++ application/single_app/functions_settings.py | 7 +- application/single_app/requirements.txt | 1 + application/single_app/route_backend_chats.py | 1 + .../route_frontend_admin_settings.py | 8 + .../single_app/semantic_kernel_loader.py | 106 ++++++-- 9 files changed, 388 insertions(+), 35 deletions(-) create mode 100644 application/single_app/functions_keyvault.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 5c740776d..a35e3075d 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -158,9 +158,6 @@ else: AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" -# Commercial Azure Video Indexer Endpoint -video_indexer_endpoint = "https://api.videoindexer.ai" - WORD_CHUNK_SIZE = 400 if AZURE_ENVIRONMENT == "usgovernment": @@ -171,6 +168,7 @@ cognitive_services_scope = "https://cognitiveservices.azure.us/.default" video_indexer_endpoint = "https://api.videoindexer.ai.azure.us" search_resource_manager = "https://search.azure.us" + KEY_VAULT_DOMAIN = ".vault.usgovcloudapi.net" elif AZURE_ENVIRONMENT == "custom": resource_manager = CUSTOM_RESOURCE_MANAGER_URL_VALUE @@ -178,6 +176,7 @@ credential_scopes=[resource_manager + "/.default"] cognitive_services_scope = CUSTOM_COGNITIVE_SERVICES_URL_VALUE search_resource_manager = CUSTOM_SEARCH_RESOURCE_MANAGER_URL_VALUE + KEY_VAULT_DOMAIN = os.getenv("KEY_VAULT_DOMAIN", ".vault.azure.net") else: OIDC_METADATA_URL = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0/.well-known/openid-configuration" resource_manager = "https://management.azure.com" @@ -185,6 +184,7 @@ credential_scopes=[resource_manager + "/.default"] cognitive_services_scope = "https://cognitiveservices.azure.com/.default" video_indexer_endpoint = "https://api.videoindexer.ai" + KEY_VAULT_DOMAIN = ".vault.azure.net" def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: """ @@ -205,6 +205,7 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: else: # Default to Azure Public Cloud return f"https://{redis_hostname}.cacheinfra.windows.net:10225/appid" + storage_account_user_documents_container_name = "user-documents" storage_account_group_documents_container_name = "group-documents" diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py index 320f8c5f1..1ec314f95 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -44,6 +44,10 @@ def log_event( exceptionTraceback (Any, optional): If set to True, includes exception traceback. """ try: + # Limit message to 32767 characters + if message and isinstance(message, str) and len(message) > 32767: + message = message[:32767] + # Get logger - use Azure Monitor logger if configured, otherwise standard logger logger = get_appinsights_logger() if not logger: @@ -51,11 +55,11 @@ def log_event( if not logger.handlers: logger.addHandler(logging.StreamHandler()) logger.setLevel(logging.INFO) - + # Enhanced exception handling for Application Insights # When exceptionTraceback=True, ensure we capture full exception context exc_info_to_use = exceptionTraceback - + # For ERROR level logs with exceptionTraceback=True, always log as exception if level >= logging.ERROR and exceptionTraceback: if logger and hasattr(logger, 'exception'): @@ -65,7 +69,7 @@ def log_event( else: # Fallback to standard logging with exc_info exc_info_to_use = True - + # Format message with extra properties for structured logging if extra: # For modern Azure Monitor, extra properties are automatically captured @@ -85,12 +89,12 @@ def log_event( stack_info=includeStack, exc_info=exc_info_to_use ) - + # For Azure Monitor, ensure exception-level logs are properly categorized if level >= logging.ERROR and _azure_monitor_configured: # Add a debug print to verify exception logging is working print(f"[Azure Monitor] Exception logged: {message[:100]}...") - + except Exception as e: # Fallback to basic logging if anything fails try: @@ -98,11 +102,11 @@ def log_event( if not fallback_logger.handlers: fallback_logger.addHandler(logging.StreamHandler()) fallback_logger.setLevel(logging.INFO) - + fallback_message = f"{message} | Original error: {str(e)}" if extra: fallback_message += f" | Extra: {extra}" - + fallback_logger.log(level, fallback_message) except: # If even basic logging fails, print to console diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 9d4e934a1..e6e3e3944 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -14,6 +14,9 @@ from functions_authentication import get_current_user_id from datetime import datetime from config import cosmos_global_agents_container +from functions_keyvault import keyvault_agent_save_helper, store_secret_in_key_vault +from functions_settings import * + def ensure_default_global_agent_exists(): @@ -110,6 +113,19 @@ def get_global_agent(agent_id): item=agent_id, partition_key=agent_id ) + """ Code to retrieve and replace Key Vault secrets if needed + if agent.get("enable_agent_gpt_apim", False): + key_value = agent["azure_openai_gpt_key"] = None # Hide the standard OpenAI key if APIM is enabled + else: + key_value = agent["azure_agent_apim_gpt_subscription_key"] + if validate_secret_name_dynamic(key_value): + # Retrieve the actual key from Key Vault + actual_key = retrieve_secret_from_key_vault(key_value) + if agent.get("enable_agent_gpt_apim", False): + agent["azure_agent_apim_gpt_subscription_key"] = actual_key + else: + agent["azure_openai_gpt_key"] = actual_key + """ print(f"โœ… Found global agent: {agent_id}") return agent except Exception as e: @@ -147,6 +163,13 @@ def save_global_agent(agent_data): extra={"agent_name": agent_data.get('name', 'Unknown')}, ) print(f"๐Ÿ’พ Saving global agent: {agent_data.get('name', 'Unknown')}") + + + settings = get_settings() + if settings.get("enable_key_vault_secret_storage", False): + # Use the new helper to store sensitive agent keys in Key Vault + agent_data = keyvault_agent_save_helper(agent_data, agent_data['id'], scope="global") + result = cosmos_global_agents_container.upsert_item(body=agent_data) log_event( "Global agent saved successfully.", diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py new file mode 100644 index 000000000..674ceba05 --- /dev/null +++ b/application/single_app/functions_keyvault.py @@ -0,0 +1,252 @@ +# functions_keyvault.py + +import re +from config import * +from functions_authentication import * +from functions_settings import * + +try: + from azure.identity import DefaultAzureCredential + from azure.keyvault.secrets import SecretClient +except ImportError as e: + raise ImportError("Required Azure SDK packages are not installed. Please install azure-identity and azure-keyvault-secrets.") from e + +""" +KEY_VAULT_DOMAIN # ENV VAR from config.py +enable_key_vault_secret_storage # setting from functions_settings.py +key_vault_name # setting from functions_settings.py +key_vault_identity # setting from functions_settings.py +""" + +supported_sources = [ + 'model_deployment', + 'speech_service', + 'storage_account', + 'cognitive_service', + 'action', + 'agent' +] + +supported_scopes = [ + 'global', + 'user', + 'group' +] + +def retrieve_secret_from_key_vault(secret_name, scope_value, scope="global", source="global"): + """ + Retrieve a secret from Key Vault using a dynamic name based on source, scope, and scope_value. + + Args: + secret_name (str): The base name of the secret. + scope_value (str): The value for the scope (e.g., user id). + scope (str): The scope (e.g., 'user', 'global'). + source (str): The source (e.g., 'agent', 'plugin'). + + Returns: + str: The value of the retrieved secret. + Raises: + Exception: If retrieval fails or configuration is invalid. + """ + if source not in supported_sources: + raise ValueError(f"Source '{source}' is not supported. Supported sources: {supported_sources}") + if scope not in supported_scopes: + raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + + full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) + return retrieve_secret_by_full_name(full_secret_name) + +def retrieve_secret_from_keyvault_by_full_name(full_secret_name): + """ + Retrieve a secret from Key Vault using a preformatted full secret name. + + Args: + full_secret_name (str): The full secret name (already formatted). + + Returns: + str: The value of the retrieved secret. + Raises: + Exception: If retrieval fails or configuration is invalid. + """ + if not enable_key_vault_secret_storage: + raise Exception("Key Vault secret storage is not enabled.") + + if not key_vault_name: + raise Exception("Key Vault name is not configured.") + + try: + if key_vault_identity: + credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) + else: + credential = DefaultAzureCredential() + key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + secret_client = SecretClient(vault_url=key_vault_url, credential=credential) + + retrieved_secret = secret_client.get_secret(full_secret_name) + print(f"Secret '{full_secret_name}' retrieved successfully from Key Vault.") + return retrieved_secret.value + except Exception as e: + raise Exception(f"Failed to retrieve secret '{full_secret_name}' from Key Vault: {str(e)}") from e + +def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="global", scope="global"): + """ + Store a secret in Key Vault using a dynamic name based on source, scope, and scope_value. + + Args: + secret_name (str): The base name of the secret. + secret_value (str): The value to store in Key Vault. + scope_value (str): The value for the scope (e.g., user id). + source (str): The source (e.g., 'agent', 'plugin'). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + str: The full secret name used in Key Vault. + Raises: + Exception: If storing fails or configuration is invalid. + """ + if not enable_key_vault_secret_storage: + raise Exception("Key Vault secret storage is not enabled.") + + if not key_vault_name: + raise Exception("Key Vault name is not configured.") + + if source not in supported_sources: + raise ValueError(f"Source '{source}' is not supported. Supported sources: {supported_sources}") + if scope not in supported_scopes: + raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + + + full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) + + try: + if key_vault_identity: + credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) + else: + credential = DefaultAzureCredential() + key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + secret_client = SecretClient(vault_url=key_vault_url, credential=credential) + + secret_client.set_secret(full_secret_name, secret_value) + print(f"Secret '{full_secret_name}' stored successfully in Key Vault.") + return full_secret_name + except Exception as e: + raise Exception(f"Failed to store secret '{full_secret_name}' in Key Vault: {str(e)}") from e + +def build_full_secret_name(secret_name, scope_value, source, scope): + """ + Build the full secret name for Key Vault and check its length. + + Args: + secret_name (str): The base name of the secret. + scope_value (str): The value for the scope (e.g., user id). + source (str): The source (e.g., 'agent', 'plugin'). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + str: The constructed full secret name. + Raises: + ValueError: If the name exceeds 127 characters. + """ + full_secret_name = f"{scope_value}_{source}_{scope}_{secret_name}" + if len(full_secret_name) > 127: + raise ValueError(f"The full secret name '{full_secret_name}' exceeds the maximum length of 127 characters.") + return full_secret_name + +def validate_secret_name_dynamic(secret_name): + """ + Validate a Key Vault secret name using a dynamically built regex based on supported scopes and sources. + The secret_name and scope_value can be wildcards, but scope and source must match supported lists. + + Args: + secret_name (str): The full secret name to validate. + + Returns: + bool: True if valid, False otherwise. + """ + # Build regex pattern dynamically + scopes_pattern = '|'.join(re.escape(scope) for scope in supported_scopes) + sources_pattern = '|'.join(re.escape(source) for source in supported_sources) + # Wildcards for secret_name and scope_value + pattern = rf"^(.+)_({sources_pattern})_({scopes_pattern})_(.+)$" + match = re.match(pattern, secret_name) + if not match: + return False + # Optionally, check length + if len(secret_name) > 127: + return False + return True + +def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): + """ + For agent dicts, store sensitive keys in Key Vault and replace their values with the Key Vault secret name. + Only processes 'azure_agent_apim_gpt_subscription_key' and 'azure_openai_gpt_key'. + + Args: + agent_dict (dict): The agent dictionary to process. + scope_value (str): The value for the scope (e.g., agent id). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + dict: A new agent dict with sensitive values replaced by Key Vault references. + Raises: + Exception: If storing a key in Key Vault fails. + """ + source = "agent" + updated = dict(agent_dict) + agent_name = updated.get('name', 'agent') + # Decide which key to store based on enable_agent_gpt_apim + use_apim = updated.get('enable_agent_gpt_apim', False) + if use_apim: + key = 'azure_agent_apim_gpt_subscription_key' + else: + key = 'azure_openai_gpt_key' + + if key in updated and updated[key]: + value = updated[key] + # If already a Key Vault reference, skip (simple heuristic: if value matches secret name pattern) + if not validate_secret_name_dynamic(value): + # Store in Key Vault and replace value with secret name + secret_name = agent_name + try: + full_secret_name = store_secret_in_key_vault(secret_name, value, scope_value, source=source, scope=scope) + updated[key] = full_secret_name + except Exception as e: + raise Exception(f"Failed to store agent key '{key}' in Key Vault: {e}") + return updated + +def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): + """ + For plugin dicts, store the auth.key in Key Vault if auth.type is 'key' or 'servicePrincipal', + and replace its value with the Key Vault secret name. + + Args: + plugin_dict (dict): The plugin dictionary to process. + scope_value (str): The value for the scope (e.g., plugin id). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + dict: A new plugin dict with sensitive values replaced by Key Vault references. + Raises: + Exception: If storing a key in Key Vault fails. + """ + source = "plugin" + updated = dict(plugin_dict) + plugin_name = updated.get('name', 'plugin') + auth = updated.get('auth', {}) + if not isinstance(auth, dict): + return updated + auth_type = auth.get('type', None) + if auth_type in ('key', 'servicePrincipal') and 'key' in auth and auth['key']: + value = auth['key'] + # If already a Key Vault reference, skip + if not validate_secret_name_dynamic(value): + secret_name = plugin_name + try: + full_secret_name = store_secret_in_key_vault(secret_name, value, scope_value, source=source, scope=scope) + # Update the auth dict with the Key Vault reference + new_auth = dict(auth) + new_auth['key'] = full_secret_name + updated['auth'] = new_auth + except Exception as e: + raise Exception(f"Failed to store plugin key in Key Vault: {e}") + return updated \ No newline at end of file diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 712a8d1cd..d1bd47523 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -226,7 +226,12 @@ def get_settings(): "speech_service_endpoint": '', "speech_service_location": '', "speech_service_locale": "en-US", - "speech_service_key": "" + "speech_service_key": "", + + #key vault settings + 'enable_key_vault_secret_storage': False, + 'key_vault_name': '', + 'key_vault_identity': '', } try: diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 4acd23269..09bd3e8fa 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -30,6 +30,7 @@ azure-identity==1.23.0 azure-ai-contentsafety==1.0.0 azure-storage-blob==12.24.1 azure-storage-queue==12.12.0 +azure-keyvault-secrets==4.10.0 pypdf==6.0.0 python-docx==1.1.2 flask-executor==1.0.0 diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 8e6aa1961..fb1d14a5a 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -1328,6 +1328,7 @@ async def run_sk_call(callable_obj, *args, **kwargs): per_user_semantic_kernel = settings.get('per_user_semantic_kernel', False) enable_semantic_kernel = settings.get('enable_semantic_kernel', False) user_enable_agents = user_settings.get('enable_agents', True) # Default to True for backward compatibility + enable_key_vault_secret_storage = settings.get('enable_key_vault_secret_storage', False) redis_client = None # --- Semantic Kernel state management (per-user mode) --- if enable_semantic_kernel and per_user_semantic_kernel: diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 17e46e0b3..de4b6d578 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -191,6 +191,14 @@ def admin_settings(): if 'classification_banner_color' not in settings: settings['classification_banner_color'] = '#ffc107' # Bootstrap warning color + # --- Add defaults for key vault + if 'enable_key_vault_secret_storage' not in settings: + settings['enable_key_vault_secret_storage'] = False + if 'key_vault_name' not in settings: + settings['key_vault_name'] = '' + if 'key_vault_identity' not in settings: + settings['key_vault_identity'] = '' + # --- Add defaults for left nav --- if 'enable_left_nav_default' not in settings: settings['enable_left_nav_default'] = True diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index daadc5876..ffadb38bd 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -30,6 +30,7 @@ import importlib.util import inspect import builtins +from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_keyvault_by_full_name # Agent and Azure OpenAI chat service imports log_event("[SK Loader] Starting loader imports") @@ -107,6 +108,11 @@ def resolve_agent_config(agent, settings): debug_print(f"[SK Loader] user_apim_enabled: {user_apim_enabled}, global_apim_enabled: {global_apim_enabled}, per_user_enabled: {per_user_enabled}") + def resolve_secret_value_if_needed(value, scope_value, source, scope): + if validate_secret_name_dynamic(value): + return retrieve_secret_from_key_vault(value, scope_value, scope, source) + return value + def any_filled(*fields): return any(bool(f) for f in fields) @@ -114,36 +120,88 @@ def all_filled(*fields): return all(bool(f) for f in fields) def get_user_apim(): - return ( - agent.get("azure_apim_gpt_endpoint"), - agent.get("azure_apim_gpt_subscription_key"), - agent.get("azure_apim_gpt_deployment"), - agent.get("azure_apim_gpt_api_version") - ) + endpoint = agent.get("azure_apim_gpt_endpoint") + key = agent.get("azure_apim_gpt_subscription_key") + deployment = agent.get("azure_apim_gpt_deployment") + api_version = agent.get("azure_apim_gpt_api_version") + + # Check if key vault secret storage is enabled in settings + if settings.get("enable_key_vault_secret_storage", False) and key: + try: + if validate_secret_name_dynamic(key): + # Try to retrieve the secret from Key Vault + resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + if resolved_key: + # Update the agent dict with the resolved key for this session + agent["azure_apim_gpt_subscription_key"] = resolved_key + key = resolved_key + except Exception as e: + log_event(f"[SK Loader] Failed to resolve Key Vault secret for agent '{agent.get('name')}' in get_user_apim: {e}", level=logging.ERROR, exceptionTraceback=True) + # Fallback to using the value as-is + return (endpoint, key, deployment, api_version) def get_global_apim(): - return ( - settings.get("azure_apim_gpt_endpoint"), - settings.get("azure_apim_gpt_subscription_key"), - first_if_comma(settings.get("azure_apim_gpt_deployment")), - settings.get("azure_apim_gpt_api_version") - ) + endpoint = settings.get("azure_apim_gpt_endpoint") + key = settings.get("azure_apim_gpt_subscription_key") + deployment = first_if_comma(settings.get("azure_apim_gpt_deployment")) + api_version = settings.get("azure_apim_gpt_api_version") + + # Check if key vault secret storage is enabled in settings + if settings.get("enable_key_vault_secret_storage", False) and key: + try: + if validate_secret_name_dynamic(key): + # Try to retrieve the secret from Key Vault + resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + if resolved_key: + # Update the settings dict with the resolved key for this session + settings["azure_apim_gpt_subscription_key"] = resolved_key + key = resolved_key + except Exception as e: + log_event(f"[SK Loader] Failed to resolve Key Vault secret in get_global_apim: {e}", level=logging.ERROR, exceptionTraceback=True) + # Fallback to using the value as-is + return (endpoint, key, deployment, api_version) def get_user_gpt(): - return ( - agent.get("azure_openai_gpt_endpoint"), - agent.get("azure_openai_gpt_key"), - agent.get("azure_openai_gpt_deployment"), - agent.get("azure_openai_gpt_api_version") - ) + endpoint = agent.get("azure_openai_gpt_endpoint") + key = agent.get("azure_openai_gpt_key") + deployment = agent.get("azure_openai_gpt_deployment") + api_version = agent.get("azure_openai_gpt_api_version") + + # Check if key vault secret storage is enabled in settings + if settings.get("enable_key_vault_secret_storage", False) and key: + try: + if validate_secret_name_dynamic(key): + # Try to retrieve the secret from Key Vault + resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + if resolved_key: + # Update the agent dict with the resolved key for this session + agent["azure_openai_gpt_key"] = resolved_key + key = resolved_key + except Exception as e: + log_event(f"[SK Loader] Failed to resolve Key Vault secret for agent '{agent.get('name')}' in get_user_gpt: {e}", level=logging.ERROR, exceptionTraceback=True) + # Fallback to using the value as-is + return (endpoint, key, deployment, api_version) def get_global_gpt(): - return ( - settings.get("azure_openai_gpt_endpoint") or selected_model.get("endpoint"), - settings.get("azure_openai_gpt_key") or selected_model.get("key"), - settings.get("azure_openai_gpt_deployment") or selected_model.get("deploymentName"), - settings.get("azure_openai_gpt_api_version") or selected_model.get("api_version") - ) + endpoint = settings.get("azure_openai_gpt_endpoint") or selected_model.get("endpoint") + key = settings.get("azure_openai_gpt_key") or selected_model.get("key") + deployment = settings.get("azure_openai_gpt_deployment") or selected_model.get("deploymentName") + api_version = settings.get("azure_openai_gpt_api_version") or selected_model.get("api_version") + + # Check if key vault secret storage is enabled in settings + if settings.get("enable_key_vault_secret_storage", False) and key: + try: + if validate_secret_name_dynamic(key): + # Try to retrieve the secret from Key Vault + resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + if resolved_key: + # Update the settings dict with the resolved key for this session + settings["azure_openai_gpt_key"] = resolved_key + key = resolved_key + except Exception as e: + log_event(f"[SK Loader] Failed to resolve Key Vault secret in get_global_gpt: {e}", level=logging.ERROR, exceptionTraceback=True) + # Fallback to using the value as-is + return (endpoint, key, deployment, api_version) def merge_fields(primary, fallback): return tuple(p if p not in [None, ""] else f for p, f in zip(primary, fallback)) From be8d0d7fe5e6c966ca6921cda545a5fa3665f36c Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 30 Sep 2025 12:11:48 -0500 Subject: [PATCH 02/68] upd actions for MAG --- .../workflows/docker_image_publish_nadoyle.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker_image_publish_nadoyle.yml b/.github/workflows/docker_image_publish_nadoyle.yml index c39f99e45..4edc6dbf2 100644 --- a/.github/workflows/docker_image_publish_nadoyle.yml +++ b/.github/workflows/docker_image_publish_nadoyle.yml @@ -5,6 +5,7 @@ on: push: branches: - nadoyle + - keyvaultForSecrets workflow_dispatch: @@ -19,11 +20,11 @@ jobs: uses: Azure/docker-login@v2 with: # Container registry username - username: ${{ secrets.ACR_USERNAME }} + username: ${{ secrets.ACR_USERNAME_NADOYLE }} # Container registry password - password: ${{ secrets.ACR_PASSWORD }} + password: ${{ secrets.ACR_PASSWORD_NADOYLE }} # Container registry server url - login-server: ${{ secrets.ACR_LOGIN_SERVER }} + login-server: ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }} - uses: actions/checkout@v3 - name: Set up Node.js @@ -36,7 +37,7 @@ jobs: run: node scripts/generate-validators.mjs - name: Build the Docker image run: - docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; - docker tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:latest; - docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; - docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:latest; + docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; + docker tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:latest; + docker push ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; + docker push ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:latest; From 6bb5fd0d69e68c7aae644259236ebcbd55fec060 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 30 Sep 2025 12:41:19 -0500 Subject: [PATCH 03/68] add settings to fix --- application/single_app/functions_keyvault.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 674ceba05..8093833f4 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -68,14 +68,18 @@ def retrieve_secret_from_keyvault_by_full_name(full_secret_name): Raises: Exception: If retrieval fails or configuration is invalid. """ + settings = get_settings() + enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: raise Exception("Key Vault secret storage is not enabled.") + key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: raise Exception("Key Vault name is not configured.") try: - if key_vault_identity: + key_vault_identity = settings.get("key_vault_identity", None) + if key_vault_identity is not None: credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) else: credential = DefaultAzureCredential() @@ -104,9 +108,12 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl Raises: Exception: If storing fails or configuration is invalid. """ + settings = get_settings() + enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: raise Exception("Key Vault secret storage is not enabled.") + key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: raise Exception("Key Vault name is not configured.") @@ -119,7 +126,8 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) try: - if key_vault_identity: + key_vault_identity = settings.get("key_vault_identity", None) + if key_vault_identity is not None: credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) else: credential = DefaultAzureCredential() From ab28c4f5ad59265e0c034a2cfb854d212957bd6d Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 30 Sep 2025 12:55:00 -0500 Subject: [PATCH 04/68] upd secret naming convention --- application/single_app/functions_keyvault.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 8093833f4..38d06f869 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -155,7 +155,7 @@ def build_full_secret_name(secret_name, scope_value, source, scope): Raises: ValueError: If the name exceeds 127 characters. """ - full_secret_name = f"{scope_value}_{source}_{scope}_{secret_name}" + full_secret_name = f"{scope_value}--{source}--{scope}--{secret_name}" if len(full_secret_name) > 127: raise ValueError(f"The full secret name '{full_secret_name}' exceeds the maximum length of 127 characters.") return full_secret_name @@ -175,7 +175,7 @@ def validate_secret_name_dynamic(secret_name): scopes_pattern = '|'.join(re.escape(scope) for scope in supported_scopes) sources_pattern = '|'.join(re.escape(source) for source in supported_sources) # Wildcards for secret_name and scope_value - pattern = rf"^(.+)_({sources_pattern})_({scopes_pattern})_(.+)$" + pattern = rf"^(.+)--({sources_pattern})--({scopes_pattern})--(.+)$" match = re.match(pattern, secret_name) if not match: return False From 46945e7513b061aef2b9b85667b02b129c615494 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Wed, 1 Oct 2025 11:12:07 -0500 Subject: [PATCH 05/68] upd auth types to include conn string/basic(un/pw) --- application/single_app/static/json/schemas/plugin.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/single_app/static/json/schemas/plugin.schema.json b/application/single_app/static/json/schemas/plugin.schema.json index a990484b8..5401da565 100644 --- a/application/single_app/static/json/schemas/plugin.schema.json +++ b/application/single_app/static/json/schemas/plugin.schema.json @@ -41,8 +41,8 @@ "properties": { "type": { "type": "string", - "enum": ["key", "identity", "user", "servicePrincipal"], - "description": "Auth type must be 'key', 'user', 'identity', or 'servicePrincipal'" + "enum": ["key", "identity", "user", "servicePrincipal", "connection_string", "basic"], + "description": "Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', or 'basic'" }, "key": { "type": "string" From 06620534055376b50ea005fecf3c281944baea7a Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Wed, 1 Oct 2025 11:12:17 -0500 Subject: [PATCH 06/68] fix method name --- application/single_app/functions_keyvault.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 38d06f869..a118907de 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -54,7 +54,7 @@ def retrieve_secret_from_key_vault(secret_name, scope_value, scope="global", sou raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) - return retrieve_secret_by_full_name(full_secret_name) + return retrieve_secret_from_keyvault_by_full_name(full_secret_name) def retrieve_secret_from_keyvault_by_full_name(full_secret_name): """ From 09f7433b9052a33782ea3a23967ef2d21b5e9143 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 2 Oct 2025 12:50:58 -0500 Subject: [PATCH 07/68] add get agent helper --- .../single_app/functions_global_agents.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index e6e3e3944..b8086746e 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -113,19 +113,10 @@ def get_global_agent(agent_id): item=agent_id, partition_key=agent_id ) - """ Code to retrieve and replace Key Vault secrets if needed - if agent.get("enable_agent_gpt_apim", False): - key_value = agent["azure_openai_gpt_key"] = None # Hide the standard OpenAI key if APIM is enabled - else: - key_value = agent["azure_agent_apim_gpt_subscription_key"] - if validate_secret_name_dynamic(key_value): - # Retrieve the actual key from Key Vault - actual_key = retrieve_secret_from_key_vault(key_value) - if agent.get("enable_agent_gpt_apim", False): - agent["azure_agent_apim_gpt_subscription_key"] = actual_key - else: - agent["azure_openai_gpt_key"] = actual_key - """ + settings = get_settings() + # Code to retrieve and replace Key Vault secrets if needed + if settings.get("enable_key_vault_secret_storage", False): + agent = keyvault_agent_get_helper(agent, agent_id, scope="global") print(f"โœ… Found global agent: {agent_id}") return agent except Exception as e: From e24da0432d56f9170de836b14d280d78e665101e Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 2 Oct 2025 12:51:15 -0500 Subject: [PATCH 08/68] add ui trigger word and get agent helper --- application/single_app/functions_keyvault.py | 54 ++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index a118907de..6a406c564 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -33,6 +33,8 @@ 'group' ] +ui_trigger_word = "Stored_In_KeyVault" + def retrieve_secret_from_key_vault(secret_name, scope_value, scope="global", source="global"): """ Retrieve a secret from Key Vault using a dynamic name based on source, scope, and scope_value. @@ -211,10 +213,15 @@ def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): if key in updated and updated[key]: value = updated[key] - # If already a Key Vault reference, skip (simple heuristic: if value matches secret name pattern) - if not validate_secret_name_dynamic(value): - # Store in Key Vault and replace value with secret name - secret_name = agent_name + secret_name = agent_name + # 1. If the value is the UI trigger word, set to the built secret name (for display only) + if value == ui_trigger_word: + updated[key] = build_full_secret_name(secret_name, scope_value, source, scope) + # 2. If the value is already a Key Vault reference, leave as is (or set to built name for display) + elif validate_secret_name_dynamic(value): + updated[key] = build_full_secret_name(secret_name, scope_value, source, scope) + # 3. Otherwise, store in Key Vault and set to the new secret name + else: try: full_secret_name = store_secret_in_key_vault(secret_name, value, scope_value, source=source, scope=scope) updated[key] = full_secret_name @@ -222,6 +229,45 @@ def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): raise Exception(f"Failed to store agent key '{key}' in Key Vault: {e}") return updated +def keyvault_agent_get_helper(agent_dict, scope_value, scope="global"): + """ + For agent dicts, retrieve sensitive keys from Key Vault if they are stored as Key Vault references. + Only processes 'azure_agent_apim_gpt_subscription_key' and 'azure_openai_gpt_key'. + + Args: + agent_dict (dict): The agent dictionary to process. + scope_value (str): The value for the scope (e.g., agent id). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + dict: A new agent dict with sensitive values replaced by Key Vault references. + Raises: + Exception: If retrieving a key from Key Vault fails. + """ + source = "agent" + updated = dict(agent_dict) + agent_name = updated.get('name', 'agent') + # Decide which key to retrieve based on enable_agent_gpt_apim + use_apim = updated.get('enable_agent_gpt_apim', False) + if use_apim: + key = 'azure_agent_apim_gpt_subscription_key' + else: + key = 'azure_openai_gpt_key' + + if key in updated and updated[key]: + value = updated[key] + # If the value is a Key Vault reference, retrieve the actual key + if validate_secret_name_dynamic(value): + try: + """ + actual_key = retrieve_secret_from_key_vault(value) + updated[key] = actual_key + """ + updated[key] = ui_trigger_word + except Exception as e: + raise Exception(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") + return updated + def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): """ For plugin dicts, store the auth.key in Key Vault if auth.type is 'key' or 'servicePrincipal', From e46afa27472ff834df81ba40b22d78d9abb7ad39 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 2 Oct 2025 13:34:14 -0500 Subject: [PATCH 09/68] upd function imports --- .../single_app/route_backend_agents.py | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 2af1d8dfe..c5229bb1b 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -8,6 +8,7 @@ from semantic_kernel_loader import get_agent_orchestration_types from functions_settings import get_settings, update_settings, get_user_settings, update_user_settings from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent +from functions_personal_agents import get_personal_agents, ensure_migration_complete, save_personal_agent, delete_personal_agent from functions_authentication import * from functions_appinsights import log_event from json_schema_validation import validate_agent @@ -33,10 +34,6 @@ def generate_agent_id(): @login_required def get_user_agents(): user_id = get_current_user_id() - - # Import the new personal agents functions - from functions_personal_agents import get_personal_agents, ensure_migration_complete - # Ensure migration is complete (will migrate any remaining legacy data) ensure_migration_complete(user_id) @@ -53,7 +50,6 @@ def get_user_agents(): merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) if per_user and merge_global: # Import and get global agents from container - from functions_global_agents import get_global_agents global_agents = get_global_agents() # Mark global agents for agent in global_agents: @@ -87,10 +83,6 @@ def set_user_agents(): user_id = get_current_user_id() agents = request.json if isinstance(request.json, list) else [] settings = get_settings() - - # Import the new personal agents functions - from functions_personal_agents import save_personal_agent, delete_personal_agent, get_personal_agents - # If custom endpoints are not allowed, strip deployment settings for endpoint, key, and api-revision if not settings.get('allow_user_custom_agent_endpoints', False): for agent in agents: @@ -151,10 +143,6 @@ def set_user_agents(): @login_required def delete_user_agent(agent_name): user_id = get_current_user_id() - - # Import the new personal agents functions - from functions_personal_agents import get_personal_agents, delete_personal_agent - # Get current agents from personal_agents container agents = get_personal_agents(user_id) agent_to_delete = next((a for a in agents if a['name'] == agent_name), None) @@ -236,7 +224,6 @@ def set_selected_agent(): return jsonify({'error': 'Agent name is required.'}), 400 # Import and get global agents from container - from functions_global_agents import get_global_agents agents = get_global_agents() # Check that the agent exists @@ -266,8 +253,6 @@ def set_selected_agent(): def list_agents(): try: # Use new global agents container - from functions_global_agents import get_global_agents - agents = get_global_agents() # Ensure each agent has an actions_to_load field @@ -400,8 +385,6 @@ def update_agent_setting(setting_name): @admin_required def edit_agent(agent_name): try: - from functions_global_agents import get_global_agents, save_global_agent - agents = get_global_agents() updated_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) updated_agent['is_global'] = True @@ -465,8 +448,6 @@ def edit_agent(agent_name): @admin_required def delete_agent(agent_name): try: - from functions_global_agents import get_global_agents, delete_global_agent - agents = get_global_agents() # Find the agent to delete @@ -550,9 +531,7 @@ def orchestration_settings(): log_event(f"Error updating orchestration settings: {e}", level=logging.ERROR, exceptionTraceback=True) return jsonify({'error': 'Failed to update orchestration settings.'}), 500 -def get_global_agent_settings(include_admin_extras=False): - from functions_global_agents import get_global_agents - +def get_global_agent_settings(include_admin_extras=False): settings = get_settings() agents = get_global_agents() From bf5e85acf0f41ff6baa4095b575d886fc93ec752 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 2 Oct 2025 13:34:27 -0500 Subject: [PATCH 10/68] upd agents call --- application/single_app/functions_global_agents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index b8086746e..8db477c4d 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -14,7 +14,7 @@ from functions_authentication import get_current_user_id from datetime import datetime from config import cosmos_global_agents_container -from functions_keyvault import keyvault_agent_save_helper, store_secret_in_key_vault +from functions_keyvault import keyvault_agent_save_helper, store_secret_in_key_vault, keyvault_agent_get_helper from functions_settings import * @@ -86,6 +86,8 @@ def get_global_agents(): query="SELECT * FROM c", enable_cross_partition_query=True )) + # Mask or replace sensitive keys for UI display + agents = [keyvault_agent_get_helper(agent, agent.get('id', ''), scope="global") for agent in agents] return agents except Exception as e: log_event( From 8aeea1ac772a76609cbdf24d0bae590762581b86 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 2 Oct 2025 13:57:32 -0500 Subject: [PATCH 11/68] add desc of plugins --- .../single_app/static/json/schemas/plugin.schema.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/application/single_app/static/json/schemas/plugin.schema.json b/application/single_app/static/json/schemas/plugin.schema.json index 5401da565..2d44c931b 100644 --- a/application/single_app/static/json/schemas/plugin.schema.json +++ b/application/single_app/static/json/schemas/plugin.schema.json @@ -45,13 +45,16 @@ "description": "Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', or 'basic'" }, "key": { - "type": "string" + "type": "string", + "description": "The secret value for the plugin should be stored here, such as a SQL connection string, a password for a service principal or username/password combination" }, "identity": { - "type": "string" + "type": "string", + "description": "This could be the Id of an (managed) identity, a user name, or similar to pair with the key, in most situations" }, "tenantId": { - "type": "string" + "type": "string", + "description": "The Azure AD tenant ID used with Service Principal authentication" } }, "required": ["type"], From a4054ab169cbb6b376f4b1640675e055c2ee4066 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 3 Oct 2025 16:32:49 -0500 Subject: [PATCH 12/68] fix for admin modal loading --- application/single_app/route_backend_plugins.py | 1 + .../single_app/semantic_kernel_plugins/base_plugin.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 3ece6a8ce..d77d40347 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -288,6 +288,7 @@ def set_user_plugins(): plugin.setdefault('endpoint', f'sql://{plugin_type}') elif plugin_type == 'msgraph': # MS Graph plugin does not require an endpoint, but schema validation requires one + #TODO: Update to support different clouds plugin.setdefault('endpoint', 'https://graph.microsoft.com') else: # For other plugin types, require a real endpoint diff --git a/application/single_app/semantic_kernel_plugins/base_plugin.py b/application/single_app/semantic_kernel_plugins/base_plugin.py index 4aba46dc6..e56e97ca7 100644 --- a/application/single_app/semantic_kernel_plugins/base_plugin.py +++ b/application/single_app/semantic_kernel_plugins/base_plugin.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from typing import Dict, Any, List, Optional +import re class BasePlugin(ABC): @property @@ -36,8 +37,6 @@ def display_name(self) -> str: # Remove 'Plugin' suffix and format nicely name = class_name.replace('Plugin', '') - # Handle common acronyms by keeping them together - import re # Split on word boundaries while preserving acronyms parts = re.findall(r'[A-Z]+(?=[A-Z][a-z]|$)|[A-Z][a-z]*', name) @@ -45,6 +44,11 @@ def display_name(self) -> str: formatted = ' '.join(parts).replace('_', ' ').strip() return formatted if formatted else name + """ + This class provides common functionality and enforces a standard interface. + All plugins should inherit from this base class. + All plugins should call super().__init__(manifest) in their init constructor. + """ @abstractmethod def __init__(self, manifest: Optional[Dict[str, Any]] = None): self.manifest = manifest or {} From 5cce7bf11365fba86fa3dd230ce3630f57ea45f5 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 6 Oct 2025 10:55:25 -0500 Subject: [PATCH 13/68] upd default agent handling --- application/single_app/functions_global_agents.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 8db477c4d..7d92aad65 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -18,7 +18,6 @@ from functions_settings import * - def ensure_default_global_agent_exists(): """ Ensure at least one global agent exists in the global_agents container. @@ -64,6 +63,14 @@ def ensure_default_global_agent_exists(): extra={"existing_agents_count": len(agents)}, ) print("โ„น๏ธ At least one global agent already exists.") + + settings = get_settings() + if settings and settings.get("global_selected_agent", {}).name != "": + settings["global_selected_agent"] = { + "name": default_agent["name"], + "is_global": True + } + save_settings(settings) except Exception as e: log_event( f"Error ensuring default global agent exists: {e}", From 5a56bdc18f18f7adc1bd00eb6dd52fde8b16c508 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 6 Oct 2025 10:55:36 -0500 Subject: [PATCH 14/68] rmv unneeded file --- application/single_app/functions_personal_agents_plugins.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 application/single_app/functions_personal_agents_plugins.py diff --git a/application/single_app/functions_personal_agents_plugins.py b/application/single_app/functions_personal_agents_plugins.py deleted file mode 100644 index e69de29bb..000000000 From db6372ee1951bc29e0a866445276294b16da0b5b Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 6 Oct 2025 10:56:16 -0500 Subject: [PATCH 15/68] rmv extra imp statements --- application/single_app/functions_personal_agents.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 6cca4aa14..429ee5ea4 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -12,6 +12,10 @@ from azure.cosmos import exceptions from flask import current_app import logging +from config import cosmos_personal_agents_container +from functions_settings import get_settings +from functions_keyvault import keyvault_agent_save_helper + def get_personal_agents(user_id): """ @@ -24,8 +28,6 @@ def get_personal_agents(user_id): list: List of agent dictionaries """ try: - from config import cosmos_personal_agents_container - query = "SELECT * FROM c WHERE c.user_id = @user_id" parameters = [{"name": "@user_id", "value": user_id}] @@ -61,8 +63,6 @@ def get_personal_agent(user_id, agent_id): dict: Agent dictionary or None if not found """ try: - from config import cosmos_personal_agents_container - agent = cosmos_personal_agents_container.read_item( item=agent_id, partition_key=user_id @@ -90,8 +90,6 @@ def save_personal_agent(user_id, agent_data): dict: Saved agent data with ID """ try: - from config import cosmos_personal_agents_container - # Ensure required fields if 'id' not in agent_data: agent_data['id'] = str(f"{user_id}_{agent_data.get('name', 'default')}") @@ -137,8 +135,6 @@ def delete_personal_agent(user_id, agent_id): bool: True if deleted, False if not found """ try: - from config import cosmos_personal_agents_container - # Try to find the agent first to get the correct ID # Check if agent_id is actually a name and we need to find the real ID agent = get_personal_agent(user_id, agent_id) From d0aff17d339d4e9b04ce3d36447a4203acbcd833 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 6 Oct 2025 10:56:24 -0500 Subject: [PATCH 16/68] add new cosmos container script --- deployers/New-CosmosContainerDynamicRUs.ps1 | 98 +++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 deployers/New-CosmosContainerDynamicRUs.ps1 diff --git a/deployers/New-CosmosContainerDynamicRUs.ps1 b/deployers/New-CosmosContainerDynamicRUs.ps1 new file mode 100644 index 000000000..5373b52cb --- /dev/null +++ b/deployers/New-CosmosContainerDynamicRUs.ps1 @@ -0,0 +1,98 @@ +#requires -Module Az.CosmosDB +param( + [Parameter(Mandatory=$true)] + [string]$ResourceGroup, + [Parameter(Mandatory=$true)] + [string]$AccountName, + [string]$DatabaseName = "SimpleChat", + [ValidateRange(1000, 1000000)] + [int]$NewMaxRU = 1000, + [Parameter(Mandatory=$false, HelpMessage="Azure Cloud Environment: AzureCloud, AzureUSGovernment, Custom")] + [ValidateSet("AzureCloud", "AzureUSGovernment", "Custom")] + [string]$AzureCloudEnvironment = "AzureCloud" +) +$CloudEndpoint = "" +if ($AzureCloudEnvironment -eq "Custom") +{ + $CloudEndpoint = Read-Host "Enter the custom Azure Cloud Environment endpoint (e.g. https://management.azurecustom.you/)" + if ([string]::IsNullOrEmpty($CloudEndpoint)) + { + throw "Custom environment selected but no endpoint provided." + } + Write-Host "Using custom Azure Cloud Environment endpoint: $CloudEndpoint" + $AzureCloudEnvironment = New-AzEnvironment -Name "Custom" -ActiveDirectoryAuthority "https://login.microsoftonline.com/" -ResourceManagerEndpoint $CloudEndpoint -GraphEndpoint "https://graph.windows.net/" -GalleryEndpoint "https://gallery.azure.com/" -ManagementEndpoint $CloudEndpoint -StorageEndpointSuffix "core.windows.net" -SqlDatabaseDnsSuffix "database.windows.net" -TrafficManagerDnsSuffix "trafficmanager.net" -KeyVaultDnsSuffix "vault.azure.net" -ServiceManagementUrl $CloudEndpoint +} + +if ($(Get-AzContext)?.Account) +{ + Write-Host "Logged in as $((Get-AzContext).Account.Name)" +} +else +{ + Login-AzAccount -Environment $AzureCloudEnvironment -UseDeviceAuthentication +} + +$subscriptionName = $(Get-AzContext)?.Subscription?.Name +while ($subChoice -notin ("Y","y","N","n")) +{ + $subChoice = Read-Host "Use subscription '$subscriptionName'? (Y/N)" + if ($subChoice -eq "N") + { + $subscriptions = Get-AzSubscription + $subscriptions | ForEach-Object { Write-Host "$($_.SubscriptionId): $($_.Name)" } + $subId = Read-Host "Enter SubscriptionId to use" + Set-AzContext -SubscriptionId $subId + $subscriptionName = $(Get-AzContext)?.Subscription?.Name + Write-Host "Using subscription '$subscriptionName'" + } + elseif ($subChoice -ne "Y") + { + Write-Host "Please enter Y or N." + } +} + +# Get all containers in the database +$containers = Get-AzCosmosDBSqlContainer -ResourceGroupName $ResourceGroup -AccountName $AccountName -DatabaseName $DatabaseName + +foreach ($container in $containers) { + $containerName = $container.Name + Write-Host "Processing container: $containerName..." + + # Get current throughput settings + $throughput = Get-AzCosmosDBSqlContainerThroughput -ResourceGroupName $ResourceGroup -AccountName $AccountName -DatabaseName $DatabaseName -Name $containerName -ErrorAction SilentlyContinue + + Write-Host " Current Throughput Type: $($throughput.Throughput)" + + if ($null -eq $throughput) { + Write-Warning "No throughput found for $containerName. Skipping." + continue + } + + if ($throughput.AutoscaleSettings.MaxThroughput -eq 0) { + Write-Host " Migrating $containerName from Manual to Autoscale (max $NewMaxRU RU/s)..." + Invoke-AzCosmosDBSqlContainerThroughputMigration ` + -ResourceGroupName $ResourceGroup ` + -AccountName $AccountName ` + -DatabaseName $DatabaseName ` + -Name $containerName ` + -ThroughputType "Autoscale" + Write-Host "Updating $containerName to $NewMaxRU RU/s" + Update-AzCosmosDBSqlContainerThroughput ` + -ResourceGroupName $ResourceGroup ` + -AccountName $AccountName ` + -DatabaseName $DatabaseName ` + -Name $containerName ` + -AutoscaleMaxThroughput $NewMaxRU + Write-Host "Updated $containerName to $NewMaxRU RU/s" + } else { + Write-Host " $containerName already Autoscale. Updating max RU/s to $NewMaxRU..." + Update-AzCosmosDBSqlContainerThroughput ` + -ResourceGroupName $ResourceGroup ` + -AccountName $AccountName ` + -DatabaseName $DatabaseName ` + -Name $containerName ` + -AutoscaleMaxThroughput $NewMaxRU + } +} + +Write-Host "All containers processed for autoscale ($($NewMaxRU*.10)-$NewMaxRU RU/s)." \ No newline at end of file From 427f01dedd311ede85f73c2aff24048e02809b4b Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 9 Oct 2025 13:21:21 -0500 Subject: [PATCH 17/68] upd instructions for consistency of code --- .github/copilot-instructions.md | 4 ++++ .github/instructions/python-lang.instructions.md | 13 +++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 .github/instructions/python-lang.instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7062c29a6..3e6be37dc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,7 @@ +--- +applyTo: '**' +--- + # REPO SPECIFIC INSTRUCTIONS --- diff --git a/.github/instructions/python-lang.instructions.md b/.github/instructions/python-lang.instructions.md new file mode 100644 index 000000000..c37b99c72 --- /dev/null +++ b/.github/instructions/python-lang.instructions.md @@ -0,0 +1,13 @@ +--- +applyTo: '**' +--- + +# Python Language Guide + +- Files should start with a comment of the file name. Ex: `# functions_personal_agents.py` + +- Imports should be grouped at the top of the document after the module docstring, unless otherwise indicated by the user or for performance reasons in which case the import should be as close as possible to the usage with a documented note as to why the import is not at the top of the file. + +- Use 4 spaces per indentation level. No tabs. + +- Code and definitions should occur after the imports block. \ No newline at end of file From 33ba357ce41fc1bb8996a3a41e3aecbf74f648e6 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 9 Oct 2025 13:22:30 -0500 Subject: [PATCH 18/68] adds safe calls for akv functions --- .../single_app/functions_global_agents.py | 16 +-- application/single_app/functions_keyvault.py | 103 ++++++++++++------ 2 files changed, 75 insertions(+), 44 deletions(-) diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 7d92aad65..8c83b569c 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -14,7 +14,7 @@ from functions_authentication import get_current_user_id from datetime import datetime from config import cosmos_global_agents_container -from functions_keyvault import keyvault_agent_save_helper, store_secret_in_key_vault, keyvault_agent_get_helper +from functions_keyvault import keyvault_agent_save_helper, store_secret_in_key_vault, keyvault_agent_get_helper, keyvault_agent_delete_helper from functions_settings import * @@ -122,10 +122,7 @@ def get_global_agent(agent_id): item=agent_id, partition_key=agent_id ) - settings = get_settings() - # Code to retrieve and replace Key Vault secrets if needed - if settings.get("enable_key_vault_secret_storage", False): - agent = keyvault_agent_get_helper(agent, agent_id, scope="global") + agent = keyvault_agent_get_helper(agent, agent_id, scope="global") print(f"โœ… Found global agent: {agent_id}") return agent except Exception as e: @@ -164,11 +161,8 @@ def save_global_agent(agent_data): ) print(f"๐Ÿ’พ Saving global agent: {agent_data.get('name', 'Unknown')}") - - settings = get_settings() - if settings.get("enable_key_vault_secret_storage", False): - # Use the new helper to store sensitive agent keys in Key Vault - agent_data = keyvault_agent_save_helper(agent_data, agent_data['id'], scope="global") + # Use the new helper to store sensitive agent keys in Key Vault + agent_data = keyvault_agent_save_helper(agent_data, agent_data['id'], scope="global") result = cosmos_global_agents_container.upsert_item(body=agent_data) log_event( @@ -202,6 +196,8 @@ def delete_global_agent(agent_id): try: user_id = get_current_user_id() print(f"๐Ÿ—‘๏ธ Deleting global agent: {agent_id}") + agent_dict = get_global_agent(agent_id) + keyvault_agent_delete_helper(agent_dict, agent_id, scope="global") cosmos_global_agents_container.delete_item( item=agent_id, partition_key=agent_id diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 6a406c564..a304b3026 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -80,13 +80,8 @@ def retrieve_secret_from_keyvault_by_full_name(full_secret_name): raise Exception("Key Vault name is not configured.") try: - key_vault_identity = settings.get("key_vault_identity", None) - if key_vault_identity is not None: - credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) - else: - credential = DefaultAzureCredential() key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" - secret_client = SecretClient(vault_url=key_vault_url, credential=credential) + secret_client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) retrieved_secret = secret_client.get_secret(full_secret_name) print(f"Secret '{full_secret_name}' retrieved successfully from Key Vault.") @@ -128,14 +123,8 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) try: - key_vault_identity = settings.get("key_vault_identity", None) - if key_vault_identity is not None: - credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) - else: - credential = DefaultAzureCredential() key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" - secret_client = SecretClient(vault_url=key_vault_url, credential=credential) - + secret_client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) secret_client.set_secret(full_secret_name, secret_value) print(f"Secret '{full_secret_name}' stored successfully in Key Vault.") return full_secret_name @@ -201,26 +190,23 @@ def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): Raises: Exception: If storing a key in Key Vault fails. """ + settings = get_settings() + enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) + key_vault_name = settings.get("key_vault_name", None) + if not enable_key_vault_secret_storage or not key_vault_name: + return agent_dict source = "agent" updated = dict(agent_dict) agent_name = updated.get('name', 'agent') - # Decide which key to store based on enable_agent_gpt_apim use_apim = updated.get('enable_agent_gpt_apim', False) - if use_apim: - key = 'azure_agent_apim_gpt_subscription_key' - else: - key = 'azure_openai_gpt_key' - + key = 'azure_agent_apim_gpt_subscription_key' if use_apim else 'azure_openai_gpt_key' if key in updated and updated[key]: value = updated[key] secret_name = agent_name - # 1. If the value is the UI trigger word, set to the built secret name (for display only) if value == ui_trigger_word: updated[key] = build_full_secret_name(secret_name, scope_value, source, scope) - # 2. If the value is already a Key Vault reference, leave as is (or set to built name for display) elif validate_secret_name_dynamic(value): updated[key] = build_full_secret_name(secret_name, scope_value, source, scope) - # 3. Otherwise, store in Key Vault and set to the new secret name else: try: full_secret_name = store_secret_in_key_vault(secret_name, value, scope_value, source=source, scope=scope) @@ -244,25 +230,23 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global"): Raises: Exception: If retrieving a key from Key Vault fails. """ + settings = get_settings() + enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) + key_vault_name = settings.get("key_vault_name", None) + if not enable_key_vault_secret_storage or not key_vault_name: + return agent_dict source = "agent" updated = dict(agent_dict) agent_name = updated.get('name', 'agent') - # Decide which key to retrieve based on enable_agent_gpt_apim use_apim = updated.get('enable_agent_gpt_apim', False) - if use_apim: - key = 'azure_agent_apim_gpt_subscription_key' - else: - key = 'azure_openai_gpt_key' - + key = 'azure_agent_apim_gpt_subscription_key' if use_apim else 'azure_openai_gpt_key' if key in updated and updated[key]: value = updated[key] - # If the value is a Key Vault reference, retrieve the actual key if validate_secret_name_dynamic(value): try: - """ - actual_key = retrieve_secret_from_key_vault(value) - updated[key] = actual_key - """ + # Uncomment below to actually retrieve the secret value + # actual_key = retrieve_secret_from_key_vault(value) + # updated[key] = actual_key updated[key] = ui_trigger_word except Exception as e: raise Exception(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") @@ -303,4 +287,55 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): updated['auth'] = new_auth except Exception as e: raise Exception(f"Failed to store plugin key in Key Vault: {e}") - return updated \ No newline at end of file + return updated + +# Helper to delete agent secrets from Key Vault +def keyvault_agent_delete_helper(agent_dict, scope_value, scope="global"): + """ + For agent dicts, delete sensitive keys from Key Vault if they are stored as Key Vault references. + Only processes 'azure_agent_apim_gpt_subscription_key' and 'azure_openai_gpt_key'. + + Args: + agent_dict (dict): The agent dictionary to process. + scope_value (str): The value for the scope (e.g., agent id). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + None + """ + settings = get_settings() + enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) + key_vault_name = settings.get("key_vault_name", None) + if not enable_key_vault_secret_storage or not key_vault_name: + return agent_dict + source = "agent" + updated = dict(agent_dict) + agent_name = updated.get('name', 'agent') + use_apim = updated.get('enable_agent_gpt_apim', False) + keys = ['azure_agent_apim_gpt_subscription_key'] if use_apim else ['azure_openai_gpt_key'] + for key in keys: + if key in updated and updated[key]: + secret_name = updated[key] + if validate_secret_name_dynamic(secret_name): + try: + key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) + client.begin_delete_secret(secret_name) + except Exception as e: + logging.error(f"Error deleting secret '{secret_name}' for agent '{agent_name}': {e}") + return agent_dict + +def get_keyvault_credential(): + """ + Get the Key Vault credential using DefaultAzureCredential, optionally with a managed identity client ID. + + Returns: + DefaultAzureCredential: The credential object for Key Vault access. + """ + settings = get_settings() + key_vault_identity = settings.get("key_vault_identity", None) + if key_vault_identity is not None: + credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) + else: + credential = DefaultAzureCredential() + return credential \ No newline at end of file From 07e31c71dd631d3643df83b2c30730369a152e2b Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 9 Oct 2025 13:22:37 -0500 Subject: [PATCH 19/68] adds akv to personal agents --- .../single_app/functions_personal_agents.py | 92 ++++--------------- 1 file changed, 16 insertions(+), 76 deletions(-) diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 429ee5ea4..949e06c70 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -1,21 +1,23 @@ + # functions_personal_agents.py """ Personal Agents Management -This module handles all operations related to personal agents stored in the +This module handles all operations related to personal agents stored in the personal_agents container with user_id partitioning. """ + +# Imports (grouped after docstring) import uuid from datetime import datetime from azure.cosmos import exceptions from flask import current_app import logging from config import cosmos_personal_agents_container -from functions_settings import get_settings -from functions_keyvault import keyvault_agent_save_helper - +from functions_settings import get_settings, get_user_settings, update_user_settings +from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper def get_personal_agents(user_id): """ @@ -37,12 +39,12 @@ def get_personal_agents(user_id): partition_key=user_id )) - # Remove Cosmos metadata for cleaner response + # Remove Cosmos metadata for cleaner response and retrieve secrets from Key Vault cleaned_agents = [] for agent in agents: cleaned_agent = {k: v for k, v in agent.items() if not k.startswith('_')} + cleaned_agent = keyvault_agent_get_helper(cleaned_agent, cleaned_agent.get('id', ''), scope="user") cleaned_agents.append(cleaned_agent) - return cleaned_agents except exceptions.CosmosResourceNotFoundError: @@ -68,9 +70,10 @@ def get_personal_agent(user_id, agent_id): partition_key=user_id ) - # Remove Cosmos metadata - cleaned_agent = {k: v for k, v in agent.items() if not k.startswith('_')} - return cleaned_agent + # Remove Cosmos metadata and retrieve secrets from Key Vault + cleaned_agent = {k: v for k, v in agent.items() if not k.startswith('_')} + cleaned_agent = keyvault_agent_get_helper(cleaned_agent, cleaned_agent.get('id', agent_id), scope="user") + return cleaned_agent except exceptions.CosmosResourceNotFoundError: return None @@ -113,8 +116,9 @@ def save_personal_agent(user_id, agent_data): agent_data.setdefault('other_settings', {}) agent_data.setdefault('is_global', False) + # Store sensitive keys in Key Vault if enabled + agent_data = keyvault_agent_save_helper(agent_data, agent_data.get('id', ''), scope="user") result = cosmos_personal_agents_container.upsert_item(body=agent_data) - # Remove Cosmos metadata from response cleaned_result = {k: v for k, v in result.items() if not k.startswith('_')} return cleaned_result @@ -142,16 +146,15 @@ def delete_personal_agent(user_id, agent_id): # Try to find by name if direct ID lookup failed agents = get_personal_agents(user_id) agent = next((a for a in agents if a['name'] == agent_id), None) - if not agent: return False - + # Delete secrets from Key Vault if present + keyvault_agent_delete_helper(agent, agent.get('id', agent_id), scope="user") cosmos_personal_agents_container.delete_item( item=agent['id'], partition_key=user_id ) return True - except exceptions.CosmosResourceNotFoundError: return False except Exception as e: @@ -170,8 +173,6 @@ def ensure_migration_complete(user_id): int: Number of agents migrated (0 if already migrated) """ try: - from functions_settings import get_user_settings, update_user_settings - user_settings = get_user_settings(user_id) agents = user_settings.get('settings', {}).get('agents', []) @@ -208,15 +209,11 @@ def migrate_agents_from_user_settings(user_id): int: Number of agents migrated """ try: - from functions_settings import get_user_settings, update_user_settings - user_settings = get_user_settings(user_id) agents = user_settings.get('settings', {}).get('agents', []) - # Get existing personal agents to avoid duplicates existing_personal_agents = get_personal_agents(user_id) existing_agent_names = {agent['name'] for agent in existing_personal_agents} - migrated_count = 0 for agent in agents: try: @@ -224,77 +221,20 @@ def migrate_agents_from_user_settings(user_id): if agent.get('name') in existing_agent_names: current_app.logger.info(f"Skipping migration of agent '{agent.get('name')}' - already exists") continue - # Ensure agent has an ID if 'id' not in agent: agent['id'] = str(uuid.uuid4()) - save_personal_agent(user_id, agent) migrated_count += 1 - except Exception as e: current_app.logger.error(f"Error migrating agent {agent.get('name', 'unknown')} for user {user_id}: {e}") - # Always remove agents from user settings after processing (even if no new ones migrated) settings_to_update = user_settings.get('settings', {}) settings_to_update['agents'] = [] # Set to empty array instead of removing update_user_settings(user_id, settings_to_update) - current_app.logger.info(f"Migrated {migrated_count} new agents for user {user_id}, cleaned up legacy data") return migrated_count - except Exception as e: current_app.logger.error(f"Error during agent migration for user {user_id}: {e}") return 0 -def get_selected_agent(user_id): - """ - Get the user's selected agent preference. - - Args: - user_id (str): The user's unique identifier - - Returns: - dict: Selected agent info or None - """ - try: - from functions_settings import get_user_settings - - user_settings = get_user_settings(user_id) - selected_agent = user_settings.get('settings', {}).get('selected_agent') - - return selected_agent - - except Exception as e: - current_app.logger.error(f"Error getting selected agent for user {user_id}: {e}") - return None - -def set_selected_agent(user_id, agent_name, is_global=False): - """ - Set the user's selected agent preference. - - Args: - user_id (str): The user's unique identifier - agent_name (str): Name of the selected agent - is_global (bool): Whether the agent is global or personal - - Returns: - bool: True if successful - """ - try: - from functions_settings import get_user_settings, update_user_settings - - user_settings = get_user_settings(user_id) - settings_to_update = user_settings.get('settings', {}) - - settings_to_update['selected_agent'] = { - 'name': agent_name, - 'is_global': is_global - } - - update_user_settings(user_id, settings_to_update) - return True - - except Exception as e: - current_app.logger.error(f"Error setting selected agent for user {user_id}: {e}") - return False From 91f3a866bc3efde69aeacf9a24f146d362faee7f Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 13:09:00 -0500 Subject: [PATCH 20/68] fix for user agents boot issue --- application/single_app/functions_personal_agents.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 949e06c70..4e04e6241 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -70,12 +70,12 @@ def get_personal_agent(user_id, agent_id): partition_key=user_id ) - # Remove Cosmos metadata and retrieve secrets from Key Vault - cleaned_agent = {k: v for k, v in agent.items() if not k.startswith('_')} - cleaned_agent = keyvault_agent_get_helper(cleaned_agent, cleaned_agent.get('id', agent_id), scope="user") - return cleaned_agent - + # Remove Cosmos metadata and retrieve secrets from Key Vault + cleaned_agent = {k: v for k, v in agent.items() if not k.startswith('_')} + cleaned_agent = keyvault_agent_get_helper(cleaned_agent, cleaned_agent.get('id', agent_id), scope="user") + return cleaned_agent except exceptions.CosmosResourceNotFoundError: + current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}") return None except Exception as e: current_app.logger.error(f"Error fetching agent {agent_id} for user {user_id}: {e}") @@ -156,6 +156,7 @@ def delete_personal_agent(user_id, agent_id): ) return True except exceptions.CosmosResourceNotFoundError: + current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}") return False except Exception as e: current_app.logger.error(f"Error deleting agent {agent_id} for user {user_id}: {e}") From 2a23b84627a7fc2af7af93a991efd6851df635cf Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 13:18:27 -0500 Subject: [PATCH 21/68] fix global set --- application/single_app/functions_global_agents.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 8c83b569c..4eda793b4 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -65,7 +65,13 @@ def ensure_default_global_agent_exists(): print("โ„น๏ธ At least one global agent already exists.") settings = get_settings() - if settings and settings.get("global_selected_agent", {}).name != "": + needs_default = False + global_selected = settings.get("global_selected_agent") if settings else None + if not isinstance(global_selected, dict): + needs_default = True + elif global_selected.get("name", "") == "": + needs_default = True + if settings and needs_default: settings["global_selected_agent"] = { "name": default_agent["name"], "is_global": True From 570ec56a2fc9d6f088a887777da3eb9c277ce7fd Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 14:28:37 -0500 Subject: [PATCH 22/68] upd azure function plugin to super init --- .../single_app/semantic_kernel_plugins/azure_function_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/single_app/semantic_kernel_plugins/azure_function_plugin.py b/application/single_app/semantic_kernel_plugins/azure_function_plugin.py index e24fd6e55..2e0928c6e 100644 --- a/application/single_app/semantic_kernel_plugins/azure_function_plugin.py +++ b/application/single_app/semantic_kernel_plugins/azure_function_plugin.py @@ -7,7 +7,7 @@ class AzureFunctionPlugin(BasePlugin): def __init__(self, manifest: Dict[str, Any]): - self.manifest = manifest + super().__init__(manifest) self.endpoint = manifest.get('endpoint') self.key = manifest.get('auth', {}).get('key') self.auth_type = manifest.get('auth', {}).get('type', 'key') From def744b3dcd1187d4f251d689c77f05b51a87d6c Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 14:31:19 -0500 Subject: [PATCH 23/68] upd to clean imports --- application/single_app/functions_global_actions.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 07ce3a193..f5dc6ff85 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -10,7 +10,6 @@ import json import traceback from datetime import datetime - from config import cosmos_global_actions_container def get_global_actions(): @@ -25,7 +24,7 @@ def get_global_actions(): query="SELECT * FROM c", enable_cross_partition_query=True )) - + return actions except Exception as e: @@ -45,8 +44,6 @@ def get_global_action(action_id): dict: Action data or None if not found """ try: - from config import cosmos_global_actions_container - action = cosmos_global_actions_container.read_item( item=action_id, partition_key=action_id @@ -71,8 +68,6 @@ def save_global_action(action_data): dict: Saved action data or None if failed """ try: - from config import cosmos_global_actions_container - # Ensure required fields if 'id' not in action_data: action_data['id'] = str(uuid.uuid4()) @@ -106,8 +101,6 @@ def delete_global_action(action_id): bool: True if successful, False otherwise """ try: - from config import cosmos_global_actions_container - print(f"๐Ÿ—‘๏ธ Deleting global action: {action_id}") cosmos_global_actions_container.delete_item( From 3b0b743e22fa8e858c99b6c8b43aed6bc1a04b18 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 14:31:50 -0500 Subject: [PATCH 24/68] add keyvault to global actions loading --- application/single_app/functions_keyvault.py | 224 +++++++++++++++--- .../single_app/semantic_kernel_loader.py | 89 ++++--- 2 files changed, 252 insertions(+), 61 deletions(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index a304b3026..6b01b9b30 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -1,6 +1,7 @@ # functions_keyvault.py import re +import logging from config import * from functions_authentication import * from functions_settings import * @@ -24,7 +25,9 @@ 'storage_account', 'cognitive_service', 'action', - 'agent' + 'action-addset', + 'agent', + 'other' ] supported_scopes = [ @@ -33,6 +36,13 @@ 'group' ] +supported_action_auth_types = [ + 'key', + 'servicePrincipal', + 'basic', + 'connection_string' +] + ui_trigger_word = "Stored_In_KeyVault" def retrieve_secret_from_key_vault(secret_name, scope_value, scope="global", source="global"): @@ -51,8 +61,10 @@ def retrieve_secret_from_key_vault(secret_name, scope_value, scope="global", sou Exception: If retrieval fails or configuration is invalid. """ if source not in supported_sources: + logging.error(f"Source '{source}' is not supported. Supported sources: {supported_sources}") raise ValueError(f"Source '{source}' is not supported. Supported sources: {supported_sources}") if scope not in supported_scopes: + logging.error(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) @@ -73,10 +85,12 @@ def retrieve_secret_from_keyvault_by_full_name(full_secret_name): settings = get_settings() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: + logging.error(f"Key Vault secret storage is not enabled.") raise Exception("Key Vault secret storage is not enabled.") key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: + logging.error(f"Key Vault name is not configured.") raise Exception("Key Vault name is not configured.") try: @@ -108,15 +122,19 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl settings = get_settings() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: + logging.error(f"Key Vault secret storage is not enabled.") raise Exception("Key Vault secret storage is not enabled.") key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: + logging.error(f"Key Vault name is not configured.") raise Exception("Key Vault name is not configured.") if source not in supported_sources: + logging.error(f"Source '{source}' is not supported. Supported sources: {supported_sources}") raise ValueError(f"Source '{source}' is not supported. Supported sources: {supported_sources}") if scope not in supported_scopes: + logging.error(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") @@ -129,6 +147,7 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl print(f"Secret '{full_secret_name}' stored successfully in Key Vault.") return full_secret_name except Exception as e: + logging.error(f"Failed to store secret '{full_secret_name}' in Key Vault: {str(e)}") raise Exception(f"Failed to store secret '{full_secret_name}' in Key Vault: {str(e)}") from e def build_full_secret_name(secret_name, scope_value, source, scope): @@ -147,8 +166,9 @@ def build_full_secret_name(secret_name, scope_value, source, scope): ValueError: If the name exceeds 127 characters. """ full_secret_name = f"{scope_value}--{source}--{scope}--{secret_name}" - if len(full_secret_name) > 127: - raise ValueError(f"The full secret name '{full_secret_name}' exceeds the maximum length of 127 characters.") + if not validate_secret_name_dynamic(full_secret_name): + logging.error(f"The full secret name '{full_secret_name}' is invalid.") + raise ValueError(f"The full secret name '{full_secret_name}' is invalid.") return full_secret_name def validate_secret_name_dynamic(secret_name): @@ -169,9 +189,13 @@ def validate_secret_name_dynamic(secret_name): pattern = rf"^(.+)--({sources_pattern})--({scopes_pattern})--(.+)$" match = re.match(pattern, secret_name) if not match: + print(f"Secret name '{secret_name}' does not match the required pattern.") + logging.warning(f"Secret name '{secret_name}' does not match the required pattern.") return False # Optionally, check length if len(secret_name) > 127: + print(f"Secret name '{secret_name}' exceeds the maximum length of 127 characters.") + logging.warning(f"Secret name '{secret_name}' exceeds the maximum length of 127 characters.") return False return True @@ -212,10 +236,11 @@ def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): full_secret_name = store_secret_in_key_vault(secret_name, value, scope_value, source=source, scope=scope) updated[key] = full_secret_name except Exception as e: + logging.error(f"Failed to store agent key '{key}' in Key Vault: {e}") raise Exception(f"Failed to store agent key '{key}' in Key Vault: {e}") return updated -def keyvault_agent_get_helper(agent_dict, scope_value, scope="global"): +def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_actual_key=False): """ For agent dicts, retrieve sensitive keys from Key Vault if they are stored as Key Vault references. Only processes 'azure_agent_apim_gpt_subscription_key' and 'azure_openai_gpt_key'. @@ -224,6 +249,7 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global"): agent_dict (dict): The agent dictionary to process. scope_value (str): The value for the scope (e.g., agent id). scope (str): The scope (e.g., 'user', 'global'). + return_actual_key (bool): If True, retrieves the actual secret value from Key Vault. If False, replaces with ui_trigger_word. Returns: dict: A new agent dict with sensitive values replaced by Key Vault references. @@ -244,18 +270,20 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global"): value = updated[key] if validate_secret_name_dynamic(value): try: - # Uncomment below to actually retrieve the secret value - # actual_key = retrieve_secret_from_key_vault(value) - # updated[key] = actual_key - updated[key] = ui_trigger_word + if return_actual_key: + actual_key = retrieve_secret_from_key_vault(value) + updated[key] = actual_key + else: + updated[key] = ui_trigger_word except Exception as e: + logging.error(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") raise Exception(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") return updated def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): """ - For plugin dicts, store the auth.key in Key Vault if auth.type is 'key' or 'servicePrincipal', - and replace its value with the Key Vault secret name. + For plugin dicts, store the auth.key in Key Vault if auth.type is 'key', 'servicePrincipal', 'basic', or 'connection_string', + and replace its value with the Key Vault secret name. Also supports dynamic secret storage for any additionalFields key ending with '__Secret'. Args: plugin_dict (dict): The plugin dictionary to process. @@ -266,28 +294,167 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): dict: A new plugin dict with sensitive values replaced by Key Vault references. Raises: Exception: If storing a key in Key Vault fails. + + Feature: + Any key in additionalFields ending with '__Secret' will be stored in Key Vault and replaced with a Key Vault reference. + This allows plugin writers to dynamically store secrets without custom code. """ - source = "plugin" + if scope not in supported_scopes: + logging.error(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + source = "action" # Use 'action' for plugins per app convention updated = dict(plugin_dict) plugin_name = updated.get('name', 'plugin') auth = updated.get('auth', {}) - if not isinstance(auth, dict): - return updated - auth_type = auth.get('type', None) - if auth_type in ('key', 'servicePrincipal') and 'key' in auth and auth['key']: - value = auth['key'] - # If already a Key Vault reference, skip - if not validate_secret_name_dynamic(value): - secret_name = plugin_name - try: - full_secret_name = store_secret_in_key_vault(secret_name, value, scope_value, source=source, scope=scope) - # Update the auth dict with the Key Vault reference - new_auth = dict(auth) - new_auth['key'] = full_secret_name - updated['auth'] = new_auth - except Exception as e: - raise Exception(f"Failed to store plugin key in Key Vault: {e}") + if isinstance(auth, dict): + auth_type = auth.get('type', None) + if auth_type in supported_action_auth_types and 'key' in auth and auth['key']: + value = auth['key'] + if not validate_secret_name_dynamic(value): + try: + full_secret_name = store_secret_in_key_vault(plugin_name, value, scope_value, source=source, scope=scope) + new_auth = dict(auth) + new_auth['key'] = full_secret_name + updated['auth'] = new_auth + except Exception as e: + logging.error(f"Failed to store plugin key in Key Vault: {e}") + raise Exception(f"Failed to store plugin key in Key Vault: {e}") + else: + print(f"Auth type '{auth_type}' does not require Key Vault storage. Does not match ") + + # Handle additionalFields dynamic secrets + additional_fields = updated.get('additionalFields', {}) + if isinstance(additional_fields, dict): + new_additional_fields = dict(additional_fields) + for k, v in additional_fields.items(): + if k.endswith('__Secret') and v: + addset_source = 'action-addset' + base_field = k[:-8] # Remove '__Secret' + akv_key = f"{plugin_name}-{base_field}".replace('__', '-') + full_secret_name = build_full_secret_name(akv_key, scope_value, addset_source, scope) + if not validate_secret_name_dynamic(full_secret_name): + logging.error(f"Generated secret name for additionalField '{k}' is not valid.") + raise ValueError(f"Generated secret name for additionalField '{k}' is not valid.") + try: + full_secret_name = store_secret_in_key_vault(akv_key, v, scope_value, source=addset_source, scope=scope) + new_additional_fields[k] = full_secret_name + except Exception as e: + logging.error(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") + raise Exception(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") + updated['additionalFields'] = new_additional_fields + return updated +# Helper to retrieve plugin secrets from Key Vault +def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_actual_key=False): + """ + For plugin dicts, retrieve secrets from Key Vault for auth.key and any additionalFields key ending with '__Secret'. + If the value is a Key Vault reference, retrieve the actual secret and replace with ui_trigger_word. + + Args: + plugin_dict (dict): The plugin dictionary to process. + scope_value (str): The value for the scope (e.g., plugin id). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + dict: A new plugin dict with sensitive values replaced by ui_trigger_word if stored in Key Vault. + """ + if scope not in supported_scopes: + logging.error(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + source = "action" + updated = dict(plugin_dict) + plugin_name = updated.get('name', 'plugin') + auth = updated.get('auth', {}) + if isinstance(auth, dict): + if 'key' in auth and auth['key']: + value = auth['key'] + if validate_secret_name_dynamic(value): + try: + if return_actual_key: + actual_key = retrieve_secret_from_key_vault(plugin_name, scope_value, scope, source) + new_auth = dict(auth) + new_auth['key'] = actual_key + updated['auth'] = new_auth + else: + new_auth = dict(auth) + new_auth['key'] = ui_trigger_word + updated['auth'] = new_auth + except Exception as e: + logging.error(f"Failed to retrieve plugin key from Key Vault: {e}") + raise Exception(f"Failed to retrieve plugin key from Key Vault: {e}") + + additional_fields = updated.get('additionalFields', {}) + if isinstance(additional_fields, dict): + new_additional_fields = dict(additional_fields) + for k, v in additional_fields.items(): + if k.endswith('__Secret') and v and validate_secret_name_dynamic(v): + addset_source = 'action-addset' + base_field = k[:-8] # Remove '__Secret' + akv_key = f"{plugin_name}-{base_field}".replace('__', '-') + try: + if return_actual_key: + actual_secret = retrieve_secret_from_key_vault(f"{akv_key}", scope_value, scope, addset_source) + new_additional_fields[k] = actual_secret + else: + new_additional_fields[k] = ui_trigger_word + except Exception as e: + logging.error(f"Failed to retrieve plugin additionalField secret '{k}' from Key Vault: {e}") + raise Exception(f"Failed to retrieve plugin additionalField secret '{k}' from Key Vault: {e}") + updated['additionalFields'] = new_additional_fields return updated +# Helper to delete plugin secrets from Key Vault +def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): + """ + For plugin dicts, delete secrets from Key Vault for auth.key and any additionalFields key ending with '__Secret'. + Only deletes if the value is a Key Vault reference. + + Args: + plugin_dict (dict): The plugin dictionary to process. + scope_value (str): The value for the scope (e.g., plugin id). + scope (str): The scope (e.g., 'user', 'global'). + + Returns: + plugin_dict (dict): The original plugin dict. + Raises: + """ + if scope not in supported_scopes: + logging.error(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + settings = get_settings() + enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) + key_vault_name = settings.get("key_vault_name", None) + if not enable_key_vault_secret_storage or not key_vault_name: + return plugin_dict + source = "action" + plugin_name = plugin_dict.get('name', 'plugin') + auth = plugin_dict.get('auth', {}) + if isinstance(auth, dict): + if 'key' in auth and auth['key']: + secret_name = auth['key'] + if validate_secret_name_dynamic(secret_name): + try: + key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) + client.begin_delete_secret(secret_name) + except Exception as e: + logging.error(f"Error deleting plugin secret '{secret_name}' for plugin '{plugin_name}': {e}") + raise Exception(f"Error deleting plugin secret '{secret_name}' for plugin '{plugin_name}': {e}") + + additional_fields = plugin_dict.get('additionalFields', {}) + if isinstance(additional_fields, dict): + for k, v in additional_fields.items(): + if k.endswith('__Secret') and v and validate_secret_name_dynamic(v): + addset_source = 'action-addset' + base_field = k[:-8] # Remove '__Secret' + akv_key = f"{plugin_name}-{base_field}".replace('__', '-') + try: + keyvault_secret_name = build_full_secret_name(akv_key, scope_value, addset_source, scope) + key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) + client.begin_delete_secret(keyvault_secret_name) + except Exception as e: + logging.error(f"Error deleting plugin additionalField secret '{k}' for plugin '{plugin_name}': {e}") + raise Exception(f"Error deleting plugin additionalField secret '{k}' for plugin '{plugin_name}': {e}") + return plugin_dict # Helper to delete agent secrets from Key Vault def keyvault_agent_delete_helper(agent_dict, scope_value, scope="global"): @@ -301,7 +468,7 @@ def keyvault_agent_delete_helper(agent_dict, scope_value, scope="global"): scope (str): The scope (e.g., 'user', 'global'). Returns: - None + agent_dict (dict): The original agent dict. """ settings = get_settings() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) @@ -323,6 +490,7 @@ def keyvault_agent_delete_helper(agent_dict, scope_value, scope="global"): client.begin_delete_secret(secret_name) except Exception as e: logging.error(f"Error deleting secret '{secret_name}' for agent '{agent_name}': {e}") + raise Exception(f"Error deleting secret '{secret_name}' for agent '{agent_name}': {e}") return agent_dict def get_keyvault_credential(): diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index ffadb38bd..f6061070e 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -22,6 +22,7 @@ from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery from semantic_kernel_plugins.logged_plugin_loader import create_logged_plugin_loader from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger +from semantic_kernel_plugins.smart_http_plugin import SmartHttpPlugin from functions_debug import debug_print from flask import g import logging @@ -31,6 +32,14 @@ import inspect import builtins from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_keyvault_by_full_name +from functions_global_actions import get_global_actions +from functions_global_agents import get_global_agents +from functions_personal_actions import get_personal_actions, ensure_migration_complete as ensure_actions_migration_complete +from functions_personal_agents import get_personal_agents, ensure_migration_complete as ensure_agents_migration_complete +from semantic_kernel_plugins.plugin_loader import discover_plugins +from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory + + # Agent and Azure OpenAI chat service imports log_event("[SK Loader] Starting loader imports") @@ -126,7 +135,7 @@ def get_user_apim(): api_version = agent.get("azure_apim_gpt_api_version") # Check if key vault secret storage is enabled in settings - if settings.get("enable_key_vault_secret_storage", False) and key: + if settings.get("enable_key_vault_secret_storage", False) and settings.get("key_vault_name") and key: try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault @@ -147,7 +156,7 @@ def get_global_apim(): api_version = settings.get("azure_apim_gpt_api_version") # Check if key vault secret storage is enabled in settings - if settings.get("enable_key_vault_secret_storage", False) and key: + if settings.get("enable_key_vault_secret_storage", False) and settings.get("key_vault_name") and key: try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault @@ -168,7 +177,7 @@ def get_user_gpt(): api_version = agent.get("azure_openai_gpt_api_version") # Check if key vault secret storage is enabled in settings - if settings.get("enable_key_vault_secret_storage", False) and key: + if settings.get("enable_key_vault_secret_storage", False) and settings.get("key_vault_name") and key: try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault @@ -189,7 +198,7 @@ def get_global_gpt(): api_version = settings.get("azure_openai_gpt_api_version") or selected_model.get("api_version") # Check if key vault secret storage is enabled in settings - if settings.get("enable_key_vault_secret_storage", False) and key: + if settings.get("enable_key_vault_secret_storage", False) and settings.get("key_vault_name") and key: try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault @@ -294,9 +303,7 @@ def load_time_plugin(kernel: Kernel): ) def load_http_plugin(kernel: Kernel): - # Import the smart HTTP plugin for better content size management try: - from semantic_kernel_plugins.smart_http_plugin import SmartHttpPlugin # Use smart HTTP plugin with 75k character limit (โ‰ˆ50k tokens) smart_plugin = SmartHttpPlugin(max_content_size=75000, extract_text_only=True) kernel.add_plugin( @@ -463,7 +470,6 @@ def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_ # Get plugin manifests based on mode if mode_label == "per-user": - from functions_personal_actions import get_personal_actions if user_id: all_plugin_manifests = get_personal_actions(user_id) print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} personal plugin manifests for user {user_id}") @@ -472,7 +478,6 @@ def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_ all_plugin_manifests = [] else: # Global mode - get from global actions container - from functions_global_actions import get_global_actions all_plugin_manifests = get_global_actions() print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} global plugin manifests") @@ -537,13 +542,11 @@ def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_ try: # Get plugin manifests again for fallback if mode_label == "per-user": - from functions_personal_actions import get_personal_actions if user_id: all_plugin_manifests = get_personal_actions(user_id) else: all_plugin_manifests = [] else: - from functions_global_actions import get_global_actions all_plugin_manifests = get_global_actions() plugin_manifests = [p for p in all_plugin_manifests if p.get('name') in plugin_names] @@ -563,7 +566,6 @@ def _load_agent_plugins_original_method(kernel, plugin_manifests, mode_label="gl """ try: # Load the filtered plugins using original method - from semantic_kernel_plugins.plugin_loader import discover_plugins discovered_plugins = discover_plugins() for manifest in plugin_manifests: @@ -587,12 +589,11 @@ def normalize(s): try: # Special handling for OpenAPI plugins if normalized_type == normalize('openapi') or 'openapi' in normalized_type: - from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory plugin = OpenApiPluginFactory.create_from_config(manifest) print(f"[SK Loader] Created OpenAPI plugin: {name}") else: # Standard plugin instantiation - from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery + plugin_instance, instantiation_errors = PluginHealthChecker.create_plugin_safely( matched_class, manifest, name ) @@ -605,9 +606,6 @@ def normalize(s): plugin = plugin_instance - # Add plugin to kernel - from semantic_kernel.functions.kernel_plugin import KernelPlugin - # Special handling for OpenAPI plugins with dynamic functions if hasattr(plugin, 'get_kernel_plugin'): print(f"[SK Loader] Using custom kernel plugin method for: {name}") @@ -795,10 +793,48 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis log_event(f"[SK Loader] load_single_agent_for_kernel completed - returning {len(agent_objs)} agents: {list(agent_objs.keys())}", level=logging.INFO) return kernel, agent_objs +def resolve_key_vault_secrets_in_plugins(plugin_manifest, settings): + """ + Resolve any Key Vault secrets in a plugin manifest. + """ + if not isinstance(plugin_manifest, dict): + raise ValueError("Plugin manifest must be a dictionary") + + kv_name = settings.get("key_vault_name") + if not kv_name: + raise ValueError("Key Vault name not configured in settings") + + def resolve_value(value): + if isinstance(value, str) and validate_secret_name_dynamic(value): + resolved = retrieve_secret_from_keyvault(kv_name, value) + if resolved: + return resolved + else: + raise ValueError(f"Failed to retrieve secret '{value}' from Key Vault '{kv_name}'") + return value + + resolved_manifest = {} + for k, v in plugin_manifest.items(): + if isinstance(v, str): + resolved_manifest[k] = resolve_value(v) + elif isinstance(v, list): + resolved_manifest[k] = [resolve_value(item) for item in v] + elif isinstance(v, dict): + resolved_manifest[k] = {sub_k: resolve_value(sub_v) for sub_k, sub_v in v.items()} + else: + resolved_manifest[k] = v # Leave other types unchanged + return resolved_manifest + def load_plugins_for_kernel(kernel, plugin_manifests, settings, mode_label="global"): """ DRY helper to load plugins from a manifest list (user or global). """ + if settings.get("enable_key_vault_secret_storage", False) and settings.get("key_vault_name"): + try: + plugin_manifests = [resolve_key_vault_secrets_in_plugins(p, settings) for p in plugin_manifests] + except Exception as e: + log_event(f"[SK Loader] Failed to resolve Key Vault secrets in plugin manifests: {e}", level=logging.ERROR, exceptionTraceback=True) + print(f"[SK Loader] Failed to resolve Key Vault secrets in plugin manifests: {e}") # Create logged plugin loader for enhanced logging logged_loader = create_logged_plugin_loader(kernel) @@ -912,7 +948,6 @@ def _load_plugins_original_method(kernel, plugin_manifests, settings, mode_label Original plugin loading method as fallback. """ try: - from semantic_kernel_plugins.plugin_loader import discover_plugins discovered_plugins = discover_plugins() for manifest in plugin_manifests: plugin_type = manifest.get('type') @@ -932,7 +967,6 @@ def normalize(s): try: # Special handling for OpenAPI plugins if normalized_type == normalize('openapi') or 'openapi' in normalized_type: - from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory # Use the factory to create OpenAPI plugins from configuration plugin = OpenApiPluginFactory.create_from_config(manifest) else: @@ -1004,13 +1038,8 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie load_core_plugins_only(kernel, settings) return kernel, None - # Redis is now optional for per-user mode. If not present, state will not persist. - - # Load agents from personal_agents container - from functions_personal_agents import get_personal_agents, ensure_migration_complete - # Ensure migration is complete (will migrate any remaining legacy data) - ensure_migration_complete(user_id) + ensure_agents_migration_complete(user_id) agents_cfg = get_personal_agents(user_id) print(f"[SK Loader] User settings found {len(agents_cfg)} agents for user '{user_id}'") @@ -1023,7 +1052,6 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) print(f"[SK Loader] merge_global_semantic_kernel_with_workspace: {merge_global}") if merge_global: - from functions_global_agents import get_global_agents global_agents = get_global_agents() print(f"[SK Loader] Found {len(global_agents)} global agents to merge") # Mark global agents @@ -1056,17 +1084,12 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie "agents": agents_cfg }, level=logging.INFO) - - # Load plugins from personal_actions container - from functions_personal_actions import get_personal_actions, ensure_migration_complete - # Ensure migration is complete (will migrate any remaining legacy data) - ensure_migration_complete(user_id) + ensure_actions_migration_complete(user_id) plugin_manifests = get_personal_actions(user_id) # PATCH: Merge global plugins if enabled if merge_global: - from functions_global_actions import get_global_actions global_plugins = get_global_actions() # User plugins take precedence all_plugins = {p.get('name'): p for p in plugin_manifests} @@ -1206,7 +1229,7 @@ def load_semantic_kernel(kernel: Kernel, settings): log_event("[SK Loader] Global Semantic Kernel mode enabled. Loading global plugins and agents.", level=logging.INFO) # Conditionally load core plugins based on settings - from functions_global_actions import get_global_actions + plugin_manifests = get_global_actions() log_event(f"[SK Loader] Found {len(plugin_manifests)} plugin manifests", level=logging.INFO) @@ -1215,7 +1238,7 @@ def load_semantic_kernel(kernel: Kernel, settings): # --- Agent and Service Loading --- # region Multi-agent Orchestration - from functions_global_agents import get_global_agents + agents_cfg = get_global_agents() enable_multi_agent_orchestration = settings.get('enable_multi_agent_orchestration', False) merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) From a037caaad2c66fb4888689bde4bf64249c3bcac7 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 14:32:01 -0500 Subject: [PATCH 25/68] add plugin loading docs --- .../PLUGIN_DYNAMIC_SECRET_STORAGE.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 application/single_app/semantic_kernel_plugins/PLUGIN_DYNAMIC_SECRET_STORAGE.md diff --git a/application/single_app/semantic_kernel_plugins/PLUGIN_DYNAMIC_SECRET_STORAGE.md b/application/single_app/semantic_kernel_plugins/PLUGIN_DYNAMIC_SECRET_STORAGE.md new file mode 100644 index 000000000..6518e569e --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/PLUGIN_DYNAMIC_SECRET_STORAGE.md @@ -0,0 +1,69 @@ +# PLUGIN_DYNAMIC_SECRET_STORAGE.md + +## Feature: Dynamic Secret Storage for Plugins/Actions + +**Implemented in version:** (add your current config.py version here) + +### Overview +This feature allows plugin writers to store secrets in Azure Key Vault dynamically by simply naming any key in the plugin's `additionalFields` dictionary with the suffix `__Secret`. The application will automatically detect these keys, store their values in Key Vault, and replace the value with a Key Vault reference. This works in addition to the standard `auth.key` secret handling. + + +### How It Works +- When saving a plugin, any key in `additionalFields` ending with `__Secret` (two underscores and a capital S) will be stored in Key Vault. +- The Key Vault secret name for these fields is constructed as `{pluginName-additionalsettingnamewithout__Secret}` (e.g., `loganal-alpharoemo` for plugin `loganal` and field `alpharoemo__Secret`). +- The value in the plugin dict will be replaced with the Key Vault reference (the full secret name). +- When retrieving a plugin, any Key Vault reference in `auth.key` or `additionalFields` ending with `__Secret` will be replaced with a UI trigger word (or optionally, the actual secret value). +- When deleting a plugin, any Key Vault reference in `auth.key` or `additionalFields` ending with `__Secret` will be deleted from Key Vault. + + +### Example +```json +{ + "name": "loganal", + "auth": { + "type": "key", + "key": "my-actual-secret-value" + }, + "additionalFields": { + "alpharoemo__Secret": "supersecretvalue", + "otherSetting__Secret": "anothersecret" + } +} +``` +After saving, the plugin dict will look like: +```json +{ + "name": "loganal", + "auth": { + "type": "key", + "key": "loganal--action--global--loganal" // Key Vault reference + }, + "additionalFields": { + "alpharoemo__Secret": "loganal--action-addset--global--loganal-alpharoemo", // Key Vault reference + "otherSetting__Secret": "loganal--action-addset--global--loganal-otherSetting" // Key Vault reference + } +} +``` +**Note:** The Key Vault secret name for each additional setting is constructed as `{pluginName}-{additionalsettingname}` (with __Secret removed). + + +### Benefits +- No custom code required for plugin writers to leverage Key Vault for secrets. +- Supports any number of dynamic secrets per plugin. +- Consistent with existing agent secret handling. +- Secret names are AKV-compliant and descriptive, making management and debugging easier. + + +### Usage +- To store a secret, add a key to `additionalFields` ending with `__Secret` and set its value to the secret. +- The application will handle storing, retrieving, and deleting the secret in Key Vault automatically. +- Secret names for additional settings will follow the `{pluginName-additionalsettingname}` pattern. + +### Related Files +- `functions_keyvault.py` (helpers for save, get, delete) +- `plugin.schema.json` (schema supports arbitrary additionalFields) + +### Version History +- Feature added in version: (add your current config.py version here) + +--- From 74c200ccd1d12330dc1ed604a998fbdf764b698d Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 16:53:06 -0500 Subject: [PATCH 26/68] rmv secret leak via logging --- application/single_app/functions_keyvault.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 6b01b9b30..76b63fc8f 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -189,13 +189,9 @@ def validate_secret_name_dynamic(secret_name): pattern = rf"^(.+)--({sources_pattern})--({scopes_pattern})--(.+)$" match = re.match(pattern, secret_name) if not match: - print(f"Secret name '{secret_name}' does not match the required pattern.") - logging.warning(f"Secret name '{secret_name}' does not match the required pattern.") return False # Optionally, check length if len(secret_name) > 127: - print(f"Secret name '{secret_name}' exceeds the maximum length of 127 characters.") - logging.warning(f"Secret name '{secret_name}' exceeds the maximum length of 127 characters.") return False return True From 509bc63bdc9122fa30357a3a76df8947a2f66931 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 16:58:47 -0500 Subject: [PATCH 27/68] rmv displaying of token in logs --- application/single_app/route_frontend_authentication.py | 1 - 1 file changed, 1 deletion(-) diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index 395e757a3..a208168ef 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -123,7 +123,6 @@ def authorized(): # Store user identity info (claims from ID token) debug_print(f" [claims] User {result.get('id_token_claims', {}).get('name', 'Unknown')} logged in.") debug_print(f" [claims] User claims: {result.get('id_token_claims', {})}") - debug_print(f" [claims] User token: {result.get('access_token', 'Unknown')}") session["user"] = result.get("id_token_claims") From 0bb4a6b43c0e81d60b175527065f14c5aceec3b4 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 17:05:05 -0500 Subject: [PATCH 28/68] fix not loading global actions for personal agents --- application/single_app/semantic_kernel_loader.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index f6061070e..19b3652f1 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -468,17 +468,21 @@ def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_ # Create logged plugin loader for enhanced logging logged_loader = create_logged_plugin_loader(kernel) - # Get plugin manifests based on mode + global_plugins = get_global_actions() if mode_label == "per-user": if user_id: all_plugin_manifests = get_personal_actions(user_id) + personal_action_names = {p.get('name') for p in plugin_manifests} + for g in global_plugins: + if g.get('name') not in personal_action_names: + plugin_manifests.append(g) print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} personal plugin manifests for user {user_id}") else: print(f"[SK Loader] Warning: No user_id provided for per-user plugin loading") all_plugin_manifests = [] else: # Global mode - get from global actions container - all_plugin_manifests = get_global_actions() + all_plugin_manifests = global_plugins print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} global plugin manifests") # Filter manifests to only include requested plugins From 186a6e14edddad8ce01aba8637d66ea6b2ed23f8 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 17:12:35 -0500 Subject: [PATCH 29/68] rmv unsupported characters from logging --- .../single_app/functions_global_agents.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 4eda793b4..b7d907fbf 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -56,13 +56,13 @@ def ensure_default_global_agent_exists(): "agent_name": default_agent["name"] }, ) - print("โœ… Default global agent created.") + print("Default global agent created.") else: log_event( "At least one global agent already exists.", extra={"existing_agents_count": len(agents)}, ) - print("โ„น๏ธ At least one global agent already exists.") + print("At least one global agent already exists.") settings = get_settings() needs_default = False @@ -84,7 +84,7 @@ def ensure_default_global_agent_exists(): level=logging.ERROR, exceptionTraceback=True ) - print(f"โŒ Error ensuring default global agent exists: {e}") + print(f"Error ensuring default global agent exists: {e}") traceback.print_exc() def get_global_agents(): @@ -108,7 +108,7 @@ def get_global_agents(): extra={"exception": str(e)}, exceptionTraceback=True ) - print(f"โŒ Error getting global agents: {str(e)}") + print(f"Error getting global agents: {str(e)}") traceback.print_exc() return [] @@ -129,7 +129,7 @@ def get_global_agent(agent_id): partition_key=agent_id ) agent = keyvault_agent_get_helper(agent, agent_id, scope="global") - print(f"โœ… Found global agent: {agent_id}") + print(f"Found global agent: {agent_id}") return agent except Exception as e: log_event( @@ -138,7 +138,7 @@ def get_global_agent(agent_id): level=logging.ERROR, exceptionTraceback=True ) - print(f"โŒ Error getting global agent {agent_id}: {str(e)}") + print(f"Error getting global agent {agent_id}: {str(e)}") return None @@ -165,7 +165,7 @@ def save_global_agent(agent_data): "Saving global agent.", extra={"agent_name": agent_data.get('name', 'Unknown')}, ) - print(f"๐Ÿ’พ Saving global agent: {agent_data.get('name', 'Unknown')}") + print(f"Saving global agent: {agent_data.get('name', 'Unknown')}") # Use the new helper to store sensitive agent keys in Key Vault agent_data = keyvault_agent_save_helper(agent_data, agent_data['id'], scope="global") @@ -175,7 +175,7 @@ def save_global_agent(agent_data): "Global agent saved successfully.", extra={"agent_id": result['id'], "user_id": user_id}, ) - print(f"โœ… Global agent saved successfully: {result['id']}") + print(f"Global agent saved successfully: {result['id']}") return result except Exception as e: log_event( @@ -184,7 +184,7 @@ def save_global_agent(agent_data): level=logging.ERROR, exceptionTraceback=True ) - print(f"โŒ Error saving global agent: {str(e)}") + print(f"Error saving global agent: {str(e)}") traceback.print_exc() return None @@ -201,7 +201,7 @@ def delete_global_agent(agent_id): """ try: user_id = get_current_user_id() - print(f"๐Ÿ—‘๏ธ Deleting global agent: {agent_id}") + print(f"Deleting global agent: {agent_id}") agent_dict = get_global_agent(agent_id) keyvault_agent_delete_helper(agent_dict, agent_id, scope="global") cosmos_global_agents_container.delete_item( @@ -212,7 +212,7 @@ def delete_global_agent(agent_id): "Global agent deleted successfully.", extra={"agent_id": agent_id, "user_id": user_id}, ) - print(f"โœ… Global agent deleted successfully: {agent_id}") + print(f"Global agent deleted successfully: {agent_id}") return True except Exception as e: log_event( @@ -221,6 +221,6 @@ def delete_global_agent(agent_id): level=logging.ERROR, exceptionTraceback=True ) - print(f"โŒ Error deleting global agent {agent_id}: {str(e)}") + print(f"Error deleting global agent {agent_id}: {str(e)}") traceback.print_exc() return False From f11381c7521d680e71429af027a42ef7255cf96e Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 17:44:45 -0500 Subject: [PATCH 30/68] fix chat links in dark mode --- application/single_app/static/css/chats.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 61ac309a1..fa15821da 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -941,9 +941,14 @@ a.citation-link:hover { margin-bottom: 10px; /* Add some space before the footer or citation box */ } +[data-bs-theme="dark"] .message-text a { + color: #212529; + text-decoration: underline; +} + /* Optional: Style links within messages */ .message-text a { - color: #0d6efd; + color: #ffeb3b; /* Bootstrap warning color for better visibility */ text-decoration: underline; } From b2d6613dcab5386b27694e6436bc689d42b3b288 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 17:46:04 -0500 Subject: [PATCH 31/68] chg order of css for links in dark mode --- application/single_app/static/css/chats.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index fa15821da..4f2c0c20d 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -941,17 +941,17 @@ a.citation-link:hover { margin-bottom: 10px; /* Add some space before the footer or citation box */ } -[data-bs-theme="dark"] .message-text a { - color: #212529; - text-decoration: underline; -} - /* Optional: Style links within messages */ .message-text a { color: #ffeb3b; /* Bootstrap warning color for better visibility */ text-decoration: underline; } +[data-bs-theme="dark"] .message-text a { + color: #212529; + text-decoration: underline; +} + .message-text a:hover { color: #0a58ca; text-decoration: none; From 967f9cba371d6ebf32b50ecf47a24d9268242db1 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 18:11:15 -0500 Subject: [PATCH 32/68] fix chat color --- application/single_app/static/css/chats.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 4f2c0c20d..147fa696f 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -943,12 +943,12 @@ a.citation-link:hover { /* Optional: Style links within messages */ .message-text a { - color: #ffeb3b; /* Bootstrap warning color for better visibility */ + color: #0d6efd; text-decoration: underline; } [data-bs-theme="dark"] .message-text a { - color: #212529; + color: #ffeb3b; text-decoration: underline; } From bf201f11709d0c5c97d43d839c43afa6a19a68db Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 10 Oct 2025 18:25:51 -0500 Subject: [PATCH 33/68] add default plugin print logging --- application/single_app/semantic_kernel_loader.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 19b3652f1..55a5a33c1 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -1106,30 +1106,37 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie # Only load core Semantic Kernel plugins here if settings.get('enable_time_plugin', True): load_time_plugin(kernel) + print(f"[SK Loader] Loaded Time plugin.") log_event("[SK Loader] Loaded Time plugin.", level=logging.INFO) if settings.get('enable_fact_memory_plugin', True): load_fact_memory_plugin(kernel) + print(f"[SK Loader] Loaded Fact Memory plugin.") log_event("[SK Loader] Loaded Fact Memory plugin.", level=logging.INFO) if settings.get('enable_math_plugin', True): load_math_plugin(kernel) + print(f"[SK Loader] Loaded Math plugin.") log_event("[SK Loader] Loaded Math plugin.", level=logging.INFO) if settings.get('enable_text_plugin', True): load_text_plugin(kernel) + print(f"[SK Loader] Loaded Text plugin.") log_event("[SK Loader] Loaded Text plugin.", level=logging.INFO) if settings.get('enable_http_plugin', True): load_http_plugin(kernel) + print(f"[SK Loader] Loaded HTTP plugin.") log_event("[SK Loader] Loaded HTTP plugin.", level=logging.INFO) if settings.get('enable_wait_plugin', True): load_wait_plugin(kernel) + print(f"[SK Loader] Loaded Wait plugin.") log_event("[SK Loader] Loaded Wait plugin.", level=logging.INFO) if settings.get('enable_default_embedding_model_plugin', True): load_embedding_model_plugin(kernel, settings) + print(f"[SK Loader] Loaded Default Embedding Model plugin.") log_event("[SK Loader] Loaded Default Embedding Model plugin.", level=logging.INFO) # Get selected agent from user settings (this still needs to be in user settings for UI state) From 39d944e9cd2410973ab0b2f5f0078524b3c71555 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 14 Oct 2025 13:41:53 -0500 Subject: [PATCH 34/68] rmv default check for nonsql plugins --- application/single_app/templates/_plugin_modal.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 563a4981d..d38610066 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -263,7 +263,7 @@
API Information
- + @@ -436,7 +436,7 @@
Advanced
Optional metadata for this action.
-
+
Additional configuration fields specific to this action type.
From 7114bac1cdcd3a185af466ef733552852d332266 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 14 Oct 2025 13:42:08 -0500 Subject: [PATCH 35/68] upd requirements --- deployers/New-CosmosContainerDynamicRUs.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/deployers/New-CosmosContainerDynamicRUs.ps1 b/deployers/New-CosmosContainerDynamicRUs.ps1 index 5373b52cb..2d64b25e1 100644 --- a/deployers/New-CosmosContainerDynamicRUs.ps1 +++ b/deployers/New-CosmosContainerDynamicRUs.ps1 @@ -1,4 +1,5 @@ #requires -Module Az.CosmosDB +#requires -Module Az.Accounts param( [Parameter(Mandatory=$true)] [string]$ResourceGroup, From 3d64e251fbf88fad61040c4bc915a1089ce3d0a7 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 14 Oct 2025 17:00:04 -0500 Subject: [PATCH 36/68] add keyvault and dynamic addsetting ui --- .../agent_logging_chat_completion.py | 37 +- application/single_app/app.py | 1 + .../single_app/functions_appinsights.py | 1 + .../single_app/functions_global_actions.py | 23 +- application/single_app/functions_keyvault.py | 55 +- .../single_app/functions_personal_actions.py | 47 +- .../single_app/json_schema_validation.py | 2 +- .../single_app/route_backend_plugins.py | 10 +- .../single_app/semantic_kernel_loader.py | 72 +- .../databricks_table_example.json | 33 - .../logged_plugin_loader.py | 69 +- .../queue_storage_plugin.py | 2 +- .../semantic_kernel_plugins/ui_test_plugin.py | 80 +++ .../static/js/admin/admin_plugins.js | 19 +- .../static/js/agent_modal_stepper.js | 22 +- .../static/js/plugin_modal_stepper.js | 663 ++++++++++++++++-- .../single_app/static/js/validatePlugin.mjs | 2 +- .../static/js/workspace/workspace_plugins.js | 20 +- .../static/json/schemas/PLUGIN_SCHEMAS.md | 18 + .../static/json/schemas/plugin.schema.json | 19 +- ...age_plugin.additional_settings.schema.json | 14 + .../schemas/queue_storage_plugin.schema.json | 28 + ...ery_plugin.additional_settings.schema.json | 55 ++ ...ema_plugin.additional_settings.schema.json | 38 + ...est_plugin.additional_settings.schema.json | 113 +++ .../schemas/ui_test_plugin.plugin.schema.json | 23 + .../single_app/templates/_plugin_modal.html | 6 +- 27 files changed, 1231 insertions(+), 241 deletions(-) delete mode 100644 application/single_app/semantic_kernel_plugins/databricks_table_example.json create mode 100644 application/single_app/semantic_kernel_plugins/ui_test_plugin.py create mode 100644 application/single_app/static/json/schemas/PLUGIN_SCHEMAS.md create mode 100644 application/single_app/static/json/schemas/queue_storage_plugin.additional_settings.schema.json create mode 100644 application/single_app/static/json/schemas/queue_storage_plugin.schema.json create mode 100644 application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json create mode 100644 application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json create mode 100644 application/single_app/static/json/schemas/ui_test_plugin.additional_settings.schema.json create mode 100644 application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json diff --git a/application/single_app/agent_logging_chat_completion.py b/application/single_app/agent_logging_chat_completion.py index 1e1ae3ce9..e4173ef27 100644 --- a/application/single_app/agent_logging_chat_completion.py +++ b/application/single_app/agent_logging_chat_completion.py @@ -144,13 +144,6 @@ async def invoke(self, *args, **kwargs): } ) - log_event("[Logging Agent Request] Agent invoke started", - extra={ - "agent": self.name, - "prompt_preview": [m.content[:30] for m in args[0]] if args else None - }, - level=logging.DEBUG) - # Store user question context for better tool detection if args and args[0] and hasattr(args[0][-1], 'content'): self._user_question = args[0][-1].content @@ -163,12 +156,14 @@ async def invoke(self, *args, **kwargs): initial_message_count = len(args[0]) if args and args[0] else 0 result = super().invoke(*args, **kwargs) - log_event("[Logging Agent Request] Result received", - extra={ - "agent": self.name, - "result_type": type(result).__name__ - }, - level=logging.DEBUG) + log_event( + "[Logging Agent Request] Result received", + extra={ + "agent": self.name, + "result_type": type(result).__name__ + }, + level=logging.DEBUG + ) if hasattr(result, "__aiter__"): # Streaming/async generator response @@ -180,13 +175,15 @@ async def invoke(self, *args, **kwargs): # Regular coroutine response response = await result - log_event("[Logging Agent Request] Response received", - extra={ - "agent": self.name, - "response_type": type(response).__name__, - "response_preview": str(response)[:100] if response else None - }, - level=logging.DEBUG) + log_event( + "[Logging Agent Request] Response received", + extra={ + "agent": self.name, + "response_type": type(response).__name__, + "response_preview": str(response)[:100] if response else None + }, + level=logging.DEBUG + ) # Store the response for analysis self._last_response = response diff --git a/application/single_app/app.py b/application/single_app/app.py index 0dccf70ac..528908c5b 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -105,6 +105,7 @@ from route_external_health import * +#TODO: Remove this after speaking with Paul configure_azure_monitor() # =================== Session Configuration =================== diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py index 1ec314f95..9522e4dd4 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -71,6 +71,7 @@ def log_event( exc_info_to_use = True # Format message with extra properties for structured logging + print(f"[Log] {message} -- {extra}") # Debug print to console if extra: # For modern Azure Monitor, extra properties are automatically captured logger.log( diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index f5dc6ff85..439bef886 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -11,8 +11,9 @@ import traceback from datetime import datetime from config import cosmos_global_actions_container +from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper -def get_global_actions(): +def get_global_actions(return_actual_key=False): """ Get all global actions. @@ -24,7 +25,8 @@ def get_global_actions(): query="SELECT * FROM c", enable_cross_partition_query=True )) - + # Resolve Key Vault references for each action + actions = [keyvault_plugin_get_helper(a, scope_value=a.get('id'), scope="global", return_actual_key=return_actual_key) for a in actions] return actions except Exception as e: @@ -33,7 +35,7 @@ def get_global_actions(): return [] -def get_global_action(action_id): +def get_global_action(action_id, return_actual_key=False): """ Get a specific global action by ID. @@ -48,7 +50,8 @@ def get_global_action(action_id): item=action_id, partition_key=action_id ) - + # Resolve Key Vault references + action = keyvault_plugin_get_helper(action, scope_value=action_id, scope="global", return_actual_key=return_actual_key) print(f"โœ… Found global action: {action_id}") return action @@ -71,16 +74,14 @@ def save_global_action(action_data): # Ensure required fields if 'id' not in action_data: action_data['id'] = str(uuid.uuid4()) - # Add metadata action_data['is_global'] = True action_data['created_at'] = datetime.utcnow().isoformat() action_data['updated_at'] = datetime.utcnow().isoformat() - print(f"๐Ÿ’พ Saving global action: {action_data.get('name', 'Unknown')}") - + # Store secrets in Key Vault before upsert + action_data = keyvault_plugin_save_helper(action_data, scope_value=action_data.get('id'), scope="global") result = cosmos_global_actions_container.upsert_item(body=action_data) - print(f"โœ… Global action saved successfully: {result['id']}") return result @@ -102,12 +103,14 @@ def delete_global_action(action_id): """ try: print(f"๐Ÿ—‘๏ธ Deleting global action: {action_id}") - + # Delete secrets from Key Vault before deleting the action + action = get_global_action(action_id) + if action: + keyvault_plugin_delete_helper(action, scope_value=action_id, scope="global") cosmos_global_actions_container.delete_item( item=action_id, partition_key=action_id ) - print(f"โœ… Global action deleted successfully: {action_id}") return True diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 76b63fc8f..23efc33ae 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -2,6 +2,7 @@ import re import logging +from functions_appinsights import log_event from config import * from functions_authentication import * from functions_settings import * @@ -20,10 +21,6 @@ """ supported_sources = [ - 'model_deployment', - 'speech_service', - 'storage_account', - 'cognitive_service', 'action', 'action-addset', 'agent', @@ -40,6 +37,7 @@ 'key', 'servicePrincipal', 'basic', + 'username_password', 'connection_string' ] @@ -85,13 +83,14 @@ def retrieve_secret_from_keyvault_by_full_name(full_secret_name): settings = get_settings() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: - logging.error(f"Key Vault secret storage is not enabled.") - raise Exception("Key Vault secret storage is not enabled.") + return value key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: - logging.error(f"Key Vault name is not configured.") - raise Exception("Key Vault name is not configured.") + return value + + if not validate_secret_name_dynamic(full_secret_name): + return value try: key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" @@ -101,7 +100,9 @@ def retrieve_secret_from_keyvault_by_full_name(full_secret_name): print(f"Secret '{full_secret_name}' retrieved successfully from Key Vault.") return retrieved_secret.value except Exception as e: - raise Exception(f"Failed to retrieve secret '{full_secret_name}' from Key Vault: {str(e)}") from e + logging.error(f"Failed to retrieve secret '{full_secret_name}' from Key Vault: {str(e)}") + return value + def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="global", scope="global"): """ @@ -122,13 +123,13 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl settings = get_settings() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: - logging.error(f"Key Vault secret storage is not enabled.") - raise Exception("Key Vault secret storage is not enabled.") + logging.warn(f"Key Vault secret storage is not enabled.") + return secret_value key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: - logging.error(f"Key Vault name is not configured.") - raise Exception("Key Vault name is not configured.") + logging.warn(f"Key Vault name is not configured.") + return secret_value if source not in supported_sources: logging.error(f"Source '{source}' is not supported. Supported sources: {supported_sources}") @@ -148,7 +149,7 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl return full_secret_name except Exception as e: logging.error(f"Failed to store secret '{full_secret_name}' in Key Vault: {str(e)}") - raise Exception(f"Failed to store secret '{full_secret_name}' in Key Vault: {str(e)}") from e + return secret_value def build_full_secret_name(secret_name, scope_value, source, scope): """ @@ -267,13 +268,13 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_ac if validate_secret_name_dynamic(value): try: if return_actual_key: - actual_key = retrieve_secret_from_key_vault(value) + actual_key = retrieve_secret_from_key_vault_by_full_name(value) updated[key] = actual_key else: updated[key] = ui_trigger_word except Exception as e: logging.error(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") - raise Exception(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") + return updated return updated def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): @@ -375,8 +376,8 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ new_auth['key'] = ui_trigger_word updated['auth'] = new_auth except Exception as e: - logging.error(f"Failed to retrieve plugin key from Key Vault: {e}") - raise Exception(f"Failed to retrieve plugin key from Key Vault: {e}") + logging.error(f"Failed to retrieve action key from Key Vault: {e}") + raise Exception(f"Failed to retrieve action key from Key Vault: {e}") additional_fields = updated.get('additionalFields', {}) if isinstance(additional_fields, dict): @@ -393,8 +394,8 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ else: new_additional_fields[k] = ui_trigger_word except Exception as e: - logging.error(f"Failed to retrieve plugin additionalField secret '{k}' from Key Vault: {e}") - raise Exception(f"Failed to retrieve plugin additionalField secret '{k}' from Key Vault: {e}") + logging.error(f"Failed to retrieve action additionalField secret '{k}' from Key Vault: {e}") + raise Exception(f"Failed to retrieve action additionalField secret '{k}' from Key Vault: {e}") updated['additionalFields'] = new_additional_fields return updated # Helper to delete plugin secrets from Key Vault @@ -413,12 +414,13 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): Raises: """ if scope not in supported_scopes: - logging.error(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") + log_event(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}", level="WARNING") raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") settings = get_settings() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) key_vault_name = settings.get("key_vault_name", None) if not enable_key_vault_secret_storage or not key_vault_name: + log_event(f"Key Vault secret storage is not enabled or key vault name is missing.", level="WARNING") return plugin_dict source = "action" plugin_name = plugin_dict.get('name', 'plugin') @@ -429,11 +431,12 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): if validate_secret_name_dynamic(secret_name): try: key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + log_event(f"Deleting action secret '{secret_name}' for action '{plugin_name}' for '{scope}' '{scope_value}'", level="INFO") client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) client.begin_delete_secret(secret_name) except Exception as e: - logging.error(f"Error deleting plugin secret '{secret_name}' for plugin '{plugin_name}': {e}") - raise Exception(f"Error deleting plugin secret '{secret_name}' for plugin '{plugin_name}': {e}") + logging.error(f"Error deleting action secret '{secret_name}' for action '{plugin_name}': {e}") + raise Exception(f"Error deleting action secret '{secret_name}' for action '{plugin_name}': {e}") additional_fields = plugin_dict.get('additionalFields', {}) if isinstance(additional_fields, dict): @@ -445,11 +448,12 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): try: keyvault_secret_name = build_full_secret_name(akv_key, scope_value, addset_source, scope) key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + log_event(f"Deleting action additionalField secret '{k}' for action '{plugin_name}' for '{scope}' '{scope_value}'", level="INFO") client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) client.begin_delete_secret(keyvault_secret_name) except Exception as e: - logging.error(f"Error deleting plugin additionalField secret '{k}' for plugin '{plugin_name}': {e}") - raise Exception(f"Error deleting plugin additionalField secret '{k}' for plugin '{plugin_name}': {e}") + logging.error(f"Error deleting action additionalField secret '{k}' for action '{plugin_name}': {e}") + raise Exception(f"Error deleting action additionalField secret '{k}' for action '{plugin_name}': {e}") return plugin_dict # Helper to delete agent secrets from Key Vault @@ -482,6 +486,7 @@ def keyvault_agent_delete_helper(agent_dict, scope_value, scope="global"): if validate_secret_name_dynamic(secret_name): try: key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + log_event(f"Deleting agent secret '{secret_name}' for agent '{agent_name}' for '{scope}' '{scope_value}'", level="INFO") client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) client.begin_delete_secret(secret_name) except Exception as e: diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index b9cbaa640..42c9a8a05 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -11,9 +11,12 @@ from datetime import datetime from azure.cosmos import exceptions from flask import current_app +from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper +from functions_settings import get_user_settings, update_user_settings +from config import cosmos_personal_actions_container import logging -def get_personal_actions(user_id): +def get_personal_actions(user_id, return_actual_key=False): """ Fetch all personal actions/plugins for a user. @@ -24,8 +27,6 @@ def get_personal_actions(user_id): list: List of action/plugin dictionaries """ try: - from config import cosmos_personal_actions_container - query = "SELECT * FROM c WHERE c.user_id = @user_id" parameters = [{"name": "@user_id", "value": user_id}] @@ -35,12 +36,12 @@ def get_personal_actions(user_id): partition_key=user_id )) - # Remove Cosmos metadata for cleaner response + # Remove Cosmos metadata for cleaner response and resolve Key Vault references cleaned_actions = [] for action in actions: cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) cleaned_actions.append(cleaned_action) - return cleaned_actions except exceptions.CosmosResourceNotFoundError: @@ -49,7 +50,7 @@ def get_personal_actions(user_id): current_app.logger.error(f"Error fetching personal actions for user {user_id}: {e}") return [] -def get_personal_action(user_id, action_id): +def get_personal_action(user_id, action_id, return_actual_key=False): """ Fetch a specific personal action/plugin. @@ -61,9 +62,6 @@ def get_personal_action(user_id, action_id): dict: Action dictionary or None if not found """ try: - from config import cosmos_personal_actions_container - - # Try to find by ID first try: action = cosmos_personal_actions_container.read_item( item=action_id, @@ -87,8 +85,9 @@ def get_personal_action(user_id, action_id): return None action = actions[0] - # Remove Cosmos metadata + # Remove Cosmos metadata and resolve Key Vault references cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) return cleaned_action except Exception as e: @@ -107,8 +106,6 @@ def save_personal_action(user_id, action_data): dict: Saved action data with ID """ try: - from config import cosmos_personal_actions_container - # Check if an action with this name already exists existing_action = None if 'name' in action_data and action_data['name']: @@ -146,8 +143,9 @@ def save_personal_action(user_id, action_data): elif 'type' not in action_data['auth']: action_data['auth']['type'] = 'identity' + # Store secrets in Key Vault before upsert + action_data = keyvault_plugin_save_helper(action_data, scope_value=user_id, scope="user") result = cosmos_personal_actions_container.upsert_item(body=action_data) - # Remove Cosmos metadata from response cleaned_result = {k: v for k, v in result.items() if not k.startswith('_')} return cleaned_result @@ -168,13 +166,13 @@ def delete_personal_action(user_id, action_id): bool: True if deleted, False if not found """ try: - from config import cosmos_personal_actions_container - # Try to find the action first to get the correct ID action = get_personal_action(user_id, action_id) if not action: return False + # Delete secrets from Key Vault before deleting the action + keyvault_plugin_delete_helper(action, scope_value=user_id, scope="user") cosmos_personal_actions_container.delete_item( item=action['id'], partition_key=user_id @@ -199,8 +197,6 @@ def ensure_migration_complete(user_id): int: Number of actions migrated (0 if already migrated) """ try: - from functions_settings import get_user_settings, update_user_settings - user_settings = get_user_settings(user_id) plugins = user_settings.get('settings', {}).get('plugins', []) @@ -237,8 +233,6 @@ def migrate_actions_from_user_settings(user_id): int: Number of actions migrated """ try: - from functions_settings import get_user_settings, update_user_settings - user_settings = get_user_settings(user_id) plugins = user_settings.get('settings', {}).get('plugins', []) @@ -253,14 +247,13 @@ def migrate_actions_from_user_settings(user_id): if plugin.get('name') in existing_action_names: current_app.logger.info(f"Skipping migration of plugin '{plugin.get('name')}' - already exists") continue - # Ensure plugin has an ID (generate GUID if missing) if 'id' not in plugin or not plugin['id']: plugin['id'] = str(uuid.uuid4()) - + # Store secrets in Key Vault before migration + plugin = keyvault_plugin_save_helper(plugin, scope_value=user_id, scope="user") save_personal_action(user_id, plugin) migrated_count += 1 - except Exception as e: current_app.logger.error(f"Error migrating plugin {plugin.get('name', 'unknown')} for user {user_id}: {e}") @@ -276,7 +269,7 @@ def migrate_actions_from_user_settings(user_id): current_app.logger.error(f"Error during action migration for user {user_id}: {e}") return 0 -def get_actions_by_names(user_id, action_names): +def get_actions_by_names(user_id, action_names, return_actual_key=False): """ Get multiple actions by their names. @@ -288,8 +281,6 @@ def get_actions_by_names(user_id, action_names): list: List of action dictionaries """ try: - from config import cosmos_personal_actions_container - if not action_names: return [] @@ -311,6 +302,7 @@ def get_actions_by_names(user_id, action_names): cleaned_actions = [] for action in actions: cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) cleaned_actions.append(cleaned_action) return cleaned_actions @@ -319,7 +311,7 @@ def get_actions_by_names(user_id, action_names): current_app.logger.error(f"Error fetching actions by names for user {user_id}: {e}") return [] -def get_actions_by_type(user_id, action_type): +def get_actions_by_type(user_id, action_type, return_actual_key=False): """ Get all actions of a specific type for a user. @@ -331,8 +323,6 @@ def get_actions_by_type(user_id, action_type): list: List of action dictionaries """ try: - from config import cosmos_personal_actions_container - query = "SELECT * FROM c WHERE c.user_id = @user_id AND c.type = @type" parameters = [ {"name": "@user_id", "value": user_id}, @@ -349,6 +339,7 @@ def get_actions_by_type(user_id, action_type): cleaned_actions = [] for action in actions: cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) cleaned_actions.append(cleaned_action) return cleaned_actions diff --git a/application/single_app/json_schema_validation.py b/application/single_app/json_schema_validation.py index 4cda4da2e..6231540f1 100644 --- a/application/single_app/json_schema_validation.py +++ b/application/single_app/json_schema_validation.py @@ -43,7 +43,7 @@ def validate_plugin(plugin): validator = Draft7Validator(schema['definitions']['Plugin']) errors = sorted(validator.iter_errors(plugin_copy), key=lambda e: e.path) if errors: - return '; '.join([e.message for e in errors]) + return '; '.join([f"{plugin.name}: {e.message}" for e in errors]) # Additional business logic validation # For non-SQL plugins, endpoint must not be empty diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index d77d40347..5edcba1b6 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -18,8 +18,9 @@ from functions_global_actions import * from functions_personal_actions import * +#from functions_personal_actions import delete_personal_action - +from functions_debug import debug_print from json_schema_validation import validate_plugin def discover_plugin_types(): @@ -109,6 +110,7 @@ def get_plugin_types(): safe_manifest = {} # Only add minimal required fields based on plugin type + #TODO: This can be improved by ensuring we have additional fields from the schemas we have not created if needed. if 'databricks' in module_name.lower(): safe_manifest = { 'endpoint': 'https://example.databricks.com', @@ -151,12 +153,15 @@ def get_plugin_types(): try: plugin_instance = obj(safe_manifest) except (TypeError, ValueError, KeyError) as e: + debug_print(f"[RBEP] Failed to instantiate {attr} with safe manifest: {e}") try: plugin_instance = obj({}) except (TypeError, ValueError) as e2: + debug_print(f"[RBEP] Failed to instantiate {attr} with empty manifest: {e2}") try: plugin_instance = obj() except Exception as e3: + debug_print(f"[RBEP] Failed to instantiate {attr} with no args: {e3}") instantiation_error = e3 except Exception as e: instantiation_error = e @@ -339,9 +344,6 @@ def set_user_plugins(): def delete_user_plugin(plugin_name): user_id = get_current_user_id() - # Import the new personal actions functions - from functions_personal_actions import delete_personal_action - # Try to delete from personal_actions container deleted = delete_personal_action(user_id, plugin_name) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 55a5a33c1..3314761a2 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -5,6 +5,12 @@ - Registers plugins with the Semantic Kernel instance """ +import logging +import importlib +import os +import importlib.util +import inspect +import builtins from agent_orchestrator_groupchat import OrchestratorAgent, SCGroupChatManager from semantic_kernel import Kernel from semantic_kernel.agents import Agent @@ -25,12 +31,6 @@ from semantic_kernel_plugins.smart_http_plugin import SmartHttpPlugin from functions_debug import debug_print from flask import g -import logging -import importlib -import os -import importlib.util -import inspect -import builtins from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_keyvault_by_full_name from functions_global_actions import get_global_actions from functions_global_agents import get_global_agents @@ -449,7 +449,7 @@ def initialize_semantic_kernel(user_id: str=None, redis_client=None): ) debug_print(f"[SK Loader] Semantic Kernel Agent and Plugins loading completed.") -def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_id=None): +def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="global", user_id=None): """ Load specific plugins by name for an agent with enhanced logging. @@ -460,25 +460,26 @@ def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_ user_id: User ID for per-user mode """ if not plugin_names: + debug_print(f"[SK Loader] No plugin names provided to load_agent_specific_plugins") return print(f"[SK Loader] Loading {len(plugin_names)} agent-specific plugins: {plugin_names}") try: + merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) # Create logged plugin loader for enhanced logging logged_loader = create_logged_plugin_loader(kernel) - global_plugins = get_global_actions() if mode_label == "per-user": if user_id: - all_plugin_manifests = get_personal_actions(user_id) - personal_action_names = {p.get('name') for p in plugin_manifests} - for g in global_plugins: - if g.get('name') not in personal_action_names: - plugin_manifests.append(g) - print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} personal plugin manifests for user {user_id}") + all_plugin_manifests = get_personal_actions(user_id, return_actual_key=True) + if merge_global: + global_plugins = get_global_actions(return_actual_key=True) + for g in global_plugins: + all_plugin_manifests.append(g) + debug_print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} personal plugin manifests for user {user_id}") else: - print(f"[SK Loader] Warning: No user_id provided for per-user plugin loading") + debug_print(f"[SK Loader] Warning: No user_id provided for per-user plugin loading") all_plugin_manifests = [] else: # Global mode - get from global actions container @@ -491,6 +492,18 @@ def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_ p for p in all_plugin_manifests if p.get('name') in plugin_names or p.get('id') in plugin_names ] + + debug_print(f"[SK Loader] Filtered to {len(plugin_manifests)} plugin manifests after matching names/IDs") + debug_print(f"[SK Loader] Plugin manifests to load: {plugin_manifests}") + + if settings.get("enable_key_vault_secret_storage", False) and settings.get("key_vault_name"): + debug_print(f"[SK Loader] Resolving Key Vault secrets in plugin manifests if needed") + try: + plugin_manifests = [resolve_key_vault_secrets_in_plugins(p, settings) for p in plugin_manifests] + debug_print(f"[SK Loader] Resolved Key Vault secrets in plugin manifests {plugin_manifests}") + except Exception as e: + log_event(f"[SK Loader] Failed to resolve Key Vault secrets in plugin manifests: {e}", level=logging.ERROR, exceptionTraceback=True) + print(f"[SK Loader] Failed to resolve Key Vault secrets in plugin manifests: {e}") if not plugin_manifests: print(f"[SK Loader] Warning: No plugin manifests found for names/IDs: {plugin_names}") @@ -535,33 +548,38 @@ def load_agent_specific_plugins(kernel, plugin_names, mode_label="global", user_ except Exception as e: log_event( - f"[SK Loader] Error in agent-specific plugin loading: {e}", + f"[SK Loader][Error] Error in agent-specific plugin loading: {e}", extra={"error": str(e), "mode": mode_label, "user_id": user_id, "plugin_names": plugin_names}, level=logging.ERROR, exceptionTraceback=True ) + print(f"[SK Loader][Error] Error in agent-specific plugin loading: {e}") # Fallback to original method - log_event("[SK Loader] Falling back to original plugin loading method due to error", level=logging.WARNING) try: # Get plugin manifests again for fallback if mode_label == "per-user": if user_id: - all_plugin_manifests = get_personal_actions(user_id) + all_plugin_manifests = get_personal_actions(user_id, return_actual_key=True) + if merge_global: + global_plugins = get_global_actions(return_actual_key=True) + for g in global_plugins: + all_plugin_manifests.append(g) else: all_plugin_manifests = [] else: - all_plugin_manifests = get_global_actions() - + all_plugin_manifests = get_global_actions(return_actual_key=True) + plugin_manifests = [p for p in all_plugin_manifests if p.get('name') in plugin_names] _load_agent_plugins_original_method(kernel, plugin_manifests, mode_label) except Exception as fallback_error: log_event( - f"[SK Loader] Fallback plugin loading also failed: {fallback_error}", + f"[SK Loader][Error] Fallback plugin loading also failed: {fallback_error}", extra={"error": str(fallback_error), "mode": mode_label, "user_id": user_id}, level=logging.ERROR, exceptionTraceback=True ) + print(f"[SK Loader][Error] Fallback plugin loading also failed: {fallback_error}") def _load_agent_plugins_original_method(kernel, plugin_manifests, mode_label="global"): @@ -741,7 +759,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis plugin_mode = "global" if agent_is_global else mode_label user_id = get_current_user_id() if not agent_is_global else None print(f"[SK Loader] Agent is_global: {agent_is_global}, using plugin_mode: {plugin_mode}") - load_agent_specific_plugins(kernel, agent_config["actions_to_load"], plugin_mode, user_id=user_id) + load_agent_specific_plugins(kernel, agent_config["actions_to_load"], settings, plugin_mode, user_id=user_id) try: kwargs = { @@ -810,7 +828,7 @@ def resolve_key_vault_secrets_in_plugins(plugin_manifest, settings): def resolve_value(value): if isinstance(value, str) and validate_secret_name_dynamic(value): - resolved = retrieve_secret_from_keyvault(kv_name, value) + resolved = retrieve_secret_from_keyvault_by_full_name(value) if resolved: return resolved else: @@ -838,7 +856,6 @@ def load_plugins_for_kernel(kernel, plugin_manifests, settings, mode_label="glob plugin_manifests = [resolve_key_vault_secrets_in_plugins(p, settings) for p in plugin_manifests] except Exception as e: log_event(f"[SK Loader] Failed to resolve Key Vault secrets in plugin manifests: {e}", level=logging.ERROR, exceptionTraceback=True) - print(f"[SK Loader] Failed to resolve Key Vault secrets in plugin manifests: {e}") # Create logged plugin loader for enhanced logging logged_loader = create_logged_plugin_loader(kernel) @@ -1090,11 +1107,11 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie level=logging.INFO) # Ensure migration is complete (will migrate any remaining legacy data) ensure_actions_migration_complete(user_id) - plugin_manifests = get_personal_actions(user_id) + plugin_manifests = get_personal_actions(user_id, return_actual_key=True) # PATCH: Merge global plugins if enabled if merge_global: - global_plugins = get_global_actions() + global_plugins = get_global_actions(return_actual_key=True) # User plugins take precedence all_plugins = {p.get('name'): p for p in plugin_manifests} all_plugins.update({p.get('name'): p for p in global_plugins}) @@ -1208,6 +1225,7 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie f"[SK Loader] User {user_id} No agent found matching global selected agent: {global_selected_agent_name}", level=logging.WARNING ) + # If still not found, DON'T use first agent - only load when explicitly selected if agent_cfg is None and agents_cfg: debug_print(f"[SK Loader] User {user_id} Agent selection final status: agent_cfg is None") @@ -1241,7 +1259,7 @@ def load_semantic_kernel(kernel: Kernel, settings): # Conditionally load core plugins based on settings - plugin_manifests = get_global_actions() + plugin_manifests = get_global_actions(return_actual_key=True) log_event(f"[SK Loader] Found {len(plugin_manifests)} plugin manifests", level=logging.INFO) # --- Dynamic Plugin Type Loading (semantic_kernel_plugins) --- diff --git a/application/single_app/semantic_kernel_plugins/databricks_table_example.json b/application/single_app/semantic_kernel_plugins/databricks_table_example.json deleted file mode 100644 index 32cfecdd8..000000000 --- a/application/single_app/semantic_kernel_plugins/databricks_table_example.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "users_table", - "type": "databricks_table", - "description": "Query the users table in Databricks.", - "endpoint": "https:///api/2.0/sql/statements", - "auth": { - "type": "key", // Authentication type, can be 'key' or 'identity', etc. - "key": "", - "managedIdentity": "" // Optional, if using identity-based auth - }, - "metadata": {}, - "additionalFields": { - "table": "users", - "warehouse_id": "", - "columns": [ - { - "name": "id", - "type": "int", - "description": "User ID" - }, - { - "name": "name", - "type": "string", - "description": "User's full name" - }, - { - "name": "email", - "type": "string", - "description": "User's email address" - } - ] - } -} \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py index 5b2c7193a..30b2dfccf 100644 --- a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py +++ b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py @@ -13,13 +13,12 @@ from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_plugin import KernelPlugin from semantic_kernel_plugins.base_plugin import BasePlugin -from semantic_kernel_plugins.plugin_invocation_logger import ( - get_plugin_logger, - plugin_function_logger, - auto_wrap_plugin_functions -) +from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger, plugin_function_logger, auto_wrap_plugin_functions +from semantic_kernel_plugins.plugin_loader import discover_plugins from functions_appinsights import log_event - +from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory +from semantic_kernel_plugins.sql_schema_plugin import SQLSchemaPlugin +from semantic_kernel_plugins.sql_query_plugin import SQLQueryPlugin class LoggedPluginLoader: """Enhanced plugin loader that automatically adds invocation logging.""" @@ -48,17 +47,21 @@ def load_plugin_from_manifest(self, manifest: Dict[str, Any], log_event(f"[Logged Plugin Loader] Starting to load plugin: {plugin_name} (type: {plugin_type})") if not plugin_name: - self.logger.error("Plugin manifest missing required 'name' field") + log_event(f"[Logged Plugin Loader] Plugin manifest missing required 'name' field", level=logging.ERROR) return False try: # Load the plugin instance + debug_print(f"[Logged Plugin Loader] Creating plugin instance for {plugin_name} of type {plugin_type}") plugin_instance = self._create_plugin_instance(manifest) + debug_print(f"[Logged Plugin Loader] Created plugin instance: {plugin_instance}") if not plugin_instance: + debug_print(f"[Logged Plugin Loader] Failed to create plugin instance for {plugin_name} of type {plugin_type}") return False # Enable logging if the plugin supports it if hasattr(plugin_instance, 'enable_invocation_logging'): + debug_print(f"[Logged Plugin Loader] Enabling invocation logging for {plugin_name}") plugin_instance.enable_invocation_logging(True) # Auto-wrap plugin functions with logging @@ -76,7 +79,7 @@ def load_plugin_from_manifest(self, manifest: Dict[str, Any], self._register_plugin_with_kernel(plugin_instance, plugin_name) log_event( - f"[Plugin Loader] Successfully loaded plugin: {plugin_name}", + f"[Logged Plugin Loader] Successfully loaded plugin: {plugin_name}", extra={ "plugin_name": plugin_name, "plugin_type": plugin_type, @@ -90,7 +93,7 @@ def load_plugin_from_manifest(self, manifest: Dict[str, Any], except Exception as e: log_event( - f"[Plugin Loader] Failed to load plugin: {plugin_name}", + f"[Logged Plugin Loader] Failed to load plugin: {plugin_name}", extra={ "plugin_name": plugin_name, "plugin_type": plugin_type, @@ -112,13 +115,40 @@ def _create_plugin_instance(self, manifest: Dict[str, Any]): return self._create_openapi_plugin(manifest) elif plugin_type == 'python': return self._create_python_plugin(manifest) - elif plugin_type == 'custom': - return self._create_custom_plugin(manifest) - elif plugin_type in ['sql_schema', 'sql_query']: - return self._create_sql_plugin(manifest) + #elif plugin_type in ['sql_schema', 'sql_query']: + # return self._create_sql_plugin(manifest) else: - self.logger.warning(f"Unknown plugin type: {plugin_type} for plugin: {plugin_name}") - return None + try: + debug_print("[Logged Plugin Loader] Attempting to discover plugin type:", plugin_type) + discovered_plugins = discover_plugins() + plugin_type = manifest.get('type') + name = manifest.get('name') + description = manifest.get('description', '') + # Normalize for matching + def normalize(s): + return s.replace('_', '').replace('-', '').replace('plugin', '').lower() if s else '' + debug_print("[Logged Plugin Loader] Normalizing plugin type for matching:", plugin_type) + normalized_type = normalize(plugin_type) + debug_print(f"[Logged Plugin Loader] Normalized plugin type: {normalized_type}") + matched_class = None + for class_name, cls in discovered_plugins.items(): + normalized_class = normalize(class_name) + print("[Logged Plugin Loader] Checking plugin class:", class_name, "normalized:", normalized_class) + if normalized_type == normalized_class or normalized_type in normalized_class: + matched_class = cls + break + debug_print(f"[Logged Plugin Loader] Matched class for plugin '{name}' of type '{plugin_type}': {matched_class}") + if matched_class: + try: + plugin = matched_class(manifest) if 'manifest' in matched_class.__init__.__code__.co_varnames else matched_class() + log_event(f"[Logged Plugin Loader] Instanced plugin: {name} (type: {plugin_type}) [{mode_label}]", {"plugin_name": name, "plugin_type": plugin_type}, level=logging.INFO) + return plugin + except Exception as e: + log_event(f"[Logged Plugin Loader] Failed to instantiate plugin: {name}: {e}", {"plugin_name": name, "plugin_type": plugin_type, "error": str(e)}, level=logging.ERROR, exceptionTraceback=True) + else: + log_event(f"[Logged Plugin Loader] Unknown plugin type: {plugin_type} for plugin '{name}' [{mode_label}]", {"plugin_name": name, "plugin_type": plugin_type}, level=logging.WARNING) + except Exception as e: + log_event(f"[Logged Plugin Loader] Error discovering plugin types for {mode_label} mode: {e}", {"error": str(e)}, level=logging.ERROR, exceptionTraceback=True) def _create_openapi_plugin(self, manifest: Dict[str, Any]): """Create an OpenAPI plugin instance.""" @@ -126,7 +156,6 @@ def _create_openapi_plugin(self, manifest: Dict[str, Any]): log_event(f"[Logged Plugin Loader] Attempting to create OpenAPI plugin: {plugin_name}", level=logging.DEBUG) try: - from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory log_event(f"[Logged Plugin Loader] Successfully imported OpenApiPluginFactory", level=logging.DEBUG) log_event(f"[Logged Plugin Loader] Creating OpenAPI plugin using factory", @@ -176,22 +205,14 @@ def _create_python_plugin(self, manifest: Dict[str, Any]): self.logger.error(f"Failed to create Python plugin {class_name} from {module_name}: {e}") return None - def _create_custom_plugin(self, manifest: Dict[str, Any]): - """Create a custom plugin instance.""" - # This is where you'd handle custom plugin types specific to your application - self.logger.warning(f"Custom plugin type not yet implemented: {manifest}") - return None - def _create_sql_plugin(self, manifest: Dict[str, Any]): """Create a SQL plugin instance.""" plugin_type = manifest.get('type') try: if plugin_type == 'sql_schema': - from semantic_kernel_plugins.sql_schema_plugin import SQLSchemaPlugin return SQLSchemaPlugin(manifest) elif plugin_type == 'sql_query': - from semantic_kernel_plugins.sql_query_plugin import SQLQueryPlugin return SQLQueryPlugin(manifest) else: self.logger.error(f"Unknown SQL plugin type: {plugin_type}") diff --git a/application/single_app/semantic_kernel_plugins/queue_storage_plugin.py b/application/single_app/semantic_kernel_plugins/queue_storage_plugin.py index f3ca9aad6..58e918bcd 100644 --- a/application/single_app/semantic_kernel_plugins/queue_storage_plugin.py +++ b/application/single_app/semantic_kernel_plugins/queue_storage_plugin.py @@ -10,7 +10,7 @@ def __init__(self, manifest: Dict[str, Any]): super().__init__(manifest) self.manifest = manifest self.endpoint = manifest.get('endpoint') - self.queue_name = manifest.get('queue_name') + self.queue_name = manifest.get('additional_settings', {}).get('queue_name') self.key = manifest.get('auth', {}).get('key') self.auth_type = manifest.get('auth', {}).get('type', 'key') self._metadata = manifest.get('metadata', {}) diff --git a/application/single_app/semantic_kernel_plugins/ui_test_plugin.py b/application/single_app/semantic_kernel_plugins/ui_test_plugin.py new file mode 100644 index 000000000..f2161fe7e --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/ui_test_plugin.py @@ -0,0 +1,80 @@ +""" +SQL Schema Plugin for Semantic Kernel +- Connects to various SQL databases (SQL Server, PostgreSQL, MySQL, SQLite) +- Extracts schema information (tables, columns, data types, relationships) +- Provides structured schema data for query generation +""" + +import json +import logging +from typing import Dict, Any, List, Optional, Union +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel.functions import kernel_function +from functions_appinsights import log_event +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger +from functions_debug import debug_print + +# Helper class to wrap results with metadata +class ResultWithMetadata: + def __init__(self, data, metadata): + self.data = data + self.metadata = metadata + def __str__(self): + return str(self.data) + def __repr__(self): + return f"ResultWithMetadata(data={self.data!r}, metadata={self.metadata!r})" + +class UITestPlugin(BasePlugin): + def __init__(self, manifest: Dict[str, Any]): + super().__init__(manifest) + + @property + def display_name(self) -> str: + return "UI Test Plugin" + + @property + def metadata(self) -> Dict[str, Any]: + return { + "name": "ui_test_plugin", + "type": "ui_test", + "description": "A plugin for UI testing and demonstration purposes.", + "methods": [ + { + "name": "greet_user", + "description": "Returns a greeting message.", + "parameters": [ + {"name": "name", "type": "str", "description": "Name to greet.", "required": True} + ], + "returns": {"type": "str", "description": "Greeting message."} + }, + { + "name": "farewell_user", + "description": "Returns a farewell message.", + "parameters": [ + {"name": "name", "type": "str", "description": "Name to bid farewell.", "required": True} + ], + "returns": {"type": "str", "description": "Farewell message."} + }, + { + "name": "get_manifest", + "description": "Returns the plugin manifest.", + "parameters": [], + "returns": {"type": "str", "description": "Manifest as JSON string."} + } + ] + } + + @kernel_function(description="A function that returns a greeting message.") + @plugin_function_logger("UITestPlugin") + def greet_user(self, name: str) -> str: + return f"Hello, {name}!" + + @kernel_function(description="A function that returns a farewell message.") + @plugin_function_logger("UITestPlugin") + def farewell_user(self, name: str) -> str: + return f"Goodbye, {name}!" + + @kernel_function(description="A function that returns the plugin manifest") + @plugin_function_logger("UITestPlugin") + def get_manifest(self) -> str: + return json.dumps(self.manifest, indent=2) \ No newline at end of file diff --git a/application/single_app/static/js/admin/admin_plugins.js b/application/single_app/static/js/admin/admin_plugins.js index 682d329d2..ad497f621 100644 --- a/application/single_app/static/js/admin/admin_plugins.js +++ b/application/single_app/static/js/admin/admin_plugins.js @@ -55,7 +55,11 @@ function setupSaveHandler(plugin, modal) { saveBtn.onclick = async (event) => { event.preventDefault(); - + const errorDiv = document.getElementById('plugin-modal-error'); + if (errorDiv) { + errorDiv.classList.add('d-none'); + errorDiv.textContent = ''; + } try { // Get form data from the stepper const formData = window.pluginModalStepper.getFormData(); @@ -67,8 +71,19 @@ function setupSaveHandler(plugin, modal) { return; } + const originalText = saveBtn.innerHTML; + saveBtn.innerHTML = `Saving...`; + saveBtn.disabled = true; // Save the action - await savePlugin(formData, plugin); + try { + await savePlugin(formData, plugin); + } catch (error) { + window.pluginModalStepper.showError(error.message); + return; + } finally { + saveBtn.innerHTML = originalText; + saveBtn.disabled = false; + } // Close modal and refresh if (modal && typeof modal.hide === 'function') { diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index 28a22a64c..41111c26a 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -1165,12 +1165,22 @@ export class AgentModalStepper { } // Use appropriate endpoint and save method based on context - if (this.isAdmin) { - // Admin context - save to global agents - await this.saveGlobalAgent(agentData); - } else { - // User context - save to personal agents - await this.savePersonalAgent(agentData); + let saveBtn = document.getElementById('agent-modal-save-btn'); + const originalText = saveBtn.innerHTML; + saveBtn.innerHTML = `Saving...`; + saveBtn.disabled = true; + try { + if (this.isAdmin) { + // Admin context - save to global agents + await this.saveGlobalAgent(agentData); + } else { + // User context - save to personal agents + await this.savePersonalAgent(agentData); + } + //No catch to allow outer catch to handle errors + } finally { + saveBtn.innerHTML = originalText; + saveBtn.disabled = false; } } catch (error) { diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 955af056e..9e6826f9b 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -3,6 +3,8 @@ import { showToast } from "./chat/chat-toast.js"; export class PluginModalStepper { + + constructor() { this.currentStep = 1; this.maxSteps = 5; @@ -13,10 +15,62 @@ export class PluginModalStepper { this.itemsPerPage = 12; this.filteredTypes = []; this.originalPlugin = null; // Store original state for change tracking - + this.pluginSchemaCache = null; // Will hold plugin.schema.json + this.additionalSettingsSchemaCache = {}; // Cache for additional settings schemas + this.lastAdditionalFieldsType = null; // Track last type to avoid unnecessary redraws + this.defaultAuthTypes = ["key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"]; + + this._loadPluginSchema().then(() => { // Load schema on initialization + this._populateGenericAuthTypeDropdown(); // Dynamically populate generic auth type dropdown after schema loads (will be called again after schema loads) + }); this.bindEvents(); } + async _loadPluginSchema() { + try { + const res = await fetch('/static/json/schemas/plugin.schema.json'); + if (!res.ok) throw new Error('Failed to load plugin.schema.json'); + this.pluginSchemaCache = await res.json(); + } catch (err) { + console.error('Error loading plugin.schema.json:', err); + this.pluginSchemaCache = null; + } + } + + _populateGenericAuthTypeDropdown() { + // Only run if dropdown exists + const dropdown = document.getElementById('plugin-auth-type-generic'); + if (!dropdown) return; + // If schema not loaded, fallback to static options + if (!this.pluginSchemaCache) { + dropdown.innerHTML = ''; + this.defaultAuthTypes.forEach(type => { + const option = document.createElement('option'); + option.value = type; + option.textContent = this.formatAuthType(type); + dropdown.appendChild(option); + }); + return; + } + // Find the enum for generic auth type in the schema + let authTypeEnum = []; + if (this.pluginSchemaCache.properties && this.pluginSchemaCache.properties.authTypeGeneric) { + authTypeEnum = this.pluginSchemaCache.properties.authTypeGeneric.enum || []; + } + // Fallback: if not found, use a default + if (!authTypeEnum.length) { + authTypeEnum = this.defaultAuthTypes; + } + // Clear existing options + dropdown.innerHTML = ''; + authTypeEnum.forEach(type => { + const option = document.createElement('option'); + option.value = type; + option.textContent = this.formatAuthType(type); + dropdown.appendChild(option); + }); + } + bindEvents() { // Step navigation buttons document.getElementById('plugin-modal-next').addEventListener('click', () => this.nextStep()); @@ -368,12 +422,12 @@ export class PluginModalStepper { goToStep(stepNumber) { if (stepNumber < 1 || stepNumber > this.maxSteps) return; - + this.currentStep = stepNumber; this.showStep(stepNumber); this.updateStepIndicator(); this.updateNavigationButtons(); - + // Handle step-specific logic if (stepNumber === 3) { this.showConfigSectionForType(); @@ -426,6 +480,10 @@ export class PluginModalStepper { if (currentStepEl) { currentStepEl.classList.remove('d-none'); } + + if (stepNumber === 2) { + + } // Update step 3 title based on plugin type if (stepNumber === 3) { @@ -449,7 +507,45 @@ export class PluginModalStepper { } if (stepNumber === 4) { - // Only run for new plugins (not editing) + // Load additional settings schema for selected type + let options = {forceReload: true}; + this.getAdditionalSettingsSchema(this.selectedType, options); + const additionalFieldsDiv = document.getElementById('plugin-additional-fields-div'); + if (additionalFieldsDiv) { + // Only clear and rebuild if type changes + if (this.selectedType !== this.lastAdditionalFieldsType) { + additionalFieldsDiv.innerHTML = ''; + additionalFieldsDiv.classList.remove('d-none'); + if (this.selectedType) { + this.getAdditionalSettingsSchema(this.selectedType) + .then(schema => { + if (schema) { + this.buildAdditionalFieldsUI(schema, additionalFieldsDiv); + try { + if (this.isEditMode && this.originalPlugin && this.originalPlugin.additionalFields) { + this.populateDynamicAdditionalFields(this.originalPlugin.additionalFields); + } + } catch (error) { + console.error('Error populating dynamic additional fields:', error); + } + } else { + console.log('No additional settings schema found'); + additionalFieldsDiv.classList.add('d-none'); + } + }) + .catch(error => { + console.error(`Error fetching additional settings schema for type: ${this.selectedType} -- ${error}`); + additionalFieldsDiv.classList.add('d-none'); + }); + } else { + console.warn('No plugin type selected'); + additionalFieldsDiv.classList.add('d-none'); + } + this.lastAdditionalFieldsType = this.selectedType; + } + // Otherwise, preserve user data and do not redraw + } + if (!this.isEditMode) { const typeField = document.getElementById('plugin-type'); const selectedType = typeField && typeField.value ? typeField.value : null; @@ -458,13 +554,13 @@ export class PluginModalStepper { import('./plugin_common.js').then(module => { module.fetchAndMergePluginSettings(selectedType, {}).then(merged => { document.getElementById('plugin-metadata').value = merged.metadata ? JSON.stringify(merged.metadata, null, 2) : '{}'; - document.getElementById('plugin-additional-fields').value = merged.additionalFields ? JSON.stringify(merged.additionalFields, null, 2) : '{}'; + //document.getElementById('plugin-additional-fields').value = merged.additionalFields ? JSON.stringify(merged.additionalFields, null, 2) : '{}'; }); }); } else { // Fallback to empty objects if no type selected document.getElementById('plugin-metadata').value = '{}'; - document.getElementById('plugin-additional-fields').value = '{}'; + //document.getElementById('plugin-additional-fields').value = '{}'; } } } @@ -695,7 +791,7 @@ export class PluginModalStepper { case 4: // Validate JSON fields if (!this.validateJSONField('plugin-metadata', 'Metadata')) return false; - if (!this.validateJSONField('plugin-additional-fields', 'Additional Fields')) return false; + //if (!this.validateJSONField('plugin-additional-fields', 'Additional Fields')) return false; break; } @@ -885,33 +981,63 @@ export class PluginModalStepper { } toggleGenericAuthFields() { - const authType = document.getElementById('plugin-auth-type-generic').value; - const identityGroup = document.getElementById('auth-identity-group'); - const keyGroup = document.getElementById('auth-key-group'); - const tenantIdGroup = document.getElementById('auth-tenantid-group'); - - // Hide all groups first - [identityGroup, keyGroup, tenantIdGroup].forEach(group => { - if (group) group.style.display = 'none'; - }); - - // Show relevant groups based on auth type - switch (authType) { - case 'key': - if (keyGroup) keyGroup.style.display = 'flex'; - break; - case 'identity': - if (identityGroup) identityGroup.style.display = 'flex'; - break; - case 'servicePrincipal': - if (identityGroup) identityGroup.style.display = 'flex'; - if (keyGroup) keyGroup.style.display = 'flex'; - if (tenantIdGroup) tenantIdGroup.style.display = 'flex'; - break; - case 'user': - // No additional fields needed - break; + const dropdown = document.getElementById('plugin-auth-type-generic'); + if (!dropdown) return; + const authType = dropdown.value; + + // Get required fields for selected auth type from schema + let requiredFields = []; + // Defensive: find the correct schema location + const pluginDef = this.pluginSchemaCache?.definitions?.Plugin; + const authSchema = pluginDef?.properties?.auth; + if (authSchema && Array.isArray(authSchema.allOf)) { + for (const cond of authSchema.allOf) { + // Check if this allOf block matches the selected type + if (cond.if && cond.if.properties && cond.if.properties.type && cond.if.properties.type.const === authType) { + // Use the required array from then + if (cond.then && Array.isArray(cond.then.required)) { + requiredFields = cond.then.required.filter(f => f !== 'type'); + } + break; + } + } } + + // Map field keys to DOM groups + const fieldMap = { + identity: document.getElementById('auth-identity-group'), + key: document.getElementById('auth-key-group'), + tenantId: document.getElementById('auth-tenantid-group') + }; + + // Hide all groups first using d-none + Object.values(fieldMap).forEach(group => { if (group) group.classList.add('d-none'); }); + + // Show only required fields for selected auth type using d-none + requiredFields.forEach(field => { + if (fieldMap[field]) { + fieldMap[field].classList.remove('d-none'); + // Update label using mapping or schema description + const label = fieldMap[field].querySelector('span.input-group-text'); + console.log('Updating label for field:', field, 'Auth type:', authType, 'label:', label); + if (label) { + if (authType === 'username_password') { + if (field === 'key') label.textContent = 'Password'; + else if (field === 'identity') label.textContent = 'Username'; + } else if (authType === 'connection_string') { + if (field === 'key') label.textContent = 'Connection String'; + } else if (authType === 'servicePrincipal') { + if (field === 'key') label.textContent = 'Client Secret'; + else if (field === 'identity') label.textContent = 'Client ID'; + else if (field === 'tenantId') label.textContent = 'Tenant ID'; + } else { + if (field === 'key') label.textContent = 'Key'; + else if (field === 'identity') label.textContent = 'Identity'; + else if (field === 'tenantId') label.textContent = 'Tenant ID'; + } + } + } + }); } // SQL Plugin Configuration Methods @@ -1354,7 +1480,11 @@ export class PluginModalStepper { JSON.stringify(plugin.additionalFields, null, 2) : '{}'; document.getElementById('plugin-metadata').value = metadata; - document.getElementById('plugin-additional-fields').value = additionalFields; + try { + document.getElementById('plugin-additional-fields').value = additionalFields; + } catch (e) { + console.warn('Legacy additional fields accessed:', e); + } } getFormData() { @@ -1556,13 +1686,11 @@ export class PluginModalStepper { } } - // Parse existing additional fields and merge + // Collect additional fields from the dynamic UI try { - const additionalFieldsValue = document.getElementById('plugin-additional-fields').value.trim(); - const existingAdditionalFields = additionalFieldsValue ? JSON.parse(additionalFieldsValue) : {}; - additionalFields = { ...existingAdditionalFields, ...additionalFields }; + additionalFields = this.collectAdditionalFields(); } catch (e) { - throw new Error('Invalid additional fields JSON'); + throw new Error('Invalid additional fields input'); } let metadata = {}; @@ -1703,7 +1831,14 @@ export class PluginModalStepper { 'basic': 'Basic Authentication', 'oauth2': 'OAuth2', 'windows': 'Windows Authentication', - 'sql': 'SQL Authentication' + 'sql': 'SQL Authentication', + 'username_password': 'Username/Password', + 'key': 'Key', + 'identity': 'Identity', + 'user': 'User', + 'servicePrincipal': 'Service Principal', + 'connection_string': 'Connection String', + 'basic': 'Basic' }; return authTypeMap[authType] || authType; } @@ -1939,7 +2074,7 @@ export class PluginModalStepper { // Check if there's any metadata or additional fields const metadata = document.getElementById('plugin-metadata').value.trim(); - const additionalFields = document.getElementById('plugin-additional-fields').value.trim(); + //const additionalFields = document.getElementById('plugin-additional-fields').value.trim(); // Check if metadata/additional fields actually contain meaningful data (not just empty objects) let hasMetadata = false; @@ -1953,13 +2088,9 @@ export class PluginModalStepper { hasMetadata = metadata.length > 0 && metadata !== '{}'; } - try { - const additionalFieldsObj = JSON.parse(additionalFields || '{}'); - hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0; - } catch (e) { - // If it's not valid JSON, consider it as having content if it's not empty - hasAdditionalFields = additionalFields.length > 0 && additionalFields !== '{}'; - } + // DRY: Use private helper to collect additional fields + let additionalFieldsObj = this.collectAdditionalFields(); + hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0; // Update has metadata/additional fields indicators document.getElementById('summary-has-metadata').textContent = hasMetadata ? 'Yes' : 'No'; @@ -1977,7 +2108,13 @@ export class PluginModalStepper { // Show/hide additional fields preview const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview'); if (hasAdditionalFields) { - document.getElementById('summary-additional-fields-content').textContent = additionalFields; + let previewContent = ''; + if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) { + previewContent = JSON.stringify(additionalFieldsObj, null, 2); + } else { + previewContent = additionalFields; + } + document.getElementById('summary-additional-fields-content').textContent = previewContent; additionalFieldsPreview.style.display = ''; } else { additionalFieldsPreview.style.display = 'none'; @@ -2148,6 +2285,434 @@ export class PluginModalStepper { div.textContent = str; return div.innerHTML; } + + formatLabel(str) { + // Convert snake_case, camelCase, PascalCase to spaced words + return str + .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase, PascalCase + .replace(/_/g, ' ') // snake_case + .replace(/\b([A-Z]+)\b/g, match => match.charAt(0) + match.slice(1).toLowerCase()) // ALLCAPS to Capitalized + .replace(/^\w/, c => c.toUpperCase()); + } + + // Build the additional fields UI from a JSON schema + buildAdditionalFieldsUI(schema, parentDiv) { + // Utility to create a labeled field + const self = this; + // Render title and description + const title = document.createElement('h6'); + title.textContent = schema.title || 'Additional Settings'; + parentDiv.appendChild(title); + if (schema.description) { + const desc = document.createElement('p'); + desc.className = 'text-muted'; + desc.textContent = schema.description; + parentDiv.appendChild(desc); + } + // Render all top-level properties + if (schema.properties) { + Object.entries(schema.properties).forEach(([key, prop]) => { + if (prop.type === 'array') { + this.addArrayFieldUI(prop, key, parentDiv, prop.default || []); + } else if (prop.type === 'object') { + const wrapper = document.createElement('div'); + wrapper.className = 'additional-field-object'; + // Create a fieldset for the object + const fieldset = document.createElement('fieldset'); + fieldset.dataset.schemaKey = key; + // Optionally add a legend for the object + const legend = document.createElement('legend'); + legend.textContent = this.formatLabel(key); + fieldset.appendChild(legend); + // Render all sub-properties inside the fieldset + if (prop.properties) { + Object.entries(prop.properties).forEach(([subKey, subProp]) => { + this.createField(subKey, subProp, fieldset); + }); + } + wrapper.appendChild(fieldset); + parentDiv.appendChild(wrapper); + } else { + const wrapper = document.createElement('div'); + wrapper.className = 'additional-field-primitive'; + this.createField(key, prop, wrapper); + parentDiv.appendChild(wrapper); + } + }); + } + } + + // Recursively populate dynamic additional fields UI + populateDynamicAdditionalFields(fields, parentKey = '') { + if (!fields || typeof fields !== 'object') return; + if (this.additionalSettingsSchemaCache && this.selectedType && !this.additionalSettingsSchemaCache[this.getSafeType(this.selectedType)]) { + this.getAdditionalSettingsSchema(this.selectedType); + } + const schema = this.additionalSettingsSchemaCache && this.selectedType ? this.additionalSettingsSchemaCache[this.getSafeType(this.selectedType)] : null; + Object.entries(fields).forEach(([key, value]) => { + console.log('Processing field:', key, 'with value:', value, 'under parentKey:', parentKey); + let fieldName = key; + if (Array.isArray(value)) { + // Find array wrapper, add items if needed + let arrayWrapper = document.querySelector(`#plugin-additional-fields-div [data-schema-key="${fieldName}"]`); + if (!arrayWrapper) { + // Try to find schema for this array (assume you have access to schema) + if (this.additionalSettingsSchemaCache && this.selectedType) { + if (schema && schema.properties && schema.properties[fieldName] && schema.properties[fieldName].type === 'array') { + this.addArrayFieldUI(schema.properties[fieldName], fieldName, document.getElementById('plugin-additional-fields-div'), value); + arrayWrapper = document.querySelector(`#plugin-additional-fields-div [data-schema-key="${fieldName}"]`); + } + } + } + // Now populate each item + if (arrayWrapper) { + const itemsContainer = arrayWrapper.querySelector('.array-group'); + // Remove existing items + while (itemsContainer && itemsContainer.firstChild) itemsContainer.removeChild(itemsContainer.firstChild); + value.forEach(item => { + this.addArrayItemUI( + (schema && schema.properties && schema.properties[fieldName] && schema.properties[fieldName].items) || {}, + fieldName, + itemsContainer, + item + ); + }); + } + } else if (value && typeof value === 'object') { + this.populateDynamicAdditionalFields(value, fieldName); + } else { + let query = parentKey ? `#plugin-additional-fields-div [data-schema-key="${parentKey}"] [name="${fieldName}"]` : `#plugin-additional-fields-div [name="${fieldName}"]`; + console.log('Querying elements with:', query); + const elements = document.querySelectorAll(query); + console.log('Found elements for field', fieldName, ':', elements); + elements.forEach(el => { + console.log('Setting field:', fieldName, 'with value:', value, 'on element:', el); + if (el.type === 'checkbox') { + el.checked = !!value; + } else if (el.type === 'radio') { + el.checked = el.value == value; + } else if (el.tagName === 'SELECT') { + el.value = value; + } else if (el.tagName === 'TEXTAREA') { + el.value = value; + } else if (el.type === 'number') { + el.value = value !== undefined && value !== null ? Number(value) : ''; + } else { + el.value = value; + } + }); + } + }); + } + + // Private deep merge utility + deepMerge(target, source) { + for (const key in source) { + if (source[key] && typeof source[key] === 'object' && + !Array.isArray(source[key]) && target[key] && typeof target[key] === 'object' && + !Array.isArray(target[key]) + ) { + target[key] = this.deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + return target; + } + + // Private method to collect additional fields from both legacy textarea and dynamic UI + collectAdditionalFields() { + // 1. Get from textarea (legacy) + const additionalFieldsValue = document.getElementById('plugin-additional-fields')?.value?.trim() || ''; + let legacyFields = {}; + if (additionalFieldsValue && additionalFieldsValue !== '{}') { + try { + legacyFields = JSON.parse(additionalFieldsValue); + } catch { + // If not valid JSON, skip + } + } + + // 2. Get from dynamic UI + let uiFields = {}; + const additionalFieldsDiv = document.getElementById('plugin-additional-fields-div'); + if (additionalFieldsDiv) { + // Arrays + const arrayWrappers = additionalFieldsDiv.querySelectorAll('.additional-field-array'); + arrayWrappers.forEach(wrapper => { + const arrayGroup = wrapper.querySelector('.array-group'); + if (arrayGroup) { + const arrayKey = arrayGroup.dataset.schemaKey; + const items = []; + // Loop over each .array-item inside .array-group + const arrayItems = arrayGroup.querySelectorAll('.array-item'); + arrayItems.forEach(itemDiv => { + // Check for array of objects (fieldset present) + const fieldset = itemDiv.querySelector('fieldset'); + if (fieldset) { + let obj = {}; + const subInputs = fieldset.querySelectorAll('input, select, textarea'); + subInputs.forEach(subEl => { + let subKey = subEl.name || subEl.id; + if (!subKey) return; + let subValue = subEl.type === 'checkbox' ? subEl.checked : (subEl.type === 'number' ? (subEl.value !== '' ? Number(subEl.value) : '') : subEl.value); + obj[subKey] = subValue; + }); + items.push(obj); + } else { + // Primitive array: find first input/select/textarea directly inside .array-item (not in fieldset or button) + const possibleInputs = Array.from(itemDiv.querySelectorAll('input, select, textarea')); + // Exclude those inside a fieldset or button + const input = possibleInputs.find(el => { + // Not inside a fieldset or button + return !el.closest('fieldset') && !el.closest('button'); + }); + if (input) { + let subValue = input.type === 'checkbox' ? input.checked : (input.type === 'number' ? (input.value !== '' ? Number(input.value) : '') : input.value); + items.push(subValue); + } + } + }); + if (arrayKey) { + uiFields[arrayKey] = items; + } + } + }); + // Objects + const objectWrappers = additionalFieldsDiv.querySelectorAll('.additional-field-object'); + objectWrappers.forEach(wrapper => { + const objFieldset = wrapper.querySelector('fieldset'); + const objKey = objFieldset.dataset.schemaKey; + let obj = {}; + const subInputs = objFieldset.querySelectorAll('input, select, textarea'); + subInputs.forEach(subEl => { + let subKey = subEl.name || subEl.id; + if (!subKey) return; + let subValue = subEl.type === 'checkbox' ? subEl.checked : (subEl.type === 'number' ? (subEl.value !== '' ? Number(subEl.value) : '') : subEl.value); + obj[subKey] = subValue; + }); + if (objKey) { + uiFields[objKey] = obj; + } + }); + // Primitives + const primitiveWrappers = additionalFieldsDiv.querySelectorAll('.additional-field-primitive'); + primitiveWrappers.forEach(wrapper => { + const inputs = wrapper.querySelectorAll('input, select, textarea'); + inputs.forEach(input => { + let key = input.name || input.id; + let value = input.type === 'checkbox' ? input.checked : (input.type === 'number' ? (input.value !== '' ? Number(input.value) : '') : input.value); + uiFields[key] = value; + }); + }); + } + + // 3. Deep merge (UI fields take precedence) + return this.deepMerge(legacyFields, uiFields); + } + + getSafeType(type) { + return type ? type.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() : null; + } + + async getAdditionalSettingsSchema(type, options = {}) { + if (!type) return null; + const { useLegacyPattern = false, forceReload = false } = options; + // Normalize type for filename + const safeType = this.getSafeType(type); + // Choose filename pattern + const schemaFile = `${safeType}_plugin.additional_settings.schema.json` + + const schemaPath = `/static/json/schemas/${schemaFile}`; + + // Use cache unless forceReload + if (!forceReload && this.additionalSettingsSchemaCache[safeType]) { + return this.additionalSettingsSchemaCache[safeType]; + } + try { + console.log(`Fetching additional settings schema for type: ${safeType} (pattern: ${safeType})`); + const res = await fetch(schemaPath); + if (res.status === 404) { + console.log(`No additional settings schema found for type: ${type} (404)`); + this.additionalSettingsSchemaCache[safeType] = null; + return null; + } + if (!res.ok) throw new Error(`Failed to load additional settings schema for type: ${type}`); + const schema = await res.json(); + this.additionalSettingsSchemaCache[safeType] = schema; + return schema; + } catch (err) { + console.error(`Error loading additional settings schema for type ${type}:`, err); + this.additionalSettingsSchemaCache[safeType] = null; + return null; + } + } + + // Utility to create a labeled field (refactored from buildAdditionalFieldsUI) + createField(key, prop, parent, prefix = '') { + // If prefix is a number, treat as array index for uniqueness + let fieldId; + if (typeof prefix === 'number') { + fieldId = `${key}_${prefix}`; + } else { + fieldId = `${prefix}${key}`; + } + const wrapper = document.createElement('div'); + wrapper.className = 'mb-3'; + // Label with tooltip if description exists + const label = document.createElement('label'); + label.className = 'form-label'; + label.htmlFor = fieldId; + label.textContent = this.formatLabel(key); + if (prop.description) { + label.title = prop.description; + // Add help icon + const helpIcon = document.createElement('span'); + helpIcon.className = 'ms-1 bi bi-question-circle-fill text-info'; + helpIcon.setAttribute('tabindex', '0'); + helpIcon.setAttribute('data-bs-toggle', 'tooltip'); + helpIcon.setAttribute('title', prop.description); + label.appendChild(helpIcon); + } + wrapper.appendChild(label); + + let input; + if (prop.enum) { + input = document.createElement('select'); + input.className = 'form-select'; + input.id = fieldId; + input.name = key; + prop.enum.forEach(opt => { + const option = document.createElement('option'); + option.value = opt; + option.textContent = this.formatLabel(opt); + option.title = opt; + input.appendChild(option); + }); + if (prop.default) input.value = prop.default; + } else if (prop.type === 'boolean') { + input = document.createElement('input'); + input.type = 'checkbox'; + input.className = 'form-check-input'; + input.id = fieldId; + input.name = key; + input.checked = !!prop.default; + wrapper.className += ' form-check'; + } else if (prop.type === 'number' || prop.type === 'integer') { + input = document.createElement('input'); + input.type = 'number'; + input.className = 'form-control'; + input.id = fieldId; + input.name = key; + if (prop.minimum !== undefined) input.min = prop.minimum; + if (prop.maximum !== undefined) input.max = prop.maximum; + if (prop.default !== undefined) input.value = prop.default; + if (prop.pattern) input.pattern = prop.pattern; + } else if (prop.type === 'string' && prop.format === 'email') { + input = document.createElement('input'); + input.type = 'email'; + input.className = 'form-control'; + input.id = fieldId; + input.name = key; + if (prop.default) input.value = prop.default; + } else if (prop.type === 'string') { + input = document.createElement('input'); + input.type = 'text'; + input.className = 'form-control'; + input.id = fieldId; + input.name = key; + if (prop.minLength !== undefined) input.minLength = prop.minLength; + if (prop.maxLength !== undefined) input.maxLength = prop.maxLength; + if (prop.default) input.value = prop.default; + if (prop.pattern) input.pattern = prop.pattern; + } + if (input) wrapper.appendChild(input); + parent.appendChild(wrapper); + } + + // New: Array field builder for both initial render and dynamic population + addArrayFieldUI(arraySchema, arrayKey, parentDiv, initialValues = []) { + // Create array wrapper + const wrapper = document.createElement('div'); + wrapper.className = 'additional-field-array'; + wrapper.dataset.schemaKey = arrayKey; + + // Title + const label = document.createElement('label'); + label.className = 'form-label'; + label.textContent = this.formatLabel(arrayKey); + wrapper.appendChild(label); + + // Items container + const itemsContainer = document.createElement('div'); + itemsContainer.className = 'array-group'; + itemsContainer.dataset.schemaKey = arrayKey; + wrapper.appendChild(itemsContainer); + + // Add button + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'btn btn-sm btn-outline-primary mb-2'; + addBtn.textContent = 'Add Item'; + addBtn.onclick = () => { + this.addArrayItemUI(arraySchema.items, arrayKey, itemsContainer); + }; + wrapper.appendChild(addBtn); + + // Initial values + if (Array.isArray(initialValues)) { + initialValues.forEach(val => { + this.addArrayItemUI(arraySchema.items, arrayKey, itemsContainer, val); + }); + } + + parentDiv.appendChild(wrapper); + return wrapper; + } + + // Helper to add a single array item + addArrayItemUI(itemSchema, arrayKey, itemsContainer, initialValue = undefined) { + const itemDiv = document.createElement('div'); + itemDiv.className = 'array-item mb-2 p-2 border rounded'; + // Remove button + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn btn-sm btn-outline-danger float-end'; + removeBtn.textContent = 'Remove'; + removeBtn.onclick = () => { + itemsContainer.removeChild(itemDiv); + }; + itemDiv.appendChild(removeBtn); + // Determine index for uniqueness + let index = itemsContainer.childNodes.length; + // Render item fields + if (itemSchema.type === 'object' && itemSchema.properties) { + // Create a fieldset for the object item + const fieldset = document.createElement('fieldset'); + fieldset.dataset.schemaKey = arrayKey; + // Optionally add a legend for the object item + const legend = document.createElement('legend'); + legend.textContent = this.formatLabel(arrayKey); + fieldset.appendChild(legend); + Object.entries(itemSchema.properties).forEach(([subKey, subProp]) => { + this.createField(subKey, subProp, fieldset, index); + // Set initial value if provided + if (initialValue && initialValue[subKey] !== undefined) { + const input = fieldset.querySelector(`[name="${subKey}"]`); + if (input) input.value = initialValue[subKey]; + } + }); + itemDiv.appendChild(fieldset); + } else { + // Primitive array + this.createField(arrayKey, itemSchema, itemDiv, index); + if (initialValue !== undefined) { + const input = itemDiv.querySelector(`[name="${arrayKey}"]`); + if (input) input.value = initialValue; + } + } + itemsContainer.appendChild(itemDiv); + } } // Create global instance diff --git a/application/single_app/static/js/validatePlugin.mjs b/application/single_app/static/js/validatePlugin.mjs index f43176b05..5e54f76d0 100644 --- a/application/single_app/static/js/validatePlugin.mjs +++ b/application/single_app/static/js/validatePlugin.mjs @@ -1 +1 @@ -"use strict";export const validate = validate11;export default validate11;const schema13 = {"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/Plugin","definitions":{"Plugin":{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["key","identity","user","servicePrincipal"],"description":"Auth type must be 'key', 'user', 'identity', or 'servicePrincipal'"},"key":{"type":"string"},"identity":{"type":"string"},"tenantId":{"type":"string"}},"required":["type"],"additionalProperties":false},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Arbitrary additional fields","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"}}};const schema14 = {"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["key","identity","user","servicePrincipal"],"description":"Auth type must be 'key', 'user', 'identity', or 'servicePrincipal'"},"key":{"type":"string"},"identity":{"type":"string"},"tenantId":{"type":"string"}},"required":["type"],"additionalProperties":false},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Arbitrary additional fields","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"};const func2 = Object.prototype.hasOwnProperty;const pattern1 = new RegExp("^[A-Za-z0-9_-]+$", "u");function validate11(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;if(errors === _errs0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((data.name === undefined) && (missing0 = "name")) || ((data.type === undefined) && (missing0 = "type"))) || ((data.description === undefined) && (missing0 = "description"))) || ((data.endpoint === undefined) && (missing0 = "endpoint"))) || ((data.auth === undefined) && (missing0 = "auth"))) || ((data.metadata === undefined) && (missing0 = "metadata"))) || ((data.additionalFields === undefined) && (missing0 = "additionalFields"))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {const _errs2 = errors;for(const key0 in data){if(!(func2.call(schema14.properties, key0))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs2 === errors){if(data.id !== undefined){const _errs3 = errors;if(typeof data.id !== "string"){validate11.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Plugin/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs3 === errors;}else {var valid1 = true;}if(valid1){if(data.user_id !== undefined){const _errs5 = errors;if(typeof data.user_id !== "string"){validate11.errors = [{instancePath:instancePath+"/user_id",schemaPath:"#/definitions/Plugin/properties/user_id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs5 === errors;}else {var valid1 = true;}if(valid1){if(data.last_updated !== undefined){const _errs7 = errors;if(typeof data.last_updated !== "string"){validate11.errors = [{instancePath:instancePath+"/last_updated",schemaPath:"#/definitions/Plugin/properties/last_updated/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data.name !== undefined){let data3 = data.name;const _errs9 = errors;if(errors === _errs9){if(typeof data3 === "string"){if(!pattern1.test(data3)){validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9_-]+$"},message:"must match pattern \""+"^[A-Za-z0-9_-]+$"+"\""}];return false;}}else {validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data.displayName !== undefined){const _errs11 = errors;if(typeof data.displayName !== "string"){validate11.errors = [{instancePath:instancePath+"/displayName",schemaPath:"#/definitions/Plugin/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data.type !== undefined){const _errs13 = errors;if(typeof data.type !== "string"){validate11.errors = [{instancePath:instancePath+"/type",schemaPath:"#/definitions/Plugin/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}if(valid1){if(data.description !== undefined){const _errs15 = errors;if(typeof data.description !== "string"){validate11.errors = [{instancePath:instancePath+"/description",schemaPath:"#/definitions/Plugin/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs15 === errors;}else {var valid1 = true;}if(valid1){if(data.endpoint !== undefined){const _errs17 = errors;if(typeof data.endpoint !== "string"){validate11.errors = [{instancePath:instancePath+"/endpoint",schemaPath:"#/definitions/Plugin/properties/endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs17 === errors;}else {var valid1 = true;}if(valid1){if(data.auth !== undefined){let data8 = data.auth;const _errs19 = errors;if(errors === _errs19){if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing1;if((data8.type === undefined) && (missing1 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];return false;}else {const _errs21 = errors;for(const key1 in data8){if(!((((key1 === "type") || (key1 === "key")) || (key1 === "identity")) || (key1 === "tenantId"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}];return false;break;}}if(_errs21 === errors){if(data8.type !== undefined){let data9 = data8.type;const _errs22 = errors;if(typeof data9 !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}if(!((((data9 === "key") || (data9 === "identity")) || (data9 === "user")) || (data9 === "servicePrincipal"))){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/enum",keyword:"enum",params:{allowedValues: schema14.properties.auth.properties.type.enum},message:"must be equal to one of the allowed values"}];return false;}var valid2 = _errs22 === errors;}else {var valid2 = true;}if(valid2){if(data8.key !== undefined){const _errs24 = errors;if(typeof data8.key !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/key",schemaPath:"#/definitions/Plugin/properties/auth/properties/key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid2 = _errs24 === errors;}else {var valid2 = true;}if(valid2){if(data8.identity !== undefined){const _errs26 = errors;if(typeof data8.identity !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/identity",schemaPath:"#/definitions/Plugin/properties/auth/properties/identity/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid2 = _errs26 === errors;}else {var valid2 = true;}if(valid2){if(data8.tenantId !== undefined){const _errs28 = errors;if(typeof data8.tenantId !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/tenantId",schemaPath:"#/definitions/Plugin/properties/auth/properties/tenantId/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid2 = _errs28 === errors;}else {var valid2 = true;}}}}}}}else {validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs19 === errors;}else {var valid1 = true;}if(valid1){if(data.metadata !== undefined){let data13 = data.metadata;const _errs30 = errors;if(errors === _errs30){if(data13 && typeof data13 == "object" && !Array.isArray(data13)){}else {validate11.errors = [{instancePath:instancePath+"/metadata",schemaPath:"#/definitions/Plugin/properties/metadata/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs30 === errors;}else {var valid1 = true;}if(valid1){if(data.additionalFields !== undefined){let data14 = data.additionalFields;const _errs33 = errors;if(errors === _errs33){if(data14 && typeof data14 == "object" && !Array.isArray(data14)){}else {validate11.errors = [{instancePath:instancePath+"/additionalFields",schemaPath:"#/definitions/Plugin/properties/additionalFields/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs33 === errors;}else {var valid1 = true;}}}}}}}}}}}}}}else {validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate11.errors = vErrors;return errors === 0;} \ No newline at end of file +"use strict";export const validate = validate11;export default validate11;const schema13 = {"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/Plugin","definitions":{"Plugin":{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["key","identity","user","servicePrincipal","connection_string","basic","username_password"],"description":"Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'"},"key":{"type":"string","description":"The secret value for the plugin should be stored here, such as a SQL connection string, a password for a service principal or username/password combination"},"identity":{"type":"string","description":"This could be the Id of an (managed) identity, a user name, or similar to pair with the key, in most situations"},"tenantId":{"type":"string","description":"The Azure AD tenant ID used with Service Principal authentication"}},"additionalProperties":false,"allOf":[{"if":{"properties":{"type":{"const":"key"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"identity"}}},"then":{"required":["type","identity"]}},{"if":{"properties":{"type":{"const":"user"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"servicePrincipal"}}},"then":{"required":["type","tenantId","identity","key"]}},{"if":{"properties":{"type":{"const":"connection_string"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"basic"}}},"then":{"required":["type","key","identity"]}},{"if":{"properties":{"type":{"const":"username_password"}}},"then":{"required":["type","key","identity"]}},{"required":["type"]}]},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Additional fields for plugin configuration based on plugin type. See plugin documentation for details. Any fields named __Secret (double underscore) will be stored in key vault if the feature is enabled.","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"}}};const schema14 = {"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["key","identity","user","servicePrincipal","connection_string","basic","username_password"],"description":"Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'"},"key":{"type":"string","description":"The secret value for the plugin should be stored here, such as a SQL connection string, a password for a service principal or username/password combination"},"identity":{"type":"string","description":"This could be the Id of an (managed) identity, a user name, or similar to pair with the key, in most situations"},"tenantId":{"type":"string","description":"The Azure AD tenant ID used with Service Principal authentication"}},"additionalProperties":false,"allOf":[{"if":{"properties":{"type":{"const":"key"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"identity"}}},"then":{"required":["type","identity"]}},{"if":{"properties":{"type":{"const":"user"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"servicePrincipal"}}},"then":{"required":["type","tenantId","identity","key"]}},{"if":{"properties":{"type":{"const":"connection_string"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"basic"}}},"then":{"required":["type","key","identity"]}},{"if":{"properties":{"type":{"const":"username_password"}}},"then":{"required":["type","key","identity"]}},{"required":["type"]}]},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Additional fields for plugin configuration based on plugin type. See plugin documentation for details. Any fields named __Secret (double underscore) will be stored in key vault if the feature is enabled.","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"};const func2 = Object.prototype.hasOwnProperty;const pattern1 = new RegExp("^[A-Za-z0-9_-]+$", "u");function validate11(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;if(errors === _errs0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((data.name === undefined) && (missing0 = "name")) || ((data.type === undefined) && (missing0 = "type"))) || ((data.description === undefined) && (missing0 = "description"))) || ((data.endpoint === undefined) && (missing0 = "endpoint"))) || ((data.auth === undefined) && (missing0 = "auth"))) || ((data.metadata === undefined) && (missing0 = "metadata"))) || ((data.additionalFields === undefined) && (missing0 = "additionalFields"))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {const _errs2 = errors;for(const key0 in data){if(!(func2.call(schema14.properties, key0))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs2 === errors){if(data.id !== undefined){const _errs3 = errors;if(typeof data.id !== "string"){validate11.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Plugin/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs3 === errors;}else {var valid1 = true;}if(valid1){if(data.user_id !== undefined){const _errs5 = errors;if(typeof data.user_id !== "string"){validate11.errors = [{instancePath:instancePath+"/user_id",schemaPath:"#/definitions/Plugin/properties/user_id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs5 === errors;}else {var valid1 = true;}if(valid1){if(data.last_updated !== undefined){const _errs7 = errors;if(typeof data.last_updated !== "string"){validate11.errors = [{instancePath:instancePath+"/last_updated",schemaPath:"#/definitions/Plugin/properties/last_updated/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data.name !== undefined){let data3 = data.name;const _errs9 = errors;if(errors === _errs9){if(typeof data3 === "string"){if(!pattern1.test(data3)){validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9_-]+$"},message:"must match pattern \""+"^[A-Za-z0-9_-]+$"+"\""}];return false;}}else {validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data.displayName !== undefined){const _errs11 = errors;if(typeof data.displayName !== "string"){validate11.errors = [{instancePath:instancePath+"/displayName",schemaPath:"#/definitions/Plugin/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data.type !== undefined){const _errs13 = errors;if(typeof data.type !== "string"){validate11.errors = [{instancePath:instancePath+"/type",schemaPath:"#/definitions/Plugin/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}if(valid1){if(data.description !== undefined){const _errs15 = errors;if(typeof data.description !== "string"){validate11.errors = [{instancePath:instancePath+"/description",schemaPath:"#/definitions/Plugin/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs15 === errors;}else {var valid1 = true;}if(valid1){if(data.endpoint !== undefined){const _errs17 = errors;if(typeof data.endpoint !== "string"){validate11.errors = [{instancePath:instancePath+"/endpoint",schemaPath:"#/definitions/Plugin/properties/endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs17 === errors;}else {var valid1 = true;}if(valid1){if(data.auth !== undefined){let data8 = data.auth;const _errs19 = errors;const _errs21 = errors;const _errs22 = errors;let valid3 = true;const _errs23 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("key" !== data8.type){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs23 === errors;errors = _errs22;if(vErrors !== null){if(_errs22){vErrors.length = _errs22;}else {vErrors = null;}}if(_valid0){const _errs25 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing1;if(((data8.type === undefined) && (missing1 = "type")) || ((data8.key === undefined) && (missing1 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/0/then/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];return false;}}var _valid0 = _errs25 === errors;valid3 = _valid0;}if(!valid3){const err1 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs21 === errors;if(valid2){const _errs26 = errors;const _errs27 = errors;let valid5 = true;const _errs28 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("identity" !== data8.type){const err2 = {};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}}var _valid1 = _errs28 === errors;errors = _errs27;if(vErrors !== null){if(_errs27){vErrors.length = _errs27;}else {vErrors = null;}}if(_valid1){const _errs30 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing2;if(((data8.type === undefined) && (missing2 = "type")) || ((data8.identity === undefined) && (missing2 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/1/then/required",keyword:"required",params:{missingProperty: missing2},message:"must have required property '"+missing2+"'"}];return false;}}var _valid1 = _errs30 === errors;valid5 = _valid1;}if(!valid5){const err3 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs26 === errors;if(valid2){const _errs31 = errors;const _errs32 = errors;let valid7 = true;const _errs33 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("user" !== data8.type){const err4 = {};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}var _valid2 = _errs33 === errors;errors = _errs32;if(vErrors !== null){if(_errs32){vErrors.length = _errs32;}else {vErrors = null;}}if(_valid2){const _errs35 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing3;if((data8.type === undefined) && (missing3 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/2/then/required",keyword:"required",params:{missingProperty: missing3},message:"must have required property '"+missing3+"'"}];return false;}}var _valid2 = _errs35 === errors;valid7 = _valid2;}if(!valid7){const err5 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs31 === errors;if(valid2){const _errs36 = errors;const _errs37 = errors;let valid9 = true;const _errs38 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("servicePrincipal" !== data8.type){const err6 = {};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}var _valid3 = _errs38 === errors;errors = _errs37;if(vErrors !== null){if(_errs37){vErrors.length = _errs37;}else {vErrors = null;}}if(_valid3){const _errs40 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing4;if(((((data8.type === undefined) && (missing4 = "type")) || ((data8.tenantId === undefined) && (missing4 = "tenantId"))) || ((data8.identity === undefined) && (missing4 = "identity"))) || ((data8.key === undefined) && (missing4 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/3/then/required",keyword:"required",params:{missingProperty: missing4},message:"must have required property '"+missing4+"'"}];return false;}}var _valid3 = _errs40 === errors;valid9 = _valid3;}if(!valid9){const err7 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs36 === errors;if(valid2){const _errs41 = errors;const _errs42 = errors;let valid11 = true;const _errs43 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("connection_string" !== data8.type){const err8 = {};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}var _valid4 = _errs43 === errors;errors = _errs42;if(vErrors !== null){if(_errs42){vErrors.length = _errs42;}else {vErrors = null;}}if(_valid4){const _errs45 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing5;if(((data8.type === undefined) && (missing5 = "type")) || ((data8.key === undefined) && (missing5 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/4/then/required",keyword:"required",params:{missingProperty: missing5},message:"must have required property '"+missing5+"'"}];return false;}}var _valid4 = _errs45 === errors;valid11 = _valid4;}if(!valid11){const err9 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/4/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs41 === errors;if(valid2){const _errs46 = errors;const _errs47 = errors;let valid13 = true;const _errs48 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("basic" !== data8.type){const err10 = {};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}var _valid5 = _errs48 === errors;errors = _errs47;if(vErrors !== null){if(_errs47){vErrors.length = _errs47;}else {vErrors = null;}}if(_valid5){const _errs50 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing6;if((((data8.type === undefined) && (missing6 = "type")) || ((data8.key === undefined) && (missing6 = "key"))) || ((data8.identity === undefined) && (missing6 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/5/then/required",keyword:"required",params:{missingProperty: missing6},message:"must have required property '"+missing6+"'"}];return false;}}var _valid5 = _errs50 === errors;valid13 = _valid5;}if(!valid13){const err11 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/5/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs46 === errors;if(valid2){const _errs51 = errors;const _errs52 = errors;let valid15 = true;const _errs53 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("username_password" !== data8.type){const err12 = {};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}var _valid6 = _errs53 === errors;errors = _errs52;if(vErrors !== null){if(_errs52){vErrors.length = _errs52;}else {vErrors = null;}}if(_valid6){const _errs55 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing7;if((((data8.type === undefined) && (missing7 = "type")) || ((data8.key === undefined) && (missing7 = "key"))) || ((data8.identity === undefined) && (missing7 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/6/then/required",keyword:"required",params:{missingProperty: missing7},message:"must have required property '"+missing7+"'"}];return false;}}var _valid6 = _errs55 === errors;valid15 = _valid6;}if(!valid15){const err13 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/6/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs51 === errors;if(valid2){const _errs56 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing8;if((data8.type === undefined) && (missing8 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/7/required",keyword:"required",params:{missingProperty: missing8},message:"must have required property '"+missing8+"'"}];return false;}}var valid2 = _errs56 === errors;}}}}}}}if(errors === _errs19){if(data8 && typeof data8 == "object" && !Array.isArray(data8)){const _errs57 = errors;for(const key1 in data8){if(!((((key1 === "type") || (key1 === "key")) || (key1 === "identity")) || (key1 === "tenantId"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}];return false;break;}}if(_errs57 === errors){if(data8.type !== undefined){let data16 = data8.type;const _errs58 = errors;if(typeof data16 !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}if(!(((((((data16 === "key") || (data16 === "identity")) || (data16 === "user")) || (data16 === "servicePrincipal")) || (data16 === "connection_string")) || (data16 === "basic")) || (data16 === "username_password"))){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/enum",keyword:"enum",params:{allowedValues: schema14.properties.auth.properties.type.enum},message:"must be equal to one of the allowed values"}];return false;}var valid17 = _errs58 === errors;}else {var valid17 = true;}if(valid17){if(data8.key !== undefined){const _errs60 = errors;if(typeof data8.key !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/key",schemaPath:"#/definitions/Plugin/properties/auth/properties/key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid17 = _errs60 === errors;}else {var valid17 = true;}if(valid17){if(data8.identity !== undefined){const _errs62 = errors;if(typeof data8.identity !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/identity",schemaPath:"#/definitions/Plugin/properties/auth/properties/identity/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid17 = _errs62 === errors;}else {var valid17 = true;}if(valid17){if(data8.tenantId !== undefined){const _errs64 = errors;if(typeof data8.tenantId !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/tenantId",schemaPath:"#/definitions/Plugin/properties/auth/properties/tenantId/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid17 = _errs64 === errors;}else {var valid17 = true;}}}}}}else {validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs19 === errors;}else {var valid1 = true;}if(valid1){if(data.metadata !== undefined){let data20 = data.metadata;const _errs66 = errors;if(errors === _errs66){if(data20 && typeof data20 == "object" && !Array.isArray(data20)){}else {validate11.errors = [{instancePath:instancePath+"/metadata",schemaPath:"#/definitions/Plugin/properties/metadata/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs66 === errors;}else {var valid1 = true;}if(valid1){if(data.additionalFields !== undefined){let data21 = data.additionalFields;const _errs69 = errors;if(errors === _errs69){if(data21 && typeof data21 == "object" && !Array.isArray(data21)){}else {validate11.errors = [{instancePath:instancePath+"/additionalFields",schemaPath:"#/definitions/Plugin/properties/additionalFields/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs69 === errors;}else {var valid1 = true;}}}}}}}}}}}}}}else {validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate11.errors = vErrors;return errors === 0;} \ No newline at end of file diff --git a/application/single_app/static/js/workspace/workspace_plugins.js b/application/single_app/static/js/workspace/workspace_plugins.js index 61ce9a2fe..30fef0d5a 100644 --- a/application/single_app/static/js/workspace/workspace_plugins.js +++ b/application/single_app/static/js/workspace/workspace_plugins.js @@ -88,7 +88,11 @@ function setupSaveHandler(plugin, modal) { saveBtn.onclick = async (event) => { event.preventDefault(); - + const errorDiv = document.getElementById('plugin-modal-error'); + if (errorDiv) { + errorDiv.classList.add('d-none'); + errorDiv.textContent = ''; + } try { // Get form data from the stepper const formData = window.pluginModalStepper.getFormData(); @@ -100,8 +104,19 @@ function setupSaveHandler(plugin, modal) { return; } + const originalText = saveBtn.innerHTML; + saveBtn.innerHTML = `Saving...`; + saveBtn.disabled = true; // Save the action - await savePlugin(formData, plugin); + try { + await savePlugin(formData, plugin); + } catch (error) { + window.pluginModalStepper.showError(error.message); + return; + } finally { + saveBtn.innerHTML = originalText; + saveBtn.disabled = false; + } // Close modal and refresh if (modal && typeof modal.hide === 'function') { @@ -124,6 +139,7 @@ function setupSaveHandler(plugin, modal) { async function savePlugin(pluginData, existingPlugin = null) { // Get all plugins first const res = await fetch('/api/user/plugins'); + if (!res.ok) throw new Error('Failed to load existing actions'); let plugins = await res.json(); diff --git a/application/single_app/static/json/schemas/PLUGIN_SCHEMAS.md b/application/single_app/static/json/schemas/PLUGIN_SCHEMAS.md new file mode 100644 index 000000000..3fca9371f --- /dev/null +++ b/application/single_app/static/json/schemas/PLUGIN_SCHEMAS.md @@ -0,0 +1,18 @@ +# Plugin Schemas + +This document provides information on how plugin schemas are structured and how to define them for your plugins. + +## Overview + +### .plugin.schema.json files + +These files define the main configuration schema for each plugin. They are written in JSON Schema [DRAFT7](https://json-schema.org/draft-07) format and provide a way to validate the configuration options available for each plugin, as well as instantiate the plugin with the correct settings in both the UI and the application code. Having accurate schemas ensures that users can configure plugins correctly and that the application can handle these configurations without errors. + +Your schema SHOULD declare which of the auth types your plugin supports. +Your schema MAY declare which patterns that need to be matched for other fields, default values, etc. It should inherit from the base schema located at [`application/single_app/static/json/schemas/plugin.schema.json`](/application/single_app/static/json/schemas/plugin.schema.json). + +### .additional_settings.schema.json files + +These files define the additional settings required for specific plugins. They are also written in JSON Schema [DRAFT7](https://json-schema.org/draft-07) format and provide a way to validate the additional configuration options available for each plugin, as well as instantiate the plugin with the correct settings in both the UI and the application code. Having accurate schemas ensures that users can configure plugins correctly and that the application can handle these configurations without errors. + +Any additional settings schema properties that end with `__Secret` (double underscore) will be treated as sensitive information and will be stored in key vault if the option is enabled. \ No newline at end of file diff --git a/application/single_app/static/json/schemas/plugin.schema.json b/application/single_app/static/json/schemas/plugin.schema.json index 2d44c931b..c1226d7c5 100644 --- a/application/single_app/static/json/schemas/plugin.schema.json +++ b/application/single_app/static/json/schemas/plugin.schema.json @@ -41,8 +41,8 @@ "properties": { "type": { "type": "string", - "enum": ["key", "identity", "user", "servicePrincipal", "connection_string", "basic"], - "description": "Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', or 'basic'" + "enum": ["key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"], + "description": "Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'" }, "key": { "type": "string", @@ -57,8 +57,17 @@ "description": "The Azure AD tenant ID used with Service Principal authentication" } }, - "required": ["type"], - "additionalProperties": false + "additionalProperties": false, + "allOf": [ + { "if": { "properties": { "type": { "const": "key" } } }, "then": { "required": ["type", "key"] } }, + { "if": { "properties": { "type": { "const": "identity" } } }, "then": { "required": ["type", "identity"] } }, + { "if": { "properties": { "type": { "const": "user" } } }, "then": { "required": ["type"] } }, + { "if": { "properties": { "type": { "const": "servicePrincipal" } } }, "then": { "required": ["type", "tenantId", "identity", "key"] } }, + { "if": { "properties": { "type": { "const": "connection_string" } } }, "then": { "required": ["type", "key"] } }, + { "if": { "properties": { "type": { "const": "basic" } } }, "then": { "required": ["type", "key", "identity"] } }, + { "if": { "properties": { "type": { "const": "username_password" } } }, "then": { "required": ["type", "key", "identity"] } }, + { "required": ["type"] } + ] }, "metadata": { "type": "object", @@ -67,7 +76,7 @@ }, "additionalFields": { "type": "object", - "description": "Arbitrary additional fields", + "description": "Additional fields for plugin configuration based on plugin type. See plugin documentation for details. Any fields named __Secret (double underscore) will be stored in key vault if the feature is enabled.", "additionalProperties": true } }, diff --git a/application/single_app/static/json/schemas/queue_storage_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/queue_storage_plugin.additional_settings.schema.json new file mode 100644 index 000000000..f9ee4b645 --- /dev/null +++ b/application/single_app/static/json/schemas/queue_storage_plugin.additional_settings.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Queue Storage Additional Settings", + "description": "Additional settings required for Azure Queue Storage plugin.", + "type": "object", + "properties": { + "queue_name": { + "type": "string", + "title": "Queue Name", + "description": "The name of the Azure Storage Queue to use." + } + }, + "required": ["queue_name"] +} diff --git a/application/single_app/static/json/schemas/queue_storage_plugin.schema.json b/application/single_app/static/json/schemas/queue_storage_plugin.schema.json new file mode 100644 index 000000000..5a0886fdc --- /dev/null +++ b/application/single_app/static/json/schemas/queue_storage_plugin.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Queue Storage Plugin", + "description": "Schema for Azure Queue Storage plugin configuration.", + "allOf": [ + { "$ref": "plugin.schema.json" }, + { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "pattern": "^https://.*\\.queue\\.core\\.windows\\.net/?$", + "description": "Must be a valid Azure Queue Storage endpoint." + }, + "auth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["key", "identity"], + "description": "Only 'key' or 'identity' are allowed for queue storage." + } + } + } + } + } + ] +} \ No newline at end of file diff --git a/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json new file mode 100644 index 000000000..9e4f6d341 --- /dev/null +++ b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SQL Query Plugin Additional Settings", + "type": "object", + "properties": { + "connection_string__Secret": { + "type": "string", + "description": "Database connection string. Required if server/database not provided." + }, + "database_type": { + "type": "string", + "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"], + "description": "Type of database engine." + }, + "server": { + "type": "string", + "description": "Database server hostname or IP." + }, + "database": { + "type": "string", + "description": "Database name or path." + }, + "username": { + "type": "string", + "description": "Username for authentication." + }, + "password__Secret": { + "type": "string", + "description": "Password for authentication." + }, + "driver": { + "type": "string", + "description": "ODBC or DB driver name." + }, + "read_only": { + "type": "boolean", + "default": true, + "description": "If true, restricts queries to read-only operations." + }, + "max_rows": { + "type": "integer", + "default": 1000, + "minimum": 1, + "description": "Maximum number of rows returned by a query." + }, + "timeout": { + "type": "integer", + "default": 30, + "minimum": 1, + "description": "Query timeout in seconds." + } + }, + "required": ["database_type", "database"], + "additionalProperties": false +} diff --git a/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json new file mode 100644 index 000000000..e97c7b4bb --- /dev/null +++ b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SQL Schema Plugin Additional Settings", + "type": "object", + "properties": { + "connection_string__Secret": { + "type": "string", + "description": "Database connection string. Required if server/database not provided." + }, + "database_type": { + "type": "string", + "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"], + "description": "Type of database engine." + }, + "server": { + "type": "string", + "description": "Database server hostname or IP." + }, + "database": { + "type": "string", + "description": "Database name or path." + }, + "username": { + "type": "string", + "description": "Username for authentication." + }, + "password__Secret": { + "type": "string", + "description": "Password for authentication." + }, + "driver": { + "type": "string", + "description": "ODBC or DB driver name." + } + }, + "required": ["database_type", "database"], + "additionalProperties": false +} diff --git a/application/single_app/static/json/schemas/ui_test_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/ui_test_plugin.additional_settings.schema.json new file mode 100644 index 000000000..d611c0348 --- /dev/null +++ b/application/single_app/static/json/schemas/ui_test_plugin.additional_settings.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UI Test Plugin Additional Settings", + "type": "object", + "properties": { + "string": { + "type": "string", + "description": "A string value", + "minLength": 1, + "maxLength": 100 + }, + "string__Secret": { + "type": "string", + "description": "A string value", + "minLength": 1, + "maxLength": 100 + }, + "email": { + "type": "string", + "description": "An email address", + "format": "email", + "pattern": "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$", + "default": "user@example.com" + }, + "enum": { + "type": "string", + "enum": ["", "bob", "alice", "eve"], + "description": "An enumeration of string values" + }, + "number": { + "type": "number", + "description": "A numeric value", + "minimum": 0, + "maximum": 100, + "default": 50 + }, + "integer": { + "type": "integer", + "description": "An integer value", + "minimum": 0, + "maximum": 10, + "default": 5 + }, + "boolean": { + "type": "boolean", + "description": "A boolean value", + "default": false + }, + "object": { + "type": "object", + "description": "An object with string and number properties", + "properties": { + "object_string": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "object_number": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "required": ["object_string"] + }, + "array": { + "type": "array", + "description": "An array of objects with string and number properties", + "items": { + "type": "object", + "properties": { + "array_string": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "array_number": { + "type": "number", + "minimum": 0, + "maximum": 100 + } + }, + "required": ["array_string"] + } + }, + "string_array": { + "type": "array", + "description": "An array of strings", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + }, + "required": ["string", "enum", "string__Secret"], + "allOf": [ + { + "if": { + "properties": { "boolean": { "const": true } } + }, + "then": { + "required": ["number", "integer"] + }, + "else": { + "not": { + "required": ["number", "integer"] + } + } + } + ], + "additionalProperties": false +} diff --git a/application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json b/application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json new file mode 100644 index 000000000..113f004bb --- /dev/null +++ b/application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UI Test Plugin", + "description": "Schema for UI Test Plugin configuration, restricting auth.type to key, connection_string, and identity.", + "allOf": [ + { "$ref": "plugin.schema.json" }, + { + "type": "object", + "properties": { + "auth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["user", "key", "connection_string", "identity"], + "description": "Allowed values for UI Test Plugin: user, key, connection_string, identity." + } + } + } + } + } + ] +} \ No newline at end of file diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index d38610066..3af18019a 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -174,15 +174,15 @@
API Information
- @@ -2906,7 +2908,54 @@
Speech Service Settings
- +
+

+ Configure Security Settings. +

+
+
Key Vault
+

+ Configure Key Vault settings. +

+
+ + + +
+
+ โš ๏ธ Warning: Once you enable Key Vault, you should NOT disable it. Disabling Key Vault after enabling WILL cause loss of access to secrets and break application functionality. +
+
+ + + +
+
+ + + +
+ +
+
+
+
+ From 298b34259d8e7abb565339cdc651b441b57d3deb Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Wed, 15 Oct 2025 14:13:24 -0500 Subject: [PATCH 40/68] add keyvault settings --- application/single_app/route_frontend_admin_settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index de4b6d578..46db34a7c 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -768,6 +768,10 @@ def is_valid_url(url): 'azure_apim_document_intelligence_endpoint': form_data.get('azure_apim_document_intelligence_endpoint', '').strip(), 'azure_apim_document_intelligence_subscription_key': form_data.get('azure_apim_document_intelligence_subscription_key', '').strip(), + 'enable_key_vault_secret_storage': form_data.get('enable_key_vault_secret_storage') == 'on', + 'key_vault_name': form_data.get('key_vault_name', '').strip(), + 'key_vault_identity': form_data.get('key_vault_identity', ''), + # Authentication & Redirect Settings 'enable_front_door': enable_front_door, 'front_door_url': front_door_url, From 048decf1264ba4b58cdb59036f7b262444b5eb96 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Wed, 15 Oct 2025 16:27:04 -0500 Subject: [PATCH 41/68] fix for copilot findings. --- application/single_app/functions_keyvault.py | 14 +++++++------- application/single_app/json_schema_validation.py | 2 +- application/single_app/route_backend_settings.py | 5 +++-- application/single_app/semantic_kernel_loader.py | 14 +++++++------- .../logged_plugin_loader.py | 10 ++++------ .../semantic_kernel_plugins/ui_test_plugin.py | 8 ++++---- .../single_app/static/js/plugin_modal_stepper.js | 2 +- .../json/schemas/ui_test_plugin.plugin.schema.json | 2 +- 8 files changed, 28 insertions(+), 29 deletions(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 44b192c59..5a179278e 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -66,9 +66,9 @@ def retrieve_secret_from_key_vault(secret_name, scope_value, scope="global", sou raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") full_secret_name = build_full_secret_name(secret_name, scope_value, source, scope) - return retrieve_secret_from_keyvault_by_full_name(full_secret_name) + return retrieve_secret_from_key_vault_by_full_name(full_secret_name) -def retrieve_secret_from_keyvault_by_full_name(full_secret_name): +def retrieve_secret_from_key_vault_by_full_name(full_secret_name): """ Retrieve a secret from Key Vault using a preformatted full secret name. @@ -83,14 +83,14 @@ def retrieve_secret_from_keyvault_by_full_name(full_secret_name): settings = get_settings() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: - return value + return full_secret_name key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: - return value + return full_secret_name if not validate_secret_name_dynamic(full_secret_name): - return value + return full_secret_name try: key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" @@ -101,7 +101,7 @@ def retrieve_secret_from_keyvault_by_full_name(full_secret_name): return retrieved_secret.value except Exception as e: logging.error(f"Failed to retrieve secret '{full_secret_name}' from Key Vault: {str(e)}") - return value + return full_secret_name def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="global", scope="global"): @@ -369,7 +369,7 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ if validate_secret_name_dynamic(value): try: if return_actual_key: - actual_key = retrieve_secret_from_key_vault(plugin_name, scope_value, scope, source) + actual_key = retrieve_secret_from_key_vault_by_full_name(value) new_auth = dict(auth) new_auth['key'] = actual_key updated['auth'] = new_auth diff --git a/application/single_app/json_schema_validation.py b/application/single_app/json_schema_validation.py index 6231540f1..c7c58a3c6 100644 --- a/application/single_app/json_schema_validation.py +++ b/application/single_app/json_schema_validation.py @@ -43,7 +43,7 @@ def validate_plugin(plugin): validator = Draft7Validator(schema['definitions']['Plugin']) errors = sorted(validator.iter_errors(plugin_copy), key=lambda e: e.path) if errors: - return '; '.join([f"{plugin.name}: {e.message}" for e in errors]) + return '; '.join([f"{plugin.get('name', '')}: {e.message}" for e in errors]) # Additional business logic validation # For non-SQL plugins, endpoint must not be empty diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 137ce05b0..68e9ccaa5 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -716,8 +716,9 @@ def _test_key_vault_connection(payload): else: credential = DefaultAzureCredential() - if AZURE_ENVIRONMENT in ("custom"): - kv_client = SecretClient(vault_url=vault_url, credential=credential, credential_scopes=[key_vault_scope]) + if AZURE_ENVIRONMENT == "custom": + #TODO: Needs to be tested with a custom environment + kv_client = SecretClient(vault_url=vault_url, credential=credential) else: kv_client = SecretClient(vault_url=vault_url, credential=credential) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 3314761a2..aee723cbc 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -31,7 +31,7 @@ from semantic_kernel_plugins.smart_http_plugin import SmartHttpPlugin from functions_debug import debug_print from flask import g -from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_keyvault_by_full_name +from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_key_vault_by_full_name from functions_global_actions import get_global_actions from functions_global_agents import get_global_agents from functions_personal_actions import get_personal_actions, ensure_migration_complete as ensure_actions_migration_complete @@ -139,7 +139,7 @@ def get_user_apim(): try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault - resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + resolved_key = retrieve_secret_from_key_vault_by_full_name(key) if resolved_key: # Update the agent dict with the resolved key for this session agent["azure_apim_gpt_subscription_key"] = resolved_key @@ -160,7 +160,7 @@ def get_global_apim(): try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault - resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + resolved_key = retrieve_secret_from_key_vault_by_full_name(key) if resolved_key: # Update the settings dict with the resolved key for this session settings["azure_apim_gpt_subscription_key"] = resolved_key @@ -181,7 +181,7 @@ def get_user_gpt(): try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault - resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + resolved_key = retrieve_secret_from_key_vault_by_full_name(key) if resolved_key: # Update the agent dict with the resolved key for this session agent["azure_openai_gpt_key"] = resolved_key @@ -202,7 +202,7 @@ def get_global_gpt(): try: if validate_secret_name_dynamic(key): # Try to retrieve the secret from Key Vault - resolved_key = retrieve_secret_from_keyvault_by_full_name(key) + resolved_key = retrieve_secret_from_key_vault_by_full_name(key) if resolved_key: # Update the settings dict with the resolved key for this session settings["azure_openai_gpt_key"] = resolved_key @@ -483,7 +483,7 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob all_plugin_manifests = [] else: # Global mode - get from global actions container - all_plugin_manifests = global_plugins + all_plugin_manifests = get_global_plugins(return_actual_key=True) print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} global plugin manifests") # Filter manifests to only include requested plugins @@ -828,7 +828,7 @@ def resolve_key_vault_secrets_in_plugins(plugin_manifest, settings): def resolve_value(value): if isinstance(value, str) and validate_secret_name_dynamic(value): - resolved = retrieve_secret_from_keyvault_by_full_name(value) + resolved = retrieve_secret_from_key_vault_by_full_name(value) if resolved: return resolved else: diff --git a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py index 30b2dfccf..c7cc7d57e 100644 --- a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py +++ b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py @@ -141,14 +141,14 @@ def normalize(s): if matched_class: try: plugin = matched_class(manifest) if 'manifest' in matched_class.__init__.__code__.co_varnames else matched_class() - log_event(f"[Logged Plugin Loader] Instanced plugin: {name} (type: {plugin_type}) [{mode_label}]", {"plugin_name": name, "plugin_type": plugin_type}, level=logging.INFO) + log_event(f"[Logged Plugin Loader] Instanced plugin: {name} (type: {plugin_type})", {"plugin_name": name, "plugin_type": plugin_type}, level=logging.INFO) return plugin except Exception as e: log_event(f"[Logged Plugin Loader] Failed to instantiate plugin: {name}: {e}", {"plugin_name": name, "plugin_type": plugin_type, "error": str(e)}, level=logging.ERROR, exceptionTraceback=True) else: - log_event(f"[Logged Plugin Loader] Unknown plugin type: {plugin_type} for plugin '{name}' [{mode_label}]", {"plugin_name": name, "plugin_type": plugin_type}, level=logging.WARNING) + log_event(f"[Logged Plugin Loader] Unknown plugin type: {plugin_type} for plugin '{name}'", {"plugin_name": name, "plugin_type": plugin_type}, level=logging.WARNING) except Exception as e: - log_event(f"[Logged Plugin Loader] Error discovering plugin types for {mode_label} mode: {e}", {"error": str(e)}, level=logging.ERROR, exceptionTraceback=True) + log_event(f"[Logged Plugin Loader] Error discovering plugin types: {e}", {"error": str(e)}, level=logging.ERROR, exceptionTraceback=True) def _create_openapi_plugin(self, manifest: Dict[str, Any]): """Create an OpenAPI plugin instance.""" @@ -156,8 +156,6 @@ def _create_openapi_plugin(self, manifest: Dict[str, Any]): log_event(f"[Logged Plugin Loader] Attempting to create OpenAPI plugin: {plugin_name}", level=logging.DEBUG) try: - log_event(f"[Logged Plugin Loader] Successfully imported OpenApiPluginFactory", level=logging.DEBUG) - log_event(f"[Logged Plugin Loader] Creating OpenAPI plugin using factory", extra={"plugin_name": plugin_name, "manifest": manifest}, level=logging.DEBUG) @@ -357,7 +355,7 @@ def load_multiple_plugins(self, manifests: List[Dict[str, Any]], total_count = len(results) log_event( - f"[Plugin Loader] Loaded {successful_count}/{total_count} plugins", + f"[Logged Plugin Loader] Loaded {successful_count}/{total_count} plugins", extra={ "successful_plugins": [name for name, success in results.items() if success], "failed_plugins": [name for name, success in results.items() if not success], diff --git a/application/single_app/semantic_kernel_plugins/ui_test_plugin.py b/application/single_app/semantic_kernel_plugins/ui_test_plugin.py index f2161fe7e..44068d43f 100644 --- a/application/single_app/semantic_kernel_plugins/ui_test_plugin.py +++ b/application/single_app/semantic_kernel_plugins/ui_test_plugin.py @@ -1,8 +1,8 @@ """ -SQL Schema Plugin for Semantic Kernel -- Connects to various SQL databases (SQL Server, PostgreSQL, MySQL, SQLite) -- Extracts schema information (tables, columns, data types, relationships) -- Provides structured schema data for query generation +UI Test Plugin for Semantic Kernel +- Provides demonstration methods for UI testing (greeting, farewell, manifest retrieval) +- Useful for testing plugin integration and UI workflows +- Does not interact with external systems or databases """ import json diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 9e6826f9b..84df9a5fc 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -2112,7 +2112,7 @@ export class PluginModalStepper { if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) { previewContent = JSON.stringify(additionalFieldsObj, null, 2); } else { - previewContent = additionalFields; + previewContent = ''; } document.getElementById('summary-additional-fields-content').textContent = previewContent; additionalFieldsPreview.style.display = ''; diff --git a/application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json b/application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json index 113f004bb..59b2f051e 100644 --- a/application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json +++ b/application/single_app/static/json/schemas/ui_test_plugin.plugin.schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "UI Test Plugin", - "description": "Schema for UI Test Plugin configuration, restricting auth.type to key, connection_string, and identity.", + "description": "NYI-Schema for UI Test Plugin configuration, restricting auth.type to user, key, connection_string, and identity.", "allOf": [ { "$ref": "plugin.schema.json" }, { From 3a369b21f0ddace219f1104798f8e395980c0e74 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Wed, 15 Oct 2025 19:46:17 -0500 Subject: [PATCH 42/68] fix for resaving plugin without changing secret --- .../single_app/functions_global_actions.py | 10 ++-- application/single_app/functions_keyvault.py | 52 ++++++++++++++----- .../single_app/functions_personal_actions.py | 18 +++---- .../single_app/semantic_kernel_loader.py | 21 ++++---- 4 files changed, 63 insertions(+), 38 deletions(-) diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 439bef886..91f0d9f9b 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -11,9 +11,9 @@ import traceback from datetime import datetime from config import cosmos_global_actions_container -from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper +from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper, SecretReturnType -def get_global_actions(return_actual_key=False): +def get_global_actions(return_type=SecretReturnType.TRIGGER): """ Get all global actions. @@ -26,7 +26,7 @@ def get_global_actions(return_actual_key=False): enable_cross_partition_query=True )) # Resolve Key Vault references for each action - actions = [keyvault_plugin_get_helper(a, scope_value=a.get('id'), scope="global", return_actual_key=return_actual_key) for a in actions] + actions = [keyvault_plugin_get_helper(a, scope_value=a.get('id'), scope="global", return_type=return_type) for a in actions] return actions except Exception as e: @@ -35,7 +35,7 @@ def get_global_actions(return_actual_key=False): return [] -def get_global_action(action_id, return_actual_key=False): +def get_global_action(action_id, return_type=SecretReturnType.TRIGGER): """ Get a specific global action by ID. @@ -51,7 +51,7 @@ def get_global_action(action_id, return_actual_key=False): partition_key=action_id ) # Resolve Key Vault references - action = keyvault_plugin_get_helper(action, scope_value=action_id, scope="global", return_actual_key=return_actual_key) + action = keyvault_plugin_get_helper(action, scope_value=action_id, scope="global", return_type=return_type) print(f"โœ… Found global action: {action_id}") return action diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 5a179278e..11a6f0bda 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -6,6 +6,7 @@ from config import * from functions_authentication import * from functions_settings import * +from enum import Enum try: from azure.identity import DefaultAzureCredential @@ -43,6 +44,11 @@ ui_trigger_word = "Stored_In_KeyVault" +class SecretReturnType(Enum): + VALUE = "value" + TRIGGER = "trigger" + NAME = "name" + def retrieve_secret_from_key_vault(secret_name, scope_value, scope="global", source="global"): """ Retrieve a secret from Key Vault using a dynamic name based on source, scope, and scope_value. @@ -239,7 +245,7 @@ def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): log_event(f"Agent key '{key}' not found while APIM is '{use_apim}' or empty in agent '{agent_name}'. No action taken.", level="INFO") return updated -def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_actual_key=False): +def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_type=SecretReturnType.TRIGGER): """ For agent dicts, retrieve sensitive keys from Key Vault if they are stored as Key Vault references. Only processes 'azure_agent_apim_gpt_subscription_key' and 'azure_openai_gpt_key'. @@ -269,9 +275,11 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_ac value = updated[key] if validate_secret_name_dynamic(value): try: - if return_actual_key: + if return_type == SecretReturnType.VALUE: actual_key = retrieve_secret_from_key_vault_by_full_name(value) updated[key] = actual_key + elif return_type == SecretReturnType.NAME: + updated[key] = value else: updated[key] = ui_trigger_word except Exception as e: @@ -309,7 +317,13 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): auth_type = auth.get('type', None) if auth_type in supported_action_auth_types and 'key' in auth and auth['key']: value = auth['key'] - if not validate_secret_name_dynamic(value): + if value == ui_trigger_word: + auth['key'] = build_full_secret_name(plugin_name, scope_value, source, scope) + updated['auth'] = auth + elif validate_secret_name_dynamic(value): + auth['key'] = build_full_secret_name(plugin_name, scope_value, source, scope) + updated['auth'] = auth + else: try: full_secret_name = store_secret_in_key_vault(plugin_name, value, scope_value, source=source, scope=scope) new_auth = dict(auth) @@ -331,19 +345,23 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global"): base_field = k[:-8] # Remove '__Secret' akv_key = f"{plugin_name}-{base_field}".replace('__', '-') full_secret_name = build_full_secret_name(akv_key, scope_value, addset_source, scope) - if not validate_secret_name_dynamic(full_secret_name): - logging.error(f"Generated secret name for additionalField '{k}' is not valid.") - raise ValueError(f"Generated secret name for additionalField '{k}' is not valid.") - try: - full_secret_name = store_secret_in_key_vault(akv_key, v, scope_value, source=addset_source, scope=scope) + if v == ui_trigger_word: new_additional_fields[k] = full_secret_name - except Exception as e: - logging.error(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") - raise Exception(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") + continue + elif validate_secret_name_dynamic(v): + new_additional_fields[k] = full_secret_name + continue + else: + try: + full_secret_name = store_secret_in_key_vault(akv_key, v, scope_value, source=addset_source, scope=scope) + new_additional_fields[k] = full_secret_name + except Exception as e: + logging.error(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") + raise Exception(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") updated['additionalFields'] = new_additional_fields return updated # Helper to retrieve plugin secrets from Key Vault -def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_actual_key=False): +def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_type=SecretReturnType.TRIGGER): """ For plugin dicts, retrieve secrets from Key Vault for auth.key and any additionalFields key ending with '__Secret'. If the value is a Key Vault reference, retrieve the actual secret and replace with ui_trigger_word. @@ -368,11 +386,15 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ value = auth['key'] if validate_secret_name_dynamic(value): try: - if return_actual_key: + if return_type == SecretReturnType.VALUE: actual_key = retrieve_secret_from_key_vault_by_full_name(value) new_auth = dict(auth) new_auth['key'] = actual_key updated['auth'] = new_auth + elif return_type == SecretReturnType.NAME: + new_auth = dict(auth) + new_auth['key'] = value + updated['auth'] = new_auth else: new_auth = dict(auth) new_auth['key'] = ui_trigger_word @@ -390,9 +412,11 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ base_field = k[:-8] # Remove '__Secret' akv_key = f"{plugin_name}-{base_field}".replace('__', '-') try: - if return_actual_key: + if return_type == SecretReturnType.VALUE: actual_secret = retrieve_secret_from_key_vault(f"{akv_key}", scope_value, scope, addset_source) new_additional_fields[k] = actual_secret + elif return_type == SecretReturnType.NAME: + new_additional_fields[k] = v else: new_additional_fields[k] = ui_trigger_word except Exception as e: diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 42c9a8a05..108d31512 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -11,12 +11,12 @@ from datetime import datetime from azure.cosmos import exceptions from flask import current_app -from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper +from functions_keyvault import keyvault_plugin_save_helper, keyvault_plugin_get_helper, keyvault_plugin_delete_helper, SecretReturnType from functions_settings import get_user_settings, update_user_settings from config import cosmos_personal_actions_container import logging -def get_personal_actions(user_id, return_actual_key=False): +def get_personal_actions(user_id, return_type=SecretReturnType.TRIGGER): """ Fetch all personal actions/plugins for a user. @@ -40,7 +40,7 @@ def get_personal_actions(user_id, return_actual_key=False): cleaned_actions = [] for action in actions: cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} - cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_type=return_type) cleaned_actions.append(cleaned_action) return cleaned_actions @@ -50,7 +50,7 @@ def get_personal_actions(user_id, return_actual_key=False): current_app.logger.error(f"Error fetching personal actions for user {user_id}: {e}") return [] -def get_personal_action(user_id, action_id, return_actual_key=False): +def get_personal_action(user_id, action_id, return_type=SecretReturnType.TRIGGER): """ Fetch a specific personal action/plugin. @@ -87,7 +87,7 @@ def get_personal_action(user_id, action_id, return_actual_key=False): # Remove Cosmos metadata and resolve Key Vault references cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} - cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_type=return_type) return cleaned_action except Exception as e: @@ -269,7 +269,7 @@ def migrate_actions_from_user_settings(user_id): current_app.logger.error(f"Error during action migration for user {user_id}: {e}") return 0 -def get_actions_by_names(user_id, action_names, return_actual_key=False): +def get_actions_by_names(user_id, action_names, return_type=SecretReturnType.TRIGGER): """ Get multiple actions by their names. @@ -302,7 +302,7 @@ def get_actions_by_names(user_id, action_names, return_actual_key=False): cleaned_actions = [] for action in actions: cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} - cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_type=return_type) cleaned_actions.append(cleaned_action) return cleaned_actions @@ -311,7 +311,7 @@ def get_actions_by_names(user_id, action_names, return_actual_key=False): current_app.logger.error(f"Error fetching actions by names for user {user_id}: {e}") return [] -def get_actions_by_type(user_id, action_type, return_actual_key=False): +def get_actions_by_type(user_id, action_type, return_type=SecretReturnType.TRIGGER): """ Get all actions of a specific type for a user. @@ -339,7 +339,7 @@ def get_actions_by_type(user_id, action_type, return_actual_key=False): cleaned_actions = [] for action in actions: cleaned_action = {k: v for k, v in action.items() if not k.startswith('_')} - cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_actual_key=return_actual_key) + cleaned_action = keyvault_plugin_get_helper(cleaned_action, scope_value=user_id, scope="user", return_type=return_type) cleaned_actions.append(cleaned_action) return cleaned_actions diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index aee723cbc..d248c5e61 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -31,7 +31,7 @@ from semantic_kernel_plugins.smart_http_plugin import SmartHttpPlugin from functions_debug import debug_print from flask import g -from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_key_vault_by_full_name +from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_key_vault_by_full_name, SecretReturnType from functions_global_actions import get_global_actions from functions_global_agents import get_global_agents from functions_personal_actions import get_personal_actions, ensure_migration_complete as ensure_actions_migration_complete @@ -472,9 +472,9 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob if mode_label == "per-user": if user_id: - all_plugin_manifests = get_personal_actions(user_id, return_actual_key=True) + all_plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) if merge_global: - global_plugins = get_global_actions(return_actual_key=True) + global_plugins = get_global_actions(return_type=SecretReturnType.NAME) for g in global_plugins: all_plugin_manifests.append(g) debug_print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} personal plugin manifests for user {user_id}") @@ -483,7 +483,7 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob all_plugin_manifests = [] else: # Global mode - get from global actions container - all_plugin_manifests = get_global_plugins(return_actual_key=True) + all_plugin_manifests = get_global_plugins(return_type=SecretReturnType.NAME) print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} global plugin manifests") # Filter manifests to only include requested plugins @@ -560,15 +560,15 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob # Get plugin manifests again for fallback if mode_label == "per-user": if user_id: - all_plugin_manifests = get_personal_actions(user_id, return_actual_key=True) + all_plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) if merge_global: - global_plugins = get_global_actions(return_actual_key=True) + global_plugins = get_global_actions(return_type=SecretReturnType.NAME) for g in global_plugins: all_plugin_manifests.append(g) else: all_plugin_manifests = [] else: - all_plugin_manifests = get_global_actions(return_actual_key=True) + all_plugin_manifests = get_global_actions(return_type=SecretReturnType.NAME) plugin_manifests = [p for p in all_plugin_manifests if p.get('name') in plugin_names] _load_agent_plugins_original_method(kernel, plugin_manifests, mode_label) @@ -837,6 +837,7 @@ def resolve_value(value): resolved_manifest = {} for k, v in plugin_manifest.items(): + print(f"[SK Loader] Resolving plugin manifest key: {k} with value type: {type(v)}") if isinstance(v, str): resolved_manifest[k] = resolve_value(v) elif isinstance(v, list): @@ -1107,11 +1108,11 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie level=logging.INFO) # Ensure migration is complete (will migrate any remaining legacy data) ensure_actions_migration_complete(user_id) - plugin_manifests = get_personal_actions(user_id, return_actual_key=True) + plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) # PATCH: Merge global plugins if enabled if merge_global: - global_plugins = get_global_actions(return_actual_key=True) + global_plugins = get_global_actions(return_type=SecretReturnType.NAME) # User plugins take precedence all_plugins = {p.get('name'): p for p in plugin_manifests} all_plugins.update({p.get('name'): p for p in global_plugins}) @@ -1259,7 +1260,7 @@ def load_semantic_kernel(kernel: Kernel, settings): # Conditionally load core plugins based on settings - plugin_manifests = get_global_actions(return_actual_key=True) + plugin_manifests = get_global_actions(return_type=SecretReturnType.NAME) log_event(f"[SK Loader] Found {len(plugin_manifests)} plugin manifests", level=logging.INFO) # --- Dynamic Plugin Type Loading (semantic_kernel_plugins) --- From 9cb063fb0a6d71587d9c8e5390d022b729568122 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Wed, 15 Oct 2025 18:44:37 -0500 Subject: [PATCH 43/68] init azure billing plugin --- application/single_app/requirements.txt | 3 +- .../azure_billing_plugin.py | 391 ++++++++++++++++++ .../azure_function_plugin.py | 4 +- .../semantic_kernel_plugins/base_plugin.py | 1 + ...ing_plugin.additional_settings.schema.json | 14 + 5 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 application/single_app/semantic_kernel_plugins/azure_billing_plugin.py create mode 100644 application/single_app/static/json/schemas/azure_billing_plugin.additional_settings.schema.json diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 09bd3e8fa..d00610708 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -51,4 +51,5 @@ psycopg2-binary==2.9.10 cython pyyaml==6.0.2 aiohttp==3.12.15 -html2text==2025.4.15 \ No newline at end of file +html2text==2025.4.15 +matplotlib==3.10.7 \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py b/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py new file mode 100644 index 000000000..bb028587c --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py @@ -0,0 +1,391 @@ +# azure_billing_plugin.py +""" +Azure Billing Plugin for Semantic Kernel +- Supports user (Entra ID) and service principal authentication +- Uses Azure Cost Management REST API for billing, budgets, alerts, forecasting +- Renders graphs server-side as PNG (base64 for web, downloadable) +- Returns tabular data as CSV for minimal token usage +""" + +import io +import base64 +import requests +import csv +import inspect +import matplotlib.pyplot as plt +from typing import Dict, Any, List, Optional, Union +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel.functions import kernel_function +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger +from functions_authentication import get_valid_access_token, get_valid_access_token_for_plugins +from azure.identity import DefaultAzureCredential + +class AzureBillingPlugin(BasePlugin): + def __init__(self, manifest: Dict[str, Any]): + super().__init__(manifest) + self.manifest = manifest + self.additional_fields = manifest.get('additionalFields', {}) + self.auth = manifest.get('auth', {}) + self.endpoint = manifest.get('endpoint', 'https://management.azure.com') + self.metadata_dict = manifest.get('metadata', {}) + self.api_version = additional_fields.get('apiVersion', '2023-03-01') + + def _get_token(self) -> Optional[str]: + """Get an access token for Azure REST API calls.""" + auth_type = self.auth.get('type') + if auth_type == 'servicePrincipal': + # Service principal: use client credentials + tenant_id = self.auth.get('tenantId') + client_id = self.auth.get('identity') + client_secret = self.auth.get('key') + authority = self.endpoint.rstrip('/') + token_url = f"{authority}/{tenant_id}/oauth2/v2.0/token" + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id, + 'client_secret': client_secret, + 'scope': 'https://{}/.default'.format(authority) + } + resp = requests.post(token_url, data=data) + resp.raise_for_status() + return resp.json().get('access_token') + else: + # User: use session token helper + token = get_valid_access_token_for_plugins() + return token + + def _get_headers(self) -> Dict[str, str]: + token = self._get_token() + return { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + def _get(self, url: str, params: Dict[str, Any] = None) -> Any: + headers = self._get_headers() + resp = requests.get(url, headers=headers, params=params) + resp.raise_for_status() + return resp.json() + + def _post(self, url: str, data: Dict[str, Any]) -> Any: + headers = self._get_headers() + resp = requests.post(url, headers=headers, json=data) + resp.raise_for_status() + return resp.json() + + def _csv_from_table(self, rows: List[Dict[str, Any]]) -> str: + if not rows: + return '' + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + return output.getvalue() + + def _plot_graph(self, x, y, title: str = "", xlabel: str = "", ylabel: str = "") -> str: + plt.figure(figsize=(8, 4)) + plt.plot(x, y, marker='o') + plt.title(title) + plt.xlabel(xlabel) + plt.ylabel(ylabel) + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png') + plt.close() + buf.seek(0) + img_b64 = base64.b64encode(buf.read()).decode('utf-8') + return img_b64 + + @property + def display_name(self) -> str: + return "Azure Billing" + + @property + def metadata(self) -> Dict[str, Any]: + return { + "name": self.metadata_dict.get("name", "azure_billing_plugin"), + "type": "azure_billing", + "description": "Azure Billing plugin for cost, budgets, alerts, forecasting, CSV export, and PNG graphing.", + "methods": [ + {"name": "list_subscriptions", "description": "List all subscriptions accessible to the user/service principal. Returns CSV."}, + {"name": "list_resource_groups", "description": "List all resource groups in a subscription. Returns CSV."}, + {"name": "get_scope", "description": "Get the billing scope string for a subscription or resource group (e.g., /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg})."}, + {"name": "get_current_charges", "description": "Get current charges for a subscription or resource group. Returns CSV."}, + {"name": "get_historical_charges", "description": "Get historical billing data. Returns CSV."}, + {"name": "get_forecast", "description": "Get cost forecast for a given period and granularity. Returns CSV."}, + {"name": "get_budgets", "description": "Get budgets for a subscription/resource group. Returns CSV."}, + {"name": "get_alerts", "description": "Get cost alerts. Returns CSV."}, + {"name": "get_actual_cost_data", "description": "Retrieve actual cost data as a list of dicts."}, + {"name": "get_forecast_cost_data", "description": "Retrieve forecast cost data as a list of dicts."}, + {"name": "plot_cost_trend", "description": "Return a PNG graph of actual cost trend (base64 PNG)."}, + {"name": "plot_actual_and_forecast_cost", "description": "Return a PNG graph of actual and forecasted cost trends (base64 PNG)."} + ] + } + + def get_functions(self) -> List[str]: + """ + functions = [] + for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + # Check for a custom attribute set by the decorator + if getattr(method, "is_kernel_function", False): + print(f"Registering function: {name} from AzureBillingPlugin") + functions.append(name) + return functions + """ + return [ + "list_subscriptions", + "list_resource_groups", + "get_scope", + "get_current_charges", + "get_historical_charges", + "get_forecast", + "get_budgets", + "get_alerts", + "get_actual_cost_data", + "get_forecast_cost_data", + "plot_cost_trend", + "plot_actual_and_forecast_cost" + ] + + @kernel_function(description="List all subscriptions and resource groups acccessible to the user/service principal.") + @plugin_function_logger("AzureBillingPlugin") + def get_scope(self) -> str: + url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" + subs = self._get(url).get('value', []) + result = [] + for sub in subs: + sub_id = sub.get('subscriptionId') + sub_name = sub.get('displayName') + rg_url = f"{self.endpoint}/subscriptions/{sub_id}/resourcegroups?api-version=2021-04-01" + rgs = self._get(rg_url).get('value', []) + result.append({ + "subscriptionId": sub_id, + "subscriptionName": sub_name, + "resourceGroups": [rg.get('name') for rg in rgs] + }) + return self._csv_from_table(result) + + @kernel_function(description="List all subscriptions accessible to the user/service principal.") + @plugin_function_logger("AzureBillingPlugin") + def list_subscriptions(self) -> str: + url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" + data = self._get(url) + subs = data.get('value', []) + return self._csv_from_table(subs) + + @kernel_function(description="List all resource groups in a subscription.") + @plugin_function_logger("AzureBillingPlugin") + def list_resource_groups(self, subscription_id: str) -> str: + url = f"{self.endpoint}/subscriptions/{subscription_id}/resourcegroups?api-version=2021-04-01" + data = self._get(url) + rgs = data.get('value', []) + return self._csv_from_table(rgs) + + @kernel_function(description="Get current charges for a subscription or resource group.") + @plugin_function_logger("AzureBillingPlugin") + def get_current_charges(self, scope: str) -> str: + # scope: /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg} + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + query = { + "type": "ActualCost", + "timeframe": "MonthToDate", + "dataset": {"granularity": "Daily"} + } + data = self._post(url, query) + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + return self._csv_from_table(result) + + @kernel_function(description="Get historical billing data.") + @plugin_function_logger("AzureBillingPlugin") + def get_historical_charges(self, scope: str, timeframe: str = "MonthToDate") -> str: + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + query = { + "type": "ActualCost", + "timeframe": timeframe, + "dataset": {"granularity": "Daily"} + } + data = self._post(url, query) + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + return self._csv_from_table(result) + + @kernel_function(description="Get cost forecast.") + @plugin_function_logger("AzureBillingPlugin") + def get_forecast(self, scope: str) -> str: + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + query = { + "type": "Forecast", + "timeframe": "MonthToDate", + "dataset": {"granularity": "Daily"} + } + data = self._post(url, query) + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + return self._csv_from_table(result) + + @kernel_function(description="Get cost forecast with custom duration and granularity.") + @plugin_function_logger("AzureBillingPlugin") + def get_forecast(self, scope: str, forecast_period_months: int = 12, granularity: str = "Monthly", lookback_months: Optional[int] = None) -> str: + """ + Get cost forecast for a given period and granularity. + scope: /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg} + forecast_period_months: Number of months to forecast (default 12) + granularity: "Daily", "Monthly", "Weekly" + lookback_months: If provided, use last N months as historical data for forecasting + """ + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + timeframe = "Custom" + # Calculate start/end dates for forecast + from datetime import datetime, timedelta + today = datetime.utcnow().date() + start_date = today + end_date = today + timedelta(days=forecast_period_months * 30) + # If lookback_months is set, use that for historical data + if lookback_months: + hist_start = today - timedelta(days=lookback_months * 30) + hist_end = today + else: + hist_start = None + hist_end = None + query = { + "type": "Forecast", + "timeframe": timeframe, + "timePeriod": { + "from": start_date.isoformat(), + "to": end_date.isoformat() + }, + "dataset": {"granularity": granularity} + } + # Optionally add historical data window + if hist_start and hist_end: + query["historicalTimePeriod"] = { + "from": hist_start.isoformat(), + "to": hist_end.isoformat() + } + data = self._post(url, query) + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + return self._csv_from_table(result) + + @kernel_function(description="Get budgets for a subscription/resource group.") + @plugin_function_logger("AzureBillingPlugin") + def get_budgets(self, scope: str) -> str: + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/budgets?api-version={self.api_version}" + data = self._get(url) + budgets = data.get('value', []) + return self._csv_from_table(budgets) + + @kernel_function(description="Get cost alerts.") + @plugin_function_logger("AzureBillingPlugin") + def get_alerts(self, scope: str) -> str: + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/alerts?api-version={self.api_version}" + data = self._get(url) + alerts = data.get('value', []) + return self._csv_from_table(alerts) + + @kernel_function(description="Return a PNG graph of cost trend.") + @plugin_function_logger("AzureBillingPlugin") + def plot_cost_trend(self, scope: str, timeframe: str = "MonthToDate") -> str: + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + query = { + "type": "ActualCost", + "timeframe": timeframe, + "dataset": {"granularity": "Daily"} + } + data = self._post(url, query) + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + # Assume columns include 'UsageDate' and 'Cost' or similar + x = [r.get('UsageDate') or r.get('date') for r in result] + y = [r.get('Cost') or r.get('PreTaxCost') or r.get('cost') for r in result] + img_b64 = self._plot_graph(x, y, title="Cost Trend", xlabel="Date", ylabel="Cost ($)") + return img_b64 + + def get_historical_cost_data(self, scope: str, timeframe: str = "MonthToDate", granularity: str = "Daily") -> List[Dict[str, Any]]: + """ + Retrieve actual cost data for a given scope and timeframe. + Returns a list of dicts with date and cost. + """ + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + query = { + "type": "ActualCost", + "timeframe": timeframe, + "dataset": {"granularity": granularity} + } + data = self._post(url, query) + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + return result + + def get_forecast_cost_data(self, scope: str, forecast_period_months: int = 12, granularity: str = "Monthly", lookback_months: Optional[int] = None) -> List[Dict[str, Any]]: + """ + Retrieve forecast cost data for a given scope and period. + Returns a list of dicts with date and forecasted cost. + """ + url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + timeframe = "Custom" + from datetime import datetime, timedelta + today = datetime.utcnow().date() + start_date = today + end_date = today + timedelta(days=forecast_period_months * 30) + if lookback_months: + hist_start = today - timedelta(days=lookback_months * 30) + hist_end = today + else: + hist_start = None + hist_end = None + query = { + "type": "Forecast", + "timeframe": timeframe, + "timePeriod": { + "from": start_date.isoformat(), + "to": end_date.isoformat() + }, + "dataset": {"granularity": granularity} + } + if hist_start and hist_end: + query["historicalTimePeriod"] = { + "from": hist_start.isoformat(), + "to": hist_end.isoformat() + } + data = self._post(url, query) + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + return result + + @kernel_function(description="Return a PNG graph of actual and forecasted cost trend.") + @plugin_function_logger("AzureBillingPlugin") + def plot_actual_and_forecast_cost(self, scope: str, actual_timeframe: str = "MonthToDate", actual_granularity: str = "Daily", forecast_period_months: int = 12, forecast_granularity: str = "Monthly", lookback_months: Optional[int] = None) -> str: + """ + Plot both actual and forecasted cost trends on a single PNG graph. + Returns base64 PNG string. + """ + actual_data = self.get_actual_cost_data(scope, actual_timeframe, actual_granularity) + forecast_data = self.get_forecast_cost_data(scope, forecast_period_months, forecast_granularity, lookback_months) + # Extract dates and costs + actual_x = [r.get('UsageDate') or r.get('date') for r in actual_data] + actual_y = [r.get('Cost') or r.get('PreTaxCost') or r.get('cost') for r in actual_data] + forecast_x = [r.get('UsageDate') or r.get('date') for r in forecast_data] + forecast_y = [r.get('Cost') or r.get('PreTaxCost') or r.get('cost') for r in forecast_data] + plt.figure(figsize=(10, 5)) + plt.plot(actual_x, actual_y, marker='o', label='Actual Cost') + plt.plot(forecast_x, forecast_y, marker='x', linestyle='--', label='Forecast Cost') + plt.title("Actual vs Forecasted Cost Trend") + plt.xlabel("Date") + plt.ylabel("Cost ($)") + plt.legend() + plt.tight_layout() + buf = io.BytesIO() + plt.savefig(buf, format='png') + plt.close() + buf.seek(0) + img_b64 = base64.b64encode(buf.read()).decode('utf-8') + return img_b64 \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/azure_function_plugin.py b/application/single_app/semantic_kernel_plugins/azure_function_plugin.py index 2e0928c6e..f388404e3 100644 --- a/application/single_app/semantic_kernel_plugins/azure_function_plugin.py +++ b/application/single_app/semantic_kernel_plugins/azure_function_plugin.py @@ -62,7 +62,7 @@ def call_function_post(self, payload: dict) -> dict: url = self.endpoint headers = {} if self.auth_type == 'identity': - token = self.credential.get_token("https://management.azure.com/.default").token + token = self.credential.get_token("{resource_manager}/.default").token headers["Authorization"] = f"Bearer {token}" elif self.auth_type == 'key': if '?' in url: @@ -79,7 +79,7 @@ def call_function_get(self, params: dict = None) -> dict: url = self.endpoint headers = {} if self.auth_type == 'identity': - token = self.credential.get_token("https://management.azure.com/.default").token + token = self.credential.get_token("{resource_manager}/.default").token headers["Authorization"] = f"Bearer {token}" elif self.auth_type == 'key': if '?' in url: diff --git a/application/single_app/semantic_kernel_plugins/base_plugin.py b/application/single_app/semantic_kernel_plugins/base_plugin.py index e56e97ca7..9dd3c31ec 100644 --- a/application/single_app/semantic_kernel_plugins/base_plugin.py +++ b/application/single_app/semantic_kernel_plugins/base_plugin.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import Dict, Any, List, Optional import re +import inspect class BasePlugin(ABC): @property diff --git a/application/single_app/static/json/schemas/azure_billing_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/azure_billing_plugin.additional_settings.schema.json new file mode 100644 index 000000000..134ee581a --- /dev/null +++ b/application/single_app/static/json/schemas/azure_billing_plugin.additional_settings.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Azure Billing Plugin Additional Settings", + "type": "object", + "properties": { + "api_version": { + "type": "string", + "description": "API version to use for Azure Cost Management REST API calls (e.g., '2023-03-01', '2025-03-01').", + "default": "2025-03-01" + } + }, + "required": ["api_version"], + "additionalProperties": false +} From 75a74cdfd1a1df8f6d3210642410998917476d54 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 16 Oct 2025 17:37:42 -0500 Subject: [PATCH 44/68] add app settings cache --- application/single_app/app.py | 3 +++ application/single_app/app_settings_cache.py | 9 +++++++++ application/single_app/functions_appinsights.py | 10 ++++++++-- application/single_app/functions_debug.py | 13 +++++++------ application/single_app/functions_keyvault.py | 15 ++++++++------- application/single_app/functions_settings.py | 2 ++ application/single_app/semantic_kernel_loader.py | 5 +++-- 7 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 application/single_app/app_settings_cache.py diff --git a/application/single_app/app.py b/application/single_app/app.py index 528908c5b..a721c8f8c 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -5,6 +5,8 @@ import json import os +from app_settings_cache import APP_SETTINGS_CACHE, update_settings_cache, get_settings_cache + from semantic_kernel import Kernel from semantic_kernel_loader import initialize_semantic_kernel @@ -163,6 +165,7 @@ def configure_sessions(settings): def before_first_request(): print("Initializing application...") settings = get_settings() + update_settings_cache(settings) print(f"DEBUG:Application settings: {settings}") initialize_clients(settings) ensure_custom_logo_file_exists(app, settings) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py new file mode 100644 index 000000000..d020d2f02 --- /dev/null +++ b/application/single_app/app_settings_cache.py @@ -0,0 +1,9 @@ +# settings_cache.py +APP_SETTINGS_CACHE = {} + +def update_settings_cache(new_settings): + global APP_SETTINGS_CACHE + APP_SETTINGS_CACHE = new_settings + +def get_settings_cache(): + return APP_SETTINGS_CACHE \ No newline at end of file diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py index 05c656e4d..d38458ebf 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -4,6 +4,7 @@ import os import threading from azure.monitor.opentelemetry import configure_azure_monitor +from app_settings_cache import get_settings_cache # Singleton for the logger and Azure Monitor configuration _appinsights_logger = None @@ -44,6 +45,7 @@ def log_event( exceptionTraceback (Any, optional): If set to True, includes exception traceback. """ try: + cache = get_settings_cache() # Limit message to 32767 characters if message and isinstance(message, str) and len(message) > 32767: message = message[:32767] @@ -64,6 +66,8 @@ def log_event( # For ERROR level logs with exceptionTraceback=True, always log as exception if level >= logging.ERROR and exceptionTraceback: if logger and hasattr(logger, 'exception'): + if cache and cache.get('enable_debug_logging', False): + print(f"DEBUG: [ERROR][Log] {message} -- {extra if extra else 'No Extra Dimensions'}") # Use logger.exception() for better exception capture in Application Insights logger.exception(message, extra=extra, stacklevel=stacklevel, stack_info=includeStack, exc_info=True) return @@ -72,8 +76,10 @@ def log_event( exc_info_to_use = True # Format message with extra properties for structured logging - - print(f"[Log] {message} -- {extra}") # Debug print to console + + #TODO: Find a way to cache get_settings() globally (and update it when changed) to enable debug printing. Cannot use debug_print due to circular import + if cache and cache.get('enable_debug_logging', False): + print(f"DEBUG: [Log] {message} -- {extra if extra else 'No Extra Dimensions'}") # Debug print to console if extra: # For modern Azure Monitor, extra properties are automatically captured logger.log( diff --git a/application/single_app/functions_debug.py b/application/single_app/functions_debug.py index 43a8e1f5e..c82c90d9d 100644 --- a/application/single_app/functions_debug.py +++ b/application/single_app/functions_debug.py @@ -1,6 +1,6 @@ # functions_debug.py - -from functions_settings import get_settings +# +from app_settings_cache import get_settings_cache def debug_print(message): """ @@ -10,8 +10,8 @@ def debug_print(message): message (str): The debug message to print """ try: - settings = get_settings() - if settings and settings.get('enable_debug_logging', False): + cache = get_settings_cache() + if cache and cache.get('enable_debug_logging', False): print(f"DEBUG: {message}") except Exception: # If there's any error getting settings, don't print debug messages @@ -26,7 +26,8 @@ def is_debug_enabled(): bool: True if debug logging is enabled, False otherwise """ try: - settings = get_settings() - return settings and settings.get('enable_debug_logging', False) + cache = get_settings_cache() + print(f"IS_DEBUG_ENABLED: {cache.get('enable_debug_logging', False)}") + return cache and cache.get('enable_debug_logging', False) except Exception: return False \ No newline at end of file diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 11a6f0bda..a2d9d5d86 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -7,6 +7,7 @@ from functions_authentication import * from functions_settings import * from enum import Enum +from app_settings_cache import get_settings_cache try: from azure.identity import DefaultAzureCredential @@ -86,7 +87,7 @@ def retrieve_secret_from_key_vault_by_full_name(full_secret_name): Raises: Exception: If retrieval fails or configuration is invalid. """ - settings = get_settings() + settings = get_settings_cache() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: return full_secret_name @@ -126,7 +127,7 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl Raises: Exception: If storing fails or configuration is invalid. """ - settings = get_settings() + settings = get_settings_cache() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) if not enable_key_vault_secret_storage: logging.warn(f"Key Vault secret storage is not enabled.") @@ -217,7 +218,7 @@ def keyvault_agent_save_helper(agent_dict, scope_value, scope="global"): Raises: Exception: If storing a key in Key Vault fails. """ - settings = get_settings() + settings = get_settings_cache() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) key_vault_name = settings.get("key_vault_name", None) if not enable_key_vault_secret_storage or not key_vault_name: @@ -261,7 +262,7 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_ty Raises: Exception: If retrieving a key from Key Vault fails. """ - settings = get_settings() + settings = get_settings_cache() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) key_vault_name = settings.get("key_vault_name", None) if not enable_key_vault_secret_storage or not key_vault_name: @@ -442,7 +443,7 @@ def keyvault_plugin_delete_helper(plugin_dict, scope_value, scope="global"): if scope not in supported_scopes: log_event(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}", level="WARNING") raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") - settings = get_settings() + settings = get_settings_cache() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) key_vault_name = settings.get("key_vault_name", None) if not enable_key_vault_secret_storage or not key_vault_name: @@ -496,7 +497,7 @@ def keyvault_agent_delete_helper(agent_dict, scope_value, scope="global"): Returns: agent_dict (dict): The original agent dict. """ - settings = get_settings() + settings = get_settings_cache() enable_key_vault_secret_storage = settings.get("enable_key_vault_secret_storage", False) key_vault_name = settings.get("key_vault_name", None) if not enable_key_vault_secret_storage or not key_vault_name: @@ -527,7 +528,7 @@ def get_keyvault_credential(): Returns: DefaultAzureCredential: The credential object for Key Vault access. """ - settings = get_settings() + settings = get_settings_cache() key_vault_identity = settings.get("key_vault_identity", None) if key_vault_identity is not None: credential = DefaultAzureCredential(managed_identity_client_id=key_vault_identity) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index d1bd47523..1f24f3ac1 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -2,6 +2,7 @@ from config import * from functions_appinsights import log_event +from app_settings_cache import get_settings_cache, update_settings_cache def get_settings(): import secrets @@ -269,6 +270,7 @@ def update_settings(new_settings): settings_item = get_settings() settings_item.update(new_settings) cosmos_settings_container.upsert_item(settings_item) + update_settings_cache(settings_item) # Update the in-memory cache as well print("Settings updated successfully.") return True except Exception as e: diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index d248c5e61..d7985b3b2 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -38,6 +38,7 @@ from functions_personal_agents import get_personal_agents, ensure_migration_complete as ensure_agents_migration_complete from semantic_kernel_plugins.plugin_loader import discover_plugins from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory +from app_settings_cache import get_settings_cache @@ -400,7 +401,7 @@ def initialize_semantic_kernel(user_id: str=None, redis_client=None): "[SK Loader] Starting to load Semantic Kernel Agent and Plugins", level=logging.INFO ) - settings = get_settings() + settings = get_settings_cache() print(f"[SK Loader] Settings check - per_user_semantic_kernel: {settings.get('per_user_semantic_kernel', False)}, user_id: {user_id}") log_event(f"[SK Loader] Settings check - per_user_semantic_kernel: {settings.get('per_user_semantic_kernel', False)}, user_id: {user_id}", level=logging.INFO) @@ -837,7 +838,7 @@ def resolve_value(value): resolved_manifest = {} for k, v in plugin_manifest.items(): - print(f"[SK Loader] Resolving plugin manifest key: {k} with value type: {type(v)}") + debug_print(f"[SK Loader] Resolving plugin manifest key: {k} with value type: {type(v)}") if isinstance(v, str): resolved_manifest[k] = resolve_value(v) elif isinstance(v, list): From edd4a6da900a350bac1fdeec257c064cc24e7fa2 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 17 Oct 2025 16:16:54 -0500 Subject: [PATCH 45/68] upd to azure billing plugin --- .../azure_billing_plugin.py | 679 +++++++++++++----- .../semantic_kernel_plugins/base_plugin.py | 8 +- .../logged_plugin_loader.py | 8 +- .../semantic_kernel_plugins/plugin_loader.py | 3 +- .../static/json/schemas/plugin.schema.json | 3 +- 5 files changed, 528 insertions(+), 173 deletions(-) diff --git a/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py b/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py index bb028587c..649e66415 100644 --- a/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py +++ b/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py @@ -5,6 +5,7 @@ - Uses Azure Cost Management REST API for billing, budgets, alerts, forecasting - Renders graphs server-side as PNG (base64 for web, downloadable) - Returns tabular data as CSV for minimal token usage +- Requires user_impersonation for user auth on 40a69793-8fe6-4db1-9591-dbc5c57b17d8 (Azure Service Management) """ import io @@ -13,22 +14,41 @@ import csv import inspect import matplotlib.pyplot as plt +import logging +import time +import re +from datetime import datetime, timedelta from typing import Dict, Any, List, Optional, Union +import json from semantic_kernel_plugins.base_plugin import BasePlugin from semantic_kernel.functions import kernel_function from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger from functions_authentication import get_valid_access_token, get_valid_access_token_for_plugins from azure.identity import DefaultAzureCredential +from functions_debug import debug_print +from azure.core.credentials import AccessToken, TokenCredential + +RESOURCE_ID_REGEX = r"^/subscriptions/(?P[a-fA-F0-9-]+)/?(?:resourceGroups/(?P[^/]+))?$" +TIME_FRAME_TYPE = ["MonthToDate", "BillingMonthToDate", "WeekToDate", "Custom"] # "TheLastMonth, TheLastBillingMonth" are not supported in MAG +QUERY_TYPE = ["Usage", "ActualCost", "AmortizedCost"] +GRANULARITY_TYPE = ["None", "Daily", "Monthly", "Accumulated"] +GROUPING_TYPE = ["Dimension", "TagKey"] +AGGREGATION_FUNCTIONS = ["Sum"]#, "Average", "Min", "Max", "Count", "None"] +GROUPING_CATEGORY = ["None", "BillingPeriod", "ChargeType", "Frequency", "MeterCategory", "MeterId", "MeterSubCategory", "Product", "ResourceGroupName", "ResourceLocation", "ResourceType", "ServiceFamily", "ServiceName", "SubscriptionId", "SubscriptionName", "Tag"] class AzureBillingPlugin(BasePlugin): def __init__(self, manifest: Dict[str, Any]): super().__init__(manifest) self.manifest = manifest - self.additional_fields = manifest.get('additionalFields', {}) + self.additionalFields = manifest.get('additionalFields', {}) self.auth = manifest.get('auth', {}) - self.endpoint = manifest.get('endpoint', 'https://management.azure.com') + endpoint = manifest.get('endpoint', 'https://management.azure.com').rstrip('/') + if not endpoint.startswith('https://'): + # Remove any leading http:// and force https:// + endpoint = 'https://' + endpoint.lstrip('http://').lstrip('https://') + self.endpoint = endpoint self.metadata_dict = manifest.get('metadata', {}) - self.api_version = additional_fields.get('apiVersion', '2023-03-01') + self.api_version = self.additionalFields.get('apiVersion', '2023-03-01') def _get_token(self) -> Optional[str]: """Get an access token for Azure REST API calls.""" @@ -38,24 +58,71 @@ def _get_token(self) -> Optional[str]: tenant_id = self.auth.get('tenantId') client_id = self.auth.get('identity') client_secret = self.auth.get('key') - authority = self.endpoint.rstrip('/') - token_url = f"{authority}/{tenant_id}/oauth2/v2.0/token" + + # Determine AAD authority host based on management endpoint (public, gov, china) + host = self.endpoint.lower() + if "management.usgovcloudapi.net" in host: + aad_authority_host = "login.microsoftonline.us" + elif "management.azure.com" in host: + aad_authority_host = "login.microsoftonline.com" + else: + aad_authority_host = "login.microsoftonline.com" + + if not tenant_id or not client_id or not client_secret: + raise ValueError("Service principal auth requires tenantId, identity (client id), and key (client secret) in manifest 'auth'.") + + token_url = f"https://{aad_authority_host}/{tenant_id}/oauth2/v2.0/token" data = { 'grant_type': 'client_credentials', 'client_id': client_id, 'client_secret': client_secret, - 'scope': 'https://{}/.default'.format(authority) + 'scope': f'{self.endpoint.rstrip('/')}/.default' } - resp = requests.post(token_url, data=data) - resp.raise_for_status() - return resp.json().get('access_token') + try: + resp = requests.post(token_url, data=data, timeout=10) + resp.raise_for_status() + except requests.exceptions.HTTPError as e: + # Log the response text for diagnostics and raise a clear error + resp_text = getattr(e.response, 'text', '') if hasattr(e, 'response') else '' + logging.error("Failed to obtain service principal token. URL=%s, Error=%s, Response=%s", token_url, e, resp_text) + raise RuntimeError(f"Failed to obtain service principal token: {e}. Response: {resp_text}") + except requests.exceptions.RequestException as e: + logging.error("Error requesting service principal token: %s", e) + raise + try: + token = resp.json().get('access_token') + except ValueError: + logging.error("Invalid JSON returned from token endpoint: %s", resp.text) + raise RuntimeError(f"Invalid JSON returned from token endpoint: {resp.text}") + if not token: + logging.error("Token endpoint did not return access_token. Response: %s", resp.text) + raise RuntimeError(f"Token endpoint did not return access_token. Response: {resp.text}") + return token else: + class UserTokenCredential(TokenCredential): + def __init__(self, scope): + self.scope = scope + + def get_token(self, *args, **kwargs): + token_result = get_valid_access_token_for_plugins(scopes=[self.scope]) + if isinstance(token_result, dict) and token_result.get("access_token"): + token = token_result["access_token"] + elif isinstance(token_result, dict) and token_result.get("error"): + # Propagate error up to plugin + raise Exception(token_result) + else: + raise RuntimeError("Could not acquire user access token for Log Analytics API.") + expires_on = int(time.time()) + 300 + return AccessToken(token, expires_on) # User: use session token helper - token = get_valid_access_token_for_plugins() - return token + scope = f"{self.endpoint.rstrip('/')}/.default" + credential = UserTokenCredential(scope) + return credential.get_token(scope).token def _get_headers(self) -> Dict[str, str]: token = self._get_token() + if isinstance(token, dict) and ("error" in token or "consent_url" in token): + return token return { 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json' @@ -63,7 +130,14 @@ def _get_headers(self) -> Dict[str, str]: def _get(self, url: str, params: Dict[str, Any] = None) -> Any: headers = self._get_headers() - resp = requests.get(url, headers=headers, params=params) + if isinstance(headers, dict) and ("error" in headers or "consent_url" in headers): + return headers + if params: + debug_print(f"GET {url} with params: {params}") + resp = requests.get(url, headers=headers, params=params) + else: + debug_print(f"GET {url} without params") + resp = requests.get(url, headers=headers) resp.raise_for_status() return resp.json() @@ -76,12 +150,30 @@ def _post(self, url: str, data: Dict[str, Any]) -> Any: def _csv_from_table(self, rows: List[Dict[str, Any]]) -> str: if not rows: return '' + all_keys = set() + for row in rows: + all_keys.update(row.keys()) + fieldnames = list(all_keys) output = io.StringIO() - writer = csv.DictWriter(output, fieldnames=rows[0].keys()) + writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) return output.getvalue() + def _flatten_dict(self, d: Dict[str, Any], parent_key: str = '', sep: str = '.') -> Dict[str, Any]: + """Flatten a nested dict into a single-level dict with dotted keys. + + Example: {'properties': {'details': {'threshold': 0.8}}} => {'properties.details.threshold': 0.8} + """ + items = {} + for k, v in (d or {}).items(): + new_key = f"{parent_key}{sep}{k}" if parent_key else k + if isinstance(v, dict): + items.update(self._flatten_dict(v, new_key, sep=sep)) + else: + items[new_key] = v + return items + def _plot_graph(self, x, y, title: str = "", xlabel: str = "", ylabel: str = "") -> str: plt.figure(figsize=(8, 4)) plt.plot(x, y, marker='o') @@ -96,6 +188,139 @@ def _plot_graph(self, x, y, title: str = "", xlabel: str = "", ylabel: str = "") img_b64 = base64.b64encode(buf.read()).decode('utf-8') return img_b64 + def _fig_to_base64_dict(self, fig, filename: str = "chart.png") -> Dict[str, str]: + """Convert a matplotlib Figure to a structured base64 dict. + + Returns: {"mime": "image/png", "filename": filename, "base64": , "data_url": "data:image/png;base64,"} + """ + buf = io.BytesIO() + fig.savefig(buf, format='png', bbox_inches='tight') + fig.clf() + buf.seek(0) + img_b64 = base64.b64encode(buf.read()).decode('utf-8') + return { + "mime": "image/png", + "filename": filename, + "base64": img_b64, + "data_url": f"data:image/png;base64,{img_b64}" + } + + @kernel_function(description="Plot a custom chart from provided data. Supports pie, column_stacked, column_grouped, line, and area.") + @plugin_function_logger("AzureBillingPlugin") + def plot_custom_chart(self, + data: List[Dict[str, Any]], + x_key: Optional[str] = None, + y_keys: Optional[List[str]] = None, + chart_type: str = "line", + title: str = "", + xlabel: str = "", + ylabel: str = "", + filename: str = "chart.png", + figsize: tuple = (10, 6)) -> Dict[str, Any]: + """ + General plotting function. + + - data: list of dict rows (e.g., [{'date': '2025-10-01', 'cost': 12.3, 'type': 'A'}, ...]) + - x_key: key to use for x axis (required for non-pie charts) + - y_keys: list of keys to plot on y axis (if None and chart_type is not pie, autodetect numeric columns) + - chart_type: one of ['pie', 'column_stacked', 'column_grouped', 'line', 'area'] + - returns structured dict with mime, filename, base64, data_url and metadata + """ + chart_type = chart_type.lower() if isinstance(chart_type, str) else str(chart_type) + supported = ["pie", "column_stacked", "column_grouped", "line", "area"] + if chart_type not in supported: + raise ValueError(f"Unsupported chart_type '{chart_type}'. Supported: {supported}") + + # Defensive copy + rows = [r.copy() for r in (data or [])] + + # If no data, return an error-like dict + if not rows: + raise ValueError("No data provided for plotting") + + # Autodetect numeric columns if y_keys not provided + if not y_keys and chart_type != "pie": + sample = rows[0] + y_keys = [k for k, v in sample.items() if isinstance(v, (int, float))] + if not y_keys: + raise ValueError("Could not autodetect numeric columns for y axis. Provide y_keys explicitly.") + + # Prepare x values + x_vals = None + if chart_type != "pie": + if not x_key: + # attempt to pick a sensible x_key (date-like or first non-numeric) + for k, v in rows[0].items(): + if not isinstance(v, (int, float)): + x_key = k + break + if not x_key: + raise ValueError("x_key is required for this chart type") + x_vals = [r.get(x_key) for r in rows] + + # Build matplotlib figure + fig, ax = plt.subplots(figsize=figsize) + + if chart_type == "pie": + # For pie, expect y_keys to be a single key and aggregate values by label (x_key) + if not x_key or (not y_keys or len(y_keys) != 1): + raise ValueError("Pie chart requires an x_key (labels) and a single y_key for values") + labels = [r.get(x_key) for r in rows] + values = [r.get(y_keys[0]) or 0 for r in rows] + ax.pie(values, labels=labels, autopct="%1.1f%%") + ax.set_title(title) + + elif chart_type in ("line", "area"): + for yk in y_keys: + y_vals = [r.get(yk) or 0 for r in rows] + if chart_type == "line": + ax.plot(x_vals, y_vals, marker='o', label=yk) + else: + ax.fill_between(x_vals, y_vals, alpha=0.5, label=yk) + if y_keys and len(y_keys) > 1: + ax.legend() + ax.set_title(title) + ax.set_xlabel(xlabel or x_key) + ax.set_ylabel(ylabel) + + elif chart_type == "column_grouped": + # Grouped bar chart: for each x position, multiple bars side-by-side + import numpy as np + n_groups = len(rows) + n_bars = len(y_keys) + index = np.arange(n_groups) + bar_width = 0.8 / max(1, n_bars) + for i, yk in enumerate(y_keys): + y_vals = [r.get(yk) or 0 for r in rows] + ax.bar(index + i * bar_width, y_vals, bar_width, label=yk) + ax.set_xticks(index + bar_width * (n_bars - 1) / 2) + ax.set_xticklabels([str(x) for x in x_vals], rotation=45, ha='right') + ax.set_title(title) + ax.set_xlabel(xlabel or x_key) + ax.set_ylabel(ylabel) + if y_keys and len(y_keys) > 1: + ax.legend() + + elif chart_type == "column_stacked": + import numpy as np + index = np.arange(len(rows)) + bottoms = [0] * len(rows) + for yk in y_keys: + y_vals = [r.get(yk) or 0 for r in rows] + ax.bar(index, y_vals, bottom=bottoms, label=yk) + bottoms = [b + y for b, y in zip(bottoms, y_vals)] + ax.set_xticks(index) + ax.set_xticklabels([str(x) for x in x_vals], rotation=45, ha='right') + ax.set_title(title) + ax.set_xlabel(xlabel or x_key) + ax.set_ylabel(ylabel) + if y_keys and len(y_keys) > 1: + ax.legend() + + plt.tight_layout() + result = self._fig_to_base64_dict(fig, filename=filename) + return {"status": "ok", "chart": result, "metadata": {"type": chart_type, "x_key": x_key, "y_keys": y_keys}} + @property def display_name(self) -> str: return "Azure Billing" @@ -122,36 +347,13 @@ def metadata(self) -> Dict[str, Any]: ] } - def get_functions(self) -> List[str]: - """ - functions = [] - for name, method in inspect.getmembers(self, predicate=inspect.ismethod): - # Check for a custom attribute set by the decorator - if getattr(method, "is_kernel_function", False): - print(f"Registering function: {name} from AzureBillingPlugin") - functions.append(name) - return functions - """ - return [ - "list_subscriptions", - "list_resource_groups", - "get_scope", - "get_current_charges", - "get_historical_charges", - "get_forecast", - "get_budgets", - "get_alerts", - "get_actual_cost_data", - "get_forecast_cost_data", - "plot_cost_trend", - "plot_actual_and_forecast_cost" - ] - - @kernel_function(description="List all subscriptions and resource groups acccessible to the user/service principal.") @plugin_function_logger("AzureBillingPlugin") - def get_scope(self) -> str: + @kernel_function(description="List all subscriptions and resource groups accessible to the user/service principal.") + def list_subscriptions_and_resourcegroups(self) -> str: url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" subs = self._get(url).get('value', []) + if isinstance(subs, dict) and ("error" in subs or "consent_url" in subs): + return subs result = [] for sub in subs: sub_id = sub.get('subscriptionId') @@ -165,82 +367,39 @@ def get_scope(self) -> str: }) return self._csv_from_table(result) - @kernel_function(description="List all subscriptions accessible to the user/service principal.") @plugin_function_logger("AzureBillingPlugin") + @kernel_function(description="List all subscriptions accessible to the user/service principal.") def list_subscriptions(self) -> str: url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data subs = data.get('value', []) return self._csv_from_table(subs) - @kernel_function(description="List all resource groups in a subscription.") @plugin_function_logger("AzureBillingPlugin") + @kernel_function(description="List all resource groups in a subscription.") def list_resource_groups(self, subscription_id: str) -> str: - url = f"{self.endpoint}/subscriptions/{subscription_id}/resourcegroups?api-version=2021-04-01" + url = f"{self.endpoint}/subscriptions/{subscription_id}/resourcegroups?api-version=2020-01-01" data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data rgs = data.get('value', []) return self._csv_from_table(rgs) - @kernel_function(description="Get current charges for a subscription or resource group.") - @plugin_function_logger("AzureBillingPlugin") - def get_current_charges(self, scope: str) -> str: - # scope: /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg} - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" - query = { - "type": "ActualCost", - "timeframe": "MonthToDate", - "dataset": {"granularity": "Daily"} - } - data = self._post(url, query) - rows = data.get('properties', {}).get('rows', []) - columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] - result = [dict(zip(columns, row)) for row in rows] - return self._csv_from_table(result) - - @kernel_function(description="Get historical billing data.") - @plugin_function_logger("AzureBillingPlugin") - def get_historical_charges(self, scope: str, timeframe: str = "MonthToDate") -> str: - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" - query = { - "type": "ActualCost", - "timeframe": timeframe, - "dataset": {"granularity": "Daily"} - } - data = self._post(url, query) - rows = data.get('properties', {}).get('rows', []) - columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] - result = [dict(zip(columns, row)) for row in rows] - return self._csv_from_table(result) - - @kernel_function(description="Get cost forecast.") - @plugin_function_logger("AzureBillingPlugin") - def get_forecast(self, scope: str) -> str: - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" - query = { - "type": "Forecast", - "timeframe": "MonthToDate", - "dataset": {"granularity": "Daily"} - } - data = self._post(url, query) - rows = data.get('properties', {}).get('rows', []) - columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] - result = [dict(zip(columns, row)) for row in rows] - return self._csv_from_table(result) - @kernel_function(description="Get cost forecast with custom duration and granularity.") @plugin_function_logger("AzureBillingPlugin") - def get_forecast(self, scope: str, forecast_period_months: int = 12, granularity: str = "Monthly", lookback_months: Optional[int] = None) -> str: + def get_forecast(self, resourceId: str, forecast_period_months: int = 12, granularity: str = "Monthly", lookback_months: Optional[int] = None) -> str: """ - Get cost forecast for a given period and granularity. - scope: /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg} - forecast_period_months: Number of months to forecast (default 12) - granularity: "Daily", "Monthly", "Weekly" - lookback_months: If provided, use last N months as historical data for forecasting + #Get cost forecast for a given period and granularity. + #scope: /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg} + #forecast_period_months: Number of months to forecast (default 12) + #granularity: "Daily", "Monthly", "Weekly" + #lookback_months: If provided, use last N months as historical data for forecasting """ - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + url = f"{self.endpoint.rstrip('/')}/{resourceId.lstrip('/').rstrip('/')}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" timeframe = "Custom" # Calculate start/end dates for forecast - from datetime import datetime, timedelta today = datetime.utcnow().date() start_date = today end_date = today + timedelta(days=forecast_period_months * 30) @@ -267,37 +426,83 @@ def get_forecast(self, scope: str, forecast_period_months: int = 12, granularity "to": hist_end.isoformat() } data = self._post(url, query) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data rows = data.get('properties', {}).get('rows', []) columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] result = [dict(zip(columns, row)) for row in rows] return self._csv_from_table(result) - @kernel_function(description="Get budgets for a subscription/resource group.") + @kernel_function(description="Get budgets for a subscription or resource group.") @plugin_function_logger("AzureBillingPlugin") - def get_budgets(self, scope: str) -> str: - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/budgets?api-version={self.api_version}" + def get_budgets(self, subscription_id: str, resource_group_name: Optional[str] = None) -> str: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/budgets?api-version={self.api_version}" data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data budgets = data.get('value', []) return self._csv_from_table(budgets) @kernel_function(description="Get cost alerts.") @plugin_function_logger("AzureBillingPlugin") - def get_alerts(self, scope: str) -> str: - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/alerts?api-version={self.api_version}" + def get_alerts(self, subscription_id: str, resource_group_name: Optional[str] = None) -> str: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/alerts?api-version={self.api_version}" data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data alerts = data.get('value', []) return self._csv_from_table(alerts) + @kernel_function(description="Get specific cost alert by ID.") + @plugin_function_logger("AzureBillingPlugin") + def get_specific_alert(self, subscription_id: str, resource_group_name: Optional[str] = None, alertId: str ) -> str: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/alerts/{alertId}?api-version={self.api_version}" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + # Flatten nested properties for CSV friendliness + if isinstance(data, dict): + flat = self._flatten_dict(data) + # Convert lists to JSON strings for CSV + for k, v in list(flat.items()): + if isinstance(v, (list, dict)): + try: + flat[k] = json.dumps(v) + except Exception: + flat[k] = str(v) + return self._csv_from_table([flat]) + else: + # Fallback: return raw JSON string in a single column + return self._csv_from_table([{"raw": json.dumps(data)}]) + @kernel_function(description="Return a PNG graph of cost trend.") @plugin_function_logger("AzureBillingPlugin") - def plot_cost_trend(self, scope: str, timeframe: str = "MonthToDate") -> str: - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + def plot_cost_trend(self, subscription_id: str, resource_group_name: Optional[str] = None, timeframe: str = "MonthToDate") -> str: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" query = { "type": "ActualCost", "timeframe": timeframe, "dataset": {"granularity": "Daily"} } data = self._post(url, query) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data rows = data.get('properties', {}).get('rows', []) columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] result = [dict(zip(columns, row)) for row in rows] @@ -305,87 +510,227 @@ def plot_cost_trend(self, scope: str, timeframe: str = "MonthToDate") -> str: x = [r.get('UsageDate') or r.get('date') for r in result] y = [r.get('Cost') or r.get('PreTaxCost') or r.get('cost') for r in result] img_b64 = self._plot_graph(x, y, title="Cost Trend", xlabel="Date", ylabel="Cost ($)") - return img_b64 + return f'' - def get_historical_cost_data(self, scope: str, timeframe: str = "MonthToDate", granularity: str = "Daily") -> List[Dict[str, Any]]: + @kernel_function(description="Run a general Azure Cost Management query with flexible dataset and aggregation. Defaults to BillingMonthToDate. Supports up to two allowed query aggregations as per API spec.") + @plugin_function_logger("AzureBillingPlugin") + def run_data_query(self, subscription_id: str, resource_group_name: Optional[str] = None, query_type: str = "Usage", timeframe: str = "BillingMonthToDate", granularity: str = "Daily", aggregations: Optional[List[Dict[str, Any]]] = None, groupings: Optional[List[Dict[str, Any]]] = None, query_filter: Optional[Dict[str, Any]] = None, time_period: Optional[Dict[str, str]] = None) -> str: """ - Retrieve actual cost data for a given scope and timeframe. - Returns a list of dicts with date and cost. + Run a general Azure Cost Management query. + - subscription_id: Azure subscription ID (required) + - resource_group_name: Resource group name (optional) + - query_type: "Usage", "ActualCost", or "Forecast" (default: "Usage") + - timeframe: e.g., "BillingMonthToDate", "MonthToDate", "Custom" (default: "BillingMonthToDate") + - granularity: "None", "Daily", "Monthly" (default: "Daily") + - aggregations: list of aggregation dicts, e.g., [{"name": "totalCost", "function": "Sum", "column": "PreTaxCost"}] + - groupings: list of grouping dicts, e.g., [{"type": "Dimension", "name": "ResourceGroupName"}] + - query_filter: dict representing filter (optional) + - time_period: dict with "from" and "to" ISO date strings (required if timeframe is "Custom") """ - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + if query_type not in QUERY_TYPE: + raise ValueError(f"Invalid query_type: {query_type}. Must be one of {QUERY_TYPE}.") + if timeframe not in TIME_FRAME_TYPE: + raise ValueError(f"Invalid timeframe: {timeframe}. Must be one of {TIME_FRAME_TYPE}.") + if granularity not in GRANULARITY_TYPE: + raise ValueError(f"Invalid granularity: {granularity}. Must be one of {GRANULARITY_TYPE}.") query = { - "type": "ActualCost", + "type": query_type, "timeframe": timeframe, - "dataset": {"granularity": granularity} + "dataset": { + "granularity": granularity + } } + if not aggregations and not groupings and not query_filter: + return "Either aggregations and groupings or a query_filter must be provided." + # Validate and normalize aggregations (if provided) + if aggregations: + if not isinstance(aggregations, list): + raise ValueError("aggregations must be a list of aggregation definitions") + if len(aggregations) > 2: + logging.warning("More than 2 aggregations provided; only the first 2 will be used") + agg_map: Dict[str, Any] = {} + for agg in aggregations[:2]: + if not isinstance(agg, dict): + raise ValueError("Each aggregation must be a dict") + # Support shape: {"name":..., "function":..., ...} or {"type":..., "aggregation": {"name":..., "function":..., ...}} + if 'aggregation' in agg and isinstance(agg['aggregation'], dict): + sub = agg['aggregation'] + name = sub.get('name') or agg.get('name') + function = sub.get('function') or agg.get('function') + details = {k: v for k, v in sub.items() if k != 'name'} + else: + name = agg.get('name') + function = agg.get('function') + details = {k: v for k, v in agg.items() if k != 'name'} + if not name: + raise ValueError("Aggregation entry missing 'name'") + if not function: + raise ValueError(f"Aggregation '{name}' missing 'function'") + if function not in AGGREGATION_FUNCTIONS: + raise ValueError(f"Aggregation function '{function}' is invalid. Must be one of: {AGGREGATION_FUNCTIONS}") + agg_map[name] = details + query["dataset"]["aggregation"] = agg_map + + # Validate and normalize groupings (if provided) + if groupings: + if not isinstance(groupings, list): + raise ValueError("groupings must be a list of grouping definitions") + if len(groupings) > 2: + logging.warning("More than 2 groupings provided; only the first 2 will be used") + normalized_groupings: List[Dict[str, str]] = [] + for grp in groupings[:2]: + if not isinstance(grp, dict): + raise ValueError("Each grouping must be a dict with 'type' and 'name'") + gtype = grp.get('type') + gname = grp.get('name') + if not gtype or gtype not in GROUPING_TYPE: + raise ValueError(f"Grouping type '{gtype}' is invalid. Must be one of: {GROUPING_TYPE}") + if not gname or gname not in GROUPING_CATEGORY: + raise ValueError(f"Grouping name '{gname}' is invalid. Must be one of: {GROUPING_CATEGORY}") + normalized_groupings.append({'type': gtype, 'name': gname}) + query["dataset"]["grouping"] = normalized_groupings + if query_filter: + query["dataset"]["filter"] = query_filter + if timeframe == "Custom" and time_period: + query["timePeriod"] = time_period data = self._post(url, query) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data rows = data.get('properties', {}).get('rows', []) columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] result = [dict(zip(columns, row)) for row in rows] - return result + return self._csv_from_table(result) + + @kernel_function(description="Return available configuration options for Azure Billing report queries.") + @plugin_function_logger("AzureBillingPlugin") + def get_query_configuration_options(self, subscription_id: str, resource_group_name: Optional[str] = None) -> Dict[str, Any]: + get_categories_result = self.get_grouping_categories(subscription_id, resource_group_name) + if isinstance(get_categories_result, dict) and ("error" in get_categories_result or "consent_url" in get_categories_result): + return get_categories_result + if isinstance(get_categories_result, list): + global GROUPING_CATEGORY + GROUPING_CATEGORY = get_categories_result + return { + "TIME_FRAME_TYPE": TIME_FRAME_TYPE, + "QUERY_TYPE": QUERY_TYPE, + "GRANULARITY_TYPE": GRANULARITY_TYPE, + "GROUPING_TYPE": GROUPING_TYPE, + "GROUPING_CATEGORY": GROUPING_CATEGORY, + "AGGREGATION_FUNCTIONS": AGGREGATION_FUNCTIONS, + "NOTE": "Not all combinations are available for all queries." + } + + @kernel_function(description="Get available cost categories (dimensions) for Azure Billing.") + @plugin_function_logger("AzureBillingPlugin") + def get_grouping_categories(self, subscription_id: str, resource_group_name: Optional[str] = None) -> List[str]: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + # Use the Cost Management query endpoint to retrieve available dimensions/categories + # Note: some Cost Management responses return a 'value' array where each item has a + # 'properties' object containing a 'category' property. We handle that shape and + # fall back to other common fields. + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data - def get_forecast_cost_data(self, scope: str, forecast_period_months: int = 12, granularity: str = "Monthly", lookback_months: Optional[int] = None) -> List[Dict[str, Any]]: + values = data.get('value', []) if isinstance(data, dict) else [] + cats = [] + for item in values: + if not isinstance(item, dict): + continue + # Preferred location: item['properties']['category'] + props = item.get('properties') if isinstance(item.get('properties'), dict) else {} + cat = props.get('category') or props.get('Category') + if not cat: + # fallback to name/displayName + cat = item.get('name') or props.get('name') or props.get('displayName') + if cat: + cats.append(cat) + + # dedupe while preserving order + seen = set() + deduped = [] + for c in cats: + if c not in seen: + seen.add(c) + deduped.append(c) + return deduped + + @kernel_function(description="Run a sample or provided Cost Management query and return the columns metadata (name + type). Useful for discovering which columns can be used for aggregation and grouping.") + @plugin_function_logger("AzureBillingPlugin") + def get_query_columns(self, subscription_id: str, resource_group_name: Optional[str] = None, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """ - Retrieve forecast cost data for a given scope and period. - Returns a list of dicts with date and forecasted cost. + Discover columns for a Cost Management query. + + - subscription_id: required + - resource_group_name: optional + - query: optional Cost Management query dict; if omitted a minimal Usage MonthToDate query is used + + Returns a list of {"name": , "type": }. """ - url = f"{self.endpoint}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" - timeframe = "Custom" - from datetime import datetime, timedelta - today = datetime.utcnow().date() - start_date = today - end_date = today + timedelta(days=forecast_period_months * 30) - if lookback_months: - hist_start = today - timedelta(days=lookback_months * 30) - hist_end = today + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" else: - hist_start = None - hist_end = None - query = { - "type": "Forecast", - "timeframe": timeframe, - "timePeriod": { - "from": start_date.isoformat(), - "to": end_date.isoformat() - }, - "dataset": {"granularity": granularity} - } - if hist_start and hist_end: - query["historicalTimePeriod"] = { - "from": hist_start.isoformat(), - "to": hist_end.isoformat() + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}" + f"{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + + if not query: + query = { + "type": "Usage", + "timeframe": "MonthToDate", + "dataset": {"granularity": "None"} } + data = self._post(url, query) - rows = data.get('properties', {}).get('rows', []) - columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] - result = [dict(zip(columns, row)) for row in rows] + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + + # Two possible shapes: properties.columns or value[].properties.columns + cols = [] + props = data.get('properties') if isinstance(data, dict) else None + if props and isinstance(props, dict) and props.get('columns'): + cols = props.get('columns', []) + else: + # Inspect value[] items for properties.columns + values = data.get('value', []) if isinstance(data, dict) else [] + for item in values: + if not isinstance(item, dict): + continue + p = item.get('properties') if isinstance(item.get('properties'), dict) else {} + if p.get('columns'): + cols = p.get('columns') + break + + result = [] + for c in cols or []: + if not isinstance(c, dict): + continue + name = c.get('name') or c.get('displayName') + typ = c.get('type') or c.get('dataType') or c.get('data', {}).get('type') if isinstance(c.get('data'), dict) else c.get('type') + result.append({"name": name, "type": typ}) + return result - @kernel_function(description="Return a PNG graph of actual and forecasted cost trend.") + @kernel_function(description="Return only aggregatable (numeric) columns from a sample or provided query.") @plugin_function_logger("AzureBillingPlugin") - def plot_actual_and_forecast_cost(self, scope: str, actual_timeframe: str = "MonthToDate", actual_granularity: str = "Daily", forecast_period_months: int = 12, forecast_granularity: str = "Monthly", lookback_months: Optional[int] = None) -> str: + def get_aggregatable_columns(self, subscription_id: str, resource_group_name: Optional[str] = None, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """ - Plot both actual and forecasted cost trends on a single PNG graph. - Returns base64 PNG string. + Returns columns suitable for aggregation (numeric types). Uses `get_query_columns` internally. """ - actual_data = self.get_actual_cost_data(scope, actual_timeframe, actual_granularity) - forecast_data = self.get_forecast_cost_data(scope, forecast_period_months, forecast_granularity, lookback_months) - # Extract dates and costs - actual_x = [r.get('UsageDate') or r.get('date') for r in actual_data] - actual_y = [r.get('Cost') or r.get('PreTaxCost') or r.get('cost') for r in actual_data] - forecast_x = [r.get('UsageDate') or r.get('date') for r in forecast_data] - forecast_y = [r.get('Cost') or r.get('PreTaxCost') or r.get('cost') for r in forecast_data] - plt.figure(figsize=(10, 5)) - plt.plot(actual_x, actual_y, marker='o', label='Actual Cost') - plt.plot(forecast_x, forecast_y, marker='x', linestyle='--', label='Forecast Cost') - plt.title("Actual vs Forecasted Cost Trend") - plt.xlabel("Date") - plt.ylabel("Cost ($)") - plt.legend() - plt.tight_layout() - buf = io.BytesIO() - plt.savefig(buf, format='png') - plt.close() - buf.seek(0) - img_b64 = base64.b64encode(buf.read()).decode('utf-8') - return img_b64 \ No newline at end of file + cols = self.get_query_columns(subscription_id, resource_group_name, query) + if isinstance(cols, dict) and ("error" in cols or "consent_url" in cols): + return cols + numeric_types = {"Number", "Double", "Integer", "Decimal", "Long", "Float"} + agg = [c for c in (cols or []) if (c.get('type') in numeric_types or (isinstance(c.get('type'), str) and c.get('type').lower() == 'number'))] + return agg + + + \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/base_plugin.py b/application/single_app/semantic_kernel_plugins/base_plugin.py index 9dd3c31ec..d0d5f4827 100644 --- a/application/single_app/semantic_kernel_plugins/base_plugin.py +++ b/application/single_app/semantic_kernel_plugins/base_plugin.py @@ -69,6 +69,12 @@ def get_functions(self) -> List[str]: Default implementation returns an empty list. Override this method if you want to explicitly declare exposed functions. """ - return [] + functions = [] + for name, method in inspect.getmembers(self, predicate=inspect.ismethod): + # Check for a custom attribute set by the decorator + if getattr(method, "is_kernel_function", False): + print(f"Registering function: {name}") + functions.append(name) + return functions diff --git a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py index c7cc7d57e..644436335 100644 --- a/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py +++ b/application/single_app/semantic_kernel_plugins/logged_plugin_loader.py @@ -16,9 +16,11 @@ from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger, plugin_function_logger, auto_wrap_plugin_functions from semantic_kernel_plugins.plugin_loader import discover_plugins from functions_appinsights import log_event +from functions_debug import debug_print from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory from semantic_kernel_plugins.sql_schema_plugin import SQLSchemaPlugin from semantic_kernel_plugins.sql_query_plugin import SQLQueryPlugin +from app_settings_cache import get_settings_cache class LoggedPluginLoader: """Enhanced plugin loader that automatically adds invocation logging.""" @@ -119,7 +121,7 @@ def _create_plugin_instance(self, manifest: Dict[str, Any]): # return self._create_sql_plugin(manifest) else: try: - debug_print("[Logged Plugin Loader] Attempting to discover plugin type:", plugin_type) + debug_print(f"[Logged Plugin Loader] Attempting to discover plugin type: {plugin_type}") discovered_plugins = discover_plugins() plugin_type = manifest.get('type') name = manifest.get('name') @@ -127,7 +129,7 @@ def _create_plugin_instance(self, manifest: Dict[str, Any]): # Normalize for matching def normalize(s): return s.replace('_', '').replace('-', '').replace('plugin', '').lower() if s else '' - debug_print("[Logged Plugin Loader] Normalizing plugin type for matching:", plugin_type) + debug_print(f"[Logged Plugin Loader] Normalizing plugin type for matching: {plugin_type}") normalized_type = normalize(plugin_type) debug_print(f"[Logged Plugin Loader] Normalized plugin type: {normalized_type}") matched_class = None @@ -136,8 +138,8 @@ def normalize(s): print("[Logged Plugin Loader] Checking plugin class:", class_name, "normalized:", normalized_class) if normalized_type == normalized_class or normalized_type in normalized_class: matched_class = cls + debug_print(f"[Logged Plugin Loader] Matched class for plugin '{name}' of type '{plugin_type}': {matched_class}") break - debug_print(f"[Logged Plugin Loader] Matched class for plugin '{name}' of type '{plugin_type}': {matched_class}") if matched_class: try: plugin = matched_class(manifest) if 'manifest' in matched_class.__init__.__code__.co_varnames else matched_class() diff --git a/application/single_app/semantic_kernel_plugins/plugin_loader.py b/application/single_app/semantic_kernel_plugins/plugin_loader.py index 0c9ab56bd..9e897c4ed 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_loader.py +++ b/application/single_app/semantic_kernel_plugins/plugin_loader.py @@ -2,6 +2,7 @@ import importlib.util import inspect import logging +from functions_appinsights import log_event from typing import Dict, Type, List from semantic_kernel_plugins.base_plugin import BasePlugin @@ -30,7 +31,7 @@ def discover_plugins() -> Dict[str, Type[BasePlugin]]: except Exception as e: # Log the error but continue with other plugins - logging.warning(f"Failed to load plugin module {module_name}: {str(e)}") + log_event(f"Failed to load plugin module {module_name}: {str(e)}") continue return plugins diff --git a/application/single_app/static/json/schemas/plugin.schema.json b/application/single_app/static/json/schemas/plugin.schema.json index c1226d7c5..c9b80f8b9 100644 --- a/application/single_app/static/json/schemas/plugin.schema.json +++ b/application/single_app/static/json/schemas/plugin.schema.json @@ -41,7 +41,7 @@ "properties": { "type": { "type": "string", - "enum": ["key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"], + "enum": ["NoAuth", "key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"], "description": "Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'" }, "key": { @@ -59,6 +59,7 @@ }, "additionalProperties": false, "allOf": [ + { "if": { "properties": { "type": { "const": "NoAuth" } } }, "then": { "required": ["type"] } }, { "if": { "properties": { "type": { "const": "key" } } }, "then": { "required": ["type", "key"] } }, { "if": { "properties": { "type": { "const": "identity" } } }, "then": { "required": ["type", "identity"] } }, { "if": { "properties": { "type": { "const": "user" } } }, "then": { "required": ["type"] } }, From 8d5885777314155a2c73b9f5af885251dc8cade6 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 17 Oct 2025 16:17:38 -0500 Subject: [PATCH 46/68] upd to msgraph plugin --- .../single_app/semantic_kernel_plugins/msgraph_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/single_app/semantic_kernel_plugins/msgraph_plugin.py b/application/single_app/semantic_kernel_plugins/msgraph_plugin.py index c6d34d0d5..0eeef4192 100644 --- a/application/single_app/semantic_kernel_plugins/msgraph_plugin.py +++ b/application/single_app/semantic_kernel_plugins/msgraph_plugin.py @@ -53,7 +53,7 @@ def get_functions(self) -> List[str]: def _get_token(self, scopes=None): # Use the existing authentication helper to get a valid token for Graph - scopes = scopes or ["https://graph.microsoft.com/.default"] + scopes = scopes or [f"{self.manifest.get('endpoint', 'https://graph.microsoft.com').rstrip('/')}/.default"] token = get_valid_access_token(scopes=scopes) if not token: raise Exception("Could not acquire MS Graph access token. User may need to re-authenticate.") From 29d01b9b81b4a5f8916a4f4f1f2c507810c5d2f3 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 24 Oct 2025 18:21:51 -0500 Subject: [PATCH 47/68] init community customizations --- .../actions/databricks_mag/Dockerfile | 125 +++++++++ ...ble_plugin.additional_settings.schema.json | 41 +++ .../databricks_mag/databricks_table_plugin.py | 258 ++++++++++++++++++ .../actions/databricks_mag/readme.md | 0 .../default_token_consumption.kql | 30 ++ 5 files changed, 454 insertions(+) create mode 100644 application/community_customizations/actions/databricks_mag/Dockerfile create mode 100644 application/community_customizations/actions/databricks_mag/databricks_table_plugin.additional_settings.schema.json create mode 100644 application/community_customizations/actions/databricks_mag/databricks_table_plugin.py create mode 100644 application/community_customizations/actions/databricks_mag/readme.md create mode 100644 application/community_customizations/kusto_queries/default_token_consumption.kql diff --git a/application/community_customizations/actions/databricks_mag/Dockerfile b/application/community_customizations/actions/databricks_mag/Dockerfile new file mode 100644 index 000000000..0d982c48c --- /dev/null +++ b/application/community_customizations/actions/databricks_mag/Dockerfile @@ -0,0 +1,125 @@ +# Stage 1: System dependencies and ODBC driver install +ARG PYTHON_VERSION_ARG="3.12" +FROM python:3.12 AS builder + +ARG PYTHON_VERSION_ARG +ARG DRIVER_MAJOR_VERSION="2.9.2" +ARG DRIVER_MINOR_VERSION=1008 +ARG BUCKET_URI="https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc" + +ENV PYTHONIOENCODING=utf-8 +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV DRIVER_FULL_VERSION=${DRIVER_MAJOR_VERSION}.${DRIVER_MINOR_VERSION} +ENV FOLDER_NAME=SimbaSparkODBC-${DRIVER_FULL_VERSION}-Debian-64bit +ENV ZIP_FILE_NAME=${FOLDER_NAME}.zip + +WORKDIR /deps + +RUN apt-get update && apt-get install -y unixodbc unixodbc-dev wget unzip libsasl2-modules-gssapi-mit +# "https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.9.2/SimbaSparkODBC-2.9.2.1008-Debian-64bit.zip" +RUN wget -O /tmp/simbaspark.zip ${BUCKET_URI}/${DRIVER_MAJOR_VERSION}/${ZIP_FILE_NAME} \ + && unzip /tmp/simbaspark.zip -d /tmp/simbaspark && rm /tmp/simbaspark.zip + +RUN dpkg -i /tmp/simbaspark/SimbaSparkODBC-2.9.2.1008-Debian-64bit/simbaspark_2.9.2.1008-2_amd64.deb + +USER root +RUN groupadd -g 65532 nonroot && useradd -m -u 65532 -g nonroot nonroot +RUN python -m venv /app/venv +RUN pip install pyodbc \ + && pip install wheel \ + && pip wheel pyodbc -w /tmp/pyodbc-wheel + +#RUN find / -name "*odbc*" || true +RUN find / -name "*python${PYTHON_VERSION_ARG}*" || true + +WORKDIR /app +# Copy requirements and install them into the virtualenv +ENV PATH="/app/venv/bin:$PATH" +COPY requirements.txt . +RUN pip install /tmp/pyodbc-wheel/pyodbc-*.whl \ + && pip install --no-cache-dir -r requirements.txt + +# Fix permissions so nonroot can use everything +RUN chown -R 65532:65532 /app + +RUN echo "[Simba Spark ODBC Driver]\nDescription=Simba Spark ODBC Driver\nDriver=/opt/simba/spark/lib/64/libsparkodbc_sb64.so" > /etc/odbcinst.ini +RUN echo "[ODBC Data Sources]\nSimba Spark ODBC DSN=Simba Spark ODBC Driver" > /etc/odbc.ini +RUN find / -type f -name '*odbc*' || true +RUN find /etc/ -type f -name '*odbc*' -exec echo "Contents of {}:" \; -exec cat {} \; || true + +RUN echo "PATH contents:" && echo $PATH | tr ':' '\n' \ + && echo "LD_LIBRARY_PATH contents:" && echo $LD_LIBRARY_PATH | tr ':' '\n' + +RUN mkdir -p /app/flask_session && chown -R 65532:65532 /app/flask_session +RUN mkdir /sc-temp-files +RUN cat /opt/simba/spark/Setup/odbc.ini +RUN cat /opt/simba/spark/Setup/odbcinst.ini +USER 65532:65532 + +#Stage 3: Final containter +FROM gcr.io/distroless/python3:latest +ARG PYTHON_VERSION_ARG +WORKDIR /app +USER root +ENV PYTHONIOENCODING=utf-8 +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV PYTHONUNBUFFERED=1 +ENV PATH="/app/venv/bin:/usr/local/bin:$PATH" +ENV LD_LIBRARY_PATH="/opt/simba/spark/lib/64:/usr/local/lib:/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" +#Copy 3.12 from Base +COPY --from=builder /usr/local/lib/python${PYTHON_VERSION_ARG} /usr/local/lib/python${PYTHON_VERSION_ARG} +COPY --from=builder \ + /usr/local/lib/libpython3.12.so \ + /usr/local/lib/libpython3.12.so.1.0 \ + /usr/local/lib/libpython3.so \ + /usr/local/lib/pkgconfig \ + /usr/local/lib/python3.12 \ + /usr/local/lib/python3.13 \ + /usr/local/lib/ +# Copy the Python interpreter with a specific name +COPY --from=builder /usr/local/bin/python${PYTHON_VERSION_ARG} /usr/local/bin/python${PYTHON_VERSION_ARG} +# Add all common Python 3.12 entrypoints for compatibility +COPY --from=builder \ + /usr/local/bin/python \ + /usr/local/bin/python3 \ + /usr/local/bin/python${PYTHON_VERSION_ARG} \ + /usr/local/bin/ + +# Copy system libraries for x86_64 +COPY --from=builder /lib/x86_64-linux-gnu/ /lib/x86_64-linux-gnu/ + +# Copy ODBC from deps build +COPY --from=builder /usr/include /usr/include +COPY --from=builder /opt/simba /opt/simba +COPY --from=builder \ + /etc/odbc.ini \ + /etc/odbcinst.ini \ + /etc/ +COPY --from=builder \ + /usr/lib/x86_64-linux-gnu/libodbc.so \ + /usr/lib/x86_64-linux-gnu/libodbc.so.2 \ + /usr/lib/x86_64-linux-gnu/libodbc.so.2.0.0 \ + /usr/lib/x86_64-linux-gnu/libodbcinst.so \ + /usr/lib/x86_64-linux-gnu/libodbcinst.so.2 \ + /usr/lib/x86_64-linux-gnu/libodbcinst.so.2.0.0 \ + /usr/lib/x86_64-linux-gnu/libodbccr.so \ + /usr/lib/x86_64-linux-gnu/libodbccr.so.2 \ + /usr/lib/x86_64-linux-gnu/libodbccr.so.2.0.0 \ + /usr/lib/x86_64-linux-gnu/ + +# Copy application code and set ownership +COPY --chown=65532:65532 . ./ + +# Copy the virtualenv from the builder stage +COPY --from=builder --chown=65532:65532 /app/venv /app/venv +COPY --from=builder --chown=65532:65532 /app/flask_session /app/flask_session +COPY --from=builder --chown=65532:65532 /sc-temp-files /sc-temp-files + +# Expose port +EXPOSE 5000 + +USER 65532:65532 + +ENTRYPOINT ["/app/venv/bin/python", "-c", "import sys, runpy; print('Executable:', sys.executable); print('Version:', sys.version); runpy.run_path('/app/app.py', run_name='__main__')"] \ No newline at end of file diff --git a/application/community_customizations/actions/databricks_mag/databricks_table_plugin.additional_settings.schema.json b/application/community_customizations/actions/databricks_mag/databricks_table_plugin.additional_settings.schema.json new file mode 100644 index 000000000..7dcc6b2ae --- /dev/null +++ b/application/community_customizations/actions/databricks_mag/databricks_table_plugin.additional_settings.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Databricks Table Plugin Additional Settings", + "type": "object", + "properties": { + "warehouse_id": { + "type": "string", + "description": "Databricks SQL Warehouse ID (string, required)" + }, + "httpPath": { + "type": "string", + "description": "Databricks SQL Warehouse HTTP Path (string, required)" + }, + "port": { + "type": "integer", + "description": "Port for Databricks ODBC connection (default 443)", + "default": 443 + }, + "database": { + "type": "string", + "description": "Default database/schema to use (optional)" + }, + "table_name": { + "type": "string", + "description": "Name of the hive table that represents the 'global catalog'" + }, + "query_history": { + "type": "array", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + } + } + } + }, + "required": ["warehouse_id", "httpPath"], + "additionalProperties": false +} diff --git a/application/community_customizations/actions/databricks_mag/databricks_table_plugin.py b/application/community_customizations/actions/databricks_mag/databricks_table_plugin.py new file mode 100644 index 000000000..7fa7ec892 --- /dev/null +++ b/application/community_customizations/actions/databricks_mag/databricks_table_plugin.py @@ -0,0 +1,258 @@ +# databricks_table_plugin.py +""" +Databricks Table Plugin for Semantic Kernel +- Dynamically created per table manifest +- Executes parameterized SQL via Databricks REST API +""" + +import requests +import logging +import pyodbc +import re +import sqlglot +from semantic_kernel_plugins.base_plugin import BasePlugin +from typing import Annotated, List, Optional, Required +from functions_appinsights import log_event +from semantic_kernel.functions import kernel_function + +class DatabricksTablePlugin(BasePlugin): + def __init__(self, manifest): + self.manifest = manifest + self.authtype = manifest.get('auth', {}).get('type', 'key') + self.endpoint = manifest['endpoint'] + self.key = manifest.get('auth', {}).get('key', None) + self.identity = manifest.get('auth', {}).get('identity', None) + self.client_id = manifest.get('auth', {}).get('identity', None) + self.client_secret = manifest.get('auth', {}).get('key', None) + self.tenant_id = manifest.get('auth', {}).get('tenantId', None) + self._metadata = manifest['metadata'] + self.warehouse_id = manifest['additionalFields'].get('warehouse_id', '') + self.table_name = manifest['additionalFields'].get('table_name', '') + self.port = manifest['additionalFields'].get('port', 443) + self.http_path = manifest['additionalFields'].get('httpPath', '') + + def _get_azure_ad_token(self): + """Acquire Azure AD token for Databricks using Service Principal credentials, supporting Commercial and MAG.""" + # Determine the correct login endpoint and scope based on the Databricks endpoint + if ".azure.us" in self.endpoint or ".us/" in self.endpoint: + login_host = "login.microsoftonline.us" + scope = "https://databricks.azure.us/.default" + else: + login_host = "login.microsoftonline.com" + scope = "https://databricks.azure.net/.default" + url = f"https://{login_host}/{self.tenant_id}/oauth2/v2.0/token" + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": scope + } + resp = requests.post(url, data=data) + resp.raise_for_status() + return resp.json()["access_token"] + + def _get_databricks_token(self): + if ".azure.us" in self.endpoint or ".us/" in self.endpoint: + login_host = "login.microsoftonline.us" + scope = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default" + else: + login_host = "login.microsoftonline.com" + scope = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default" + url = f"https://{login_host}/{self.tenant_id}/oauth2/v2.0/token" + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": scope + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + resp = requests.post(url, data=data, headers=headers) + resp.raise_for_status() + print(f"[DBP] Received Databricks token response") + return resp.json()["access_token"] + + def _get_pyodbc_connection(self, additional_fields: dict = None): + """ + Create and return a DSN-less pyodbc connection to Databricks using parameters from the manifest and additional_fields. + Supports only Personal Access Token (PAT) authentication for now. + Args: + additional_fields (dict, optional): Additional connection parameters to override manifest values. + Returns: + pyodbc.Connection: An open pyodbc connection to Databricks. + Raises: + ValueError: If required fields are missing or authentication is not supported. + pyodbc.Error: If connection fails. + """ + # Merge manifest and additional_fields + fields = dict(self.manifest.get('additionalFields', {})) + if additional_fields: + fields.update(additional_fields) + + if not (self.warehouse_id and self.http_path and self.key and self.endpoint): + raise ValueError("Missing required ODBC connection parameters: warehouse_id, httpPath, endpoint, or PAT (key)") + + # Parse hostname from endpoint (strip protocol and path) + match = re.match(r"https?://([^/]+)", self.endpoint) + if not match: + raise ValueError(f"Invalid endpoint URL: {self.endpoint}") + host = match.group(1) + conn_str = None + # Only support PAT for now + if self.identity and self.identity.lower() == "managedIdentity": + raise NotImplementedError("Managed Identity authentication is not yet supported for ODBC.") + + # Build ODBC connection string + if self.authtype == "key": + print("[DBP] Using Personal Access Token Auth") + conn_str = "Driver={Simba Spark ODBC Driver};" + \ + f"Host={host};" + \ + f"Port={self.port};" + \ + f"HTTPPath={self.http_path};" + \ + "AuthMech=3;" + \ + "UID=token;" + \ + f"PWD={self.key};" + \ + "SSL=1;" + \ + "SSLVersion=TLSv1.2;" + \ + "ThriftTransport=2;" + \ + "Database=default;" + \ + "SparkServerType=3;" + + if self.authtype == "servicePrincipal": + print("[DBP] Using Service Principal Auth") + #access_token = self._get_azure_ad_token() + access_token = self._get_databricks_token() + conn_str = "Driver={Simba Spark ODBC Driver};" + \ + f"Host={host};" + \ + f"Port={self.port};" + \ + f"HTTPPath={self.http_path};" + \ + f"Auth_AccessToken={access_token};" + \ + "AuthMech=11;" + \ + "Auth_Flow=0;" + \ + "SSL=1;" + \ + "SSLVersion=TLSv1.2;" + \ + "ThriftTransport=2;" + \ + "Database=default;" + \ + "SparkServerType=3;" + + if conn_str is None: + print(f"[DBP] Unsupported auth type for ODBC: {self.authtype}") + raise ValueError(f"Unsupported authentication type for ODBC: {self.authtype}") + + try: + conn = pyodbc.connect(conn_str, autocommit=True) + print("[DBP] Successfully connected to Databricks via ODBC") + return conn + except Exception as ex: + logging.error(f"Failed to connect to Databricks ODBC: {ex}") + raise + + @property + def metadata(self): + # Compose a detailed description for the LLM and Semantic Kernel + user_desc = self._metadata.get("description", f"Databricks table plugin (table name required, columns optional)") + api_desc = ( + "This plugin executes SQL statements against Azure Databricks using the Statement Execution API. " + "It sends a POST request to the Databricks SQL endpoint provided in the manifest (e.g., 'https:///api/2.0/sql/statements'). " + "Authentication is via a Databricks personal access token or Azure AD token (for Service Principal), passed as a Bearer token in the 'Authorization' header. " + "The request body is JSON and must include: " + "'statement': the SQL query string to execute, and 'warehouse_id': the ID of the Databricks SQL warehouse to use. " + "Optional filters can be provided as keyword arguments and are converted into a SQL WHERE clause. " + "The plugin constructs the SQL statement based on the provided columns (optional), table_name (required), and filters, then submits it to Databricks. " + "If columns is not provided, all columns will be selected (SELECT *). " + "The response is the result of the SQL query, returned as JSON. " + "For more details, see: https://docs.databricks.com/api/azure/workspace/statementexecution/executestatement\n\n" + "Configuration: The plugin is configured with the Databricks API endpoint (from the manifest), access token or service principal credentials, warehouse_id via the plugin manifest. " + "The manifest should provide: 'endpoint', 'auth.key' or service principal fields, and 'additionalFields.warehouse_id'. " + "Example request body: { 'statement': 'SELECT * FROM my_table WHERE id = 1', 'warehouse_id': '' }. " + "The plugin handles parameterization and SQL construction automatically.\n\n" + "NOTE: The table name is required, columns is optional for the query_table function." + ) + full_desc = f"{user_desc}\n\n{api_desc}" + return { + "name": self._metadata.get("name", "databricks_table_plugin"), + "type": "databricks_table", + "description": full_desc, + "methods": [ + { + "name": "query_table", + "description": "Query the Databricks table using parameterized SQL. Table name is required, columns is optional. Filters can be applied as keyword arguments.", + "parameters": [ + {"name": "table_name", "type": "str", "description": "Name of the table to query", "required": True}, + {"name": "columns", "type": "List[str]", "description": "Columns to select (optional, selects all if not provided)", "required": False}, + {"name": "warehouse_id", "type": "str", "description": "Databricks warehouse ID", "required": False}, + {"name": "filters", "type": "dict", "description": "Additional filters as column=value pairs", "required": False} + ], + "returns": {"type": "dict", "description": "The query result as a dictionary (Databricks SQL API response)."} + } + ] + } + + def get_functions(self): + return ["query_table"] + + @kernel_function( + description=""" + Query the Databricks table using parameterized SQL. Table name is required and should be databasename.tablename format. + Only read-only queries (SELECT, SHOW, DESCRIBE, EXPLAIN) are allowed. + Returns the query result as a list of dictionaries, or an error result if the query is not allowed or fails. + """, + name="query_table", + ) + async def query_table( + self, + query: str, + ) -> dict: + # Only allow read-only queries + try: + statements = sqlglot.parse(query) + for stmt in statements: + if stmt.key.upper() not in ("SELECT", "SHOW", "DESCRIBE", "EXPLAIN"): + return { + "error": True, + "message": f"Only read-only queries (SELECT, SHOW, DESCRIBE, EXPLAIN) are allowed. Found: {stmt.key}", + "query": query, + "result": [] + } + conn = self._get_pyodbc_connection() + cursor = conn.cursor() + print(f"[DBP] Executing SQL: {query}") + cursor.execute(query) + print(f"[DBP] Executed successfully: {query}") + # JSON format + """ + columns = [col[0] for col in cursor.description] + rows = cursor.fetchall() + result = [dict(zip(columns, row)) for row in rows] + """ + #CSV format for data compression + columns = [col[0] for col in cursor.description] + rows = cursor.fetchall() + csv_lines = [",".join(columns)] + for row in rows: + csv_row = [str(val).replace('"', '""') for val in row] + csv_lines.append(",".join(f'"{v}"' for v in csv_row)) + + result = "\n".join(csv_lines) + + cursor.close() + conn.close() + # Estimate token count (approximate: 1 token โ‰ˆ 4 characters) + result_str = str(result) + char_count = len(result_str) + approx_tokens = char_count // 4 + print(f"[DBP] Queried {len(result)} rows from query | {char_count} chars โ‰ˆ {approx_tokens} tokens") + return { + "error": False, + "message": "Success", + "query": query, + "result": result + } + except Exception as ex: + logging.error(f"Failed to run query {query}: {ex}") + print(f"[DBP] Failed to run query: {query}\n {ex}") + return { + "error": True, + "message": f"Error: {ex}", + "query": query, + "result": [] + } diff --git a/application/community_customizations/actions/databricks_mag/readme.md b/application/community_customizations/actions/databricks_mag/readme.md new file mode 100644 index 000000000..e69de29bb diff --git a/application/community_customizations/kusto_queries/default_token_consumption.kql b/application/community_customizations/kusto_queries/default_token_consumption.kql new file mode 100644 index 000000000..010f7b6f3 --- /dev/null +++ b/application/community_customizations/kusto_queries/default_token_consumption.kql @@ -0,0 +1,30 @@ +let base = + AppTraces + | where Message startswith "[tokens]" + | extend + user_id = tostring(Properties.user_id), + active_group_id = tostring(Properties.active_group_id), + doc_scope = tostring(Properties.doc_scope), + total_tokens = toint(Properties.total_tokens), + prompt_tokens = toint(Properties.prompt_tokens), + completion_tokens = toint(Properties.completion_tokens); +let per_group = + base + | summarize + sum_total_tokens = sum(total_tokens), + sum_prompt_tokens = sum(prompt_tokens), + sum_completion_tokens = sum(completion_tokens) + by user_id, active_group_id, doc_scope + | extend total = sum_total_tokens; +per_group +| union ( + per_group + | summarize + user_id = "ALL_USERS", + active_group_id = "ALL_GROUPS", + doc_scope = "ALL_DOCS", + sum_total_tokens = sum(sum_total_tokens), + sum_prompt_tokens = sum(sum_prompt_tokens), + sum_completion_tokens = sum(sum_completion_tokens), + total = sum(total) +) \ No newline at end of file From 0ba3e3e60608a666213bf53e5860dafeb4661a41 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 24 Oct 2025 18:22:03 -0500 Subject: [PATCH 48/68] add module --- application/single_app/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 application/single_app/__init__.py diff --git a/application/single_app/__init__.py b/application/single_app/__init__.py new file mode 100644 index 000000000..e69de29bb From 5337b7aac236fd7598badfa3dadc5615ca3bad1a Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Mon, 27 Oct 2025 08:46:09 -0500 Subject: [PATCH 49/68] add key vault config modal --- .../single_app/templates/_key_vault_info.html | 245 ++++++++++++++++++ .../single_app/templates/admin_settings.html | 13 +- 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 application/single_app/templates/_key_vault_info.html diff --git a/application/single_app/templates/_key_vault_info.html b/application/single_app/templates/_key_vault_info.html new file mode 100644 index 000000000..669c81082 --- /dev/null +++ b/application/single_app/templates/_key_vault_info.html @@ -0,0 +1,245 @@ + +
+ + diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 3854e1d26..2a37d3d33 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -2913,7 +2913,15 @@
Speech Service Settings
Configure Security Settings.

-
Key Vault
+
+
+ Key Vault +
+ +
+

Configure Key Vault settings.

@@ -2976,6 +2984,9 @@
Key Vault
{% include '_health_check_info.html' %} + + {% include '_key_vault_info.html' %} + diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 2a37d3d33..cc42301f6 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -1332,6 +1332,7 @@

Each model defined here will be available in the Chat UI as an option for the User. You can include multiple models seperated by a comma (example: gpt-4o, o-1, o-3). +
NOTE: The APIM GPT Test is against the first model in the list.
From 15507e1a239cd5d439f7e856077a397df94d6227 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:06:44 -0600 Subject: [PATCH 54/68] Remove abp for pr --- application/single_app/config.py | 2 +- .../azure_billing_plugin.py | 1281 ----------------- 2 files changed, 1 insertion(+), 1282 deletions(-) delete mode 100644 application/single_app/semantic_kernel_plugins/azure_billing_plugin.py diff --git a/application/single_app/config.py b/application/single_app/config.py index a35e3075d..89bb0fc74 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.229.063" +VERSION = "0.233.153" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py b/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py deleted file mode 100644 index fdd017adf..000000000 --- a/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py +++ /dev/null @@ -1,1281 +0,0 @@ -# azure_billing_plugin.py -""" -Azure Billing Plugin for Semantic Kernel -- Supports user (Entra ID) and service principal authentication -- Uses Azure Cost Management REST API for billing, budgets, alerts, forecasting -- Renders graphs server-side as PNG (base64 for web, downloadable) -- Returns tabular data as CSV for minimal token usage -- Requires user_impersonation for user auth on 40a69793-8fe6-4db1-9591-dbc5c57b17d8 (Azure Service Management) -""" - -import io -import base64 -import requests -import csv -import inspect -import matplotlib.pyplot as plt -import logging -import time -import random -import re -import numpy as np -import datetime -from typing import Dict, Any, List, Optional, Union -import json -from collections import defaultdict -from semantic_kernel_plugins.base_plugin import BasePlugin -from semantic_kernel.functions import kernel_function -from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger -from functions_authentication import get_valid_access_token, get_valid_access_token_for_plugins -from azure.identity import DefaultAzureCredential -from functions_debug import debug_print -from azure.core.credentials import AccessToken, TokenCredential -from semantic_kernel.contents import ImageContent -from config import cosmos_messages_container, cosmos_conversations_container - - -RESOURCE_ID_REGEX = r"^/subscriptions/(?P[a-fA-F0-9-]+)/?(?:resourceGroups/(?P[^/]+))?$" -TIME_FRAME_TYPE = ["MonthToDate", "BillingMonthToDate", "WeekToDate", "Custom"] # "TheLastMonth, TheLastBillingMonth" are not supported in MAG -QUERY_TYPE = ["Usage", "ActualCost", "AmortizedCost"] -GRANULARITY_TYPE = ["None", "Daily", "Monthly", "Accumulated"] -GROUPING_TYPE = ["Dimension", "TagKey"] -AGGREGATION_FUNCTIONS = ["Sum"] #, "Average", "Min", "Max", "Count", "None"] -AGGREGATION_COLUMNS= ["Cost", "CostUSD", "PreTaxCost", "PreTaxCostUSD"] -GROUPING_DIMENSIONS = ["None", "BillingPeriod", "ChargeType", "Frequency", "MeterCategory", "MeterId", "MeterSubCategory", "Product", "ResourceGroupName", "ResourceLocation", "ResourceType", "ServiceFamily", "ServiceName", "SubscriptionId", "SubscriptionName", "Tag"] -SUPPORTED_GRAPH_TYPES = ["pie", "column_stacked", "column_grouped", "line", "area"] - -class AzureBillingPlugin(BasePlugin): - def __init__(self, manifest: Dict[str, Any]): - super().__init__(manifest) - self.manifest = manifest - self.additionalFields = manifest.get('additionalFields', {}) - self.auth = manifest.get('auth', {}) - endpoint = manifest.get('endpoint', 'https://management.azure.com').rstrip('/') - if not endpoint.startswith('https://'): - # Remove any leading http:// and force https:// - endpoint = 'https://' + endpoint.lstrip('http://').lstrip('https://') - self.endpoint = endpoint - self.metadata_dict = manifest.get('metadata', {}) - self.api_version = self.additionalFields.get('apiVersion', '2023-03-01') - - def _get_token(self) -> Optional[str]: - """Get an access token for Azure REST API calls.""" - auth_type = self.auth.get('type') - if auth_type == 'servicePrincipal': - # Service principal: use client credentials - tenant_id = self.auth.get('tenantId') - client_id = self.auth.get('identity') - client_secret = self.auth.get('key') - - # Determine AAD authority host based on management endpoint (public, gov, china) - host = self.endpoint.lower() - if "management.usgovcloudapi.net" in host: - aad_authority_host = "login.microsoftonline.us" - elif "management.azure.com" in host: - aad_authority_host = "login.microsoftonline.com" - else: - aad_authority_host = "login.microsoftonline.com" - - if not tenant_id or not client_id or not client_secret: - raise ValueError("Service principal auth requires tenantId, identity (client id), and key (client secret) in manifest 'auth'.") - - token_url = f"https://{aad_authority_host}/{tenant_id}/oauth2/v2.0/token" - data = { - 'grant_type': 'client_credentials', - 'client_id': client_id, - 'client_secret': client_secret, - 'scope': f'{self.endpoint.rstrip('/')}/.default' - } - try: - resp = requests.post(token_url, data=data, timeout=10) - resp.raise_for_status() - except requests.exceptions.HTTPError as e: - # Log the response text for diagnostics and raise a clear error - resp_text = getattr(e.response, 'text', '') if hasattr(e, 'response') else '' - logging.error("Failed to obtain service principal token. URL=%s, Error=%s, Response=%s", token_url, e, resp_text) - raise RuntimeError(f"Failed to obtain service principal token: {e}. Response: {resp_text}") - except requests.exceptions.RequestException as e: - logging.error("Error requesting service principal token: %s", e) - raise - try: - token = resp.json().get('access_token') - except ValueError: - logging.error("Invalid JSON returned from token endpoint: %s", resp.text) - raise RuntimeError(f"Invalid JSON returned from token endpoint: {resp.text}") - if not token: - logging.error("Token endpoint did not return access_token. Response: %s", resp.text) - raise RuntimeError(f"Token endpoint did not return access_token. Response: {resp.text}") - return token - else: - class UserTokenCredential(TokenCredential): - def __init__(self, scope): - self.scope = scope - - def get_token(self, *args, **kwargs): - token_result = get_valid_access_token_for_plugins(scopes=[self.scope]) - if isinstance(token_result, dict) and token_result.get("access_token"): - token = token_result["access_token"] - elif isinstance(token_result, dict) and token_result.get("error"): - # Propagate error up to plugin - raise Exception(token_result) - else: - raise RuntimeError("Could not acquire user access token for Log Analytics API.") - expires_on = int(time.time()) + 300 - return AccessToken(token, expires_on) - # User: use session token helper - scope = f"{self.endpoint.rstrip('/')}/.default" - credential = UserTokenCredential(scope) - return credential.get_token(scope).token - - def _get_headers(self) -> Dict[str, str]: - token = self._get_token() - if isinstance(token, dict) and ("error" in token or "consent_url" in token): - return token - return { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } - - def _get(self, url: str, params: Dict[str, Any] = None) -> Any: - headers = self._get_headers() - if isinstance(headers, dict) and ("error" in headers or "consent_url" in headers): - return headers - if params: - debug_print(f"GET {url} with params: {params}") - resp = requests.get(url, headers=headers, params=params) - else: - debug_print(f"GET {url} without params") - resp = requests.get(url, headers=headers) - resp.raise_for_status() - return resp.json() - - def _post(self, url: str, data: Dict[str, Any]) -> Any: - headers = self._get_headers() - resp = requests.post(url, headers=headers, json=data) - resp.raise_for_status() - return resp.json() - - def _csv_from_table(self, rows: List[Dict[str, Any]]) -> str: - if not rows: - return '' - all_keys = set() - for row in rows: - all_keys.update(row.keys()) - fieldnames = list(all_keys) - output = io.StringIO() - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - writer.writerows(rows) - return output.getvalue() - - def _flatten_dict(self, d: Dict[str, Any], parent_key: str = '', sep: str = '.') -> Dict[str, Any]: - """Flatten a nested dict into a single-level dict with dotted keys. - - Example: {'properties': {'details': {'threshold': 0.8}}} => {'properties.details.threshold': 0.8} - """ - items = {} - for k, v in (d or {}).items(): - new_key = f"{parent_key}{sep}{k}" if parent_key else k - if isinstance(v, dict): - items.update(self._flatten_dict(v, new_key, sep=sep)) - else: - items[new_key] = v - return items - - def _fig_to_base64_dict(self, fig, filename: str = "chart.png") -> Dict[str, str]: - """Convert a matplotlib Figure to a structured base64 dict. - - Returns: {"mime": "image/png", "filename": filename, "base64": , "image_url": "data:image/png;base64,"} - """ - buf = io.BytesIO() - fig.savefig(buf, format='png', bbox_inches='tight') - fig.clf() - buf.seek(0) - img_b64 = base64.b64encode(buf.read()).decode('utf-8') - return { - "mime": "image/png", - "filename": filename, - "base64": img_b64, - "image_url": f"data:image/png;base64,{img_b64}" - } - - def _parse_csv_to_rows(self, data_csv: Union[str, List[str]]) -> List[Dict[str, Any]]: - """Parse CSV content (string or list-of-lines) into list[dict]. - - - Accepts a CSV string or a list of CSV lines. - - Converts numeric-looking fields to float where possible. - """ - # Accept list of lines or full string - if isinstance(data_csv, list): - csv_text = "\n".join(data_csv) - else: - csv_text = str(data_csv) - - f = io.StringIO(csv_text) - reader = csv.DictReader(f) - rows = [] - for row in reader: - parsed = {} - for k, v in row.items(): - if v is None: - parsed[k] = None - continue - s = v.strip() - # Try int then float conversion; leave as string if neither - if s == '': - parsed[k] = '' - else: - # remove thousands separators - s_clean = s.replace(',', '') - try: - if re.match(r'^-?\d+$', s_clean): - parsed[k] = int(s_clean) - else: - # float detection (handles scientific notation) - if re.match(r'^-?\d*\.?\d+(e[-+]?\d+)?$', s_clean, re.IGNORECASE): - parsed[k] = float(s_clean) - else: - parsed[k] = s - except Exception: - parsed[k] = s - rows.append(parsed) - return rows - - def _iso_utc(dt: datetime.datetime) -> str: - return dt.astimezone(datetime.timezone.utc).isoformat() - - def _add_months(dt: datetime.datetime, months: int) -> datetime.datetime: - # Add (or subtract) months without external deps. - year = dt.year + (dt.month - 1 + months) // 12 - month = (dt.month - 1 + months) % 12 + 1 - day = min(dt.day, [31, - 29 if (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)) else 28, - 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month-1]) - return dt.replace(year=year, month=month, day=day) - - def _first_day_of_month(dt: datetime.datetime) -> datetime.datetime: - return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - - def _last_day_of_month(dt: datetime.datetime) -> datetime.datetime: - # move to first of next month then subtract one second - next_month = self._add_months(self._first_day_of_month(dt), 1) - return next_month - datetime.timedelta(seconds=1) - - def _last_n_months_timeperiod(n: int): - now = datetime.datetime.now(datetime.timezone.utc) - start = self._add_months(now, -n) - return {"from": self._iso_utc(start), "to": self._iso_utc(now)} - - def _previous_n_months_timeperiod(n: int): - today = datetime.datetime.now(datetime.timezone.utc) - first_this_month = self._first_day_of_month(today) - last_of_prev = first_this_month - datetime.timedelta(seconds=1) - first_of_earliest = self._first_day_of_month(self._add_months(first_this_month, -n)) - # ensure full days for readability - return { - "from": self._iso_utc(first_of_earliest), - "to": self._iso_utc(last_of_prev.replace(hour=23, minute=59, second=59, microsecond=0)) - } - - def _normalize_enum(self, value: Optional[str], choices: List[str]) -> Optional[str]: - """ - Normalize a string to one of the canonical choices in a case-insensitive way. - Returns the canonical choice if matched, otherwise None. - """ - if value is None: - return None - if not isinstance(value, str): - return None - v = value.strip() - # quick exact match - if v in choices: - return v - # case-insensitive match - lower_map = {c.lower(): c for c in choices} - return lower_map.get(v.lower()) - - @property - def display_name(self) -> str: - return "Azure Billing" - - @property - def metadata(self) -> Dict[str, Any]: - return { - "name": self.metadata_dict.get("name", "azure_billing_plugin"), - "type": "azure_billing", - "description": "Azure Billing plugin for cost, budgets, alerts, forecasting, CSV export, and PNG graphing.", - "methods": self._collect_kernel_methods_for_metadata() - } - - @kernel_function(description="Plot a chart/graph from provided data. Supports pie, column_stacked, column_grouped, line, and area.",) - @plugin_function_logger("AzureBillingPlugin") - def plot_chart(self, - conversationId: str, - data, - x_keys: Optional[List[str]] = None, - y_keys: Optional[List[str]] = None, - graph_type: str = "line", - title: str = "", - xlabel: str = "", - ylabel: str = "", - filename: str = "chart.png", - figsize: Optional[List[float]] = [7.0, 5.0]) -> Dict[str, Any]: - return self.plot_custom_chart( - conversation_id=conversation_id, - data=data, - x_keys=x_keys, - y_keys=y_keys, - graph_type=graph_type, - title=title, - xlabel=xlabel, - ylabel=ylabel, - filename=filename, - figsize=figsize - ) - - def plot_custom_chart(self, - conversation_id: str, - data, - x_keys: Optional[List[str]] = None, - y_keys: Optional[List[str]] = None, - graph_type: str = "line", - title: str = "", - xlabel: str = "", - ylabel: str = "", - filename: str = "chart.png", - figsize: Optional[List[float]] = [7.0, 5.0]) -> Dict[str, Any]: - """ - General plotting function. - - - data: list of dict rows (e.g., [{'date': '2025-10-01', 'cost': 12.3, 'type': 'A'}, ...]) - - x_keys: list of keys to use for x axis (required for non-pie charts); first key is primary x-axis, additional keys are used for stacking/grouping - - y_keys: list of keys to plot on y axis (if None and graph_type is not pie, autodetect numeric columns) - - graph_type: one of ['pie', 'column_stacked', 'column_grouped', 'line', 'area'] - - returns structured dict with mime, filename, base64, image_url and metadata - """ - try: - #print(f"[AzureBillingPlugin] plot_custom_chart called with conversation_id={conversation_id}, graph_type={graph_type},\n x_key={x_key},\n y_keys={y_keys},\n title={title},\n xlabel={xlabel},\n ylabel={ylabel},\n figsize={figsize},\n data:{data}") - graph_type = graph_type.lower() if isinstance(graph_type, str) else str(graph_type) - # Validate figsize: must be a list/tuple of two numbers if provided - if figsize is None: - figsize = [7.0, 5.0] - elif isinstance(figsize, (list, tuple)): - if len(figsize) != 2: - return {"status": "error", "error": "figsize must be a list of two numbers: [width, height]"} - try: - figsize = [float(figsize[0]), float(figsize[1])] - except Exception: - return {"status": "error", "error": "figsize elements must be numeric"} - else: - return {"status": "error", "error": "figsize must be a list of two numbers or null"} - - except Exception as ex: - logging.exception("Unexpected error in plot_custom_chart parameter validation") - return {"status": "error", "error": str(ex)} - if graph_type not in SUPPORTED_GRAPH_TYPES: - raise ValueError(f"Unsupported graph_type '{graph_type}'. Supported: {SUPPORTED_GRAPH_TYPES}") - - - # Accept CSV string, list of strings, or list of dicts - print(f"=====================================================================\n[ABP][PCC]data type: {type(data)}\n=====================================================================") - print(f"=====================================================================\n[ABP][PCC]data content: {str(data)[:125]}\n=====================================================================\n=====================================================================") - rows = [] - if isinstance(data, list): - if len(data) == 0: - return {"status": "error", "error": "No data provided for plotting"} - # If first item is a dict, treat as list of dicts - if isinstance(data[0], dict): - try: - rows = [r.copy() for r in data] - except Exception as ex: - logging.exception("plot_custom_chart expected list[dict] or CSV string/list") - return {"status": "error", "error": "data must be a list of dicts, a CSV string, or a list of CSV lines"} - # If first item is a string, treat as list of CSV lines - elif isinstance(data[0], str): - try: - rows = self._parse_csv_to_rows(data) - except Exception as ex: - logging.exception("Failed to parse CSV input for plotting") - return {"status": "error", "error": f"Failed to parse CSV input: {str(ex)}"} - else: - return {"status": "error", "error": "data must be a list of dicts, a CSV string, or a list of CSV lines"} - elif isinstance(data, str): - try: - rows = self._parse_csv_to_rows(data) - except Exception as ex: - logging.exception("Failed to parse CSV input for plotting") - return {"status": "error", "error": f"Failed to parse CSV input: {str(ex)}"} - else: - return {"status": "error", "error": "data must be a list of dicts, a CSV string, or a list of CSV lines"} - - # If no data, return an error-like dict - if not rows: - raise ValueError("No data provided for plotting") - - # Autodetect numeric columns if y_keys not provided - if not y_keys and graph_type != "pie": - sample = rows[0] - y_keys = [k for k, v in sample.items() if isinstance(v, (int, float))] - if not y_keys: - raise ValueError("Could not autodetect numeric columns for y axis. Provide y_keys explicitly.") - - # Prepare x values and handle x_keys as list - x_vals = None - x_key = None # Primary x-axis key - stack_col = None # Secondary key for stacking/grouping - - if graph_type != "pie": - # Normalize x_keys to a list - if x_keys is None: - x_keys = [] - elif isinstance(x_keys, str): - x_keys = [x_keys] - elif not isinstance(x_keys, list): - x_keys = list(x_keys) if hasattr(x_keys, '__iter__') else [str(x_keys)] - - # Auto-detect x_key if not provided - if not x_keys: - # attempt to pick a sensible x_key (date-like or first non-numeric) - for k, v in rows[0].items(): - if not isinstance(v, (int, float)): - x_keys.append(k) - break - - if not x_keys: - raise ValueError("x_keys is required for this chart type") - - # Primary x-axis is first key - x_key = x_keys[0] - # If multiple x_keys provided, second one is for stacking/grouping - if len(x_keys) > 1: - stack_col = x_keys[1] - - x_vals = [r.get(x_key) for r in rows] - - # Wrap plotting in try/except so we return structured errors rather than raising - try: - # Build matplotlib figure - fig, ax = plt.subplots(figsize=tuple(figsize)) - - if graph_type == "pie": - # For pie, use first x_key for labels and single y_key for values - pie_x_key = x_keys[0] if x_keys else None - if not pie_x_key or (not y_keys or len(y_keys) != 1): - raise ValueError("Pie chart requires an x_key (labels) and a single y_key for values") - labels = [r.get(pie_x_key) for r in rows] - values = [r.get(y_keys[0]) or 0 for r in rows] - ax.pie(values, labels=labels, autopct="%1.1f%%") - ax.set_title(title) - - elif graph_type in ("line", "area"): - for yk in y_keys: - y_vals = [r.get(yk) or 0 for r in rows] - if graph_type == "line": - ax.plot(x_vals, y_vals, marker='o', label=yk) - else: - ax.fill_between(x_vals, y_vals, alpha=0.5, label=yk) - if y_keys and len(y_keys) > 1: - ax.legend() - ax.set_title(title) - ax.set_xlabel(xlabel or x_key) - ax.set_ylabel(ylabel) - - elif graph_type == "column_grouped": - # Grouped bar chart: for each x position, multiple bars side-by-side - n_groups = len(rows) - n_bars = len(y_keys) - index = np.arange(n_groups) - bar_width = 0.8 / max(1, n_bars) - for i, yk in enumerate(y_keys): - y_vals = [r.get(yk) or 0 for r in rows] - ax.bar(index + i * bar_width, y_vals, bar_width, label=yk) - ax.set_xticks(index + bar_width * (n_bars - 1) / 2) - ax.set_xticklabels([str(x) for x in x_vals], rotation=45, ha='right') - ax.set_title(title) - ax.set_xlabel(xlabel or x_key) - ax.set_ylabel(ylabel) - if y_keys and len(y_keys) > 1: - ax.legend() - - elif graph_type == "column_stacked": - # Pivot data for stacking: for each x_val, get a value for each stack (y_key or grouping col) - # Get all unique x values (preserve order) - x_vals_unique = [] - seen_x = set() - for r in rows: - xval = r.get(x_key) - if xval not in seen_x: - seen_x.add(xval) - x_vals_unique.append(xval) - - # Use stack_col from x_keys if provided, otherwise auto-detect - if not stack_col and len(y_keys) == 1: - for k in rows[0].keys(): - if k != x_key and k != y_keys[0] and isinstance(rows[0][k], str): - stack_col = k - break - - pivot = defaultdict(lambda: defaultdict(float)) - stack_labels = set() - if stack_col: - for r in rows: - xval = r.get(x_key) - sval = r.get(stack_col) - yval = r.get(y_keys[0], 0) or 0 - pivot[xval][sval] += yval - stack_labels.add(sval) - stack_labels = sorted(stack_labels) - y_keys_plot = stack_labels - else: - for r in rows: - xval = r.get(x_key) - for yk in y_keys: - yval = r.get(yk, 0) or 0 - pivot[xval][yk] += yval - y_keys_plot = y_keys - data_matrix = [] - for yk in y_keys_plot: - data_matrix.append([pivot[x][yk] for x in x_vals_unique]) - index = np.arange(len(x_vals_unique)) - bottoms = np.zeros(len(x_vals_unique)) - for i, yk in enumerate(y_keys_plot): - ax.bar(index, data_matrix[i], bottom=bottoms, label=str(yk)) - bottoms += np.array(data_matrix[i]) - ax.set_xticks(index) - ax.set_xticklabels([str(x) for x in x_vals_unique], rotation=45, ha='right') - ax.set_title(title) - ax.set_xlabel(xlabel or x_key) - ax.set_ylabel(ylabel) - if len(y_keys_plot) > 1: - ax.legend(title=stack_col if stack_col else "") - - plt.tight_layout() - img_b64 = self._fig_to_base64_dict(fig, filename=filename) - self.upload_cosmos_message(conversation_id, str(img_b64.get("image_url", ""))) - return {"type": "image_url", "image_url": {"url": str(img_b64.get("image_url", ""))}} - # return f'' - """ - return { - "status": "ok", - "mime": img_b64.get("mime"), - "filename": img_b64.get("filename"), - "base64": img_b64.get("base64"), - "image_url": img_b64.get("image_url"), - "metadata": {"type": graph_type, "x_key": x_key, "y_keys": y_keys}, - "instructions": "Provide the image_url field to the application to render the chart in the chat messages." - } - """ - except Exception as ex: - logging.exception(f"Error while generating chart {str(ex)}") - return {"status": "error", "error": f"Error while generating chart: {str(ex)}"} - finally: - plt.close(fig) - - - @plugin_function_logger("AzureBillingPlugin") - @kernel_function(description="List all subscriptions and resource groups accessible to the user/service principal.") - def list_subscriptions_and_resourcegroups(self) -> str: - url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" - subs = self._get(url).get('value', []) - if isinstance(subs, dict) and ("error" in subs or "consent_url" in subs): - return subs - result = [] - for sub in subs: - sub_id = sub.get('subscriptionId') - sub_name = sub.get('displayName') - rg_url = f"{self.endpoint}/subscriptions/{sub_id}/resourcegroups?api-version=2021-04-01" - rgs = self._get(rg_url).get('value', []) - result.append({ - "subscriptionId": sub_id, - "subscriptionName": sub_name, - "resourceGroups": [rg.get('name') for rg in rgs] - }) - return self._csv_from_table(result) - - @plugin_function_logger("AzureBillingPlugin") - @kernel_function(description="List all subscriptions accessible to the user/service principal.") - def list_subscriptions(self) -> str: - url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" - data = self._get(url) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - subs = data.get('value', []) - return self._csv_from_table(subs) - - @plugin_function_logger("AzureBillingPlugin") - @kernel_function(description="List all resource groups in a subscription.") - def list_resource_groups(self, subscription_id: str) -> str: - url = f"{self.endpoint}/subscriptions/{subscription_id}/resourcegroups?api-version=2020-01-01" - data = self._get(url) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - rgs = data.get('value', []) - return self._csv_from_table(rgs) - - @kernel_function(description="Get cost forecast with custom duration and granularity.") - @plugin_function_logger("AzureBillingPlugin") - def get_forecast(self, resourceId: str, forecast_period_months: int = 12, granularity: str = "Monthly", lookback_months: Optional[int] = None) -> str: - """ - #Get cost forecast for a given period and granularity. - #scope: /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg} - #forecast_period_months: Number of months to forecast (default 12) - #granularity: "Daily", "Monthly", "Weekly" - #lookback_months: If provided, use last N months as historical data for forecasting - """ - url = f"{self.endpoint.rstrip('/')}/{resourceId.lstrip('/').rstrip('/')}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" - timeframe = "Custom" - # Calculate start/end dates for forecast - today = datetime.datetime.utcnow().date() - start_date = today - end_date = today + datetime.timedelta(days=forecast_period_months * 30) - # If lookback_months is set, use that for historical data - if lookback_months: - hist_start = today - datetime.timedelta(days=lookback_months * 30) - hist_end = today - else: - hist_start = None - hist_end = None - query = { - "type": "Forecast", - "timeframe": timeframe, - "timePeriod": { - "from": start_date.isoformat(), - "to": end_date.isoformat() - }, - "dataset": {"granularity": granularity} - } - # Optionally add historical data window - if hist_start and hist_end: - query["historicalTimePeriod"] = { - "from": hist_start.isoformat(), - "to": hist_end.isoformat() - } - data = self._post(url, query) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - rows = data.get('properties', {}).get('rows', []) - columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] - result = [dict(zip(columns, row)) for row in rows] - return self._csv_from_table(result) - - @kernel_function(description="Get budgets for a subscription or resource group.") - @plugin_function_logger("AzureBillingPlugin") - def get_budgets(self, subscription_id: str, resource_group_name: Optional[str] = None) -> str: - if resource_group_name: - scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" - else: - scope = f"/subscriptions/{subscription_id}" - url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/budgets?api-version={self.api_version}" - data = self._get(url) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - budgets = data.get('value', []) - return self._csv_from_table(budgets) - - @kernel_function(description="Get cost alerts.") - @plugin_function_logger("AzureBillingPlugin") - def get_alerts(self, subscription_id: str, resource_group_name: Optional[str] = None) -> str: - if resource_group_name: - scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" - else: - scope = f"/subscriptions/{subscription_id}" - url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/alerts?api-version={self.api_version}" - data = self._get(url) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - alerts = data.get('value', []) - return self._csv_from_table(alerts) - - @kernel_function(description="Get specific cost alert by ID.") - @plugin_function_logger("AzureBillingPlugin") - def get_specific_alert(self, subscription_id: str, alertId: str , resource_group_name: Optional[str] = None) -> str: - if resource_group_name: - scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" - else: - scope = f"/subscriptions/{subscription_id}" - url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/alerts/{alertId}?api-version={self.api_version}" - data = self._get(url) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - # Flatten nested properties for CSV friendliness - if isinstance(data, dict): - flat = self._flatten_dict(data) - # Convert lists to JSON strings for CSV - for k, v in list(flat.items()): - if isinstance(v, (list, dict)): - try: - flat[k] = json.dumps(v) - except Exception: - flat[k] = str(v) - return self._csv_from_table([flat]) - else: - # Fallback: return raw JSON string in a single column - return self._csv_from_table([{"raw": json.dumps(data)}]) - - @kernel_function(description="Run a general Azure Cost Management query with flexible dataset and aggregation. Defaults to BillingMonthToDate. Supports up to two allowed query aggregations as per API spec. Custom timeframe requires a start and stop time_period in ISO8601 extended format.") - @plugin_function_logger("AzureBillingPlugin") - def run_data_query(self, - conversation_id: str, - subscription_id: str, - generate_graph: bool = True, - graph_type: str = "stacked_column", - resource_group_name: Optional[str] = None, - query_type: str = "Usage", - timeframe: str = "BillingMonthToDate", - granularity: str = "Daily", - aggregations: Optional[List[Dict[str, Any]]] = None, - groupings: Optional[List[Dict[str, Any]]] = None, - query_filter: Optional[Dict[str, Any]] = None, - time_period: Optional[Dict[str, str]] = None, - title: Optional[str] = None, - xlabel: Optional[str] = None, - ylabel: Optional[str] = None, - filename: Optional[str] = None, - figsize: Optional[List[float]] = None) -> str: - """ - Run a general Azure Cost Management query. - - subscription_id: Azure subscription ID (required) - - resource_group_name: Resource group name (optional) - - query_type: "Usage", "ActualCost", or "Forecast" (default: "Usage") - - timeframe: e.g., "BillingMonthToDate", "MonthToDate", "Custom" (default: "BillingMonthToDate") - - granularity: "None", "Daily", "Monthly" (default: "Daily") - - aggregations: list of aggregation dicts, e.g., [{"name": "totalCost", "function": "Sum", "column": "PreTaxCost"}] - - groupings: list of grouping dicts, e.g., [{"type": "Dimension", "name": "ResourceGroupName"}] - - query_filter: dict representing filter (optional) - - time_period: dict with "from" and "to" ISO date strings (required if timeframe is "Custom"). - Example: - { - "from": "2025-04-01T00:00:00+00:00", - "to": "2025-09-30T23:59:59+00:00" - } - """ - if resource_group_name: - scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" - else: - scope = f"/subscriptions/{subscription_id}" - url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" - if not self._normalize_enum(query_type, QUERY_TYPE): - raise ValueError(f"Invalid query_type: {query_type}. Must be one of {QUERY_TYPE}.") - if not self._normalize_enum(timeframe, TIME_FRAME_TYPE): - raise ValueError(f"Invalid timeframe: {timeframe}. Must be one of {TIME_FRAME_TYPE}.") - if not self._normalize_enum(granularity, GRANULARITY_TYPE): - raise ValueError(f"Invalid granularity: {granularity}. Must be one of {GRANULARITY_TYPE}.") - query = { - "type": query_type, - "timeframe": timeframe, - "dataset": { - "granularity": granularity - } - } - # If user did not provide aggregations/groupings or filter, construct sensible defaults - if not aggregations and not groupings and not query_filter: - logging.info("No aggregations/groupings/filter provided; applying default aggregation and grouping.") - aggregations = [{ - "name": "totalCost", - "function": "Sum", - "column": "PreTaxCost" - }] - groupings = [{ - "type": "Dimension", - "name": "ResourceType" - }] - # Validate and normalize aggregations (if provided) - if aggregations: - if not isinstance(aggregations, list): - return {"status": "error", "error": "aggregations must be a list of aggregation definitions", "example": [{"name": "totalCost", "function": "Sum", "column": "PreTaxCost"}]} - if len(aggregations) > 2: - logging.warning("More than 2 aggregations provided; only the first 2 will be used") - agg_map: Dict[str, Any] = {} - for agg in aggregations[:2]: - if not isinstance(agg, dict): - return {"status": "error", "error": "Each aggregation must be a dict", "example": [{"name": "totalCost", "function": "Sum", "column": "PreTaxCost"}]} - - # Determine aggregation alias (outer key) and underlying column + function - # Support these shapes: - # 1) flat: {"name": "totalCost", "function": "Sum", "column": "PreTaxCost"} - # 2) nested: {"name": "totalCost", "aggregation": {"name": "PreTaxCost", "function": "Sum"}} - # We will produce agg_map[alias] = {"name": , "function": } - - alias = agg.get('name') - column_name = None - function = None - - if 'aggregation' in agg and isinstance(agg['aggregation'], dict): - sub = agg['aggregation'] - # sub.get('name') is the column name in nested form - column_name = sub.get('name') or sub.get('column') or agg.get('column') - function = sub.get('function') or agg.get('function') - # allow sub to specify other properties but we'll only keep name and function for compatibility - else: - # flat form - column_name = agg.get('column') or agg.get('name_of_column') or agg.get('columnName') - function = agg.get('function') - - if not alias: - return {"status": "error", "error": "Aggregation entry missing aggregation alias in 'name' field", "example": [{"name": "totalCost", "aggregation": {"name": "PreTaxCost", "function": "Sum"}}]} - if not function: - return {"status": "error", "error": f"Aggregation '{alias}' missing 'function'", "example": [{"name": alias, "aggregation": {"name": "PreTaxCost", "function": "Sum"}}]} - if not self._normalize_enum(function, AGGREGATION_FUNCTIONS): - return {"status": "error", "error": f"Aggregation function '{function}' is invalid. Must be one of: {AGGREGATION_FUNCTIONS}", "example": [{"name": alias, "aggregation": {"name": "PreTaxCost", "function": "Sum"}}]} - - details: Dict[str, Any] = {} - # per your requested shape, the inner object should include the column as 'name' - if column_name: - details['name'] = column_name - details['function'] = function - - agg_map[alias] = details - query["dataset"]["aggregation"] = agg_map - - # Validate and normalize groupings (if provided) - if groupings: - if not isinstance(groupings, list): - return {"status": "error", "error": "groupings must be a list of grouping definitions", "example": [{"type": "Dimension", "name": "ResourceLocation"}]} - if len(groupings) > 2: - logging.warning("More than 2 groupings provided; only the first 2 will be used") - normalized_groupings: List[Dict[str, str]] = [] - for grp in groupings[:2]: - if not isinstance(grp, dict): - return {"status": "error", "error": "Each grouping must be a dict with 'type' and 'name'", "example": [{"type": "Dimension", "name": "ResourceType"}]} - gtype = grp.get('type') - gname = grp.get('name') - if not gtype or not self._normalize_enum(gtype, GROUPING_TYPE): - return {"status": "error", "error": f"Grouping type '{gtype}' is invalid. Must be one of: {GROUPING_TYPE}", "example": [{"type": "Dimension", "name": "ResourceType"}]} - if not gname or not self._normalize_enum(gname, GROUPING_DIMENSIONS): - return {"status": "error", "error": f"Grouping name '{gname}' is invalid. Must be one of: {GROUPING_DIMENSIONS}", "example": [{"type": "Dimension", "name": "ResourceType"}]} - normalized_groupings.append({'type': gtype, 'name': gname}) - query["dataset"]["grouping"] = normalized_groupings - if query_filter: - query["dataset"]["filter"] = query_filter - # Enforce presence and shape of time_period when timeframe is Custom - if self._normalize_enum(timeframe, TIME_FRAME_TYPE) == "Custom": - if not time_period or not isinstance(time_period, dict): - example = { - "type": query_type, - "dataSet": { - "granularity": granularity, - "aggregation": { - "totalCost": {"name": "Cost", "function": "Sum"} - }, - "grouping": [{"type": "Dimension", "name": "ResourceType"}] - }, - "timeframe": "Custom", - "time_period": {"from": "2025-04-01T00:00:00+00:00", "to": "2025-09-30T23:59:59+00:00"} - } - return { - "status": "valueError", - "error": "timeframe is 'Custom' but no valid time_period provided. Provide a time_period dict with 'from' and 'to' ISO timestamps. Resubmit the query again with the time_period included.", - "example": example - } - # Validate that 'from' and 'to' exist - if 'from' not in time_period or 'to' not in time_period: - return {"status": "error", "error": "time_period must include 'from' and 'to' keys with ISO datetime strings."} - query["timePeriod"] = time_period - print(f"Running Cost Management query with: {json.dumps(query, indent=2)}") - data = self._post(url, query) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - rows = data.get('properties', {}).get('rows', []) - columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] - result = [dict(zip(columns, row)) for row in rows] - data = self._csv_from_table(result) - - #print(f"Data as CSV:\nColumns: {columns}\n\n{data}\n\nData as rows:\n\n{result}") - if generate_graph: - # Attempt to generate a simple line chart if possible - try: - sample = result[0] if result else None - print(f"================================================\nData as CSV:\n{sample if sample else 'No rows returned'}\n================================================") - numeric_keys = [k for k, v in sample.items() if isinstance(v, (int, float))] - - # 2. Identify likely x_key candidates (prefer date, month, or resource columns) - x_candidates = [k for k, v in sample.items() if not isinstance(v, (int, float))] - preferred_x_names = ["BillingMonth", "ResourceType", "Date", "Month", "Name"] - x_keys = [] - print(f"================================================\nNumeric keys: {numeric_keys}\nX candidates: {x_candidates}\n================================================") - for name in preferred_x_names: - if name in sample: - x_keys.append(name) - break - if not x_keys and x_candidates: - x_keys = x_candidates[0] # fallback to first non-numeric - - # 3. Optionally, group y_keys by logical meaning (e.g., cost columns) - cost_y_keys = [k for k in numeric_keys if "cost" in k.lower()] - if cost_y_keys: - y_keys = cost_y_keys - else: - y_keys = numeric_keys - print(f"Generating chart with x_keys={x_keys}, y_keys={y_keys}, graph_type={graph_type}") - graph = self.plot_custom_chart( - conversation_id=conversation_id, - data=result, #rows - x_keys=x_keys, - y_keys=y_keys, - graph_type=graph_type or "stacked_column", - title=title or "Cost Management Query Results", - xlabel=xlabel or (x_keys[0] if x_keys else "Interval"), - ylabel=ylabel or "Values", - filename=filename or "cost_query_chart.png", - figsize=figsize or [7.0, 5.0] - ) - print(f"Generated graph: {graph}") - if isinstance(graph, dict) and graph.get("type") == "image_url": - return { - "image_url": graph.get("image_url"), - "csv_data": data - } - except Exception as ex: - print(f"EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\nFailed to generate chart: {str(ex)}EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE\n") - return { - "image_url": None, - "csv_data": data - } - - @kernel_function(description="Return available configuration options for Azure Billing report queries.") - @plugin_function_logger("AzureBillingPlugin") - def get_query_configuration_options(self, subscription_id: str, resource_group_name: Optional[str] = None) -> Dict[str, Any]: - get_dimension_results = self.get_grouping_dimensions(subscription_id, resource_group_name) - if isinstance(get_dimension_results, dict) and ("error" in get_dimension_results or "consent_url" in get_dimension_results): - return get_dimension_results - if isinstance(get_dimension_results, list): - global GROUPING_DIMENSIONS - GROUPING_DIMENSIONS = get_dimension_results - return { - "TIME_FRAME_TYPE": TIME_FRAME_TYPE, - "QUERY_TYPE": QUERY_TYPE, - "GRANULARITY_TYPE": GRANULARITY_TYPE, - "GROUPING_TYPE": GROUPING_TYPE, - "GROUPING_DIMENSIONS": GROUPING_DIMENSIONS, - "AGGREGATION_FUNCTIONS": AGGREGATION_FUNCTIONS, - "AGGREGATION_COLUMNS": AGGREGATION_COLUMNS, - "NOTE": "Not all combinations are available for all queries." - } - - @kernel_function(description="Get available cost dimensions for Azure Billing.") - @plugin_function_logger("AzureBillingPlugin") - def get_grouping_dimensions(self, subscription_id: str, resource_group_name: Optional[str] = None) -> List[str]: - if resource_group_name: - scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" - else: - scope = f"/subscriptions/{subscription_id}" - # Use the Cost Management query endpoint to retrieve available dimensions/categories - # Note: some Cost Management responses return a 'value' array where each item has a - # 'properties' object containing a 'category' property. We handle that shape and - # fall back to other common fields. - url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/dimensions?api-version={self.api_version}&$expand=properties/data" - data = self._get(url) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - - values = data.get('value', []) if isinstance(data, dict) else [] - dims = [] - for item in values: - if not isinstance(item, dict): - continue - # Preferred location: item['properties']['category'] - props = item.get('properties') if isinstance(item.get('properties'), dict) else {} - cat = props.get('category') or props.get('Category') - if not cat: - # fallback to name/displayName - cat = item.get('name') or props.get('name') or props.get('displayName') - if cat: - dims.append(cat) - - # dedupe while preserving order - seen = set() - deduped = [] - for d in dims: - if d not in seen: - seen.add(d) - deduped.append(d) - return deduped - - @kernel_function(description="Run a sample or provided Cost Management query and return the columns metadata (name + type). Useful for discovering which columns can be used for aggregation and grouping.") - @plugin_function_logger("AzureBillingPlugin") - def get_query_columns(self, subscription_id: str, resource_group_name: Optional[str] = None, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: - """ - Discover columns for a Cost Management query. - - - subscription_id: required - - resource_group_name: optional - - query: optional Cost Management query dict; if omitted a minimal Usage MonthToDate query is used - - Returns a list of {"name": , "type": }. - """ - if resource_group_name: - scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" - else: - scope = f"/subscriptions/{subscription_id}" - url = f"{self.endpoint.rstrip('/')}" + f"{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" - - if not query: - query = { - "type": "Usage", - "timeframe": "MonthToDate", - "dataset": {"granularity": "None"} - } - - data = self._post(url, query) - if isinstance(data, dict) and ("error" in data or "consent_url" in data): - return data - - # Two possible shapes: properties.columns or value[].properties.columns - cols = [] - props = data.get('properties') if isinstance(data, dict) else None - if props and isinstance(props, dict) and props.get('columns'): - cols = props.get('columns', []) - else: - # Inspect value[] items for properties.columns - values = data.get('value', []) if isinstance(data, dict) else [] - for item in values: - if not isinstance(item, dict): - continue - p = item.get('properties') if isinstance(item.get('properties'), dict) else {} - if p.get('columns'): - cols = p.get('columns') - break - - result = [] - for c in cols or []: - if not isinstance(c, dict): - continue - name = c.get('name') or c.get('displayName') - typ = c.get('type') or c.get('dataType') or c.get('data', {}).get('type') if isinstance(c.get('data'), dict) else c.get('type') - result.append({"name": name, "type": typ}) - - return result - - @kernel_function(description="Return only aggregatable (numeric) columns from a sample or provided query.") - @plugin_function_logger("AzureBillingPlugin") - def get_aggregatable_columns(self, subscription_id: str, resource_group_name: Optional[str] = None, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: - """ - Returns columns suitable for aggregation (numeric types). Uses `get_query_columns` internally. - """ - cols = self.get_query_columns(subscription_id, resource_group_name, query) - if isinstance(cols, dict) and ("error" in cols or "consent_url" in cols): - return cols - numeric_types = {"Number", "Double", "Integer", "Decimal", "Long", "Float"} - agg = [c for c in (cols or []) if (c.get('type') in numeric_types or (isinstance(c.get('type'), str) and c.get('type').lower() == 'number'))] - return agg - - - @kernel_function(description="Get the expected formatting, in JSON, for run_data_query parameters.") - @plugin_function_logger("AzureBillingPlugin") - def get_format_run_data_query(self) -> Dict[str, Any]: - """ - Returns an example JSON object describing the expected parameters for run_data_query. - Includes required/optional fields, types, valid values, and reflects the latest method signature. - """ - return { - "conversation_id": "", - "subscription_id": "", - "resource_group_name": "", - "generate_graph": "", - "graph_type": f"", - "query_type": f"", - "timeframe": f"", - "granularity": f"", - "aggregations": [ - { - "name": "totalCost", - "function": f"", - "column": f"" - } - ], - "groupings": [ - { - "type": f"", - "name": f"" - } - ], - "query_filter": { - "": "" - }, - "time_period": { - "from": "2025-04-01T00:00:00+00:00", - "to": "2025-09-30T23:59:59+00:00" - }, - "title": "", - "xlabel": "", - "ylabel": "", - "filename": "", - "figsize": "", - "example": { - "conversation_id": "abc123", - "subscription_id": "00000000-0000-0000-0000-000000000000", - "resource_group_name": "my-resource-group", - "generate_graph": True, - "graph_type": "column_stacked", - "query_type": "Usage", - "timeframe": "Custom", - "granularity": "Monthly", - "aggregations": [ - {"name": "totalCost", "function": "Sum", "column": "PreTaxCost"} - ], - "groupings": [ - {"type": "Dimension", "name": "ResourceType"} - ], - "query_filter": {}, - "time_period": { - "from": "2025-04-01T00:00:00+00:00", - "to": "2025-09-30T23:59:59+00:00" - }, - "title": "Monthly Cost by Resource Type", - "xlabel": "Month", - "ylabel": "Cost (USD)", - "filename": "cost_chart.png", - "figsize": [7.0, 5.0] - } - } - - # Returns the expected input data format for plot_custom_chart - @kernel_function(description="Get the expected input data format for plot_custom_chart (graphing) as JSON.") - @plugin_function_logger("AzureBillingPlugin") - def get_format_plot_chart() -> Dict[str, Any]: - """ - Returns an example object describing the expected 'data' parameter for plot_custom_chart. - The 'data' field should be a CSV string (with headers and rows), matching the output format of run_data_query. - """ - return { - "conversationId": "", - "data": ( - "Currency,PreTaxCost,ResourceType,BillingMonth\n" - "USD,381.494,microsoft.aad/domainservices,2025-04-01T00:00:00\n" - "USD,29.0501126666667,microsoft.automation/automationaccounts,2025-04-01T00:00:00\n" - "USD,4715.19880797811,microsoft.compute/disks,2025-04-01T00:00:00\n" - "USD,12694.4370275807,microsoft.compute/virtualmachines,2025-04-01T00:00:00" - ), - "x_key": "BillingMonth", - "y_keys": ["PreTaxCost"], - "graph_type": "column_stacked", - "title": "Monthly Cost by Resource Type for Subscription xxx", - "xlabel": "Month", - "ylabel": "Cost (USD)", - "filename": "chart.png", - "figsize": [7.0, 5.0], - "notes": "'data' should be a CSV string with headers and rows, as output by run_data_query. x_key is the field for the x-axis, y_keys are the numeric fields to plot." - } - - def upload_cosmos_message(self, - conversation_id: str, - content: str) -> Dict[str, Any]: - """ - Upload a message to Azure Cosmos DB. - """ - try: - image_message_id = f"{conversation_id}_image_{int(time.time())}_{random.randint(1000,9999)}" - # Check if image data is too large for a single Cosmos document (2MB limit) - # Account for JSON overhead by using 1.5MB as the safe limit for base64 content - max_content_size = 1500000 # 1.5MB in bytes - - if len(content) > max_content_size: - debug_print(f"Large image detected ({len(content)} bytes), splitting across multiple documents") - - # Split the data URL into manageable chunks - if content.startswith('data:image/png;base64,'): - # Extract just the base64 part for splitting - data_url_prefix = 'data:image/png;base64,' - base64_content = content[len(data_url_prefix):] - debug_print(f"Extracted base64 content length: {len(base64_content)} bytes") - else: - # For regular URLs, store as-is (shouldn't happen with large content) - data_url_prefix = '' - base64_content = content - - # Calculate chunk size and number of chunks - chunk_size = max_content_size - len(data_url_prefix) - 200 # More room for JSON overhead - chunks = [base64_content[i:i+chunk_size] for i in range(0, len(base64_content), chunk_size)] - total_chunks = len(chunks) - - debug_print(f"Splitting into {total_chunks} chunks of max {chunk_size} bytes each") - for i, chunk in enumerate(chunks): - debug_print(f"Chunk {i} length: {len(chunk)} bytes") - - # Verify we can reassemble before storing - reassembled_test = data_url_prefix + ''.join(chunks) - if len(reassembled_test) == len(content): - debug_print(f"โœ… Chunking verification passed - can reassemble to original size") - else: - debug_print(f"โŒ Chunking verification failed - {len(reassembled_test)} vs {len(content)}") - - - # Create main image document with metadata - main_image_doc = { - 'id': image_message_id, - 'conversation_id': conversation_id, - 'role': 'image', - 'content': f"{data_url_prefix}{chunks[0]}", # First chunk with data URL prefix - 'prompt': '', - 'created_at': datetime.datetime.utcnow().isoformat(), - 'timestamp': datetime.datetime.utcnow().isoformat(), - 'model_deployment_name': 'azurebillingplugin', - 'metadata': { - 'is_chunked': True, - 'total_chunks': total_chunks, - 'chunk_index': 0, - 'original_size': len(content) - } - } - - # Create additional chunk documents - chunk_docs = [] - for i in range(1, total_chunks): - chunk_doc = { - 'id': f"{image_message_id}_chunk_{i}", - 'conversation_id': conversation_id, - 'role': 'image_chunk', - 'content': chunks[i], - 'parent_message_id': image_message_id, - 'created_at': datetime.datetime.utcnow().isoformat(), - 'timestamp': datetime.datetime.utcnow().isoformat(), - 'metadata': { - 'is_chunk': True, - 'chunk_index': i, - 'total_chunks': total_chunks, - 'parent_message_id': image_message_id - } - } - chunk_docs.append(chunk_doc) - - # Store all documents - debug_print(f"Storing main document with content length: {len(main_image_doc['content'])} bytes") - cosmos_messages_container.upsert_item(main_image_doc) - - for i, chunk_doc in enumerate(chunk_docs): - debug_print(f"Storing chunk {i+1} with content length: {len(chunk_doc['content'])} bytes") - cosmos_messages_container.upsert_item(chunk_doc) - - debug_print(f"Successfully stored image in {total_chunks} documents") - debug_print(f"Main doc content starts with: {main_image_doc['content'][:50]}...") - debug_print(f"Main doc content ends with: ...{main_image_doc['content'][-50:]}") - - # Return the full image URL for immediate display - response_image_url = content - - else: - # Small image - store normally in single document - debug_print(f"Small image ({len(content)} bytes), storing in single document") - - image_doc = { - 'id': image_message_id, - 'conversation_id': conversation_id, - 'role': 'image', - 'content': content, - 'prompt': user_message, - 'created_at': datetime.datetime.utcnow().isoformat(), - 'timestamp': datetime.datetime.utcnow().isoformat(), - 'model_deployment_name': image_gen_model, - 'metadata': { - 'is_chunked': False, - 'original_size': len(content) - } - } - cosmos_messages_container.upsert_item(image_doc) - response_image_url = content - conversation_item = cosmos_conversations_container.read_item(item=conversation_id, partition_key=conversation_id) - conversation_item['last_updated'] = datetime.datetime.utcnow().isoformat() - cosmos_conversations_container.upsert_item(conversation_item) - except Exception as e: - print(f"[ABP] Error uploading image message to Cosmos DB: {str(e)}") - logging.error(f"[ABP] Error uploading image message to Cosmos DB: {str(e)}") From 841a70b8ef4a4ec52956bd22621ac1c5ed179d7b Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:08:51 -0600 Subject: [PATCH 55/68] disable static logging for development --- application/single_app/app.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/application/single_app/app.py b/application/single_app/app.py index 17a01f768..2393ee684 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -59,7 +59,7 @@ from route_migration import bp_migration from route_plugin_logging import bpl as plugin_logging_bp -app = Flask(__name__) +app = Flask(__name__, static_url_path='/static', static_folder='static') app.config['EXECUTOR_TYPE'] = EXECUTOR_TYPE app.config['EXECUTOR_MAX_WORKERS'] = EXECUTOR_MAX_WORKERS @@ -465,7 +465,12 @@ def list_semantic_kernel_plugins(): if debug_mode: # Local development with HTTPS - app.run(host="0.0.0.0", port=5000, debug=True, ssl_context='adhoc', threaded=True) + # use_reloader=False prevents too_many_retries errors with static files + # Disable excessive logging for static file requests in development + import logging + werkzeug_logger = logging.getLogger('werkzeug') + werkzeug_logger.setLevel(logging.ERROR) + app.run(host="0.0.0.0", port=5000, debug=True, ssl_context='adhoc', threaded=True, use_reloader=False) else: # Production port = int(os.environ.get("PORT", 5000)) From 73ac08cecc17936dea91f378c189cbb779872ddc Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:25:08 -0600 Subject: [PATCH 56/68] rmv dup import --- application/single_app/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/application/single_app/app.py b/application/single_app/app.py index 2393ee684..e63932d3b 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -467,7 +467,6 @@ def list_semantic_kernel_plugins(): # Local development with HTTPS # use_reloader=False prevents too_many_retries errors with static files # Disable excessive logging for static file requests in development - import logging werkzeug_logger = logging.getLogger('werkzeug') werkzeug_logger.setLevel(logging.ERROR) app.run(host="0.0.0.0", port=5000, debug=True, ssl_context='adhoc', threaded=True, use_reloader=False) From 825555aa57220d88ba0f9918b08fb627f968f5a8 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:27:12 -0600 Subject: [PATCH 57/68] add note on pass --- application/single_app/semantic_kernel_loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 7047c7aef..6347ffaab 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -1678,6 +1678,7 @@ def pick(key): try: setattr(prompt_exec_settings, fld, val) except Exception: + # pass this to prevent additional future agent types from potentially failing pass # stop sequences -> map to 'stop' which OpenAI expects From a4d6725fde6f81c96c37c084dc83a8d7195d95d8 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:27:48 -0600 Subject: [PATCH 58/68] added notes --- application/single_app/semantic_kernel_loader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 6347ffaab..6280ad3ef 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -1687,6 +1687,7 @@ def pick(key): try: setattr(prompt_exec_settings, "stop", stop_seqs) except Exception: + # pass this to prevent additional future agent types from potentially failing pass if hasattr(prompt_exec_settings, 'function_choice_behavior'): @@ -1694,6 +1695,7 @@ def pick(key): try: prompt_exec_settings.function_choice_behavior = FunctionChoiceBehavior.from_string('auto') except Exception: + # pass this to prevent additional future agent types from potentially failing pass else: print(f"[SK Loader] function_choice_behavior attribute not found in prompt execution settings for agent: {agent_config.get('name')}") From 2abfccfa2e4b461a3df687b79db4d4bc48b0d453 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:29:47 -0600 Subject: [PATCH 59/68] rmv dup decl --- application/single_app/static/js/plugin_modal_stepper.js | 1 - 1 file changed, 1 deletion(-) diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 84df9a5fc..2b49c9f78 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -1828,7 +1828,6 @@ export class PluginModalStepper { 'none': 'No Authentication', 'api_key': 'API Key', 'bearer': 'Bearer Token', - 'basic': 'Basic Authentication', 'oauth2': 'OAuth2', 'windows': 'Windows Authentication', 'sql': 'SQL Authentication', From 900c9f5f7ac675f88d8f73be635a2a66e2ace498 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:30:27 -0600 Subject: [PATCH 60/68] add semicolon --- application/single_app/static/js/plugin_modal_stepper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 2b49c9f78..8904ec817 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -2520,7 +2520,7 @@ export class PluginModalStepper { // Normalize type for filename const safeType = this.getSafeType(type); // Choose filename pattern - const schemaFile = `${safeType}_plugin.additional_settings.schema.json` + const schemaFile = `${safeType}_plugin.additional_settings.schema.json`; const schemaPath = `/static/json/schemas/${schemaFile}`; From 8f37e5442a56e6944fa5ff0ccd7a229eef7a4681 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:37:37 -0600 Subject: [PATCH 61/68] rmv unused variable add agent name to log --- application/single_app/functions_keyvault.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 6dd235c7f..e1dd7e82b 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -267,7 +267,6 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_ty key_vault_name = settings.get("key_vault_name", None) if not enable_key_vault_secret_storage or not key_vault_name: return agent_dict - source = "agent" updated = dict(agent_dict) agent_name = updated.get('name', 'agent') use_apim = updated.get('enable_agent_gpt_apim', False) @@ -284,7 +283,7 @@ def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_ty else: updated[key] = ui_trigger_word except Exception as e: - logging.error(f"Failed to retrieve agent key '{key}' from Key Vault: {e}") + logging.error(f"Failed to retrieve agent key '{key}' for agent '{agent_name}' from Key Vault: {e}") return updated return updated From 41c3a8331373c065e4aadeec610b78127fbbb472 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 09:59:59 -0600 Subject: [PATCH 62/68] add actions migration back in --- application/single_app/semantic_kernel_loader.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 6280ad3ef..80bc2fd92 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -6,10 +6,6 @@ """ import logging -import importlib -import os -import importlib.util -import inspect import builtins from agent_orchestrator_groupchat import OrchestratorAgent, SCGroupChatManager from semantic_kernel import Kernel @@ -1113,7 +1109,8 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie "agents": agents_cfg }, level=logging.INFO) - + # Ensure migration is complete (will migrate any remaining legacy data) + ensure_actions_migration_complete(user_id) plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) # PATCH: Merge global plugins if enabled From b287aac8cae42d0734100698f3f485dccf308725 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Tue, 11 Nov 2025 10:00:07 -0600 Subject: [PATCH 63/68] add notes and copilot fixes --- application/single_app/app_settings_cache.py | 2 -- application/single_app/functions_global_agents.py | 2 +- application/single_app/functions_keyvault.py | 5 ++--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index 89ec7cbe3..cf908540b 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -4,8 +4,6 @@ ALWAYS import app_settings_cache and use app_settings_cache.get_settings_cache() to get settings. This supports the dynamic selection of redis or in-memory caching of settings. """ -import os -import redis import json from redis import Redis from azure.identity import DefaultAzureCredential diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index addb5fe9c..e0595b832 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -14,7 +14,7 @@ from functions_authentication import get_current_user_id from datetime import datetime from config import cosmos_global_agents_container -from functions_keyvault import keyvault_agent_save_helper, store_secret_in_key_vault, keyvault_agent_get_helper, keyvault_agent_delete_helper +from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper from functions_settings import * diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index e1dd7e82b..b00cc4f02 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -377,7 +377,6 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ if scope not in supported_scopes: logging.error(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") raise ValueError(f"Scope '{scope}' is not supported. Supported scopes: {supported_scopes}") - source = "action" updated = dict(plugin_dict) plugin_name = updated.get('name', 'plugin') auth = updated.get('auth', {}) @@ -400,8 +399,8 @@ def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_ new_auth['key'] = ui_trigger_word updated['auth'] = new_auth except Exception as e: - logging.error(f"Failed to retrieve action key from Key Vault: {e}") - raise Exception(f"Failed to retrieve action key from Key Vault: {e}") + logging.error(f"Failed to retrieve action {plugin_name} key from Key Vault: {e}") + raise Exception(f"Failed to retrieve action {plugin_name} key from Key Vault: {e}") additional_fields = updated.get('additionalFields', {}) if isinstance(additional_fields, dict): From 26b174eeee33866e0be87e2426a76c3f0f40697f Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Thu, 13 Nov 2025 13:45:34 -0600 Subject: [PATCH 64/68] add group agents/actions --- application/single_app/config.py | 2 +- .../single_app/functions_global_agents.py | 6 + application/single_app/functions_group.py | 27 ++ .../single_app/functions_group_actions.py | 206 ++++++++++ .../single_app/functions_group_agents.py | 197 +++++++++ .../single_app/functions_personal_agents.py | 9 +- application/single_app/functions_settings.py | 25 ++ .../single_app/route_backend_agents.py | 196 ++++++++- application/single_app/route_backend_chats.py | 12 +- .../single_app/route_backend_plugins.py | 174 ++++++++ .../single_app/semantic_kernel_loader.py | 148 ++++++- .../single_app/static/js/agents_common.js | 60 ++- .../single_app/static/js/chat/chat-agents.js | 72 ++-- .../static/js/chat/chat-messages.js | 12 +- .../static/js/workspace/group_agents.js | 388 ++++++++++++++++++ .../static/js/workspace/group_plugins.js | 381 +++++++++++++++++ .../static/json/schemas/agent.schema.json | 6 + .../azure_billing_plugin.definition.json | 0 .../schemas/plugin.definition.schema.json | 0 .../single_app/templates/_sidebar_nav.html | 12 + application/single_app/templates/chats.html | 1 + .../templates/group_workspaces.html | 174 ++++++++ 22 files changed, 2038 insertions(+), 70 deletions(-) create mode 100644 application/single_app/functions_group_actions.py create mode 100644 application/single_app/functions_group_agents.py create mode 100644 application/single_app/static/js/workspace/group_agents.js create mode 100644 application/single_app/static/js/workspace/group_plugins.js create mode 100644 application/single_app/static/json/schemas/azure_billing_plugin.definition.json create mode 100644 application/single_app/static/json/schemas/plugin.definition.schema.json diff --git a/application/single_app/config.py b/application/single_app/config.py index 89bb0fc74..ede91012b 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.233.153" +VERSION = "0.233.159" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index e0595b832..e08377514 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -40,6 +40,7 @@ def ensure_default_global_agent_exists(): "azure_agent_apim_gpt_api_version": "", "enable_agent_gpt_apim": False, "is_global": True, + "is_group": False, "instructions": ( "You are a highly capable research assistant. Your role is to help the user investigate academic, technical, and real-world topics by finding relevant information, summarizing key points, identifying knowledge gaps, and suggesting credible sources for further study.\n\n" "You must always:\n- Think step-by-step and work methodically.\n- Distinguish between fact, inference, and opinion.\n- Clearly state your assumptions when making inferences.\n- Cite authoritative sources when possible (e.g., peer-reviewed journals, academic publishers, government agencies).\n- Avoid speculation unless explicitly asked for.\n- When asked to summarize, preserve the intent, nuance, and technical accuracy of the original content.\n- When generating questions, aim for depth and clarity to guide rigorous inquiry.\n- Present answers in a clear, structured format using bullet points, tables, or headings when appropriate.\n\n" @@ -105,6 +106,8 @@ def get_global_agents(): for agent in agents: if agent.get('max_completion_tokens') is None: agent['max_completion_tokens'] = -1 + agent.setdefault('is_global', True) + agent.setdefault('is_group', False) return agents except Exception as e: log_event( @@ -135,6 +138,8 @@ def get_global_agent(agent_id): agent = keyvault_agent_get_helper(agent, agent_id, scope="global") if agent.get('max_completion_tokens') is None: agent['max_completion_tokens'] = -1 + agent.setdefault('is_global', True) + agent.setdefault('is_group', False) print(f"Found global agent: {agent_id}") return agent except Exception as e: @@ -165,6 +170,7 @@ def save_global_agent(agent_data): agent_data['id'] = str(uuid.uuid4()) # Add metadata agent_data['is_global'] = True + agent_data['is_group'] = False agent_data['created_at'] = datetime.utcnow().isoformat() agent_data['updated_at'] = datetime.utcnow().isoformat() log_event( diff --git a/application/single_app/functions_group.py b/application/single_app/functions_group.py index 2d1179f1f..195e268e3 100644 --- a/application/single_app/functions_group.py +++ b/application/single_app/functions_group.py @@ -3,6 +3,7 @@ from config import * from functions_authentication import * from functions_settings import * +from typing import Iterable def create_group(name, description): @@ -128,6 +129,32 @@ def get_user_role_in_group(group_doc, user_id): return None +def require_active_group(user_id: str) -> str: + """Return the active group id for a user or raise ValueError if missing.""" + settings = get_user_settings(user_id) + active_group_id = settings.get("settings", {}).get("activeGroupOid") + if not active_group_id: + raise ValueError("No active group selected") + return active_group_id + + +def assert_group_role(user_id: str, group_id: str, allowed_roles: Iterable[str] = ("Owner", "Admin")) -> str: + """Ensure the user holds one of the allowed roles for the group.""" + group_doc = find_group_by_id(group_id) + if not group_doc: + raise LookupError("Group not found") + + role = get_user_role_in_group(group_doc, user_id) + if not role: + raise PermissionError("User is not a member of this group") + + allowed = {r.lower() for r in allowed_roles} + if role.lower() not in allowed: + raise PermissionError("Insufficient permissions for this group") + + return role + + def map_group_list_for_frontend(groups, current_user_id): """ Utility to produce a simplified list of group data diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py new file mode 100644 index 000000000..2a1f536ea --- /dev/null +++ b/application/single_app/functions_group_actions.py @@ -0,0 +1,206 @@ +# functions_group_actions.py + +"""Group-level plugin/action management helpers.""" + +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from azure.cosmos import exceptions +from flask import current_app + +from config import cosmos_group_actions_container +from functions_keyvault import ( + SecretReturnType, + keyvault_plugin_delete_helper, + keyvault_plugin_get_helper, + keyvault_plugin_save_helper, +) + + +_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +def get_group_actions( + group_id: str, return_type: SecretReturnType = SecretReturnType.TRIGGER +) -> List[Dict[str, Any]]: + """Return all actions/plugins scoped to the provided group.""" + try: + query = "SELECT * FROM c WHERE c.group_id = @group_id" + parameters = [ + {"name": "@group_id", "value": group_id}, + ] + results = list( + cosmos_group_actions_container.query_items( + query=query, + parameters=parameters, + partition_key=group_id, + ) + ) + return [_clean_action(action, group_id, return_type) for action in results] + except exceptions.CosmosResourceNotFoundError: + return [] + except Exception as exc: + current_app.logger.error( + "Error fetching group actions for %s: %s", group_id, exc + ) + return [] + + +def get_group_action( + group_id: str, action_id: str, return_type: SecretReturnType = SecretReturnType.TRIGGER +) -> Optional[Dict[str, Any]]: + """Fetch a single group action by id or name.""" + try: + action = cosmos_group_actions_container.read_item( + item=action_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + query = "SELECT * FROM c WHERE c.group_id = @group_id AND c.name = @name" + parameters = [ + {"name": "@group_id", "value": group_id}, + {"name": "@name", "value": action_id}, + ] + actions = list( + cosmos_group_actions_container.query_items( + query=query, + parameters=parameters, + partition_key=group_id, + ) + ) + if not actions: + return None + action = actions[0] + except Exception as exc: + current_app.logger.error( + "Error fetching group action %s for %s: %s", action_id, group_id, exc + ) + return None + + return _clean_action(action, group_id, return_type) + + +def save_group_action(group_id: str, action_data: Dict[str, Any]) -> Dict[str, Any]: + """Create or update a group action entry.""" + payload = dict(action_data) + action_id = payload.get("id") or str(uuid.uuid4()) + + payload["id"] = action_id + payload["group_id"] = group_id + payload["last_updated"] = datetime.utcnow().isoformat() + + payload.setdefault("name", "") + payload.setdefault("displayName", payload.get("name", "")) + payload.setdefault("type", "") + payload.setdefault("description", "") + payload.setdefault("endpoint", "") + payload.setdefault("auth", {"type": "identity"}) + payload.setdefault("metadata", {}) + payload.setdefault("additionalFields", {}) + + if not isinstance(payload["auth"], dict): + payload["auth"] = {"type": "identity"} + elif "type" not in payload["auth"]: + payload["auth"]["type"] = "identity" + + payload.pop("user_id", None) + + payload = keyvault_plugin_save_helper(payload, scope_value=group_id, scope="group") + + try: + stored = cosmos_group_actions_container.upsert_item(body=payload) + return _clean_action(stored, group_id, SecretReturnType.TRIGGER) + except Exception as exc: + current_app.logger.error( + "Error saving group action %s for %s: %s", action_id, group_id, exc + ) + raise + + +def delete_group_action(group_id: str, action_id: str) -> bool: + """Remove a group action entry if it exists.""" + try: + action = cosmos_group_actions_container.read_item( + item=action_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + return False + + try: + keyvault_plugin_delete_helper(action, scope_value=group_id, scope="group") + cosmos_group_actions_container.delete_item( + item=action_id, + partition_key=group_id, + ) + return True + except Exception as exc: + current_app.logger.error( + "Error deleting group action %s for %s: %s", action_id, group_id, exc + ) + raise + + +def validate_group_action_payload(payload: Dict[str, Any], partial: bool = False) -> None: + """Validate incoming payload data for group actions.""" + if not isinstance(payload, dict): + raise ValueError("Action payload must be an object") + + required_fields = ( + "name", + "displayName", + "type", + "description", + "endpoint", + "auth", + "metadata", + "additionalFields", + ) + + if not partial: + missing = [field for field in required_fields if field not in payload] + if missing: + raise ValueError(f"Missing required action fields: {', '.join(missing)}") + + if "name" in payload: + name = payload["name"] + if not isinstance(name, str) or not name or not _NAME_PATTERN.fullmatch(name): + raise ValueError("Action name must be alphanumeric with optional underscores or hyphens") + + if "displayName" in payload and not isinstance(payload["displayName"], str): + raise ValueError("displayName must be a string") + + if "type" in payload and not isinstance(payload["type"], str): + raise ValueError("type must be a string") + + if "description" in payload and not isinstance(payload["description"], str): + raise ValueError("description must be a string") + + if "endpoint" in payload and not isinstance(payload["endpoint"], str): + raise ValueError("endpoint must be a string") + + if "auth" in payload and not isinstance(payload["auth"], dict): + raise ValueError("auth must be an object") + + if "metadata" in payload and not isinstance(payload["metadata"], dict): + raise ValueError("metadata must be an object") + + if "additionalFields" in payload and not isinstance(payload["additionalFields"], dict): + raise ValueError("additionalFields must be an object") + + +def _clean_action( + action: Dict[str, Any], + group_id: str, + return_type: SecretReturnType, +) -> Dict[str, Any]: + cleaned = {k: v for k, v in action.items() if not k.startswith("_")} + cleaned = keyvault_plugin_get_helper( + cleaned, + scope_value=group_id, + scope="group", + return_type=return_type, + ) + return cleaned diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py new file mode 100644 index 000000000..8c587a268 --- /dev/null +++ b/application/single_app/functions_group_agents.py @@ -0,0 +1,197 @@ +# functions_group_agents.py + +"""Group-level agent management helpers.""" + +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from azure.cosmos import exceptions +from flask import current_app + +from config import cosmos_group_agents_container +from functions_keyvault import ( + keyvault_agent_delete_helper, + keyvault_agent_get_helper, + keyvault_agent_save_helper, +) + + +_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +def get_group_agents(group_id: str) -> List[Dict[str, Any]]: + """Return all agents scoped to the provided group.""" + try: + query = "SELECT * FROM c WHERE c.group_id = @group_id" + parameters = [ + {"name": "@group_id", "value": group_id}, + ] + results = list( + cosmos_group_agents_container.query_items( + query=query, + parameters=parameters, + partition_key=group_id, + ) + ) + return [_clean_agent(agent) for agent in results] + except exceptions.CosmosResourceNotFoundError: + return [] + except Exception as exc: + current_app.logger.error( + "Error fetching group agents for %s: %s", group_id, exc + ) + return [] + + +def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: + """Fetch a single group agent document.""" + try: + agent = cosmos_group_agents_container.read_item( + item=agent_id, + partition_key=group_id, + ) + return _clean_agent(agent) + except exceptions.CosmosResourceNotFoundError: + return None + except Exception as exc: + current_app.logger.error( + "Error fetching group agent %s for %s: %s", agent_id, group_id, exc + ) + return None + + +def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any]: + """Create or update a group agent entry.""" + agent_id = agent_data.get("id") or str(uuid.uuid4()) + payload = dict(agent_data) + payload["id"] = agent_id + payload["group_id"] = group_id + payload["last_updated"] = datetime.utcnow().isoformat() + payload["is_global"] = False + payload["is_group"] = True + + # Required/defaulted fields + payload.setdefault("name", "") + payload.setdefault("display_name", payload.get("name", "")) + payload.setdefault("description", "") + payload.setdefault("instructions", "") + payload.setdefault("actions_to_load", []) + payload.setdefault("other_settings", {}) + payload.setdefault("max_completion_tokens", -1) + payload.setdefault("enable_agent_gpt_apim", False) + + # Ensure optional Azure fields exist + payload.setdefault("azure_openai_gpt_endpoint", "") + payload.setdefault("azure_openai_gpt_key", "") + payload.setdefault("azure_openai_gpt_deployment", "") + payload.setdefault("azure_openai_gpt_api_version", "") + payload.setdefault("azure_agent_apim_gpt_endpoint", "") + payload.setdefault("azure_agent_apim_gpt_subscription_key", "") + payload.setdefault("azure_agent_apim_gpt_deployment", "") + payload.setdefault("azure_agent_apim_gpt_api_version", "") + + # Remove user-specific residue if present + payload.pop("user_id", None) + + if payload.get("max_completion_tokens") is None: + payload["max_completion_tokens"] = -1 + + # Store sensitive values in Key Vault before persistence + payload = keyvault_agent_save_helper(payload, payload["id"], scope="group") + + try: + stored = cosmos_group_agents_container.upsert_item(body=payload) + return _clean_agent(stored) + except Exception as exc: + current_app.logger.error( + "Error saving group agent %s for %s: %s", agent_id, group_id, exc + ) + raise + + +def delete_group_agent(group_id: str, agent_id: str) -> bool: + """Remove a group agent entry if it exists.""" + try: + agent = cosmos_group_agents_container.read_item( + item=agent_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + return False + + try: + keyvault_agent_delete_helper(agent, agent.get("id", agent_id), scope="group") + cosmos_group_agents_container.delete_item( + item=agent_id, + partition_key=group_id, + ) + return True + except Exception as exc: + current_app.logger.error( + "Error deleting group agent %s for %s: %s", agent_id, group_id, exc + ) + raise + + +def validate_group_agent_payload(payload: Dict[str, Any], partial: bool = False) -> None: + """Validate incoming payload data for group agents.""" + if not isinstance(payload, dict): + raise ValueError("Agent payload must be an object") + + required_fields = ( + "name", + "display_name", + "description", + "instructions", + "actions_to_load", + "other_settings", + "max_completion_tokens", + ) + + if not partial: + missing = [field for field in required_fields if field not in payload] + if missing: + raise ValueError(f"Missing required agent fields: {', '.join(missing)}") + + if "name" in payload: + name = payload["name"] + if not isinstance(name, str) or not name or not _NAME_PATTERN.fullmatch(name): + raise ValueError("Agent name must be alphanumeric with optional underscores or hyphens") + + if "display_name" in payload and not isinstance(payload["display_name"], str): + raise ValueError("display_name must be a string") + + if "description" in payload and not isinstance(payload["description"], str): + raise ValueError("description must be a string") + + if "instructions" in payload and not isinstance(payload["instructions"], str): + raise ValueError("instructions must be a string") + + if "actions_to_load" in payload: + actions = payload["actions_to_load"] + if not isinstance(actions, list) or not all(isinstance(a, str) for a in actions): + raise ValueError("actions_to_load must be a list of strings") + + if "other_settings" in payload and not isinstance(payload["other_settings"], dict): + raise ValueError("other_settings must be an object") + + if "max_completion_tokens" in payload: + tokens = payload["max_completion_tokens"] + if not isinstance(tokens, int): + raise ValueError("max_completion_tokens must be an integer") + + +def _clean_agent(agent: Dict[str, Any]) -> Dict[str, Any]: + cleaned = {k: v for k, v in agent.items() if not k.startswith("_")} + cleaned = keyvault_agent_get_helper( + cleaned, + cleaned.get("id", ""), + scope="group", + ) + if cleaned.get("max_completion_tokens") is None: + cleaned["max_completion_tokens"] = -1 + cleaned.setdefault("is_global", False) + cleaned.setdefault("is_group", True) + return cleaned diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 0017ae40b..0b72e096c 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -46,6 +46,8 @@ def get_personal_agents(user_id): cleaned_agent = keyvault_agent_get_helper(cleaned_agent, cleaned_agent.get('id', ''), scope="user") if cleaned_agent.get('max_completion_tokens') is None: cleaned_agent['max_completion_tokens'] = -1 + cleaned_agent.setdefault('is_global', False) + cleaned_agent.setdefault('is_group', False) cleaned_agents.append(cleaned_agent) return cleaned_agents @@ -78,6 +80,8 @@ def get_personal_agent(user_id, agent_id): # Ensure max_completion_tokens field exists if cleaned_agent.get('max_completion_tokens') is None: cleaned_agent['max_completion_tokens'] = -1 + cleaned_agent.setdefault('is_global', False) + cleaned_agent.setdefault('is_group', False) return cleaned_agent except exceptions.CosmosResourceNotFoundError: current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}") @@ -119,7 +123,8 @@ def save_personal_agent(user_id, agent_data): agent_data.setdefault('enable_agent_gpt_apim', False) agent_data.setdefault('actions_to_load', []) agent_data.setdefault('other_settings', {}) - agent_data.setdefault('is_global', False) + agent_data['is_global'] = False + agent_data['is_group'] = False # Store sensitive keys in Key Vault if enabled agent_data = keyvault_agent_save_helper(agent_data, agent_data.get('id', ''), scope="user") @@ -128,6 +133,8 @@ def save_personal_agent(user_id, agent_data): result = cosmos_personal_agents_container.upsert_item(body=agent_data) # Remove Cosmos metadata from response cleaned_result = {k: v for k, v in result.items() if not k.startswith('_')} + cleaned_result.setdefault('is_global', False) + cleaned_result.setdefault('is_group', False) return cleaned_result except Exception as e: diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 9cc270c4f..ab9883bbd 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -554,8 +554,13 @@ def update_user_settings(user_id, settings_to_update): first_user_agent = doc['settings']['agents'][0] if first_user_agent: doc['settings']['selected_agent'] = { + 'id': first_user_agent.get('id'), 'name': first_user_agent['name'], + 'display_name': first_user_agent.get('display_name', first_user_agent['name']), 'is_global': False, + 'is_group': False, + 'group_id': None, + 'group_name': None, } else: settings = get_settings() @@ -567,24 +572,44 @@ def update_user_settings(user_id, settings_to_update): if global_agents: first_global_agent = global_agents[0] doc['settings']['selected_agent'] = { + 'id': first_global_agent.get('id'), 'name': first_global_agent['name'], + 'display_name': first_global_agent.get('display_name', first_global_agent['name']), 'is_global': True, + 'is_group': False, + 'group_id': None, + 'group_name': None, } else: doc['settings']['selected_agent'] = { + 'id': None, 'name': 'default_agent', + 'display_name': 'default_agent', 'is_global': True, + 'is_group': False, + 'group_id': None, + 'group_name': None, } except Exception: # Fallback if container access fails doc['settings']['selected_agent'] = { + 'id': None, 'name': 'default_agent', + 'display_name': 'default_agent', 'is_global': True, + 'is_group': False, + 'group_id': None, + 'group_name': None, } else: doc['settings']['selected_agent'] = { + 'id': None, 'name': 'researcher', + 'display_name': 'researcher', 'is_global': False, + 'is_group': False, + 'group_id': None, + 'group_name': None, } if doc['settings']['agents'] is not None and len(doc['settings']['agents']) > 0: diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index c5229bb1b..b3948873f 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -4,11 +4,20 @@ import uuid import logging import builtins -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, current_app from semantic_kernel_loader import get_agent_orchestration_types from functions_settings import get_settings, update_settings, get_user_settings, update_user_settings from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent from functions_personal_agents import get_personal_agents, ensure_migration_complete, save_personal_agent, delete_personal_agent +from functions_group import require_active_group, assert_group_role +from functions_group_agents import ( + get_group_agents, + get_group_agent, + save_group_agent, + delete_group_agent, + validate_group_agent_payload, +) +from functions_keyvault import SecretReturnType from functions_authentication import * from functions_appinsights import log_event from json_schema_validation import validate_agent @@ -43,6 +52,7 @@ def get_user_agents(): # Always mark user agents as is_global: False for agent in agents: agent['is_global'] = False + agent['is_group'] = False # Check global/merge toggles settings = get_settings() @@ -54,6 +64,7 @@ def get_user_agents(): # Mark global agents for agent in global_agents: agent['is_global'] = True + agent['is_group'] = False # Merge agents using ID as key to avoid name conflicts # This allows both personal and global agents with same name to coexist @@ -99,6 +110,7 @@ def set_user_agents(): if agent.get('is_global', False): continue # Skip global agents agent['is_global'] = False # Ensure user agents are not global + agent['is_group'] = False # --- Require at least one deployment field --- #if not (agent.get('azure_openai_gpt_deployment') or agent.get('azure_agent_apim_gpt_deployment')): # return jsonify({'error': f'Agent "{agent.get("name", "(unnamed)")}" must have either azure_openai_gpt_deployment or azure_agent_apim_gpt_deployment set.'}), 400 @@ -169,6 +181,171 @@ def delete_user_agent(agent_name): log_event("User agent deleted", extra={"user_id": user_id, "agent_name": agent_name}) return jsonify({'success': True}) + +# === GROUP AGENT ENDPOINTS === + +@bpa.route('/api/group/agents', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_agents_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + agents = get_group_agents(active_group) + return jsonify({'agents': agents}), 200 + + +@bpa.route('/api/group/agents/', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_agent_route(agent_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + agent = get_group_agent(active_group, agent_id) + if not agent: + return jsonify({'error': 'Agent not found'}), 404 + return jsonify(agent), 200 + + +@bpa.route('/api/group/agents', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def create_group_agent_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + try: + validate_group_agent_payload(payload, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + for key in ('group_id', 'last_updated', 'is_global', 'is_group'): + payload.pop(key, None) + + try: + saved = save_group_agent(active_group, payload) + except Exception as exc: + current_app.logger.error('Failed to save group agent: %s', exc) + return jsonify({'error': 'Unable to save agent'}), 500 + + return jsonify(saved), 201 + + +@bpa.route('/api/group/agents/', methods=['PATCH']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def update_group_agent_route(agent_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + existing = get_group_agent(active_group, agent_id) + if not existing: + return jsonify({'error': 'Agent not found'}), 404 + + updates = request.get_json(silent=True) or {} + for key in ('id', 'group_id', 'last_updated', 'is_global', 'is_group'): + updates.pop(key, None) + + try: + validate_group_agent_payload(updates, partial=True) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + merged = dict(existing) + merged.update(updates) + merged['id'] = agent_id + + try: + validate_group_agent_payload(merged, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + try: + saved = save_group_agent(active_group, merged) + except Exception as exc: + current_app.logger.error('Failed to update group agent %s: %s', agent_id, exc) + return jsonify({'error': 'Unable to update agent'}), 500 + + return jsonify(saved), 200 + + +@bpa.route('/api/group/agents/', methods=['DELETE']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def delete_group_agent_route(agent_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + try: + removed = delete_group_agent(active_group, agent_id) + except Exception as exc: + current_app.logger.error('Failed to delete group agent %s: %s', agent_id, exc) + return jsonify({'error': 'Unable to delete agent'}), 500 + + if not removed: + return jsonify({'error': 'Agent not found'}), 404 + return jsonify({'message': 'Agent deleted'}), 200 + # User endpoint to set selected agent (new model, not legacy default_agent) @bpa.route('/api/user/settings/selected_agent', methods=['POST']) @swagger_route( @@ -183,9 +360,17 @@ def set_user_selected_agent(): return jsonify({'error': 'selected_agent is required.'}), 400 user_settings = get_user_settings(user_id) settings_to_update = user_settings.get('settings', {}) + agent_name = (selected_agent.get('name') or '').strip() + if not agent_name: + return jsonify({'error': 'selected_agent.name is required.'}), 400 agent = { - "name": selected_agent.get('name'), - "is_global": selected_agent.get('is_global', False) + "id": selected_agent.get('id'), + "name": agent_name, + "display_name": selected_agent.get('display_name'), + "is_global": selected_agent.get('is_global', False), + "is_group": selected_agent.get('is_group', False), + "group_id": selected_agent.get('group_id'), + "group_name": selected_agent.get('group_name') } settings_to_update['selected_agent'] = agent update_user_settings(user_id, settings_to_update) @@ -233,7 +418,7 @@ def set_selected_agent(): # Set global_selected_agent field only settings = get_settings() - settings['global_selected_agent'] = { 'name': agent_name, 'is_global': True } + settings['global_selected_agent'] = { 'name': agent_name, 'is_global': True, 'is_group': False } update_settings(settings) log_event("Global selected agent set", extra={"action": "set-global-selected", "agent_name": agent_name, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- @@ -261,6 +446,7 @@ def list_agents(): agent['actions_to_load'] = [] # Mark as global agents agent['is_global'] = True + agent['is_group'] = False log_event("List agents", extra={"action": "list", "user": str(get_current_user_id())}) return jsonify(agents) @@ -279,6 +465,7 @@ def add_agent(): agents = get_global_agents() new_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) new_agent['is_global'] = True + new_agent['is_group'] = False validation_error = validate_agent(new_agent) if validation_error: log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent": new_agent, "error": validation_error}) @@ -388,6 +575,7 @@ def edit_agent(agent_name): agents = get_global_agents() updated_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) updated_agent['is_global'] = True + updated_agent['is_group'] = False validation_error = validate_agent(updated_agent) if validation_error: log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent": updated_agent, "error": validation_error}) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index fb1d14a5a..198305369 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -364,7 +364,11 @@ def chat_api(): user_metadata['agent_selection'] = { 'selected_agent': selected_agent_info.get('name'), 'agent_display_name': selected_agent_info.get('display_name'), - 'is_global': selected_agent_info.get('is_global', False) + 'is_global': selected_agent_info.get('is_global', False), + 'is_group': selected_agent_info.get('is_group', False), + 'group_id': selected_agent_info.get('group_id'), + 'group_name': selected_agent_info.get('group_name'), + 'agent_id': selected_agent_info.get('id') } except Exception as e: print(f"Error retrieving agent details: {e}") @@ -385,7 +389,11 @@ def chat_api(): user_metadata['agent_selection'] = { 'selected_agent': agent_info.get('name'), 'agent_display_name': agent_info.get('display_name'), - 'is_global': agent_info.get('is_global', False) + 'is_global': agent_info.get('is_global', False), + 'is_group': agent_info.get('is_group', False), + 'group_id': agent_info.get('group_id'), + 'group_name': agent_info.get('group_name'), + 'agent_id': agent_info.get('id') } # Model selection information diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 5edcba1b6..d79745cc3 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -18,6 +18,15 @@ from functions_global_actions import * from functions_personal_actions import * +from functions_group import require_active_group, assert_group_role +from functions_group_actions import ( + get_group_actions, + get_group_action, + save_group_action, + delete_group_action, + validate_group_action_payload, +) +from functions_keyvault import SecretReturnType #from functions_personal_actions import delete_personal_action from functions_debug import debug_print @@ -353,6 +362,171 @@ def delete_user_plugin(plugin_name): log_event("User plugin deleted", extra={"user_id": user_id, "plugin_name": plugin_name}) return jsonify({'success': True}) + +# === GROUP ACTION ENDPOINTS === + +@bpap.route('/api/group/plugins', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_actions_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + actions = get_group_actions(active_group, return_type=SecretReturnType.TRIGGER) + return jsonify({'actions': actions}), 200 + + +@bpap.route('/api/group/plugins/', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def get_group_action_route(action_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role( + user_id, + active_group, + allowed_roles=("Owner", "Admin", "DocumentManager", "User"), + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + action = get_group_action(active_group, action_id, return_type=SecretReturnType.TRIGGER) + if not action: + return jsonify({'error': 'Action not found'}), 404 + return jsonify(action), 200 + + +@bpap.route('/api/group/plugins', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def create_group_action_route(): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + try: + validate_group_action_payload(payload, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + for key in ('group_id', 'last_updated', 'user_id'): + payload.pop(key, None) + + try: + saved = save_group_action(active_group, payload) + except Exception as exc: + current_app.logger.error('Failed to save group action: %s', exc) + return jsonify({'error': 'Unable to save action'}), 500 + + return jsonify(saved), 201 + + +@bpap.route('/api/group/plugins/', methods=['PATCH']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def update_group_action_route(action_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + existing = get_group_action(active_group, action_id, return_type=SecretReturnType.NAME) + if not existing: + return jsonify({'error': 'Action not found'}), 404 + + updates = request.get_json(silent=True) or {} + for key in ('id', 'group_id', 'last_updated', 'user_id'): + updates.pop(key, None) + + try: + validate_group_action_payload(updates, partial=True) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + merged = dict(existing) + merged.update(updates) + merged['id'] = existing.get('id', action_id) + + try: + validate_group_action_payload(merged, partial=False) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + + try: + saved = save_group_action(active_group, merged) + except Exception as exc: + current_app.logger.error('Failed to update group action %s: %s', action_id, exc) + return jsonify({'error': 'Unable to update action'}), 500 + + return jsonify(saved), 200 + + +@bpap.route('/api/group/plugins/', methods=['DELETE']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +@enabled_required('enable_group_workspaces') +def delete_group_action_route(action_id): + user_id = get_current_user_id() + try: + active_group = require_active_group(user_id) + assert_group_role(user_id, active_group) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except LookupError as exc: + return jsonify({'error': str(exc)}), 404 + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + + try: + removed = delete_group_action(active_group, action_id) + except Exception as exc: + current_app.logger.error('Failed to delete group action %s: %s', action_id, exc) + return jsonify({'error': 'Unable to delete action'}), 500 + + if not removed: + return jsonify({'error': 'Action not found'}), 404 + return jsonify({'message': 'Action deleted'}), 200 + @bpap.route('/api/user/plugins/types', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 80bc2fd92..2135d7356 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -31,6 +31,9 @@ from functions_keyvault import validate_secret_name_dynamic, retrieve_secret_from_key_vault, retrieve_secret_from_key_vault_by_full_name, SecretReturnType from functions_global_actions import get_global_actions from functions_global_agents import get_global_agents +from functions_group_agents import get_group_agent, get_group_agents +from functions_group_actions import get_group_actions +from functions_group import require_active_group from functions_personal_actions import get_personal_actions, ensure_migration_complete as ensure_actions_migration_complete from functions_personal_agents import get_personal_agents, ensure_migration_complete as ensure_agents_migration_complete from semantic_kernel_plugins.plugin_loader import discover_plugins @@ -100,6 +103,7 @@ def resolve_agent_config(agent, settings): debug_print(f"[SK Loader] resolve_agent_config called for agent: {agent.get('name')}") debug_print(f"[SK Loader] Agent config: {agent}") debug_print(f"[SK Loader] Agent is_global flag: {agent.get('is_global')}") + debug_print(f"[SK Loader] Agent is_group flag: {agent.get('is_group')}") gpt_model_obj = settings.get('gpt_model', {}) selected_model = gpt_model_obj.get('selected', [{}])[0] if gpt_model_obj.get('selected') else {} @@ -112,9 +116,18 @@ def resolve_agent_config(agent, settings): per_user_enabled = settings.get('per_user_semantic_kernel', False) allow_user_custom_agent_endpoints = settings.get('allow_user_custom_agent_endpoints', False) allow_group_custom_agent_endpoints = settings.get('allow_group_custom_agent_endpoints', False) + is_group_agent = agent.get("is_group", False) + is_global_agent = agent.get("is_global", False) + + if is_group_agent: + allow_custom_agent_endpoints = allow_group_custom_agent_endpoints + elif is_global_agent: + allow_custom_agent_endpoints = False + else: + allow_custom_agent_endpoints = allow_user_custom_agent_endpoints debug_print(f"[SK Loader] user_apim_enabled: {user_apim_enabled}, global_apim_enabled: {global_apim_enabled}, per_user_enabled: {per_user_enabled}") - debug_print(f"[SK Loader] allow_user_custom_agent_endpoints: {allow_user_custom_agent_endpoints}, allow_group_custom_agent_endpoints: {allow_group_custom_agent_endpoints}") + debug_print(f"[SK Loader] allow_user_custom_agent_endpoints: {allow_user_custom_agent_endpoints}, allow_group_custom_agent_endpoints: {allow_group_custom_agent_endpoints}, allow_custom_agent_endpoints_resolved: {allow_custom_agent_endpoints}") debug_print(f"[SK Loader] Max completion tokens from agent: {agent.get('max_completion_tokens')}") def resolve_secret_value_if_needed(value, scope_value, source, scope): @@ -238,6 +251,9 @@ def merge_fields(primary, fallback): "id": agent.get("id", ""), "default_agent": agent.get("default_agent", False), "is_global": agent.get("is_global", False), + "is_group": agent.get("is_group", False), + "group_id": agent.get("group_id"), + "group_name": agent.get("group_name"), "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), "max_completion_tokens": agent.get("max_completion_tokens", -1) } @@ -249,22 +265,24 @@ def merge_fields(primary, fallback): g_apim = get_global_apim() u_gpt = get_user_gpt() g_gpt = get_global_gpt() + can_use_agent_endpoints = allow_custom_agent_endpoints + user_apim_allowed = user_apim_enabled and can_use_agent_endpoints # 1. User APIM enabled and any user APIM values set: use user APIM (merge with global APIM if needed) - if user_apim_enabled and any_filled(*u_apim) and allow_user_custom_agent_endpoints: + if user_apim_allowed and any_filled(*u_apim): debug_print(f"[SK Loader] Using user APIM with global fallback") merged = merge_fields(u_apim, g_apim if global_apim_enabled and any_filled(*g_apim) else (None, None, None, None)) endpoint, key, deployment, api_version = merged # 2. User APIM enabled but no user APIM values, and global APIM enabled and present: use global APIM - elif user_apim_enabled and global_apim_enabled and any_filled(*g_apim) and allow_group_custom_agent_endpoints: + elif user_apim_enabled and global_apim_enabled and any_filled(*g_apim): debug_print(f"[SK Loader] Using global APIM (user APIM enabled but not present)") endpoint, key, deployment, api_version = g_apim # 3. User GPT config is FULLY filled: use user GPT (all fields filled) - elif all_filled(*u_gpt) and allow_user_custom_agent_endpoints: + elif all_filled(*u_gpt) and can_use_agent_endpoints: debug_print(f"[SK Loader] Using agent GPT config (all fields filled)") endpoint, key, deployment, api_version = u_gpt # 4. User GPT config is PARTIALLY filled, global APIM is NOT enabled: merge user GPT with global GPT - elif any_filled(*u_gpt) and not global_apim_enabled and allow_user_custom_agent_endpoints: + elif any_filled(*u_gpt) and not global_apim_enabled and can_use_agent_endpoints: debug_print(f"[SK Loader] Using agent GPT config (partially filled, merging with global GPT, global APIM not enabled)") endpoint, key, deployment, api_version = merge_fields(u_gpt, g_gpt) # 5. Global APIM enabled and present: use global APIM @@ -290,6 +308,9 @@ def merge_fields(primary, fallback): "id": agent.get("id", ""), "default_agent": agent.get("default_agent", False), # [Deprecated, use 'selected_agent' or 'global_selected_agent' in agent config] "is_global": agent.get("is_global", False), # Ensure we have this field + "is_group": agent.get("is_group", False), + "group_id": agent.get("group_id"), + "group_name": agent.get("group_name"), "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), # Use this to check if APIM is enabled for the agent "max_completion_tokens": agent.get("max_completion_tokens", -1) # -1 meant use model default determined by the service, 35-trubo is 4096, 4o is 16384, 4.1 is at least 32768 } @@ -450,7 +471,7 @@ def initialize_semantic_kernel(user_id: str=None, redis_client=None): ) debug_print(f"[SK Loader] Semantic Kernel Agent and Plugins loading completed.") -def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="global", user_id=None): +def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="global", user_id=None, group_id=None): """ Load specific plugins by name for an agent with enhanced logging. @@ -459,6 +480,7 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob plugin_names: List of plugin names to load (from agent's actions_to_load) mode_label: 'per-user' or 'global' for logging user_id: User ID for per-user mode + group_id: Active group identifier when loading group-scoped plugins """ if not plugin_names: debug_print(f"[SK Loader] No plugin names provided to load_agent_specific_plugins") @@ -471,7 +493,18 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob # Create logged plugin loader for enhanced logging logged_loader = create_logged_plugin_loader(kernel) - if mode_label == "per-user": + if mode_label == "group": + if not group_id: + debug_print(f"[SK Loader] Warning: Group mode requested without group_id. Skipping plugin load.") + all_plugin_manifests = [] + else: + all_plugin_manifests = get_group_actions(group_id, return_type=SecretReturnType.NAME) + debug_print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} group plugin manifests for group {group_id}") + if merge_global: + global_plugins = get_global_actions(return_type=SecretReturnType.NAME) + all_plugin_manifests.extend(global_plugins) + debug_print(f"[SK Loader] Merged global plugins for group mode. Total manifests: {len(all_plugin_manifests)}") + elif mode_label == "per-user": if user_id: all_plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) if merge_global: @@ -484,7 +517,7 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob all_plugin_manifests = [] else: # Global mode - get from global actions container - all_plugin_manifests = get_global_plugins(return_type=SecretReturnType.NAME) + all_plugin_manifests = get_global_actions(return_type=SecretReturnType.NAME) print(f"[SK Loader] Retrieved {len(all_plugin_manifests)} global plugin manifests") # Filter manifests to only include requested plugins @@ -559,7 +592,15 @@ def load_agent_specific_plugins(kernel, plugin_names, settings, mode_label="glob # Fallback to original method try: # Get plugin manifests again for fallback - if mode_label == "per-user": + if mode_label == "group": + if group_id: + all_plugin_manifests = get_group_actions(group_id, return_type=SecretReturnType.NAME) + if merge_global: + global_plugins = get_global_actions(return_type=SecretReturnType.NAME) + all_plugin_manifests.extend(global_plugins) + else: + all_plugin_manifests = [] + elif mode_label == "per-user": if user_id: all_plugin_manifests = get_personal_actions(user_id, return_type=SecretReturnType.NAME) if merge_global: @@ -757,12 +798,27 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis # Load agent-specific plugins into the kernel before creating the agent if agent_config.get("actions_to_load"): print(f"[SK Loader] Loading agent-specific plugins: {agent_config['actions_to_load']}") - # Determine plugin source based on agent's global status, not overall mode + # Determine plugin source based on agent scope agent_is_global = agent_config.get("is_global", False) - plugin_mode = "global" if agent_is_global else mode_label - user_id = get_current_user_id() if not agent_is_global else None - print(f"[SK Loader] Agent is_global: {agent_is_global}, using plugin_mode: {plugin_mode}") - load_agent_specific_plugins(kernel, agent_config["actions_to_load"], settings, plugin_mode, user_id=user_id) + agent_is_group = agent_config.get("is_group", False) + if agent_is_global: + plugin_mode = "global" + elif agent_is_group: + plugin_mode = "group" + else: + plugin_mode = mode_label + + resolved_user_id = None if agent_is_global else get_current_user_id() + group_id = agent_config.get("group_id") if agent_is_group else None + print(f"[SK Loader] Agent scope - is_global: {agent_is_global}, is_group: {agent_is_group}, plugin_mode: {plugin_mode}, group_id: {group_id}") + load_agent_specific_plugins( + kernel, + agent_config["actions_to_load"], + settings, + plugin_mode, + user_id=resolved_user_id, + group_id=group_id, + ) try: kwargs = { @@ -1066,13 +1122,67 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie # Ensure migration is complete (will migrate any remaining legacy data) ensure_agents_migration_complete(user_id) agents_cfg = get_personal_agents(user_id) - + print(f"[SK Loader] User settings found {len(agents_cfg)} agents for user '{user_id}'") - + # Always mark user agents as is_global: False for agent in agents_cfg: agent['is_global'] = False + # Append selected group agent (if any) to the candidate list so downstream selection logic can resolve it + selected_agent_data = selected_agent if isinstance(selected_agent, dict) else {} + selected_agent_is_group = selected_agent_data.get('is_group', False) + if selected_agent_is_group: + resolved_group_id = selected_agent_data.get('group_id') + try: + active_group_id = require_active_group(user_id) + if not resolved_group_id: + resolved_group_id = active_group_id + elif resolved_group_id != active_group_id: + debug_print( + f"[SK Loader] Selected group agent references group {resolved_group_id}, active group is {active_group_id}." + ) + except ValueError as err: + debug_print(f"[SK Loader] No active group available while loading group agent: {err}") + if not resolved_group_id: + log_event( + "[SK Loader] Group agent selected but no active group in settings.", + level=logging.WARNING + ) + + if resolved_group_id: + agent_identifier = selected_agent_data.get('id') or selected_agent_data.get('name') + group_agent_cfg = None + if agent_identifier: + group_agent_cfg = get_group_agent(resolved_group_id, agent_identifier) + if not group_agent_cfg: + # Fallback: search by name across group agents if ID lookup failed + for candidate in get_group_agents(resolved_group_id): + if candidate.get('name') == selected_agent_data.get('name'): + group_agent_cfg = candidate + break + + if group_agent_cfg: + group_agent_cfg['is_global'] = False + group_agent_cfg['is_group'] = True + group_agent_cfg.setdefault('group_id', resolved_group_id) + group_agent_cfg['group_name'] = selected_agent_data.get('group_name') + agents_cfg.append(group_agent_cfg) + log_event( + f"[SK Loader] Added group agent '{group_agent_cfg.get('name')}' from group {resolved_group_id} to candidate list.", + level=logging.INFO + ) + else: + log_event( + f"[SK Loader] Selected group agent '{selected_agent_data.get('name')}' not found for group {resolved_group_id}.", + level=logging.WARNING + ) + else: + log_event( + "[SK Loader] Unable to resolve group ID for selected group agent; skipping group agent load.", + level=logging.WARNING + ) + # PATCH: Merge global agents if enabled merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) print(f"[SK Loader] merge_global_semantic_kernel_with_workspace: {merge_global}") @@ -1092,9 +1202,11 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie key = f"global_{agent['name']}" all_agents[key] = agent - # Add personal agents with 'personal_' prefix + # Add personal and group agents with scoped prefixes for agent in agents_cfg: - key = f"personal_{agent['name']}" + prefix = "group" if agent.get('is_group') else "personal" + scoped_name = agent.get('name') or agent.get('id') or 'unnamed' + key = f"{prefix}_{scoped_name}" all_agents[key] = agent agents_cfg = list(all_agents.values()) diff --git a/application/single_app/static/js/agents_common.js b/application/single_app/static/js/agents_common.js index 0157d5f5e..f906ace8a 100644 --- a/application/single_app/static/js/agents_common.js +++ b/application/single_app/static/js/agents_common.js @@ -473,6 +473,31 @@ export async function fetchUserAgents() { return await res.json(); } +export async function fetchGroupAgentsForActiveGroup() { + if (typeof window === 'undefined' || !window.activeGroupId) { + return []; + } + try { + const res = await fetch('/api/group/agents'); + if (!res.ok) { + console.warn('Group agents request failed:', res.status, res.statusText); + return []; + } + const payload = await res.json().catch(() => ({ agents: [] })); + const agents = Array.isArray(payload.agents) ? payload.agents : []; + const activeGroupName = (typeof window !== 'undefined' && window.activeGroupName) ? window.activeGroupName : ''; + return agents.map(agent => ({ + ...agent, + is_group: true, + group_id: agent.group_id || window.activeGroupId, + group_name: agent.group_name || activeGroupName || null + })); + } catch (error) { + console.error('Failed to fetch group agents:', error); + return []; + } +} + /** * Fetch selected agent from user settings * @returns {Promise} Selected agent object or null @@ -505,23 +530,44 @@ export function populateAgentSelect(selectEl, agents, selectedAgentObj) { console.log('DEBUG: populateAgentSelect called with agents:', agents); console.log('DEBUG: Number of agents:', agents.length); agents.forEach((agent, index) => { - console.log(`DEBUG: Agent ${index}: name="${agent.name}", is_global=${agent.is_global}, display_name="${agent.display_name}"`); + console.log(`DEBUG: Agent ${index}: name="${agent.name}", is_global=${agent.is_global}, is_group=${agent.is_group}, display_name="${agent.display_name}"`); }); let selectedAgentName = typeof selectedAgentObj === 'object' ? selectedAgentObj.name : selectedAgentObj; + const selectedAgentId = typeof selectedAgentObj === 'object' ? (selectedAgentObj.id || selectedAgentObj.agent_id) : null; + const selectedAgentIsGlobal = typeof selectedAgentObj === 'object' ? !!selectedAgentObj.is_global : false; + const selectedAgentIsGroup = typeof selectedAgentObj === 'object' ? !!selectedAgentObj.is_group : false; + const selectedAgentGroupId = typeof selectedAgentObj === 'object' ? (selectedAgentObj.group_id || selectedAgentObj.groupId || null) : null; console.log('DEBUG: Selected agent name:', selectedAgentName); agents.forEach(agent => { let opt = document.createElement('option'); - // Use unique value that combines name and global status to distinguish between personal and global agents with same name - opt.value = agent.is_global ? `global_${agent.name}` : `personal_${agent.name}`; - opt.textContent = (agent.display_name || agent.name) + (agent.is_global ? ' (Global)' : ''); - // For selection matching, check if this agent matches the selected agent (by name and global status) + const agentId = agent.id || agent.agent_id || agent.name; + const contextPrefix = agent.is_group ? 'group' : (agent.is_global ? 'global' : 'personal'); + opt.value = `${contextPrefix}_${agentId}`; + const groupName = agent.group_name || agent.groupName || ''; + const labelSuffix = agent.is_group ? ` (Group${groupName ? `: ${groupName}` : ''})` : (agent.is_global ? ' (Global)' : ''); + const displayLabel = agent.display_name || agent.displayName || agent.name || ''; + opt.textContent = `${displayLabel}${labelSuffix}`; + opt.dataset.name = agent.name || ''; + opt.dataset.displayName = displayLabel; + opt.dataset.agentId = agentId || ''; + opt.dataset.isGlobal = agent.is_global ? 'true' : 'false'; + opt.dataset.isGroup = agent.is_group ? 'true' : 'false'; + opt.dataset.groupId = agent.group_id || agent.groupId || ''; + opt.dataset.groupName = groupName || ''; + // For selection matching, prefer ID if available, otherwise fallback to name/context if (selectedAgentObj && typeof selectedAgentObj === 'object') { - if (agent.name === selectedAgentObj.name && agent.is_global === selectedAgentObj.is_global) { + const candidateIds = [agentId, agent.id, agent.agent_id].filter(Boolean).map(String); + const selectedIds = [selectedAgentId].filter(Boolean).map(String); + const idMatches = selectedIds.length > 0 && selectedIds.some(selId => candidateIds.includes(selId)); + const nameMatches = agent.name === selectedAgentObj.name; + const contextMatches = (!!agent.is_global === selectedAgentIsGlobal) && (!!agent.is_group === selectedAgentIsGroup); + const groupMatches = !selectedAgentIsGroup || selectedAgentGroupId === null || String(agent.group_id || agent.groupId || '') === String(selectedAgentGroupId || ''); + if ((idMatches || nameMatches) && contextMatches && groupMatches) { opt.selected = true; } - } else if (agent.name === selectedAgentName && !agent.is_global) { + } else if (agent.name === selectedAgentName && !agent.is_global && !agent.is_group) { // Default to personal agent if just name is provided opt.selected = true; } diff --git a/application/single_app/static/js/chat/chat-agents.js b/application/single_app/static/js/chat/chat-agents.js index ace05642d..015c3fbc1 100644 --- a/application/single_app/static/js/chat/chat-agents.js +++ b/application/single_app/static/js/chat/chat-agents.js @@ -1,5 +1,13 @@ // chat-agents.js -import { fetchUserAgents, fetchSelectedAgent, populateAgentSelect, setSelectedAgent, getUserSetting, setUserSetting } from '../agents_common.js'; +import { + fetchUserAgents, + fetchGroupAgentsForActiveGroup, + fetchSelectedAgent, + populateAgentSelect, + setSelectedAgent, + getUserSetting, + setUserSetting +} from '../agents_common.js'; const enableAgentsBtn = document.getElementById("enable-agents-btn"); const agentSelectContainer = document.getElementById("agent-select-container"); @@ -43,40 +51,38 @@ export async function initializeAgentInteractions() { export async function populateAgentDropdown() { const agentSelect = agentSelectContainer.querySelector('select'); try { - const agents = await fetchUserAgents(); - const selectedAgent = await fetchSelectedAgent(); - populateAgentSelect(agentSelect, agents, selectedAgent); - agentSelect.onchange = async function() { - const selectedValue = agentSelect.value; - console.log('DEBUG: Agent dropdown changed to:', selectedValue); - console.log('DEBUG: Available agents:', agents); - - // Parse the selected value to extract name and global status - let selectedAgentObj = null; - if (selectedValue.startsWith('global_')) { - const agentName = selectedValue.substring(7); // Remove 'global_' prefix - selectedAgentObj = agents.find(a => a.name === agentName && a.is_global === true); - } else if (selectedValue.startsWith('personal_')) { - const agentName = selectedValue.substring(9); // Remove 'personal_' prefix - selectedAgentObj = agents.find(a => a.name === agentName && a.is_global === false); - } else { - // Fallback for agents without prefix (backwards compatibility) - selectedAgentObj = agents.find(a => a.name === selectedValue); + const [userAgents, selectedAgent] = await Promise.all([ + fetchUserAgents(), + fetchSelectedAgent() + ]); + const groupAgents = await fetchGroupAgentsForActiveGroup(); + const combinedAgents = [...userAgents, ...groupAgents]; + const personalAgents = combinedAgents.filter(agent => !agent.is_global && !agent.is_group); + const activeGroupAgents = combinedAgents.filter(agent => agent.is_group); + const globalAgents = combinedAgents.filter(agent => agent.is_global); + const orderedAgents = [...personalAgents, ...activeGroupAgents, ...globalAgents]; + populateAgentSelect(agentSelect, orderedAgents, selectedAgent); + agentSelect.onchange = async function () { + const selectedOption = agentSelect.options[agentSelect.selectedIndex]; + if (!selectedOption) { + return; } - - console.log('DEBUG: Found agent object:', selectedAgentObj); - - if (selectedAgentObj) { - const payload = { name: selectedAgentObj.name, is_global: !!selectedAgentObj.is_global }; - console.log('DEBUG: Setting selected agent payload:', payload); - console.log('DEBUG: Agent is_global flag:', selectedAgentObj.is_global); - console.log('DEBUG: !!selectedAgentObj.is_global:', !!selectedAgentObj.is_global); - - await setSelectedAgent(payload); - console.log('DEBUG: Agent selection saved successfully'); - } else { - console.log('DEBUG: No agent found with value:', selectedValue); + const payload = { + name: selectedOption.dataset.name || '', + display_name: selectedOption.dataset.displayName || selectedOption.textContent || '', + id: selectedOption.dataset.agentId || null, + is_global: selectedOption.dataset.isGlobal === 'true', + is_group: selectedOption.dataset.isGroup === 'true', + group_id: selectedOption.dataset.groupId || null, + group_name: selectedOption.dataset.groupName || (window.activeGroupName || null) + }; + console.log('DEBUG: Agent dropdown changed with payload:', payload); + if (!payload.name) { + console.warn('Selected agent is missing a name, skipping settings update.'); + return; } + await setSelectedAgent(payload); + console.log('DEBUG: Agent selection saved successfully'); }; } catch (e) { console.error('Error loading agents:', e); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index b5419eeed..61b52e2dc 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -993,11 +993,15 @@ export function actuallySendMessage(finalMessageToSend) { const agentSelect = document.getElementById("agent-select"); if (agentSelectContainer && agentSelectContainer.style.display !== "none" && agentSelect) { const selectedAgentOption = agentSelect.options[agentSelect.selectedIndex]; - if (selectedAgentOption && selectedAgentOption.value) { + if (selectedAgentOption) { agentInfo = { - name: selectedAgentOption.value, - display_name: selectedAgentOption.textContent, - is_global: selectedAgentOption.textContent.includes("(Global)") + id: selectedAgentOption.dataset.agentId || null, + name: selectedAgentOption.dataset.name || selectedAgentOption.value || '', + display_name: selectedAgentOption.dataset.displayName || selectedAgentOption.textContent, + is_global: selectedAgentOption.dataset.isGlobal === 'true', + is_group: selectedAgentOption.dataset.isGroup === 'true', + group_id: selectedAgentOption.dataset.groupId || null, + group_name: selectedAgentOption.dataset.groupName || null }; } } diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js new file mode 100644 index 000000000..be7fc4db3 --- /dev/null +++ b/application/single_app/static/js/workspace/group_agents.js @@ -0,0 +1,388 @@ +// group_agents.js +// Handles group agent management within the group workspace UI + +import { showToast } from "../chat/chat-toast.js"; +import * as agentsCommon from "../agents_common.js"; +import { AgentModalStepper } from "../agent_modal_stepper.js"; + +const tableBody = document.getElementById("group-agents-table-body"); +const errorContainer = document.getElementById("group-agents-error"); +const searchInput = document.getElementById("group-agents-search"); +const createButton = document.getElementById("create-group-agent-btn"); +const permissionWarning = document.getElementById("group-agents-permission-warning"); + +let agents = []; +let filteredAgents = []; +let agentStepper = null; +let currentContext = window.groupWorkspaceContext || { + activeGroupId: null, + activeGroupName: "", + userRole: null +}; + +function escapeHtml(value) { + if (!value) return ""; + return value.replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[char] || char)); +} + +function canManageAgents() { + const role = currentContext?.userRole; + return role === "Owner" || role === "Admin"; +} + +function truncateName(name, maxLength = 18) { + if (!name || name.length <= maxLength) return name || ""; + return `${name.substring(0, maxLength)}โ€ฆ`; +} + +function updatePermissionUI() { + const canManage = canManageAgents(); + if (createButton) { + createButton.classList.toggle("d-none", !canManage); + createButton.disabled = !canManage; + } + if (permissionWarning) { + permissionWarning.classList.toggle("d-none", canManage); + } +} + +function renderLoading() { + if (!tableBody) return; + tableBody.innerHTML = ` + + +
+ Loadingโ€ฆ +
+ Loading group agentsโ€ฆ + + `; + if (errorContainer) { + errorContainer.innerHTML = ""; + } +} + +function renderNoGroupSelected() { + if (!tableBody) return; + tableBody.innerHTML = ` + + + Select a group to load agents. + + `; +} + +function renderError(message) { + if (errorContainer) { + errorContainer.innerHTML = `
${escapeHtml(message)}
`; + } + if (tableBody) { + tableBody.innerHTML = ""; + } +} + +function renderAgentsTable(list) { + if (!tableBody) return; + + if (!list.length) { + tableBody.innerHTML = ` + + + No group agents found. + + `; + return; + } + + const canManage = canManageAgents(); + tableBody.innerHTML = ""; + + list.forEach((agent) => { + const tr = document.createElement("tr"); + const displayName = truncateName(agent.display_name || agent.displayName || agent.name || ""); + const description = escapeHtml(agent.description || "No description available."); + + let actionsHtml = "โ€”"; + if (canManage) { + actionsHtml = ` + + `; + } + + tr.innerHTML = ` + ${escapeHtml(displayName)} + ${description} + ${actionsHtml}`; + + tableBody.appendChild(tr); + }); +} + +function filterAgents(term) { + if (!term) { + filteredAgents = agents.slice(); + } else { + const needle = term.toLowerCase(); + filteredAgents = agents.filter((agent) => { + const name = (agent.display_name || agent.displayName || agent.name || "").toLowerCase(); + const description = (agent.description || "").toLowerCase(); + return name.includes(needle) || description.includes(needle); + }); + } + renderAgentsTable(filteredAgents); +} + +function overrideAgentStepper(stepper) { + stepper.loadAvailableActions = async function loadGroupActions() { + const container = document.getElementById("agent-actions-container"); + const emptyMessage = document.getElementById("agent-no-actions-message"); + if (!container) return; + + try { + container.innerHTML = ` +
+
+ Loadingโ€ฆ +
+

Loading available group actionsโ€ฆ

+
`; + + const response = await fetch("/api/group/plugins"); + const payload = await response.json().catch(() => ({ actions: [] })); + if (!response.ok) { + throw new Error(payload?.error || response.statusText || "Failed to load actions"); + } + + const actions = Array.isArray(payload.actions) ? payload.actions : []; + const normalized = actions.map((action) => ({ + ...action, + display_name: action.display_name || action.displayName || action.name || "", + description: action.description || "" + })); + + normalized.sort((a, b) => { + const nameA = (a.display_name || a.name || "").toLowerCase(); + const nameB = (b.display_name || b.name || "").toLowerCase(); + return nameA.localeCompare(nameB); + }); + + container.innerHTML = ""; + + if (!normalized.length) { + container.style.display = "none"; + if (emptyMessage) emptyMessage.classList.remove("d-none"); + return; + } + + container.style.display = ""; + if (emptyMessage) emptyMessage.classList.add("d-none"); + + normalized.forEach((action) => { + const card = this.createActionCard(action); + container.appendChild(card); + }); + + this.initializeActionSearch(normalized); + + if (this.actionsToSelect && Array.isArray(this.actionsToSelect)) { + this.setSelectedActions(this.actionsToSelect); + this.actionsToSelect = null; + } + } catch (error) { + console.error("Error loading group actions:", error); + container.innerHTML = ` +
+
Unable to load group actions. ${escapeHtml(error.message || "")}
+
`; + } + }; + + stepper.savePersonalAgent = async function saveGroupAgent(agentData) { + const payload = { ...agentData }; + const isEdit = this.isEditMode && this.originalAgent && (this.originalAgent.id || this.originalAgent.name); + + if (!isEdit || !payload.id) { + payload.id = payload.id || crypto.randomUUID(); + } + + const agentId = encodeURIComponent(payload.id || ""); + const url = isEdit ? `/api/group/agents/${agentId}` : "/api/group/agents"; + const method = isEdit ? "PATCH" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + let body = null; + try { + body = await response.json(); + } catch (parseError) { + body = null; + } + + if (!response.ok) { + const message = body?.error || `Failed to ${isEdit ? "update" : "create"} group agent`; + throw new Error(message); + } + + this.handleSaveSuccess(); + if (typeof window.fetchGroupAgents === "function") { + await window.fetchGroupAgents(); + } + }; + + return stepper; +} + +function getAgentStepper() { + if (!agentStepper) { + agentStepper = overrideAgentStepper(new AgentModalStepper(false)); + window.agentModalStepper = agentStepper; + } + return agentStepper; +} + +async function openAgentModal(agent = null) { + if (!canManageAgents()) { + showToast("You do not have permission to manage group agents.", "warning"); + return; + } + + try { + const stepper = getAgentStepper(); + await stepper.showModal(agent); + agentsCommon.setupApimToggle( + document.getElementById("agent-enable-apim"), + document.getElementById("agent-apim-fields"), + document.getElementById("agent-gpt-fields"), + () => agentsCommon.loadGlobalModelsForModal({ + endpoint: "/api/user/agent/settings", + agent, + globalModelSelect: document.getElementById("agent-global-model-select"), + isGlobal: false, + customConnectionCheck: agentsCommon.shouldEnableCustomConnection, + deploymentFieldIds: { gpt: "agent-gpt-deployment", apim: "agent-apim-deployment" } + }) + ); + } catch (error) { + console.error("Error opening group agent modal:", error); + showToast(error.message || "Unable to open agent modal.", "danger"); + } +} + +async function deleteGroupAgent(agentId) { + if (!canManageAgents()) { + showToast("You do not have permission to delete group agents.", "warning"); + return; + } + + if (!agentId) return; + if (!confirm("Delete this group agent?")) return; + + try { + const response = await fetch(`/api/group/agents/${encodeURIComponent(agentId)}`, { + method: "DELETE" + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || "Failed to delete group agent"); + } + showToast("Group agent deleted successfully.", "success"); + await fetchGroupAgents(); + } catch (error) { + console.error("Error deleting group agent:", error); + showToast(error.message || "Unable to delete group agent.", "danger"); + } +} + +async function fetchGroupAgents() { + if (!tableBody) return; + + if (!currentContext?.activeGroupId) { + renderNoGroupSelected(); + return; + } + + renderLoading(); + + try { + const response = await fetch("/api/group/agents"); + const payload = await response.json().catch(() => ({ agents: [] })); + if (!response.ok) { + throw new Error(payload?.error || response.statusText || "Failed to load group agents"); + } + + agents = Array.isArray(payload.agents) ? payload.agents : []; + const searchTerm = searchInput?.value?.trim() || ""; + filterAgents(searchTerm); + } catch (error) { + console.error("Error loading group agents:", error); + renderError(error.message || "Unable to load group agents."); + } +} + +function handleTableClick(event) { + const editBtn = event.target.closest(".edit-group-agent-btn"); + if (editBtn) { + const agentId = editBtn.dataset.agentId; + const agent = agents.find((item) => item.id === agentId || item.name === agentId); + openAgentModal(agent || null); + return; + } + + const deleteBtn = event.target.closest(".delete-group-agent-btn"); + if (deleteBtn) { + const agentId = deleteBtn.dataset.agentId; + deleteGroupAgent(agentId); + } +} + +function bindEventHandlers() { + if (searchInput) { + searchInput.addEventListener("input", (event) => { + filterAgents(event.target.value.trim()); + }); + } + + if (createButton) { + createButton.addEventListener("click", () => openAgentModal()); + } + + if (tableBody) { + tableBody.addEventListener("click", handleTableClick); + } + + window.addEventListener("groupWorkspace:context-changed", (event) => { + currentContext = event.detail || currentContext; + updatePermissionUI(); + }); +} + +function initialize() { + updatePermissionUI(); + bindEventHandlers(); + + if (document.getElementById("group-agents-tab-btn")?.classList.contains("active")) { + fetchGroupAgents(); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize); +} else { + initialize(); +} + +window.fetchGroupAgents = fetchGroupAgents; diff --git a/application/single_app/static/js/workspace/group_plugins.js b/application/single_app/static/js/workspace/group_plugins.js new file mode 100644 index 000000000..d169564c1 --- /dev/null +++ b/application/single_app/static/js/workspace/group_plugins.js @@ -0,0 +1,381 @@ +// group_plugins.js +// Handles group action management within the group workspace UI + +import { ensurePluginsTableInRoot, validatePluginManifest } from "../plugin_common.js"; +import { showToast } from "../chat/chat-toast.js"; + +const root = document.getElementById("group-plugins-root"); +const permissionWarning = document.getElementById("group-plugins-permission-warning"); + +let plugins = []; +let filteredPlugins = []; +let templateReady = false; +let listenersBound = false; +let currentContext = window.groupWorkspaceContext || { + activeGroupId: null, + activeGroupName: "", + userRole: null +}; + +function escapeHtml(value) { + if (!value) return ""; + return value.replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }[char] || char)); +} + +function canManagePlugins() { + const role = currentContext?.userRole; + return role === "Owner" || role === "Admin"; +} + +function ensureTemplate() { + if (!root) return null; + if (!templateReady) { + ensurePluginsTableInRoot({ + rootSelector: "#group-plugins-root", + templateId: "group-plugins-table-template" + }); + templateReady = true; + updatePermissionUI(); + bindRootEvents(); + } + return document.getElementById("group-plugins-table-body"); +} + +function bindRootEvents() { + if (!root || listenersBound) return; + + root.addEventListener("input", (event) => { + if (event.target && event.target.id === "group-plugins-search") { + filterPlugins(event.target.value.trim()); + } + }); + + root.addEventListener("click", async (event) => { + const createBtn = event.target.closest("#create-group-plugin-btn"); + if (createBtn) { + event.preventDefault(); + openPluginModal(); + return; + } + + const editBtn = event.target.closest(".edit-group-plugin-btn"); + if (editBtn) { + const pluginId = editBtn.dataset.pluginId; + openPluginModal(pluginId); + return; + } + + const deleteBtn = event.target.closest(".delete-group-plugin-btn"); + if (deleteBtn) { + const pluginId = deleteBtn.dataset.pluginId; + deleteGroupPlugin(pluginId); + } + }); + + listenersBound = true; +} + +function renderLoading() { + if (!root) return; + root.innerHTML = ` +
+
+ Loadingโ€ฆ +
+
`; + templateReady = false; +} + +function renderNoGroupSelected() { + if (!root) return; + root.innerHTML = ` +
+ Select a group to load actions. +
`; + templateReady = false; +} + +function renderError(message) { + if (!root) return; + root.innerHTML = ` +
+ ${escapeHtml(message)} +
`; + templateReady = false; +} + +function updatePermissionUI() { + if (!root) return; + const canManage = canManagePlugins(); + const createBtn = document.getElementById("create-group-plugin-btn"); + if (createBtn) { + createBtn.classList.toggle("d-none", !canManage); + createBtn.disabled = !canManage; + } + if (permissionWarning) { + permissionWarning.classList.toggle("d-none", canManage); + } +} + +function renderPluginsTable(list) { + const tbody = ensureTemplate(); + if (!tbody) return; + + tbody.innerHTML = ""; + if (!list.length) { + tbody.innerHTML = ` + + No group actions found. + `; + return; + } + + const canManage = canManagePlugins(); + list.forEach((plugin) => { + const tr = document.createElement("tr"); + const displayName = plugin.displayName || plugin.display_name || plugin.name || ""; + const description = plugin.description || "No description available."; + + let actionsHtml = "โ€”"; + if (canManage) { + actionsHtml = ` +
+ + +
`; + } + + tr.innerHTML = ` + ${escapeHtml(displayName)} + ${escapeHtml(description)} + ${actionsHtml}`; + + tbody.appendChild(tr); + }); +} + +function filterPlugins(term) { + if (!term) { + filteredPlugins = plugins.slice(); + } else { + const needle = term.toLowerCase(); + filteredPlugins = plugins.filter((plugin) => { + const name = (plugin.displayName || plugin.display_name || plugin.name || "").toLowerCase(); + const description = (plugin.description || "").toLowerCase(); + return name.includes(needle) || description.includes(needle); + }); + } + renderPluginsTable(filteredPlugins); +} + +async function fetchGroupPlugins() { + if (!root) return; + + if (!currentContext?.activeGroupId) { + renderNoGroupSelected(); + return; + } + + renderLoading(); + + try { + const response = await fetch("/api/group/plugins"); + const payload = await response.json().catch(() => ({ actions: [] })); + if (!response.ok) { + throw new Error(payload?.error || response.statusText || "Failed to load group actions"); + } + + plugins = (payload.actions || []).map((action) => ({ + ...action, + displayName: action.displayName || action.display_name || action.name || "", + description: action.description || "" + })); + filteredPlugins = plugins.slice(); + + renderPluginsTable(filteredPlugins); + updatePermissionUI(); + } catch (error) { + console.error("Error loading group actions:", error); + renderError(error.message || "Unable to load group actions."); + } +} + +async function openPluginModal(pluginId = null) { + if (!canManagePlugins()) { + showToast("You do not have permission to manage group actions.", "warning"); + return; + } + + if (!window.pluginModalStepper) { + showToast("Action modal is not available. Please refresh and try again.", "danger"); + return; + } + + let plugin = null; + if (pluginId) { + try { + const response = await fetch(`/api/group/plugins/${encodeURIComponent(pluginId)}`); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || "Failed to load group action"); + } + plugin = payload; + } catch (error) { + console.error("Error loading group action:", error); + showToast(error.message || "Unable to load action details.", "danger"); + return; + } + } + + try { + const modal = await window.pluginModalStepper.showModal(plugin); + setupSaveHandler(plugin, modal); + } catch (error) { + console.error("Error opening action modal:", error); + showToast(error.message || "Unable to open action modal.", "danger"); + } +} + +function setupSaveHandler(existingPlugin, modalInstance) { + const saveBtn = document.getElementById("save-plugin-btn"); + if (!saveBtn) return; + + saveBtn.onclick = null; + saveBtn.onclick = async (event) => { + event.preventDefault(); + + const errorDiv = document.getElementById("plugin-modal-error"); + if (errorDiv) { + errorDiv.classList.add("d-none"); + errorDiv.textContent = ""; + } + + try { + const formData = window.pluginModalStepper.getFormData(); + if (existingPlugin?.id) { + formData.id = existingPlugin.id; + } + + const validation = await validatePluginManifest(formData); + const validationFailed = validation === false || (validation && validation.valid === false); + if (validationFailed) { + const message = validation?.errors?.join("\n") || "Validation error: Invalid action data."; + if (window.pluginModalStepper?.showError) { + window.pluginModalStepper.showError(message); + } + return; + } + + const originalText = saveBtn.innerHTML; + saveBtn.innerHTML = `Savingโ€ฆ`; + saveBtn.disabled = true; + try { + await saveGroupPlugin(formData, existingPlugin); + } finally { + saveBtn.innerHTML = originalText; + saveBtn.disabled = false; + } + + if (modalInstance && typeof modalInstance.hide === "function") { + modalInstance.hide(); + } else { + bootstrap.Modal.getInstance(document.getElementById("plugin-modal"))?.hide(); + } + + showToast(existingPlugin ? "Group action updated successfully." : "Group action created successfully.", "success"); + await fetchGroupPlugins(); + } catch (error) { + console.error("Error saving group action:", error); + const message = error.message || "Unable to save group action."; + if (window.pluginModalStepper?.showError) { + window.pluginModalStepper.showError(message); + } else { + showToast(message, "danger"); + } + } + }; +} + +async function saveGroupPlugin(pluginManifest, existingPlugin) { + const payload = { + ...pluginManifest, + displayName: pluginManifest.displayName || pluginManifest.display_name || pluginManifest.name || "" + }; + + const hasId = Boolean(existingPlugin?.id || payload.id); + if (!payload.id && existingPlugin?.id) { + payload.id = existingPlugin.id; + } + + const url = hasId ? `/api/group/plugins/${encodeURIComponent(payload.id)}` : "/api/group/plugins"; + const method = hasId ? "PATCH" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error || `Failed to ${hasId ? "update" : "create"} group action`); + } + return body; +} + +async function deleteGroupPlugin(pluginId) { + if (!canManagePlugins()) { + showToast("You do not have permission to delete group actions.", "warning"); + return; + } + if (!pluginId) return; + if (!confirm("Delete this group action?")) return; + + try { + const response = await fetch(`/api/group/plugins/${encodeURIComponent(pluginId)}`, { + method: "DELETE" + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || "Failed to delete group action"); + } + showToast("Group action deleted successfully.", "success"); + await fetchGroupPlugins(); + } catch (error) { + console.error("Error deleting group action:", error); + showToast(error.message || "Unable to delete group action.", "danger"); + } +} + +function initialize() { + if (!root) return; + ensureTemplate(); + updatePermissionUI(); + + window.addEventListener("groupWorkspace:context-changed", (event) => { + currentContext = event.detail || currentContext; + updatePermissionUI(); + }); + + if (document.getElementById("group-plugins-tab-btn")?.classList.contains("active")) { + fetchGroupPlugins(); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize); +} else { + initialize(); +} + +window.fetchGroupPlugins = fetchGroupPlugins; diff --git a/application/single_app/static/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json index 786a8c99c..4de6fbc5f 100644 --- a/application/single_app/static/json/schemas/agent.schema.json +++ b/application/single_app/static/json/schemas/agent.schema.json @@ -66,6 +66,11 @@ "description": "True if this agent is a global agent; required for agent selection and UI badging.", "default": false }, + "is_group": { + "type": "boolean", + "description": "True if this agent is a group agent; required for agent selection and UI badging.", + "default": false + }, "instructions": { "type": "string" }, @@ -89,6 +94,7 @@ "display_name", "description", "is_global", + "is_group", "instructions", "actions_to_load", "other_settings", diff --git a/application/single_app/static/json/schemas/azure_billing_plugin.definition.json b/application/single_app/static/json/schemas/azure_billing_plugin.definition.json new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/static/json/schemas/plugin.definition.schema.json b/application/single_app/static/json/schemas/plugin.definition.schema.json new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index df81b4b06..cef4eb85e 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -137,6 +137,18 @@ Group Prompts + {% if settings.allow_group_agents and settings.enable_semantic_kernel %} + + + {% endif %} {% else %} diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 828c9734b..5d6e618a6 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -465,6 +465,7 @@

Group Workspace

Group Prompts + {% if settings.enable_semantic_kernel and settings.allow_group_agents %} + + {% endif %} + {% if settings.enable_semantic_kernel and settings.allow_group_plugins %} + + {% endif %} @@ -560,6 +592,106 @@
Group Prompts
+ + {% if settings.enable_semantic_kernel and settings.allow_group_agents %} + +
+
+
+
Group Agents
+ +
+
+ You do not have permission to manage group agents. +
+
+ +
+ + + + + + + + + + + + + +
Display NameDescriptionActions
+
+ Loading... +
+ Select a group to load agents. +
+
+
+
+ + {% endif %} + + {% if settings.enable_semantic_kernel and settings.allow_group_plugins %} + +
+
+ You do not have permission to manage group actions. +
+
+ +
+ + {% endif %} @@ -729,6 +861,13 @@