Skip to content

Commit 4e4468f

Browse files
authored
Merge pull request #1861 from microsoft/Development
Promote Development to Staging (v0.261.061)
2 parents 2e44e19 + 0b36eb8 commit 4e4468f

36 files changed

Lines changed: 2017 additions & 641 deletions

‎application/single_app/config.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
EXECUTOR_TYPE = 'thread'
9999
EXECUTOR_MAX_WORKERS = 30
100100
SESSION_TYPE = 'filesystem'
101-
VERSION = "0.261.054"
101+
VERSION = "0.261.061"
102102
IS_DEVELOPMENT = is_development_env_enabled()
103103

104104
# Opt-out for deployments where App Service Easy Auth is active but the platform

‎application/single_app/functions_appinsights.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,17 @@
105105
"attempt",
106106
"container",
107107
"containername",
108+
"diagnosticrunid",
109+
"endpointfingerprint",
108110
"error",
109111
"errortype",
110112
"failurereasons",
111113
"failuresummary",
112114
"indexname",
115+
"httpattemptid",
113116
"jobid",
114117
"operation",
118+
"providerrequestid",
115119
"reason",
116120
"resource",
117121
"resourcename",

‎application/single_app/functions_content_safety.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@
33

44
CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT = 'Your message was blocked by Content Safety.'
55
CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH = 3000
6+
CONTENT_SAFETY_CATEGORY_THRESHOLD_DEFAULT = 4
7+
CONTENT_SAFETY_CATEGORY_THRESHOLD_LOW = 2
8+
CONTENT_SAFETY_CATEGORY_THRESHOLD_MEDIUM = 4
9+
CONTENT_SAFETY_CATEGORY_THRESHOLD_HIGH = 6
10+
CONTENT_SAFETY_CATEGORY_KEYS = ('Violence', 'Hate', 'Sexual', 'SelfHarm')
11+
CONTENT_SAFETY_CATEGORY_LABELS = {
12+
'Violence': 'Violence',
13+
'Hate': 'Hate',
14+
'Sexual': 'Sexual',
15+
'SelfHarm': 'Self-harm',
16+
}
17+
CONTENT_SAFETY_CATEGORY_KEY_ALIASES = {
18+
'violence': 'Violence',
19+
'hate': 'Hate',
20+
'sexual': 'Sexual',
21+
'selfharm': 'SelfHarm',
22+
}
623

724

825
def normalize_content_safety_violation_message(value):
@@ -13,6 +30,77 @@ def normalize_content_safety_violation_message(value):
1330
return candidate[:CONTENT_SAFETY_VIOLATION_MESSAGE_MAX_LENGTH]
1431

1532

33+
def normalize_content_safety_category_key(value):
34+
"""Return a supported Azure Content Safety category key."""
35+
candidate = str(value or '').strip()
36+
if candidate in CONTENT_SAFETY_CATEGORY_KEYS:
37+
return candidate
38+
normalized = (
39+
candidate.lower()
40+
.replace(' ', '')
41+
.replace('-', '')
42+
.replace('_', '')
43+
)
44+
if '.' in normalized:
45+
normalized = normalized.rsplit('.', 1)[-1]
46+
return CONTENT_SAFETY_CATEGORY_KEY_ALIASES.get(normalized)
47+
48+
49+
def normalize_content_safety_category_threshold(value):
50+
"""Return a trimmed Azure Content Safety threshold: low, medium, or high."""
51+
try:
52+
candidate = int(value)
53+
except (TypeError, ValueError):
54+
return CONTENT_SAFETY_CATEGORY_THRESHOLD_DEFAULT
55+
56+
if candidate <= CONTENT_SAFETY_CATEGORY_THRESHOLD_LOW:
57+
return CONTENT_SAFETY_CATEGORY_THRESHOLD_LOW
58+
if candidate <= CONTENT_SAFETY_CATEGORY_THRESHOLD_MEDIUM:
59+
return CONTENT_SAFETY_CATEGORY_THRESHOLD_MEDIUM
60+
return CONTENT_SAFETY_CATEGORY_THRESHOLD_HIGH
61+
62+
63+
def normalize_content_safety_category_thresholds(value, legacy_threshold=None):
64+
"""Return category-specific Content Safety block thresholds."""
65+
legacy_default = normalize_content_safety_category_threshold(legacy_threshold)
66+
raw_thresholds = value if isinstance(value, dict) else {}
67+
return {
68+
category_key: normalize_content_safety_category_threshold(
69+
raw_thresholds.get(category_key, legacy_default)
70+
)
71+
for category_key in CONTENT_SAFETY_CATEGORY_KEYS
72+
}
73+
74+
75+
def get_content_safety_category_thresholds(settings):
76+
"""Read category-specific severity thresholds from the settings document."""
77+
if not isinstance(settings, dict):
78+
return normalize_content_safety_category_thresholds({})
79+
return normalize_content_safety_category_thresholds(
80+
settings.get('content_safety_category_thresholds'),
81+
legacy_threshold=settings.get('content_safety_block_threshold'),
82+
)
83+
84+
85+
def get_content_safety_category_threshold(settings, category):
86+
"""Read the severity threshold for one Content Safety category."""
87+
category_key = normalize_content_safety_category_key(category)
88+
if not category_key:
89+
return CONTENT_SAFETY_CATEGORY_THRESHOLD_DEFAULT
90+
return get_content_safety_category_thresholds(settings).get(
91+
category_key,
92+
CONTENT_SAFETY_CATEGORY_THRESHOLD_DEFAULT,
93+
)
94+
95+
96+
def get_content_safety_category_label(category):
97+
"""Return a user-facing Content Safety category label."""
98+
category_key = normalize_content_safety_category_key(category)
99+
if not category_key:
100+
return str(category or '').strip() or 'Unknown'
101+
return CONTENT_SAFETY_CATEGORY_LABELS[category_key]
102+
103+
16104
def build_content_safety_violation_message(
17105
settings,
18106
block_reasons,

‎application/single_app/functions_documents.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,8 @@ def resolve_vision_model_endpoint(settings, vision_model):
303303
return find_enabled_model_endpoint_for_model_name(endpoints, vision_model)
304304

305305

306-
def _resolve_metadata_extraction_client(settings, identity_context=None):
306+
def resolve_metadata_extraction_client(settings, identity_context=None):
307+
"""Resolve the metadata extraction chat client and request model from configured settings."""
307308
selection = _normalize_model_endpoint_selection(settings.get("metadata_extraction_model_selection"))
308309

309310
if (
@@ -361,6 +362,9 @@ def _resolve_metadata_extraction_client(settings, identity_context=None):
361362
), gpt_model
362363

363364

365+
_resolve_metadata_extraction_client = resolve_metadata_extraction_client
366+
367+
364368
ARCHIVED_SCOPE_PREFIX = "__archived__::"
365369
CURRENT_ALIAS_BLOB_PATH_MODE = "current_alias"
366370
ARCHIVED_REVISION_BLOB_PATH_MODE = "archived_revision"

‎application/single_app/functions_model_endpoint_runtime.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from functions_model_endpoint_validation import validate_custom_model_endpoint_url
2626
from functions_settings import resolve_model_endpoint_foundry_scope
27+
from functions_tabular_model_diagnostics import build_tabular_model_http_options
2728
from model_endpoint_clients import (
2829
MODEL_ENDPOINT_PROTOCOL_ANTHROPIC,
2930
MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI,
@@ -63,6 +64,21 @@
6364

6465

6566
def _build_azure_chat_completion(default_headers=None, **kwargs):
67+
diagnostic_options = build_tabular_model_http_options(kwargs.get('service_id'))
68+
if diagnostic_options:
69+
client = AsyncAzureOpenAI(
70+
azure_endpoint=kwargs.get('endpoint'),
71+
azure_deployment=kwargs.get('deployment_name'),
72+
api_version=kwargs.get('api_version'),
73+
api_key=kwargs.get('api_key'),
74+
azure_ad_token=kwargs.get('ad_token'),
75+
azure_ad_token_provider=kwargs.get('azure_ad_token_provider') or kwargs.get('ad_token_provider'),
76+
default_headers=default_headers,
77+
**diagnostic_options,
78+
)
79+
service_options = dict(kwargs)
80+
service_options.pop('azure_ad_token_provider', None)
81+
return AzureChatCompletion(async_client=client, **service_options)
6682
if not default_headers:
6783
return AzureChatCompletion(**kwargs)
6884
try:

‎application/single_app/functions_settings.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from config import *
1818
from functions_appinsights import log_event
1919
from functions_content_safety import (
20+
CONTENT_SAFETY_CATEGORY_KEYS,
21+
CONTENT_SAFETY_CATEGORY_THRESHOLD_DEFAULT,
2022
CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT,
2123
)
2224
from functions_cosmos_throughput import get_default_cosmos_throughput_settings
@@ -1750,6 +1752,10 @@ def get_settings(use_cosmos=False, include_source=False):
17501752

17511753
# Safety (Content Safety) Settings
17521754
'enable_content_safety': False,
1755+
'content_safety_category_thresholds': {
1756+
category_key: CONTENT_SAFETY_CATEGORY_THRESHOLD_DEFAULT
1757+
for category_key in CONTENT_SAFETY_CATEGORY_KEYS
1758+
},
17531759
'content_safety_violation_message': CONTENT_SAFETY_VIOLATION_MESSAGE_DEFAULT,
17541760
'content_safety_include_trigger_information': True,
17551761
'require_member_of_safety_violation_admin': False,
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# functions_tabular_model_diagnostics.py
2+
"""Opt-in, metadata-only HTTP diagnostics for foreground tabular model calls."""
3+
4+
import asyncio
5+
import hashlib
6+
import logging
7+
import math
8+
import os
9+
import re
10+
import time
11+
import uuid
12+
13+
from openai import DefaultAsyncHttpxClient
14+
15+
from functions_appinsights import log_event
16+
17+
18+
DIAGNOSTICS_ENV = 'SIMPLECHAT_TABULAR_HTTP_DIAGNOSTICS'
19+
TIMEOUT_ENV = 'SIMPLECHAT_TABULAR_HTTP_TIMEOUT_SECONDS'
20+
RETRIES_ENV = 'SIMPLECHAT_TABULAR_HTTP_MAX_RETRIES'
21+
BUDGET_ENV = 'SIMPLECHAT_TABULAR_HTTP_BUDGET_SECONDS'
22+
23+
24+
def _diagnostic_number(name, default, minimum, maximum, integer=False):
25+
try:
26+
raw_value = os.environ.get(name, str(default))
27+
value = int(raw_value) if integer else float(raw_value)
28+
if math.isfinite(value) and minimum <= value <= maximum:
29+
return value
30+
except (ValueError, TypeError, OverflowError):
31+
pass
32+
return default
33+
34+
35+
def _safe_provider_request_id(response):
36+
for name in ('x-request-id', 'apim-request-id', 'x-ms-request-id'):
37+
value = response.headers.get(name, '')
38+
if re.fullmatch(r'[A-Za-z0-9_-]{1,128}', value):
39+
return value
40+
return ''
41+
42+
43+
class TabularDiagnosticAsyncClient(DefaultAsyncHttpxClient):
44+
"""Observe SDK HTTP attempts without inspecting request or response bodies."""
45+
46+
def __init__(self, *, budget_seconds, **kwargs):
47+
super().__init__(**kwargs)
48+
self.diagnostic_run_id = uuid.uuid4().hex
49+
self.budget_seconds = budget_seconds
50+
self._first_attempt_at = None
51+
self._attempt_sequence = 0
52+
53+
def _emit(self, operation, properties, level=logging.INFO):
54+
try:
55+
log_event(
56+
f'[TABULAR_MODEL_HTTP] {operation}',
57+
extra={
58+
'diagnostic_run_id': self.diagnostic_run_id,
59+
'operation': operation,
60+
'service': 'tabular-analysis',
61+
**properties,
62+
},
63+
level=level,
64+
)
65+
except Exception:
66+
pass
67+
68+
async def send(self, request, **kwargs):
69+
started_at = time.monotonic()
70+
if self._first_attempt_at is None:
71+
self._first_attempt_at = started_at
72+
self._attempt_sequence += 1
73+
try:
74+
retry_count = max(0, int(request.headers.get('x-stainless-retry-count', '0')))
75+
except (ValueError, TypeError):
76+
retry_count = 0
77+
endpoint_identity = f'{request.url.host}:{request.url.port}{request.url.path}'
78+
properties = {
79+
'http_attempt_id': uuid.uuid4().hex,
80+
'attempt_sequence': self._attempt_sequence,
81+
'retry_count': retry_count,
82+
'endpoint_fingerprint': hashlib.sha256(endpoint_identity.encode('utf-8')).hexdigest()[:16],
83+
'budget_seconds': self.budget_seconds,
84+
}
85+
self._emit('attempt_started', properties)
86+
try:
87+
remaining = self.budget_seconds - (started_at - self._first_attempt_at)
88+
if remaining <= 0:
89+
raise TimeoutError('Tabular diagnostic HTTP budget exhausted')
90+
async with asyncio.timeout(remaining):
91+
response = await super().send(request, **kwargs)
92+
except (Exception, asyncio.CancelledError) as exc:
93+
cause = exc
94+
seen = set()
95+
while cause.__cause__ is not None and id(cause) not in seen:
96+
seen.add(id(cause))
97+
cause = cause.__cause__
98+
self._emit('attempt_failed', {
99+
**properties,
100+
'duration_ms': round((time.monotonic() - started_at) * 1000, 3),
101+
'error_type': type(exc).__name__,
102+
'reason': type(cause).__name__,
103+
'budget_exhausted': time.monotonic() - self._first_attempt_at >= self.budget_seconds,
104+
}, level=logging.WARNING)
105+
raise
106+
self._emit('attempt_completed', {
107+
**properties,
108+
'duration_ms': round((time.monotonic() - started_at) * 1000, 3),
109+
'status_code': response.status_code,
110+
'success': response.is_success,
111+
'provider_request_id': _safe_provider_request_id(response),
112+
})
113+
return response
114+
115+
116+
def build_tabular_model_http_options(service_id):
117+
"""Return SDK options only for explicitly enabled tabular-analysis clients."""
118+
enabled = os.environ.get(DIAGNOSTICS_ENV, '').strip().lower() in {'1', 'true', 'yes', 'on'}
119+
if service_id != 'tabular-analysis' or not enabled:
120+
return {}
121+
timeout = _diagnostic_number(TIMEOUT_ENV, 60, 1, 600)
122+
retries = _diagnostic_number(RETRIES_ENV, 0, 0, 2, integer=True)
123+
budget = _diagnostic_number(BUDGET_ENV, 180, 1, 1800)
124+
return {
125+
'http_client': TabularDiagnosticAsyncClient(budget_seconds=budget, timeout=timeout),
126+
'timeout': timeout,
127+
'max_retries': retries,
128+
}
129+
130+
131+
async def close_tabular_model_http_client(service):
132+
"""Close only the explicitly owned diagnostic SDK client after a tabular run."""
133+
sdk_client = getattr(service, 'client', None)
134+
http_client = getattr(sdk_client, '_client', None)
135+
if isinstance(http_client, TabularDiagnosticAsyncClient):
136+
try:
137+
await sdk_client.close()
138+
except Exception as exc:
139+
http_client._emit('cleanup_failed', {'error_type': type(exc).__name__}, level=logging.WARNING)

0 commit comments

Comments
 (0)