diff --git a/application/single_app/config.py b/application/single_app/config.py index e5e7ad5e..ef623f47 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -95,7 +95,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.108" +VERSION = "0.250.109" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 564d00fe..eb56889d 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -1938,6 +1938,42 @@ def normalize_model_endpoint_auth_for_environment(endpoint_copy): return changed +MODEL_RESPONSE_LENGTH_FIELDS = ( + "responseLength", + "response_length", + "maxTokens", + "max_tokens", + "maxCompletionTokens", + "max_completion_tokens", +) + + +def normalize_model_response_length(value): + """Return a positive integer response length, or None when unset/invalid.""" + if value in (None, "") or isinstance(value, bool): + return None + if isinstance(value, int): + return value if value > 0 else None + + value_text = str(value).strip() + if not value_text or not value_text.isdigit(): + return None + + response_length = int(value_text) + return response_length if response_length > 0 else None + + +def normalize_model_response_length_from_model(model): + """Resolve the canonical response length from a model endpoint row.""" + if not isinstance(model, dict): + return None + + for field_name in MODEL_RESPONSE_LENGTH_FIELDS: + if field_name in model: + return normalize_model_response_length(model.get(field_name)) + return None + + def normalize_model_endpoints(endpoints): """Normalize model endpoints with stable IDs and enabled flags.""" if not isinstance(endpoints, list): @@ -1986,6 +2022,18 @@ def normalize_model_endpoints(endpoints): if model_copy.get("enabled") is None: model_copy["enabled"] = True changed = True + response_length = normalize_model_response_length_from_model(model_copy) + for response_length_field in MODEL_RESPONSE_LENGTH_FIELDS: + if response_length_field != "responseLength" and response_length_field in model_copy: + model_copy.pop(response_length_field, None) + changed = True + if response_length is None: + if "responseLength" in model_copy: + model_copy.pop("responseLength", None) + changed = True + elif model_copy.get("responseLength") != response_length: + model_copy["responseLength"] = response_length + changed = True try: normalized_icon = normalize_icon_payload(model_copy.get("icon"), field_name="model.icon") except ValueError: diff --git a/application/single_app/model_endpoint_clients.py b/application/single_app/model_endpoint_clients.py index dba5f212..9e75e65e 100644 --- a/application/single_app/model_endpoint_clients.py +++ b/application/single_app/model_endpoint_clients.py @@ -54,7 +54,15 @@ def is_anthropic_model(self) -> bool: @property def is_openai_reasoning_model(self) -> bool: - return self.normalized_deployment_name.startswith(OPENAI_REASONING_MODEL_PREFIXES) + normalized_model_name = ( + self.normalized_deployment_name + .replace("_", "-") + .replace(" ", "-") + ) + return ( + normalized_model_name.startswith(OPENAI_REASONING_MODEL_PREFIXES) + or "gpt-5" in normalized_model_name + ) @property def is_foundry_non_openai_model(self) -> bool: @@ -76,6 +84,10 @@ def resolve_reasoning_effort(self, reasoning_effort: Any) -> str: return "" return normalized_reasoning_effort if self.is_openai_reasoning_model else "" + @property + def response_length_parameter(self) -> str: + return "max_completion_tokens" if self.is_openai_reasoning_model else "max_tokens" + def normalize_endpoint_text(endpoint: Any) -> str: """Return a trimmed endpoint URL without a trailing slash.""" diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 78d5ddde..2bfc73d7 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -290,6 +290,19 @@ def _resolve_reasoning_effort_for_model(reasoning_effort, model_name, provider=N return resolved_reasoning_effort +def _apply_response_length_for_model(api_params, response_length, model_name, provider=None, response_length_parameter=None): + normalized_response_length = normalize_model_response_length(response_length) + if not normalized_response_length: + return None + + response_length_parameter = response_length_parameter or ModelEndpointBehavior(provider, model_name).response_length_parameter + api_params[response_length_parameter] = normalized_response_length + debug_print( + f"[ModelEndpoint] Applying response length: {response_length_parameter}={normalized_response_length} for {model_name}" + ) + return response_length_parameter + + def _is_foundry_non_openai_model(provider, model_name): return ModelEndpointBehavior(provider, model_name).is_foundry_non_openai_model @@ -298,6 +311,18 @@ def _should_inject_fact_memory_context_for_model(provider, model_name): return ModelEndpointBehavior(provider, model_name).context_mode != MODEL_CONTEXT_MODE_FOLD_LATEST_USER +def _build_model_endpoint_behavior_name(model_cfg, deployment): + if not isinstance(model_cfg, dict): + return deployment + model_names = [ + deployment, + model_cfg.get('modelName'), + model_cfg.get('displayName'), + model_cfg.get('name'), + ] + return ' '.join(str(model_name or '').strip() for model_name in model_names if str(model_name or '').strip()) + + def _build_plain_fact_memory_background_notes(prompt_payload): note_values = [] for payload_key in ('instruction_payload', 'recall_payload'): @@ -10876,6 +10901,13 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ api_version = str(connection.get('openai_api_version') or connection.get('api_version') or '').strip() runtime_protocol = infer_model_endpoint_protocol(provider, endpoint, deployment) 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) + model_response_length_parameter = ( + ModelEndpointBehavior(provider, model_behavior_name).response_length_parameter + if model_response_length + else None + ) if requested_provider and requested_provider != provider: debug_print( @@ -10905,7 +10937,9 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ 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"deployment={deployment} | api_version={api_version} | protocol={runtime_protocol} | " + f"response_length={model_response_length or ''} | " + f"response_length_parameter={model_response_length_parameter or ''}" ) return ( gpt_client, @@ -10917,6 +10951,8 @@ def resolve_streaming_multi_endpoint_gpt_config(settings, data, user_id, active_ requested_endpoint_id, str(model_cfg.get('id') or '').strip(), model_icon, + model_response_length, + model_response_length_parameter, ) @@ -12889,6 +12925,8 @@ def result_requires_message_reload(result: Any) -> bool: gpt_endpoint_id = None gpt_model_id = None gpt_model_icon = None + gpt_response_length = None + gpt_response_length_parameter = None tabular_model_context = None enable_gpt_apim = settings.get('enable_gpt_apim', False) enable_image_gen_apim = settings.get('enable_image_gen_apim', False) @@ -12921,6 +12959,8 @@ def result_requires_message_reload(result: Any) -> bool: gpt_endpoint_id, gpt_model_id, gpt_model_icon, + gpt_response_length, + gpt_response_length_parameter, ) = multi_endpoint_config elif enable_gpt_apim: # read raw comma-delimited deployments @@ -13342,6 +13382,7 @@ def result_requires_message_reload(result: Any) -> bool: 'model_id': gpt_model_id or data.get('model_id'), 'model_provider': gpt_provider or data.get('model_provider'), 'model_icon': gpt_model_icon, + 'response_length': gpt_response_length, 'reasoning_effort': reasoning_effort if reasoning_effort and reasoning_effort != 'none' else None, 'streaming': 'Disabled' } @@ -15597,6 +15638,13 @@ def invoke_gpt_fallback(): 'model': gpt_model, 'messages': conversation_history_for_api, } + _apply_response_length_for_model( + api_params, + gpt_response_length, + gpt_model, + provider=gpt_provider, + response_length_parameter=gpt_response_length_parameter, + ) request_reasoning_effort = _resolve_reasoning_effort_for_model( reasoning_effort, @@ -15869,6 +15917,7 @@ def gpt_error(e): 'model_id': gpt_model_id, 'model_provider': gpt_provider, 'model_icon': gpt_model_icon, + 'response_length': gpt_response_length, 'streaming': 'Disabled', }, 'history_context': history_debug_info, @@ -16592,6 +16641,8 @@ def build_streaming_capability_usage(): gpt_endpoint_id = None gpt_model_id = None gpt_model_icon = None + gpt_response_length = None + gpt_response_length_parameter = None tabular_model_context = None enable_gpt_apim = settings.get('enable_gpt_apim', False) should_use_default_model = ( @@ -16625,6 +16676,8 @@ def build_streaming_capability_usage(): gpt_endpoint_id, gpt_model_id, gpt_model_icon, + gpt_response_length, + gpt_response_length_parameter, ) = streaming_multi_endpoint_config elif enable_gpt_apim: raw = settings.get('azure_apim_gpt_deployment', '') @@ -16993,6 +17046,7 @@ def build_streaming_capability_usage(): 'model_id': gpt_model_id or data.get('model_id'), 'model_provider': gpt_provider or data.get('model_provider'), 'model_icon': gpt_model_icon, + 'response_length': gpt_response_length, 'reasoning_effort': reasoning_effort if reasoning_effort and reasoning_effort != 'none' else None, 'streaming': 'Enabled' } @@ -18432,6 +18486,7 @@ def finalize_cancelled_stream_response(): 'model_id': gpt_model_id, 'model_provider': gpt_provider, 'model_icon': gpt_model_icon, + 'response_length': gpt_response_length, 'streaming': 'Enabled', }, 'history_context': history_debug_info, @@ -18836,6 +18891,13 @@ def finalize_cancelled_agent_stream_response(): 'stream': True, 'stream_options': {'include_usage': True} # Request token usage in final chunk } + _apply_response_length_for_model( + stream_params, + gpt_response_length, + gpt_model, + provider=gpt_provider, + response_length_parameter=gpt_response_length_parameter, + ) request_reasoning_effort = _resolve_reasoning_effort_for_model( reasoning_effort, @@ -19014,6 +19076,7 @@ def finalize_cancelled_agent_stream_response(): 'model_id': gpt_model_id, 'model_provider': gpt_provider, 'model_icon': gpt_model_icon, + 'response_length': gpt_response_length, 'streaming': 'Enabled', }, 'history_context': history_debug_info, @@ -19102,6 +19165,7 @@ def finalize_cancelled_agent_stream_response(): if 'metadata' in user_message_doc and 'model_selection' in user_message_doc['metadata']: user_message_doc['metadata']['model_selection']['selected_model'] = final_model_used if use_agent_streaming else gpt_model user_message_doc['metadata']['model_selection']['model_icon'] = gpt_model_icon + user_message_doc['metadata']['model_selection']['response_length'] = gpt_response_length if selected_agent_metadata: user_message_doc.setdefault('metadata', {})['agent_selection'] = selected_agent_metadata cosmos_messages_container.upsert_item(user_message_doc) 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 d6c6e69a..8957b647 100644 --- a/application/single_app/static/js/admin/admin_model_endpoints.js +++ b/application/single_app/static/js/admin/admin_model_endpoints.js @@ -998,6 +998,44 @@ function createModelTextInput(modelId, datasetKey, value, readOnly = 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}`; @@ -1148,6 +1186,7 @@ function renderModalModels(models) { const modelName = model.modelName || ""; const displayName = model.displayName || deploymentName; const description = model.description || ""; + const responseLength = getModelResponseLength(model); const deploymentReadonly = model.isDiscovered ? "readonly" : ""; const modelId = model.id || generateId(); model.id = modelId; @@ -1179,12 +1218,22 @@ function renderModalModels(models) { const iconCol = createElement("div", "col-md-4"); iconCol.appendChild(createSmallLabel("Icon")); iconCol.appendChild(createModelIconEditor(model, modelId)); + 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); const descriptionCol = createElement("div", "col-md-8"); descriptionCol.appendChild(createSmallLabel("Description (optional)")); descriptionCol.appendChild(createModelTextInput(modelId, "descriptionFor", description)); fieldsRow.appendChild(deploymentCol); fieldsRow.appendChild(displayCol); fieldsRow.appendChild(iconCol); + fieldsRow.appendChild(responseLengthCol); fieldsRow.appendChild(descriptionCol); const actions = createElement("div", "d-flex gap-2 mt-2"); @@ -1224,12 +1273,22 @@ function collectModalModels() { const deploymentInput = modelsListEl.querySelector(`input[data-deployment-name-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}"]`); 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; 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; } diff --git a/application/single_app/templates/_multiendpoint_modal.html b/application/single_app/templates/_multiendpoint_modal.html index 6380cd6b..34fcafcc 100644 --- a/application/single_app/templates/_multiendpoint_modal.html +++ b/application/single_app/templates/_multiendpoint_modal.html @@ -153,6 +153,9 @@
Identity se
Available Models
+
+ Set an optional response length on each model to cap standard chat output tokens for that model. +
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index b4163139..91efdb65 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.109)** + +#### New Features + +* **Per-Model Response Length Overrides** + * Administrators can now set an optional response-length/output-token ceiling on each model in global multi-endpoint GPT configuration. + * Standard chat applies the selected model's configured ceiling with the correct backend token parameter for GPT-5/o-series aliases and other OpenAI-compatible chat models. + * Existing endpoint model records remain compatible when the field is blank or absent. + * (Ref: Closes #1143, related #1047 and #358, `functions_settings.py`, `route_backend_chats.py`, `admin_model_endpoints.js`) + ### **(v0.250.108)** #### User Interface Enhancements diff --git a/functional_tests/test_model_endpoint_normalization_backend.py b/functional_tests/test_model_endpoint_normalization_backend.py index acc64aa3..cd97f653 100644 --- a/functional_tests/test_model_endpoint_normalization_backend.py +++ b/functional_tests/test_model_endpoint_normalization_backend.py @@ -1,11 +1,13 @@ # test_model_endpoint_normalization_backend.py """ Functional test for backend model endpoint normalization. -Version: 0.239.155 -Implemented in: 0.239.155 +Version: 0.250.109 +Implemented in: 0.239.155; updated in 0.250.109 This test ensures model endpoints are normalized with stable IDs and enabled -flags so frontend consumers receive consistent identifiers. +flags so frontend consumers receive consistent identifiers. It also verifies +per-model response length values are stored as positive integer output-token +ceilings and invalid values are removed before runtime use. """ import os @@ -31,19 +33,57 @@ def _restore_modules(original_modules): def _load_functions_settings_module(): config_stub = types.ModuleType("config") config_stub.json = json + config_stub.AZURE_ENVIRONMENT = "public" appinsights_stub = types.ModuleType("functions_appinsights") appinsights_stub.log_event = lambda *args, **kwargs: None + appinsights_stub.debug_print = lambda *args, **kwargs: None + appinsights_stub.is_debug_enabled = lambda *args, **kwargs: False cache_stub = types.ModuleType("app_settings_cache") 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: {} + + document_actions_stub = types.ModuleType("functions_document_actions") + document_actions_stub.get_default_document_action_capabilities = lambda: {} + + icon_utils_stub = types.ModuleType("functions_icon_utils") + icon_utils_stub.normalize_icon_payload = lambda value, field_name="": value if isinstance(value, dict) else {} + + 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: {} + + support_menu_stub = types.ModuleType("support_menu_config") + support_menu_stub.get_default_support_latest_features_visibility = lambda: {} + support_menu_stub.has_visible_support_latest_features = lambda *args, **kwargs: False + support_menu_stub.normalize_support_latest_features_visibility = lambda settings: None + original_modules = {} for module_name, module_stub in { "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, }.items(): original_modules[module_name] = sys.modules.get(module_name) sys.modules[module_name] = module_stub @@ -67,7 +107,12 @@ def test_model_endpoint_normalization_backend(): "connection": {"endpoint": "https://foundry.example"}, "models": [ { - "deploymentName": "gpt-4o" + "deploymentName": "gpt-4o", + "response_length": "2048" + }, + { + "deploymentName": "gpt-5.6-luna", + "responseLength": 0 } ] } @@ -83,6 +128,15 @@ def test_model_endpoint_normalization_backend(): assert "has_client_secret" not in normalized[0] assert normalized[0]["models"][0]["id"] == "gpt-4o" assert normalized[0]["models"][0]["enabled"] is True + assert normalized[0]["models"][0]["responseLength"] == 2048 + assert "response_length" not in normalized[0]["models"][0] + assert "responseLength" not in normalized[0]["models"][1] + assert functions_settings.normalize_model_response_length_from_model( + {"max_completion_tokens": "4096"} + ) == 4096 + assert functions_settings.normalize_model_response_length_from_model( + {"responseLength": "not-a-number"} + ) is None finally: _restore_modules(original_modules) diff --git a/functional_tests/test_model_endpoint_protocol_inference.py b/functional_tests/test_model_endpoint_protocol_inference.py index 30520eb6..c2d9ce34 100644 --- a/functional_tests/test_model_endpoint_protocol_inference.py +++ b/functional_tests/test_model_endpoint_protocol_inference.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for model endpoint protocol inference. -Version: 0.250.006 -Implemented in: 0.241.179; updated in 0.250.006 +Version: 0.250.109 +Implemented in: 0.241.179; updated in 0.250.109 This test ensures that Foundry model endpoint runtime calls infer Claude as Anthropic messages, OpenAI-compatible Foundry endpoints as /openai/v1, and @@ -30,6 +30,7 @@ AnthropicSemanticKernelChatCompletion, extract_chat_completion_response_text, infer_model_endpoint_protocol, + ModelEndpointBehavior, normalize_anthropic_messages_url, normalize_chat_completion_text, normalize_openai_style_base_url, @@ -89,6 +90,31 @@ def test_model_endpoint_protocol_inference(): MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI, "Azure OpenAI endpoints should keep the Azure OpenAI protocol", ) + assert_equal( + ModelEndpointBehavior("aoai", "gpt-5.6-luna").response_length_parameter, + "max_completion_tokens", + "GPT-5 models should use max_completion_tokens for response length", + ) + assert_equal( + ModelEndpointBehavior("aoai", "N-gpt-5.6-terra").response_length_parameter, + "max_completion_tokens", + "GPT-5 aliases embedded in deployment names should use max_completion_tokens", + ) + assert_equal( + ModelEndpointBehavior("aoai", "luna-deployment GPT 5.6 Luna").response_length_parameter, + "max_completion_tokens", + "GPT-5 aliases from model display names should use max_completion_tokens", + ) + assert_equal( + ModelEndpointBehavior("aoai", "o4-mini").response_length_parameter, + "max_completion_tokens", + "o-series models should use max_completion_tokens for response length", + ) + assert_equal( + ModelEndpointBehavior("aoai", "gpt-4o").response_length_parameter, + "max_tokens", + "Non-reasoning chat models should use max_tokens for response length", + ) assert_equal( normalize_anthropic_messages_url(project_endpoint), diff --git a/functional_tests/test_model_endpoints_api_key_manual_models.py b/functional_tests/test_model_endpoints_api_key_manual_models.py index ec210504..704720ed 100644 --- a/functional_tests/test_model_endpoints_api_key_manual_models.py +++ b/functional_tests/test_model_endpoints_api_key_manual_models.py @@ -2,11 +2,12 @@ #!/usr/bin/env python3 """ Functional test for API key manual model entry in endpoint modal. -Version: 0.239.155 -Implemented in: 0.239.155 +Version: 0.250.109 +Implemented in: 0.239.155; updated in 0.250.109 This test ensures the API key flow exposes manual model entry UI, -per-model test buttons, and management cloud fields for service principal. +per-model test buttons, management cloud fields for service principal, and +per-model response length inputs for admin-managed output-token ceilings. """ import os @@ -32,11 +33,15 @@ def test_model_endpoints_api_key_manual_models(): assert 'id="model-endpoint-add-model-btn"' in template_content, "Missing Add Model button for API key flow." assert 'id="model-endpoint-management-cloud"' in template_content, "Missing management cloud selector." assert 'id="model-endpoint-custom-authority"' in template_content, "Missing custom authority input." + assert 'optional response length' in template_content, "Missing response length guidance." assert 'addManualModel' in js_content, "Missing manual model add handler." assert 'test-model' in js_content, "Missing per-model test action wiring." assert 'management_cloud' in js_content, "Missing management cloud payload wiring." assert 'const endpointId = endpointIdInput?.value.trim() || "";' in js_content, "Missing endpoint ID request wiring." + assert 'dataset.responseLengthFor' in js_content, "Missing response length input data binding." + assert 'model.responseLength = responseLength' in js_content, "Missing response length serialization." + assert 'Response length must be a positive whole number.' in js_content, "Missing response length validation message." assert '/api/models/test-model' in backend_content, "Missing backend test-model endpoint." assert 'resolve_request_endpoint_payload' in backend_content, "Missing stored-secret request resolution helper." diff --git a/functional_tests/test_streaming_multi_endpoint_resolution.py b/functional_tests/test_streaming_multi_endpoint_resolution.py index 00c862b3..0c14c5ab 100644 --- a/functional_tests/test_streaming_multi_endpoint_resolution.py +++ b/functional_tests/test_streaming_multi_endpoint_resolution.py @@ -2,12 +2,13 @@ #!/usr/bin/env python3 """ Functional test for streaming multi-endpoint model resolution. -Version: 0.239.200 -Implemented in: 0.239.200 +Version: 0.250.109 +Implemented in: 0.239.200; updated in 0.250.109 This test ensures streaming requests resolve selected models by endpoint and model identifiers, hydrate saved endpoint auth, and build provider-aware -clients for Azure OpenAI and Foundry selections. +clients for Azure OpenAI and Foundry selections. It also verifies the selected +model's response length is resolved and applied to chat completion params. """ import os @@ -52,6 +53,24 @@ def test_streaming_multi_endpoint_resolution_wiring(): assert 'active_group_ids=active_group_ids' in content, ( 'Expected streaming group context to be supplied when resolving scoped model endpoints.' ) + assert 'model_response_length = normalize_model_response_length_from_model(model_cfg)' in content, ( + 'Expected model endpoint resolution to read per-model response length.' + ) + assert 'gpt_response_length' in content, ( + 'Expected resolved response length to flow through chat generation state.' + ) + assert '_apply_response_length_for_model(' in content, ( + 'Expected chat completion params to apply per-model response length.' + ) + assert 'response_length_parameter = response_length_parameter or ModelEndpointBehavior(provider, model_name).response_length_parameter' in content, ( + 'Expected model behavior helper to choose max_tokens vs max_completion_tokens.' + ) + assert 'gpt_response_length_parameter' in content, ( + 'Expected resolved response length parameter to flow through chat generation state.' + ) + assert '_build_model_endpoint_behavior_name(model_cfg, deployment)' in content, ( + 'Expected parameter selection to consider model display/model/deployment aliases.' + ) print('✅ Streaming multi-endpoint model resolution wiring verified.') diff --git a/ui_tests/test_model_endpoint_request_uses_endpoint_id.py b/ui_tests/test_model_endpoint_request_uses_endpoint_id.py index 8954cd15..dd5015a9 100644 --- a/ui_tests/test_model_endpoint_request_uses_endpoint_id.py +++ b/ui_tests/test_model_endpoint_request_uses_endpoint_id.py @@ -1,14 +1,15 @@ # test_model_endpoint_request_uses_endpoint_id.py """ UI test for model endpoint request identity wiring. -Version: 0.250.006 -Implemented in: 0.250.003; updated in 0.250.006 +Version: 0.250.109 +Implemented in: 0.250.003; updated in 0.250.109 This test ensures the admin multi-endpoint modal exposes the supported providers, shows the APIM provider guidance, handles Foundry API version selection and project endpoint parsing, exposes setup guidance and model icon picker controls, and sends the endpoint ID in the test-model request payload so -the backend can resolve Key Vault-backed secrets. +the backend can resolve Key Vault-backed secrets. It also validates per-model +response length entry serialization. """ import os @@ -114,6 +115,7 @@ def handle_test_request(route): page.locator("#model-endpoint-api-key").fill("temporary-ui-secret") page.locator("#model-endpoint-add-model-btn").click() page.locator("input[data-deployment-name-for]").first.fill("gpt-4o") + page.locator("input[data-response-length-for]").first.fill("2048") expect(page.locator(".model-icon-preview").first).to_be_visible() expect(page.locator(".model-icon-picker-button").first).to_contain_text("bi-stars") page.locator(".model-icon-picker-button").first.click() @@ -130,6 +132,10 @@ def handle_test_request(route): expect(page.locator("#modelEndpointModal")).to_be_visible() assert captured_request.get("id") == "stored-endpoint-123" assert captured_request.get("model", {}).get("deploymentName") == "gpt-4o" + + page.locator("#model-endpoint-save-btn").click() + endpoint_payload = page.locator("#model_endpoints_json").input_value() + assert '"responseLength":2048' in endpoint_payload finally: context.close() browser.close() \ No newline at end of file