+ โ ๏ธ 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 @@
+
+
+
+
+
+
+
+ Azure Key Vault Configuration Guide
+
+
+
+
+
+
+ What is Azure Key Vault? Azure Key Vault is a cloud service that provides secure storage of secrets, keys, and certificates, helping you safeguard cryptographic keys and secrets used by cloud applications and services.
+
+ Identity to Access:
+ {% if settings.key_vault_identity == "" %}System-Assigned{% else %}{{ settings.key_vault_identity }}{% endif %}
+
+
+
+
+
+ โน๏ธ Note: Using Key Vault may introduce latency to chat experience when retrieving secrets.
+
+
+
+
+
+
Azure AD App Registration
+
+
+
You need to update your Azure AD App Registration with the Key Vault URLs:
+
+
+
+
+ OAuth2
+
+
+
+
+ Auth Callback
+
+
+
+
+
+
+
+
+ Logout
+
+
+
+
+
+
+ Important: Make sure to add these URLs to your Azure AD App Registration in the Azure Portal under "Authentication" โ "Redirect URIs" and "Front-channel logout URL".
+
+
+
+
+
+
+
+
Azure Key Vault Configuration
+
+
+
+
1. Set Permissions for the Identity
+
In your Key Vault configuration, ensure the identity has the necessary permissions to access the Key Vault:
+ Important: Go to your Key Vault โ Access policies โ Add the identity with the required permissions
+
+
+
+
+ Important: Go to your Key Vault โ Access Control (IAM) โ Add (dropdown) โ Select "Add role assignment" โ Add the identity with the required role.
+
+
+
+
+
+
2. Configure Key Vault in the Application
+
In your application settings, ensure the following configurations are set:
+
+
Enable Key Vault for Agent and Action Secrets: Set to true
+
Key Vault Name: Provide the name of your Key Vault (e.g., your-key-vault-name) The vault is assumed to be in the same cloud as the application and currently does not support cross-cloud configurations. This is determined by your AZURE_ENVIRONMENT app service configuration setting.
+
Key Vault Identity: Specify the identity used to access the Key Vault (System-Assigned or User-Assigned)
+
+
+
+
+
+
+
+
+
Troubleshooting
+
+
+
+
+
+
+
+
+
+
+
Verify access method for key vault: RBAC or Legacy Access Policies
+
Verify the identity in the Key Vault Id (app service system-assigned if blank) has permissions
+
+
+
+
+
+
+
+
+
+
+
+
403 typically means there is a networking issue accessing key vault
+
Verify if there are any firewall or virtual network restrictions on the Key Vault
+
If using private endpoints, ensure the app service has access to the private endpoint, that it is VNet integrated, and that the DNS resolves correctly (use the terminal in SCM)
+
Check that the correct network rules are applied to the Key Vault
+
Verify the UDRs (User Defined Routes) are correctly configured between the app service and the Key Vault
{% include '_health_check_info.html' %}
+
+ {% include '_key_vault_info.html' %}
+
From 10e95c3f8251504469822634b42118138b0b8ca4 Mon Sep 17 00:00:00 2001
From: Bionic711
Date: Mon, 3 Nov 2025 14:29:52 -0600
Subject: [PATCH 50/68] add logging and functions to math
---
.../semantic_kernel_plugins/math_plugin.py | 69 ++++++++++++++++++-
1 file changed, 67 insertions(+), 2 deletions(-)
diff --git a/application/single_app/semantic_kernel_plugins/math_plugin.py b/application/single_app/semantic_kernel_plugins/math_plugin.py
index f32301ed3..159f6d78a 100644
--- a/application/single_app/semantic_kernel_plugins/math_plugin.py
+++ b/application/single_app/semantic_kernel_plugins/math_plugin.py
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated
-
+from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger
from semantic_kernel.functions.kernel_function_decorator import kernel_function
@@ -37,6 +37,7 @@ def add(
return x + y
@kernel_function(name="Subtract")
+ @plugin_function_logger("MathPlugin")
def subtract(
self,
input: Annotated[int | float | str, "The number to subtract from"],
@@ -45,4 +46,68 @@ def subtract(
"""Returns the difference of numbers provided (supports float and int)."""
x = self._parse_number(input)
y = self._parse_number(amount)
- return x - y
\ No newline at end of file
+ return x - y
+
+ @kernel_function(name="Multiply")
+ @plugin_function_logger("MathPlugin")
+ def multiply(
+ self,
+ input: Annotated[int | float | str, "The first number to multiply"],
+ amount: Annotated[int | float | str, "The second number to multiply"],
+ ) -> Annotated[float, "The result"]:
+ """Returns the multiplication result of the values provided (supports float and int)."""
+ x = self._parse_number(input)
+ y = self._parse_number(amount)
+ return x * y
+
+ @kernel_function(name="Divide")
+ @plugin_function_logger("MathPlugin")
+ def divide(
+ self,
+ input: Annotated[int | float | str, "The numerator"],
+ amount: Annotated[int | float | str, "The denominator"],
+ ) -> Annotated[float, "The result"]:
+ """Returns the division result of the values provided (supports float and int)."""
+ x = self._parse_number(input)
+ y = self._parse_number(amount)
+ if y == 0:
+ raise ValueError("Cannot divide by zero")
+ return x / y
+
+ @kernel_function(name="Power")
+ @plugin_function_logger("MathPlugin")
+ def power(
+ self,
+ input: Annotated[int | float | str, "The base number"],
+ exponent: Annotated[int | float | str, "The exponent"],
+ ) -> Annotated[float, "The result"]:
+ """Returns the power result of the values provided (supports float and int)."""
+ x = self._parse_number(input)
+ y = self._parse_number(exponent)
+ return x**y
+
+ @kernel_function(name="SquareRoot")
+ @plugin_function_logger("MathPlugin")
+ def square_root(
+ self,
+ input: Annotated[int | float | str, "The number to calculate the square root of"],
+ ) -> Annotated[float, "The result"]:
+ """Returns the square root of the value provided (supports float and int)."""
+ x = self._parse_number(input)
+ if x < 0:
+ raise ValueError("Cannot calculate square root of a negative number")
+ return x**0.5
+
+ @kernel_function(name="Modulus")
+ @plugin_function_logger("MathPlugin")
+ def modulus(
+ self,
+ input: Annotated[int | float | str, "The dividend"],
+ amount: Annotated[int | float | str, "The divisor"],
+ ) -> Annotated[float, "The result"]:
+ """Returns the modulus of the values provided (supports float and int)."""
+ x = self._parse_number(input)
+ y = self._parse_number(amount)
+ if y == 0:
+ raise ValueError("Cannot divide by zero for modulus operation")
+ return x % y
\ No newline at end of file
From 0bee627535bd22047b2bc2f8a418d712f063f844 Mon Sep 17 00:00:00 2001
From: Bionic711
Date: Tue, 4 Nov 2025 12:09:04 -0600
Subject: [PATCH 51/68] rmv extra telemetry, add appcache
---
application/single_app/app.py | 20 +++---
application/single_app/app_settings_cache.py | 74 ++++++++++++++++++--
2 files changed, 77 insertions(+), 17 deletions(-)
diff --git a/application/single_app/app.py b/application/single_app/app.py
index a721c8f8c..17a01f768 100644
--- a/application/single_app/app.py
+++ b/application/single_app/app.py
@@ -5,14 +5,12 @@
import json
import os
-from app_settings_cache import APP_SETTINGS_CACHE, update_settings_cache, get_settings_cache
-
+import app_settings_cache
+from config import *
from semantic_kernel import Kernel
from semantic_kernel_loader import initialize_semantic_kernel
-from azure.monitor.opentelemetry import configure_azure_monitor
-
-from config import *
+#from azure.monitor.opentelemetry import configure_azure_monitor
from functions_authentication import *
from functions_content import *
@@ -107,9 +105,6 @@
from route_external_health import *
-#TODO: Remove this after speaking with Paul
-configure_azure_monitor()
-
# =================== Session Configuration ===================
def configure_sessions(settings):
"""Configure session backend (Redis or filesystem) once.
@@ -165,8 +160,10 @@ def configure_sessions(settings):
def before_first_request():
print("Initializing application...")
settings = get_settings()
- update_settings_cache(settings)
+ app_settings_cache.configure_app_cache(settings, get_redis_cache_infrastructure_endpoint(settings.get('redis_url', '').strip().split('.')[0]))
+ app_settings_cache.update_settings_cache(settings)
print(f"DEBUG:Application settings: {settings}")
+ print(f"DEBUG:App settings cache initialized: {'Using Redis cache:' + str(app_settings_cache.app_cache_is_using_redis)} {app_settings_cache.get_settings_cache()}")
initialize_clients(settings)
ensure_custom_logo_file_exists(app, settings)
# Enable Application Insights logging globally if configured
@@ -249,7 +246,6 @@ def check_logging_timers():
# Unified session setup
configure_sessions(settings)
-
@app.context_processor
def inject_settings():
settings = get_settings()
@@ -461,13 +457,15 @@ def list_semantic_kernel_plugins():
if __name__ == '__main__':
settings = get_settings()
+ app_settings_cache.configure_app_cache(settings, get_redis_cache_infrastructure_endpoint(settings.get('redis_url', '').strip().split('.')[0]))
+ app_settings_cache.update_settings_cache(settings)
initialize_clients(settings)
debug_mode = os.environ.get("FLASK_DEBUG", "0") == "1"
if debug_mode:
# Local development with HTTPS
- app.run(host="0.0.0.0", port=5000, debug=True, ssl_context='adhoc')
+ app.run(host="0.0.0.0", port=5000, debug=True, ssl_context='adhoc', threaded=True)
else:
# Production
port = int(os.environ.get("PORT", 5000))
diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py
index d020d2f02..89ec7cbe3 100644
--- a/application/single_app/app_settings_cache.py
+++ b/application/single_app/app_settings_cache.py
@@ -1,9 +1,71 @@
-# settings_cache.py
+# app_settings_cache.py
+"""
+WARNING: NEVER 'from app_settings_cache import' settings or any other module that imports settings.
+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
+
+_settings = None
APP_SETTINGS_CACHE = {}
+update_settings_cache = None
+get_settings_cache = None
+app_cache_is_using_redis = False
+
+def configure_app_cache(settings, redis_cache_endpoint=None):
+ global _settings, update_settings_cache, get_settings_cache, APP_SETTINGS_CACHE, app_cache_is_using_redis
+ _settings = settings
+ use_redis = _settings.get('enable_redis_cache', False)
+
+ if use_redis:
+ app_cache_is_using_redis = True
+ redis_url = settings.get('redis_url', '').strip()
+ redis_auth_type = settings.get('redis_auth_type', 'key').strip().lower()
+ if redis_auth_type == 'managed_identity':
+ print("[ASC] Redis enabled using Managed Identity")
+ credential = DefaultAzureCredential()
+ redis_hostname = redis_url.split('.')[0]
+ cache_endpoint = redis_cache_endpoint
+ token = credential.get_token(cache_endpoint)
+ redis_client = Redis(
+ host=redis_url,
+ port=6380,
+ db=0,
+ password=token.token,
+ ssl=True
+ )
+ else:
+ redis_key = settings.get('redis_key', '').strip()
+ print("[ASC] Redis enabled using Access Key")
+ redis_client = Redis(
+ host=redis_url,
+ port=6380,
+ db=0,
+ password=redis_key,
+ ssl=True
+ )
+
+ def update_settings_cache_redis(new_settings):
+ redis_client.set('APP_SETTINGS_CACHE', json.dumps(new_settings))
+
+ def get_settings_cache_redis():
+ cached = redis_client.get('APP_SETTINGS_CACHE')
+ return json.loads(cached) if cached else {}
+
+ update_settings_cache = update_settings_cache_redis
+ get_settings_cache = get_settings_cache_redis
+
+ else:
+ def update_settings_cache_mem(new_settings):
+ global APP_SETTINGS_CACHE
+ APP_SETTINGS_CACHE = new_settings
-def update_settings_cache(new_settings):
- global APP_SETTINGS_CACHE
- APP_SETTINGS_CACHE = new_settings
+ def get_settings_cache_mem():
+ return APP_SETTINGS_CACHE
-def get_settings_cache():
- return APP_SETTINGS_CACHE
\ No newline at end of file
+ update_settings_cache = update_settings_cache_mem
+ get_settings_cache = get_settings_cache_mem
\ No newline at end of file
From ee8067209efd9afdcb6d4a43f7e785146acc6c84 Mon Sep 17 00:00:00 2001
From: Bionic711
Date: Tue, 4 Nov 2025 12:09:19 -0600
Subject: [PATCH 52/68] upd billing plugin
---
.../azure_billing_plugin.py | 945 ++++++++++++++----
1 file changed, 745 insertions(+), 200 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 649e66415..fdd017adf 100644
--- a/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py
+++ b/application/single_app/semantic_kernel_plugins/azure_billing_plugin.py
@@ -16,10 +16,13 @@
import matplotlib.pyplot as plt
import logging
import time
+import random
import re
-from datetime import datetime, timedelta
+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
@@ -27,14 +30,19 @@
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"]
-GROUPING_CATEGORY = ["None", "BillingPeriod", "ChargeType", "Frequency", "MeterCategory", "MeterId", "MeterSubCategory", "Product", "ResourceGroupName", "ResourceLocation", "ResourceType", "ServiceFamily", "ServiceName", "SubscriptionId", "SubscriptionName", "Tag"]
+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]):
@@ -174,24 +182,10 @@ def _flatten_dict(self, d: Dict[str, Any], parent_key: str = '', sep: str = '.')
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')
- 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
-
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,"}
+ Returns: {"mime": "image/png", "filename": filename, "base64": , "image_url": "data:image/png;base64,"}
"""
buf = io.BytesIO()
fig.savefig(buf, format='png', bbox_inches='tight')
@@ -202,150 +196,381 @@ def _fig_to_base64_dict(self, fig, filename: str = "chart.png") -> Dict[str, str
"mime": "image/png",
"filename": filename,
"base64": img_b64,
- "data_url": f"data:image/png;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 custom chart from provided data. Supports pie, column_stacked, column_grouped, line, and area.")
+ @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,
- data: List[Dict[str, Any]],
- x_key: Optional[str] = None,
+ conversation_id: str,
+ data,
+ x_keys: Optional[List[str]] = None,
y_keys: Optional[List[str]] = None,
- chart_type: str = "line",
+ graph_type: str = "line",
title: str = "",
xlabel: str = "",
ylabel: str = "",
filename: str = "chart.png",
- figsize: tuple = (10, 6)) -> Dict[str, Any]:
+ 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_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
+ - 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
"""
- 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}")
+ 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"}
- # Defensive copy
- rows = [r.copy() for r in (data or [])]
+ 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 chart_type != "pie":
+ 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
+ # Prepare x values and handle x_keys as list
x_vals = None
- if chart_type != "pie":
- if not x_key:
+ 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_key = k
+ x_keys.append(k)
break
- if not x_key:
- raise ValueError("x_key is required for this chart type")
+
+ 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]
- # 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)
+ # 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:
- 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}}
+ 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 "")
- @property
- def display_name(self) -> str:
- return "Azure Billing"
+ 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)
- @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)."}
- ]
- }
@plugin_function_logger("AzureBillingPlugin")
@kernel_function(description="List all subscriptions and resource groups accessible to the user/service principal.")
@@ -400,12 +625,12 @@ def get_forecast(self, resourceId: str, forecast_period_months: int = 12, granul
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.utcnow().date()
+ today = datetime.datetime.utcnow().date()
start_date = today
- end_date = today + timedelta(days=forecast_period_months * 30)
+ 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 - timedelta(days=lookback_months * 30)
+ hist_start = today - datetime.timedelta(days=lookback_months * 30)
hist_end = today
else:
hist_start = None
@@ -463,7 +688,7 @@ def get_alerts(self, subscription_id: str, resource_group_name: Optional[str] =
@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:
+ 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:
@@ -487,34 +712,26 @@ def get_specific_alert(self, subscription_id: str, resource_group_name: Optional
# 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, 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]
- # 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 f''
-
- @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.")
+ @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, 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:
+ 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)
@@ -525,18 +742,23 @@ def run_data_query(self, subscription_id: str, resource_group_name: Optional[str
- 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")
+ - 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 query_type not in QUERY_TYPE:
+ if not self._normalize_enum(query_type, QUERY_TYPE):
raise ValueError(f"Invalid query_type: {query_type}. Must be one of {QUERY_TYPE}.")
- if timeframe not in TIME_FRAME_TYPE:
+ if not self._normalize_enum(timeframe, TIME_FRAME_TYPE):
raise ValueError(f"Invalid timeframe: {timeframe}. Must be one of {TIME_FRAME_TYPE}.")
- if granularity not in GRANULARITY_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,
@@ -545,89 +767,194 @@ def run_data_query(self, subscription_id: str, resource_group_name: Optional[str
"granularity": granularity
}
}
+ # If user did not provide aggregations/groupings or filter, construct sensible defaults
if not aggregations and not groupings and not query_filter:
- return "Either aggregations and groupings or a query_filter must be provided."
+ 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):
- raise ValueError("aggregations must be a list of aggregation definitions")
+ 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):
- raise ValueError("Each aggregation must be a dict")
- # Support shape: {"name":..., "function":..., ...} or {"type":..., "aggregation": {"name":..., "function":..., ...}}
+ 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']
- name = sub.get('name') or agg.get('name')
+ # 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')
- details = {k: v for k, v in sub.items() if k != 'name'}
+ # allow sub to specify other properties but we'll only keep name and function for compatibility
else:
- name = agg.get('name')
+ # flat form
+ column_name = agg.get('column') or agg.get('name_of_column') or agg.get('columnName')
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 alias:
+ return {"status": "error", "error": "Aggregation entry missing aggregation alias in 'name' field", "example": [{"name": "totalCost", "aggregation": {"name": "PreTaxCost", "function": "Sum"}}]}
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
+ 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):
- raise ValueError("groupings must be a list of grouping definitions")
+ 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):
- raise ValueError("Each grouping must be a dict with 'type' and 'name'")
+ 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 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}")
+ 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
- if timeframe == "Custom" and time_period:
+ # 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]
- return self._csv_from_table(result)
+ 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_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
+ 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_CATEGORY": GROUPING_CATEGORY,
+ "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 categories (dimensions) for Azure Billing.")
+ @kernel_function(description="Get available cost dimensions for Azure Billing.")
@plugin_function_logger("AzureBillingPlugin")
- def get_grouping_categories(self, subscription_id: str, resource_group_name: Optional[str] = None) -> List[str]:
+ 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:
@@ -636,13 +963,13 @@ def get_grouping_categories(self, subscription_id: str, resource_group_name: Opt
# 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}"
+ 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 []
- cats = []
+ dims = []
for item in values:
if not isinstance(item, dict):
continue
@@ -653,15 +980,15 @@ def get_grouping_categories(self, subscription_id: str, resource_group_name: Opt
# fallback to name/displayName
cat = item.get('name') or props.get('name') or props.get('displayName')
if cat:
- cats.append(cat)
+ dims.append(cat)
# dedupe while preserving order
seen = set()
deduped = []
- for c in cats:
- if c not in seen:
- seen.add(c)
- deduped.append(c)
+ 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.")
@@ -733,4 +1060,222 @@ def get_aggregatable_columns(self, subscription_id: str, resource_group_name: Op
return agg
-
\ No newline at end of file
+ @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 c7dec1b4eb2b0139202f97fe39dfb5acd8526c2b Mon Sep 17 00:00:00 2001
From: Bionic711
Date: Tue, 4 Nov 2025 12:10:06 -0600
Subject: [PATCH 53/68] add/upd key vault, admin settings, agents, max tokens
---
application/single_app/functions_agents.py | 2 +-
.../single_app/functions_appinsights.py | 11 +-
.../single_app/functions_global_agents.py | 8 +
application/single_app/functions_keyvault.py | 16 +-
.../single_app/functions_personal_agents.py | 7 +
application/single_app/functions_settings.py | 4 +-
.../single_app/route_backend_settings.py | 4 +-
.../single_app/semantic_kernel_loader.py | 149 +++++++++++++++---
.../semantic_kernel_plugins/base_plugin.py | 46 +++++-
.../databricks_table_plugin.py | 3 -
.../static/js/agent_modal_stepper.js | 57 ++++++-
.../single_app/static/js/agents_common.js | 2 +
.../single_app/static/js/validateAgent.mjs | 2 +-
.../single_app/static/js/validatePlugin.mjs | 2 +-
.../static/json/schemas/agent.schema.json | 9 +-
.../single_app/templates/_agent_modal.html | 18 +++
.../single_app/templates/admin_settings.html | 1 +
17 files changed, 286 insertions(+), 55 deletions(-)
diff --git a/application/single_app/functions_agents.py b/application/single_app/functions_agents.py
index 63b7edeb5..9aa589c50 100644
--- a/application/single_app/functions_agents.py
+++ b/application/single_app/functions_agents.py
@@ -3,6 +3,7 @@
import asyncio
from concurrent.futures import ThreadPoolExecutor
from functions_settings import get_settings
+from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
# Global executor for background orchestration
executor = ThreadPoolExecutor(max_workers=4) # Tune as needed
@@ -13,7 +14,6 @@ def _runner():
asyncio.set_event_loop(loop)
runtime = None
try:
- from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
runtime = InProcessRuntime()
result = loop.run_until_complete(
run_sk_call(
diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py
index d38458ebf..090101edc 100644
--- a/application/single_app/functions_appinsights.py
+++ b/application/single_app/functions_appinsights.py
@@ -4,7 +4,7 @@
import os
import threading
from azure.monitor.opentelemetry import configure_azure_monitor
-from app_settings_cache import get_settings_cache
+import app_settings_cache
# Singleton for the logger and Azure Monitor configuration
_appinsights_logger = None
@@ -45,10 +45,11 @@ 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]
+ try:
+ cache = app_settings_cache.get_settings_cache() or None
+ except Exception as e:
+ print(f"[Log] Could not retrieve settings cache: {e}")
+ cache = None
# Get logger - use Azure Monitor logger if configured, otherwise standard logger
logger = get_appinsights_logger()
diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py
index b7d907fbf..addb5fe9c 100644
--- a/application/single_app/functions_global_agents.py
+++ b/application/single_app/functions_global_agents.py
@@ -48,6 +48,7 @@ def ensure_default_global_agent_exists():
),
"actions_to_load": [],
"other_settings": {},
+ "max_completion_tokens": 4096
}
save_global_agent(default_agent)
log_event(
@@ -101,6 +102,9 @@ def get_global_agents():
))
# Mask or replace sensitive keys for UI display
agents = [keyvault_agent_get_helper(agent, agent.get('id', ''), scope="global") for agent in agents]
+ for agent in agents:
+ if agent.get('max_completion_tokens') is None:
+ agent['max_completion_tokens'] = -1
return agents
except Exception as e:
log_event(
@@ -129,6 +133,8 @@ def get_global_agent(agent_id):
partition_key=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
print(f"Found global agent: {agent_id}")
return agent
except Exception as e:
@@ -169,6 +175,8 @@ def save_global_agent(agent_data):
# 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")
+ if agent_data.get('max_completion_tokens') is None:
+ agent_data['max_completion_tokens'] = -1 # Default value
result = cosmos_global_agents_container.upsert_item(body=agent_data)
log_event(
diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py
index a2d9d5d86..6dd235c7f 100644
--- a/application/single_app/functions_keyvault.py
+++ b/application/single_app/functions_keyvault.py
@@ -7,7 +7,7 @@
from functions_authentication import *
from functions_settings import *
from enum import Enum
-from app_settings_cache import get_settings_cache
+import app_settings_cache
try:
from azure.identity import DefaultAzureCredential
@@ -87,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_cache()
+ settings = app_settings_cache.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
@@ -127,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_cache()
+ settings = app_settings_cache.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.")
@@ -218,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_cache()
+ settings = app_settings_cache.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:
@@ -262,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_cache()
+ settings = app_settings_cache.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:
@@ -443,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_cache()
+ settings = app_settings_cache.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:
@@ -497,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_cache()
+ settings = app_settings_cache.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:
@@ -528,7 +528,7 @@ def get_keyvault_credential():
Returns:
DefaultAzureCredential: The credential object for Key Vault access.
"""
- settings = get_settings_cache()
+ settings = app_settings_cache.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_personal_agents.py b/application/single_app/functions_personal_agents.py
index 4e04e6241..0017ae40b 100644
--- a/application/single_app/functions_personal_agents.py
+++ b/application/single_app/functions_personal_agents.py
@@ -44,6 +44,8 @@ def get_personal_agents(user_id):
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")
+ if cleaned_agent.get('max_completion_tokens') is None:
+ cleaned_agent['max_completion_tokens'] = -1
cleaned_agents.append(cleaned_agent)
return cleaned_agents
@@ -73,6 +75,9 @@ def get_personal_agent(user_id, agent_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")
+ # Ensure max_completion_tokens field exists
+ if cleaned_agent.get('max_completion_tokens') is None:
+ cleaned_agent['max_completion_tokens'] = -1
return cleaned_agent
except exceptions.CosmosResourceNotFoundError:
current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}")
@@ -118,6 +123,8 @@ def save_personal_agent(user_id, agent_data):
# Store sensitive keys in Key Vault if enabled
agent_data = keyvault_agent_save_helper(agent_data, agent_data.get('id', ''), scope="user")
+ if agent_data.get('max_completion_tokens') is None:
+ agent_data['max_completion_tokens'] = -1
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('_')}
diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py
index 1f24f3ac1..9cc270c4f 100644
--- a/application/single_app/functions_settings.py
+++ b/application/single_app/functions_settings.py
@@ -2,7 +2,7 @@
from config import *
from functions_appinsights import log_event
-from app_settings_cache import get_settings_cache, update_settings_cache
+import app_settings_cache
def get_settings():
import secrets
@@ -270,7 +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
+ app_settings_cacheupdate_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/route_backend_settings.py b/application/single_app/route_backend_settings.py
index 68e9ccaa5..a31d1da82 100644
--- a/application/single_app/route_backend_settings.py
+++ b/application/single_app/route_backend_settings.py
@@ -329,9 +329,9 @@ def _test_gpt_connection(payload):
# Decide GPT model
if enable_apim:
apim_data = payload.get('apim', {})
- endpoint = apim_data.get('endpoint')
+ endpoint = apim_data.get('endpoint') #.rstrip('/openai')
api_version = apim_data.get('api_version')
- gpt_model = apim_data.get('deployment')
+ gpt_model = apim_data.get('deployment').split(',')[0]
subscription_key = apim_data.get('subscription_key')
gpt_client = AzureOpenAI(
diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py
index d7985b3b2..7047c7aef 100644
--- a/application/single_app/semantic_kernel_loader.py
+++ b/application/single_app/semantic_kernel_loader.py
@@ -15,6 +15,7 @@
from semantic_kernel import Kernel
from semantic_kernel.agents import Agent
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
+from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.core_plugins import TimePlugin, HttpPlugin
from semantic_kernel.core_plugins.wait_plugin import WaitPlugin
from semantic_kernel_plugins.math_plugin import MathPlugin
@@ -38,7 +39,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
+import app_settings_cache
@@ -117,6 +118,8 @@ def resolve_agent_config(agent, settings):
allow_group_custom_agent_endpoints = settings.get('allow_group_custom_agent_endpoints', False)
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] Max completion tokens from agent: {agent.get('max_completion_tokens')}")
def resolve_secret_value_if_needed(value, scope_value, source, scope):
if validate_secret_name_dynamic(value):
@@ -239,7 +242,8 @@ def merge_fields(primary, fallback):
"id": agent.get("id", ""),
"default_agent": agent.get("default_agent", False),
"is_global": agent.get("is_global", False),
- "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False)
+ "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False),
+ "max_completion_tokens": agent.get("max_completion_tokens", -1)
}
except Exception as e:
log_event(f"[SK Loader] Error resolving agent config: {e}", level=logging.ERROR, exceptionTraceback=True)
@@ -290,7 +294,8 @@ 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
- "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False) # Use this to check if APIM is enabled for the agent
+ "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
}
print(f"[SK Loader] Final resolved config for {agent.get('name')}: endpoint={bool(endpoint)}, key={bool(key)}, deployment={deployment}")
@@ -401,8 +406,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_cache()
- print(f"[SK Loader] Settings check - per_user_semantic_kernel: {settings.get('per_user_semantic_kernel', False)}, user_id: {user_id}")
+ settings = app_settings_cache.get_settings_cache()
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)
if settings.get('per_user_semantic_kernel', False) and user_id is not None:
@@ -672,12 +676,11 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis
context_obj.redis_client = redis_client
agent_objs = {}
agent_config = resolve_agent_config(agent_cfg, settings)
- print(f"[SK Loader] Agent config resolved for {agent_cfg.get('name')}: endpoint={bool(agent_config.get('endpoint'))}, key={bool(agent_config.get('key'))}, deployment={agent_config.get('deployment')}")
service_id = f"aoai-chat-{agent_config['name']}"
chat_service = None
apim_enabled = settings.get("enable_gpt_apim", False)
-
- log_event(f"[SK Loader] Agent config resolved - endpoint: {bool(agent_config.get('endpoint'))}, key: {bool(agent_config.get('key'))}, deployment: {agent_config.get('deployment')}", level=logging.INFO)
+
+ log_event(f"[SK Loader] Agent config resolved for {agent_cfg.get('name')} - endpoint: {bool(agent_config.get('endpoint'))}, key: {bool(agent_config.get('key'))}, deployment: {agent_config.get('deployment')}, max_completion_tokens: {agent_config.get('max_completion_tokens')}", level=logging.INFO)
if AzureChatCompletion and agent_config["endpoint"] and agent_config["key"] and agent_config["deployment"]:
print(f"[SK Loader] Azure config valid for {agent_config['name']}, creating chat service...")
@@ -716,8 +719,12 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis
deployment_name=agent_config["deployment"],
endpoint=agent_config["endpoint"],
api_key=agent_config["key"],
- api_version=agent_config["api_version"]
+ api_version=agent_config["api_version"],
+ # default_headers={"Ocp-Apim-Subscription-Key": agent_config["key"]}
)
+ if agent_config.get('max_completion_tokens', -1) > 0:
+ print(f"[SK Loader] Using {agent_config['max_completion_tokens']} max_completion_tokens for {agent_config['name']}")
+ chat_service = set_prompt_settings_for_agent(chat_service, agent_config)
kernel.add_service(chat_service)
log_event(
f"[SK Loader] AOAI chat completion service registered for agent: {agent_config['name']} ({mode_label})",
@@ -751,7 +758,6 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis
return None, None
if LoggingChatCompletionAgent and chat_service:
print(f"[SK Loader] Creating LoggingChatCompletionAgent for {agent_config['name']}...")
-
# 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']}")
@@ -761,7 +767,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis
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)
-
+
try:
kwargs = {
"name": agent_config["name"],
@@ -1107,8 +1113,7 @@ 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
@@ -1344,13 +1349,20 @@ def load_semantic_kernel(kernel: Kernel, settings):
deployment_name=agent_config["deployment"],
endpoint=agent_config["endpoint"],
api_key=agent_config["key"],
- api_version=agent_config["api_version"]
+ api_version=agent_config["api_version"],
+ # default_headers={"Ocp-Apim-Subscription-Key": key}
)
+ if agent_config.get('max_completion_tokens', -1) > 0:
+ print(f"[SK Loader] Using {agent_config['max_completion_tokens']} max_completion_tokens for {agent_config['name']}")
+ chat_service = set_prompt_settings_for_agent(chat_service, agent_config)
kernel.add_service(chat_service)
except Exception as e:
log_event(f"[SK Loader] Failed to create or get AzureChatCompletion for agent: {agent_config['name']}: {e}", {"error": str(e)}, level=logging.ERROR, exceptionTraceback=True)
if LoggingChatCompletionAgent and chat_service:
try:
+ if agent_config.get('max_completion_tokens', -1) > 0:
+ print(f"[SK Loader] Using {agent_config['max_completion_tokens']} max_completion_tokens for {agent_config['name']}")
+ chat_service = set_prompt_settings_for_agent(chat_service, agent_config)
kwargs = {
"name": agent_config["name"],
"instructions": agent_config["instructions"],
@@ -1445,8 +1457,12 @@ def load_semantic_kernel(kernel: Kernel, settings):
deployment_name=orchestrator_config["deployment"],
endpoint=orchestrator_config["endpoint"],
api_key=orchestrator_config["key"],
- api_version=orchestrator_config["api_version"]
+ api_version=orchestrator_config["api_version"],
+ # default_headers={"Ocp-Apim-Subscription-Key": orchestrator_config["key"]}
)
+ if agent_config.get('max_completion_tokens', -1) > 0:
+ print(f"[SK Loader] Using {agent_config['max_completion_tokens']} max_completion_tokens for {agent_config['name']}")
+ chat_service = set_prompt_settings_for_agent(chat_service, agent_config)
kernel.add_service(chat_service)
if not chat_service:
raise RuntimeError(f"[SK Loader] No AzureChatCompletion service available for orchestrator agent '{orchestrator_config['name']}'")
@@ -1559,26 +1575,25 @@ def load_semantic_kernel(kernel: Kernel, settings):
if AzureChatCompletion and endpoint and key and deployment:
apim_enabled = settings.get("enable_gpt_apim", False)
if apim_enabled:
- kernel.add_service(
- AzureChatCompletion(
+ chat_service = AzureChatCompletion(
service_id=f"aoai-chat-global",
deployment_name=deployment,
endpoint=endpoint,
api_key=key,
api_version=api_version,
# default_headers={"Ocp-Apim-Subscription-Key": key}
- )
)
+ kernel.add_service(chat_service)
else:
- kernel.add_service(
- AzureChatCompletion(
+ chat_service = AzureChatCompletion(
service_id=f"aoai-chat-global",
deployment_name=deployment,
endpoint=endpoint,
api_key=key,
- api_version=api_version
+ api_version=api_version,
+ # default_headers={"Ocp-Apim-Subscription-Key": key}
)
- )
+ kernel.add_service(chat_service)
log_event(
f"[SK Loader] Azure OpenAI chat completion service registered (kernel-only mode)",
{
@@ -1602,4 +1617,92 @@ def load_semantic_kernel(kernel: Kernel, settings):
def load_multi_agent_for_kernel(kernel: Kernel, settings):
- return None, None
\ No newline at end of file
+ return None, None
+
+def set_prompt_settings_for_agent(chat_service, agent_config: dict):
+ """
+ Update the chat_service's prompt execution settings by merging agent_config overrides
+ into the existing settings. No prompt_settings argument is needed; all defaults are read
+ from the chat_service itself.
+ """
+ if not (chat_service and agent_config):
+ return
+
+ PromptExecutionSettingsClass = chat_service.get_prompt_execution_settings_class()
+
+ # Try to get an existing settings object from the service
+ existing = getattr(chat_service, "prompt_execution_settings", None)
+ if existing is None and hasattr(chat_service, "instantiate_prompt_execution_settings"):
+ try:
+ existing = chat_service.instantiate_prompt_execution_settings()
+ except Exception:
+ existing = None
+
+ # Convert/normalize existing settings into the concrete class if needed
+ if existing:
+ try:
+ prompt_exec_settings = PromptExecutionSettingsClass.from_prompt_execution_settings(existing)
+ except Exception:
+ prompt_exec_settings = PromptExecutionSettingsClass()
+ else:
+ prompt_exec_settings = PromptExecutionSettingsClass()
+
+ # Utility to pick an override from agent_config (None means no override)
+ def pick(key):
+ return agent_config.get(key, None)
+
+ # Handle token fields - prefer agent_config max_completion_tokens then max_tokens
+ desired_tokens = pick("max_completion_tokens")
+ if desired_tokens is None:
+ desired_tokens = pick("max_tokens")
+
+ model_fields = getattr(PromptExecutionSettingsClass, "model_fields", {})
+ if desired_tokens is not None:
+ try:
+ desired_tokens = int(desired_tokens)
+ except Exception:
+ desired_tokens = None
+ if desired_tokens and desired_tokens > 0:
+ # This includes reasoning tokens in addition to response tokens. max_tokens is ONLY response tokens.
+ if "max_completion_tokens" in model_fields:
+ setattr(prompt_exec_settings, "max_completion_tokens", desired_tokens)
+ if "max_tokens" in model_fields:
+ setattr(prompt_exec_settings, "max_tokens", desired_tokens)
+
+ chat_service.get_prompt_execution_settings_class()
+
+ # Common numeric settings
+ for fld in ("temperature", "top_p", "frequency_penalty", "presence_penalty"):
+ val = pick(fld)
+ if val is not None:
+ try:
+ setattr(prompt_exec_settings, fld, val)
+ except Exception:
+ pass
+
+ # stop sequences -> map to 'stop' which OpenAI expects
+ stop_seqs = pick("stop_sequences") or pick("stop")
+ if stop_seqs is not None:
+ try:
+ setattr(prompt_exec_settings, "stop", stop_seqs)
+ except Exception:
+ pass
+
+ if hasattr(prompt_exec_settings, 'function_choice_behavior'):
+ if getattr(prompt_exec_settings, 'function_choice_behavior', None) is None:
+ try:
+ prompt_exec_settings.function_choice_behavior = FunctionChoiceBehavior.from_string('auto')
+ except Exception:
+ pass
+ else:
+ print(f"[SK Loader] function_choice_behavior attribute not found in prompt execution settings for agent: {agent_config.get('name')}")
+
+ # Apply settings back to service (prefer explicit setter, do NOT set attribute if not supported)
+ if hasattr(chat_service, "set_prompt_execution_settings"):
+ try:
+ chat_service.set_prompt_execution_settings(prompt_exec_settings)
+ except Exception as e:
+ # Log error but do not set attribute directly to avoid Pydantic validation errors
+ log_event(f"[SK Loader] Failed to set prompt execution settings via setter: {e}", level=logging.ERROR, exceptionTraceback=True)
+ # Do not set prompt_execution_settings as an attribute if not supported by the service
+ return chat_service
diff --git a/application/single_app/semantic_kernel_plugins/base_plugin.py b/application/single_app/semantic_kernel_plugins/base_plugin.py
index d0d5f4827..b12969de5 100644
--- a/application/single_app/semantic_kernel_plugins/base_plugin.py
+++ b/application/single_app/semantic_kernel_plugins/base_plugin.py
@@ -70,11 +70,49 @@ def get_functions(self) -> List[str]:
Override this method if you want to explicitly declare exposed functions.
"""
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}")
+ # First check unbound functions on the class where decorator attributes are set
+ for name, fn in inspect.getmembers(self.__class__, predicate=inspect.isfunction):
+ if getattr(fn, "is_kernel_function", False):
functions.append(name)
+
+ # Fallback: check bound methods on the instance (older decorators may attach to the bound method)
+ if not functions:
+ for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
+ if getattr(method, "is_kernel_function", False):
+ functions.append(name)
+
+ # Debug print for visibility during registration
+ for f in functions:
+ print(f"Registering function: {f}")
+
return functions
+ def _collect_kernel_methods_for_metadata(self) -> List[Dict[str, str]]:
+ """
+ Collect methods decorated with @kernel_function by parsing the class source code.
+ Falls back to gathering function names and the first line of their docstring when decorator metadata isn't available.
+ """
+ methods: List[Dict[str, str]] = []
+ try:
+ src = inspect.getsource(self.__class__)
+ except Exception:
+ src = None
+ if src:
+ # Try to find @kernel_function(...description="...") followed by the def
+ regex = re.compile(r"@kernel_function\s*\(\s*[^)]*?description\s*=\s*(['\"])(.*?)\1[^)]*?\)\s*(?:\n\s*@[^\"]*?)*\n\s*def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.S)
+ for m in regex.finditer(src):
+ desc = m.group(2).strip()
+ name = m.group(3).strip()
+ methods.append({"name": name, "description": desc})
+ # If parsing didn't find anything, fall back to introspection of methods and docstrings
+ if not methods:
+ for name, fn in inspect.getmembers(self.__class__, predicate=inspect.isfunction):
+ # skip private/internal functions
+ if name.startswith("_"):
+ continue
+ doc = (fn.__doc__ or "").strip().splitlines()
+ desc = doc[0] if doc else ""
+ methods.append({"name": name, "description": desc})
+ return methods
+
diff --git a/application/single_app/semantic_kernel_plugins/databricks_table_plugin.py b/application/single_app/semantic_kernel_plugins/databricks_table_plugin.py
index e61a55d23..533a6fac2 100644
--- a/application/single_app/semantic_kernel_plugins/databricks_table_plugin.py
+++ b/application/single_app/semantic_kernel_plugins/databricks_table_plugin.py
@@ -76,9 +76,6 @@ def metadata(self):
]
}
- def get_functions(self):
- return ["query_table"]
-
@kernel_function(
description="""
Query the Databricks table using parameterized SQL. Column names are listed in self.columns.
diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js
index 41111c26a..81df7aa8c 100644
--- a/application/single_app/static/js/agent_modal_stepper.js
+++ b/application/single_app/static/js/agent_modal_stepper.js
@@ -22,6 +22,7 @@ export class AgentModalStepper {
const prevBtn = document.getElementById('agent-modal-prev');
const saveBtn = document.getElementById('agent-modal-save-btn');
const skipBtn = document.getElementById('agent-modal-skip');
+ const powerUserToggle = document.getElementById('agent-power-user-toggle');
if (nextBtn) {
nextBtn.addEventListener('click', () => this.nextStep());
@@ -35,6 +36,9 @@ export class AgentModalStepper {
if (skipBtn) {
skipBtn.addEventListener('click', () => this.skipToEnd());
}
+ if (powerUserToggle) {
+ powerUserToggle.addEventListener('change', (e) => this.togglePowerUserMode(e.target.checked));
+ }
// Set up display name to generated name conversion
this.setupNameGeneration();
@@ -53,6 +57,14 @@ export class AgentModalStepper {
}
}
+ togglePowerUserMode(isEnabled) {
+ console.log('Toggling power user mode:', isEnabled);
+ const powerUserSection = document.getElementById('agent-power-user-settings');
+ if (powerUserSection) {
+ powerUserSection.classList.toggle('d-none', !isEnabled);
+ }
+ }
+
generateAgentName(displayName) {
if (!displayName) return '';
@@ -204,6 +216,19 @@ export class AgentModalStepper {
agentsCommon.setAgentModalFields(agent);
}
+ // any agent advanced settings
+ if (this.currentAgent
+ && this.currentAgent.max_completion_tokens != -1) {
+ const powerUserToggle = document.getElementById('agent-power-user-toggle');
+ if (powerUserToggle) {
+ powerUserToggle.checked = true; // true/false from your agent data
+ const agentPowerUserSettings = document.getElementById('agent-power-user-settings');
+ if (agentPowerUserSettings) {
+ agentPowerUserSettings.classList.remove('d-none');
+ }
+ }
+ }
+
// Show/hide custom connection fields as needed
if (customConnection) {
// Find the custom fields and global model group containers
@@ -249,9 +274,32 @@ export class AgentModalStepper {
}
}
- skipToEnd() {
+ async skipToEnd() {
// Skip to the summary step (step 6)
- this.goToStep(this.maxSteps);
+ //if (this.actionsToSelect != null && this.actionsToSelect.length > 0) {
+ // this.setSelectedActions(this.actionsToSelect);
+ //}
+ const skipBtn = document.getElementById('agent-modal-skip');
+ const originalText = skipBtn.innerHTML;
+ if (skipBtn) {
+ skipBtn.disabled = true;
+ skipBtn.innerHTML = `Skipping...`;
+ }
+ try {
+ await this.loadAvailableActions();
+ this.goToStep(this.maxSteps);
+ } catch (error) {
+ console.error('Error loading actions:', error);
+ if (skipBtn) {
+ skipBtn.disabled = false;
+ skipBtn.innerHTML = originalText;
+ }
+ } finally {
+ if (skipBtn) {
+ skipBtn.disabled = false;
+ skipBtn.innerHTML = originalText;
+ }
+ }
}
goToStep(stepNumber) {
@@ -965,7 +1013,7 @@ export class AgentModalStepper {
// Selected actions
const currentActions = this.getSelectedActionIds();
- const originalActions = this.originalAgent.actions || [];
+ const originalActions = this.originalAgent.actions_to_load || [];
// Compare fields
if (currentDisplayName !== (this.originalAgent.display_name || '')) {
@@ -1203,7 +1251,8 @@ export class AgentModalStepper {
instructions: document.getElementById('agent-instructions')?.value || '',
model: document.getElementById('agent-global-model-select')?.value || '',
custom_connection: document.getElementById('agent-custom-connection')?.checked || false,
- other_settings: document.getElementById('agent-additional-settings')?.value || '{}'
+ other_settings: document.getElementById('agent-additional-settings')?.value || '{}',
+ max_completion_tokens: parseInt(document.getElementById('agent-max-completion-tokens')?.value.trim()) || null
};
// Handle model and deployment configuration
diff --git a/application/single_app/static/js/agents_common.js b/application/single_app/static/js/agents_common.js
index fbbadf3a4..0157d5f5e 100644
--- a/application/single_app/static/js/agents_common.js
+++ b/application/single_app/static/js/agents_common.js
@@ -47,6 +47,7 @@ export function setAgentModalFields(agent, opts = {}) {
root.getElementById('agent-enable-apim').checked = !!agent.enable_agent_gpt_apim;
root.getElementById('agent-instructions').value = agent.instructions || '';
root.getElementById('agent-additional-settings').value = agent.other_settings ? JSON.stringify(agent.other_settings, null, 2) : '{}';
+ root.getElementById('agent-max-completion-tokens').value = agent.max_completion_tokens || '';
// Actions handled separately
}
@@ -95,6 +96,7 @@ export function getAgentModalFields(opts = {}) {
azure_agent_apim_gpt_api_version: root.getElementById('agent-apim-api-version').value.trim(),
enable_agent_gpt_apim: root.getElementById('agent-enable-apim').checked,
instructions: root.getElementById('agent-instructions').value.trim(),
+ max_completion_tokens: parseInt(root.getElementById('agent-max-completion-tokens').value.trim()) || null,
actions_to_load: actions_to_load,
other_settings: additionalSettings
};
diff --git a/application/single_app/static/js/validateAgent.mjs b/application/single_app/static/js/validateAgent.mjs
index da0776357..a65b75a94 100644
--- a/application/single_app/static/js/validateAgent.mjs
+++ b/application/single_app/static/js/validateAgent.mjs
@@ -1 +1 @@
-"use strict";export const validate = validate10;export default validate10;const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/Agent","definitions":{"Agent":{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}","description":"Agent ID = GUID (UUID v4 pattern) and possible userId/groupId"},"user_id":{"type":"string","description":"User ID that owns this personal agent"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"display_name":{"type":"string"},"description":{"type":"string"},"azure_openai_gpt_endpoint":{"type":"string"},"azure_openai_gpt_key":{"type":"string"},"azure_openai_gpt_deployment":{"type":"string"},"azure_openai_gpt_api_version":{"type":"string"},"azure_agent_apim_gpt_endpoint":{"type":"string"},"azure_agent_apim_gpt_subscription_key":{"type":"string"},"azure_agent_apim_gpt_deployment":{"type":"string"},"azure_agent_apim_gpt_api_version":{"type":"string"},"enable_agent_gpt_apim":{"type":"boolean"},"default_agent":{"type":"boolean","description":"(deprecated) Use selected_agent for agent selection."},"is_global":{"type":"boolean","description":"True if this agent is a global agent; required for agent selection and UI badging.","default":false},"instructions":{"type":"string"},"actions_to_load":{"type":"array","items":{"type":"string"}},"other_settings":{"type":"object"}},"required":["id","name","display_name","description","is_global","instructions","actions_to_load","other_settings"],"title":"Agent"}}};const schema12 = {"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}","description":"Agent ID = GUID (UUID v4 pattern) and possible userId/groupId"},"user_id":{"type":"string","description":"User ID that owns this personal agent"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"display_name":{"type":"string"},"description":{"type":"string"},"azure_openai_gpt_endpoint":{"type":"string"},"azure_openai_gpt_key":{"type":"string"},"azure_openai_gpt_deployment":{"type":"string"},"azure_openai_gpt_api_version":{"type":"string"},"azure_agent_apim_gpt_endpoint":{"type":"string"},"azure_agent_apim_gpt_subscription_key":{"type":"string"},"azure_agent_apim_gpt_deployment":{"type":"string"},"azure_agent_apim_gpt_api_version":{"type":"string"},"enable_agent_gpt_apim":{"type":"boolean"},"default_agent":{"type":"boolean","description":"(deprecated) Use selected_agent for agent selection."},"is_global":{"type":"boolean","description":"True if this agent is a global agent; required for agent selection and UI badging.","default":false},"instructions":{"type":"string"},"actions_to_load":{"type":"array","items":{"type":"string"}},"other_settings":{"type":"object"}},"required":["id","name","display_name","description","is_global","instructions","actions_to_load","other_settings"],"title":"Agent"};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "u");const pattern1 = new RegExp("^[A-Za-z0-9_-]+$", "u");function validate10(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.id === undefined) && (missing0 = "id")) || ((data.name === undefined) && (missing0 = "name"))) || ((data.display_name === undefined) && (missing0 = "display_name"))) || ((data.description === undefined) && (missing0 = "description"))) || ((data.is_global === undefined) && (missing0 = "is_global"))) || ((data.instructions === undefined) && (missing0 = "instructions"))) || ((data.actions_to_load === undefined) && (missing0 = "actions_to_load"))) || ((data.other_settings === undefined) && (missing0 = "other_settings"))){validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/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(schema12.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs2 === errors){if(data.id !== undefined){let data0 = data.id;const _errs3 = errors;if(errors === _errs3){if(typeof data0 === "string"){if(!pattern0.test(data0)){validate10.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Agent/properties/id/pattern",keyword:"pattern",params:{pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"},message:"must match pattern \""+"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"+"\""}];return false;}}else {validate10.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Agent/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"){validate10.errors = [{instancePath:instancePath+"/user_id",schemaPath:"#/definitions/Agent/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"){validate10.errors = [{instancePath:instancePath+"/last_updated",schemaPath:"#/definitions/Agent/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)){validate10.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Agent/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9_-]+$"},message:"must match pattern \""+"^[A-Za-z0-9_-]+$"+"\""}];return false;}}else {validate10.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Agent/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.display_name !== undefined){const _errs11 = errors;if(typeof data.display_name !== "string"){validate10.errors = [{instancePath:instancePath+"/display_name",schemaPath:"#/definitions/Agent/properties/display_name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data.description !== undefined){const _errs13 = errors;if(typeof data.description !== "string"){validate10.errors = [{instancePath:instancePath+"/description",schemaPath:"#/definitions/Agent/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_endpoint !== undefined){const _errs15 = errors;if(typeof data.azure_openai_gpt_endpoint !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_endpoint",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs15 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_key !== undefined){const _errs17 = errors;if(typeof data.azure_openai_gpt_key !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_key",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs17 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_deployment !== undefined){const _errs19 = errors;if(typeof data.azure_openai_gpt_deployment !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_deployment",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_deployment/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs19 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_api_version !== undefined){const _errs21 = errors;if(typeof data.azure_openai_gpt_api_version !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_api_version",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_api_version/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs21 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_endpoint !== undefined){const _errs23 = errors;if(typeof data.azure_agent_apim_gpt_endpoint !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_endpoint",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs23 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_subscription_key !== undefined){const _errs25 = errors;if(typeof data.azure_agent_apim_gpt_subscription_key !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_subscription_key",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_subscription_key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs25 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_deployment !== undefined){const _errs27 = errors;if(typeof data.azure_agent_apim_gpt_deployment !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_deployment",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_deployment/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs27 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_api_version !== undefined){const _errs29 = errors;if(typeof data.azure_agent_apim_gpt_api_version !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_api_version",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_api_version/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs29 === errors;}else {var valid1 = true;}if(valid1){if(data.enable_agent_gpt_apim !== undefined){const _errs31 = errors;if(typeof data.enable_agent_gpt_apim !== "boolean"){validate10.errors = [{instancePath:instancePath+"/enable_agent_gpt_apim",schemaPath:"#/definitions/Agent/properties/enable_agent_gpt_apim/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs31 === errors;}else {var valid1 = true;}if(valid1){if(data.default_agent !== undefined){const _errs33 = errors;if(typeof data.default_agent !== "boolean"){validate10.errors = [{instancePath:instancePath+"/default_agent",schemaPath:"#/definitions/Agent/properties/default_agent/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs33 === errors;}else {var valid1 = true;}if(valid1){if(data.is_global !== undefined){const _errs35 = errors;if(typeof data.is_global !== "boolean"){validate10.errors = [{instancePath:instancePath+"/is_global",schemaPath:"#/definitions/Agent/properties/is_global/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs35 === errors;}else {var valid1 = true;}if(valid1){if(data.instructions !== undefined){const _errs37 = errors;if(typeof data.instructions !== "string"){validate10.errors = [{instancePath:instancePath+"/instructions",schemaPath:"#/definitions/Agent/properties/instructions/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs37 === errors;}else {var valid1 = true;}if(valid1){if(data.actions_to_load !== undefined){let data18 = data.actions_to_load;const _errs39 = errors;if(errors === _errs39){if(Array.isArray(data18)){var valid2 = true;const len0 = data18.length;for(let i0=0; i0 512000 || isNaN(data21)){validate10.errors = [{instancePath:instancePath+"/max_completion_tokens",schemaPath:"#/definitions/Agent/properties/max_completion_tokens/maximum",keyword:"maximum",params:{comparison: "<=", limit: 512000},message:"must be <= 512000"}];return false;}else {if(data21 < -1 || isNaN(data21)){validate10.errors = [{instancePath:instancePath+"/max_completion_tokens",schemaPath:"#/definitions/Agent/properties/max_completion_tokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1},message:"must be >= -1"}];return false;}}}}var valid1 = _errs45 === errors;}else {var valid1 = true;}}}}}}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;}
\ No newline at end of file
diff --git a/application/single_app/static/js/validatePlugin.mjs b/application/single_app/static/js/validatePlugin.mjs
index 5e54f76d0..ae4ad0168 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","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
+"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":["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":{"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":"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"]}},{"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":["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":{"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":"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"]}},{"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("NoAuth" !== 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")){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("key" !== 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.key === undefined) && (missing2 = "key"))){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("identity" !== 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")) || ((data8.identity === undefined) && (missing3 = "identity"))){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("user" !== 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")){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("servicePrincipal" !== 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.tenantId === undefined) && (missing5 = "tenantId"))) || ((data8.identity === undefined) && (missing5 = "identity"))) || ((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("connection_string" !== 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"))){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("basic" !== 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;const _errs57 = errors;let valid17 = true;const _errs58 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("username_password" !== data8.type){const err14 = {};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}var _valid7 = _errs58 === errors;errors = _errs57;if(vErrors !== null){if(_errs57){vErrors.length = _errs57;}else {vErrors = null;}}if(_valid7){const _errs60 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing8;if((((data8.type === undefined) && (missing8 = "type")) || ((data8.key === undefined) && (missing8 = "key"))) || ((data8.identity === undefined) && (missing8 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/7/then/required",keyword:"required",params:{missingProperty: missing8},message:"must have required property '"+missing8+"'"}];return false;}}var _valid7 = _errs60 === errors;valid17 = _valid7;}if(!valid17){const err15 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/7/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs56 === errors;if(valid2){const _errs61 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing9;if((data8.type === undefined) && (missing9 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/8/required",keyword:"required",params:{missingProperty: missing9},message:"must have required property '"+missing9+"'"}];return false;}}var valid2 = _errs61 === errors;}}}}}}}}if(errors === _errs19){if(data8 && typeof data8 == "object" && !Array.isArray(data8)){const _errs62 = 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(_errs62 === errors){if(data8.type !== undefined){let data17 = data8.type;const _errs63 = errors;if(typeof data17 !== "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(!((((((((data17 === "NoAuth") || (data17 === "key")) || (data17 === "identity")) || (data17 === "user")) || (data17 === "servicePrincipal")) || (data17 === "connection_string")) || (data17 === "basic")) || (data17 === "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 valid19 = _errs63 === errors;}else {var valid19 = true;}if(valid19){if(data8.key !== undefined){const _errs65 = 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 valid19 = _errs65 === errors;}else {var valid19 = true;}if(valid19){if(data8.identity !== undefined){const _errs67 = 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 valid19 = _errs67 === errors;}else {var valid19 = true;}if(valid19){if(data8.tenantId !== undefined){const _errs69 = 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 valid19 = _errs69 === errors;}else {var valid19 = 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 data21 = data.metadata;const _errs71 = errors;if(errors === _errs71){if(data21 && typeof data21 == "object" && !Array.isArray(data21)){}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 = _errs71 === errors;}else {var valid1 = true;}if(valid1){if(data.additionalFields !== undefined){let data22 = data.additionalFields;const _errs74 = errors;if(errors === _errs74){if(data22 && typeof data22 == "object" && !Array.isArray(data22)){}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 = _errs74 === 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/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json
index 139751fd8..786a8c99c 100644
--- a/application/single_app/static/json/schemas/agent.schema.json
+++ b/application/single_app/static/json/schemas/agent.schema.json
@@ -75,6 +75,12 @@
},
"other_settings": {
"type": "object"
+ },
+ "max_completion_tokens": {
+ "type": "integer",
+ "minimum": -1,
+ "maximum": 512000,
+ "default": 4096
}
},
"required": [
@@ -85,7 +91,8 @@
"is_global",
"instructions",
"actions_to_load",
- "other_settings"
+ "other_settings",
+ "max_completion_tokens"
],
"title": "Agent"
}
diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html
index a6bbe7852..b22dc789d 100644
--- a/application/single_app/templates/_agent_modal.html
+++ b/application/single_app/templates/_agent_modal.html
@@ -260,6 +260,24 @@
Advanced Settings
Optional additional configuration settings for this agent in JSON format.
+
+
+
+
+
+
+
+
+
+
+
+
+ Specify the maximum number of tokens the model can generate in a single response.
+ Set to -1 to use the model's default limit.
+ Note: Setting this value too high may lead to increased latency or higher costs.
+
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
+
+ {% if settings.enable_semantic_kernel and settings.allow_group_agents %}
+
+
+
+
+
Group Agents
+
+
+
+ You do not have permission to manage group agents.
+
+
+
+
+
+
+
+
Display Name
+
Description
+
Actions
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
Group Actions
+
+
+
+
+
+
+
+
+
Display Name
+
Description
+
Actions
+
+
+
+
+
+
+ Loading...
+
+ Select a group to load actions.
+
+
+
+
+
+
+
+
+ {% endif %}
@@ -729,6 +861,13 @@
+{% if settings.enable_semantic_kernel and (settings.allow_group_agents or settings.allow_group_plugins) %}
+
+ {% include "_agent_modal.html" %}
+ {% include "_plugin_modal.html" %}
+
+{% endif %}
+
@@ -838,6 +977,7 @@
Currently Shared With:
+{% if settings.enable_semantic_kernel and settings.allow_group_agents %}
+
+
+{% endif %}
+{% if settings.enable_semantic_kernel and settings.allow_group_plugins %}
+
+
+{% endif %}
{% endblock %}
From 8241ae9ea296f42bd02ce227bd790a9f04da0c95 Mon Sep 17 00:00:00 2001
From: Bionic711
Date: Thu, 13 Nov 2025 13:46:13 -0600
Subject: [PATCH 65/68] add branch for testing/rmv old branch
---
.github/workflows/docker_image_publish_nadoyle.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docker_image_publish_nadoyle.yml b/.github/workflows/docker_image_publish_nadoyle.yml
index 4edc6dbf2..ae5a2a3d6 100644
--- a/.github/workflows/docker_image_publish_nadoyle.yml
+++ b/.github/workflows/docker_image_publish_nadoyle.yml
@@ -5,7 +5,7 @@ on:
push:
branches:
- nadoyle
- - keyvaultForSecrets
+ - feature/group-agents-actions
workflow_dispatch:
From 1abd60814276898764c28546e4b0aed9a88f4181 Mon Sep 17 00:00:00 2001
From: Bionic711
Date: Tue, 18 Nov 2025 22:30:05 -0600
Subject: [PATCH 66/68] bug fixes, group agent modifications, rmv client
validation
---
application/single_app/app.py | 4 +-
application/single_app/config.py | 2 +-
.../single_app/functions_appinsights.py | 3 +-
.../functions_conversation_metadata.py | 39 ++++++-
.../single_app/functions_global_agents.py | 4 +
.../single_app/functions_group_actions.py | 3 +
.../single_app/functions_group_agents.py | 2 +
.../single_app/functions_personal_agents.py | 4 +
application/single_app/functions_settings.py | 49 +++++++-
.../single_app/route_backend_agents.py | 2 +
application/single_app/route_backend_chats.py | 1 +
.../single_app/route_backend_plugins.py | 66 ++++++++++-
.../single_app/semantic_kernel_loader.py | 39 ++++++-
application/single_app/static/css/sidebar.css | 12 ++
.../static/js/agent_modal_stepper.js | 21 +---
.../single_app/static/js/agents_common.js | 26 ++++-
.../js/chat/chat-sidebar-conversations.js | 39 +++++++
.../single_app/static/js/plugin_common.js | 25 +---
.../single_app/static/js/validateAgent.mjs | 1 -
.../single_app/static/js/validatePlugin.mjs | 1 -
.../static/js/workspace/group_agents.js | 3 +-
.../static/js/workspace/group_plugins.js | 28 ++++-
.../static/json/schemas/agent.schema.json | 8 +-
application/single_app/swagger_wrapper.py | 2 +-
docs/fixes/GROUP_AGENT_METADATA_FIX.md | 23 ++++
docs/fixes/GROUP_PLUGIN_GLOBAL_MERGE_FIX.md | 33 ++++++
docs/fixes/SIDEBAR_GROUP_BADGE_FIX.md | 21 ++++
docs/fixes/TOP_NAV_SIDEBAR_OVERLAP_FIX.md | 26 +++++
...t_group_agent_conversation_metadata_fix.py | 107 +++++++++++++++++
.../test_group_plugin_global_merge_fix.py | 108 ++++++++++++++++++
.../test_sidebar_group_badge_fix.py | 73 ++++++++++++
.../test_top_nav_sidebar_offset_fix.py | 59 ++++++++++
32 files changed, 758 insertions(+), 76 deletions(-)
delete mode 100644 application/single_app/static/js/validateAgent.mjs
delete mode 100644 application/single_app/static/js/validatePlugin.mjs
create mode 100644 docs/fixes/GROUP_AGENT_METADATA_FIX.md
create mode 100644 docs/fixes/GROUP_PLUGIN_GLOBAL_MERGE_FIX.md
create mode 100644 docs/fixes/SIDEBAR_GROUP_BADGE_FIX.md
create mode 100644 docs/fixes/TOP_NAV_SIDEBAR_OVERLAP_FIX.md
create mode 100644 functional_tests/test_group_agent_conversation_metadata_fix.py
create mode 100644 functional_tests/test_group_plugin_global_merge_fix.py
create mode 100644 functional_tests/test_sidebar_group_badge_fix.py
create mode 100644 functional_tests/test_top_nav_sidebar_offset_fix.py
diff --git a/application/single_app/app.py b/application/single_app/app.py
index e63932d3b..77078f752 100644
--- a/application/single_app/app.py
+++ b/application/single_app/app.py
@@ -159,7 +159,7 @@ def configure_sessions(settings):
@app.before_first_request
def before_first_request():
print("Initializing application...")
- settings = get_settings()
+ settings = get_settings(use_cosmos=True)
app_settings_cache.configure_app_cache(settings, get_redis_cache_infrastructure_endpoint(settings.get('redis_url', '').strip().split('.')[0]))
app_settings_cache.update_settings_cache(settings)
print(f"DEBUG:Application settings: {settings}")
@@ -456,7 +456,7 @@ def list_semantic_kernel_plugins():
register_route_external_health(app)
if __name__ == '__main__':
- settings = get_settings()
+ settings = get_settings(use_cosmos=True)
app_settings_cache.configure_app_cache(settings, get_redis_cache_infrastructure_endpoint(settings.get('redis_url', '').strip().split('.')[0]))
app_settings_cache.update_settings_cache(settings)
initialize_clients(settings)
diff --git a/application/single_app/config.py b/application/single_app/config.py
index ede91012b..91df92058 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.159"
+VERSION = "0.233.166"
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py
index 090101edc..41e535e5c 100644
--- a/application/single_app/functions_appinsights.py
+++ b/application/single_app/functions_appinsights.py
@@ -47,8 +47,7 @@ def log_event(
try:
try:
cache = app_settings_cache.get_settings_cache() or None
- except Exception as e:
- print(f"[Log] Could not retrieve settings cache: {e}")
+ except Exception:
cache = None
# Get logger - use Azure Monitor logger if configured, otherwise standard logger
diff --git a/application/single_app/functions_conversation_metadata.py b/application/single_app/functions_conversation_metadata.py
index 5924a877b..262b09558 100644
--- a/application/single_app/functions_conversation_metadata.py
+++ b/application/single_app/functions_conversation_metadata.py
@@ -45,7 +45,7 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active
document_scope=None, selected_document_id=None, model_deployment=None,
hybrid_search_enabled=False,
image_gen_enabled=False, selected_documents=None,
- selected_agent=None, search_results=None, web_search_results=None,
+ selected_agent=None, selected_agent_details=None, search_results=None, web_search_results=None,
conversation_item=None, additional_participants=None):
"""
Collect comprehensive metadata for a conversation based on the user's interaction.
@@ -65,6 +65,7 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active
search_results: Results from hybrid search
conversation_item: Existing conversation item to update
additional_participants: List of additional user IDs to include as participants
+ selected_agent_details: Detailed agent metadata (is_group, group_id, group_name)
Returns:
dict: Updated conversation metadata
@@ -86,6 +87,25 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active
if 'strict' not in conversation_item:
conversation_item['strict'] = False
+ # Prepare agent-derived group context (used when agent is a group and no documents were used)
+ agent_primary_context = None
+ agent_primary_context_active = False
+ if selected_agent_details and selected_agent_details.get('is_group'):
+ group_id = selected_agent_details.get('group_id')
+ group_name = selected_agent_details.get('group_name')
+
+ if group_id:
+ if not group_name:
+ group_info = find_group_by_id(group_id)
+ if group_info:
+ group_name = group_info.get('name')
+ agent_primary_context = {
+ "type": "primary",
+ "scope": "group",
+ "id": group_id,
+ "name": group_name or "Unknown Group"
+ }
+
# Process documents from search results first to determine primary context
document_map = {} # Map of document_id -> {scope, chunks, classification}
workspace_used = None # Track the first workspace used (becomes primary context)
@@ -144,19 +164,30 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active
"id": scope_id,
"name": context_name
}
- # If no documents were used, we don't set a primary context yet
- # This allows us to track conversations that only use model knowledge
+ # If no documents were used, fall back to agent-based primary context
+ if not primary_context and agent_primary_context:
+ primary_context = agent_primary_context
+ agent_primary_context_active = True
# Update or add primary context only if we don't already have one
existing_primary = next((ctx for ctx in conversation_item['context'] if ctx.get('type') == 'primary'), None)
if primary_context:
if existing_primary:
- # Primary context already exists - check if this is the same workspace
+ # Primary context already exists - determine how to handle the new context
if (existing_primary.get('scope') == primary_context.get('scope') and
existing_primary.get('id') == primary_context.get('id')):
# Same workspace - update existing primary context (e.g., refresh name)
existing_primary.update(primary_context)
debug_print(f"Updated existing primary context: {existing_primary}")
+ elif agent_primary_context_active:
+ # Promote the group agent context to become the new primary context
+ existing_primary.update({
+ "scope": primary_context.get('scope'),
+ "id": primary_context.get('id'),
+ "name": primary_context.get('name')
+ })
+ debug_print(f"Replaced existing primary context with agent group context: {existing_primary}")
+ primary_context = None
else:
# Different workspace - this should become a secondary context
debug_print(f"Primary context already exists ({existing_primary.get('scope')}:{existing_primary.get('id')}), "f"treating new workspace ({primary_context.get('scope')}:{primary_context.get('id')}) as secondary")
diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py
index e08377514..720a1b6cc 100644
--- a/application/single_app/functions_global_agents.py
+++ b/application/single_app/functions_global_agents.py
@@ -41,6 +41,7 @@ def ensure_default_global_agent_exists():
"enable_agent_gpt_apim": False,
"is_global": True,
"is_group": False,
+ "agent_type": "local",
"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"
@@ -108,6 +109,7 @@ def get_global_agents():
agent['max_completion_tokens'] = -1
agent.setdefault('is_global', True)
agent.setdefault('is_group', False)
+ agent.setdefault('agent_type', 'local')
return agents
except Exception as e:
log_event(
@@ -140,6 +142,7 @@ def get_global_agent(agent_id):
agent['max_completion_tokens'] = -1
agent.setdefault('is_global', True)
agent.setdefault('is_group', False)
+ agent.setdefault('agent_type', 'local')
print(f"Found global agent: {agent_id}")
return agent
except Exception as e:
@@ -171,6 +174,7 @@ def save_global_agent(agent_data):
# Add metadata
agent_data['is_global'] = True
agent_data['is_group'] = False
+ agent_data.setdefault('agent_type', 'local')
agent_data['created_at'] = datetime.utcnow().isoformat()
agent_data['updated_at'] = datetime.utcnow().isoformat()
log_event(
diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py
index 2a1f536ea..0dc0c3ddc 100644
--- a/application/single_app/functions_group_actions.py
+++ b/application/single_app/functions_group_actions.py
@@ -203,4 +203,7 @@ def _clean_action(
scope="group",
return_type=return_type,
)
+ cleaned.setdefault("is_global", False)
+ cleaned.setdefault("is_group", True)
+ cleaned.setdefault("scope", "group")
return cleaned
diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py
index 8c587a268..92880ebce 100644
--- a/application/single_app/functions_group_agents.py
+++ b/application/single_app/functions_group_agents.py
@@ -81,6 +81,7 @@ def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any
payload.setdefault("other_settings", {})
payload.setdefault("max_completion_tokens", -1)
payload.setdefault("enable_agent_gpt_apim", False)
+ payload.setdefault("agent_type", "local")
# Ensure optional Azure fields exist
payload.setdefault("azure_openai_gpt_endpoint", "")
@@ -194,4 +195,5 @@ def _clean_agent(agent: Dict[str, Any]) -> Dict[str, Any]:
cleaned["max_completion_tokens"] = -1
cleaned.setdefault("is_global", False)
cleaned.setdefault("is_group", True)
+ cleaned.setdefault("agent_type", "local")
return cleaned
diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py
index 0b72e096c..284e2f250 100644
--- a/application/single_app/functions_personal_agents.py
+++ b/application/single_app/functions_personal_agents.py
@@ -48,6 +48,7 @@ def get_personal_agents(user_id):
cleaned_agent['max_completion_tokens'] = -1
cleaned_agent.setdefault('is_global', False)
cleaned_agent.setdefault('is_group', False)
+ cleaned_agent.setdefault('agent_type', 'local')
cleaned_agents.append(cleaned_agent)
return cleaned_agents
@@ -82,6 +83,7 @@ def get_personal_agent(user_id, agent_id):
cleaned_agent['max_completion_tokens'] = -1
cleaned_agent.setdefault('is_global', False)
cleaned_agent.setdefault('is_group', False)
+ cleaned_agent.setdefault('agent_type', 'local')
return cleaned_agent
except exceptions.CosmosResourceNotFoundError:
current_app.logger.warning(f"Agent {agent_id} not found for user {user_id}")
@@ -125,6 +127,7 @@ def save_personal_agent(user_id, agent_data):
agent_data.setdefault('other_settings', {})
agent_data['is_global'] = False
agent_data['is_group'] = False
+ agent_data.setdefault('agent_type', 'local')
# Store sensitive keys in Key Vault if enabled
agent_data = keyvault_agent_save_helper(agent_data, agent_data.get('id', ''), scope="user")
@@ -135,6 +138,7 @@ def save_personal_agent(user_id, agent_data):
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)
+ cleaned_result.setdefault('agent_type', 'local')
return cleaned_result
except Exception as e:
diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py
index ab9883bbd..ed91b25f8 100644
--- a/application/single_app/functions_settings.py
+++ b/application/single_app/functions_settings.py
@@ -3,8 +3,9 @@
from config import *
from functions_appinsights import log_event
import app_settings_cache
+import inspect
-def get_settings():
+def get_settings(use_cosmos=False):
import secrets
default_settings = {
# External health check
@@ -237,10 +238,44 @@ def get_settings():
try:
# Attempt to read the existing doc
- settings_item = cosmos_settings_container.read_item(
- item="app_settings",
- partition_key="app_settings"
- )
+ if use_cosmos:
+ settings_item = cosmos_settings_container.read_item(
+ item="app_settings",
+ partition_key="app_settings"
+ )
+ else:
+ settings_item = None
+
+ cache_accessor = getattr(app_settings_cache, "get_settings_cache", None)
+ if callable(cache_accessor):
+ try:
+ settings_item = cache_accessor()
+ except Exception:
+ settings_item = None
+
+ if not settings_item:
+ settings_item = cosmos_settings_container.read_item(
+ item="app_settings",
+ partition_key="app_settings"
+ )
+
+ frame = inspect.currentframe()
+ caller = frame.f_back # the function that called *this* code
+
+ if caller is not None:
+ code = caller.f_code
+ caller_file = code.co_filename
+ caller_line = caller.f_lineno
+ caller_func = code.co_name
+ print(
+ "Warning: Failed to get settings from cache, read from Cosmos DB instead. "
+ f"Called from {caller_file}:{caller_line} in {caller_func}()."
+ )
+ else:
+ print(
+ "Warning: Failed to get settings from cache, "
+ "read from Cosmos DB instead. (no caller frame)"
+ )
#print("Successfully retrieved settings from Cosmos DB.")
# Merge default_settings in, to fill in any missing or nested keys
@@ -270,7 +305,9 @@ def update_settings(new_settings):
settings_item = get_settings()
settings_item.update(new_settings)
cosmos_settings_container.upsert_item(settings_item)
- app_settings_cacheupdate_settings_cache(settings_item) # Update the in-memory cache as well
+ cache_updater = getattr(app_settings_cache, "update_settings_cache", None)
+ if callable(cache_updater):
+ cache_updater(settings_item)
print("Settings updated successfully.")
return True
except Exception as e:
diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py
index b3948873f..494a46ac8 100644
--- a/application/single_app/route_backend_agents.py
+++ b/application/single_app/route_backend_agents.py
@@ -53,6 +53,7 @@ def get_user_agents():
for agent in agents:
agent['is_global'] = False
agent['is_group'] = False
+ agent.setdefault('agent_type', 'local')
# Check global/merge toggles
settings = get_settings()
@@ -65,6 +66,7 @@ def get_user_agents():
for agent in global_agents:
agent['is_global'] = True
agent['is_group'] = False
+ agent.setdefault('agent_type', 'local')
# Merge agents using ID as key to avoid name conflicts
# This allows both personal and global agents with same name to coexist
diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py
index 198305369..e095dc1a5 100644
--- a/application/single_app/route_backend_chats.py
+++ b/application/single_app/route_backend_chats.py
@@ -1788,6 +1788,7 @@ def gpt_error(e):
image_gen_enabled=image_gen_enabled,
selected_documents=combined_documents if 'combined_documents' in locals() else None,
selected_agent=selected_agent_name,
+ selected_agent_details=user_metadata.get('agent_selection'),
search_results=search_results if 'search_results' in locals() else None,
conversation_item=conversation_item
)
diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py
index d79745cc3..51f0c6a04 100644
--- a/application/single_app/route_backend_plugins.py
+++ b/application/single_app/route_backend_plugins.py
@@ -387,7 +387,18 @@ def get_group_actions_route():
return jsonify({'error': str(exc)}), 403
actions = get_group_actions(active_group, return_type=SecretReturnType.TRIGGER)
- return jsonify({'actions': actions}), 200
+
+ settings = get_settings()
+ merge_global = bool(settings.get('merge_global_semantic_kernel_with_workspace', False)) if settings else False
+
+ if merge_global:
+ global_actions = get_global_actions(return_type=SecretReturnType.TRIGGER)
+ merged_actions = _merge_group_and_global_actions(actions, global_actions)
+ else:
+ merged_actions = [_normalize_group_action(action) for action in actions]
+ merged_actions.sort(key=lambda item: (item.get('displayName') or item.get('display_name') or item.get('name') or '').lower())
+
+ return jsonify({'actions': merged_actions}), 200
@bpap.route('/api/group/plugins/', methods=['GET'])
@@ -440,7 +451,10 @@ def create_group_action_route():
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
- for key in ('group_id', 'last_updated', 'user_id'):
+ if payload.get('is_global'):
+ return jsonify({'error': 'Global actions are managed centrally and cannot be created within a group.'}), 400
+
+ for key in ('group_id', 'last_updated', 'user_id', 'is_global', 'is_group', 'scope'):
payload.pop(key, None)
try:
@@ -474,7 +488,10 @@ def update_group_action_route(action_id):
return jsonify({'error': 'Action not found'}), 404
updates = request.get_json(silent=True) or {}
- for key in ('id', 'group_id', 'last_updated', 'user_id'):
+ if updates.get('is_global'):
+ return jsonify({'error': 'Global actions cannot be modified within a group.'}), 400
+
+ for key in ('id', 'group_id', 'last_updated', 'user_id', 'is_global', 'is_group', 'scope'):
updates.pop(key, None)
try:
@@ -484,6 +501,8 @@ def update_group_action_route(action_id):
merged = dict(existing)
merged.update(updates)
+ merged['is_global'] = False
+ merged['is_group'] = True
merged['id'] = existing.get('id', action_id)
try:
@@ -798,3 +817,44 @@ def list_dynamic_plugins():
"""
plugins = get_all_plugin_metadata()
return jsonify(plugins)
+
+# Helper functions for group/global action merging
+def _normalize_group_action(action: dict) -> dict:
+ normalized = dict(action)
+ normalized['is_global'] = False
+ normalized['is_group'] = True
+ normalized.setdefault('scope', 'group')
+ return normalized
+
+
+def _normalize_global_action(action: dict) -> dict:
+ normalized = dict(action)
+ normalized['is_global'] = True
+ normalized['is_group'] = False
+ normalized.setdefault('scope', 'global')
+ return normalized
+
+
+def _merge_group_and_global_actions(group_actions, global_actions):
+ normalized_actions = []
+ seen_names = set()
+
+ for action in group_actions:
+ normalized = _normalize_group_action(action)
+ action_name = (normalized.get('name') or '').lower()
+ if action_name:
+ seen_names.add(action_name)
+ normalized_actions.append(normalized)
+
+ for action in global_actions:
+ normalized = _normalize_global_action(action)
+ action_name = (normalized.get('name') or '').lower()
+ if action_name and action_name in seen_names:
+ continue
+ normalized_actions.append(normalized)
+
+ normalized_actions.sort(key=lambda item: (item.get('displayName') or item.get('display_name') or item.get('name') or '').lower())
+ return normalized_actions
+
+
+
diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py
index 2135d7356..370c4917b 100644
--- a/application/single_app/semantic_kernel_loader.py
+++ b/application/single_app/semantic_kernel_loader.py
@@ -104,6 +104,8 @@ def resolve_agent_config(agent, settings):
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')}")
+ agent_type = (agent.get('agent_type') or 'local').lower()
+ agent['agent_type'] = agent_type
gpt_model_obj = settings.get('gpt_model', {})
selected_model = gpt_model_obj.get('selected', [{}])[0] if gpt_model_obj.get('selected') else {}
@@ -255,7 +257,8 @@ def merge_fields(primary, fallback):
"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)
+ "max_completion_tokens": agent.get("max_completion_tokens", -1),
+ "agent_type": agent_type or "local"
}
except Exception as e:
log_event(f"[SK Loader] Error resolving agent config: {e}", level=logging.ERROR, exceptionTraceback=True)
@@ -312,7 +315,8 @@ def merge_fields(primary, fallback):
"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
+ "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
+ "agent_type": agent_type,
}
print(f"[SK Loader] Final resolved config for {agent.get('name')}: endpoint={bool(endpoint)}, key={bool(key)}, deployment={deployment}")
@@ -713,6 +717,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis
context_obj.redis_client = redis_client
agent_objs = {}
agent_config = resolve_agent_config(agent_cfg, settings)
+ agent_type = (agent_config.get("agent_type") or agent_cfg.get("agent_type") or "local").lower()
service_id = f"aoai-chat-{agent_config['name']}"
chat_service = None
apim_enabled = settings.get("enable_gpt_apim", False)
@@ -832,7 +837,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis
"default_agent": agent_config.get("default_agent", False),
"deployment_name": agent_config["deployment"],
"azure_endpoint": agent_config["endpoint"],
- "api_version": agent_config["api_version"]
+ "api_version": agent_config["api_version"],
}
# Don't pass plugins to agent since they're already loaded in kernel
agent_obj = LoggingChatCompletionAgent(**kwargs)
@@ -845,7 +850,9 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis
"aoai_endpoint": agent_config["endpoint"],
"aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None,
"aoai_deployment": agent_config["deployment"],
- "agent_name": agent_config["name"]
+ "agent_name": agent_config["name"],
+ "max_completion_tokens": agent_config.get("max_completion_tokens", -1),
+ "agent_type": agent_type,
},
level=logging.INFO
)
@@ -1365,7 +1372,17 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie
debug_print(f"[SK Loader] User {user_id} Agent azure_deployment: {agent_cfg.get('azure_deployment', 'NOT SET')}")
print(f"[SK Loader] User {user_id} Loading agent: {agent_cfg.get('name')}")
- kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user")
+ agent_type = (agent_cfg.get('agent_type') or 'local').lower()
+ agent_cfg['agent_type'] = agent_type
+ if agent_type == 'local':
+ kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user")
+ else:
+ log_event(
+ f"[SK Loader] Unsupported agent_type '{agent_type}' for agent '{agent_cfg.get('name')}'. Defaulting to local path.",
+ level=logging.WARNING,
+ extra={'agent_type': agent_type, 'agent_name': agent_cfg.get('name')}
+ )
+ kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user")
print(f"[SK Loader] User {user_id} Agent loading completed. Agent objects: {type(agent_objs)} with {len(agent_objs) if agent_objs else 0} items")
return kernel, agent_objs
@@ -1669,7 +1686,17 @@ def load_semantic_kernel(kernel: Kernel, settings):
if global_selected_agent_cfg:
log_event(f"[SK Loader] Using global_selected_agent: {global_selected_agent_cfg.get('name')}", level=logging.INFO)
- kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global")
+ agent_type = (global_selected_agent_cfg.get('agent_type') or 'local').lower()
+ global_selected_agent_cfg['agent_type'] = agent_type
+ if agent_type == 'local':
+ kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global")
+ else:
+ log_event(
+ f"[SK Loader] Unsupported agent_type '{agent_type}' for global agent '{global_selected_agent_cfg.get('name')}'. Defaulting to local path.",
+ level=logging.WARNING,
+ extra={'agent_type': agent_type, 'agent_name': global_selected_agent_cfg.get('name')}
+ )
+ kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global")
log_event(f"[SK Loader] load_single_agent_for_kernel returned agent_objs: {type(agent_objs)} with {len(agent_objs) if agent_objs else 0} agents", level=logging.INFO)
else:
log_event("[SK Loader] No global_selected_agent found. Proceeding in kernel-only mode.", level=logging.WARNING)
diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css
index 5ec8fead2..e42c0385e 100644
--- a/application/single_app/static/css/sidebar.css
+++ b/application/single_app/static/css/sidebar.css
@@ -45,6 +45,18 @@ body.has-classification-banner #sidebar-nav {
height: calc(100vh - 40px) !important; /* Adjust height to account for banner */
}
+/* Chats top-nav layout: align the fixed sidebar just below the navbar */
+nav.navbar.fixed-top + #sidebar-nav {
+ top: 66px !important;
+ height: calc(100vh - 66px);
+}
+
+/* Account for classification banner when present */
+body.has-classification-banner nav.navbar + #sidebar-nav {
+ top: 98px !important;
+ height: calc(100vh - 106px);
+}
+
/* Floating expand button positioning when classification banner is present */
body.has-classification-banner #floating-expand-btn {
top: calc(0.5rem + 40px) !important; /* Start below the classification banner */
diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js
index 81df7aa8c..eb5736240 100644
--- a/application/single_app/static/js/agent_modal_stepper.js
+++ b/application/single_app/static/js/agent_modal_stepper.js
@@ -1143,6 +1143,7 @@ export class AgentModalStepper {
try {
// Get agent data from form
const agentData = this.getAgentFormData();
+ agentData.agent_type = (this.originalAgent?.agent_type) || agentData.agent_type || 'local';
// Validate required fields
if (!agentData.display_name || !agentData.name) {
@@ -1195,23 +1196,6 @@ export class AgentModalStepper {
}
});
- // Validate with schema if available
- try {
- if (!window.validateAgent) {
- window.validateAgent = (await import('/static/js/validateAgent.mjs')).default;
- }
- const valid = window.validateAgent(agentData);
- if (!valid) {
- let errorMsg = 'Validation error: Invalid agent data.';
- if (window.validateAgent.errors && window.validateAgent.errors.length) {
- errorMsg += '\n' + window.validateAgent.errors.map(e => `${e.instancePath} ${e.message}`).join('\n');
- }
- throw new Error(errorMsg);
- }
- } catch (e) {
- console.warn('Schema validation failed:', e.message);
- }
-
// Use appropriate endpoint and save method based on context
let saveBtn = document.getElementById('agent-modal-save-btn');
const originalText = saveBtn.innerHTML;
@@ -1252,7 +1236,8 @@ export class AgentModalStepper {
model: document.getElementById('agent-global-model-select')?.value || '',
custom_connection: document.getElementById('agent-custom-connection')?.checked || false,
other_settings: document.getElementById('agent-additional-settings')?.value || '{}',
- max_completion_tokens: parseInt(document.getElementById('agent-max-completion-tokens')?.value.trim()) || null
+ max_completion_tokens: parseInt(document.getElementById('agent-max-completion-tokens')?.value.trim()) || null,
+ agent_type: 'local'
};
// Handle model and deployment configuration
diff --git a/application/single_app/static/js/agents_common.js b/application/single_app/static/js/agents_common.js
index f906ace8a..8ae1333be 100644
--- a/application/single_app/static/js/agents_common.js
+++ b/application/single_app/static/js/agents_common.js
@@ -98,7 +98,8 @@ export function getAgentModalFields(opts = {}) {
instructions: root.getElementById('agent-instructions').value.trim(),
max_completion_tokens: parseInt(root.getElementById('agent-max-completion-tokens').value.trim()) || null,
actions_to_load: actions_to_load,
- other_settings: additionalSettings
+ other_settings: additionalSettings,
+ agent_type: (opts.agent && opts.agent.agent_type) || 'local'
};
}
/**
@@ -533,6 +534,16 @@ export function populateAgentSelect(selectEl, agents, selectedAgentObj) {
console.log(`DEBUG: Agent ${index}: name="${agent.name}", is_global=${agent.is_global}, is_group=${agent.is_group}, display_name="${agent.display_name}"`);
});
+ const getDisplayLabel = (agent) => (agent.display_name || agent.displayName || agent.name || '').trim();
+ const displayLabelCounts = agents.reduce((acc, agent) => {
+ const label = getDisplayLabel(agent).toLowerCase();
+ if (!label) {
+ return acc;
+ }
+ acc[label] = (acc[label] || 0) + 1;
+ return acc;
+ }, {});
+
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;
@@ -546,8 +557,17 @@ export function populateAgentSelect(selectEl, agents, selectedAgentObj) {
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 || '';
+ const displayLabel = getDisplayLabel(agent);
+ const labelKey = displayLabel.toLowerCase();
+ const hasDuplicateLabel = labelKey && displayLabelCounts[labelKey] > 1;
+ let labelSuffix = '';
+ if (agent.is_group) {
+ if (hasDuplicateLabel) {
+ labelSuffix = ` (Group${groupName ? `: ${groupName}` : ''})`;
+ }
+ } else if (agent.is_global) {
+ labelSuffix = ' (Global)';
+ }
opt.textContent = `${displayLabel}${labelSuffix}`;
opt.dataset.name = agent.name || '';
opt.dataset.displayName = displayLabel;
diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js
index bfbba5c6b..a1d6f70ba 100644
--- a/application/single_app/static/js/chat/chat-sidebar-conversations.js
+++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js
@@ -50,6 +50,19 @@ function createSidebarConversationItem(convo) {
const convoItem = document.createElement("div");
convoItem.classList.add("sidebar-conversation-item");
convoItem.setAttribute("data-conversation-id", convo.id);
+ if (convo.chat_type) {
+ convoItem.setAttribute("data-chat-type", convo.chat_type);
+ }
+ let groupName = null;
+ if (Array.isArray(convo.context)) {
+ const primaryGroupContext = convo.context.find(ctx => ctx.type === "primary" && ctx.scope === "group");
+ if (primaryGroupContext) {
+ groupName = primaryGroupContext.name || null;
+ }
+ }
+ if (groupName) {
+ convoItem.setAttribute("data-group-name", groupName);
+ }
convoItem.innerHTML = `
@@ -67,6 +80,32 @@ function createSidebarConversationItem(convo) {