diff --git a/application/single_app/config.py b/application/single_app/config.py index 497d32d97..e6c4ac472 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.171" +VERSION = "0.250.172" 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..5150d6afa 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -33,6 +33,11 @@ from functions_debug import * from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper from functions_model_endpoint_runtime import MODEL_ENDPOINT_PROVIDER_ALLOWLIST, build_model_endpoint_sync_chat_client +from functions_model_endpoint_types import ( + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) +from model_endpoint_clients import MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI, infer_model_endpoint_protocol import azure.cognitiveservices.speech as speechsdk _AUDIO_RUNTIME_CAPABILITIES_CACHE = None @@ -126,13 +131,26 @@ 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, + *, + api_type='', + anthropic_version='', + allow_private_custom_endpoints=False, +): client, _ = build_model_endpoint_sync_chat_client( auth_settings, provider, endpoint, api_version, deployment_name=deployment_name, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=allow_private_custom_endpoints, ) return client @@ -169,16 +187,37 @@ def _resolve_metadata_extraction_client(settings): provider = str(endpoint_cfg.get("provider") or selection["provider"] or "aoai").lower() connection = endpoint_cfg.get("connection", {}) or {} auth_settings = endpoint_cfg.get("auth", {}) or {} - deployment = str(model_cfg.get("deploymentName") or model_cfg.get("deployment") or "").strip() + deployment = resolve_model_endpoint_request_model(endpoint_cfg, model_cfg) endpoint = str(connection.get("endpoint") or "").strip() api_version = str(connection.get("openai_api_version") or connection.get("api_version") or "").strip() + api_type = get_model_endpoint_api_type(endpoint_cfg) + anthropic_version = str(connection.get("anthropic_version") or "").strip() + runtime_protocol = infer_model_endpoint_protocol( + provider, + endpoint, + deployment, + api_type, + ) if provider not in MODEL_ENDPOINT_PROVIDER_ALLOWLIST: raise ValueError(f"Selected metadata extraction provider '{provider}' is not supported.") - 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 + if not endpoint or not deployment or ( + runtime_protocol == MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI and not api_version + ): + raise ValueError("Selected metadata extraction endpoint is incomplete.") + + return _build_model_endpoint_client( + auth_settings, + provider, + endpoint, + api_version, + deployment, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=bool( + settings.get("allow_private_custom_model_endpoints", False) + ), + ), deployment gpt_model = settings.get('metadata_extraction_model') if not gpt_model: diff --git a/application/single_app/functions_model_endpoint_runtime.py b/application/single_app/functions_model_endpoint_runtime.py index b1cf5449f..9366d2676 100644 --- a/application/single_app/functions_model_endpoint_runtime.py +++ b/application/single_app/functions_model_endpoint_runtime.py @@ -1,12 +1,19 @@ # functions_model_endpoint_runtime.py """Runtime helpers for configured model endpoint clients and Semantic Kernel services.""" -from openai import AsyncOpenAI, AzureOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI from azure.identity import ClientSecretCredential, DefaultAzureCredential, get_bearer_token_provider from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion, OpenAIChatCompletion from config import cognitive_services_scope from foundry_agent_runtime import resolve_authority +from functions_model_endpoint_types import ( + DEFAULT_ANTHROPIC_VERSION, + MODEL_ENDPOINT_PROVIDER_CUSTOM, + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) +from functions_model_endpoint_validation import validate_custom_model_endpoint_url from functions_settings import resolve_model_endpoint_foundry_scope from model_endpoint_clients import ( MODEL_ENDPOINT_PROTOCOL_ANTHROPIC, @@ -14,14 +21,26 @@ MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE, AnthropicSemanticKernelChatCompletion, build_anthropic_chat_client, + build_custom_openai_async_http_client, + build_custom_openai_sync_http_client, build_openai_style_chat_client, infer_model_endpoint_protocol, + normalize_custom_openai_base_url, normalize_openai_style_base_url, resolve_openai_style_request_api_version, + SanitizedCustomChatCompletionClient, + sanitize_custom_async_openai_client, ) -MODEL_ENDPOINT_PROVIDER_ALLOWLIST = {'aoai', 'aifoundry', 'new_foundry', 'anthropic', 'claude'} +MODEL_ENDPOINT_PROVIDER_ALLOWLIST = { + 'aoai', + 'aifoundry', + 'new_foundry', + 'anthropic', + 'claude', + MODEL_ENDPOINT_PROVIDER_CUSTOM, +} MODEL_CONTEXT_AUTH_FIELDS = ( 'type', 'tenant_id', @@ -52,9 +71,12 @@ def build_model_endpoint_context( endpoint=None, auth=None, api_version=None, + api_type=None, + anthropic_version=None, endpoint_id=None, model_id=None, model_deployment=None, + request_model=None, user_id=None, active_group_ids=None, ): @@ -63,9 +85,12 @@ def build_model_endpoint_context( 'provider': str(provider or '').strip().lower(), 'endpoint': str(endpoint or '').strip(), 'api_version': str(api_version or '').strip(), + 'api_type': str(api_type or '').strip().lower(), + 'anthropic_version': str(anthropic_version or '').strip(), 'endpoint_id': str(endpoint_id or '').strip(), 'model_id': str(model_id or '').strip(), 'model_deployment': str(model_deployment or '').strip(), + 'request_model': str(request_model or model_deployment or '').strip(), } normalized_user_id = str(user_id or '').strip() @@ -114,26 +139,63 @@ def build_model_endpoint_sync_chat_client( endpoint, api_version, deployment_name='', + *, + api_type='', + anthropic_version=DEFAULT_ANTHROPIC_VERSION, + allow_private_custom_endpoints=False, ): """Create a protocol-aware synchronous chat client for a configured model endpoint.""" auth_settings = auth_settings or {} normalized_provider = str(provider or 'aoai').strip().lower() - runtime_protocol = infer_model_endpoint_protocol(normalized_provider, endpoint, deployment_name) + direct_custom = normalized_provider == MODEL_ENDPOINT_PROVIDER_CUSTOM + if direct_custom: + endpoint = validate_custom_model_endpoint_url( + endpoint, + allow_private=allow_private_custom_endpoints, + ) + runtime_protocol = infer_model_endpoint_protocol( + normalized_provider, + endpoint, + deployment_name, + api_type, + ) auth_type = str(auth_settings.get('type') or 'managed_identity').strip().lower() + if direct_custom and auth_type not in ('api_key', 'key'): + raise ValueError('Custom model endpoints support API key authentication only.') if auth_type in ('api_key', 'key'): api_key = auth_settings.get('api_key') 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, + anthropic_version=anthropic_version, + direct_custom=direct_custom, + allow_private_custom_endpoints=allow_private_custom_endpoints, + ), runtime_protocol if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: - return build_openai_style_chat_client(api_key, endpoint, api_version), runtime_protocol - return AzureOpenAI( - api_version=api_version, - azure_endpoint=endpoint, - api_key=api_key, - ), runtime_protocol + return build_openai_style_chat_client( + api_key, + endpoint, + api_version, + direct_custom=direct_custom, + allow_private_custom_endpoints=allow_private_custom_endpoints, + ), runtime_protocol + client_kwargs = { + 'api_version': api_version, + 'azure_endpoint': endpoint, + 'api_key': api_key, + } + if direct_custom: + client_kwargs['http_client'] = build_custom_openai_sync_http_client( + allow_private=allow_private_custom_endpoints, + ) + client = AzureOpenAI(**client_kwargs) + if direct_custom: + client = SanitizedCustomChatCompletionClient(client) + return client, runtime_protocol credential = resolve_credential_for_model_endpoint_auth(auth_settings) scope = cognitive_services_scope @@ -171,11 +233,15 @@ def resolve_model_endpoint_from_context(settings, model_context): model_context = model_context if isinstance(model_context, dict) else {} requested_endpoint_id = str(model_context.get('endpoint_id') or '').strip() requested_model_id = str(model_context.get('model_id') or '').strip() - requested_deployment = str(model_context.get('model_deployment') or '').strip() + requested_model_name = str( + model_context.get('request_model') + or model_context.get('model_deployment') + or '' + ).strip() requested_provider = str(model_context.get('provider') or '').strip().lower() if not settings.get('enable_multi_model_endpoints', False): return None - if not (requested_endpoint_id or requested_model_id or requested_deployment): + if not (requested_endpoint_id or requested_model_id or requested_model_name): return None endpoints = [] @@ -213,11 +279,11 @@ def resolve_model_endpoint_from_context(settings, model_context): models = endpoint_cfg.get('models', []) or [] matched_model = None for model_cfg in models: - deployment = str(model_cfg.get('deploymentName') or model_cfg.get('deployment') or '').strip() if requested_model_id and str(model_cfg.get('id') or '').strip() == requested_model_id: matched_model = model_cfg break - if requested_deployment and deployment == requested_deployment: + request_model = resolve_model_endpoint_request_model(endpoint_cfg, model_cfg) + if requested_model_name and request_model == requested_model_name: matched_model = model_cfg break if not matched_model or not matched_model.get('enabled', True): @@ -257,18 +323,34 @@ def build_semantic_kernel_chat_service_for_model( provider = str(model_context.get('provider') or '').strip().lower() endpoint = str(model_context.get('endpoint') or '').strip() api_version = str(model_context.get('api_version') or '').strip() + api_type = str(model_context.get('api_type') or '').strip().lower() + anthropic_version = str( + model_context.get('anthropic_version') + or DEFAULT_ANTHROPIC_VERSION + ).strip() auth_settings = model_context.get('auth') if isinstance(model_context.get('auth'), dict) else {} - deployment_name = str(model_context.get('model_deployment') or gpt_model or '').strip() + request_model = str( + model_context.get('request_model') + or model_context.get('model_deployment') + or gpt_model + or '' + ).strip() if resolved_model_endpoint: provider = str(resolved_model_endpoint.get('provider') or provider or 'aoai').strip().lower() connection = resolved_model_endpoint.get('connection', {}) or {} endpoint = str(connection.get('endpoint') or endpoint).strip() + api_type = get_model_endpoint_api_type(resolved_model_endpoint) or api_type api_version = str( connection.get('openai_api_version') or connection.get('api_version') or api_version ).strip() + anthropic_version = str( + connection.get('anthropic_version') + or anthropic_version + or DEFAULT_ANTHROPIC_VERSION + ).strip() auth_settings = resolved_model_endpoint.get('auth', {}) or auth_settings resolved_models = resolved_model_endpoint.get('models', []) or [] requested_model_id = str(model_context.get('model_id') or '').strip() @@ -278,22 +360,42 @@ def build_semantic_kernel_chat_service_for_model( (model for model in resolved_models if str(model.get('id') or '').strip() == requested_model_id), None, ) - if matched_model is None and deployment_name: + if matched_model is None and request_model: matched_model = next( ( model for model in resolved_models - if str(model.get('deploymentName') or model.get('deployment') or '').strip() == deployment_name + if resolve_model_endpoint_request_model( + resolved_model_endpoint, + model, + ) == request_model ), None, ) if matched_model: - deployment_name = str( - matched_model.get('deploymentName') or matched_model.get('deployment') or deployment_name - ).strip() + request_model = resolve_model_endpoint_request_model( + resolved_model_endpoint, + matched_model, + ) - if provider and endpoint and deployment_name: - runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment_name) + if provider and endpoint and request_model: + direct_custom = provider == MODEL_ENDPOINT_PROVIDER_CUSTOM + allow_private_custom_endpoints = bool( + settings.get('allow_private_custom_model_endpoints', False) + ) + if direct_custom: + endpoint = validate_custom_model_endpoint_url( + endpoint, + allow_private=allow_private_custom_endpoints, + ) + runtime_protocol = infer_model_endpoint_protocol( + provider, + endpoint, + request_model, + api_type, + ) auth_type = str(auth_settings.get('type') or 'managed_identity').lower() + if direct_custom and auth_type not in ('api_key', 'key'): + raise ValueError('Custom model endpoints support API key authentication only.') if auth_type in ('api_key', 'key'): api_key = auth_settings.get('api_key') if not api_key: @@ -301,26 +403,55 @@ def build_semantic_kernel_chat_service_for_model( if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: return AnthropicSemanticKernelChatCompletion( service_id=service_id, - deployment_name=deployment_name, + deployment_name=request_model, endpoint=endpoint, api_key=api_key, + anthropic_version=anthropic_version, + direct_custom=direct_custom, + allow_private_custom_endpoints=allow_private_custom_endpoints, ), runtime_protocol if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: request_api_version = resolve_openai_style_request_api_version(api_version) client_kwargs = { 'api_key': api_key, - 'base_url': normalize_openai_style_base_url(endpoint), + 'base_url': ( + normalize_custom_openai_base_url(endpoint) + if direct_custom + else normalize_openai_style_base_url(endpoint) + ), } + if direct_custom: + client_kwargs['http_client'] = build_custom_openai_async_http_client( + allow_private=allow_private_custom_endpoints, + ) if request_api_version: client_kwargs['default_query'] = {'api-version': request_api_version} + async_client = AsyncOpenAI(**client_kwargs) + if direct_custom: + async_client = sanitize_custom_async_openai_client(async_client) return OpenAIChatCompletion( service_id=service_id, - ai_model_id=deployment_name, - async_client=AsyncOpenAI(**client_kwargs), + ai_model_id=request_model, + async_client=async_client, + ), runtime_protocol + if direct_custom: + async_client = AsyncAzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + api_key=api_key, + http_client=build_custom_openai_async_http_client( + allow_private=allow_private_custom_endpoints, + ), + ) + async_client = sanitize_custom_async_openai_client(async_client) + return AzureChatCompletion( + service_id=service_id, + deployment_name=request_model, + async_client=async_client, ), runtime_protocol return AzureChatCompletion( service_id=service_id, - deployment_name=deployment_name, + deployment_name=request_model, endpoint=endpoint, api_key=api_key, api_version=api_version, @@ -335,7 +466,7 @@ def build_semantic_kernel_chat_service_for_model( token = credential.get_token(scope).token return AnthropicSemanticKernelChatCompletion( service_id=service_id, - deployment_name=deployment_name, + deployment_name=request_model, endpoint=endpoint, bearer_token=token, ), runtime_protocol @@ -351,7 +482,7 @@ def build_semantic_kernel_chat_service_for_model( client_kwargs['default_query'] = {'api-version': request_api_version} return OpenAIChatCompletion( service_id=service_id, - ai_model_id=deployment_name, + ai_model_id=request_model, async_client=AsyncOpenAI(**client_kwargs), ), runtime_protocol @@ -359,7 +490,7 @@ def build_semantic_kernel_chat_service_for_model( try: return AzureChatCompletion( service_id=service_id, - deployment_name=deployment_name, + deployment_name=request_model, endpoint=endpoint, api_version=api_version, azure_ad_token_provider=token_provider, @@ -367,7 +498,7 @@ def build_semantic_kernel_chat_service_for_model( except TypeError: return AzureChatCompletion( service_id=service_id, - deployment_name=deployment_name, + deployment_name=request_model, endpoint=endpoint, api_version=api_version, ad_token_provider=token_provider, diff --git a/application/single_app/functions_model_endpoint_types.py b/application/single_app/functions_model_endpoint_types.py new file mode 100644 index 000000000..9bab708c7 --- /dev/null +++ b/application/single_app/functions_model_endpoint_types.py @@ -0,0 +1,62 @@ +# functions_model_endpoint_types.py +"""Canonical provider, API type, and model identifier helpers.""" + +from typing import Any, Dict + + +MODEL_ENDPOINT_PROVIDER_CUSTOM = "custom" +MODEL_ENDPOINT_API_TYPE_OPENAI = "openai" +MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI = "azure_openai" +MODEL_ENDPOINT_API_TYPE_ANTHROPIC = "anthropic" +MODEL_ENDPOINT_CUSTOM_API_TYPES = { + MODEL_ENDPOINT_API_TYPE_OPENAI, + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, +} +DEFAULT_ANTHROPIC_VERSION = "2023-06-01" + + +def normalize_model_endpoint_api_type(provider: Any, api_type: Any) -> str: + """Return a supported explicit API type for Custom endpoints.""" + normalized_provider = str(provider or "").strip().lower() + normalized_api_type = str(api_type or "").strip().lower().replace("-", "_") + if normalized_provider != MODEL_ENDPOINT_PROVIDER_CUSTOM: + return "" + return normalized_api_type if normalized_api_type in MODEL_ENDPOINT_CUSTOM_API_TYPES else "" + + +def get_model_endpoint_api_type(endpoint: Any) -> str: + """Return the canonical explicit API type from an endpoint record.""" + if not isinstance(endpoint, dict): + return "" + return normalize_model_endpoint_api_type(endpoint.get("provider"), endpoint.get("api_type")) + + +def resolve_model_endpoint_request_model(endpoint: Any, model: Any) -> str: + """Resolve the model identifier that must be sent to the configured API.""" + endpoint_data: Dict[str, Any] = endpoint if isinstance(endpoint, dict) else {} + model_data: Dict[str, Any] = model if isinstance(model, dict) else {} + provider = str(endpoint_data.get("provider") or "aoai").strip().lower() + api_type = get_model_endpoint_api_type(endpoint_data) + + if provider == MODEL_ENDPOINT_PROVIDER_CUSTOM: + if api_type in { + MODEL_ENDPOINT_API_TYPE_OPENAI, + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + }: + return str(model_data.get("modelName") or model_data.get("name") or "").strip() + if api_type == MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI: + return str( + model_data.get("deploymentName") + or model_data.get("deployment") + or "" + ).strip() + return "" + + return str( + model_data.get("deploymentName") + or model_data.get("deployment") + or model_data.get("modelName") + or model_data.get("name") + or "" + ).strip() diff --git a/application/single_app/functions_model_endpoint_validation.py b/application/single_app/functions_model_endpoint_validation.py new file mode 100644 index 000000000..3d081d5ad --- /dev/null +++ b/application/single_app/functions_model_endpoint_validation.py @@ -0,0 +1,312 @@ +# functions_model_endpoint_validation.py +"""Validation and outbound-network safety for Custom model endpoints.""" + +import ipaddress +import re +import socket +from typing import Any, Dict, Iterable +from urllib.parse import urlparse, urlunparse + +from functions_model_endpoint_types import ( + DEFAULT_ANTHROPIC_VERSION, + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + MODEL_ENDPOINT_PROVIDER_CUSTOM, + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) + + +CUSTOM_ENDPOINT_MAX_URL_LENGTH = 2048 +CUSTOM_ENDPOINT_VERSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$") +CUSTOM_ENDPOINT_BLOCKED_HOSTNAMES = { + "instance-data.ec2.internal", + "localhost", + "localhost.localdomain", + "metadata.azure.com", + "metadata.google.internal", +} +CUSTOM_ENDPOINT_BLOCKED_IPS = { + ipaddress.ip_address("168.63.129.16"), + ipaddress.ip_address("169.254.169.254"), + ipaddress.ip_address("169.254.169.250"), + ipaddress.ip_address("169.254.169.251"), +} +CUSTOM_ENDPOINT_PRIVATE_NETWORKS = ( + ipaddress.ip_network("10.0.0.0/8"), + ipaddress.ip_network("172.16.0.0/12"), + ipaddress.ip_network("192.168.0.0/16"), + ipaddress.ip_network("fc00::/7"), +) + + +class ModelEndpointValidationError(ValueError): + """Raised when a model endpoint configuration violates the saved policy.""" + + +def _is_ip_literal(hostname: str) -> bool: + try: + ipaddress.ip_address(hostname) + return True + except ValueError: + return False + + +def validate_custom_model_endpoint_address( + address: str, + *, + allow_private: bool = False, +) -> None: + """Validate one resolved Custom endpoint address against the outbound policy.""" + try: + ip_address = ipaddress.ip_address(address) + except ValueError as exc: + raise ModelEndpointValidationError( + "Custom endpoint hostname resolved to an invalid address." + ) from exc + + if ip_address in CUSTOM_ENDPOINT_BLOCKED_IPS: + raise ModelEndpointValidationError( + "Custom endpoint hostname resolves to a blocked platform address." + ) + if ip_address.is_loopback: + raise ModelEndpointValidationError( + "Custom endpoint hostname must not resolve to a loopback address." + ) + if ip_address.is_link_local: + raise ModelEndpointValidationError( + "Custom endpoint hostname must not resolve to a link-local address." + ) + if ip_address.is_multicast or ip_address.is_reserved or ip_address.is_unspecified: + raise ModelEndpointValidationError( + "Custom endpoint hostname must resolve to a usable network address." + ) + is_allowed_private_address = any( + ip_address in private_network + for private_network in CUSTOM_ENDPOINT_PRIVATE_NETWORKS + if ip_address.version == private_network.version + ) + if is_allowed_private_address: + if not allow_private: + raise ModelEndpointValidationError( + "Private Custom endpoint hosts are not enabled by the administrator." + ) + return + if not ip_address.is_global: + raise ModelEndpointValidationError( + "Custom endpoint hostname must resolve to a globally routable address." + ) + + +def resolve_custom_model_endpoint_addresses( + hostname: str, + port: int = 443, + *, + allow_private: bool = False, +) -> tuple[str, ...]: + """Resolve and validate every address before a Custom endpoint connection.""" + try: + resolved_addresses = socket.getaddrinfo( + hostname, + port, + type=socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + raise ModelEndpointValidationError( + "Custom endpoint hostname could not be resolved." + ) from exc + + if not resolved_addresses: + raise ModelEndpointValidationError( + "Custom endpoint hostname did not resolve to an address." + ) + + validated_addresses = [] + seen_addresses = set() + for address_info in resolved_addresses: + address = address_info[4][0] + validate_custom_model_endpoint_address( + address, + allow_private=allow_private, + ) + if address not in seen_addresses: + seen_addresses.add(address) + validated_addresses.append(address) + return tuple(validated_addresses) + + +def validate_custom_model_endpoint_url( + endpoint: Any, + *, + allow_private: bool = False, +) -> str: + """Validate and normalize a Custom endpoint URL before an outbound request.""" + endpoint_text = str(endpoint or "").strip() + if not endpoint_text: + raise ModelEndpointValidationError("Custom endpoint URL is required.") + if len(endpoint_text) > CUSTOM_ENDPOINT_MAX_URL_LENGTH: + raise ModelEndpointValidationError("Custom endpoint URL is too long.") + + try: + parsed_endpoint = urlparse(endpoint_text) + port = parsed_endpoint.port + except ValueError as exc: + raise ModelEndpointValidationError("Custom endpoint URL is invalid.") from exc + + if parsed_endpoint.scheme.lower() != "https": + raise ModelEndpointValidationError("Custom endpoint URL must use HTTPS.") + if not parsed_endpoint.netloc or not parsed_endpoint.hostname: + raise ModelEndpointValidationError( + "Custom endpoint URL must include a fully qualified domain name." + ) + if parsed_endpoint.username or parsed_endpoint.password: + raise ModelEndpointValidationError( + "Custom endpoint URL must not include embedded credentials." + ) + if parsed_endpoint.query or parsed_endpoint.fragment: + raise ModelEndpointValidationError( + "Custom endpoint URL must not include a query string or fragment." + ) + + hostname = parsed_endpoint.hostname.strip().lower().rstrip(".") + try: + hostname = hostname.encode("idna").decode("ascii") + except UnicodeError as exc: + raise ModelEndpointValidationError( + "Custom endpoint hostname is invalid." + ) from exc + + if ( + hostname in CUSTOM_ENDPOINT_BLOCKED_HOSTNAMES + or hostname.endswith(".localhost") + ): + raise ModelEndpointValidationError("Custom endpoint hostname is blocked.") + if _is_ip_literal(hostname) or "." not in hostname: + raise ModelEndpointValidationError( + "Custom endpoint URL must use a fully qualified domain name, not an IP address." + ) + if not allow_private and hostname.endswith((".internal", ".local")): + raise ModelEndpointValidationError( + "Private Custom endpoint hosts are not enabled by the administrator." + ) + + resolve_custom_model_endpoint_addresses( + hostname, + port or 443, + allow_private=allow_private, + ) + + normalized_netloc = hostname + if port and port != 443: + normalized_netloc = f"{hostname}:{port}" + return urlunparse(( + "https", + normalized_netloc, + parsed_endpoint.path or "", + "", + "", + "", + )).rstrip("/") + + +def _validate_version(value: Any, field_label: str) -> str: + normalized_value = str(value or "").strip() + if not CUSTOM_ENDPOINT_VERSION_PATTERN.fullmatch(normalized_value): + raise ModelEndpointValidationError( + f"{field_label} must contain only letters, numbers, dots, underscores, or hyphens." + ) + return normalized_value + + +def validate_custom_model_endpoint( + endpoint: Any, + settings: Dict[str, Any] | None = None, + *, + require_api_key: bool = True, +) -> None: + """Validate a normalized Custom endpoint record.""" + if not isinstance(endpoint, dict): + raise ModelEndpointValidationError("Custom endpoint configuration is invalid.") + if str(endpoint.get("provider") or "").strip().lower() != MODEL_ENDPOINT_PROVIDER_CUSTOM: + return + + endpoint_name = str(endpoint.get("name") or "").strip() + if not endpoint_name: + raise ModelEndpointValidationError("Custom endpoint name is required.") + + api_type = get_model_endpoint_api_type(endpoint) + if not api_type: + raise ModelEndpointValidationError("Custom endpoint API type is not supported.") + + auth = endpoint.get("auth") if isinstance(endpoint.get("auth"), dict) else {} + auth_type = str(auth.get("type") or "").strip().lower() + if auth_type not in {"api_key", "key"}: + raise ModelEndpointValidationError( + "Custom endpoints support API key authentication only." + ) + if require_api_key and not auth.get("api_key"): + raise ModelEndpointValidationError("Custom endpoint API key is required.") + + connection = ( + endpoint.get("connection") + if isinstance(endpoint.get("connection"), dict) + else {} + ) + allow_private = bool((settings or {}).get("allow_private_custom_model_endpoints", False)) + connection["endpoint"] = validate_custom_model_endpoint_url( + connection.get("endpoint"), + allow_private=allow_private, + ) + + if api_type == MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI: + _validate_version(connection.get("api_version"), "Azure OpenAI API version") + elif api_type == MODEL_ENDPOINT_API_TYPE_ANTHROPIC: + _validate_version( + connection.get("anthropic_version") or DEFAULT_ANTHROPIC_VERSION, + "Anthropic Version", + ) + + seen_model_names = set() + models: Iterable[Any] = endpoint.get("models") or [] + if not isinstance(models, list): + raise ModelEndpointValidationError("Custom endpoint models must be a list.") + if not models: + raise ModelEndpointValidationError( + "Custom endpoints require at least one manually configured model." + ) + for model in models: + if not isinstance(model, dict): + raise ModelEndpointValidationError("Custom endpoint model configuration is invalid.") + request_model = resolve_model_endpoint_request_model(endpoint, model) + if not request_model: + model_field = ( + "Deployment Name" + if api_type == MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI + else "Model Name" + ) + raise ModelEndpointValidationError( + f"Custom endpoint models require {model_field}." + ) + normalized_model_name = request_model.casefold() + if normalized_model_name in seen_model_names: + raise ModelEndpointValidationError( + "Custom endpoint model names must be unique." + ) + seen_model_names.add(normalized_model_name) + + +def validate_custom_model_endpoints( + endpoints: Any, + settings: Dict[str, Any] | None = None, + *, + require_api_key: bool = True, +) -> None: + """Validate every Custom endpoint in an endpoint list.""" + if not isinstance(endpoints, list): + raise ModelEndpointValidationError("Model endpoints must be a list.") + for endpoint in endpoints: + validate_custom_model_endpoint( + endpoint, + settings, + require_api_key=require_api_key, + ) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 1e6063a09..9c6d19c39 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1,6 +1,7 @@ # functions_settings.py from functools import wraps +import logging from flask import g, has_request_context, jsonify, request, session @@ -14,6 +15,14 @@ from functions_icon_utils import normalize_icon_payload from functions_latest_features_nav import LATEST_FEATURES_HIDDEN_VERSION_SETTING from functions_mcp_server_config import INBOUND_MCP_SETTINGS_DEFAULTS, normalize_inbound_mcp_settings +from functions_model_endpoint_types import ( + DEFAULT_ANTHROPIC_VERSION, + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + MODEL_ENDPOINT_PROVIDER_CUSTOM, + get_model_endpoint_api_type, + normalize_model_endpoint_api_type, +) from functions_service_health import get_default_service_health import app_settings_cache import inspect @@ -1077,6 +1086,7 @@ def get_settings(use_cosmos=False, include_source=False): }, 'allow_user_agents': False, 'allow_user_custom_endpoints': False, + 'allow_private_custom_model_endpoints': False, 'allow_user_custom_agent_endpoints': False, 'allow_user_plugins': False, 'allow_user_workflows': False, @@ -2169,6 +2179,24 @@ def normalize_model_endpoint_auth_for_environment(endpoint_copy): provider = str(endpoint_copy.get("provider") or "").strip().lower() auth_type = str(auth.get("type") or "").strip().lower() + if provider == MODEL_ENDPOINT_PROVIDER_CUSTOM: + if auth.get("type") != "api_key": + auth["type"] = "api_key" + changed = True + for field_name in ( + "management_cloud", + "custom_authority", + "foundry_scope", + "tenant_id", + "client_id", + "client_secret", + "managed_identity_client_id", + ): + if field_name in auth: + auth.pop(field_name, None) + changed = True + return changed + current_cloud = normalize_model_endpoint_management_cloud(auth.get("management_cloud")) default_cloud = get_model_endpoint_management_cloud_for_environment() cloud_user_editable = is_model_endpoint_management_cloud_user_editable(provider, auth_type) @@ -2248,6 +2276,48 @@ 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 {} + provider = str(endpoint_copy.get("provider") or "aoai").strip().lower() + if endpoint_copy.get("provider") != provider: + endpoint_copy["provider"] = provider + changed = True + if provider == MODEL_ENDPOINT_PROVIDER_CUSTOM: + api_type = normalize_model_endpoint_api_type( + provider, + endpoint_copy.get("api_type"), + ) + if endpoint_copy.get("api_type") != api_type: + endpoint_copy["api_type"] = api_type + changed = True + if not isinstance(connection, dict): + connection = {} + changed = True + connection = json.loads(json.dumps(connection)) + if api_type == MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI: + if "anthropic_version" in connection: + connection.pop("anthropic_version", None) + changed = True + elif api_type == MODEL_ENDPOINT_API_TYPE_ANTHROPIC: + anthropic_version = str( + connection.get("anthropic_version") + or DEFAULT_ANTHROPIC_VERSION + ).strip() + if connection.get("anthropic_version") != anthropic_version: + connection["anthropic_version"] = anthropic_version + changed = True + for field_name in ("api_version", "openai_api_version"): + if field_name in connection: + connection.pop(field_name, None) + changed = True + else: + for field_name in ( + "api_version", + "openai_api_version", + "anthropic_version", + ): + if field_name in connection: + connection.pop(field_name, None) + changed = True + endpoint_copy["connection"] = connection if not endpoint_copy.get("id"): fallback_id = endpoint_copy.get("name") or connection.get("endpoint") @@ -2264,10 +2334,38 @@ def normalize_model_endpoints(endpoints): models = endpoint_copy.get("models") or [] normalized_models = [] + custom_api_type = get_model_endpoint_api_type(endpoint_copy) for model in models: if not isinstance(model, dict): continue model_copy = json.loads(json.dumps(model)) + if provider == MODEL_ENDPOINT_PROVIDER_CUSTOM: + if custom_api_type == MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI: + deployment_name = str( + model_copy.get("deploymentName") + or model_copy.get("deployment") + or "" + ).strip() + if deployment_name and model_copy.get("deploymentName") != deployment_name: + model_copy["deploymentName"] = deployment_name + changed = True + for field_name in ("deployment", "modelName", "name"): + if field_name in model_copy: + model_copy.pop(field_name, None) + changed = True + else: + model_name = str( + model_copy.get("modelName") + or model_copy.get("name") + or "" + ).strip() + if model_name and model_copy.get("modelName") != model_name: + model_copy["modelName"] = model_name + changed = True + for field_name in ("deploymentName", "deployment", "name"): + if field_name in model_copy: + model_copy.pop(field_name, None) + changed = True if not model_copy.get("id"): model_id = ( model_copy.get("deploymentName") @@ -2313,7 +2411,12 @@ def normalize_model_endpoints(endpoints): def is_frontend_visible_model_endpoint_provider(provider): """Return whether the provider should be exposed in user-facing endpoint UIs.""" normalized_provider = (provider or "aoai").lower() - return normalized_provider in {"aoai", "aifoundry", "new_foundry"} + return normalized_provider in { + "aoai", + "aifoundry", + "new_foundry", + MODEL_ENDPOINT_PROVIDER_CUSTOM, + } def merge_model_endpoint_auth(existing_auth, incoming_auth): diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index e5ac553eb..b840e17ba 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -153,6 +153,10 @@ build_model_endpoint_sync_chat_client, build_semantic_kernel_chat_service_for_model, ) +from functions_model_endpoint_types import ( + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) from functions_notifications import create_workflow_priority_notification from functions_personal_workflows import ( get_personal_workflow, @@ -5524,14 +5528,11 @@ def _build_multi_endpoint_client(user_id, endpoint_id, model_id, settings, group connection = resolved_endpoint.get('connection', {}) if isinstance(resolved_endpoint, dict) else {} auth = resolved_endpoint.get('auth', {}) if isinstance(resolved_endpoint, dict) else {} provider = str(resolved_endpoint.get('provider') or endpoint_cfg.get('provider') or 'aoai').strip().lower() - deployment_name = ( - model_cfg.get('deploymentName') - or model_cfg.get('deployment') - or model_cfg.get('displayName') - or model_id - ) - api_version = connection.get('api_version') or connection.get('openai_api_version') or settings.get('azure_openai_gpt_api_version') + deployment_name = resolve_model_endpoint_request_model(resolved_endpoint, model_cfg) + api_version = connection.get('api_version') or connection.get('openai_api_version') or '' endpoint = connection.get('endpoint') + api_type = get_model_endpoint_api_type(resolved_endpoint) + anthropic_version = connection.get('anthropic_version') or '' auth_type = str(auth.get('type') or 'api_key').strip().lower() auth_settings = { **auth, @@ -5546,6 +5547,11 @@ def _build_multi_endpoint_client(user_id, endpoint_id, model_id, settings, group endpoint, api_version, deployment_name=deployment_name, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=bool( + settings.get('allow_private_custom_model_endpoints', False) + ), ) return client, deployment_name, provider diff --git a/application/single_app/model_endpoint_clients.py b/application/single_app/model_endpoint_clients.py index c88aa5922..75d6a7b29 100644 --- a/application/single_app/model_endpoint_clients.py +++ b/application/single_app/model_endpoint_clients.py @@ -7,8 +7,16 @@ from typing import Any, Dict, Iterable, Iterator, List from urllib.parse import urlparse +import anyio +import httpcore +import httpx import requests -from openai import OpenAI +from openai import ( + DEFAULT_CONNECTION_LIMITS, + DefaultAsyncHttpxClient, + DefaultHttpxClient, + OpenAI, +) from pydantic import Field from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.function_calling_utils import update_settings_from_function_call_configuration @@ -24,6 +32,18 @@ from semantic_kernel.exceptions.service_exceptions import ServiceInvalidExecutionSettingsError from functions_debug import debug_print +from functions_model_endpoint_types import ( + DEFAULT_ANTHROPIC_VERSION, + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + MODEL_ENDPOINT_API_TYPE_OPENAI, + MODEL_ENDPOINT_PROVIDER_CUSTOM, + normalize_model_endpoint_api_type, +) +from functions_model_endpoint_validation import ( + ModelEndpointValidationError, + resolve_custom_model_endpoint_addresses, +) MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI = "azure_openai" @@ -131,9 +151,27 @@ def endpoint_uses_openai_style_protocol(endpoint: Any) -> bool: ) -def infer_model_endpoint_protocol(provider: Any, endpoint: Any, deployment_name: Any = "") -> str: +def infer_model_endpoint_protocol( + provider: Any, + endpoint: Any, + deployment_name: Any = "", + api_type: Any = "", +) -> str: """Infer the runtime protocol from provider, endpoint path, and deployment name.""" normalized_provider = str(provider or "aoai").strip().lower() + if normalized_provider == MODEL_ENDPOINT_PROVIDER_CUSTOM: + normalized_api_type = normalize_model_endpoint_api_type( + normalized_provider, + api_type, + ) + if normalized_api_type == MODEL_ENDPOINT_API_TYPE_OPENAI: + return MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE + if normalized_api_type == MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI: + return MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI + if normalized_api_type == MODEL_ENDPOINT_API_TYPE_ANTHROPIC: + return MODEL_ENDPOINT_PROTOCOL_ANTHROPIC + raise ValueError("Custom model endpoints require a supported API type.") + endpoint_path = get_endpoint_path(endpoint) if normalized_provider in ("anthropic", "claude"): @@ -173,13 +211,44 @@ def normalize_openai_style_base_url(raw_endpoint: Any) -> str: return endpoint.rstrip("/") + "/openai/v1/" -def normalize_anthropic_messages_url(raw_endpoint: Any) -> str: +def normalize_custom_openai_base_url(raw_endpoint: Any) -> str: + """Normalize a Custom OpenAI-compatible endpoint to its v1 base URL.""" + endpoint = normalize_endpoint_text(raw_endpoint) + if not endpoint: + raise ValueError("A Custom endpoint is required for OpenAI-compatible inference.") + + lowered_endpoint = endpoint.lower() + for suffix in ("/chat/completions", "/responses", "/models"): + if lowered_endpoint.endswith(suffix): + endpoint = endpoint[: -len(suffix)].rstrip("/") + lowered_endpoint = endpoint.lower() + break + + if lowered_endpoint.endswith("/v1"): + return endpoint.rstrip("/") + "/" + return endpoint.rstrip("/") + "/v1/" + + +def normalize_anthropic_messages_url( + raw_endpoint: Any, + *, + direct_custom: bool = False, +) -> str: """Normalize a Foundry endpoint to the Anthropic messages URL.""" endpoint = normalize_endpoint_text(raw_endpoint) if not endpoint: - raise ValueError("A Foundry endpoint is required for Anthropic inference.") + raise ValueError("An endpoint is required for Anthropic inference.") lowered_endpoint = endpoint.lower() + if direct_custom: + if lowered_endpoint.endswith("/v1/messages"): + return endpoint + if lowered_endpoint.endswith("/v1"): + return endpoint.rstrip("/") + "/messages" + if lowered_endpoint.endswith("/messages"): + return endpoint + return endpoint.rstrip("/") + "/v1/messages" + messages_index = lowered_endpoint.find("/anthropic/v1/messages") if messages_index >= 0: return endpoint[: messages_index + len("/anthropic/v1/messages")] @@ -231,29 +300,330 @@ 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 _resolve_custom_connection_addresses(host, port, allow_private): + hostname = host.decode("ascii") if isinstance(host, bytes) else str(host) + try: + return resolve_custom_model_endpoint_addresses( + hostname, + port, + allow_private=allow_private, + ) + except ModelEndpointValidationError: + raise httpcore.ConnectError("Custom endpoint connection blocked.") from None + + +class _PinnedCustomEndpointSyncBackend(httpcore.NetworkBackend): + """Connect only to addresses returned by the validated DNS lookup.""" + + def __init__(self, *, allow_private=False): + self._allow_private = allow_private + self._backend = httpcore.SyncBackend() + + def connect_tcp( + self, + host, + port, + timeout=None, + local_address=None, + socket_options=None, + ): + addresses = _resolve_custom_connection_addresses( + host, + port, + self._allow_private, + ) + last_error = None + for address in addresses: + try: + return self._backend.connect_tcp( + address, + port, + timeout=timeout, + local_address=local_address, + socket_options=socket_options, + ) + except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc: + last_error = exc + if last_error: + raise last_error + raise httpcore.ConnectError("Custom endpoint connection failed.") + + def connect_unix_socket(self, path, timeout=None, socket_options=None): + raise httpcore.ConnectError("Custom endpoint UNIX sockets are not supported.") + + def sleep(self, seconds): + self._backend.sleep(seconds) + + +class _PinnedCustomEndpointAsyncBackend(httpcore.AsyncNetworkBackend): + """Async counterpart to the validated synchronous DNS backend.""" + + def __init__(self, *, allow_private=False): + self._allow_private = allow_private + self._backend = httpcore.AnyIOBackend() + + async def connect_tcp( + self, + host, + port, + timeout=None, + local_address=None, + socket_options=None, + ): + addresses = await anyio.to_thread.run_sync( + _resolve_custom_connection_addresses, + host, + port, + self._allow_private, + ) + last_error = None + for address in addresses: + try: + return await self._backend.connect_tcp( + address, + port, + timeout=timeout, + local_address=local_address, + socket_options=socket_options, + ) + except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc: + last_error = exc + if last_error: + raise last_error + raise httpcore.ConnectError("Custom endpoint connection failed.") + + async def connect_unix_socket(self, path, timeout=None, socket_options=None): + raise httpcore.ConnectError("Custom endpoint UNIX sockets are not supported.") + + async def sleep(self, seconds): + await self._backend.sleep(seconds) + + +class _PinnedCustomEndpointHTTPTransport(httpx.HTTPTransport): + """HTTPX transport whose TCP connection uses the validated DNS results.""" + + def __init__(self, *, allow_private=False): + self._pool = httpcore.ConnectionPool( + ssl_context=httpx.create_ssl_context(verify=True, trust_env=False), + max_connections=DEFAULT_CONNECTION_LIMITS.max_connections, + max_keepalive_connections=DEFAULT_CONNECTION_LIMITS.max_keepalive_connections, + keepalive_expiry=DEFAULT_CONNECTION_LIMITS.keepalive_expiry, + network_backend=_PinnedCustomEndpointSyncBackend( + allow_private=allow_private, + ), + ) + + +class _PinnedCustomEndpointAsyncHTTPTransport(httpx.AsyncHTTPTransport): + """Async HTTPX transport whose TCP connection uses validated DNS results.""" + + def __init__(self, *, allow_private=False): + self._pool = httpcore.AsyncConnectionPool( + ssl_context=httpx.create_ssl_context(verify=True, trust_env=False), + max_connections=DEFAULT_CONNECTION_LIMITS.max_connections, + max_keepalive_connections=DEFAULT_CONNECTION_LIMITS.max_keepalive_connections, + keepalive_expiry=DEFAULT_CONNECTION_LIMITS.keepalive_expiry, + network_backend=_PinnedCustomEndpointAsyncBackend( + allow_private=allow_private, + ), + ) + + +def build_custom_openai_sync_http_client(*, allow_private=False): + """Return a no-redirect SDK transport pinned to validated DNS addresses.""" + return DefaultHttpxClient( + transport=_PinnedCustomEndpointHTTPTransport( + allow_private=allow_private, + ), + follow_redirects=False, + trust_env=False, + ) + + +def build_custom_openai_async_http_client(*, allow_private=False): + """Return an async no-redirect transport pinned to validated DNS addresses.""" + return DefaultAsyncHttpxClient( + transport=_PinnedCustomEndpointAsyncHTTPTransport( + allow_private=allow_private, + ), + follow_redirects=False, + trust_env=False, + ) + + +def build_openai_style_chat_client( + token_or_key: str, + base_url: str, + api_version: Any = "", + *, + direct_custom: bool = False, + allow_private_custom_endpoints: bool = False, +): """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), + "base_url": ( + normalize_custom_openai_base_url(base_url) + if direct_custom + else normalize_openai_style_base_url(base_url) + ), } + if direct_custom: + client_kwargs["http_client"] = build_custom_openai_sync_http_client( + allow_private=allow_private_custom_endpoints, + ) if request_api_version: client_kwargs["default_query"] = {"api-version": request_api_version} - return OpenAIStyleChatCompletionClient(OpenAI(**client_kwargs)) + return OpenAIStyleChatCompletionClient( + OpenAI(**client_kwargs), + sanitize_errors=direct_custom, + ) class OpenAIStyleChatCompletionClient: """Small wrapper that makes OpenAI-compatible Foundry calls tolerant of Azure-only options.""" - def __init__(self, client: OpenAI): + def __init__(self, client: OpenAI, *, sanitize_errors: bool = False): self._client = client + self._sanitize_errors = sanitize_errors self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create)) def create(self, **kwargs: Any): request_kwargs = dict(kwargs) request_kwargs.pop("stream_options", None) - return self._client.chat.completions.create(**request_kwargs) + try: + response = self._client.chat.completions.create(**request_kwargs) + except Exception: + if self._sanitize_errors: + raise RuntimeError("Custom model request failed.") from None + raise + if self._sanitize_errors and request_kwargs.get("stream"): + return _SanitizedSyncIterator(response) + return response + + +class _SanitizedSyncIterator: + """Proxy a streaming response without exposing provider exception details.""" + + def __init__(self, iterator: Any): + self._iterator = iterator + self._items = iter(iterator) + + def __iter__(self): + return self + + def __next__(self): + try: + return next(self._items) + except StopIteration: + raise + except Exception: + raise RuntimeError("Custom model stream failed.") from None + + def __enter__(self): + enter = getattr(self._iterator, "__enter__", None) + if callable(enter): + enter() + return self + + def __exit__(self, exc_type, exc_value, traceback): + exit_method = getattr(self._iterator, "__exit__", None) + if callable(exit_method): + return exit_method(exc_type, exc_value, traceback) + return False + + def close(self): + close_method = getattr(self._iterator, "close", None) + if callable(close_method): + return close_method() + return None + + def __getattr__(self, name: str): + return getattr(self._iterator, name) + + +class _SanitizedAsyncIterator: + """Proxy an async streaming response without exposing provider exception details.""" + + def __init__(self, iterator: Any): + self._iterator = iterator + self._items = iterator.__aiter__() + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return await self._items.__anext__() + except StopAsyncIteration: + raise + except Exception: + raise RuntimeError("Custom model stream failed.") from None + + async def __aenter__(self): + enter = getattr(self._iterator, "__aenter__", None) + if callable(enter): + await enter() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + exit_method = getattr(self._iterator, "__aexit__", None) + if callable(exit_method): + return await exit_method(exc_type, exc_value, traceback) + return False + + async def close(self): + close_method = getattr(self._iterator, "close", None) + if callable(close_method): + result = close_method() + if asyncio.iscoroutine(result): + return await result + return None + + def __getattr__(self, name: str): + return getattr(self._iterator, name) + + +class SanitizedCustomChatCompletionClient: + """Expose an SDK chat client while replacing direct Custom provider errors.""" + + def __init__(self, client: Any): + self._client = client + self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create)) + + def create(self, **kwargs: Any): + try: + response = self._client.chat.completions.create(**kwargs) + except Exception: + raise RuntimeError("Custom model request failed.") from None + if kwargs.get("stream"): + return _SanitizedSyncIterator(response) + return response + + def __getattr__(self, name: str): + return getattr(self._client, name) + + +def sanitize_custom_async_openai_client(client: Any): + """Replace async SDK chat errors with safe direct-Custom messages.""" + if getattr(client, "_simplechat_custom_errors_sanitized", False): + return client + + original_create = client.chat.completions.create + + async def sanitized_create(*args, **kwargs): + try: + response = await original_create(*args, **kwargs) + except Exception: + raise RuntimeError("Custom model request failed.") from None + if kwargs.get("stream"): + return _SanitizedAsyncIterator(response) + return response + + client.chat.completions.create = sanitized_create + client._simplechat_custom_errors_sanitized = True + return client def build_anthropic_chat_client( @@ -262,6 +632,9 @@ def build_anthropic_chat_client( api_key: str = "", bearer_token: str = "", timeout: int = 90, + anthropic_version: str = DEFAULT_ANTHROPIC_VERSION, + direct_custom: bool = False, + allow_private_custom_endpoints: bool = False, ): """Build a chat-completions-shaped adapter over the Anthropic messages protocol.""" return AnthropicChatCompletionClient( @@ -269,28 +642,53 @@ def build_anthropic_chat_client( api_key=api_key, bearer_token=bearer_token, timeout=timeout, + anthropic_version=anthropic_version, + direct_custom=direct_custom, + allow_private_custom_endpoints=allow_private_custom_endpoints, ) 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): - self.endpoint = normalize_anthropic_messages_url(endpoint) + def __init__( + self, + *, + endpoint: str, + api_key: str = "", + bearer_token: str = "", + timeout: int = 90, + anthropic_version: str = DEFAULT_ANTHROPIC_VERSION, + direct_custom: bool = False, + allow_private_custom_endpoints: bool = False, + ): + self.endpoint = normalize_anthropic_messages_url( + endpoint, + direct_custom=direct_custom, + ) self.api_key = api_key self.bearer_token = bearer_token self.timeout = timeout + self.anthropic_version = str( + anthropic_version or DEFAULT_ANTHROPIC_VERSION + ).strip() + self.direct_custom = direct_custom + self.allow_private_custom_endpoints = allow_private_custom_endpoints self.chat = SimpleNamespace(completions=SimpleNamespace(create=self.create)) def create(self, **kwargs: Any): payload = self._build_payload(kwargs) stream = bool(kwargs.get("stream")) + if self.direct_custom: + return self._create_direct_custom(payload, stream=stream) + response = requests.post( self.endpoint, headers=self._build_headers(stream=stream), json=payload, timeout=(30, self.timeout), stream=stream, + allow_redirects=not self.direct_custom, ) if response.status_code >= 400: self._raise_response_error(response) @@ -300,16 +698,62 @@ def create(self, **kwargs: Any): return self._build_completion_response(response.json()) + def _create_direct_custom(self, payload, *, stream): + http_client = build_custom_openai_sync_http_client( + allow_private=self.allow_private_custom_endpoints, + ) + request = http_client.build_request( + "POST", + self.endpoint, + headers=self._build_headers(stream=stream), + json=payload, + timeout=httpx.Timeout(self.timeout, connect=30), + ) + try: + response = http_client.send( + request, + stream=stream, + follow_redirects=False, + ) + except Exception: + http_client.close() + raise RuntimeError("Custom Anthropic model request failed.") from None + + if response.status_code >= 400: + status_code = response.status_code + response.close() + http_client.close() + raise RuntimeError( + f"Custom Anthropic model request failed with status {status_code}." + ) + + if stream: + return self._iter_stream_chunks( + response, + http_client=http_client, + ) + + try: + return self._build_completion_response(response.json()) + except Exception: + raise RuntimeError( + "Custom Anthropic model returned an invalid response." + ) from None + finally: + response.close() + http_client.close() + def _build_headers(self, *, stream: bool = False) -> Dict[str, str]: headers = { "Content-Type": "application/json", "Accept": "text/event-stream" if stream else "application/json", - "anthropic-version": "2023-06-01", + "anthropic-version": self.anthropic_version, } if self.bearer_token: headers["Authorization"] = f"Bearer {self.bearer_token}" elif self.api_key: - headers["api-key"] = self.api_key + if not self.direct_custom: + headers["api-key"] = self.api_key headers["x-api-key"] = self.api_key else: raise ValueError("Anthropic model endpoints require an API key or bearer token.") @@ -318,7 +762,7 @@ def _build_headers(self, *, stream: bool = False) -> Dict[str, str]: def _build_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: model = str(kwargs.get("model") or "").strip() if not model: - raise ValueError("Anthropic model requests require a deployment name.") + raise ValueError("Anthropic model requests require a model name.") messages, system_prompt = self._convert_messages(kwargs.get("messages") or []) payload: Dict[str, Any] = { @@ -425,9 +869,14 @@ def _normalize_content(self, content: Any) -> str | List[Dict[str, Any]]: text_parts.append(item) elif isinstance(item, dict): item_type = item.get("type") - if item_type in ("text", "tool_use", "tool_result"): + if item_type in ("text", "image", "tool_use", "tool_result"): normalized_blocks.append(item) continue + if item_type == "image_url": + normalized_blocks.append( + self._convert_openai_image_block(item) + ) + continue text_value = item.get("text") if isinstance(text_value, str): text_parts.append(text_value) @@ -440,6 +889,33 @@ def _normalize_content(self, content: Any) -> str | List[Dict[str, Any]]: return "" return str(content) + def _convert_openai_image_block(self, image_block: Dict[str, Any]) -> Dict[str, Any]: + """Convert an OpenAI data-URL image block to Anthropic base64 content.""" + image_value = image_block.get("image_url") + image_url = ( + image_value.get("url") + if isinstance(image_value, dict) + else image_value + ) + image_url = str(image_url or "").strip() + if not image_url.startswith("data:") or ";base64," not in image_url: + raise ValueError( + "Anthropic image content requires a base64 data URL." + ) + + metadata, image_data = image_url.split(",", 1) + media_type = metadata[5:].split(";", 1)[0].strip().lower() + if not media_type.startswith("image/") or not image_data: + raise ValueError("Anthropic image content is invalid.") + return { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": image_data, + }, + } + def _content_to_text(self, content: Any) -> str: if isinstance(content, str): return content @@ -499,11 +975,20 @@ def _extract_response_parts(self, response_payload: Dict[str, Any]) -> tuple[str )) return "".join(text_parts), tool_calls - def _iter_stream_chunks(self, response: requests.Response) -> Iterator[Any]: + def _iter_stream_chunks( + self, + response, + *, + http_client=None, + ) -> Iterator[Any]: prompt_tokens = 0 completion_tokens = 0 try: - for raw_line in response.iter_lines(decode_unicode=True): + try: + response_lines = response.iter_lines(decode_unicode=True) + except TypeError: + response_lines = response.iter_lines() + for raw_line in response_lines: if not raw_line: continue if isinstance(raw_line, bytes): @@ -518,7 +1003,10 @@ def _iter_stream_chunks(self, response: requests.Response) -> Iterator[Any]: try: event_payload = json.loads(event_data) except json.JSONDecodeError: - debug_print(f"[MODEL_ENDPOINT] Ignoring invalid Anthropic stream payload: {event_data[:200]}") + if self.direct_custom: + debug_print("[MODEL_ENDPOINT] Ignoring invalid Custom Anthropic stream payload.") + else: + debug_print(f"[MODEL_ENDPOINT] Ignoring invalid Anthropic stream payload: {event_data[:200]}") continue event_type = event_payload.get("type") @@ -528,6 +1016,8 @@ def _iter_stream_chunks(self, response: requests.Response) -> Iterator[Any]: error_message = error_payload.get("message") or error_payload.get("type") or str(error_payload) else: error_message = str(error_payload or event_payload) + if self.direct_custom: + raise RuntimeError("Custom Anthropic model stream failed.") raise RuntimeError(f"Anthropic model stream failed: {error_message}") if event_type == "message_start": usage = event_payload.get("message", {}).get("usage", {}) @@ -547,8 +1037,14 @@ def _iter_stream_chunks(self, response: requests.Response) -> Iterator[Any]: prompt_tokens = int(usage.get("input_tokens") or prompt_tokens or 0) completion_tokens = int(usage.get("output_tokens") or completion_tokens or 0) continue + except Exception: + if self.direct_custom: + raise RuntimeError("Custom Anthropic model stream failed.") from None + raise finally: response.close() + if http_client is not None: + http_client.close() if prompt_tokens or completion_tokens: yield SimpleNamespace( @@ -572,6 +1068,10 @@ def _raise_response_error(self, response: requests.Response) -> None: else: error_message = str(error_payload or payload) + if self.direct_custom: + raise RuntimeError( + f"Custom Anthropic model request failed with status {response.status_code}." + ) raise RuntimeError( f"Anthropic model request failed with status {response.status_code}: {error_message}" ) @@ -586,6 +1086,9 @@ class AnthropicSemanticKernelChatCompletion(ChatCompletionClientBase): api_key: str = "" bearer_token: str = "" timeout: int = 90 + anthropic_version: str = DEFAULT_ANTHROPIC_VERSION + direct_custom: bool = False + allow_private_custom_endpoints: bool = False prompt_execution_settings: OpenAIChatPromptExecutionSettings | None = Field(default=None) def __init__( @@ -597,6 +1100,9 @@ def __init__( api_key: str = "", bearer_token: str = "", timeout: int = 90, + anthropic_version: str = DEFAULT_ANTHROPIC_VERSION, + direct_custom: bool = False, + allow_private_custom_endpoints: bool = False, ): super().__init__( ai_model_id=deployment_name, @@ -605,6 +1111,9 @@ def __init__( api_key=api_key, bearer_token=bearer_token, timeout=timeout, + anthropic_version=anthropic_version, + direct_custom=direct_custom, + allow_private_custom_endpoints=allow_private_custom_endpoints, ) def get_prompt_execution_settings_class(self): @@ -773,6 +1282,9 @@ def _build_client(self): api_key=self.api_key, bearer_token=self.bearer_token, timeout=self.timeout, + anthropic_version=self.anthropic_version, + direct_custom=self.direct_custom, + allow_private_custom_endpoints=self.allow_private_custom_endpoints, ) def _build_request_kwargs(self, chat_history, settings, *, stream: bool) -> Dict[str, Any]: diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 72852f358..3c479c29d 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -262,6 +262,8 @@ def _format_model_provider_label(provider): return 'Foundry (classic)' if normalized_provider == 'new_foundry': return 'New Foundry' + if normalized_provider == 'custom': + return 'Custom' return 'Azure OpenAI' @@ -2001,4 +2003,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 c0cb8b1b6..5ae224ecb 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -37,6 +37,10 @@ build_model_endpoint_sync_chat_client, build_semantic_kernel_chat_service_for_model, ) +from functions_model_endpoint_types import ( + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) from functions_mixed_source_orchestration import ( MixedSourceCancellationError, MixedSourceFinalizationError, @@ -13588,7 +13592,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='', + *, + api_type='', + anthropic_version='', + allow_private_custom_endpoints=False, +): """Create an inference client for a resolved streaming model endpoint.""" client, _ = build_model_endpoint_sync_chat_client( auth_settings, @@ -13596,6 +13610,9 @@ def build_streaming_multi_endpoint_client(auth_settings, provider, endpoint, api endpoint, api_version, deployment_name=deployment_name, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=allow_private_custom_endpoints, ) return client @@ -13744,7 +13761,7 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ model_cfg = next( ( model for model in models - if str(model.get('deploymentName') or model.get('deployment') or '').strip() == requested_deployment + if resolve_model_endpoint_request_model(resolved_endpoint_cfg, model) == requested_deployment ), None, ) @@ -13776,10 +13793,12 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ connection = resolved_endpoint_cfg.get('connection', {}) or {} auth_settings = resolved_endpoint_cfg.get('auth', {}) or {} - deployment = str(model_cfg.get('deploymentName') or model_cfg.get('deployment') or '').strip() + deployment = resolve_model_endpoint_request_model(resolved_endpoint_cfg, model_cfg) endpoint = str(connection.get('endpoint') or '').strip() api_version = str(connection.get('openai_api_version') or connection.get('api_version') or '').strip() - runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment) + api_type = get_model_endpoint_api_type(resolved_endpoint_cfg) + anthropic_version = str(connection.get('anthropic_version') or '').strip() + runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment, api_type) model_icon = _normalize_model_icon_payload(model_cfg.get('icon')) model_response_length = normalize_model_response_length_from_model(model_cfg) model_behavior_name = _build_model_endpoint_behavior_name(model_cfg, deployment) @@ -13813,11 +13832,16 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ endpoint, api_version, deployment_name=deployment, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=bool( + settings.get('allow_private_custom_model_endpoints', False) + ), ) debug_print( f"[STREAMING][Model Resolution] Resolved {selection_source} multi-endpoint model | " f"provider={provider} | endpoint_id={requested_endpoint_id} | model_id={model_cfg.get('id')} | " - f"deployment={deployment} | api_version={api_version} | protocol={runtime_protocol} | " + f"request_model={deployment} | api_version={api_version} | api_type={api_type} | protocol={runtime_protocol} | " f"response_length={model_response_length or ''} | " f"response_length_parameter={model_response_length_parameter or ''}" ) @@ -13828,6 +13852,8 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ endpoint, auth_settings, api_version, + api_type, + anthropic_version, requested_endpoint_id, str(model_cfg.get('id') or '').strip(), model_icon, @@ -16135,6 +16161,8 @@ def result_requires_message_reload(result: Any) -> bool: gpt_endpoint = None gpt_auth = None gpt_api_version = None + gpt_api_type = None + gpt_anthropic_version = None gpt_endpoint_id = None gpt_model_id = None gpt_model_icon = None @@ -16169,6 +16197,8 @@ def result_requires_message_reload(result: Any) -> bool: gpt_endpoint, gpt_auth, gpt_api_version, + gpt_api_type, + gpt_anthropic_version, gpt_endpoint_id, gpt_model_id, gpt_model_icon, @@ -16262,9 +16292,12 @@ def result_requires_message_reload(result: Any) -> bool: endpoint=gpt_endpoint, auth=gpt_auth, api_version=gpt_api_version, + api_type=gpt_api_type, + anthropic_version=gpt_anthropic_version, endpoint_id=gpt_endpoint_id or data.get('model_endpoint_id'), model_id=gpt_model_id or data.get('model_id'), model_deployment=gpt_model, + request_model=gpt_model, user_id=user_id, active_group_ids=active_group_ids, ) @@ -20484,6 +20517,8 @@ def build_streaming_capability_usage(): gpt_endpoint = None gpt_auth = None gpt_api_version = None + gpt_api_type = None + gpt_anthropic_version = None gpt_endpoint_id = None gpt_model_id = None gpt_model_icon = None @@ -20519,6 +20554,8 @@ def build_streaming_capability_usage(): gpt_endpoint, gpt_auth, gpt_api_version, + gpt_api_type, + gpt_anthropic_version, gpt_endpoint_id, gpt_model_id, gpt_model_icon, @@ -20595,9 +20632,12 @@ def build_streaming_capability_usage(): endpoint=gpt_endpoint, auth=gpt_auth, api_version=gpt_api_version, + api_type=gpt_api_type, + anthropic_version=gpt_anthropic_version, endpoint_id=gpt_endpoint_id or frontend_model_endpoint_id, model_id=gpt_model_id or frontend_model_id, model_deployment=gpt_model, + request_model=gpt_model, user_id=user_id, active_group_ids=active_group_ids, ) diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py index 9ba537649..c100fdb20 100644 --- a/application/single_app/route_backend_conversation_export.py +++ b/application/single_app/route_backend_conversation_export.py @@ -47,6 +47,11 @@ ) from functions_settings import * from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper +from functions_model_endpoint_runtime import build_model_endpoint_sync_chat_client +from functions_model_endpoint_types import ( + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) from functions_simplechat_operations import download_blob_content from functions_thoughts import get_thoughts_for_conversation from foundry_agent_runtime import resolve_authority @@ -1125,13 +1130,24 @@ def _get_summary_model_endpoint_candidates(settings: Dict[str, Any], user_id: st return candidates -def _summary_model_matches(model_cfg: Dict[str, Any], requested_model: str, requested_model_id: str) -> bool: +def _summary_model_matches( + endpoint_cfg: Dict[str, Any], + model_cfg: Dict[str, Any], + requested_model: str, + requested_model_id: str, +) -> bool: + request_model = '' + if _normalize_summary_model_value(endpoint_cfg.get('provider')).lower() == 'custom': + request_model = _normalize_summary_model_value( + resolve_model_endpoint_request_model(endpoint_cfg, model_cfg) + ) model_values = { _normalize_summary_model_value(model_cfg.get('id')), _normalize_summary_model_value(model_cfg.get('deploymentName')), _normalize_summary_model_value(model_cfg.get('deployment')), _normalize_summary_model_value(model_cfg.get('modelName')), _normalize_summary_model_value(model_cfg.get('name')), + request_model, } model_values.discard('') @@ -1149,7 +1165,12 @@ def _find_summary_endpoint_model( for model_cfg in models: if not isinstance(model_cfg, dict) or not model_cfg.get('enabled', True): continue - if _summary_model_matches(model_cfg, requested_model, requested_model_id): + if _summary_model_matches( + endpoint_cfg, + model_cfg, + requested_model, + requested_model_id, + ): return model_cfg return None @@ -1185,55 +1206,88 @@ def _build_summary_model_endpoint_client( endpoint: str, api_version: str, deployment_name: str, + *, + api_type: str = '', + anthropic_version: str = '', + allow_private_custom_endpoints: bool = False, ): auth_settings = auth_settings or {} - 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) + if normalized_provider != 'custom': + auth_type = _normalize_summary_model_value( + auth_settings.get('type') or 'managed_identity' + ).lower() + runtime_protocol = infer_model_endpoint_protocol( + normalized_provider, + endpoint, + deployment_name, + ) + + if auth_type in ('api_key', 'key'): + api_key = auth_settings.get('api_key') + 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) + if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: + return build_openai_style_chat_client(api_key, endpoint, api_version) + return AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + api_key=api_key, + ) + + if auth_type == 'service_principal': + credential = ClientSecretCredential( + tenant_id=auth_settings.get('tenant_id'), + client_id=auth_settings.get('client_id'), + client_secret=auth_settings.get('client_secret'), + authority=resolve_authority(auth_settings), + ) + else: + managed_identity_client_id = auth_settings.get( + 'managed_identity_client_id' + ) or None + credential = DefaultAzureCredential( + managed_identity_client_id=managed_identity_client_id + ) + + scope = cognitive_services_scope + if ( + normalized_provider in ('aifoundry', 'new_foundry') + or runtime_protocol != MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI + ): + scope = _resolve_summary_foundry_scope_for_auth( + auth_settings, + endpoint=endpoint, + ) - if auth_type in ('api_key', 'key'): - api_key = auth_settings.get('api_key') - 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) + token = credential.get_token(scope).token + return build_anthropic_chat_client(endpoint=endpoint, bearer_token=token) + if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: - return build_openai_style_chat_client(api_key, endpoint, api_version) + token = credential.get_token(scope).token + return build_openai_style_chat_client(token, endpoint, api_version) + + token_provider = get_bearer_token_provider(credential, scope) return AzureOpenAI( api_version=api_version, azure_endpoint=endpoint, - api_key=api_key, + azure_ad_token_provider=token_provider, ) - if auth_type == 'service_principal': - credential = ClientSecretCredential( - tenant_id=auth_settings.get('tenant_id'), - client_id=auth_settings.get('client_id'), - client_secret=auth_settings.get('client_secret'), - authority=resolve_authority(auth_settings), - ) - else: - managed_identity_client_id = auth_settings.get('managed_identity_client_id') or None - credential = DefaultAzureCredential(managed_identity_client_id=managed_identity_client_id) - - scope = cognitive_services_scope - if normalized_provider in ('aifoundry', 'new_foundry') or runtime_protocol != MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI: - scope = _resolve_summary_foundry_scope_for_auth(auth_settings, endpoint=endpoint) - - if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: - token = credential.get_token(scope).token - return build_anthropic_chat_client(endpoint=endpoint, bearer_token=token) - - if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: - token = credential.get_token(scope).token - return build_openai_style_chat_client(token, endpoint, api_version) - - token_provider = get_bearer_token_provider(credential, scope) - return AzureOpenAI( - api_version=api_version, - azure_endpoint=endpoint, - azure_ad_token_provider=token_provider, + client, _ = build_model_endpoint_sync_chat_client( + auth_settings, + provider, + endpoint, + api_version, + deployment_name=deployment_name, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=allow_private_custom_endpoints, ) + return client def _resolve_summary_multi_endpoint_client( @@ -1297,12 +1351,34 @@ def _resolve_summary_multi_endpoint_client( provider = _normalize_summary_model_value(resolved_endpoint_cfg.get('provider') or requested_provider or 'aoai').lower() connection = resolved_endpoint_cfg.get('connection', {}) or {} auth_settings = resolved_endpoint_cfg.get('auth', {}) or {} - deployment = _normalize_summary_model_value( - model_cfg.get('deploymentName') or model_cfg.get('deployment') or model_cfg.get('id') - ) + if provider == 'custom': + deployment = resolve_model_endpoint_request_model( + resolved_endpoint_cfg, + model_cfg, + ) + else: + deployment = _normalize_summary_model_value( + model_cfg.get('deploymentName') + or model_cfg.get('deployment') + or model_cfg.get('modelName') + or model_cfg.get('name') + ) endpoint = _normalize_summary_model_value(connection.get('endpoint')) api_version = _normalize_summary_model_value(connection.get('openai_api_version') or connection.get('api_version')) - runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment) + api_type = ( + get_model_endpoint_api_type(resolved_endpoint_cfg) + if provider == 'custom' + else '' + ) + anthropic_version = _normalize_summary_model_value( + connection.get('anthropic_version') + ) + runtime_protocol = infer_model_endpoint_protocol( + provider, + endpoint, + deployment, + api_type, + ) missing_required_config = not endpoint or not deployment or ( runtime_protocol == MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI and not api_version @@ -1318,11 +1394,16 @@ def _resolve_summary_multi_endpoint_client( endpoint, api_version, deployment, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=bool( + settings.get('allow_private_custom_model_endpoints', False) + ), ) debug_print( f"[SUMMARY][Model Resolution] Resolved {selection_source} multi-endpoint model | " f"provider={provider} | endpoint_id={endpoint_id} | model_id={model_cfg.get('id')} | " - f"deployment={deployment} | api_version={api_version} | protocol={runtime_protocol}" + f"request_model={deployment} | api_version={api_version} | api_type={api_type} | protocol={runtime_protocol}" ) return gpt_client, deployment diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index 652b1558f..148560792 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -7,6 +7,18 @@ from functions_governance import ensure_governance_access from functions_group import assert_group_role, get_group_model_endpoints, require_active_group, update_group_model_endpoints from functions_keyvault import SecretReturnType, keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_get_helper, keyvault_model_endpoint_save_helper +from functions_model_endpoint_runtime import build_model_endpoint_sync_chat_client +from functions_model_endpoint_types import ( + DEFAULT_ANTHROPIC_VERSION, + MODEL_ENDPOINT_PROVIDER_CUSTOM, + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) +from functions_model_endpoint_validation import ( + ModelEndpointValidationError, + validate_custom_model_endpoint, + validate_custom_model_endpoints, +) from functions_settings import * from foundry_agent_runtime import FoundryAgentUserAuthenticationRequired, list_foundry_agents_from_endpoint, list_foundry_workflows_from_endpoint, list_new_foundry_agents_from_endpoint, resolve_foundry_project_base, resolve_foundry_project_api_version, build_project_credential, resolve_authority from functions_appinsights import log_event @@ -169,7 +181,39 @@ def resolve_request_endpoint_payload(payload, scope="global"): # Persisted non-admin endpoints must resolve from stored configuration only. merged_payload = merge_model_endpoint_payload(persisted_endpoint, {}) if "model" in payload: - merged_payload["model"] = payload.get("model") + requested_model = payload.get("model") + if not isinstance(requested_model, dict): + raise LookupError("Model endpoint model not found.") + requested_model_id = str(requested_model.get("id") or "").strip() + requested_model_name = resolve_model_endpoint_request_model( + persisted_endpoint, + requested_model, + ) + persisted_model = next( + ( + model + for model in (persisted_endpoint.get("models") or []) + if isinstance(model, dict) + and model.get("enabled", True) + and ( + ( + requested_model_id + and str(model.get("id") or "").strip() == requested_model_id + ) + or ( + requested_model_name + and resolve_model_endpoint_request_model( + persisted_endpoint, + model, + ) == requested_model_name + ) + ) + ), + None, + ) + if not persisted_model: + raise LookupError("Model endpoint model not found.") + merged_payload["model"] = persisted_model else: merged_payload = merge_model_endpoint_payload(persisted_endpoint or {}, payload) @@ -268,54 +312,31 @@ def build_legacy_aoai_discovery_auth_settings(): "client_secret": MICROSOFT_PROVIDER_AUTHENTICATION_SECRET, } - def build_inference_client(endpoint, api_version, auth_settings, provider="aoai", deployment_name=""): - auth_type = (auth_settings.get("type") or "managed_identity").lower() - runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment_name) - if auth_type == "api_key": - api_key = auth_settings.get("api_key") - if not api_key: - raise ValueError("API key is required for API key authentication.") - if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: - return build_anthropic_chat_client(endpoint=endpoint, api_key=api_key) - if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: - return build_openai_style_chat_client(api_key, endpoint, api_version) - return AzureOpenAI( - api_version=api_version, - azure_endpoint=endpoint, - api_key=api_key - ) - - if auth_type == "service_principal": - authority_override = resolve_authority(auth_settings) - credential = ClientSecretCredential( - tenant_id=auth_settings.get("tenant_id"), - client_id=auth_settings.get("client_id"), - client_secret=auth_settings.get("client_secret"), - authority=authority_override - ) - else: - managed_identity_client_id = auth_settings.get("managed_identity_client_id") or None - credential = DefaultAzureCredential(managed_identity_client_id=managed_identity_client_id) - - scope = cognitive_services_scope - if provider in ("aifoundry", "new_foundry") or runtime_protocol != MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI: - scope = resolve_foundry_scope(auth_settings) - log_models_debug(f"Inference token scope={scope} provider={provider} protocol={runtime_protocol}") - - if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC: - token = credential.get_token(scope).token - return build_anthropic_chat_client(endpoint=endpoint, bearer_token=token) - - if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE: - token = credential.get_token(scope).token - return build_openai_style_chat_client(token, endpoint, api_version) - - token_provider = get_bearer_token_provider(credential, scope) - return AzureOpenAI( - api_version=api_version, - azure_endpoint=endpoint, - azure_ad_token_provider=token_provider + def build_inference_client( + endpoint, + api_version, + auth_settings, + provider="aoai", + deployment_name="", + api_type="", + anthropic_version=DEFAULT_ANTHROPIC_VERSION, + ): + client, runtime_protocol = build_model_endpoint_sync_chat_client( + auth_settings, + provider, + endpoint, + api_version, + deployment_name=deployment_name, + api_type=api_type, + anthropic_version=anthropic_version, + allow_private_custom_endpoints=bool( + get_settings().get("allow_private_custom_model_endpoints", False) + ), ) + log_models_debug( + f"Inference client provider={provider} protocol={runtime_protocol}" + ) + return client def fetch_foundry_project_deployments(endpoint, api_version, auth_settings, project_name=None): if not endpoint: @@ -373,6 +394,12 @@ def handle_fetch_model_list(scope="global"): f" resource_group_present={bool(management.get('resource_group'))}" ) + if provider == MODEL_ENDPOINT_PROVIDER_CUSTOM: + return build_safe_error_response( + "Model discovery is not available for Custom endpoints. Add models manually.", + 400, + ) + if provider in ("aifoundry", "new_foundry"): endpoint = connection.get("endpoint") api_version = connection.get("project_api_version") or connection.get("api_version") or "v1" @@ -464,23 +491,48 @@ def handle_test_model_connection(scope="global"): endpoint = connection.get("endpoint") or "" api_version = connection.get("openai_api_version") or connection.get("api_version") or "" - deployment_name = model.get("deploymentName") or "" - runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment_name) + api_type = get_model_endpoint_api_type(data) + anthropic_version = ( + connection.get("anthropic_version") + or DEFAULT_ANTHROPIC_VERSION + ) + request_model = resolve_model_endpoint_request_model(data, model) + runtime_protocol = infer_model_endpoint_protocol( + provider, + endpoint, + request_model, + api_type, + ) auth_type = (auth_settings.get("type") or "managed_identity").lower() log_models_debug( "Test model request" f" provider={provider} auth_type={auth_type}" - f" endpoint={endpoint} deployment={deployment_name}" + f" endpoint={endpoint} model={request_model}" ) - if not endpoint or not deployment_name: - return jsonify({"error": "Endpoint and deployment name are required."}), 400 + if provider == MODEL_ENDPOINT_PROVIDER_CUSTOM: + validation_endpoint = dict(data) + validation_endpoint["models"] = [model] + validate_custom_model_endpoint( + validation_endpoint, + get_settings(), + ) - if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI and not api_version: - return jsonify({"error": "Endpoint, API version, and deployment name are required."}), 400 + if not endpoint or not request_model: + return jsonify({"error": "Endpoint and model identifier are required."}), 400 - if provider not in ("aoai", "aifoundry", "new_foundry", "anthropic", "claude"): + if runtime_protocol == MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI and not api_version: + return jsonify({"error": "Endpoint, API version, and model identifier are required."}), 400 + + if provider not in ( + "aoai", + "aifoundry", + "new_foundry", + "anthropic", + "claude", + MODEL_ENDPOINT_PROVIDER_CUSTOM, + ): return jsonify({"error": "Model provider not found."}), 400 gpt_client = build_inference_client( @@ -488,10 +540,12 @@ def handle_test_model_connection(scope="global"): api_version, auth_settings, provider=provider, - deployment_name=deployment_name, + deployment_name=request_model, + api_type=api_type, + anthropic_version=anthropic_version, ) response = gpt_client.chat.completions.create( - model=deployment_name, + model=request_model, messages=[{"role": "user", "content": "Testing access."}] ) @@ -820,6 +874,16 @@ def save_user_model_endpoints(): merged = merge_model_endpoints_with_existing(incoming, existing) normalized, _ = normalize_model_endpoints(merged) + try: + validate_custom_model_endpoints(normalized, get_settings()) + except ModelEndpointValidationError as exc: + log_models_exception( + "Personal model endpoint validation failed", + exc, + extra={"scope": "user"}, + level=logging.WARNING, + ) + return build_safe_error_response(str(exc), 400) existing_by_id = { endpoint.get("id"): endpoint for endpoint in existing @@ -861,7 +925,10 @@ def save_user_model_endpoints(): keyvault_model_endpoint_delete_helper(endpoint, endpoint_id, scope="user") update_user_settings(user_id, {"personal_model_endpoints": saved_endpoints}) - return jsonify({"success": True}) + return jsonify({ + "success": True, + "endpoints": sanitize_model_endpoints_for_frontend(saved_endpoints), + }) @bp.route('/api/group/model-endpoints', methods=['GET']) @@ -924,6 +991,16 @@ def save_group_model_endpoints(): merged = merge_model_endpoints_with_existing(incoming, existing) normalized, _ = normalize_model_endpoints(merged) + try: + validate_custom_model_endpoints(normalized, get_settings()) + except ModelEndpointValidationError as exc: + log_models_exception( + "Group model endpoint validation failed", + exc, + extra={"scope": "group"}, + level=logging.WARNING, + ) + return build_safe_error_response(str(exc), 400) existing_by_id = { endpoint.get("id"): endpoint for endpoint in existing @@ -965,7 +1042,10 @@ def save_group_model_endpoints(): keyvault_model_endpoint_delete_helper(endpoint, endpoint_id, scope="group") update_group_model_endpoints(group_id, saved_endpoints) - return jsonify({"success": True}) + return jsonify({ + "success": True, + "endpoints": sanitize_model_endpoints_for_frontend(saved_endpoints), + }) @bp.route('/api/models/foundry/agents', methods=['POST']) diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index b5c50f322..1ec8accf5 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -10,6 +10,10 @@ build_model_endpoint_sync_chat_client, resolve_model_endpoint_from_context, ) +from functions_model_endpoint_types import ( + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) from functions_activity_logging import ( log_admin_feedback_email_submission, log_general_admin_action, @@ -1389,6 +1393,7 @@ def _test_multimodal_vision_connection(payload): # Create a simple test image (1x1 red pixel PNG) test_image_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + is_custom_model_endpoint = False try: multi_endpoint_selection = payload.get('multi_endpoint') if isinstance(payload.get('multi_endpoint'), dict) else None @@ -1407,6 +1412,9 @@ def _test_multimodal_vision_connection(payload): resolved_endpoint = resolve_model_endpoint_from_context(settings, model_context) if not resolved_endpoint: return jsonify({'error': 'Selected vision model endpoint could not be resolved from saved settings'}), 400 + is_custom_model_endpoint = ( + str(resolved_endpoint.get('provider') or '').strip().lower() == 'custom' + ) resolved_models = resolved_endpoint.get('models', []) or [] matched_model = next( @@ -1420,18 +1428,14 @@ def _test_multimodal_vision_connection(payload): matched_model = next( ( model for model in resolved_models - if str(model.get('deploymentName') or model.get('deployment') or '').strip() == model_context['model_deployment'] + if resolve_model_endpoint_request_model(resolved_endpoint, model) == model_context['model_deployment'] ), None, ) if not matched_model: return jsonify({'error': 'Selected vision model could not be resolved from saved settings'}), 400 - vision_model = str( - matched_model.get('deploymentName') - or matched_model.get('deployment') - or model_context['model_deployment'] - ).strip() + vision_model = resolve_model_endpoint_request_model(resolved_endpoint, matched_model) vision_model_name = str(matched_model.get('modelName') or vision_model).strip() connection = resolved_endpoint.get('connection', {}) or {} gpt_client, _ = build_model_endpoint_sync_chat_client( @@ -1440,6 +1444,11 @@ def _test_multimodal_vision_connection(payload): connection.get('endpoint'), connection.get('openai_api_version') or connection.get('api_version'), deployment_name=vision_model, + api_type=get_model_endpoint_api_type(resolved_endpoint), + anthropic_version=connection.get('anthropic_version') or '', + allow_private_custom_endpoints=bool( + settings.get('allow_private_custom_model_endpoints', False) + ), ) elif enable_apim: apim_data = payload.get('apim', {}) @@ -1525,6 +1534,15 @@ def _test_multimodal_vision_connection(payload): }), 200 except Exception as e: + if is_custom_model_endpoint: + log_event( + "[MODEL_ENDPOINT] Custom vision model test failed", + extra={"exception_type": type(e).__name__}, + level=logging.WARNING, + ) + return jsonify({ + 'error': 'The Custom vision model test failed. Review the endpoint and model configuration.' + }), 500 return jsonify({'error': f'Vision test failed: {str(e)}'}), 500 def get_index_client() -> SearchIndexClient: diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 2271b43b9..4cf587118 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -9,6 +9,11 @@ from flask import current_app, jsonify, request from functions_keyvault import keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_save_helper, redact_model_endpoint_secret_values +from functions_model_endpoint_types import resolve_model_endpoint_request_model +from functions_model_endpoint_validation import ( + ModelEndpointValidationError, + validate_custom_model_endpoints, +) from functions_settings import * from functions_content_safety import normalize_content_safety_violation_message from functions_mcp_server_config import ( @@ -778,6 +783,8 @@ def admin_settings(): settings['allow_user_agents'] = False if 'allow_user_custom_endpoints' not in settings: settings['allow_user_custom_endpoints'] = settings.get('allow_user_custom_agent_endpoints', False) + if 'allow_private_custom_model_endpoints' not in settings: + settings['allow_private_custom_model_endpoints'] = False if 'allow_user_plugins' not in settings: settings['allow_user_plugins'] = False if 'allow_user_workflows' not in settings: @@ -1695,6 +1702,23 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul parsed_model_endpoints = merge_model_endpoints_with_existing(parsed_model_endpoints, existing_model_endpoints) parsed_model_endpoints, _ = normalize_model_endpoints(parsed_model_endpoints) + custom_endpoint_validation_settings = dict(settings) + custom_endpoint_validation_settings['allow_private_custom_model_endpoints'] = ( + form_data.get('allow_private_custom_model_endpoints') == 'on' + ) + try: + validate_custom_model_endpoints( + parsed_model_endpoints, + custom_endpoint_validation_settings, + ) + except ModelEndpointValidationError as exc: + log_event( + "[MODEL_ENDPOINT] Custom model endpoint validation failed", + extra={"exception_type": type(exc).__name__}, + level=logging.WARNING, + ) + flash(str(exc), 'danger') + return redirect(url_for('frontend_admin_settings.admin_settings')) existing_endpoints_by_id = { endpoint.get('id'): endpoint @@ -1857,9 +1881,10 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul if endpoint_provider: normalized_metadata_model_selection['provider'] = endpoint_provider metadata_extraction_model_deployment = str( - model_cfg.get('deploymentName') - or model_cfg.get('deployment') - or '' + resolve_model_endpoint_request_model( + endpoint_cfg, + model_cfg, + ) ).strip() else: normalized_metadata_model_selection = { @@ -2389,6 +2414,9 @@ def is_valid_url(url): 'gpt_model': gpt_model_obj, 'enable_multi_model_endpoints': enable_multi_model_endpoints, 'model_endpoints': parsed_model_endpoints, + 'allow_private_custom_model_endpoints': ( + form_data.get('allow_private_custom_model_endpoints') == 'on' + ), '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/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index ae57778a9..0d3ff27e2 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -5,6 +5,7 @@ from functions_authentication import * from functions_content import * from functions_settings import * +from functions_model_endpoint_types import resolve_model_endpoint_request_model from functions_agent_catalog import build_accessible_agent_catalog from functions_ai_notice import get_ai_notice_config, is_ai_notice_dismissed from functions_collaboration import ( @@ -460,15 +461,22 @@ def serialize_option(option): selection_key = _normalize_chat_model_value(option.get('selection_key')) model_id = _normalize_chat_model_value(option.get('model_id')) display_name = _normalize_chat_model_value( - option.get('display_name') or option.get('deployment_name') or option.get('model_id') + option.get('display_name') + or option.get('request_model') + or option.get('deployment_name') + or option.get('model_id') ) or 'Select a Model' deployment_name = _normalize_chat_model_value(option.get('deployment_name')) + request_model = _normalize_chat_model_value( + option.get('request_model') or deployment_name + ) scope_type = _normalize_chat_model_value(option.get('scope_type')) scope_name = _normalize_chat_model_value(option.get('scope_name')) search_parts = [ display_name, model_id, + request_model, deployment_name, scope_name or scope_type, ] @@ -476,6 +484,7 @@ def serialize_option(option): 'selection_key': selection_key, 'model_id': model_id, 'display_name': display_name, + 'request_model': request_model, 'deployment_name': deployment_name, 'endpoint_id': _normalize_chat_model_value(option.get('endpoint_id')), 'provider': _normalize_chat_model_value(option.get('provider')), @@ -483,23 +492,28 @@ def serialize_option(option): 'scope_id': _normalize_chat_model_value(option.get('scope_id')), 'scope_name': scope_name, 'icon': option.get('icon') if isinstance(option.get('icon'), dict) else {}, - 'option_value': deployment_name or model_id or selection_key, + 'option_value': request_model or deployment_name or model_id or selection_key, 'search_text': ' '.join(part for part in search_parts if part), } def sort_key(option): scope_type = _normalize_chat_model_value(option.get('scope_type')) display_name = _normalize_chat_model_value( - option.get('display_name') or option.get('deployment_name') or option.get('model_id') + option.get('display_name') + or option.get('request_model') + or option.get('deployment_name') + or option.get('model_id') ).lower() scope_name = _normalize_chat_model_value(option.get('scope_name')).lower() model_id = _normalize_chat_model_value(option.get('model_id')).lower() deployment_name = _normalize_chat_model_value(option.get('deployment_name')).lower() + request_model = _normalize_chat_model_value(option.get('request_model')).lower() return ( scope_order.get(scope_type, 99), scope_name, display_name, model_id, + request_model, deployment_name, ) @@ -521,7 +535,13 @@ def sort_key(option): if normalized_preferred_model_deployment: for option in sorted_options: deployment_name = _normalize_chat_model_value(option.get('deployment_name')) - if deployment_name == normalized_preferred_model_deployment: + request_model = _normalize_chat_model_value( + option.get('request_model') or deployment_name + ) + if ( + deployment_name == normalized_preferred_model_deployment + or request_model == normalized_preferred_model_deployment + ): return serialize_option(option) return serialize_option(sorted_options[0]) @@ -551,13 +571,15 @@ def append_models(endpoints, scope_type, scope_id=None, scope_name=None): model_id = model.get('id') or model.get('deploymentName') or model.get('deployment') or model.get('modelName') or model.get('name') or '' deployment_name = model.get('deploymentName') or model.get('deployment') or '' - display_name = model.get('displayName') or model.get('modelName') or deployment_name or model.get('name') or model_id - selection_key = f"{scope_type}:{scope_id or ''}:{endpoint_id}:{model_id or deployment_name}" + request_model = resolve_model_endpoint_request_model(endpoint, model) + display_name = model.get('displayName') or model.get('modelName') or request_model or deployment_name or model.get('name') or model_id + selection_key = f"{scope_type}:{scope_id or ''}:{endpoint_id}:{model_id or deployment_name or request_model}" catalog.append({ 'selection_key': selection_key, 'model_id': model_id, 'display_name': display_name, + 'request_model': request_model, 'deployment_name': deployment_name, 'endpoint_id': endpoint_id, 'provider': provider, @@ -751,6 +773,7 @@ def chats(): multi_endpoint_models.append({ "id": model.get("id"), "display_name": model.get("displayName") or model.get("deploymentName") or model.get("modelName") or "", + "request_model": resolve_model_endpoint_request_model(endpoint, model), "deployment_name": model.get("deploymentName") or "", "endpoint_id": endpoint.get("id"), "provider": endpoint.get("provider"), diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index d83ecdb19..0c3fd6163 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -27,6 +27,11 @@ from semantic_kernel_plugins.chart_plugin import ChartPlugin from semantic_kernel_plugins.tabular_processing_plugin import TabularProcessingPlugin from functions_settings import get_settings, get_user_settings, is_tabular_processing_enabled, resolve_model_endpoint_foundry_scope +from functions_model_endpoint_runtime import build_semantic_kernel_chat_service_for_model +from functions_model_endpoint_types import ( + get_model_endpoint_api_type, + resolve_model_endpoint_request_model, +) from foundry_agent_runtime import ( AzureAIFoundryChatCompletionAgent, AzureAIFoundryNewChatCompletionAgent, @@ -163,6 +168,7 @@ def resolve_agent_endpoint_protocol(agent_config): agent_config.get("model_provider") or agent_config.get("provider") or "aoai", agent_config.get("endpoint"), agent_config.get("deployment"), + agent_config.get("api_type"), ) @@ -177,11 +183,31 @@ def resolve_agent_endpoint_token(agent_config): return "" -def create_model_endpoint_chat_completion_service(agent_config, service_id): +def create_model_endpoint_chat_completion_service(agent_config, service_id, settings=None): """Create the correct Semantic Kernel chat service for an endpoint-bound agent.""" if not agent_config.get("endpoint") or not agent_config.get("deployment"): return None + provider = str( + agent_config.get("model_provider") or agent_config.get("provider") or "aoai" + ).strip().lower() + if provider == "custom": + chat_service, _ = build_semantic_kernel_chat_service_for_model( + agent_config["deployment"], + settings or {}, + service_id=service_id, + model_context={ + "provider": provider, + "endpoint": agent_config["endpoint"], + "api_version": agent_config.get("api_version") or "", + "api_type": agent_config.get("api_type") or "", + "anthropic_version": agent_config.get("anthropic_version") or "", + "auth": agent_config.get("auth") or {}, + "request_model": agent_config["deployment"], + }, + ) + return chat_service + runtime_protocol = resolve_agent_endpoint_protocol(agent_config) token_or_key = resolve_agent_endpoint_token(agent_config) if not token_or_key: @@ -562,13 +588,15 @@ def resolve_multi_endpoint_agent_binding(endpoint_candidates, endpoint_id, model provider = (endpoint_cfg.get("provider") or "aoai").lower() connection = endpoint_cfg.get("connection", {}) or {} auth = endpoint_cfg.get("auth", {}) or {} - deployment = model_cfg.get("deploymentName") or model_cfg.get("deployment") or "" + deployment = resolve_model_endpoint_request_model(endpoint_cfg, model_cfg) api_version = connection.get("openai_api_version") or connection.get("api_version") endpoint = connection.get("endpoint") return { "provider": provider, "endpoint": endpoint, "api_version": api_version, + "api_type": get_model_endpoint_api_type(endpoint_cfg), + "anthropic_version": connection.get("anthropic_version") or "", "deployment": deployment, "auth": auth, "model": model_cfg, @@ -792,7 +820,7 @@ def enrich_foundry_settings(foundry_settings, endpoint_cfg): if not per_user_enabled: try: token_provider = None - if multi_endpoint_config and multi_endpoint_config.get("provider") in ("aoai", "aifoundry", "new_foundry", "foundry_workflow"): + if multi_endpoint_config and multi_endpoint_config.get("provider") in ("aoai", "aifoundry", "new_foundry", "foundry_workflow", "custom"): auth = multi_endpoint_config.get("auth", {}) or {} auth_type = (auth.get("type") or "managed_identity").lower() provider = multi_endpoint_config.get("provider") @@ -800,13 +828,15 @@ def enrich_foundry_settings(foundry_settings, endpoint_cfg): deployment = multi_endpoint_config.get("deployment") api_version = multi_endpoint_config.get("api_version") key = auth.get("api_key") or "" - if auth_type != "api_key": + if auth_type not in ("api_key", "key"): token_provider = build_token_provider(auth, provider=provider, endpoint=endpoint) return { "endpoint": endpoint, "key": key, "deployment": deployment, "api_version": api_version, + "api_type": multi_endpoint_config.get("api_type") or "", + "anthropic_version": multi_endpoint_config.get("anthropic_version") or "", "instructions": agent.get("instructions", ""), "actions_to_load": agent.get("actions_to_load", []), "additional_settings": agent.get("additional_settings", {}), @@ -827,6 +857,7 @@ def enrich_foundry_settings(foundry_settings, endpoint_cfg): "model_endpoint_id": agent.get("model_endpoint_id", ""), "model_id": agent.get("model_id", ""), "model_provider": provider, + "auth": auth, } if global_apim_enabled: g_apim = get_global_apim() @@ -869,7 +900,7 @@ def enrich_foundry_settings(foundry_settings, endpoint_cfg): can_use_agent_endpoints = allow_custom_agent_endpoints user_apim_allowed = user_apim_enabled and can_use_agent_endpoints - if multi_endpoint_config and multi_endpoint_config.get("provider") in ("aoai", "aifoundry", "new_foundry", "foundry_workflow"): + if multi_endpoint_config and multi_endpoint_config.get("provider") in ("aoai", "aifoundry", "new_foundry", "foundry_workflow", "custom"): auth = multi_endpoint_config.get("auth", {}) or {} auth_type = (auth.get("type") or "managed_identity").lower() provider = multi_endpoint_config.get("provider") @@ -878,13 +909,15 @@ def enrich_foundry_settings(foundry_settings, endpoint_cfg): api_version = multi_endpoint_config.get("api_version") key = auth.get("api_key") or "" token_provider = None - if auth_type != "api_key": + if auth_type not in ("api_key", "key"): token_provider = build_token_provider(auth, provider=provider, endpoint=endpoint) result = { "endpoint": endpoint, "key": key, "deployment": deployment, "api_version": api_version, + "api_type": multi_endpoint_config.get("api_type") or "", + "anthropic_version": multi_endpoint_config.get("anthropic_version") or "", "instructions": agent.get("instructions", ""), "actions_to_load": agent.get("actions_to_load", []), "additional_settings": agent.get("additional_settings", {}), @@ -905,6 +938,7 @@ def enrich_foundry_settings(foundry_settings, endpoint_cfg): "model_endpoint_id": agent.get("model_endpoint_id", ""), "model_id": agent.get("model_id", ""), "model_provider": provider, + "auth": auth, } return result @@ -1778,7 +1812,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis apim_enabled = settings.get("enable_gpt_apim", False) def create_chat_completion_service(): - return create_model_endpoint_chat_completion_service(agent_config, service_id) + return create_model_endpoint_chat_completion_service(agent_config, service_id, settings) if agent_type in {"aifoundry", "new_foundry", "foundry_workflow"}: if agent_type == "foundry_workflow": @@ -2907,7 +2941,7 @@ def load_semantic_kernel(kernel: Kernel, settings): }, level=logging.INFO ) - chat_service = create_model_endpoint_chat_completion_service(agent_config, service_id) + chat_service = create_model_endpoint_chat_completion_service(agent_config, service_id, settings) if should_apply_prompt_settings(orchestrator_config, settings): if orchestrator_config.get('max_completion_tokens', -1) > 0: print(f"[SK_LOADER] Using {orchestrator_config['max_completion_tokens']} max_completion_tokens for {orchestrator_config['name']}") @@ -3005,7 +3039,7 @@ def load_semantic_kernel(kernel: Kernel, settings): }, level=logging.INFO ) - chat_service = create_model_endpoint_chat_completion_service(orchestrator_config, service_id) + chat_service = create_model_endpoint_chat_completion_service(orchestrator_config, service_id, settings) if should_apply_prompt_settings(agent_config, settings): if agent_config.get('max_completion_tokens', -1) > 0: print(f"[SK_LOADER] Using {agent_config['max_completion_tokens']} max_completion_tokens for {agent_config['name']}") 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..72460f77b 100644 --- a/application/single_app/static/js/admin/admin_model_endpoints.js +++ b/application/single_app/static/js/admin/admin_model_endpoints.js @@ -43,6 +43,8 @@ const endpointModal = endpointModalEl && window.bootstrap ? bootstrap.Modal.getO const endpointIdInput = document.getElementById("model-endpoint-id"); const endpointNameInput = document.getElementById("model-endpoint-name"); const endpointProviderSelect = document.getElementById("model-endpoint-provider"); +const endpointApiTypeGroup = document.getElementById("model-endpoint-api-type-group"); +const endpointApiTypeSelect = document.getElementById("model-endpoint-api-type"); const endpointUrlInput = document.getElementById("model-endpoint-endpoint"); const endpointUrlLabel = document.getElementById("model-endpoint-endpoint-label"); const endpointUrlHelp = document.getElementById("model-endpoint-endpoint-help"); @@ -54,6 +56,8 @@ const endpointProjectApiVersionCustomInput = document.getElementById("model-endp const endpointOpenAiApiVersionGroup = document.getElementById("model-endpoint-openai-api-version-group"); const endpointOpenAiApiVersionInput = document.getElementById("model-endpoint-openai-api-version"); const endpointOpenAiApiVersionCustomInput = document.getElementById("model-endpoint-openai-api-version-custom"); +const endpointAnthropicVersionGroup = document.getElementById("model-endpoint-anthropic-version-group"); +const endpointAnthropicVersionInput = document.getElementById("model-endpoint-anthropic-version"); const endpointSubscriptionGroup = document.getElementById("model-endpoint-subscription-group"); const endpointResourceGroup = document.getElementById("model-endpoint-resource-group-group"); const endpointSubscriptionInput = document.getElementById("model-endpoint-subscription-id"); @@ -66,6 +70,7 @@ const endpointCustomAuthorityInput = document.getElementById("model-endpoint-cus const endpointFoundryScopeGroup = document.getElementById("model-endpoint-foundry-scope-group"); const endpointFoundryScopeInput = document.getElementById("model-endpoint-foundry-scope"); const apiKeyNote = document.getElementById("model-endpoint-api-key-note"); +const apiKeyNoteText = document.getElementById("model-endpoint-api-key-note-text"); const miTypeGroup = document.getElementById("model-endpoint-mi-type-group"); const miClientGroup = document.getElementById("model-endpoint-mi-client-group"); @@ -105,6 +110,7 @@ let migrationSelectedKeys = new Set(); 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 DEFAULT_ANTHROPIC_VERSION = "2023-06-01"; const CUSTOM_VERSION_VALUE = "custom"; const MODEL_ICON_CLASS_PATTERN = /^bi-[a-z0-9][a-z0-9-]{0,80}$/; const MODEL_ICON_CONTROL_CONFIG = Object.freeze({ @@ -144,6 +150,40 @@ function isFoundryProvider(provider) { return provider === "aifoundry" || provider === "new_foundry"; } +function isCustomProvider(provider = endpointProviderSelect?.value) { + return provider === "custom"; +} + +function getCustomApiType() { + return endpointApiTypeSelect?.value || "openai"; +} + +function customApiTypeUsesModelName(apiType = getCustomApiType()) { + return apiType === "openai" || apiType === "anthropic"; +} + +function getModelRequestName(model) { + if (isCustomProvider() && customApiTypeUsesModelName()) { + return String(model?.modelName || "").trim(); + } + return String(model?.deploymentName || model?.deployment || "").trim(); +} + +function setModelRequestName(model, value) { + const requestName = String(value || "").trim(); + if (isCustomProvider() && customApiTypeUsesModelName()) { + model.modelName = requestName; + delete model.deploymentName; + delete model.deployment; + return; + } + model.deploymentName = requestName; + if (isCustomProvider()) { + delete model.modelName; + delete model.name; + } +} + function endpointIncludesProject(endpoint) { return String(endpoint || "").toLowerCase().includes("/api/projects/"); } @@ -193,9 +233,13 @@ function syncEndpointCopyForProvider() { : "Endpoint Fully Qualified Domain Name (FQDN)"; } if (endpointUrlHelp) { - endpointUrlHelp.textContent = isFoundryProvider(provider) - ? "Paste the Project endpoint from Azure AI Foundry. It can include /api/projects/; Claude deployments are detected from the model name." - : "For Azure OpenAI, paste the resource endpoint."; + if (isFoundryProvider(provider)) { + endpointUrlHelp.textContent = "Paste the Project endpoint from Azure AI Foundry. It can include /api/projects/; Claude deployments are detected from the model name."; + } else if (isCustomProvider(provider)) { + endpointUrlHelp.textContent = "Enter the HTTPS FQDN for the Custom endpoint."; + } else { + endpointUrlHelp.textContent = "For Azure OpenAI, paste the resource endpoint."; + } } } @@ -371,6 +415,9 @@ function formatProviderLabel(provider) { if (provider === "new_foundry") { return "New Foundry"; } + if (provider === "custom") { + return "Custom"; + } return "Azure OpenAI"; } @@ -396,7 +443,7 @@ function syncOpenAiApiVersionForProvider() { return; } - if (!currentValue) { + if (!currentValue || currentValue === DEFAULT_FOUNDRY_OPENAI_API_VERSION) { setSelectedVersionValue( endpointOpenAiApiVersionInput, endpointOpenAiApiVersionCustomInput, @@ -755,18 +802,32 @@ function handleMetadataExtractionModelChange() { } function updateAuthVisibility() { - const authType = endpointAuthTypeSelect?.value || "managed_identity"; + const modelsPlaceholder = document.getElementById("model-endpoint-models-placeholder"); const provider = endpointProviderSelect?.value || "aoai"; + const customProvider = isCustomProvider(provider); + if (customProvider && endpointAuthTypeSelect) { + endpointAuthTypeSelect.value = "api_key"; + } + if (endpointAuthTypeSelect) { + endpointAuthTypeSelect.disabled = customProvider; + } + setElementVisibility(endpointApiTypeGroup, customProvider); + + const apiType = getCustomApiType(); + const authType = endpointAuthTypeSelect?.value || "managed_identity"; const isApiKey = authType === "api_key"; - const isFoundry = isFoundryProvider(provider); + const isFoundry = !customProvider && isFoundryProvider(provider); + const showOpenAiVersion = !customProvider || apiType === "azure_openai"; + const showAnthropicVersion = customProvider && apiType === "anthropic"; const projectNameFromEndpoint = syncProjectNameFromEndpoint(); syncEndpointCopyForProvider(); syncVersionCustomVisibility(); setElementVisibility(endpointProjectGroup, isFoundry && !projectNameFromEndpoint); setElementVisibility(endpointProjectApiVersionGroup, isFoundry); - setElementVisibility(endpointOpenAiApiVersionGroup, true); - setElementVisibility(endpointSubscriptionGroup, provider === "aoai" && !isApiKey); - setElementVisibility(endpointResourceGroup, provider === "aoai" && !isApiKey); + setElementVisibility(endpointOpenAiApiVersionGroup, showOpenAiVersion); + setElementVisibility(endpointAnthropicVersionGroup, showAnthropicVersion); + setElementVisibility(endpointSubscriptionGroup, !customProvider && provider === "aoai" && !isApiKey); + setElementVisibility(endpointResourceGroup, !customProvider && provider === "aoai" && !isApiKey); setElementVisibility(miTypeGroup, authType === "managed_identity"); setElementVisibility(miClientGroup, authType === "managed_identity" && (miTypeSelect?.value === "user_assigned")); setElementVisibility(tenantGroup, authType === "service_principal"); @@ -776,9 +837,27 @@ function updateAuthVisibility() { setElementVisibility(endpointManagementCloudGroup, authType === "service_principal" && isFoundry); setElementVisibility(endpointCustomAuthorityGroup, authType === "service_principal" && isFoundry && endpointManagementCloudSelect?.value === "custom"); setElementVisibility(endpointFoundryScopeGroup, authType === "service_principal" && isFoundry && endpointManagementCloudSelect?.value === "custom"); - setElementVisibility(apiKeyNote, authType === "api_key"); - setElementVisibility(addModelBtn, authType === "api_key"); - setElementVisibility(fetchBtn, authType !== "api_key"); + setElementVisibility(apiKeyNote, customProvider || authType === "api_key"); + setElementVisibility(addModelBtn, customProvider || authType === "api_key"); + setElementVisibility(fetchBtn, !customProvider && authType !== "api_key"); + + if (customProvider) { + if (apiKeyNoteText) { + apiKeyNoteText.textContent = "Custom endpoints use API key authentication and manual model entry. Model discovery is unavailable."; + } + if (modelsPlaceholder) { + modelsPlaceholder.textContent = "Add a model manually."; + } + } else { + if (apiKeyNoteText) { + apiKeyNoteText.textContent = "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."; + } + if (modelsPlaceholder) { + modelsPlaceholder.textContent = authType === "api_key" + ? "Add a model manually, or switch authentication to discover deployments." + : "Fetch models or add a model manually."; + } + } } function resetModal() { @@ -788,6 +867,7 @@ function resetModal() { if (endpointIdInput) endpointIdInput.value = ""; if (endpointNameInput) endpointNameInput.value = ""; if (endpointProviderSelect) endpointProviderSelect.value = "aoai"; + if (endpointApiTypeSelect) endpointApiTypeSelect.value = "openai"; if (endpointUrlInput) endpointUrlInput.value = ""; if (endpointProjectInput) endpointProjectInput.value = ""; setSelectedVersionValue( @@ -800,6 +880,7 @@ function resetModal() { endpointOpenAiApiVersionCustomInput, getDefaultOpenAiApiVersion("aoai") ); + if (endpointAnthropicVersionInput) endpointAnthropicVersionInput.value = DEFAULT_ANTHROPIC_VERSION; if (endpointSubscriptionInput) endpointSubscriptionInput.value = ""; if (endpointResourceGroupInput) endpointResourceGroupInput.value = ""; if (endpointAuthTypeSelect) endpointAuthTypeSelect.value = "managed_identity"; @@ -816,7 +897,7 @@ function resetModal() { if (apiKeyInput) apiKeyInput.placeholder = ""; modalModels = []; - if (modelsListEl) modelsListEl.innerHTML = "

Fetch models to begin selection.

"; + if (modelsListEl) modelsListEl.innerHTML = "

Fetch models to begin selection.

"; updateAuthVisibility(); } @@ -832,6 +913,7 @@ function openModalForEndpoint(endpoint) { if (endpointIdInput) endpointIdInput.value = endpoint.id || ""; if (endpointNameInput) endpointNameInput.value = endpoint.name || ""; if (endpointProviderSelect) endpointProviderSelect.value = endpoint.provider || "aoai"; + if (endpointApiTypeSelect) endpointApiTypeSelect.value = endpoint.api_type || "openai"; if (endpointUrlInput) endpointUrlInput.value = endpoint.connection?.endpoint || ""; if (endpointProjectInput) endpointProjectInput.value = endpoint.connection?.project_name || ""; setSelectedVersionValue( @@ -844,6 +926,9 @@ function openModalForEndpoint(endpoint) { endpointOpenAiApiVersionCustomInput, endpoint.connection?.openai_api_version || endpoint.connection?.api_version || getDefaultOpenAiApiVersion(endpoint.provider || "aoai") ); + if (endpointAnthropicVersionInput) { + endpointAnthropicVersionInput.value = endpoint.connection?.anthropic_version || DEFAULT_ANTHROPIC_VERSION; + } if (endpointSubscriptionInput) endpointSubscriptionInput.value = endpoint.management?.subscription_id || ""; if (endpointResourceGroupInput) endpointResourceGroupInput.value = endpoint.management?.resource_group || ""; if (endpointAuthTypeSelect) endpointAuthTypeSelect.value = endpoint.auth?.type || "managed_identity"; @@ -1174,7 +1259,7 @@ function renderModalModels(models) { } if (!models || !models.length) { - modelsListEl.innerHTML = "

No models loaded yet.

"; + modelsListEl.innerHTML = "

No models loaded yet.

"; return; } @@ -1182,12 +1267,15 @@ function renderModalModels(models) { models.forEach((model) => { const wrapper = document.createElement("div"); wrapper.className = "border rounded p-2 mb-2"; - const deploymentName = model.deploymentName || ""; + const requestName = getModelRequestName(model); const modelName = model.modelName || ""; - const displayName = model.displayName || deploymentName; + const displayName = model.displayName || requestName; const description = model.description || ""; const responseLength = getModelResponseLength(model); - const deploymentReadonly = model.isDiscovered ? "readonly" : ""; + const requestNameReadonly = model.isDiscovered && !isCustomProvider(); + const requestNameLabel = isCustomProvider() && customApiTypeUsesModelName() + ? "Model Name" + : "Deployment Name"; const modelId = model.id || generateId(); model.id = modelId; @@ -1198,8 +1286,8 @@ function renderModalModels(models) { checkbox.dataset.modelId = modelId; checkbox.checked = !!model.enabled; const checkboxLabel = createElement("label", "form-check-label"); - checkboxLabel.appendChild(document.createTextNode(deploymentName)); - if (modelName) { + checkboxLabel.appendChild(document.createTextNode(requestName)); + if (!isCustomProvider() && modelName) { checkboxLabel.appendChild(document.createTextNode(" ")); const modelNameLabel = createElement("span", "text-muted"); modelNameLabel.textContent = `(${modelName})`; @@ -1210,8 +1298,8 @@ function renderModalModels(models) { const fieldsRow = createElement("div", "row g-2"); const deploymentCol = createElement("div", "col-md-4"); - deploymentCol.appendChild(createSmallLabel("Deployment Name")); - deploymentCol.appendChild(createModelTextInput(modelId, "deploymentNameFor", deploymentName, Boolean(deploymentReadonly))); + deploymentCol.appendChild(createSmallLabel(requestNameLabel)); + deploymentCol.appendChild(createModelTextInput(modelId, "requestModelFor", requestName, requestNameReadonly)); const displayCol = createElement("div", "col-md-4"); displayCol.appendChild(createSmallLabel("Display Name")); displayCol.appendChild(createModelTextInput(modelId, "displayNameFor", displayName)); @@ -1270,7 +1358,7 @@ function collectModalModels() { const updated = modalModels.map((model) => ({ ...model })); updated.forEach((model) => { const checkbox = modelsListEl.querySelector(`input[data-model-id="${model.id}"]`); - const deploymentInput = modelsListEl.querySelector(`input[data-deployment-name-for="${model.id}"]`); + const requestModelInput = modelsListEl.querySelector(`input[data-request-model-for="${model.id}"]`); const displayInput = modelsListEl.querySelector(`input[data-display-name-for="${model.id}"]`); const descriptionInput = modelsListEl.querySelector(`input[data-description-for="${model.id}"]`); const responseLengthInput = modelsListEl.querySelector(`input[data-response-length-for="${model.id}"]`); @@ -1280,7 +1368,7 @@ function collectModalModels() { throw new Error("Response length must be a positive whole number."); } model.enabled = checkbox ? checkbox.checked : model.enabled; - model.deploymentName = deploymentInput ? deploymentInput.value.trim() : model.deploymentName; + setModelRequestName(model, requestModelInput ? requestModelInput.value : getModelRequestName(model)); model.displayName = displayInput ? displayInput.value.trim() : model.displayName; model.icon = iconEditor ? getIconPayload(iconEditor, MODEL_ICON_CONTROL_CONFIG) : model.icon || {}; model.description = descriptionInput ? descriptionInput.value.trim() : model.description; @@ -1295,16 +1383,17 @@ function collectModalModels() { async function testModelConnection(model) { const payload = buildEndpointPayload(); - if (!payload || !model?.deploymentName) { - showToast("Model deployment name is required for testing.", "warning"); + const requestModel = getModelRequestName(model); + if (!payload || !requestModel) { + showToast(`${isCustomProvider() && customApiTypeUsesModelName() ? "Model" : "Deployment"} name is required for testing.`, "warning"); return; } + const testModel = {}; + setModelRequestName(testModel, requestModel); const requestBody = { ...payload, - model: { - deploymentName: model.deploymentName - } + model: testModel }; try { @@ -1325,6 +1414,10 @@ async function testModelConnection(model) { } async function fetchModels() { + if (isCustomProvider()) { + showToast("Model discovery is unavailable for Custom endpoints. Add models manually.", "warning"); + return; + } const payload = buildEndpointPayload(); if (!payload) { return; @@ -1390,6 +1483,8 @@ function buildEndpointPayload() { const name = endpointNameInput.value.trim(); const endpoint = endpointUrlInput.value.trim(); const provider = endpointProviderSelect?.value || "aoai"; + const customProvider = isCustomProvider(provider); + const apiType = getCustomApiType(); const projectNameFromEndpoint = isFoundryProvider(provider) ? syncProjectNameFromEndpoint() : ""; const projectName = projectNameFromEndpoint || endpointProjectInput?.value.trim() || ""; const projectApiVersion = getSelectedVersionValue( @@ -1404,11 +1499,21 @@ function buildEndpointPayload() { ); const subscriptionId = endpointSubscriptionInput?.value.trim() || ""; const resourceGroup = endpointResourceGroupInput?.value.trim() || ""; - const authType = endpointAuthTypeSelect?.value || "managed_identity"; + const authType = customProvider ? "api_key" : (endpointAuthTypeSelect?.value || "managed_identity"); const existingEndpoint = modelEndpoints.find((savedEndpoint) => savedEndpoint.id === endpointId); - if (!name || !endpoint || !openAiApiVersion) { - showToast("Endpoint name, URL, and OpenAI API version are required.", "warning"); + if (!name || !endpoint) { + showToast("Endpoint name and URL are required.", "warning"); + return null; + } + + if (customProvider && !/^https:\/\//i.test(endpoint)) { + showToast("Custom endpoint URLs must use HTTPS.", "warning"); + return null; + } + + if ((!customProvider || apiType === "azure_openai") && !openAiApiVersion) { + showToast("OpenAI API version is required.", "warning"); return null; } @@ -1427,7 +1532,7 @@ function buildEndpointPayload() { return null; } - const auth = { + let auth = { type: authType, managed_identity_type: miTypeSelect?.value || "system_assigned", managed_identity_client_id: miClientIdInput?.value.trim() || "", @@ -1439,6 +1544,12 @@ function buildEndpointPayload() { custom_authority: endpointCustomAuthorityInput?.value.trim() || "", foundry_scope: endpointFoundryScopeInput?.value.trim() || "" }; + if (customProvider) { + auth = { + type: "api_key", + api_key: apiKeyInput?.value.trim() || "" + }; + } const hasStoredApiKey = authType === "api_key" && Boolean(existingEndpoint?.has_api_key); const hasStoredClientSecret = authType === "service_principal" && Boolean(existingEndpoint?.has_client_secret); @@ -1464,17 +1575,21 @@ function buildEndpointPayload() { return null; } - const management = provider === "aoai" ? { + const management = !customProvider && provider === "aoai" ? { subscription_id: subscriptionId, resource_group: resourceGroup } : {}; - const connection = { - endpoint, - openai_api_version: openAiApiVersion - }; + const connection = { endpoint }; + if (customProvider && apiType === "azure_openai") { + connection.api_version = openAiApiVersion; + } else if (customProvider && apiType === "anthropic") { + connection.anthropic_version = endpointAnthropicVersionInput?.value.trim() || DEFAULT_ANTHROPIC_VERSION; + } else if (!customProvider) { + connection.openai_api_version = openAiApiVersion; + } - if (isFoundryProvider(provider)) { + if (!customProvider && isFoundryProvider(provider)) { connection.project_api_version = projectApiVersion; if (projectName) { connection.project_name = projectName; @@ -1484,6 +1599,7 @@ function buildEndpointPayload() { return { id: endpointId, provider, + ...(customProvider ? { api_type: apiType } : {}), name, connection, management, @@ -1509,6 +1625,7 @@ function saveEndpoint() { id: endpointId, name: payload.name, provider: payload.provider, + ...(payload.api_type ? { api_type: payload.api_type } : {}), enabled: endpointModalEl?.dataset.duplicateDisabledDefault === 'true' ? false : (existingEndpoint ? existingEndpoint.enabled !== false : true), @@ -1539,16 +1656,16 @@ function saveEndpoint() { } function addManualModel() { - modalModels.push({ + const model = { id: generateId(), - deploymentName: "", - modelName: "", displayName: "", icon: {}, description: "", enabled: true, isDiscovered: false - }); + }; + setModelRequestName(model, ""); + modalModels.push(model); renderModalModels(modalModels); } @@ -2037,8 +2154,28 @@ function init() { } if (endpointProviderSelect) { endpointProviderSelect.addEventListener("change", () => { + try { + modalModels = collectModalModels(); + } catch (error) { + showToast(error?.message || "Unable to update the endpoint provider.", "danger"); + return; + } + syncOpenAiApiVersionForProvider(); + renderModalModels(modalModels); updateAuthVisibility(); + }); + } + if (endpointApiTypeSelect) { + endpointApiTypeSelect.addEventListener("change", () => { + try { + modalModels = collectModalModels(); + } catch (error) { + showToast(error?.message || "Unable to update the API type.", "danger"); + return; + } syncOpenAiApiVersionForProvider(); + renderModalModels(modalModels); + updateAuthVisibility(); }); } if (endpointUrlInput) { diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index 808d3477c..a65bc1cb1 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -4406,7 +4406,9 @@ export class AgentModalStepper { // Using global model - need to set at least one deployment field // We'll use the selected model as the deployment name for now if (formData.model) { - const deploymentName = selectedModelOption?.dataset?.deploymentName || formData.model; + const deploymentName = selectedModelOption?.dataset?.requestModel + || selectedModelOption?.dataset?.deploymentName + || formData.model; formData.azure_openai_gpt_deployment = deploymentName; } } diff --git a/application/single_app/static/js/agents_common.js b/application/single_app/static/js/agents_common.js index 169e4ca7c..21947dab0 100644 --- a/application/single_app/static/js/agents_common.js +++ b/application/single_app/static/js/agents_common.js @@ -704,17 +704,22 @@ export function getAvailableModels({ apimEnabled, settings, agent }) { return; } const endpointId = endpoint.id || ''; + const apiType = (endpoint.api_type || '').toLowerCase(); const endpointModels = endpoint.models || []; endpointModels.forEach(model => { if (!model || model.enabled === false) return; const modelId = model.id || model.deploymentName || model.deployment || model.modelName || model.name || ''; const deploymentName = model.deploymentName || model.deployment || ''; const modelName = model.modelName || model.name || ''; - const displayName = model.displayName || deploymentName || modelName || modelId; + const requestModel = provider === 'custom' && ['openai', 'anthropic'].includes(apiType) + ? modelName + : deploymentName || modelName; + const displayName = model.displayName || requestModel || modelId; if (!displayName) return; models.push({ id: modelId, - deployment: deploymentName, + deployment: requestModel, + request_model: requestModel, name: modelName, display_name: displayName, endpoint_id: endpointId, @@ -832,7 +837,10 @@ export function populateGlobalModelDropdown(selectEl, models, selectedModel) { if (model.deployment) { opt.dataset.deploymentName = model.deployment; } - if (selectedModel && (model.name === selectedModel || model.deployment === selectedModel || model.id === selectedModel)) { + if (model.request_model) { + opt.dataset.requestModel = model.request_model; + } + if (selectedModel && (model.name === selectedModel || model.request_model === selectedModel || model.deployment === selectedModel || model.id === selectedModel)) { opt.selected = true; } selectEl.appendChild(opt); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 594a019fb..b19fcce40 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -6382,7 +6382,10 @@ function getCurrentModelSelection() { modelId = selectedOption?.dataset?.modelId || selectedOption?.value || null; modelEndpointId = selectedOption?.dataset?.endpointId || null; modelProvider = selectedOption?.dataset?.provider || null; - modelDeployment = selectedOption?.dataset?.deploymentName || null; + modelDeployment = selectedOption?.dataset?.requestModel + || selectedOption?.value + || selectedOption?.dataset?.deploymentName + || null; modelIcon = parseSafeJsonObject(selectedOption?.dataset?.modelIcon || ''); } @@ -6543,7 +6546,14 @@ function buildCollaborativeModelTarget(option = {}) { return null; } - const modelDeployment = String(dataset.deploymentName || option.deployment_name || option.value || '').trim() || null; + const modelDeployment = String( + dataset.requestModel + || option.request_model + || dataset.deploymentName + || option.deployment_name + || option.value + || '' + ).trim() || null; const modelId = String(dataset.modelId || option.model_id || option.value || '').trim() || null; const modelEndpointId = String(dataset.endpointId || option.endpoint_id || '').trim() || null; const modelProvider = String(dataset.provider || option.provider || '').trim() || null; diff --git a/application/single_app/static/js/chat/chat-model-selector.js b/application/single_app/static/js/chat/chat-model-selector.js index 5b96f2456..b339aa5a5 100644 --- a/application/single_app/static/js/chat/chat-model-selector.js +++ b/application/single_app/static/js/chat/chat-model-selector.js @@ -44,13 +44,14 @@ function getSortedGroups() { } function getModelDisplayName(option) { - return (option.display_name || option.model_id || option.deployment_name || 'Unnamed Model').trim() || 'Unnamed Model'; + return (option.display_name || option.request_model || option.model_id || option.deployment_name || 'Unnamed Model').trim() || 'Unnamed Model'; } function getModelSearchText(option, sectionLabel) { return [ getModelDisplayName(option), option.model_id || '', + option.request_model || '', option.deployment_name || '', sectionLabel, ].join(' ').trim(); @@ -71,7 +72,7 @@ function getModelOptionLabel(option, duplicateCounts) { return displayName; } - return `${displayName} (${option.deployment_name || option.model_id || 'model'})`; + return `${displayName} (${option.request_model || option.deployment_name || option.model_id || 'model'})`; } function getKnownGroupIds() { @@ -260,6 +261,7 @@ function getSelectionSnapshot() { value: null, selectionKey: null, modelId: null, + requestModel: null, deploymentName: null, }; } @@ -269,6 +271,7 @@ function getSelectionSnapshot() { value: modelSelect.value || null, selectionKey: selectedOption?.dataset?.selectionKey || null, modelId: selectedOption?.dataset?.modelId || null, + requestModel: selectedOption?.dataset?.requestModel || null, deploymentName: selectedOption?.dataset?.deploymentName || null, }; } @@ -316,14 +319,20 @@ function resolveSelectedSelectionKey(options, restoreOptions = {}) { } if (preferredModelDeployment) { - const deploymentOption = matchBy(option => option.deployment_name === preferredModelDeployment); + const deploymentOption = matchBy(option => ( + option.request_model === preferredModelDeployment + || option.deployment_name === preferredModelDeployment + )); if (deploymentOption) { return deploymentOption.selection_key; } } - if (preserveCurrentSelection && currentSelection?.deploymentName) { - const currentDeploymentOption = matchBy(option => option.deployment_name === currentSelection.deploymentName); + if (preserveCurrentSelection && (currentSelection?.requestModel || currentSelection?.deploymentName)) { + const currentDeploymentOption = matchBy(option => ( + option.request_model === currentSelection.requestModel + || option.deployment_name === currentSelection.deploymentName + )); if (currentDeploymentOption) { return currentDeploymentOption.selection_key; } @@ -376,11 +385,12 @@ function rebuildModelOptions(sections, restoreOptions = {}) { section.options.forEach(option => { const modelOption = document.createElement('option'); - modelOption.value = option.deployment_name || option.model_id || option.selection_key; + modelOption.value = option.request_model || option.deployment_name || option.model_id || option.selection_key; modelOption.textContent = option.optionLabel; modelOption.dataset.selectionKey = option.selection_key || ''; modelOption.dataset.modelId = option.model_id || ''; modelOption.dataset.displayName = option.display_name || ''; + modelOption.dataset.requestModel = option.request_model || ''; modelOption.dataset.deploymentName = option.deployment_name || ''; modelOption.dataset.endpointId = option.endpoint_id || ''; modelOption.dataset.provider = option.provider || ''; diff --git a/application/single_app/static/js/workspace/workspace_model_endpoints.js b/application/single_app/static/js/workspace/workspace_model_endpoints.js index 3658f5d94..f4a4c790d 100644 --- a/application/single_app/static/js/workspace/workspace_model_endpoints.js +++ b/application/single_app/static/js/workspace/workspace_model_endpoints.js @@ -14,6 +14,8 @@ const endpointModal = endpointModalEl && window.bootstrap ? bootstrap.Modal.getO const endpointIdInput = document.getElementById("model-endpoint-id"); const endpointNameInput = document.getElementById("model-endpoint-name"); const endpointProviderSelect = document.getElementById("model-endpoint-provider"); +const endpointApiTypeGroup = document.getElementById("model-endpoint-api-type-group"); +const endpointApiTypeSelect = document.getElementById("model-endpoint-api-type"); const endpointUrlInput = document.getElementById("model-endpoint-endpoint"); const endpointUrlLabel = document.getElementById("model-endpoint-endpoint-label"); const endpointUrlHelp = document.getElementById("model-endpoint-endpoint-help"); @@ -25,6 +27,8 @@ const endpointProjectApiVersionCustomInput = document.getElementById("model-endp const endpointOpenAiApiVersionGroup = document.getElementById("model-endpoint-openai-api-version-group"); const endpointOpenAiApiVersionInput = document.getElementById("model-endpoint-openai-api-version"); const endpointOpenAiApiVersionCustomInput = document.getElementById("model-endpoint-openai-api-version-custom"); +const endpointAnthropicVersionGroup = document.getElementById("model-endpoint-anthropic-version-group"); +const endpointAnthropicVersionInput = document.getElementById("model-endpoint-anthropic-version"); const endpointSubscriptionGroup = document.getElementById("model-endpoint-subscription-group"); const endpointResourceGroup = document.getElementById("model-endpoint-resource-group-group"); const endpointSubscriptionInput = document.getElementById("model-endpoint-subscription-id"); @@ -37,6 +41,7 @@ const endpointCustomAuthorityInput = document.getElementById("model-endpoint-cus const endpointFoundryScopeGroup = document.getElementById("model-endpoint-foundry-scope-group"); const endpointFoundryScopeInput = document.getElementById("model-endpoint-foundry-scope"); const apiKeyNote = document.getElementById("model-endpoint-api-key-note"); +const apiKeyNoteText = document.getElementById("model-endpoint-api-key-note-text"); const miTypeGroup = document.getElementById("model-endpoint-mi-type-group"); const miClientGroup = document.getElementById("model-endpoint-mi-client-group"); @@ -70,6 +75,7 @@ let modalModels = []; 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 DEFAULT_ANTHROPIC_VERSION = "2023-06-01"; const CUSTOM_VERSION_VALUE = "custom"; const MODEL_ICON_CLASS_PATTERN = /^bi-[a-z0-9][a-z0-9-]{0,80}$/; const MODEL_ICON_CONTROL_CONFIG = Object.freeze({ @@ -124,6 +130,40 @@ function isFoundryProvider(provider) { return provider === "aifoundry" || provider === "new_foundry"; } +function isCustomProvider(provider = endpointProviderSelect?.value) { + return provider === "custom"; +} + +function getCustomApiType() { + return endpointApiTypeSelect?.value || "openai"; +} + +function customApiTypeUsesModelName(apiType = getCustomApiType()) { + return apiType === "openai" || apiType === "anthropic"; +} + +function getModelRequestName(model) { + if (isCustomProvider() && customApiTypeUsesModelName()) { + return String(model?.modelName || "").trim(); + } + return String(model?.deploymentName || model?.deployment || "").trim(); +} + +function setModelRequestName(model, value) { + const requestName = String(value || "").trim(); + if (isCustomProvider() && customApiTypeUsesModelName()) { + model.modelName = requestName; + delete model.deploymentName; + delete model.deployment; + return; + } + model.deploymentName = requestName; + if (isCustomProvider()) { + delete model.modelName; + delete model.name; + } +} + function endpointIncludesProject(endpoint) { return String(endpoint || "").toLowerCase().includes("/api/projects/"); } @@ -173,9 +213,13 @@ function syncEndpointCopyForProvider() { : "Endpoint Fully Qualified Domain Name (FQDN)"; } if (endpointUrlHelp) { - endpointUrlHelp.textContent = isFoundryProvider(provider) - ? "Paste the Project endpoint from Azure AI Foundry. It can include /api/projects/; Claude deployments are detected from the model name." - : "For Azure OpenAI, paste the resource endpoint."; + if (isFoundryProvider(provider)) { + endpointUrlHelp.textContent = "Paste the Project endpoint from Azure AI Foundry. It can include /api/projects/; Claude deployments are detected from the model name."; + } else if (isCustomProvider(provider)) { + endpointUrlHelp.textContent = "Enter the HTTPS FQDN for the Custom endpoint."; + } else { + endpointUrlHelp.textContent = "For Azure OpenAI, paste the resource endpoint."; + } } } @@ -242,7 +286,7 @@ function syncOpenAiApiVersionForProvider() { return; } - if (!currentValue) { + if (!currentValue || currentValue === DEFAULT_FOUNDRY_OPENAI_API_VERSION) { setSelectedVersionValue( endpointOpenAiApiVersionInput, endpointOpenAiApiVersionCustomInput, @@ -258,6 +302,9 @@ function formatProviderLabel(provider) { if (provider === "new_foundry") { return "New Foundry"; } + if (provider === "custom") { + return "Custom"; + } return "Azure OpenAI"; } @@ -318,18 +365,30 @@ function renderEndpoints() { } function updateAuthVisibility() { - const authType = endpointAuthTypeSelect?.value || "managed_identity"; + const modelsPlaceholder = document.getElementById("model-endpoint-models-placeholder"); const provider = endpointProviderSelect?.value || "aoai"; + const customProvider = isCustomProvider(provider); + if (customProvider && endpointAuthTypeSelect) { + endpointAuthTypeSelect.value = "api_key"; + } + if (endpointAuthTypeSelect) { + endpointAuthTypeSelect.disabled = customProvider; + } + setElementVisibility(endpointApiTypeGroup, customProvider); + + const apiType = getCustomApiType(); + const authType = endpointAuthTypeSelect?.value || "managed_identity"; const isApiKey = authType === "api_key"; - const isFoundry = isFoundryProvider(provider); + const isFoundry = !customProvider && isFoundryProvider(provider); const projectNameFromEndpoint = syncProjectNameFromEndpoint(); syncEndpointCopyForProvider(); syncVersionCustomVisibility(); setElementVisibility(endpointProjectGroup, isFoundry && !projectNameFromEndpoint); setElementVisibility(endpointProjectApiVersionGroup, isFoundry); - setElementVisibility(endpointOpenAiApiVersionGroup, true); - setElementVisibility(endpointSubscriptionGroup, provider === "aoai" && !isApiKey); - setElementVisibility(endpointResourceGroup, provider === "aoai" && !isApiKey); + setElementVisibility(endpointOpenAiApiVersionGroup, !customProvider || apiType === "azure_openai"); + setElementVisibility(endpointAnthropicVersionGroup, customProvider && apiType === "anthropic"); + setElementVisibility(endpointSubscriptionGroup, !customProvider && provider === "aoai" && !isApiKey); + setElementVisibility(endpointResourceGroup, !customProvider && provider === "aoai" && !isApiKey); setElementVisibility(miTypeGroup, authType === "managed_identity"); setElementVisibility(miClientGroup, authType === "managed_identity" && (miTypeSelect?.value === "user_assigned")); setElementVisibility(tenantGroup, authType === "service_principal"); @@ -339,15 +398,28 @@ function updateAuthVisibility() { setElementVisibility(endpointManagementCloudGroup, authType === "service_principal" && isFoundry); setElementVisibility(endpointCustomAuthorityGroup, authType === "service_principal" && isFoundry && endpointManagementCloudSelect?.value === "custom"); setElementVisibility(endpointFoundryScopeGroup, authType === "service_principal" && isFoundry && endpointManagementCloudSelect?.value === "custom"); - setElementVisibility(apiKeyNote, authType === "api_key"); - setElementVisibility(addModelBtn, authType === "api_key"); - setElementVisibility(fetchBtn, authType !== "api_key"); + setElementVisibility(apiKeyNote, customProvider || authType === "api_key"); + setElementVisibility(addModelBtn, customProvider || authType === "api_key"); + setElementVisibility(fetchBtn, !customProvider && authType !== "api_key"); + if (apiKeyNoteText) { + apiKeyNoteText.textContent = customProvider + ? "Custom endpoints use API key authentication and manual model entry. Model discovery is unavailable." + : "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."; + } + if (modelsPlaceholder) { + modelsPlaceholder.textContent = customProvider + ? "Add a model manually." + : (authType === "api_key" + ? "Add a model manually, or switch authentication to discover deployments." + : "Fetch models or add a model manually."); + } } function resetModal() { if (endpointIdInput) endpointIdInput.value = ""; if (endpointNameInput) endpointNameInput.value = ""; if (endpointProviderSelect) endpointProviderSelect.value = "aoai"; + if (endpointApiTypeSelect) endpointApiTypeSelect.value = "openai"; if (endpointUrlInput) endpointUrlInput.value = ""; if (endpointProjectInput) endpointProjectInput.value = ""; setSelectedVersionValue( @@ -360,6 +432,7 @@ function resetModal() { endpointOpenAiApiVersionCustomInput, getDefaultOpenAiApiVersion("aoai") ); + if (endpointAnthropicVersionInput) endpointAnthropicVersionInput.value = DEFAULT_ANTHROPIC_VERSION; if (endpointSubscriptionInput) endpointSubscriptionInput.value = ""; if (endpointResourceGroupInput) endpointResourceGroupInput.value = ""; if (endpointAuthTypeSelect) endpointAuthTypeSelect.value = "managed_identity"; @@ -376,7 +449,7 @@ function resetModal() { if (apiKeyInput) apiKeyInput.placeholder = ""; modalModels = []; - if (modelsListEl) modelsListEl.innerHTML = "

Fetch models to begin selection.

"; + if (modelsListEl) modelsListEl.innerHTML = "

Fetch models to begin selection.

"; updateAuthVisibility(); } @@ -392,6 +465,7 @@ function openModalForEndpoint(endpoint) { if (endpointIdInput) endpointIdInput.value = endpoint.id || ""; if (endpointNameInput) endpointNameInput.value = endpoint.name || ""; if (endpointProviderSelect) endpointProviderSelect.value = endpoint.provider || "aoai"; + if (endpointApiTypeSelect) endpointApiTypeSelect.value = endpoint.api_type || "openai"; if (endpointUrlInput) endpointUrlInput.value = endpoint.connection?.endpoint || ""; if (endpointProjectInput) endpointProjectInput.value = endpoint.connection?.project_name || ""; setSelectedVersionValue( @@ -404,6 +478,9 @@ function openModalForEndpoint(endpoint) { endpointOpenAiApiVersionCustomInput, endpoint.connection?.openai_api_version || endpoint.connection?.api_version || getDefaultOpenAiApiVersion(endpoint.provider || "aoai") ); + if (endpointAnthropicVersionInput) { + endpointAnthropicVersionInput.value = endpoint.connection?.anthropic_version || DEFAULT_ANTHROPIC_VERSION; + } if (endpointSubscriptionInput) endpointSubscriptionInput.value = endpoint.management?.subscription_id || ""; if (endpointResourceGroupInput) endpointResourceGroupInput.value = endpoint.management?.resource_group || ""; if (endpointAuthTypeSelect) endpointAuthTypeSelect.value = endpoint.auth?.type || "managed_identity"; @@ -458,6 +535,44 @@ function createModelTextInput(modelId, datasetKey, value, disabled = false) { return input; } +function normalizeModelResponseLength(value) { + const valueText = String(value ?? "").trim(); + if (!valueText) { + return ""; + } + if (!/^\d+$/.test(valueText)) { + return null; + } + + const parsedValue = Number.parseInt(valueText, 10); + return parsedValue > 0 ? parsedValue : null; +} + +function getModelResponseLength(model) { + return normalizeModelResponseLength( + model.responseLength + ?? model.response_length + ?? model.maxTokens + ?? model.max_tokens + ?? model.maxCompletionTokens + ?? model.max_completion_tokens + ); +} + +function createModelResponseLengthInput(modelId, value) { + const input = document.createElement("input"); + input.type = "number"; + input.className = "form-control form-control-sm"; + input.min = "1"; + input.step = "1"; + input.placeholder = "Optional"; + input.dataset.responseLengthFor = modelId; + input.id = getModelIconDomId(modelId, "response-length"); + input.value = value || ""; + input.setAttribute("aria-describedby", getModelIconDomId(modelId, "response-length-help")); + return input; +} + function getModelIconDomId(modelId, suffix) { const safeModelId = String(modelId || "model").replace(/[^A-Za-z0-9_-]/g, "-"); return `model-${safeModelId}-${suffix}`; @@ -596,7 +711,7 @@ function renderModalModels(models) { } if (!models || !models.length) { - modelsListEl.innerHTML = "

No models loaded yet.

"; + modelsListEl.innerHTML = "

No models loaded yet.

"; return; } @@ -604,10 +719,14 @@ function renderModalModels(models) { models.forEach((model) => { const wrapper = document.createElement("div"); wrapper.className = "border rounded p-2 mb-2"; - const deploymentName = model.deploymentName || ""; + const requestName = getModelRequestName(model); const modelName = model.modelName || ""; - const displayName = model.displayName || deploymentName; + const displayName = model.displayName || requestName; const description = model.description || ""; + const responseLength = getModelResponseLength(model); + const requestNameLabel = isCustomProvider() && customApiTypeUsesModelName() + ? "Model Name" + : "Deployment Name"; const modelId = model.id || generateId(); model.id = modelId; @@ -624,18 +743,29 @@ function renderModalModels(models) { const fieldsRow = createElement("div", "row g-2 mt-2"); const deploymentCol = createElement("div", "col-md-4"); - deploymentCol.appendChild(createSmallLabel("Deployment")); - deploymentCol.appendChild(createModelTextInput(modelId, "deploymentNameFor", deploymentName)); + deploymentCol.appendChild(createSmallLabel(requestNameLabel)); + deploymentCol.appendChild(createModelTextInput(modelId, "requestModelFor", requestName)); const displayCol = createElement("div", "col-md-4"); displayCol.appendChild(createSmallLabel("Display Name")); displayCol.appendChild(createModelTextInput(modelId, "displayNameFor", displayName)); - const modelNameCol = createElement("div", "col-md-4"); - modelNameCol.appendChild(createSmallLabel("Model Name")); - const modelNameInput = createModelTextInput(modelId, "modelNameFor", modelName, true); - modelNameCol.appendChild(modelNameInput); + const responseLengthCol = createElement("div", "col-md-4"); + const responseLengthLabel = createSmallLabel("Response Length"); + responseLengthLabel.htmlFor = getModelIconDomId(modelId, "response-length"); + responseLengthCol.appendChild(responseLengthLabel); + responseLengthCol.appendChild(createModelResponseLengthInput(modelId, responseLength)); + const responseLengthHelp = createElement("div", "form-text"); + responseLengthHelp.id = getModelIconDomId(modelId, "response-length-help"); + responseLengthHelp.textContent = "Optional output token ceiling for standard chat responses."; + responseLengthCol.appendChild(responseLengthHelp); fieldsRow.appendChild(deploymentCol); fieldsRow.appendChild(displayCol); - fieldsRow.appendChild(modelNameCol); + fieldsRow.appendChild(responseLengthCol); + if (!isCustomProvider()) { + const modelNameCol = createElement("div", "col-md-4"); + modelNameCol.appendChild(createSmallLabel("Model Name")); + modelNameCol.appendChild(createModelTextInput(modelId, "modelNameFor", modelName, true)); + fieldsRow.appendChild(modelNameCol); + } const descriptionWrapper = createElement("div", "mt-2"); descriptionWrapper.appendChild(createSmallLabel("Description")); @@ -650,10 +780,27 @@ function renderModalModels(models) { iconWrapper.appendChild(createSmallLabel("Icon")); iconWrapper.appendChild(createModelIconEditor(model, modelId)); + const actions = createElement("div", "d-flex gap-2 mt-2"); + const testButton = document.createElement("button"); + testButton.type = "button"; + testButton.className = "btn btn-sm btn-outline-secondary"; + testButton.dataset.action = "test-model"; + testButton.dataset.modelId = modelId; + testButton.textContent = "Test Connection"; + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "btn btn-sm btn-outline-danger"; + removeButton.dataset.action = "remove-model"; + removeButton.dataset.modelId = modelId; + removeButton.textContent = "Remove"; + actions.appendChild(testButton); + actions.appendChild(removeButton); + wrapper.appendChild(checkWrapper); wrapper.appendChild(fieldsRow); wrapper.appendChild(descriptionWrapper); wrapper.appendChild(iconWrapper); + wrapper.appendChild(actions); fragment.appendChild(wrapper); }); @@ -670,31 +817,42 @@ function collectModalModels() { const updated = modalModels.map((model) => ({ ...model })); updated.forEach((model) => { const checkbox = modelsListEl.querySelector(`input[data-model-id="${model.id}"]`); - const deploymentInput = modelsListEl.querySelector(`input[data-deployment-name-for="${model.id}"]`); + const requestModelInput = modelsListEl.querySelector(`input[data-request-model-for="${model.id}"]`); const displayInput = modelsListEl.querySelector(`input[data-display-name-for="${model.id}"]`); const descriptionInput = modelsListEl.querySelector(`textarea[data-description-for="${model.id}"]`); + const responseLengthInput = modelsListEl.querySelector(`input[data-response-length-for="${model.id}"]`); const iconEditor = findModelEditor(model.id); + const responseLength = responseLengthInput ? normalizeModelResponseLength(responseLengthInput.value) : ""; + if (responseLength === null) { + throw new Error("Response length must be a positive whole number."); + } model.enabled = checkbox ? checkbox.checked : model.enabled; - model.deploymentName = deploymentInput ? deploymentInput.value.trim() : model.deploymentName; + setModelRequestName(model, requestModelInput ? requestModelInput.value : getModelRequestName(model)); model.displayName = displayInput ? displayInput.value.trim() : model.displayName; model.icon = iconEditor ? getIconPayload(iconEditor, MODEL_ICON_CONTROL_CONFIG) : model.icon || {}; model.description = descriptionInput ? descriptionInput.value.trim() : model.description; + if (responseLength) { + model.responseLength = responseLength; + } else { + delete model.responseLength; + } }); return updated; } async function testModelConnection(model) { const payload = buildEndpointPayload(); - if (!payload || !model?.deploymentName) { - showToast("Model deployment name is required for testing.", "warning"); + const requestModel = getModelRequestName(model); + if (!payload || !requestModel) { + showToast(`${isCustomProvider() && customApiTypeUsesModelName() ? "Model" : "Deployment"} name is required for testing.`, "warning"); return; } + const testModel = {}; + setModelRequestName(testModel, requestModel); const requestBody = { ...payload, - model: { - deploymentName: model.deploymentName - } + model: testModel }; try { @@ -715,6 +873,10 @@ async function testModelConnection(model) { } async function fetchModels() { + if (isCustomProvider()) { + showToast("Model discovery is unavailable for Custom endpoints. Add models manually.", "warning"); + return; + } const payload = buildEndpointPayload(); if (!payload) { return; @@ -780,6 +942,8 @@ function buildEndpointPayload() { const name = endpointNameInput.value.trim(); const endpoint = endpointUrlInput.value.trim(); const provider = endpointProviderSelect?.value || "aoai"; + const customProvider = isCustomProvider(provider); + const apiType = getCustomApiType(); const projectNameFromEndpoint = isFoundryProvider(provider) ? syncProjectNameFromEndpoint() : ""; const projectName = projectNameFromEndpoint || endpointProjectInput?.value.trim() || ""; const projectApiVersion = getSelectedVersionValue( @@ -794,11 +958,21 @@ function buildEndpointPayload() { ); const subscriptionId = endpointSubscriptionInput?.value.trim() || ""; const resourceGroup = endpointResourceGroupInput?.value.trim() || ""; - const authType = endpointAuthTypeSelect?.value || "managed_identity"; + const authType = customProvider ? "api_key" : (endpointAuthTypeSelect?.value || "managed_identity"); const existingEndpoint = workspaceEndpoints.find((savedEndpoint) => savedEndpoint.id === endpointId); - if (!name || !endpoint || !openAiApiVersion) { - showToast("Endpoint name, URL, and OpenAI API version are required.", "warning"); + if (!name || !endpoint) { + showToast("Endpoint name and URL are required.", "warning"); + return null; + } + + if (customProvider && !/^https:\/\//i.test(endpoint)) { + showToast("Custom endpoint URLs must use HTTPS.", "warning"); + return null; + } + + if ((!customProvider || apiType === "azure_openai") && !openAiApiVersion) { + showToast("OpenAI API version is required.", "warning"); return null; } @@ -817,7 +991,7 @@ function buildEndpointPayload() { return null; } - const auth = { + let auth = { type: authType, managed_identity_type: miTypeSelect?.value || "system_assigned", managed_identity_client_id: miClientIdInput?.value.trim() || "", @@ -829,6 +1003,12 @@ function buildEndpointPayload() { custom_authority: endpointCustomAuthorityInput?.value.trim() || "", foundry_scope: endpointFoundryScopeInput?.value.trim() || "" }; + if (customProvider) { + auth = { + type: "api_key", + api_key: apiKeyInput?.value.trim() || "" + }; + } const hasStoredApiKey = authType === "api_key" && Boolean(existingEndpoint?.has_api_key); const hasStoredClientSecret = authType === "service_principal" && Boolean(existingEndpoint?.has_client_secret); @@ -854,17 +1034,21 @@ function buildEndpointPayload() { return null; } - const management = provider === "aoai" ? { + const management = !customProvider && provider === "aoai" ? { subscription_id: subscriptionId, resource_group: resourceGroup } : {}; - const connection = { - endpoint, - openai_api_version: openAiApiVersion - }; + const connection = { endpoint }; + if (customProvider && apiType === "azure_openai") { + connection.api_version = openAiApiVersion; + } else if (customProvider && apiType === "anthropic") { + connection.anthropic_version = endpointAnthropicVersionInput?.value.trim() || DEFAULT_ANTHROPIC_VERSION; + } else if (!customProvider) { + connection.openai_api_version = openAiApiVersion; + } - if (isFoundryProvider(provider)) { + if (!customProvider && isFoundryProvider(provider)) { connection.project_api_version = projectApiVersion; if (projectName) { connection.project_name = projectName; @@ -874,6 +1058,7 @@ function buildEndpointPayload() { return { id: endpointId, provider, + ...(customProvider ? { api_type: apiType } : {}), name, connection, management, @@ -881,7 +1066,8 @@ function buildEndpointPayload() { }; } -function saveEndpoint() { +async function saveEndpoint() { + const previousEndpoints = [...workspaceEndpoints]; try { const payload = buildEndpointPayload(); if (!payload) { @@ -899,7 +1085,8 @@ function saveEndpoint() { id: endpointId, name: payload.name, provider: payload.provider, - enabled: true, + ...(payload.api_type ? { api_type: payload.api_type } : {}), + enabled: existingEndpoint ? existingEndpoint.enabled !== false : true, auth: payload.auth, connection: payload.connection, management: payload.management, @@ -915,41 +1102,60 @@ function saveEndpoint() { workspaceEndpoints.push(endpointData); } - persistEndpoints(); + await persistEndpoints(); renderEndpoints(); endpointModal.hide(); showToast("Endpoint saved successfully.", "success"); } catch (error) { + workspaceEndpoints = previousEndpoints; console.error("Error saving endpoint", error); showToast(error.message || "Failed to save endpoint.", "danger"); } } -function persistEndpoints() { - fetch(endpointsApi, { +async function persistEndpoints() { + const response = await fetch(endpointsApi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoints: workspaceEndpoints }) - }).catch((error) => { - console.error("Failed to save endpoints", error); - showToast("Failed to save endpoints.", "danger"); }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || "Failed to save endpoints."); + } + if (Array.isArray(data.endpoints)) { + workspaceEndpoints = [...data.endpoints]; + } } -function toggleEndpoint(endpointId) { +async function toggleEndpoint(endpointId) { const endpoint = workspaceEndpoints.find((item) => item.id === endpointId); if (!endpoint) { return; } + const previousEnabled = endpoint.enabled; endpoint.enabled = !endpoint.enabled; - persistEndpoints(); - renderEndpoints(); + try { + await persistEndpoints(); + renderEndpoints(); + } catch (error) { + endpoint.enabled = previousEnabled; + console.error("Failed to update endpoint", error); + showToast(error.message || "Failed to update endpoint.", "danger"); + } } -function deleteEndpoint(endpointId) { +async function deleteEndpoint(endpointId) { + const previousEndpoints = workspaceEndpoints; workspaceEndpoints = workspaceEndpoints.filter((item) => item.id !== endpointId); - persistEndpoints(); - renderEndpoints(); + try { + await persistEndpoints(); + renderEndpoints(); + } catch (error) { + workspaceEndpoints = previousEndpoints; + console.error("Failed to delete endpoint", error); + showToast(error.message || "Failed to delete endpoint.", "danger"); + } } function handleTableClick(event) { @@ -979,18 +1185,48 @@ function handleTableClick(event) { function addManualModel() { modalModels = collectModalModels(); - modalModels.push({ + const model = { id: generateId(), - deploymentName: "", - modelName: "", displayName: "", icon: {}, description: "", enabled: true - }); + }; + setModelRequestName(model, ""); + modalModels.push(model); renderModalModels(modalModels); } +function handleModelListClick(event) { + const button = event.target.closest("button[data-action]"); + if (!button) { + return; + } + + try { + modalModels = collectModalModels(); + } catch (error) { + showToast(error?.message || "Unable to update the model.", "danger"); + return; + } + + const modelId = button.dataset.modelId; + const model = modalModels.find((item) => item.id === modelId); + if (!model) { + return; + } + + if (button.dataset.action === "remove-model") { + modalModels = modalModels.filter((item) => item.id !== modelId); + renderModalModels(modalModels); + return; + } + + if (button.dataset.action === "test-model") { + testModelConnection(model); + } +} + function escapeHtml(value) { if (!value) return ""; return value.replace(/[&<>"']/g, (char) => ({ @@ -1054,8 +1290,29 @@ function initialize() { if (endpointProviderSelect) { endpointProviderSelect.addEventListener("change", () => { + try { + modalModels = collectModalModels(); + } catch (error) { + showToast(error?.message || "Unable to update the endpoint provider.", "danger"); + return; + } + syncOpenAiApiVersionForProvider(); + renderModalModels(modalModels); updateAuthVisibility(); + }); + } + + if (endpointApiTypeSelect) { + endpointApiTypeSelect.addEventListener("change", () => { + try { + modalModels = collectModalModels(); + } catch (error) { + showToast(error?.message || "Unable to update the API type.", "danger"); + return; + } syncOpenAiApiVersionForProvider(); + renderModalModels(modalModels); + updateAuthVisibility(); }); } @@ -1094,6 +1351,10 @@ function initialize() { if (addModelBtn) { addModelBtn.addEventListener("click", addManualModel); } + + if (modelsListEl) { + modelsListEl.addEventListener("click", handleModelListClick); + } } if (document.readyState === "loading") { diff --git a/application/single_app/templates/_multiendpoint_modal.html b/application/single_app/templates/_multiendpoint_modal.html index 34fcafcc0..28d41b867 100644 --- a/application/single_app/templates/_multiendpoint_modal.html +++ b/application/single_app/templates/_multiendpoint_modal.html @@ -21,6 +21,7 @@
Identity se
  • Azure OpenAI: assign Reader plus Cognitive Services OpenAI User on the Azure OpenAI resource when using managed identity or service principal model discovery.
  • Foundry (classic): assign Foundry User, or Azure AI User where older role names still appear, on the target Foundry project or backing resource.
  • New Foundry: use the same Foundry project access as classic Foundry, then select the New Foundry provider and the project endpoint in this modal.
  • +
  • Custom: select an API type, enter an HTTPS endpoint and API key, then add models manually.
  • Provider setup: for Foundry project model endpoints, keep OpenAI API Version at endpoint default v1. Use separate endpoints when Grok, Meta/Llama, DeepSeek, OpenAI-compatible, or other model families need different project settings, auth, or manual deployment rows.
  • API Key: use for inference-only endpoints or APIM paths. Model and Foundry project discovery requires managed identity or service principal RBAC.
  • @@ -38,11 +39,21 @@
    Identity se +
    - For APIM, choose the matching provider with API key auth. If using classic Foundry, use Foundry (classic). If using the application-based runtime, use New Foundry. + Choose Custom for a manually configured API type and model list.
    +
    + + +
    The API type controls request paths, model identifiers, and headers for this Custom endpoint.
    +
    @@ -73,6 +84,11 @@
    Identity se
    For Foundry project endpoints, Project API Version controls discovery and usually stays v1. OpenAI API Version controls the normalized /openai/v1 inference client and should stay Endpoint default (v1); the /v1 path does not allow an api-version query. Split model families into separate endpoints when they need different project settings or auth. Claude deployments are detected from the model name and use the Anthropic messages protocol.
    +
    + + +
    Sent as the anthropic-version request header.
    +
    @@ -144,7 +160,7 @@
    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. + 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.
    @@ -209,6 +225,11 @@
    Provider selection
    Application-based Foundry runtime, New Foundry agents, and project model deployments. Use the New Foundry project endpoint. Set Project API Version for discovery, usually v1, and keep OpenAI API Version at endpoint default v1 for the normalized /openai/v1 inference path. + + Custom + Manually configured OpenAI API, Azure OpenAI API, or Anthropic models. + Use an HTTPS endpoint with API key authentication, select the API type, and add models manually. +
    diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 5c2ae20be..a07879540 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -4769,7 +4769,7 @@
    Model Endpoints

    - Manage multiple AI model endpoints (Azure OpenAI and Azure AI Foundry). When enabled, model selection in chat is driven by these endpoints. + Manage Azure OpenAI, Foundry, New Foundry, and Custom model endpoints. When enabled, model selection in chat is driven by these endpoints.

    {% if not settings.enable_multi_model_endpoints %} @@ -4779,6 +4779,22 @@
    {% endif %} +
    + + +
    + Custom endpoints require HTTPS. When disabled, hosts resolving to private addresses are rejected; loopback, link-local, metadata, and direct IP targets are always rejected. +
    +
    + {% if settings.enable_semantic_kernel %}
    @@ -10013,14 +10029,14 @@
    {% for endpoint in settings.model_endpoints if endpoint.enabled %} {% for m in endpoint.models if m.enabled %} {% if is_vision_capable_model is defined and is_vision_capable_model(m) %} - {% set option_value = m.deploymentName %} + {% set option_value = m.modelName or m.deploymentName %} {% endif %} {% endfor %} diff --git a/docs/explanation/features/CUSTOM_MODEL_ENDPOINT_PROVIDER.md b/docs/explanation/features/CUSTOM_MODEL_ENDPOINT_PROVIDER.md new file mode 100644 index 000000000..3d3c3b35e --- /dev/null +++ b/docs/explanation/features/CUSTOM_MODEL_ENDPOINT_PROVIDER.md @@ -0,0 +1,116 @@ +# Custom Model Endpoint Provider + +## Overview and Purpose + +The Custom provider lets administrators and authorized workspace owners configure chat models through a supported API contract without changing the provider label shown throughout SimpleChat. Custom endpoints are available in global, personal, and group model endpoint scopes and use manual model entry. + +**Implemented in version: 0.250.172** + +**Issue:** [#1222](https://github.com/microsoft/simplechat/issues/1222) + +## Dependencies + +- Multi-model endpoint management +- Existing global, personal, and group endpoint governance +- Existing Key Vault model-endpoint secret storage +- OpenAI Python client and the existing Anthropic Messages adapter + +## Technical Specifications + +### Architecture + +Custom endpoints persist `provider: "custom"` and one explicit `api_type`. The API type is authoritative at runtime; endpoint paths and model names do not change the selected protocol. + +| API Type | Model Identifier | Version Field | Request Contract | +|---|---|---|---| +| OpenAI API (`openai`) | `modelName` | None | OpenAI-compatible Chat Completions under `/v1/` | +| Azure OpenAI API (`azure_openai`) | `deploymentName` | `connection.api_version` | Azure OpenAI Chat Completions | +| Anthropic (`anthropic`) | `modelName` | `connection.anthropic_version` | Anthropic Messages under `/v1/messages` | + +Every model keeps a stable SimpleChat `id` for selection and authorization. The stable ID is never sent as the provider model identifier. + +### Authentication and Secret Storage + +- API key is the only supported Custom authentication type. +- API keys use the existing model-endpoint Key Vault flow when Key Vault secret storage is enabled. +- Frontend endpoint payloads contain only `has_api_key`; they never contain a stored key. +- Existing blank-on-edit behavior preserves a stored API key. + +### Endpoint Safety + +- Custom endpoint URLs must use HTTPS and a fully qualified DNS hostname. +- Embedded credentials, query strings, fragments, direct IP literals, and single-label hosts are rejected. +- Loopback, link-local, metadata/platform, multicast, reserved, and unspecified addresses are always rejected. +- Private addresses are rejected unless an administrator enables **Allow private Custom endpoint hosts**. +- DNS and URL policy are checked when configuration is saved and again before runtime client construction. +- Each direct Custom connection is pinned to the addresses from its validated DNS lookup, preventing a second DNS resolution from redirecting the request to a blocked address. +- Direct Custom requests do not follow redirects. +- Provider response bodies and raw provider exceptions are not returned for direct Custom Anthropic failures. + +### API Endpoints + +- `POST /api/models/test-model` +- `POST /api/user/models/test-model` +- `POST /api/group/models/test-model` +- `GET|POST /api/user/model-endpoints` +- `GET|POST /api/group/model-endpoints` + +Model discovery endpoints deliberately reject Custom providers before network dispatch. Models must be entered with **Add Model**. + +### Configuration + +- `enable_multi_model_endpoints`: enables endpoint-backed model selection. +- `allow_user_custom_endpoints`: allows authorized personal endpoint management. +- `allow_group_custom_endpoints`: allows authorized group endpoint management. +- `allow_private_custom_model_endpoints`: permits private DNS results for Custom endpoints while retaining the always-blocked address classes. + +### File Structure + +- Canonical types: `application/single_app/functions_model_endpoint_types.py` +- Validation and URL policy: `application/single_app/functions_model_endpoint_validation.py` +- Runtime construction: `application/single_app/functions_model_endpoint_runtime.py` +- Protocol adapters: `application/single_app/model_endpoint_clients.py` +- Save and test routes: `application/single_app/route_backend_models.py` +- Shared modal: `application/single_app/templates/_multiendpoint_modal.html` +- Admin editor: `application/single_app/static/js/admin/admin_model_endpoints.js` +- Workspace editor: `application/single_app/static/js/workspace/workspace_model_endpoints.js` + +## Usage Instructions + +### Configure an Endpoint + +1. Open Model Endpoints in Admin Settings, Personal Workspace, or Group Workspace. +2. Add an endpoint and choose **Custom**. +3. Select **OpenAI API**, **Azure OpenAI API**, or **Anthropic**. +4. Enter the HTTPS endpoint and API key. +5. For Azure OpenAI API, enter the API version. For Anthropic, confirm or change the Anthropic Version. +6. Select **Add Model** and enter a Model Name or Deployment Name as indicated. +7. Optionally set Display Name, Response Length, Description, Icon, and Enabled state. +8. Test the model connection, then save the endpoint. + +### Scope and Governance + +Global endpoints remain controlled by administrators. Personal and group endpoints continue to use their existing feature flags, role checks, governance decisions, active-group checks, endpoint/model IDs, and Key Vault scope. Runtime requests resolve the saved endpoint and model instead of trusting client-supplied connection details. + +## Testing and Validation + +- `functional_tests/test_custom_model_endpoint_provider.py` +- Existing model endpoint normalization, protocol, Key Vault, workspace, streaming, summary, metadata, multimodal, and route-policy regressions +- JavaScript syntax checks for both endpoint editors and the chat model selector +- Python compilation checks for all modified runtime and route modules + +## Performance Considerations + +Custom model discovery is disabled, so configuration does not perform model-list requests. URL validation performs DNS resolution during save and runtime construction, and the protected transport resolves again when opening a connection so it can pin the validated addresses. Runtime latency and model capability depend on the configured API. + +## Known Limitations + +- API key is the only Custom authentication type. +- Models are entered manually; model discovery is unavailable. +- Supported inference contracts are OpenAI-compatible Chat Completions, Azure OpenAI Chat Completions, and Anthropic Messages. +- Custom endpoints do not add embeddings, image generation, OpenAI Responses, arbitrary headers, or non-HTTPS transport. +- A configured model can only use features supported by its selected API contract. + +## Version Reference + +The application version was updated in `application/single_app/config.py` to **0.250.172**. diff --git a/docs/explanation/features/v0.241.001/MODEL_ENDPOINT_API_KEY_MANUAL_MODELS.md b/docs/explanation/features/v0.241.001/MODEL_ENDPOINT_API_KEY_MANUAL_MODELS.md index 709920959..81866f892 100644 --- a/docs/explanation/features/v0.241.001/MODEL_ENDPOINT_API_KEY_MANUAL_MODELS.md +++ b/docs/explanation/features/v0.241.001/MODEL_ENDPOINT_API_KEY_MANUAL_MODELS.md @@ -1,4 +1,4 @@ -# Model Endpoint API Key Manual Models (v0.236.019) +# Model Endpoint API Key Manual Models (v0.250.172) ## Overview and Purpose Adds manual model entry for API key-authenticated endpoints, with per-model connection tests and guidance to prefer identity-based discovery. @@ -6,6 +6,8 @@ Adds manual model entry for API key-authenticated endpoints, with per-model conn ## Version Implemented Fixed/Implemented in version: **0.236.019** +Updated in version: **0.250.172** + ## Dependencies - Admin model endpoint modal - Backend model test endpoint @@ -16,6 +18,8 @@ Fixed/Implemented in version: **0.236.019** - API key endpoints skip discovery and allow manual model entries. - Each model row supports per-model connection testing. - Service principal auth includes management cloud and custom authority inputs. +- The Custom provider uses API-key-only authentication and always uses manual model entry. +- Custom OpenAI API and Anthropic models use Model Name; Custom Azure OpenAI API models use Deployment Name. ### API Endpoints - `/api/models/test-model` — tests a specific model deployment using the endpoint settings. @@ -25,8 +29,9 @@ Fixed/Implemented in version: **0.236.019** - `auth.custom_authority` — custom authority URL for service principal auth. ### File Structure -- Modal UI: application/single_app/templates/admin_settings.html +- Modal UI: application/single_app/templates/_multiendpoint_modal.html - Modal logic: application/single_app/static/js/admin/admin_model_endpoints.js +- Workspace modal logic: application/single_app/static/js/workspace/workspace_model_endpoints.js - Backend test endpoint: application/single_app/route_backend_models.py ## Usage Instructions @@ -35,6 +40,12 @@ Fixed/Implemented in version: **0.236.019** 2. Use Add Model to enter deployment name, display name, and description. 3. Use the per-model Test Connection button to verify access. +### Custom Provider Flow +1. Choose Provider: Custom. +2. Select OpenAI API, Azure OpenAI API, or Anthropic. +3. Enter the HTTPS endpoint and API key. +4. Add models manually using the type-specific Model Name or Deployment Name field. + ### Service Principal Flow 1. Choose Authentication Type: Service Principal. 2. Select Management Cloud (Public/Government/Custom). @@ -42,9 +53,12 @@ Fixed/Implemented in version: **0.236.019** ## Testing and Validation - Functional test: functional_tests/test_model_endpoints_api_key_manual_models.py +- Functional test: functional_tests/test_custom_model_endpoint_provider.py ## Known Limitations - API key auth supports inference only; discovery requires identity-based auth. +- Custom endpoints never use discovery. ## Reference to Config Version Update -- Version updated in application/single_app/config.py to **0.236.019**. +- Initial version updated in application/single_app/config.py to **0.236.019**. +- Custom provider update in application/single_app/config.py: **0.250.172**. diff --git a/docs/explanation/features/v0.241.001/WORKSPACE_MULTI_ENDPOINTS.md b/docs/explanation/features/v0.241.001/WORKSPACE_MULTI_ENDPOINTS.md index c0578df49..04654989b 100644 --- a/docs/explanation/features/v0.241.001/WORKSPACE_MULTI_ENDPOINTS.md +++ b/docs/explanation/features/v0.241.001/WORKSPACE_MULTI_ENDPOINTS.md @@ -5,7 +5,7 @@ Workspace multi-endpoint management extends the admin multi-endpoint system to p **Implemented in version: 0.236.045** -**Updated in version: 0.242.071** +**Updated in version: 0.250.172** ## Dependencies - Global model endpoints configured in admin settings @@ -19,6 +19,8 @@ Workspace multi-endpoint management extends the admin multi-endpoint system to p - Group endpoints are stored on group documents under `model_endpoints`. - Agent modal requests a combined, sanitized endpoint list for model selection. - Foundry agent lookup uses endpoint IDs to resolve authentication and list agents. +- Custom endpoints use an explicit OpenAI API, Azure OpenAI API, or Anthropic contract and manual model entry. +- Runtime calls resolve the saved endpoint and model by scope; client-supplied connection details do not replace stored personal or group configuration. ### API Endpoints - `GET /api/user/model-endpoints` / `POST /api/user/model-endpoints` @@ -32,6 +34,8 @@ Workspace multi-endpoint management extends the admin multi-endpoint system to p ### Configuration - Global toggle: `enable_multi_model_endpoints` in [application/single_app/config.py](application/single_app/config.py) - Workspace endpoints stored per user and per group +- `allow_user_custom_endpoints` and `allow_group_custom_endpoints` control personal and group endpoint management. +- `allow_private_custom_model_endpoints` is an administrator-controlled network policy shared by all Custom endpoint scopes. ### File Structure - Frontend templates: [application/single_app/templates/workspace.html](application/single_app/templates/workspace.html), [application/single_app/templates/group_workspaces.html](application/single_app/templates/group_workspaces.html), [application/single_app/templates/_agent_modal.html](application/single_app/templates/_agent_modal.html) @@ -44,6 +48,8 @@ Workspace multi-endpoint management extends the admin multi-endpoint system to p 2. Users open Personal Workspace or Group Workspace and add endpoints under the new Workspace/Group Model Endpoints card. 3. In the agent modal, select a model from the combined endpoint list. +For a Custom endpoint, choose its API Type, enter the HTTPS endpoint and API key, and add each model manually. OpenAI API and Anthropic use Model Name; Azure OpenAI API uses Deployment Name. + Use the **Setup Guide** button in the endpoint table or Model Endpoint modal for in-product RBAC reminders. For Azure OpenAI, Foundry (classic), or New Foundry managed identity and service principal setup, see [Configure Model Endpoint Identity]({{ '/how-to/model_endpoint_identity_setup/' | relative_url }}). The same RBAC guidance applies to global, personal, and group-scoped endpoints. ### Foundry Agent Import @@ -63,7 +69,8 @@ Use the **Setup Guide** button in the endpoint table or Model Endpoint modal for - Verify Foundry agent list import using configured endpoints. ## Performance Considerations -- Model discovery uses on-demand API calls to Azure/Foundry endpoints. +- Model discovery uses on-demand API calls to Azure/Foundry endpoints. Custom endpoints do not perform discovery. ## Known Limitations - Workspace endpoints require configured credentials; only stored secrets are used for runtime resolution. +- Custom endpoints support API-key authentication and manual chat-model entry only. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 4f08af54c..5950eb350 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.172)** + +#### New Features + +* **Custom Model Endpoint Provider** + * Added manually configured Custom endpoints for OpenAI API, Azure OpenAI API, and Anthropic chat models across global, personal, and group scopes. + * Added type-specific model identifiers, API-key authentication, connection testing, response-length controls, and Anthropic Version support without model discovery. + * Enforced HTTPS, DNS/address safety with connection-time address pinning, runtime URL revalidation, redirect refusal, Key Vault secret handling, and an administrator-controlled private-host policy. + * (Ref: #1222, Custom model endpoints, `functions_model_endpoint_runtime.py`, `_multiendpoint_modal.html`, `CUSTOM_MODEL_ENDPOINT_PROVIDER.md`) + ### **(v0.250.170)** #### Bug Fixes diff --git a/functional_tests/test_admin_multi_endpoint_persistence_guard.py b/functional_tests/test_admin_multi_endpoint_persistence_guard.py index 967c26fa7..91ec579e1 100644 --- a/functional_tests/test_admin_multi_endpoint_persistence_guard.py +++ b/functional_tests/test_admin_multi_endpoint_persistence_guard.py @@ -1,16 +1,15 @@ -#!/usr/bin/env python3 # test_admin_multi_endpoint_persistence_guard.py +#!/usr/bin/env python3 """ Functional test for admin multi-endpoint persistence guard. -Version: 0.239.199 -Implemented in: 0.239.199 +Version: 0.250.172 +Implemented in: 0.239.199; updated in 0.250.172 This test ensures that once multi-endpoint model management is enabled, admin settings saves preserve it even if the checkbox is omitted from later form posts, and that the backend save helper enforces the same one-way behavior. """ -import importlib import json import os import sys @@ -27,6 +26,10 @@ sys.path.append(ROOT_DIR) sys.path.append(SINGLE_APP_ROOT) +from test_model_endpoint_normalization_backend import ( + _load_functions_settings_module as load_functions_settings_module, +) + def read_file(path): with open(path, 'r', encoding='utf-8') as file_handle: @@ -42,34 +45,7 @@ def _restore_modules(original_modules): def _load_functions_settings_module(): - config_stub = types.ModuleType('config') - config_stub.json = json - config_stub.re = __import__('re') - config_stub.WORD_CHUNK_SIZE = 400 - config_stub.video_indexer_endpoint = '' - config_stub.cosmos_settings_container = types.SimpleNamespace(upsert_item=lambda item: item) - - appinsights_stub = types.ModuleType('functions_appinsights') - appinsights_stub.log_event = lambda *args, **kwargs: None - - cache_stub = types.ModuleType('app_settings_cache') - cache_stub.get_settings_cache = lambda: None - cache_stub.update_settings_cache = lambda settings: None - - original_modules = {} - for module_name, module_stub in { - 'config': config_stub, - 'functions_appinsights': appinsights_stub, - 'app_settings_cache': cache_stub, - }.items(): - original_modules[module_name] = sys.modules.get(module_name) - sys.modules[module_name] = module_stub - - module_name = 'application.single_app.functions_settings' - original_modules[module_name] = sys.modules.get(module_name) - sys.modules.pop(module_name, None) - module = importlib.import_module(module_name) - return module, original_modules + return load_functions_settings_module() def test_admin_settings_route_preserves_enabled_multi_endpoint_flag(): diff --git a/functional_tests/test_custom_model_endpoint_provider.py b/functional_tests/test_custom_model_endpoint_provider.py new file mode 100644 index 000000000..46821af07 --- /dev/null +++ b/functional_tests/test_custom_model_endpoint_provider.py @@ -0,0 +1,756 @@ +# test_custom_model_endpoint_provider.py +#!/usr/bin/env python3 +""" +Functional test for the Custom model endpoint provider. +Version: 0.250.172 +Implemented in: 0.250.172 + +This test validates canonical model identifiers, API-type precedence, Custom +endpoint URL safety, direct Anthropic request behavior, normalization, secret +sanitization, and the admin/workspace UI contract without network traffic. +""" + +import asyncio +import importlib +import socket +import sys +import types +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +APP_DIR = ROOT / "application" / "single_app" +sys.path.insert(0, str(APP_DIR)) +sys.path.insert(0, str(ROOT)) + +from functions_model_endpoint_types import ( # noqa: E402 + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + MODEL_ENDPOINT_API_TYPE_OPENAI, + resolve_model_endpoint_request_model, +) +from functions_model_endpoint_validation import ( # noqa: E402 + ModelEndpointValidationError, + validate_custom_model_endpoint, + validate_custom_model_endpoint_url, +) +from model_endpoint_clients import ( # noqa: E402 + MODEL_ENDPOINT_PROTOCOL_ANTHROPIC, + MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI, + MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE, + _PinnedCustomEndpointAsyncBackend, + _PinnedCustomEndpointSyncBackend, + AnthropicChatCompletionClient, + SanitizedCustomChatCompletionClient, + build_custom_openai_async_http_client, + build_custom_openai_sync_http_client, + infer_model_endpoint_protocol, + normalize_anthropic_messages_url, + normalize_custom_openai_base_url, + sanitize_custom_async_openai_client, +) +from test_model_endpoint_normalization_backend import ( # noqa: E402 + _load_functions_settings_module, + _restore_modules, +) + + +PUBLIC_ADDRESS_INFO = [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("93.184.216.34", 443), + ) +] + + +def assert_validation_error(callable_value, expected_message): + """Assert a configuration is rejected with a stable, user-safe message.""" + try: + callable_value() + except ModelEndpointValidationError as exc: + assert expected_message in str(exc) + return + raise AssertionError(f"Expected ModelEndpointValidationError containing {expected_message!r}") + + +def build_custom_endpoint(api_type, model, connection=None): + """Build a valid Custom endpoint record for validation tests.""" + endpoint_connection = {"endpoint": "https://models.example.com"} + endpoint_connection.update(connection or {}) + return { + "id": f"custom-{api_type}", + "name": "Custom Models", + "provider": "custom", + "api_type": api_type, + "enabled": True, + "auth": {"type": "api_key", "api_key": "test-key"}, + "connection": endpoint_connection, + "models": [{"id": "stable-model-id", "enabled": True, **model}], + } + + +def load_model_endpoint_runtime_module(): + """Load the runtime helper without initializing the application config.""" + config_stub = types.ModuleType("config") + config_stub.cognitive_services_scope = "https://cognitiveservices.azure.com/.default" + + foundry_runtime_stub = types.ModuleType("foundry_agent_runtime") + foundry_runtime_stub.resolve_authority = lambda auth_settings: None + + settings_stub = types.ModuleType("functions_settings") + settings_stub.resolve_model_endpoint_foundry_scope = ( + lambda auth_settings, endpoint=None: "https://ai.azure.com/.default" + ) + + original_modules = {} + for module_name, module_stub in { + "config": config_stub, + "foundry_agent_runtime": foundry_runtime_stub, + "functions_settings": settings_stub, + }.items(): + original_modules[module_name] = sys.modules.get(module_name) + sys.modules[module_name] = module_stub + + original_modules["functions_model_endpoint_runtime"] = sys.modules.get( + "functions_model_endpoint_runtime" + ) + sys.modules.pop("functions_model_endpoint_runtime", None) + module = importlib.import_module("functions_model_endpoint_runtime") + return module, original_modules + + +def test_request_model_resolution_and_protocol_precedence(): + """Ensure stable IDs and model-name heuristics never override Custom API type.""" + openai_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_OPENAI, + {"modelName": "claude-compatible-model", "deploymentName": "wrong-deployment"}, + ) + anthropic_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + {"modelName": "vendor-model"}, + ) + azure_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + {"deploymentName": "azure-deployment", "modelName": "wrong-model"}, + {"api_version": "2024-05-01-preview"}, + ) + + assert resolve_model_endpoint_request_model( + openai_endpoint, + openai_endpoint["models"][0], + ) == "claude-compatible-model" + assert resolve_model_endpoint_request_model( + anthropic_endpoint, + anthropic_endpoint["models"][0], + ) == "vendor-model" + assert resolve_model_endpoint_request_model( + azure_endpoint, + azure_endpoint["models"][0], + ) == "azure-deployment" + + assert infer_model_endpoint_protocol( + "custom", + "https://models.example.com", + "claude-compatible-model", + MODEL_ENDPOINT_API_TYPE_OPENAI, + ) == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE + assert infer_model_endpoint_protocol( + "custom", + "https://models.example.com", + "gpt-compatible-name", + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + ) == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC + assert infer_model_endpoint_protocol( + "custom", + "https://models.example.com/openai/v1", + "claude-name", + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + ) == MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI + assert infer_model_endpoint_protocol( + "new_foundry", + "https://eastus.services.ai.azure.com/api/projects/example", + "claude-sonnet", + ) == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC + + +def test_custom_endpoint_url_policy(): + """Ensure Custom URLs enforce HTTPS, DNS, and address-class policy.""" + with patch("functions_model_endpoint_validation.socket.getaddrinfo", return_value=PUBLIC_ADDRESS_INFO): + assert validate_custom_model_endpoint_url( + "https://Models.Example.com/custom/" + ) == "https://models.example.com/custom" + + for endpoint, expected_message in ( + ("http://models.example.com", "must use HTTPS"), + ("https://user:password@models.example.com", "embedded credentials"), + ("https://models.example.com?key=value", "query string or fragment"), + ("https://127.0.0.1", "not an IP address"), + ("https://single-label", "fully qualified domain name"), + ("https://localhost", "hostname is blocked"), + ): + assert_validation_error( + lambda endpoint=endpoint: validate_custom_model_endpoint_url(endpoint), + expected_message, + ) + + private_address_info = [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("10.20.30.40", 443), + ) + ] + with patch( + "functions_model_endpoint_validation.socket.getaddrinfo", + return_value=private_address_info, + ): + assert_validation_error( + lambda: validate_custom_model_endpoint_url("https://private.example.com"), + "not enabled", + ) + assert validate_custom_model_endpoint_url( + "https://private.example.com", + allow_private=True, + ) == "https://private.example.com" + + loopback_address_info = [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("127.0.0.1", 443), + ) + ] + with patch( + "functions_model_endpoint_validation.socket.getaddrinfo", + return_value=loopback_address_info, + ): + assert_validation_error( + lambda: validate_custom_model_endpoint_url( + "https://loopback.example.com", + allow_private=True, + ), + "loopback", + ) + + shared_address_info = [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("100.64.0.1", 443), + ) + ] + with patch( + "functions_model_endpoint_validation.socket.getaddrinfo", + return_value=shared_address_info, + ): + assert_validation_error( + lambda: validate_custom_model_endpoint_url( + "https://shared.example.com", + allow_private=True, + ), + "globally routable", + ) + + with patch( + "functions_model_endpoint_validation.socket.getaddrinfo", + side_effect=socket.gaierror(), + ): + assert_validation_error( + lambda: validate_custom_model_endpoint_url("https://missing.example.com"), + "could not be resolved", + ) + + +def test_custom_endpoint_configuration_validation(): + """Validate required type-specific fields and manual model uniqueness.""" + openai_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_OPENAI, + {"modelName": "openai-model"}, + ) + azure_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + {"deploymentName": "azure-deployment"}, + {"api_version": "2024-05-01-preview"}, + ) + anthropic_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + {"modelName": "anthropic-model"}, + {"anthropic_version": "2023-06-01"}, + ) + + with patch("functions_model_endpoint_validation.socket.getaddrinfo", return_value=PUBLIC_ADDRESS_INFO): + validate_custom_model_endpoint(openai_endpoint) + validate_custom_model_endpoint(azure_endpoint) + validate_custom_model_endpoint(anthropic_endpoint) + + missing_version = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + {"deploymentName": "azure-deployment"}, + ) + assert_validation_error( + lambda: validate_custom_model_endpoint(missing_version), + "Azure OpenAI API version", + ) + + wrong_model_field = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_OPENAI, + {"deploymentName": "deployment-only"}, + ) + assert_validation_error( + lambda: validate_custom_model_endpoint(wrong_model_field), + "Model Name", + ) + + wrong_azure_model_field = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + {"modelName": "model-only"}, + {"api_version": "2024-05-01-preview"}, + ) + assert_validation_error( + lambda: validate_custom_model_endpoint(wrong_azure_model_field), + "Deployment Name", + ) + + no_models = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_OPENAI, + {"modelName": "unused"}, + ) + no_models["models"] = [] + assert_validation_error( + lambda: validate_custom_model_endpoint(no_models), + "at least one manually configured model", + ) + + duplicate_models = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + {"modelName": "duplicate-model"}, + ) + duplicate_models["models"].append({ + "id": "another-stable-id", + "modelName": "DUPLICATE-MODEL", + "enabled": True, + }) + assert_validation_error( + lambda: validate_custom_model_endpoint(duplicate_models), + "must be unique", + ) + + +def test_custom_client_paths_headers_and_redirect_policy(): + """Ensure direct Custom adapters use provider paths, headers, and no redirects.""" + assert normalize_custom_openai_base_url( + "https://models.example.com" + ) == "https://models.example.com/v1/" + assert normalize_custom_openai_base_url( + "https://models.example.com/v1/chat/completions" + ) == "https://models.example.com/v1/" + assert normalize_anthropic_messages_url( + "https://models.example.com", + direct_custom=True, + ) == "https://models.example.com/v1/messages" + + client = AnthropicChatCompletionClient( + endpoint="https://models.example.com", + api_key="test-key", + anthropic_version="2024-01-01", + direct_custom=True, + ) + headers = client._build_headers() + assert headers["x-api-key"] == "test-key" + assert headers["anthropic-version"] == "2024-01-01" + assert "api-key" not in headers + assert "Authorization" not in headers + + image_payload = client._build_payload({ + "model": "anthropic-model", + "messages": [{ + "role": "user", + "content": [{ + "type": "image_url", + "image_url": {"url": "data:image/png;base64,aW1hZ2U="}, + }], + }], + }) + assert image_payload["messages"][0]["content"][0] == { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "aW1hZ2U=", + }, + } + + class FakeResponse: + status_code = 200 + closed = False + + def json(self): + return { + "id": "message-1", + "content": [{"type": "text", "text": "ok"}], + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + + def close(self): + self.closed = True + + class FakeHttpClient: + def __init__(self, response): + self.response = response + self.closed = False + self.send_kwargs = None + + def build_request(self, *args, **kwargs): + return (args, kwargs) + + def send(self, request, **kwargs): + self.send_kwargs = kwargs + return self.response + + def close(self): + self.closed = True + + fake_response = FakeResponse() + fake_http_client = FakeHttpClient(fake_response) + with patch( + "model_endpoint_clients.build_custom_openai_sync_http_client", + return_value=fake_http_client, + ): + client.create( + model="anthropic-model", + messages=[{"role": "user", "content": "test"}], + ) + assert fake_http_client.send_kwargs["follow_redirects"] is False + assert fake_response.closed is True + assert fake_http_client.closed is True + + class FakeErrorResponse: + status_code = 401 + closed = False + + def close(self): + self.closed = True + + fake_error_response = FakeErrorResponse() + fake_error_client = FakeHttpClient(fake_error_response) + with patch( + "model_endpoint_clients.build_custom_openai_sync_http_client", + return_value=fake_error_client, + ): + try: + client.create( + model="anthropic-model", + messages=[{"role": "user", "content": "test"}], + ) + except RuntimeError as exc: + assert "provider secret response" not in str(exc) + assert "status 401" in str(exc) + else: + raise AssertionError("Expected the direct Anthropic client to surface a safe error") + assert fake_error_response.closed is True + assert fake_error_client.closed is True + + class FailingCompletions: + @staticmethod + def create(**kwargs): + raise ValueError("provider secret response") + + fake_sync_client = types.SimpleNamespace( + chat=types.SimpleNamespace(completions=FailingCompletions()) + ) + safe_sync_client = SanitizedCustomChatCompletionClient(fake_sync_client) + try: + safe_sync_client.chat.completions.create(model="test") + except RuntimeError as exc: + assert str(exc) == "Custom model request failed." + assert exc.__cause__ is None + else: + raise AssertionError("Expected direct Custom SDK errors to be sanitized") + + class FailingAsyncCompletions: + @staticmethod + async def create(**kwargs): + raise ValueError("provider secret response") + + fake_async_client = types.SimpleNamespace( + chat=types.SimpleNamespace(completions=FailingAsyncCompletions()) + ) + sanitize_custom_async_openai_client(fake_async_client) + + async def assert_safe_async_error(): + try: + await fake_async_client.chat.completions.create(model="test") + except RuntimeError as exc: + assert str(exc) == "Custom model request failed." + assert exc.__cause__ is None + return + raise AssertionError("Expected direct Custom async SDK errors to be sanitized") + + asyncio.run(assert_safe_async_error()) + + sync_http_client = build_custom_openai_sync_http_client() + async_http_client = build_custom_openai_async_http_client() + try: + assert sync_http_client.follow_redirects is False + assert async_http_client.follow_redirects is False + finally: + sync_http_client.close() + asyncio.run(async_http_client.aclose()) + + sync_backend = _PinnedCustomEndpointSyncBackend(allow_private=True) + sync_connections = [] + + class FakeSyncBackend: + @staticmethod + def connect_tcp(host, port, **kwargs): + sync_connections.append((host, port)) + return "sync-stream" + + sync_backend._backend = FakeSyncBackend() + with patch( + "model_endpoint_clients.resolve_custom_model_endpoint_addresses", + return_value=("93.184.216.34",), + ) as resolve_addresses: + assert sync_backend.connect_tcp("models.example.com", 443) == "sync-stream" + resolve_addresses.assert_called_once_with( + "models.example.com", + 443, + allow_private=True, + ) + assert sync_connections == [("93.184.216.34", 443)] + + async_backend = _PinnedCustomEndpointAsyncBackend(allow_private=False) + async_connections = [] + + class FakeAsyncBackend: + @staticmethod + async def connect_tcp(host, port, **kwargs): + async_connections.append((host, port)) + return "async-stream" + + async_backend._backend = FakeAsyncBackend() + + async def assert_async_dns_pinning(): + with patch( + "model_endpoint_clients.resolve_custom_model_endpoint_addresses", + return_value=("93.184.216.34",), + ): + stream = await async_backend.connect_tcp("models.example.com", 443) + assert stream == "async-stream" + + asyncio.run(assert_async_dns_pinning()) + assert async_connections == [("93.184.216.34", 443)] + + +def test_custom_runtime_client_construction(): + """Ensure shared sync and Semantic Kernel builders honor explicit Custom types.""" + runtime, original_modules = load_model_endpoint_runtime_module() + try: + with patch.object( + runtime, + "validate_custom_model_endpoint_url", + return_value="https://models.example.com", + ) as validate_url: + openai_client, openai_protocol = runtime.build_model_endpoint_sync_chat_client( + {"type": "api_key", "api_key": "test-key"}, + "custom", + "https://models.example.com", + "", + deployment_name="claude-compatible-model", + api_type=MODEL_ENDPOINT_API_TYPE_OPENAI, + allow_private_custom_endpoints=True, + ) + assert openai_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE + assert str(openai_client._client.base_url) == "https://models.example.com/v1/" + validate_url.assert_called_with( + "https://models.example.com", + allow_private=True, + ) + openai_client._client.close() + + azure_client, azure_protocol = runtime.build_model_endpoint_sync_chat_client( + {"type": "api_key", "api_key": "test-key"}, + "custom", + "https://models.example.com", + "2024-05-01-preview", + deployment_name="azure-deployment", + api_type=MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + ) + assert azure_protocol == MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI + azure_client.close() + + anthropic_client, anthropic_protocol = runtime.build_model_endpoint_sync_chat_client( + {"type": "api_key", "api_key": "test-key"}, + "custom", + "https://models.example.com", + "", + deployment_name="anthropic-model", + api_type=MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + anthropic_version="2024-01-01", + allow_private_custom_endpoints=True, + ) + assert anthropic_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC + assert anthropic_client.direct_custom is True + assert anthropic_client.anthropic_version == "2024-01-01" + assert anthropic_client.allow_private_custom_endpoints is True + + openai_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_OPENAI, + {"modelName": "openai-model"}, + ) + openai_service, openai_service_protocol = ( + runtime.build_semantic_kernel_chat_service_for_model( + "stable-model-id", + {"allow_private_custom_model_endpoints": True}, + model_context={"model_id": "stable-model-id"}, + resolved_model_endpoint=openai_endpoint, + ) + ) + assert openai_service_protocol == MODEL_ENDPOINT_PROTOCOL_OPENAI_STYLE + assert openai_service.ai_model_id == "openai-model" + + anthropic_endpoint = build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + {"modelName": "anthropic-model"}, + {"anthropic_version": "2024-01-01"}, + ) + service, service_protocol = runtime.build_semantic_kernel_chat_service_for_model( + "stable-model-id", + {"allow_private_custom_model_endpoints": True}, + model_context={"model_id": "stable-model-id"}, + resolved_model_endpoint=anthropic_endpoint, + ) + assert service_protocol == MODEL_ENDPOINT_PROTOCOL_ANTHROPIC + assert service.ai_model_id == "anthropic-model" + assert service.direct_custom is True + assert service.allow_private_custom_endpoints is True + finally: + sys.modules.pop("functions_model_endpoint_runtime", None) + _restore_modules(original_modules) + + +def test_custom_endpoint_normalization_and_sanitization(): + """Ensure canonical persistence uses the right model field and strips API keys.""" + functions_settings, original_modules = _load_functions_settings_module() + try: + endpoints = [ + build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_OPENAI, + {"modelName": "openai-model", "deploymentName": "remove-me"}, + ), + build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_AZURE_OPENAI, + {"deploymentName": "azure-deployment", "modelName": "remove-me"}, + {"api_version": "2024-05-01-preview"}, + ), + build_custom_endpoint( + MODEL_ENDPOINT_API_TYPE_ANTHROPIC, + {"modelName": "anthropic-model"}, + {}, + ), + ] + normalized, changed = functions_settings.normalize_model_endpoints(endpoints) + assert changed is True + assert normalized[0]["models"][0]["modelName"] == "openai-model" + assert "deploymentName" not in normalized[0]["models"][0] + assert normalized[1]["models"][0]["deploymentName"] == "azure-deployment" + assert "modelName" not in normalized[1]["models"][0] + assert normalized[2]["connection"]["anthropic_version"] == "2023-06-01" + assert "api_version" not in normalized[0]["connection"] + assert "anthropic_version" not in normalized[1]["connection"] + + sanitized = functions_settings.sanitize_model_endpoints_for_frontend(normalized) + assert len(sanitized) == 3 + assert all(endpoint["provider"] == "custom" for endpoint in sanitized) + assert all(endpoint["has_api_key"] is True for endpoint in sanitized) + assert all("api_key" not in endpoint["auth"] for endpoint in sanitized) + finally: + _restore_modules(original_modules) + + +def test_custom_endpoint_ui_contract(): + """Ensure both endpoint editors expose the same safe Custom workflow.""" + modal = (APP_DIR / "templates" / "_multiendpoint_modal.html").read_text(encoding="utf-8") + admin_template = (APP_DIR / "templates" / "admin_settings.html").read_text(encoding="utf-8") + admin_js = ( + APP_DIR / "static" / "js" / "admin" / "admin_model_endpoints.js" + ).read_text(encoding="utf-8") + workspace_js = ( + APP_DIR / "static" / "js" / "workspace" / "workspace_model_endpoints.js" + ).read_text(encoding="utf-8") + agents_common_js = ( + APP_DIR / "static" / "js" / "agents_common.js" + ).read_text(encoding="utf-8") + agent_stepper_js = ( + APP_DIR / "static" / "js" / "agent_modal_stepper.js" + ).read_text(encoding="utf-8") + backend = (APP_DIR / "route_backend_models.py").read_text(encoding="utf-8") + agent_backend = (APP_DIR / "route_backend_agents.py").read_text(encoding="utf-8") + + assert '' in modal + assert 'id="model-endpoint-api-type"' in modal + assert '' in modal + assert '' in modal + assert '' in modal + assert 'id="model-endpoint-anthropic-version"' in modal + assert 'name="allow_private_custom_model_endpoints"' in admin_template + + for script in (admin_js, workspace_js): + assert "Custom endpoints use API key authentication and manual model entry." in script + assert "Model discovery is unavailable for Custom endpoints." in script + assert "api_type" in script + assert "anthropic_version" in script + assert "customApiTypeUsesModelName" in script + assert "dataset.responseLengthFor" in script + assert "model.responseLength = responseLength" in script + + assert "if (!response.ok)" in workspace_js + assert "provider == MODEL_ENDPOINT_PROVIDER_CUSTOM" in backend + assert "Model discovery is not available for Custom endpoints." in backend + assert "persisted_model = next(" in backend + assert "request_model: requestModel" in agents_common_js + assert "selectedModelOption?.dataset?.requestModel" in agent_stepper_js + assert "normalized_provider == 'custom'" in agent_backend + + +def run_tests(): + """Run all Custom endpoint functional checks.""" + tests = [ + test_request_model_resolution_and_protocol_precedence, + test_custom_endpoint_url_policy, + test_custom_endpoint_configuration_validation, + test_custom_client_paths_headers_and_redirect_policy, + test_custom_runtime_client_construction, + test_custom_endpoint_normalization_and_sanitization, + test_custom_endpoint_ui_contract, + ] + results = [] + for test in tests: + print(f"\nRunning {test.__name__}...") + try: + test() + print("Test passed") + results.append(True) + except Exception as exc: + print(f"Test failed: {exc}") + import traceback + + traceback.print_exc() + results.append(False) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + return all(results) + + +if __name__ == "__main__": + raise SystemExit(0 if run_tests() else 1) diff --git a/functional_tests/test_document_auto_metadata_extraction_consistency.py b/functional_tests/test_document_auto_metadata_extraction_consistency.py index 7210de5ff..1efb20489 100644 --- a/functional_tests/test_document_auto_metadata_extraction_consistency.py +++ b/functional_tests/test_document_auto_metadata_extraction_consistency.py @@ -2,8 +2,9 @@ # test_document_auto_metadata_extraction_consistency.py """ Functional test for document auto metadata extraction consistency. -Version: 0.241.111 +Version: 0.250.172 Implemented in: 0.241.110 +Updated in: 0.250.172 This test ensures upload processing runs final metadata extraction consistently for all supported file types and preserves public workspace scope for media files. @@ -11,15 +12,14 @@ import ast import os -import re import sys +from test_support.versioning import assert_app_version_at_least + ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SINGLE_APP_ROOT = os.path.join(ROOT_DIR, 'application', 'single_app') FUNCTIONS_DOCUMENTS_FILE = os.path.join(SINGLE_APP_ROOT, 'functions_documents.py') -CONFIG_FILE = os.path.join(SINGLE_APP_ROOT, 'config.py') - def read_file(path): with open(path, 'r', encoding='utf-8') as file_handle: @@ -182,10 +182,7 @@ def test_config_version_bumped_for_auto_metadata_fix(): """Verify config.py version was bumped for this fix.""" print('Testing config version bump...') - config_source = read_file(CONFIG_FILE) - version_match = re.search(r'VERSION = "([0-9.]+)"', config_source) - assert version_match, 'Could not find VERSION in config.py' - assert version_match.group(1) == '0.241.111', 'Expected config.py version 0.241.111' + assert_app_version_at_least("0.241.111") print('Config version bump passed') return True diff --git a/functional_tests/test_model_endpoint_management_cloud_environment.py b/functional_tests/test_model_endpoint_management_cloud_environment.py index cb182d42c..7b9a48487 100644 --- a/functional_tests/test_model_endpoint_management_cloud_environment.py +++ b/functional_tests/test_model_endpoint_management_cloud_environment.py @@ -1,8 +1,8 @@ # test_model_endpoint_management_cloud_environment.py """ Functional test for model endpoint management cloud environment normalization. -Version: 0.250.004 -Implemented in: 0.250.004 +Version: 0.250.172 +Implemented in: 0.250.004; updated in 0.250.172 This test ensures model endpoint normalization derives non-editable management cloud settings from AZURE_ENVIRONMENT and preserves explicit service principal @@ -53,6 +53,9 @@ def load_functions_settings_module(): cache_stub.get_settings_cache = lambda: None cache_stub.update_settings_cache = lambda settings: None + content_safety_stub = types.ModuleType("functions_content_safety") + content_safety_stub.CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT = "Content safety policy violation." + throughput_stub = types.ModuleType("functions_cosmos_throughput") throughput_stub.get_default_cosmos_throughput_settings = lambda: {} @@ -62,6 +65,13 @@ def load_functions_settings_module(): icon_utils_stub = types.ModuleType("functions_icon_utils") icon_utils_stub.normalize_icon_payload = lambda icon, field_name=None: icon or {} + latest_features_stub = types.ModuleType("functions_latest_features_nav") + latest_features_stub.LATEST_FEATURES_HIDDEN_VERSION_SETTING = "latest_features_hidden_version" + + mcp_stub = types.ModuleType("functions_mcp_server_config") + mcp_stub.INBOUND_MCP_SETTINGS_DEFAULTS = {} + mcp_stub.normalize_inbound_mcp_settings = lambda settings: None + service_health_stub = types.ModuleType("functions_service_health") service_health_stub.get_default_service_health = lambda: {} @@ -75,9 +85,12 @@ def load_functions_settings_module(): "config": config_stub, "functions_appinsights": appinsights_stub, "app_settings_cache": cache_stub, + "functions_content_safety": content_safety_stub, "functions_cosmos_throughput": throughput_stub, "functions_document_actions": document_actions_stub, "functions_icon_utils": icon_utils_stub, + "functions_latest_features_nav": latest_features_stub, + "functions_mcp_server_config": mcp_stub, "functions_service_health": service_health_stub, "support_menu_config": support_menu_stub, "functions_settings": None, diff --git a/functional_tests/test_new_foundry_streaming_runtime.py b/functional_tests/test_new_foundry_streaming_runtime.py index 3d1c08e41..9bf8cab4f 100644 --- a/functional_tests/test_new_foundry_streaming_runtime.py +++ b/functional_tests/test_new_foundry_streaming_runtime.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for new Foundry REST streaming runtime. -Version: 0.239.205 +Version: 0.250.172 Implemented in: 0.239.177 +Updated in: 0.250.172 This test ensures that new Foundry application discovery stays REST-based, that the runtime exposes a streaming executor, and that the chat stream route @@ -12,6 +13,8 @@ from pathlib import Path +from test_support.versioning import assert_app_version_at_least + ROOT = Path(__file__).resolve().parents[1] @@ -34,8 +37,6 @@ def test_new_foundry_streaming_runtime() -> None: runtime_path = ROOT / "application" / "single_app" / "foundry_agent_runtime.py" chats_path = ROOT / "application" / "single_app" / "route_backend_chats.py" models_path = ROOT / "application" / "single_app" / "route_backend_models.py" - config_path = ROOT / "application" / "single_app" / "config.py" - assert_contains(runtime_path, "async def execute_new_foundry_agent_stream(") assert_contains(runtime_path, '"stream": stream') assert_contains(runtime_path, "stream=True,") @@ -49,7 +50,7 @@ def test_new_foundry_streaming_runtime() -> None: assert_contains(chats_path, "response = loop.run_until_complete(agent_stream.__anext__())") assert_not_contains(chats_path, "chunks, stream_usage = loop.run_until_complete(stream_agent_async())") - assert_contains(config_path, 'VERSION = "0.239.205"') + assert_app_version_at_least("0.239.205") print("✅ New Foundry REST streaming runtime verified.") diff --git a/functional_tests/test_tabular_claude_model_endpoint_support.py b/functional_tests/test_tabular_claude_model_endpoint_support.py index 0785b0973..24bb75d10 100644 --- a/functional_tests/test_tabular_claude_model_endpoint_support.py +++ b/functional_tests/test_tabular_claude_model_endpoint_support.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for tabular Claude model endpoint support. -Version: 0.241.186 +Version: 0.250.172 Implemented in: 0.241.186 +Updated in: 0.250.172 This test ensures tabular analysis and generated tabular exports preserve the selected Claude/Anthropic model endpoint context, use provider-aware Semantic @@ -15,13 +16,13 @@ import sys from pathlib import Path +from test_support.versioning import assert_app_version_at_least + REPO_ROOT = Path(__file__).resolve().parents[1] APP_ROOT = REPO_ROOT / "application" / "single_app" CHAT_ROUTE = APP_ROOT / "route_backend_chats.py" RUNTIME_HELPER = APP_ROOT / "functions_model_endpoint_runtime.py" -CONFIG = APP_ROOT / "config.py" - def read_text(path): """Read source text as UTF-8.""" @@ -90,7 +91,8 @@ def test_claude_tabular_uses_direct_planner_fallback(): def test_runtime_helper_supports_claude_sk_services(): """Validate runtime helper can build Anthropic SK services from context.""" source_text = read_text(RUNTIME_HELPER) - assert_contains(source_text, "MODEL_ENDPOINT_PROVIDER_ALLOWLIST = {'aoai', 'aifoundry', 'new_foundry', 'anthropic', 'claude'}", "Claude provider allowlist") + assert_contains(source_text, "'claude'", "Claude provider allowlist") + assert_contains(source_text, "MODEL_ENDPOINT_PROVIDER_CUSTOM", "Custom provider allowlist") assert_contains(source_text, "resolve_model_endpoint_from_context", "model context re-resolution") assert_contains(source_text, "AnthropicSemanticKernelChatCompletion", "Anthropic SK adapter") assert_contains(source_text, "sanitize_model_endpoint_auth_for_context", "non-secret auth context") @@ -111,8 +113,7 @@ def test_summary_helpers_are_anthropic_message_safe(): def test_version_bumped_for_fix(): """Validate config.py version was bumped for the fix.""" - source_text = read_text(CONFIG) - assert_contains(source_text, 'VERSION = "0.241.186"', "fix version") + assert_app_version_at_least("0.241.186") def main(): diff --git a/functional_tests/test_workflow_model_core_capabilities.py b/functional_tests/test_workflow_model_core_capabilities.py index 9101f2156..78daed5d4 100644 --- a/functional_tests/test_workflow_model_core_capabilities.py +++ b/functional_tests/test_workflow_model_core_capabilities.py @@ -1,9 +1,9 @@ # test_workflow_model_core_capabilities.py """ Functional test for Direct Model workflow core capabilities. -Version: 0.250.064 +Version: 0.250.172 Implemented in: 0.250.063 -Enhanced in: 0.250.064 +Enhanced in: 0.250.064; updated in 0.250.172 This test ensures new Direct Model workflows bind their saved model selection to a Semantic Kernel service and pass the kernel to auto-invoked core tools. @@ -69,6 +69,7 @@ def load_model_core_helpers(): helper_names = { "_workflow_model_chat_capabilities_enabled", "_build_workflow_model_context", + "_resolve_workflow_conversation_context", "_workflow_model_core_execution_context", "_execute_model_workflow_with_core_capabilities", "_execute_model_workflow", diff --git a/functional_tests/test_workspace_multi_endpoints.py b/functional_tests/test_workspace_multi_endpoints.py index df5a7f0ab..431f7a125 100644 --- a/functional_tests/test_workspace_multi_endpoints.py +++ b/functional_tests/test_workspace_multi_endpoints.py @@ -1,8 +1,8 @@ # test_workspace_multi_endpoints.py """ Functional test for workspace multi-endpoint routing. -Version: 0.239.155 -Implemented in: 0.239.155 +Version: 0.250.172 +Implemented in: 0.239.155; updated in 0.250.172 This test ensures that workspace multi-endpoint payloads are sanitized and that agent payloads accept multi-endpoint selection fields. @@ -10,9 +10,6 @@ import sys import os -import importlib -import json -import types repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) single_app_root = os.path.join(repo_root, "application", "single_app") @@ -20,6 +17,9 @@ sys.path.append(single_app_root) from application.single_app.functions_agent_payload import sanitize_agent_payload +from test_model_endpoint_normalization_backend import ( + _load_functions_settings_module as load_functions_settings_module, +) def _restore_modules(original_modules): @@ -31,29 +31,7 @@ def _restore_modules(original_modules): def _load_functions_settings_module(): - config_stub = types.ModuleType("config") - config_stub.json = json - - appinsights_stub = types.ModuleType("functions_appinsights") - appinsights_stub.log_event = lambda *args, **kwargs: None - - cache_stub = types.ModuleType("app_settings_cache") - cache_stub.get_settings_cache = lambda: None - cache_stub.update_settings_cache = lambda settings: None - - original_modules = {} - for module_name, module_stub in { - "config": config_stub, - "functions_appinsights": appinsights_stub, - "app_settings_cache": cache_stub, - }.items(): - original_modules[module_name] = sys.modules.get(module_name) - sys.modules[module_name] = module_stub - - original_modules["application.single_app.functions_settings"] = sys.modules.get("application.single_app.functions_settings") - sys.modules.pop("application.single_app.functions_settings", None) - module = importlib.import_module("application.single_app.functions_settings") - return module, original_modules + return load_functions_settings_module() def test_model_endpoint_sanitization():