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 @@
Identity se API key authentication is for inference only. Use a managed identity or service principal for model discovery, or use Add Model and enter the deployment name manually exactly as it appears in Foundry. +
+
Identity Header Override
+

+ Override the global model endpoint identity header only when this backend needs different APIM policy behavior. +

+
+
+ + +
+
+ + +
Leave blank to inherit the global header name.
+
+
+ + +
+
+
+
@@ -251,6 +283,7 @@
Configuration flow
  • For Azure OpenAI discovery, enter subscription ID and resource group.
  • For Foundry providers, enter the project endpoint and project name when the endpoint does not include it.
  • Keep Foundry Project API Version separate from OpenAI API Version. Create separate endpoints when model families require different inference API versions.
  • +
  • Use the model endpoint identity header when APIM needs a stable per-user counter key. The configured value is HMAC-hashed and sent only on inference calls when the selected identity fields are available.
  • Use Fetch Models and Test Model before saving the endpoint.
  • diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index f4d552037..43a00f0ed 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -4904,6 +4904,50 @@
    Agent Default Model Revie {% endif %} +
    +
    +
    +
    Model Endpoint Identity Header
    +

    + Send a stable HMAC-hashed user key to model endpoint backends for APIM counters, quota policies, or routing policies. +

    +
    +
    + + +
    +
    +
    +
    + + +
    Reserved auth/protocol headers cannot be used.
    +
    +
    + + +
    The selected identity is HMAC-hashed before leaving SimpleChat. Missing identity values omit the header.
    +
    +
    +
    +
    diff --git a/docs/explanation/features/MODEL_ENDPOINT_IDENTITY_HEADER.md b/docs/explanation/features/MODEL_ENDPOINT_IDENTITY_HEADER.md new file mode 100644 index 000000000..75911da5b --- /dev/null +++ b/docs/explanation/features/MODEL_ENDPOINT_IDENTITY_HEADER.md @@ -0,0 +1,91 @@ +# Model Endpoint Identity Header + +## Overview + +Implemented in version: **0.250.203** + +The Model Endpoint Identity Header feature lets admins send a stable, non-reversible user identity key with model endpoint requests. This is intended for Azure API Management (APIM) counters, quota policies, routing policies, and similar backend controls that need a consistent per-user key without exposing raw user identifiers. + +## Dependencies + +- Admin Settings > AI Models configuration +- Model endpoint runtime helpers +- Azure OpenAI/OpenAI-compatible endpoint clients +- Optional APIM or custom backend policy that reads the configured header + +## Technical Specifications + +### Settings + +Global settings: + +- `model_endpoint_identity_header_enabled` +- `model_endpoint_identity_header_name` +- `model_endpoint_identity_header_value_type` +- `model_endpoint_identity_header_hmac_secret` + +Per-endpoint override: + +```json +{ + "identity_header": { + "mode": "inherit", + "header_name": "", + "value_type": "" + } +} +``` + +Supported modes: + +- `inherit` +- `enabled` +- `disabled` + +Supported identity values: + +- `user_oid` +- `user_oid_tenant_id` +- `user_upn` +- `user_upn_tenant_id` + +The selected identity string is normalized and HMAC-SHA256 hashed before being added to the outbound model request header. + +### Runtime Coverage + +The header is applied to configured model endpoint calls and legacy GPT/APIM model calls used by chat, workflows, metadata extraction, conversation export summaries, document summarization, agent instruction drafting, and Smart HTTP large-content summarization. + +### Header Guardrails + +Header names are validated before use. Reserved authentication and protocol headers such as `authorization`, `api-key`, `x-api-key`, `ocp-apim-subscription-key`, `content-type`, and `host` are rejected so the identity header cannot override required service credentials or transport metadata. + +## Usage Instructions + +1. Open Admin Settings. +2. Go to AI Models > Model Endpoints. +3. Enable **Model Endpoint Identity Header**. +4. Choose a safe header name, such as `x-simplechat-identity-key`. +5. Choose the identity value used to derive the HMAC key. +6. Optionally edit individual model endpoints and set an Identity Header Override. +7. Configure APIM or backend policy logic to read the selected header. + +If a background or workflow call has an owning user id, SimpleChat uses that owner identity. If the selected identity fields are unavailable, the header is omitted for that request. + +## Testing and Validation + +Functional coverage: + +- `functional_tests/test_model_endpoint_identity_header.py` + +UI coverage: + +- `ui_tests/test_model_endpoint_request_uses_endpoint_id.py` + +Validation confirms: + +- Stable HMAC output for the same normalized identity. +- Raw UPN/OID/tenant values are not sent in the header value. +- Missing required identity fields omit the header. +- Global disablement and endpoint-level disablement omit the header. +- Endpoint-level enablement and identity value overrides work. +- Unsafe header names are rejected. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index fac6a5a63..a2993b7f2 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.203)** + +#### New Features + +* **Model Endpoint Identity Header** + * Added admin controls to send a stable HMAC-hashed user identity key with model endpoint requests for APIM counters, quota policies, and backend routing policies. + * Supports global enablement, custom safe header names, selectable identity inputs, and per-endpoint inherit/enable/disable overrides without exposing raw UPN, object ID, or tenant ID values. + * (Ref: #1250, Model Endpoint Identity Header, `functions_model_endpoint_identity_header.py`, model endpoint runtime, Admin Settings) + ### **(v0.250.202)** #### Bug Fixes diff --git a/functional_tests/test_model_endpoint_identity_header.py b/functional_tests/test_model_endpoint_identity_header.py new file mode 100644 index 000000000..8c8785236 --- /dev/null +++ b/functional_tests/test_model_endpoint_identity_header.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +# test_model_endpoint_identity_header.py +""" +Functional test for model endpoint identity headers. +Version: 0.250.203 +Implemented in: 0.250.203 + +This test ensures configurable model endpoint identity headers produce stable, +non-reversible HMAC values and honor endpoint-level override behavior. +""" + +import hashlib +import hmac +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "application", "single_app")) + +from functions_model_endpoint_identity_header import ( + DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, + build_model_endpoint_identity_headers, + normalize_model_endpoint_identity_header_override, + resolve_effective_model_endpoint_identity_header_config, +) +from test_support.versioning import assert_app_version_at_least + + +IMPLEMENTED_VERSION = "0.250.203" +TEST_SECRET = "test-secret-for-identity-header" + + +def _settings(**overrides): + settings = { + "model_endpoint_identity_header_enabled": True, + "model_endpoint_identity_header_name": DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME, + "model_endpoint_identity_header_value_type": "user_oid_tenant_id", + "model_endpoint_identity_header_hmac_secret": TEST_SECRET, + } + settings.update(overrides) + return settings + + +def _expected_digest(canonical_identity): + return hmac.new( + TEST_SECRET.encode("utf-8"), + canonical_identity.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def test_identity_header_stable_hmac_value(): + """Validate stable HMAC output without leaking raw identity values.""" + print("Testing model endpoint identity header HMAC generation...") + + identity_context = { + "user_id": "User-OID-123", + "tenant_id": "Tenant-456", + "preferred_username": "user@example.com", + } + headers = build_model_endpoint_identity_headers(_settings(), identity_context=identity_context) + header_value = headers[DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME] + + assert header_value == _expected_digest("user_oid_tenant_id:user-oid-123|tenant-456") + assert len(header_value) == 64 + assert "User-OID-123" not in header_value + assert "Tenant-456" not in header_value + assert "user@example.com" not in header_value + + equivalent_headers = build_model_endpoint_identity_headers( + _settings(), + identity_context={ + "oid": "user-oid-123", + "tid": "tenant-456", + "upn": "USER@EXAMPLE.COM", + }, + ) + assert equivalent_headers == headers + + print("Model endpoint identity header HMAC generation passed.") + return True + + +def test_identity_header_disable_and_missing_identity_behavior(): + """Validate disabled settings and incomplete identity values omit the header.""" + print("Testing disabled and incomplete identity behavior...") + + assert build_model_endpoint_identity_headers( + _settings(model_endpoint_identity_header_enabled=False), + identity_context={"user_id": "user-1", "tenant_id": "tenant-1"}, + ) == {} + assert build_model_endpoint_identity_headers( + _settings(model_endpoint_identity_header_value_type="user_oid_tenant_id"), + identity_context={"user_id": "user-1"}, + ) == {} + assert build_model_endpoint_identity_headers( + _settings(model_endpoint_identity_header_hmac_secret=""), + identity_context={"user_id": "user-1", "tenant_id": "tenant-1"}, + ) == {} + + print("Disabled and incomplete identity behavior passed.") + return True + + +def test_identity_header_endpoint_override_behavior(): + """Validate per-endpoint override modes, value types, and header names.""" + print("Testing endpoint identity header override behavior...") + + global_settings = _settings( + model_endpoint_identity_header_name="x-global-identity", + model_endpoint_identity_header_value_type="user_upn_tenant_id", + ) + identity_context = { + "user_id": "user-1", + "tenant_id": "tenant-1", + "preferred_username": "person@example.com", + } + + inherited_headers = build_model_endpoint_identity_headers( + global_settings, + endpoint_config={"identity_header": {"mode": "inherit"}}, + identity_context=identity_context, + ) + assert inherited_headers == { + "x-global-identity": _expected_digest("user_upn_tenant_id:person@example.com|tenant-1") + } + + endpoint_headers = build_model_endpoint_identity_headers( + global_settings, + endpoint_config={ + "identity_header": { + "mode": "enabled", + "header_name": "x-endpoint-identity", + "value_type": "user_oid", + } + }, + identity_context=identity_context, + ) + assert endpoint_headers == { + "x-endpoint-identity": _expected_digest("user_oid:user-1") + } + + disabled_headers = build_model_endpoint_identity_headers( + global_settings, + endpoint_config={"identity_header": {"mode": "disabled"}}, + identity_context=identity_context, + ) + assert disabled_headers == {} + + enabled_from_endpoint = build_model_endpoint_identity_headers( + _settings(model_endpoint_identity_header_enabled=False), + endpoint_config={"identity_header": {"mode": "enabled"}}, + identity_context={"user_id": "user-2", "tenant_id": "tenant-2"}, + ) + assert enabled_from_endpoint == { + DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME: _expected_digest("user_oid_tenant_id:user-2|tenant-2") + } + + print("Endpoint identity header override behavior passed.") + return True + + +def test_identity_header_normalization_guardrails(): + """Validate unsafe names are rejected and blank endpoint values inherit global settings.""" + print("Testing identity header normalization guardrails...") + + override = normalize_model_endpoint_identity_header_override({ + "mode": "inherit", + "header_name": "Authorization", + "value_type": "", + }) + assert override == { + "mode": "inherit", + "header_name": "", + "value_type": "", + } + + effective_config = resolve_effective_model_endpoint_identity_header_config( + _settings( + model_endpoint_identity_header_name="Authorization", + model_endpoint_identity_header_value_type="user_upn", + ), + endpoint_config={"identity_header": {"mode": "inherit", "value_type": ""}}, + ) + assert effective_config["header_name"] == DEFAULT_MODEL_ENDPOINT_IDENTITY_HEADER_NAME + assert effective_config["value_type"] == "user_upn" + + print("Identity header normalization guardrails passed.") + return True + + +def test_app_version_has_identity_header_feature(): + """Validate the app version is at least the implementation version.""" + print("Testing app version marker for model endpoint identity headers...") + + assert_app_version_at_least(IMPLEMENTED_VERSION) + + print("App version marker passed.") + return True + + +if __name__ == "__main__": + tests = [ + test_identity_header_stable_hmac_value, + test_identity_header_disable_and_missing_identity_behavior, + test_identity_header_endpoint_override_behavior, + test_identity_header_normalization_guardrails, + test_app_version_has_identity_header_feature, + ] + results = [] + + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + results.append(test()) + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + results.append(False) + + success = all(results) + print(f"\nResults: {sum(1 for result in results if result)}/{len(results)} tests passed") + sys.exit(0 if success else 1) diff --git a/ui_tests/test_model_endpoint_request_uses_endpoint_id.py b/ui_tests/test_model_endpoint_request_uses_endpoint_id.py index dd5015a9f..418db889f 100644 --- a/ui_tests/test_model_endpoint_request_uses_endpoint_id.py +++ b/ui_tests/test_model_endpoint_request_uses_endpoint_id.py @@ -1,17 +1,18 @@ # test_model_endpoint_request_uses_endpoint_id.py """ UI test for model endpoint request identity wiring. -Version: 0.250.109 -Implemented in: 0.250.003; updated in 0.250.109 +Version: 0.250.203 +Implemented in: 0.250.003; updated in 0.250.203 This test ensures the admin multi-endpoint modal exposes the supported providers, shows the APIM provider guidance, handles Foundry API version selection and project endpoint parsing, exposes setup guidance and model icon picker controls, and sends the endpoint ID in the test-model request payload so the backend can resolve Key Vault-backed secrets. It also validates per-model -response length entry serialization. +response length and identity header override serialization. """ +import json import os from pathlib import Path @@ -68,6 +69,9 @@ def handle_test_request(route): page.locator("#add-model-endpoint-btn").click() expect(page.locator("#modelEndpointModal")).to_be_visible() expect(page.get_by_test_id("model-endpoint-inline-guidance-toggle")).to_be_visible() + expect(page.locator("#model-endpoint-identity-mode")).to_be_visible() + expect(page.locator("#model-endpoint-identity-header-name")).to_be_visible() + expect(page.locator("#model-endpoint-identity-value-type")).to_be_visible() page.get_by_test_id("model-endpoint-inline-guidance-toggle").click() expect(page.locator("#model-endpoint-inline-guidance")).to_be_visible() @@ -113,6 +117,9 @@ def handle_test_request(route): page.locator("#model-endpoint-endpoint").fill("https://example.openai.azure.com") page.locator("#model-endpoint-auth-type").select_option("api_key") page.locator("#model-endpoint-api-key").fill("temporary-ui-secret") + page.locator("#model-endpoint-identity-mode").select_option("enabled") + page.locator("#model-endpoint-identity-header-name").fill("x-test-model-identity") + page.locator("#model-endpoint-identity-value-type").select_option("user_upn_tenant_id") page.locator("#model-endpoint-add-model-btn").click() page.locator("input[data-deployment-name-for]").first.fill("gpt-4o") page.locator("input[data-response-length-for]").first.fill("2048") @@ -135,7 +142,13 @@ def handle_test_request(route): page.locator("#model-endpoint-save-btn").click() endpoint_payload = page.locator("#model_endpoints_json").input_value() + saved_endpoints = json.loads(endpoint_payload) assert '"responseLength":2048' in endpoint_payload + assert saved_endpoints[0]["identity_header"] == { + "mode": "enabled", + "header_name": "x-test-model-identity", + "value_type": "user_upn_tenant_id", + } finally: context.close() browser.close() \ No newline at end of file