diff --git a/application/single_app/config.py b/application/single_app/config.py index 32e453797..ed085aed9 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.202" +VERSION = "0.250.203" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index a2bdfc9e4..ecf6afa39 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -32,6 +32,7 @@ from functions_authentication import * from functions_debug import * from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from functions_model_endpoint_runtime import MODEL_ENDPOINT_PROVIDER_ALLOWLIST, build_model_endpoint_sync_chat_client import azure.cognitiveservices.speech as speechsdk @@ -126,18 +127,31 @@ def _resolve_model_endpoint_scope(provider, auth_settings, endpoint=None): return "https://ai.azure.com/.default" -def _build_model_endpoint_client(auth_settings, provider, endpoint, api_version, deployment_name): +def _build_model_endpoint_client( + auth_settings, + provider, + endpoint, + api_version, + deployment_name, + *, + settings=None, + endpoint_config=None, + identity_context=None, +): client, _ = build_model_endpoint_sync_chat_client( auth_settings, provider, endpoint, api_version, deployment_name=deployment_name, + settings=settings, + endpoint_config=endpoint_config, + identity_context=identity_context, ) return client -def _resolve_metadata_extraction_client(settings): +def _resolve_metadata_extraction_client(settings, identity_context=None): selection = _normalize_model_endpoint_selection(settings.get("metadata_extraction_model_selection")) if ( @@ -178,17 +192,28 @@ def _resolve_metadata_extraction_client(settings): if not endpoint or not api_version or not deployment: raise ValueError("Selected metadata extraction endpoint is missing endpoint, API version, or deployment configuration.") - return _build_model_endpoint_client(auth_settings, provider, endpoint, api_version, deployment), deployment + return _build_model_endpoint_client( + auth_settings, + provider, + endpoint, + api_version, + deployment, + settings=settings, + endpoint_config=endpoint_cfg, + identity_context=identity_context, + ), deployment gpt_model = settings.get('metadata_extraction_model') if not gpt_model: raise ValueError("No metadata extraction model is selected.") + extra_headers = build_model_endpoint_identity_headers(settings, identity_context=identity_context) if settings.get('enable_gpt_apim', False): return AzureOpenAI( api_version=settings.get('azure_apim_gpt_api_version'), azure_endpoint=settings.get('azure_apim_gpt_endpoint'), - api_key=settings.get('azure_apim_gpt_subscription_key') + api_key=settings.get('azure_apim_gpt_subscription_key'), + default_headers=extra_headers or None, ), gpt_model if settings.get('azure_openai_gpt_authentication_type') == 'managed_identity': @@ -199,13 +224,15 @@ def _resolve_metadata_extraction_client(settings): return AzureOpenAI( api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), - azure_ad_token_provider=token_provider + azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ), gpt_model return AzureOpenAI( api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), - api_key=settings.get('azure_openai_gpt_key') + api_key=settings.get('azure_openai_gpt_key'), + default_headers=extra_headers or None, ), gpt_model @@ -4539,7 +4566,10 @@ def extract_document_metadata(document_id, user_id, group_id=None, public_worksp # --- Step 5: Prepare GPT Client --- try: - gpt_client, gpt_model = _resolve_metadata_extraction_client(settings) + gpt_client, gpt_model = _resolve_metadata_extraction_client( + settings, + identity_context={'user_id': user_id}, + ) except Exception as e: add_file_task_to_file_processing_log( document_id=document_id, diff --git a/application/single_app/functions_model_endpoint_identity_header.py b/application/single_app/functions_model_endpoint_identity_header.py new file mode 100644 index 000000000..25d1e506d --- /dev/null +++ b/application/single_app/functions_model_endpoint_identity_header.py @@ -0,0 +1,241 @@ +# functions_model_endpoint_identity_header.py +"""Identity header helpers for configured model endpoint calls.""" + +import hashlib +import hmac +import os +import re +from typing import Any, Dict + +from flask import current_app, has_app_context, has_request_context, session + + +DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME = "x-simplechat-identity-key" +DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE = "user_oid_tenant_id" +MODEL_ENDPOINT_IDENTITY_HEADER_MODES = ("inherit", "enabled", "disabled") +MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPES = ( + "user_upn", + "user_upn_tenant_id", + "user_oid", + "user_oid_tenant_id", +) +MODEL_ENDPOINT_IDENTITY_HEADER_SETTING_KEYS = ( + "model_endpoint_identity_header_enabled", + "model_endpoint_identity_header_name", + "model_endpoint_identity_header_value_type", + "model_endpoint_identity_header_hmac_secret", +) + +_HEADER_NAME_PATTERN = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]{1,128}$") +_RESERVED_HEADER_NAMES = { + "accept", + "anthropic-version", + "api-key", + "authorization", + "connection", + "content-length", + "content-type", + "host", + "ocp-apim-subscription-key", + "openai-organization", + "openai-project", + "proxy-authorization", + "x-api-key", +} + + +def normalize_model_endpoint_identity_header_name(value: Any, fallback: str = "") -> str: + """Return a safe custom identity header name or the supplied fallback.""" + header_name = str(value or "").strip() + if _is_safe_identity_header_name(header_name): + return header_name + + fallback_name = str(fallback or "").strip() + if fallback_name and _is_safe_identity_header_name(fallback_name): + return fallback_name + return "" + + +def normalize_model_endpoint_identity_header_value_type( + value: Any, + fallback: str = DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE, +) -> str: + """Return a supported identity header value type.""" + normalized_value = str(value or "").strip().lower() + if normalized_value in MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPES: + return normalized_value + if fallback == "": + return "" + if fallback in MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPES: + return fallback + return DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE + + +def normalize_model_endpoint_identity_header_override(value: Any) -> Dict[str, str]: + """Normalize a per-endpoint identity header override payload.""" + override = value if isinstance(value, dict) else {} + raw_mode = str(override.get("mode") or "").strip().lower() + if not raw_mode: + if override.get("enabled") is True: + raw_mode = "enabled" + elif override.get("enabled") is False: + raw_mode = "disabled" + + mode = raw_mode if raw_mode in MODEL_ENDPOINT_IDENTITY_HEADER_MODES else "inherit" + header_name = normalize_model_endpoint_identity_header_name( + override.get("header_name") or override.get("name"), + ) + value_type = normalize_model_endpoint_identity_header_value_type(override.get("value_type"), fallback="") + + return { + "mode": mode, + "header_name": header_name, + "value_type": value_type, + } + + +def resolve_model_endpoint_identity_context(identity_context: Any = None) -> Dict[str, str]: + """Resolve user identity values from the request session and explicit context.""" + context: Dict[str, str] = {} + if has_request_context(): + session_user = session.get("user") if isinstance(session.get("user"), dict) else {} + _merge_identity_context(context, session_user, override=True) + + if isinstance(identity_context, dict): + _merge_identity_context(context, identity_context, override=False) + elif identity_context not in (None, ""): + _set_if_present(context, "user_oid", identity_context, override=False) + + return context + + +def resolve_effective_model_endpoint_identity_header_config( + settings: Any, + endpoint_config: Any = None, +) -> Dict[str, Any]: + """Resolve global identity header settings with a per-endpoint override.""" + source_settings = settings if isinstance(settings, dict) else {} + endpoint = endpoint_config if isinstance(endpoint_config, dict) else {} + endpoint_override = normalize_model_endpoint_identity_header_override(endpoint.get("identity_header")) + + global_enabled = source_settings.get("model_endpoint_identity_header_enabled") is True + global_header_name = normalize_model_endpoint_identity_header_name( + source_settings.get("model_endpoint_identity_header_name"), + fallback=DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, + ) + global_value_type = normalize_model_endpoint_identity_header_value_type( + source_settings.get("model_endpoint_identity_header_value_type"), + ) + + mode = endpoint_override["mode"] + enabled = global_enabled + if mode == "enabled": + enabled = True + elif mode == "disabled": + enabled = False + + return { + "enabled": enabled, + "header_name": endpoint_override["header_name"] or global_header_name, + "value_type": endpoint_override["value_type"] or global_value_type, + "hmac_secret": str( + source_settings.get("model_endpoint_identity_header_hmac_secret") + or _get_fallback_hmac_secret() + ), + } + + +def build_model_endpoint_identity_headers( + settings: Any, + endpoint_config: Any = None, + identity_context: Any = None, +) -> Dict[str, str]: + """Build the configured model endpoint identity header, if enabled and resolvable.""" + effective_config = resolve_effective_model_endpoint_identity_header_config( + settings, + endpoint_config=endpoint_config, + ) + if effective_config.get("enabled") is not True: + return {} + + header_name = normalize_model_endpoint_identity_header_name(effective_config.get("header_name")) + if not header_name: + return {} + + identity = resolve_model_endpoint_identity_context(identity_context) + canonical_identity = _build_canonical_identity( + identity, + str(effective_config.get("value_type") or DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE), + ) + if not canonical_identity: + return {} + + secret = str(effective_config.get("hmac_secret") or "") + if not secret: + return {} + + digest = hmac.new( + secret.encode("utf-8"), + canonical_identity.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return {header_name: digest} + + +def _is_safe_identity_header_name(header_name: str) -> bool: + normalized_header_name = str(header_name or "").strip() + return ( + bool(normalized_header_name) + and bool(_HEADER_NAME_PATTERN.fullmatch(normalized_header_name)) + and normalized_header_name.lower() not in _RESERVED_HEADER_NAMES + ) + + +def _get_fallback_hmac_secret() -> str: + if has_app_context(): + return str(current_app.config.get("SECRET_KEY") or "") + return str(os.environ.get("SECRET_KEY") or "") + + +def _merge_identity_context(target: Dict[str, str], source: Dict[str, Any], *, override: bool) -> None: + _set_if_present( + target, + "user_oid", + source.get("user_oid") or source.get("oid") or source.get("sub") or source.get("user_id") or source.get("userId"), + override=override, + ) + _set_if_present( + target, + "user_upn", + source.get("user_upn") or source.get("upn") or source.get("preferred_username") or source.get("email"), + override=override, + ) + _set_if_present( + target, + "tenant_id", + source.get("tenant_id") or source.get("tid") or source.get("tenantId"), + override=override, + ) + + +def _set_if_present(target: Dict[str, str], key: str, value: Any, *, override: bool) -> None: + normalized_value = str(value or "").strip() + if normalized_value and (override or not target.get(key)): + target[key] = normalized_value + + +def _build_canonical_identity(identity: Dict[str, str], value_type: str) -> str: + normalized_value_type = normalize_model_endpoint_identity_header_value_type(value_type) + user_oid = str(identity.get("user_oid") or "").strip().lower() + user_upn = str(identity.get("user_upn") or "").strip().lower() + tenant_id = str(identity.get("tenant_id") or "").strip().lower() + + if normalized_value_type == "user_upn" and user_upn: + return f"{normalized_value_type}:{user_upn}" + if normalized_value_type == "user_upn_tenant_id" and user_upn and tenant_id: + return f"{normalized_value_type}:{user_upn}|{tenant_id}" + if normalized_value_type == "user_oid" and user_oid: + return f"{normalized_value_type}:{user_oid}" + if normalized_value_type == "user_oid_tenant_id" and user_oid and tenant_id: + return f"{normalized_value_type}:{user_oid}|{tenant_id}" + return "" diff --git a/application/single_app/functions_model_endpoint_runtime.py b/application/single_app/functions_model_endpoint_runtime.py index b1cf5449f..4deb4fa7c 100644 --- a/application/single_app/functions_model_endpoint_runtime.py +++ b/application/single_app/functions_model_endpoint_runtime.py @@ -7,6 +7,7 @@ from config import cognitive_services_scope from foundry_agent_runtime import resolve_authority +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from functions_settings import resolve_model_endpoint_foundry_scope from model_endpoint_clients import ( MODEL_ENDPOINT_PROTOCOL_ANTHROPIC, @@ -33,6 +34,15 @@ ) +def _build_azure_chat_completion(default_headers=None, **kwargs): + if not default_headers: + return AzureChatCompletion(**kwargs) + try: + return AzureChatCompletion(default_headers=default_headers, **kwargs) + except TypeError: + return AzureChatCompletion(**kwargs) + + def sanitize_model_endpoint_auth_for_context(auth_settings): """Return non-secret auth metadata that can be persisted with background work.""" if not isinstance(auth_settings, dict): @@ -114,9 +124,18 @@ def build_model_endpoint_sync_chat_client( endpoint, api_version, deployment_name='', + *, + settings=None, + endpoint_config=None, + identity_context=None, ): """Create a protocol-aware synchronous chat client for a configured model endpoint.""" auth_settings = auth_settings or {} + extra_headers = build_model_endpoint_identity_headers( + settings, + endpoint_config=endpoint_config, + identity_context=identity_context, + ) normalized_provider = str(provider or 'aoai').strip().lower() runtime_protocol = infer_model_endpoint_protocol(normalized_provider, endpoint, deployment_name) auth_type = str(auth_settings.get('type') or 'managed_identity').strip().lower() @@ -126,13 +145,23 @@ def build_model_endpoint_sync_chat_client( if not api_key: raise ValueError('Selected model endpoint is missing an API key.') if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: - return build_anthropic_chat_client(endpoint=endpoint, api_key=api_key), runtime_protocol + return build_anthropic_chat_client( + endpoint=endpoint, + api_key=api_key, + extra_headers=extra_headers, + ), runtime_protocol if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: - return build_openai_style_chat_client(api_key, endpoint, api_version), runtime_protocol + return build_openai_style_chat_client( + api_key, + endpoint, + api_version, + default_headers=extra_headers, + ), runtime_protocol return AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, api_key=api_key, + default_headers=extra_headers or None, ), runtime_protocol credential = resolve_credential_for_model_endpoint_auth(auth_settings) @@ -142,17 +171,27 @@ def build_model_endpoint_sync_chat_client( if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: token = credential.get_token(scope).token - return build_anthropic_chat_client(endpoint=endpoint, bearer_token=token), runtime_protocol + return build_anthropic_chat_client( + endpoint=endpoint, + bearer_token=token, + extra_headers=extra_headers, + ), runtime_protocol if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: token = credential.get_token(scope).token - return build_openai_style_chat_client(token, endpoint, api_version), runtime_protocol + return build_openai_style_chat_client( + token, + endpoint, + api_version, + default_headers=extra_headers, + ), runtime_protocol token_provider = get_bearer_token_provider(credential, scope) return AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ), runtime_protocol @@ -294,6 +333,11 @@ def build_semantic_kernel_chat_service_for_model( if provider and endpoint and deployment_name: runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment_name) auth_type = str(auth_settings.get('type') or 'managed_identity').lower() + extra_headers = build_model_endpoint_identity_headers( + settings, + endpoint_config=resolved_model_endpoint, + identity_context=model_context, + ) if auth_type in ('api_key', 'key'): api_key = auth_settings.get('api_key') if not api_key: @@ -304,6 +348,7 @@ def build_semantic_kernel_chat_service_for_model( deployment_name=deployment_name, endpoint=endpoint, api_key=api_key, + extra_headers=extra_headers, ), runtime_protocol if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: request_api_version = resolve_openai_style_request_api_version(api_version) @@ -311,6 +356,8 @@ def build_semantic_kernel_chat_service_for_model( 'api_key': api_key, 'base_url': normalize_openai_style_base_url(endpoint), } + if extra_headers: + client_kwargs['default_headers'] = extra_headers if request_api_version: client_kwargs['default_query'] = {'api-version': request_api_version} return OpenAIChatCompletion( @@ -318,12 +365,13 @@ def build_semantic_kernel_chat_service_for_model( ai_model_id=deployment_name, async_client=AsyncOpenAI(**client_kwargs), ), runtime_protocol - return AzureChatCompletion( + return _build_azure_chat_completion( service_id=service_id, deployment_name=deployment_name, endpoint=endpoint, api_key=api_key, api_version=api_version, + default_headers=extra_headers, ), runtime_protocol credential = resolve_credential_for_model_endpoint_auth(auth_settings) @@ -338,6 +386,7 @@ def build_semantic_kernel_chat_service_for_model( deployment_name=deployment_name, endpoint=endpoint, bearer_token=token, + extra_headers=extra_headers, ), runtime_protocol if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: @@ -347,6 +396,8 @@ def build_semantic_kernel_chat_service_for_model( 'api_key': token, 'base_url': normalize_openai_style_base_url(endpoint), } + if extra_headers: + client_kwargs['default_headers'] = extra_headers if request_api_version: client_kwargs['default_query'] = {'api-version': request_api_version} return OpenAIChatCompletion( @@ -357,56 +408,63 @@ def build_semantic_kernel_chat_service_for_model( token_provider = get_bearer_token_provider(credential, scope) try: - return AzureChatCompletion( + return _build_azure_chat_completion( service_id=service_id, deployment_name=deployment_name, endpoint=endpoint, api_version=api_version, azure_ad_token_provider=token_provider, + default_headers=extra_headers, ), runtime_protocol except TypeError: - return AzureChatCompletion( + return _build_azure_chat_completion( service_id=service_id, deployment_name=deployment_name, endpoint=endpoint, api_version=api_version, ad_token_provider=token_provider, + default_headers=extra_headers, ), runtime_protocol + extra_headers = build_model_endpoint_identity_headers(settings, identity_context=model_context) enable_gpt_apim = settings.get('enable_gpt_apim', False) if enable_gpt_apim: - return AzureChatCompletion( + return _build_azure_chat_completion( service_id=service_id, deployment_name=gpt_model, endpoint=settings.get('azure_apim_gpt_endpoint'), api_key=settings.get('azure_apim_gpt_subscription_key'), api_version=settings.get('azure_apim_gpt_api_version'), + default_headers=extra_headers, ), MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI auth_type = settings.get('azure_openai_gpt_authentication_type') if auth_type == 'managed_identity': token_provider = get_bearer_token_provider(DefaultAzureCredential(), cognitive_services_scope) try: - return AzureChatCompletion( + return _build_azure_chat_completion( service_id=service_id, deployment_name=gpt_model, endpoint=settings.get('azure_openai_gpt_endpoint'), api_version=settings.get('azure_openai_gpt_api_version'), azure_ad_token_provider=token_provider, + default_headers=extra_headers, ), MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI except TypeError: - return AzureChatCompletion( + return _build_azure_chat_completion( service_id=service_id, deployment_name=gpt_model, endpoint=settings.get('azure_openai_gpt_endpoint'), api_version=settings.get('azure_openai_gpt_api_version'), ad_token_provider=token_provider, + default_headers=extra_headers, ), MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI - return AzureChatCompletion( + return _build_azure_chat_completion( service_id=service_id, deployment_name=gpt_model, endpoint=settings.get('azure_openai_gpt_endpoint'), api_key=settings.get('azure_openai_gpt_key'), api_version=settings.get('azure_openai_gpt_api_version'), + default_headers=extra_headers, ), MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI diff --git a/application/single_app/functions_search_service.py b/application/single_app/functions_search_service.py index e18f5583d..69cc22a59 100644 --- a/application/single_app/functions_search_service.py +++ b/application/single_app/functions_search_service.py @@ -22,6 +22,7 @@ from functions_debug import debug_print from functions_documents import get_document_record, get_ordered_document_chunks from functions_group import get_user_groups +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings from functions_search import ( SEARCH_DEFAULT_TOP_N, @@ -928,12 +929,17 @@ def _render_window_source_text(window_payload): return "\n\n".join(source_parts) -def _create_summary_client(settings): +def _create_summary_client(settings, user_id=None): + extra_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': user_id}, + ) if settings.get('enable_gpt_apim', False): return AzureOpenAI( api_version=settings.get('azure_apim_gpt_api_version'), azure_endpoint=settings.get('azure_apim_gpt_endpoint'), api_key=settings.get('azure_apim_gpt_subscription_key'), + default_headers=extra_headers or None, ) auth_type = settings.get('azure_openai_gpt_authentication_type', 'key') @@ -946,12 +952,14 @@ def _create_summary_client(settings): api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ) return AzureOpenAI( api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), api_key=settings.get('azure_openai_gpt_key'), + default_headers=extra_headers or None, ) @@ -1082,7 +1090,7 @@ def summarize_document_content( settings = get_settings() model_name = _resolve_summary_model(settings) - gpt_client = _create_summary_client(settings) + gpt_client = _create_summary_client(settings, user_id=user_id) reduction_batch_size = _coerce_positive_int( reduction_batch_size, SUMMARY_DEFAULT_REDUCTION_BATCH_SIZE, diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index ce864e130..b0b8c32da 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -13,6 +13,13 @@ from functions_document_actions import get_default_document_action_capabilities from functions_icon_utils import normalize_icon_payload from functions_latest_features_nav import LATEST_FEATURES_HIDDEN_VERSION_SETTING +from functions_model_endpoint_identity_header import ( + DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, + DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE, + normalize_model_endpoint_identity_header_name, + normalize_model_endpoint_identity_header_override, + normalize_model_endpoint_identity_header_value_type, +) from functions_mcp_server_config import INBOUND_MCP_SETTINGS_DEFAULTS, normalize_inbound_mcp_settings from functions_service_health import get_default_service_health import app_settings_cache @@ -20,6 +27,7 @@ import copy import os import json +import secrets import uuid from support_menu_config import ( get_default_support_latest_features_visibility, @@ -83,6 +91,7 @@ "azure_document_intelligence_key", "azure_apim_document_intelligence_subscription_key", "speech_service_key", + "model_endpoint_identity_header_hmac_secret", ) ADMIN_SETTINGS_NESTED_SECRET_FIELDS = ( "web_search_agent.other_settings.azure_ai_foundry.client_secret", @@ -305,6 +314,36 @@ def normalize_key_vault_reminder_settings(settings): return changed +def normalize_model_endpoint_identity_header_settings(settings): + """Normalize model endpoint identity header settings in-place.""" + if not isinstance(settings, dict): + return False + + hmac_secret = str(settings.get("model_endpoint_identity_header_hmac_secret") or "").strip() + if not hmac_secret: + hmac_secret = secrets.token_urlsafe(48) + + normalized_values = { + "model_endpoint_identity_header_enabled": settings.get("model_endpoint_identity_header_enabled") is True, + "model_endpoint_identity_header_name": normalize_model_endpoint_identity_header_name( + settings.get("model_endpoint_identity_header_name"), + fallback=DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, + ), + "model_endpoint_identity_header_value_type": normalize_model_endpoint_identity_header_value_type( + settings.get("model_endpoint_identity_header_value_type") + or DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE, + ), + "model_endpoint_identity_header_hmac_secret": hmac_secret, + } + + changed = False + for key, normalized_value in normalized_values.items(): + if settings.get(key) != normalized_value: + settings[key] = normalized_value + changed = True + return changed + + def redact_admin_settings_secrets_for_form(settings): redacted_settings = copy.deepcopy(settings or {}) for field_name in ADMIN_SETTINGS_FORM_SECRET_FIELDS: @@ -1073,7 +1112,6 @@ def normalize_tabular_parity_durable_preflight_defaults(settings): def get_settings(use_cosmos=False, include_source=False): - import secrets default_settings = { # External health check 'enable_external_healthcheck': False, @@ -1252,6 +1290,10 @@ def get_settings(use_cosmos=False, include_source=False): }, 'enable_multi_model_endpoints': False, 'model_endpoints': [], + 'model_endpoint_identity_header_enabled': False, + 'model_endpoint_identity_header_name': DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, + 'model_endpoint_identity_header_value_type': DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_VALUE_TYPE, + 'model_endpoint_identity_header_hmac_secret': secrets.token_urlsafe(48), 'default_model_selection': { 'endpoint_id': '', 'model_id': '', @@ -1787,6 +1829,7 @@ def _format_result(settings_payload, source): inbound_mcp_settings_updated = normalize_inbound_mcp_settings(merged) public_workspace_display_settings_updated = normalize_public_workspace_display_settings(merged) key_vault_reminder_settings_updated = normalize_key_vault_reminder_settings(merged) + model_endpoint_identity_header_settings_updated = normalize_model_endpoint_identity_header_settings(merged) tabular_parity_durable_preflight_settings_updated = normalize_tabular_parity_durable_preflight_defaults(merged) merged['enable_tabular_processing_plugin'] = is_tabular_processing_enabled(merged) @@ -1802,6 +1845,7 @@ def _format_result(settings_payload, source): or inbound_mcp_settings_updated or public_workspace_display_settings_updated or key_vault_reminder_settings_updated + or model_endpoint_identity_header_settings_updated or tabular_parity_durable_preflight_settings_updated ): cosmos_settings_container.upsert_item(merged) @@ -1856,6 +1900,7 @@ def update_settings(new_settings): normalize_inbound_mcp_settings(settings_item) normalize_public_workspace_display_settings(settings_item) normalize_key_vault_reminder_settings(settings_item) + normalize_model_endpoint_identity_header_settings(settings_item) settings_item['enable_multi_model_endpoints'] = coerce_multi_model_endpoint_enablement( existing_multi_endpoint_enabled, settings_item.get('enable_multi_model_endpoints', False), @@ -2326,6 +2371,10 @@ def normalize_model_endpoints(endpoints): endpoint_copy.pop("has_api_key", None) endpoint_copy.pop("has_client_secret", None) connection = endpoint_copy.get("connection") or {} + identity_header = normalize_model_endpoint_identity_header_override(endpoint_copy.get("identity_header")) + if endpoint_copy.get("identity_header") != identity_header: + endpoint_copy["identity_header"] = identity_header + changed = True if not endpoint_copy.get("id"): fallback_id = endpoint_copy.get("name") or connection.get("endpoint") diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 79edf69f9..1eab103f1 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -149,6 +149,7 @@ from model_endpoint_clients import ( MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI, ) +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from functions_model_endpoint_runtime import ( build_model_endpoint_sync_chat_client, build_semantic_kernel_chat_service_for_model, @@ -5761,12 +5762,16 @@ def _build_multi_endpoint_client(user_id, endpoint_id, model_id, settings, group endpoint, api_version, deployment_name=deployment_name, + settings=settings, + endpoint_config=resolved_endpoint, + identity_context={'user_id': user_id}, ) return client, deployment_name, provider -def _build_legacy_default_client(settings): +def _build_legacy_default_client(settings, identity_context=None): + extra_headers = build_model_endpoint_identity_headers(settings, identity_context=identity_context) if settings.get('enable_gpt_apim', False): endpoint = settings.get('azure_apim_gpt_endpoint') deployment_name = settings.get('azure_apim_gpt_deployment') @@ -5776,6 +5781,7 @@ def _build_legacy_default_client(settings): azure_endpoint=endpoint, api_key=api_key, api_version=api_version, + default_headers=extra_headers or None, ) return client, deployment_name, 'aoai' @@ -5792,6 +5798,7 @@ def _build_legacy_default_client(settings): azure_endpoint=endpoint, api_key=api_key, api_version=api_version, + default_headers=extra_headers or None, ) return client, deployment_name, 'aoai' @@ -5809,6 +5816,7 @@ def _build_legacy_default_client(settings): azure_endpoint=endpoint, azure_ad_token_provider=token_provider, api_version=api_version, + default_headers=extra_headers or None, ) return client, deployment_name, 'aoai' @@ -5825,7 +5833,7 @@ def _resolve_model_workflow_client(workflow, settings): return _build_multi_endpoint_client(user_id, endpoint_id, model_id, settings, group_id=group_id) if legacy_model_deployment: - client, _, provider = _build_legacy_default_client(settings) + client, _, provider = _build_legacy_default_client(settings, identity_context={'user_id': user_id}) return client, legacy_model_deployment, provider default_selection = settings.get('default_model_selection', {}) if isinstance(settings, dict) else {} @@ -5834,7 +5842,7 @@ def _resolve_model_workflow_client(workflow, settings): if default_endpoint_id and default_model_id: return _build_multi_endpoint_client(user_id, default_endpoint_id, default_model_id, settings) - return _build_legacy_default_client(settings) + return _build_legacy_default_client(settings, identity_context={'user_id': user_id}) def _chain_activity_callbacks(*callbacks): diff --git a/application/single_app/model_endpoint_clients.py b/application/single_app/model_endpoint_clients.py index c88aa5922..c40f8b9ee 100644 --- a/application/single_app/model_endpoint_clients.py +++ b/application/single_app/model_endpoint_clients.py @@ -231,13 +231,20 @@ def extract_chat_completion_response_text(response: Any) -> str: return normalize_chat_completion_text(getattr(message, "content", None)) -def build_openai_style_chat_client(token_or_key: str, base_url: str, api_version: Any = ""): +def build_openai_style_chat_client( + token_or_key: str, + base_url: str, + api_version: Any = "", + default_headers: Dict[str, str] | None = None, +): """Build an OpenAI-compatible chat client for Foundry data-plane endpoints.""" request_api_version = resolve_openai_style_request_api_version(api_version) client_kwargs: Dict[str, Any] = { "api_key": token_or_key, "base_url": normalize_openai_style_base_url(base_url), } + if default_headers: + client_kwargs["default_headers"] = default_headers if request_api_version: client_kwargs["default_query"] = {"api-version": request_api_version} return OpenAIStyleChatCompletionClient(OpenAI(**client_kwargs)) @@ -261,6 +268,7 @@ def build_anthropic_chat_client( endpoint: str, api_key: str = "", bearer_token: str = "", + extra_headers: Dict[str, str] | None = None, timeout: int = 90, ): """Build a chat-completions-shaped adapter over the Anthropic messages protocol.""" @@ -268,6 +276,7 @@ def build_anthropic_chat_client( endpoint=endpoint, api_key=api_key, bearer_token=bearer_token, + extra_headers=extra_headers, timeout=timeout, ) @@ -275,10 +284,19 @@ def build_anthropic_chat_client( class AnthropicChatCompletionClient: """Adapter that exposes Anthropic messages through chat.completions.create.""" - def __init__(self, *, endpoint: str, api_key: str = "", bearer_token: str = "", timeout: int = 90): + def __init__( + self, + *, + endpoint: str, + api_key: str = "", + bearer_token: str = "", + extra_headers: Dict[str, str] | None = None, + timeout: int = 90, + ): self.endpoint = normalize_anthropic_messages_url(endpoint) self.api_key = api_key self.bearer_token = bearer_token + self.extra_headers = extra_headers or {} self.timeout = timeout self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create)) @@ -313,6 +331,10 @@ def _build_headers(self, *, stream: bool = False) -> Dict[str, str]: headers["x-api-key"] = self.api_key else: raise ValueError("Anthropic model endpoints require an API key or bearer token.") + existing_header_names = {header_name.lower() for header_name in headers} + for header_name, header_value in self.extra_headers.items(): + if header_name.lower() not in existing_header_names: + headers[header_name] = header_value return headers def _build_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: @@ -585,6 +607,7 @@ class AnthropicSemanticKernelChatCompletion(ChatCompletionClientBase): endpoint: str api_key: str = "" bearer_token: str = "" + extra_headers: Dict[str, str] = Field(default_factory=dict) timeout: int = 90 prompt_execution_settings: OpenAIChatPromptExecutionSettings | None = Field(default=None) @@ -596,6 +619,7 @@ def __init__( endpoint: str, api_key: str = "", bearer_token: str = "", + extra_headers: Dict[str, str] | None = None, timeout: int = 90, ): super().__init__( @@ -604,6 +628,7 @@ def __init__( endpoint=endpoint, api_key=api_key, bearer_token=bearer_token, + extra_headers=extra_headers or {}, timeout=timeout, ) @@ -772,6 +797,7 @@ def _build_client(self): endpoint=self.endpoint, api_key=self.api_key, bearer_token=self.bearer_token, + extra_headers=self.extra_headers, timeout=self.timeout, ) diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 72852f358..04d4f58df 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -57,6 +57,7 @@ log_general_admin_action, ) from functions_governance import ensure_governance_access, upsert_item_policy +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers bpa = Blueprint('admin_agents', __name__) bpa.before_request(login_required_blueprint()) @@ -108,11 +109,16 @@ def _resolve_agent_instruction_model(settings): def _create_agent_instruction_client(settings): """Create an Azure OpenAI client from existing app GPT/APIM settings.""" + extra_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': get_current_user_id()}, + ) if settings.get('enable_gpt_apim', False): return AzureOpenAI( api_version=settings.get('azure_apim_gpt_api_version') or settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_apim_gpt_endpoint'), api_key=settings.get('azure_apim_gpt_subscription_key'), + default_headers=extra_headers or None, ) auth_type = str(settings.get('azure_openai_gpt_authentication_type') or 'key').strip().lower() @@ -125,12 +131,14 @@ def _create_agent_instruction_client(settings): api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ) return AzureOpenAI( api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), api_key=settings.get('azure_openai_gpt_key'), + default_headers=extra_headers or None, ) @@ -2001,4 +2009,3 @@ def get_global_agent_settings(include_admin_extras=False, user_id=None, group_id "enable_multi_model_endpoints": effective_multi_flag, "model_endpoints": combined_endpoints, }) - diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 8db8e6b2b..d675d7292 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -31,6 +31,7 @@ infer_model_endpoint_protocol, normalize_chat_completion_text, ) +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from functions_model_endpoint_runtime import ( MODEL_ENDPOINT_PROVIDER_ALLOWLIST, build_model_endpoint_context, @@ -13698,7 +13699,17 @@ def get_foundry_api_version_candidates(primary_version, settings): return unique_candidates -def build_streaming_multi_endpoint_client(auth_settings, provider, endpoint, api_version, deployment_name=''): +def build_streaming_multi_endpoint_client( + auth_settings, + provider, + endpoint, + api_version, + deployment_name='', + *, + settings=None, + endpoint_config=None, + identity_context=None, +): """Create an inference client for a resolved streaming model endpoint.""" client, _ = build_model_endpoint_sync_chat_client( auth_settings, @@ -13706,6 +13717,9 @@ def build_streaming_multi_endpoint_client(auth_settings, provider, endpoint, api endpoint, api_version, deployment_name=deployment_name, + settings=settings, + endpoint_config=endpoint_config, + identity_context=identity_context, ) return client @@ -13923,6 +13937,9 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ endpoint, api_version, deployment_name=deployment, + settings=settings, + endpoint_config=resolved_endpoint_cfg, + identity_context={'user_id': user_id}, ) debug_print( f"[STREAMING][Model Resolution] Resolved {selection_source} multi-endpoint model | " @@ -16274,6 +16291,10 @@ def result_requires_message_reload(result: Any) -> bool: ) try: multi_endpoint_config = None + legacy_identity_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': user_id}, + ) if settings.get('enable_multi_model_endpoints', False): multi_endpoint_config = resolve_streaming_multi_endpoint_gpt_config( settings, @@ -16338,7 +16359,8 @@ def result_requires_message_reload(result: Any) -> bool: gpt_client = AzureOpenAI( api_version=settings.get('azure_apim_gpt_api_version'), azure_endpoint=settings.get('azure_apim_gpt_endpoint'), - api_key=settings.get('azure_apim_gpt_subscription_key') + api_key=settings.get('azure_apim_gpt_subscription_key'), + default_headers=legacy_identity_headers or None ) else: auth_type = settings.get('azure_openai_gpt_authentication_type') @@ -16366,7 +16388,8 @@ def result_requires_message_reload(result: Any) -> bool: gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - azure_ad_token_provider=token_provider + azure_ad_token_provider=token_provider, + default_headers=legacy_identity_headers or None ) else: # Default to API Key api_key = settings.get('azure_openai_gpt_key') @@ -16374,7 +16397,8 @@ def result_requires_message_reload(result: Any) -> bool: gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=api_key + api_key=api_key, + default_headers=legacy_identity_headers or None ) if not gpt_client or not gpt_model: @@ -19527,6 +19551,8 @@ def invoke_gpt_fallback(): gpt_endpoint, candidate, deployment_name=gpt_model, + settings=settings, + identity_context={'user_id': user_id}, ) response = retry_client.chat.completions.create(**api_params) break @@ -20631,6 +20657,10 @@ def build_streaming_capability_usage(): try: streaming_multi_endpoint_config = None + legacy_identity_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': user_id}, + ) if settings.get('enable_multi_model_endpoints', False): streaming_multi_endpoint_config = resolve_streaming_multi_endpoint_gpt_config( settings, @@ -20679,7 +20709,8 @@ def build_streaming_capability_usage(): gpt_client = AzureOpenAI( api_version=gpt_api_version, azure_endpoint=gpt_endpoint, - api_key=settings.get('azure_apim_gpt_subscription_key') + api_key=settings.get('azure_apim_gpt_subscription_key'), + default_headers=legacy_identity_headers or None ) else: auth_type = settings.get('azure_openai_gpt_authentication_type') @@ -20708,13 +20739,15 @@ def build_streaming_capability_usage(): gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - azure_ad_token_provider=token_provider + azure_ad_token_provider=token_provider, + default_headers=legacy_identity_headers or None ) else: gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=settings.get('azure_openai_gpt_key') + api_key=settings.get('azure_openai_gpt_key'), + default_headers=legacy_identity_headers or None ) if not gpt_client or not gpt_model: diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index 9ba537649..68b2b855d 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -40,6 +40,7 @@ is_blob_backed_image_message, is_external_image_url, ) +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from functions_message_artifacts import ( build_message_artifact_payload_map, hydrate_agent_citations_from_artifacts, @@ -1185,8 +1186,17 @@ def _build_summary_model_endpoint_client( endpoint: str, api_version: str, deployment_name: str, + *, + settings: Dict[str, Any] = None, + endpoint_config: Dict[str, Any] = None, + identity_context: Dict[str, Any] = None, ): auth_settings = auth_settings or {} + extra_headers = build_model_endpoint_identity_headers( + settings, + endpoint_config=endpoint_config, + identity_context=identity_context, + ) auth_type = _normalize_summary_model_value(auth_settings.get('type') or 'managed_identity').lower() normalized_provider = _normalize_summary_model_value(provider or 'aoai').lower() runtime_protocol = infer_model_endpoint_protocol(normalized_provider, endpoint, deployment_name) @@ -1196,13 +1206,14 @@ def _build_summary_model_endpoint_client( if not api_key: raise ValueError('Selected summary model endpoint is missing an API key.') if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: - return build_anthropic_chat_client(endpoint=endpoint, api_key=api_key) + return build_anthropic_chat_client(endpoint=endpoint, api_key=api_key, extra_headers=extra_headers) if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: - return build_openai_style_chat_client(api_key, endpoint, api_version) + return build_openai_style_chat_client(api_key, endpoint, api_version, default_headers=extra_headers) return AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, api_key=api_key, + default_headers=extra_headers or None, ) if auth_type == 'service_principal': @@ -1222,17 +1233,18 @@ def _build_summary_model_endpoint_client( if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: token = credential.get_token(scope).token - return build_anthropic_chat_client(endpoint=endpoint, bearer_token=token) + return build_anthropic_chat_client(endpoint=endpoint, bearer_token=token, extra_headers=extra_headers) if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: token = credential.get_token(scope).token - return build_openai_style_chat_client(token, endpoint, api_version) + return build_openai_style_chat_client(token, endpoint, api_version, default_headers=extra_headers) token_provider = get_bearer_token_provider(credential, scope) return AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ) @@ -1318,6 +1330,9 @@ def _resolve_summary_multi_endpoint_client( endpoint, api_version, deployment, + settings=settings, + endpoint_config=resolved_endpoint_cfg, + identity_context={'user_id': user_id}, ) debug_print( f"[SUMMARY][Model Resolution] Resolved {selection_source} multi-endpoint model | " @@ -1353,6 +1368,10 @@ def _initialize_gpt_client( return multi_endpoint_client enable_gpt_apim = settings.get('enable_gpt_apim', False) + extra_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': user_id}, + ) if enable_gpt_apim: raw_models = settings.get('azure_apim_gpt_deployment', '') or '' @@ -1367,7 +1386,8 @@ def _initialize_gpt_client( gpt_client = AzureOpenAI( api_version=settings.get('azure_apim_gpt_api_version'), azure_endpoint=settings.get('azure_apim_gpt_endpoint'), - api_key=settings.get('azure_apim_gpt_subscription_key') + api_key=settings.get('azure_apim_gpt_subscription_key'), + default_headers=extra_headers or None, ) return gpt_client, gpt_model @@ -1388,7 +1408,8 @@ def _initialize_gpt_client( gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - azure_ad_token_provider=token_provider + azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ) else: api_key = settings.get('azure_openai_gpt_key') @@ -1397,7 +1418,8 @@ def _initialize_gpt_client( gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=api_key + api_key=api_key, + default_headers=extra_headers or None, ) return gpt_client, gpt_model diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index b5c50f322..24736ed14 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -10,6 +10,7 @@ build_model_endpoint_sync_chat_client, resolve_model_endpoint_from_context, ) +from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from functions_activity_logging import ( log_admin_feedback_email_submission, log_general_admin_action, @@ -1394,6 +1395,7 @@ def _test_multimodal_vision_connection(payload): multi_endpoint_selection = payload.get('multi_endpoint') if isinstance(payload.get('multi_endpoint'), dict) else None if multi_endpoint_selection: settings = get_settings() + identity_context = {'user_id': get_current_user_id()} model_context = { 'endpoint_id': str(multi_endpoint_selection.get('endpoint_id') or '').strip(), 'model_id': str(multi_endpoint_selection.get('model_id') or '').strip(), @@ -1440,8 +1442,16 @@ def _test_multimodal_vision_connection(payload): connection.get('endpoint'), connection.get('openai_api_version') or connection.get('api_version'), deployment_name=vision_model, + settings=settings, + endpoint_config=resolved_endpoint, + identity_context=identity_context, ) elif enable_apim: + settings = get_settings() + extra_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': get_current_user_id()}, + ) apim_data = payload.get('apim', {}) endpoint = apim_data.get('endpoint') api_version = apim_data.get('api_version') @@ -1450,9 +1460,15 @@ def _test_multimodal_vision_connection(payload): gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=subscription_key + api_key=subscription_key, + default_headers=extra_headers or None, ) else: + settings = get_settings() + extra_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': get_current_user_id()}, + ) direct_data = payload.get('direct', {}) endpoint = direct_data.get('endpoint') api_version = direct_data.get('api_version') @@ -1466,14 +1482,16 @@ def _test_multimodal_vision_connection(payload): gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - azure_ad_token_provider=token_provider + azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ) else: api_key = direct_data.get('key') gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=api_key + api_key=api_key, + default_headers=extra_headers or None, ) # Determine which token parameter to use based on model type @@ -1560,6 +1578,11 @@ def _test_gpt_connection(payload): 'role': 'system', 'content': f"Testing access." } + settings = get_settings() + extra_headers = build_model_endpoint_identity_headers( + settings, + identity_context={'user_id': get_current_user_id()}, + ) # Decide GPT model if enable_apim: @@ -1572,7 +1595,8 @@ def _test_gpt_connection(payload): gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=subscription_key + api_key=subscription_key, + default_headers=extra_headers or None, ) else: direct_data = payload.get('direct', {}) @@ -1586,7 +1610,8 @@ def _test_gpt_connection(payload): gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - azure_ad_token_provider=token_provider + azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ) else: key = direct_data.get('key') @@ -1594,7 +1619,8 @@ def _test_gpt_connection(payload): gpt_client = AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=key + api_key=key, + default_headers=extra_headers or None, ) try: diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 2271b43b9..48f0329d7 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -546,6 +546,7 @@ def admin_settings(): settings['enable_multi_model_endpoints'] = False if 'model_endpoints' not in settings or not isinstance(settings.get('model_endpoints'), list): settings['model_endpoints'] = [] + normalize_model_endpoint_identity_header_settings(settings) if 'default_model_selection' not in settings or not isinstance(settings.get('default_model_selection'), dict): settings['default_model_selection'] = { 'endpoint_id': '', @@ -1618,6 +1619,16 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul existing_multi_endpoints_enabled, requested_enable_multi_model_endpoints, ) + model_endpoint_identity_header_enabled = ( + form_data.get('model_endpoint_identity_header_enabled') == 'on' + ) + model_endpoint_identity_header_name = normalize_model_endpoint_identity_header_name( + form_data.get('model_endpoint_identity_header_name'), + fallback=DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, + ) + model_endpoint_identity_header_value_type = normalize_model_endpoint_identity_header_value_type( + form_data.get('model_endpoint_identity_header_value_type') + ) should_migrate_endpoints = enable_multi_model_endpoints and not existing_multi_endpoints_enabled migration_notice = settings.get('multi_endpoint_migration_notice', { 'enabled': False, @@ -2389,6 +2400,10 @@ def is_valid_url(url): 'gpt_model': gpt_model_obj, 'enable_multi_model_endpoints': enable_multi_model_endpoints, 'model_endpoints': parsed_model_endpoints, + 'model_endpoint_identity_header_enabled': model_endpoint_identity_header_enabled, + 'model_endpoint_identity_header_name': model_endpoint_identity_header_name, + 'model_endpoint_identity_header_value_type': model_endpoint_identity_header_value_type, + 'model_endpoint_identity_header_hmac_secret': settings.get('model_endpoint_identity_header_hmac_secret', ''), 'default_model_selection': normalized_default_model_selection, 'multi_endpoint_migrated_at': migrated_at, 'multi_endpoint_migration_notice': migration_notice, diff --git a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py index 9e6aafeae..64e220959 100644 --- a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py +++ b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py @@ -564,11 +564,13 @@ async def _summarize_large_content(self, content: str, uri: str, page_count: int try: # Import settings and AzureOpenAI here to avoid circular imports from functions_settings import get_settings + from functions_model_endpoint_identity_header import build_model_endpoint_identity_headers from openai import AzureOpenAI from azure.identity import DefaultAzureCredential, get_bearer_token_provider from config import cognitive_services_scope settings = get_settings() + extra_headers = build_model_endpoint_identity_headers(settings) # Set up Azure OpenAI client (similar to functions_documents.py) enable_gpt_apim = settings.get('enable_gpt_apim', False) @@ -583,7 +585,8 @@ async def _summarize_large_content(self, content: str, uri: str, page_count: int gpt_client = AzureOpenAI( api_version=settings.get('azure_apim_gpt_api_version'), azure_endpoint=settings.get('azure_apim_gpt_endpoint'), - api_key=settings.get('azure_apim_gpt_subscription_key') + api_key=settings.get('azure_apim_gpt_subscription_key'), + default_headers=extra_headers or None, ) else: if settings.get('azure_openai_gpt_authentication_type') == 'managed_identity': @@ -594,13 +597,15 @@ async def _summarize_large_content(self, content: str, uri: str, page_count: int gpt_client = AzureOpenAI( api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), - azure_ad_token_provider=token_provider + azure_ad_token_provider=token_provider, + default_headers=extra_headers or None, ) else: gpt_client = AzureOpenAI( api_version=settings.get('azure_openai_gpt_api_version'), azure_endpoint=settings.get('azure_openai_gpt_endpoint'), - api_key=settings.get('azure_openai_gpt_key') + api_key=settings.get('azure_openai_gpt_key'), + default_headers=extra_headers or None, ) # Chunk the content into manageable pieces (about 100k chars each) diff --git a/application/single_app/static/js/admin/admin_model_endpoints.js b/application/single_app/static/js/admin/admin_model_endpoints.js index 8957b647d..d62f67114 100644 --- a/application/single_app/static/js/admin/admin_model_endpoints.js +++ b/application/single_app/static/js/admin/admin_model_endpoints.js @@ -80,6 +80,9 @@ const tenantIdInput = document.getElementById("model-endpoint-tenant-id"); const clientIdInput = document.getElementById("model-endpoint-client-id"); const clientSecretInput = document.getElementById("model-endpoint-client-secret"); const apiKeyInput = document.getElementById("model-endpoint-api-key"); +const endpointIdentityModeSelect = document.getElementById("model-endpoint-identity-mode"); +const endpointIdentityHeaderNameInput = document.getElementById("model-endpoint-identity-header-name"); +const endpointIdentityValueTypeSelect = document.getElementById("model-endpoint-identity-value-type"); const fetchBtn = document.getElementById("model-endpoint-fetch-btn"); const saveBtn = document.getElementById("model-endpoint-save-btn"); @@ -106,6 +109,8 @@ const DEFAULT_AOAI_OPENAI_API_VERSION = "2024-05-01-preview"; const DEFAULT_FOUNDRY_OPENAI_API_VERSION = "v1"; const DEFAULT_FOUNDRY_PROJECT_API_VERSION = "v1"; const CUSTOM_VERSION_VALUE = "custom"; +const IDENTITY_HEADER_MODES = new Set(["inherit", "enabled", "disabled"]); +const IDENTITY_HEADER_VALUE_TYPES = new Set(["", "user_oid_tenant_id", "user_oid", "user_upn_tenant_id", "user_upn"]); const MODEL_ICON_CLASS_PATTERN = /^bi-[a-z0-9][a-z0-9-]{0,80}$/; const MODEL_ICON_CONTROL_CONFIG = Object.freeze({ editor: ".model-icon-editor", @@ -240,6 +245,22 @@ function setSelectedVersionValue(versionInput, customInput, value, fallbackValue syncVersionCustomVisibility(); } +function normalizeEndpointIdentityHeaderOverride(identityHeader) { + const source = identityHeader && typeof identityHeader === "object" ? identityHeader : {}; + const mode = IDENTITY_HEADER_MODES.has(String(source.mode || "").trim().toLowerCase()) + ? String(source.mode || "").trim().toLowerCase() + : "inherit"; + const valueType = IDENTITY_HEADER_VALUE_TYPES.has(String(source.value_type || "").trim().toLowerCase()) + ? String(source.value_type || "").trim().toLowerCase() + : ""; + + return { + mode, + header_name: String(source.header_name || source.name || "").trim(), + value_type: valueType + }; +} + function updateHiddenInput() { if (!endpointsInput) { return; @@ -814,6 +835,9 @@ function resetModal() { if (apiKeyInput) apiKeyInput.value = ""; if (clientSecretInput) clientSecretInput.placeholder = ""; if (apiKeyInput) apiKeyInput.placeholder = ""; + if (endpointIdentityModeSelect) endpointIdentityModeSelect.value = "inherit"; + if (endpointIdentityHeaderNameInput) endpointIdentityHeaderNameInput.value = ""; + if (endpointIdentityValueTypeSelect) endpointIdentityValueTypeSelect.value = ""; modalModels = []; if (modelsListEl) modelsListEl.innerHTML = "
Fetch models to begin selection.
"; @@ -866,6 +890,10 @@ function openModalForEndpoint(endpoint) { apiKeyInput.placeholder = "Stored"; } } + const identityHeader = normalizeEndpointIdentityHeaderOverride(endpoint.identity_header); + if (endpointIdentityModeSelect) endpointIdentityModeSelect.value = identityHeader.mode; + if (endpointIdentityHeaderNameInput) endpointIdentityHeaderNameInput.value = identityHeader.header_name; + if (endpointIdentityValueTypeSelect) endpointIdentityValueTypeSelect.value = identityHeader.value_type; modalModels = Array.isArray(endpoint.models) ? [...endpoint.models] : []; renderModalModels(modalModels); } @@ -1406,6 +1434,11 @@ function buildEndpointPayload() { const resourceGroup = endpointResourceGroupInput?.value.trim() || ""; const authType = endpointAuthTypeSelect?.value || "managed_identity"; const existingEndpoint = modelEndpoints.find((savedEndpoint) => savedEndpoint.id === endpointId); + const identityHeader = normalizeEndpointIdentityHeaderOverride({ + mode: endpointIdentityModeSelect?.value || "inherit", + header_name: endpointIdentityHeaderNameInput?.value || "", + value_type: endpointIdentityValueTypeSelect?.value || "" + }); if (!name || !endpoint || !openAiApiVersion) { showToast("Endpoint name, URL, and OpenAI API version are required.", "warning"); @@ -1487,7 +1520,8 @@ function buildEndpointPayload() { name, connection, management, - auth + auth, + identity_header: identityHeader }; } @@ -1515,6 +1549,7 @@ function saveEndpoint() { auth: payload.auth, connection: payload.connection, management: payload.management, + identity_header: payload.identity_header, models, has_api_key: hasApiKey, has_client_secret: hasClientSecret diff --git a/application/single_app/templates/_multiendpoint_modal.html b/application/single_app/templates/_multiendpoint_modal.html index 34fcafcc0..ed14026bc 100644 --- a/application/single_app/templates/_multiendpoint_modal.html +++ b/application/single_app/templates/_multiendpoint_modal.html @@ -147,6 +147,38 @@+ Override the global model endpoint identity header only when this backend needs different APIM policy behavior. +
++ Send a stable HMAC-hashed user key to model endpoint backends for APIM counters, quota policies, or routing policies. +
+