From e205255dfd50b8a1e6a463ecaf49865b9117c485 Mon Sep 17 00:00:00 2001 From: m_sakai Date: Mon, 1 Jun 2026 02:59:15 +0900 Subject: [PATCH 1/6] feat(azure_openai): add request metadata normalization helper Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.8 (1M context) --- models/azure_openai/models/llm/_metadata.py | 97 ++++++++++++++++ models/azure_openai/tests/test_metadata.py | 121 ++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 models/azure_openai/models/llm/_metadata.py create mode 100644 models/azure_openai/tests/test_metadata.py diff --git a/models/azure_openai/models/llm/_metadata.py b/models/azure_openai/models/llm/_metadata.py new file mode 100644 index 000000000..5242e342b --- /dev/null +++ b/models/azure_openai/models/llm/_metadata.py @@ -0,0 +1,97 @@ +"""Helpers for attaching Dify metadata to OpenAI chat/responses requests. + +OpenAI's ``chat.completions.create`` and ``responses.create`` accept a +``metadata`` field (a string-to-string map) that — when the account has +Stored Completions enabled — surfaces in the Usage Dashboard, enabling +per-app filtering. Constraints, from the OpenAI API reference: + + Set of 16 key-value pairs that can be attached to an object. Keys are + strings with a maximum length of 64 characters. Values are strings with + a maximum length of 512 characters. + +Unlike Bedrock or Vertex, OpenAI does not document a character pattern +restriction on metadata values, so ``normalize_metadata_value`` only +enforces string coercion and the 512-character length cap. + +This plugin deliberately does NOT set ``store=true``. Whether stored +completions are retained — and therefore whether this metadata appears +in the dashboard — is governed by the account-level Stored Completions +setting, which the terminus owner controls. Setting ``store`` from the +plugin would change persistence behavior as a side effect of an +observability opt-in, which is out of scope for this feature. +""" + +from __future__ import annotations + +from typing import Any, Optional + +_MAX_VALUE_LENGTH = 512 + + +def normalize_metadata_value(s: Any) -> str: + """Normalize an arbitrary value into an OpenAI metadata value. + + Coerces non-string input via ``str()`` so that, e.g., a numeric ``0`` + becomes ``"0"`` rather than being silently dropped by the empty-check, + then truncates to 512 characters. OpenAI does not document a + character-pattern restriction, so no character substitution is + performed. + """ + if not isinstance(s, str): + s = str(s) + if not s: + return "" + return s[:_MAX_VALUE_LENGTH] + + +def build_dify_metadata(app_id: Any) -> Optional[dict[str, str]]: + """Build the Dify metadata dict for an OpenAI request, or return ``None``. + + Returns ``None`` if ``app_id`` is ``None`` or an empty string, so the + caller can skip attaching metadata entirely. Other falsy values (e.g. + numeric ``0``) are coerced by ``normalize_metadata_value`` and pass + through. Otherwise, returns a dict with ``dify_app_id`` (normalized) + and a static ``dify_source`` marker. + """ + if app_id is None or app_id == "": + return None + return { + "dify_app_id": normalize_metadata_value(app_id), + "dify_source": "dify", + } + + +def apply_dify_metadata_if_enabled(target: dict, credentials: dict) -> None: + """Inject Dify metadata into ``target`` when opt-in credential is set. + + Reads ``credentials['enable_request_metadata']``; when ``"enabled"``, + resolves the current Dify session's ``app_id`` (best-effort) and sets + ``target['metadata']`` to the built dict, if one is produced. + + Session lookup failures are swallowed silently: metadata attachment is + telemetry, and must never break generation if the SDK is missing or + the session context is not initialized. + """ + if credentials.get("enable_request_metadata") != "enabled": + return + + app_id: Optional[str] = None + try: + from dify_plugin import get_current_session + + session = get_current_session() + if session is not None: + app_id = getattr(session, "app_id", None) + except Exception: + # Best-effort telemetry: never break generation. + pass + + metadata = build_dify_metadata(app_id) + if metadata is None: + return + existing = target.get("metadata") + if isinstance(existing, dict): + # Preserve any caller-supplied metadata; only fill in Dify keys. + existing.update(metadata) + else: + target["metadata"] = metadata diff --git a/models/azure_openai/tests/test_metadata.py b/models/azure_openai/tests/test_metadata.py new file mode 100644 index 000000000..37c5ddfb0 --- /dev/null +++ b/models/azure_openai/tests/test_metadata.py @@ -0,0 +1,121 @@ +from models.llm._metadata import ( + apply_dify_metadata_if_enabled, + build_dify_metadata, + normalize_metadata_value, +) + + +def test_normalize_uuid_passthrough(): + uuid = "550e8400-e29b-41d4-a716-446655440000" + assert normalize_metadata_value(uuid) == uuid + + +def test_normalize_preserves_punctuation_and_unicode(): + # OpenAI does not document a character pattern restriction; values are + # only length-bounded. Brackets, slashes, and non-ASCII pass through. + assert normalize_metadata_value("a[b]c") == "a[b]c" + assert normalize_metadata_value("日本語") == "日本語" + + +def test_normalize_preserves_mixed_case(): + assert normalize_metadata_value("FOO-Bar") == "FOO-Bar" + + +def test_normalize_truncates_at_512_chars(): + long_input = "a" * 600 + result = normalize_metadata_value(long_input) + assert len(result) == 512 + assert result == "a" * 512 + + +def test_normalize_empty_string(): + assert normalize_metadata_value("") == "" + + +def test_normalize_coerces_non_string_input(): + # Non-string inputs should be stringified before validation, so a + # numeric 0 (falsy) does not get dropped by the empty-check. + assert normalize_metadata_value(0) == "0" + assert normalize_metadata_value(123) == "123" + + +def test_build_dify_metadata_returns_none_for_none(): + assert build_dify_metadata(None) is None + + +def test_build_dify_metadata_returns_none_for_empty(): + assert build_dify_metadata("") is None + + +def test_build_dify_metadata_keeps_non_string_falsy(): + # build_dify_metadata only rejects None and "" — other falsy values + # such as numeric 0 are coerced by normalize_metadata_value. + metadata = build_dify_metadata(0) + assert metadata == {"dify_app_id": "0", "dify_source": "dify"} + + +def test_build_dify_metadata_includes_source_marker(): + metadata = build_dify_metadata("550e8400-e29b-41d4-a716-446655440000") + assert metadata is not None + assert metadata["dify_source"] == "dify" + + +def test_build_dify_metadata_normalizes_app_id_length(): + metadata = build_dify_metadata("x" * 1000) + assert metadata is not None + assert len(metadata["dify_app_id"]) == 512 + + +def test_build_dify_metadata_uuid_passthrough(): + uuid = "550e8400-e29b-41d4-a716-446655440000" + metadata = build_dify_metadata(uuid) + assert metadata == {"dify_app_id": uuid, "dify_source": "dify"} + + +def test_apply_no_op_when_credential_missing(): + target: dict = {} + apply_dify_metadata_if_enabled(target, {}) + assert target == {} + + +def test_apply_no_op_when_credential_disabled(): + target: dict = {} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "disabled"}) + assert target == {} + + +def test_apply_silent_on_session_lookup_failure(): + # Without a Dify session context, get_current_session raises; the + # helper must swallow that and leave target unchanged. + target: dict = {} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"}) + assert "metadata" not in target + + +class _FakeSession: + app_id = "550e8400-e29b-41d4-a716-446655440000" + + +def test_apply_merges_with_existing_metadata(monkeypatch): + # When the target already carries a metadata dict (e.g. caller-supplied + # values), Dify keys must merge into it rather than replace it wholesale. + import dify_plugin + + monkeypatch.setattr(dify_plugin, "get_current_session", lambda: _FakeSession()) + target: dict = {"metadata": {"user_supplied": "value"}} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"}) + assert target["metadata"]["user_supplied"] == "value" + assert target["metadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000" + assert target["metadata"]["dify_source"] == "dify" + + +def test_apply_replaces_non_dict_metadata(monkeypatch): + # If existing metadata is somehow not a dict, Dify keys take over rather + # than blow up — telemetry is best-effort. + import dify_plugin + + monkeypatch.setattr(dify_plugin, "get_current_session", lambda: _FakeSession()) + target: dict = {"metadata": "unexpected-string"} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"}) + assert isinstance(target["metadata"], dict) + assert target["metadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000" From 5ba7696d48804c954642617acc939377399924c7 Mon Sep 17 00:00:00 2001 From: m_sakai Date: Mon, 1 Jun 2026 02:59:16 +0900 Subject: [PATCH 2/6] feat(azure_openai): attach app_id metadata in chat completions and responses routes Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.8 (1M context) --- models/azure_openai/models/llm/llm.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/models/azure_openai/models/llm/llm.py b/models/azure_openai/models/llm/llm.py index aa1e2e327..005412495 100644 --- a/models/azure_openai/models/llm/llm.py +++ b/models/azure_openai/models/llm/llm.py @@ -47,6 +47,7 @@ from ..common import _CommonAzureOpenAI from ..constants import LLM_BASE_MODELS, uses_responses_api +from ._metadata import apply_dify_metadata_if_enabled logger = logging.getLogger(__name__) @@ -383,6 +384,14 @@ def _chat_generate( messages: Any = [ self._convert_prompt_message_to_dict(m) for m in prompt_messages ] + + # Optional: attach Dify app_id as Azure OpenAI request metadata. Default + # disabled; opt-in via the enable_request_metadata credential. Whether + # this surfaces in Stored Completions depends on the Azure OpenAI + # resource's Stored Completions setting — the plugin does not set + # store=true. + apply_dify_metadata_if_enabled(extra_model_kwargs, credentials) + response = client.chat.completions.create( messages=messages, model=model, @@ -545,6 +554,11 @@ def _chat_generate_with_responses( if reasoning: responses_params["reasoning"] = reasoning + # Optional: attach Dify app_id as Azure OpenAI request metadata. Default + # disabled; opt-in via the enable_request_metadata credential. See + # _metadata.py for the rationale on not setting store=true. + apply_dify_metadata_if_enabled(responses_params, credentials) + logger.info( f"llm request with responses api: model={model}, stream={stream}, " f"parameters={responses_params}" From e627f44b1bfc16c314bb5fef13a973a95b6f2487 Mon Sep 17 00:00:00 2001 From: m_sakai Date: Mon, 1 Jun 2026 02:59:16 +0900 Subject: [PATCH 3/6] feat(azure_openai): add enable_request_metadata credential schema Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../azure_openai/provider/azure_openai.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/models/azure_openai/provider/azure_openai.yaml b/models/azure_openai/provider/azure_openai.yaml index 29589f737..4e65cbffe 100644 --- a/models/azure_openai/provider/azure_openai.yaml +++ b/models/azure_openai/provider/azure_openai.yaml @@ -163,6 +163,25 @@ model_credential_schema: required: false type: select variable: openai_api_version + - label: + en_US: Enable request metadata (optional) + zh_Hans: 启用请求元数据(可选) + options: + - label: + en_US: Enabled + zh_Hans: 已启用 + value: enabled + - label: + en_US: Disabled + zh_Hans: 已禁用 + value: disabled + required: false + type: select + variable: enable_request_metadata + default: disabled + help: + en_US: When enabled, attaches dify_app_id and dify_source as metadata on Azure OpenAI Chat Completions and Responses API calls, enabling per-app filtering of Stored Completions. Visibility in the dashboard depends on the Stored Completions setting on your Azure OpenAI resource; the plugin does not set store=true. Default disabled. + zh_Hans: 启用后,将在 Azure OpenAI Chat Completions 和 Responses API 调用中将 dify_app_id 和 dify_source 作为 metadata 附加,从而支持按应用过滤 Stored Completions(已存储的补全)。元数据是否在仪表板中可见取决于您的 Azure OpenAI 资源上的 Stored Completions 设置,本插件不会设置 store=true。默认禁用。 - label: en_US: Base Model zh_Hans: 基础模型 From e16b021d4160cbe1425e5e45df7bb268bf1b97db Mon Sep 17 00:00:00 2001 From: m_sakai Date: Mon, 1 Jun 2026 02:59:16 +0900 Subject: [PATCH 4/6] chore(azure_openai): bump version Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.8 (1M context) --- models/azure_openai/manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/azure_openai/manifest.yaml b/models/azure_openai/manifest.yaml index feceda398..4e4344226 100644 --- a/models/azure_openai/manifest.yaml +++ b/models/azure_openai/manifest.yaml @@ -24,4 +24,4 @@ resource: model: enabled: false type: plugin -version: 0.0.59 \ No newline at end of file +version: 0.0.60 \ No newline at end of file From c56764cb190ddd1d3c12be61f1ea871c49aae9cf Mon Sep 17 00:00:00 2001 From: m_sakai Date: Mon, 1 Jun 2026 02:59:17 +0900 Subject: [PATCH 5/6] chore(azure_openai): pin dify_plugin to sdks #313 branch Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.8 (1M context) --- models/azure_openai/pyproject.toml | 10 ++++-- models/azure_openai/uv.lock | 58 ++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/models/azure_openai/pyproject.toml b/models/azure_openai/pyproject.toml index 70279da05..a72df942c 100644 --- a/models/azure_openai/pyproject.toml +++ b/models/azure_openai/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.12" dependencies = [ "azure-core>=1.41.0", "azure-identity>=1.25.3", - "dify_plugin>=0.9.0", + "dify_plugin @ git+https://github.com/ryuta-kobayashi-ug/dify-plugin-sdks.git@feat/pass-session-to-model-plugins", "httpx>=0.28.1", "numpy>=2.4.6", "openai>=2.38.0", @@ -20,4 +20,10 @@ dependencies = [ # uv run black . -C -l 100 && uv run ruff check --fix [dependency-groups] -dev = [] +dev = [ + "pytest>=8.0.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/models/azure_openai/uv.lock b/models/azure_openai/uv.lock index 1902ec26a..55079c322 100644 --- a/models/azure_openai/uv.lock +++ b/models/azure_openai/uv.lock @@ -69,11 +69,16 @@ dependencies = [ { name = "tiktoken" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "azure-core", specifier = ">=1.41.0" }, { name = "azure-identity", specifier = ">=1.25.3" }, - { name = "dify-plugin", specifier = ">=0.9.0" }, + { name = "dify-plugin", git = "https://github.com/ryuta-kobayashi-ug/dify-plugin-sdks.git?rev=feat%2Fpass-session-to-model-plugins" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "numpy", specifier = ">=2.4.6" }, { name = "openai", specifier = ">=2.38.0" }, @@ -83,7 +88,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [] +dev = [{ name = "pytest", specifier = ">=8.0.0" }] [[package]] name = "blinker" @@ -310,7 +315,7 @@ wheels = [ [[package]] name = "dify-plugin" version = "0.9.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/ryuta-kobayashi-ug/dify-plugin-sdks.git?rev=feat%2Fpass-session-to-model-plugins#ba8243c68235761f711e46ebf9a3500cff3f4b93" } dependencies = [ { name = "dpkt" }, { name = "flask" }, @@ -326,10 +331,6 @@ dependencies = [ { name = "werkzeug" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/52/86c042b72d42eb40460563bacf2ed46ff6a2e02dbec299892fef0ed2f70f/dify_plugin-0.9.0.tar.gz", hash = "sha256:c32dc99d4122d960bd447ee65a17e1e93bf942bbad8f7b4d53a2841416ecd3e0", size = 318993, upload-time = "2026-05-20T08:10:40.445Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/dc/df1d6d1a8c604880702dd792226f175523f890bfca89f828f58b58661edc/dify_plugin-0.9.0-py3-none-any.whl", hash = "sha256:3299a8c77549a43f68705796eed4c9ca494664810fb8d10eb7ecce2e545f3d2d", size = 377064, upload-time = "2026-05-20T08:10:38.848Z" }, -] [[package]] name = "distro" @@ -529,6 +530,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -968,6 +978,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -1175,6 +1194,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pyjwt" version = "2.13.0" @@ -1189,6 +1217,22 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" From c85236718c7e947dda94cfc9cc805cd892333bec Mon Sep 17 00:00:00 2001 From: m_sakai Date: Mon, 1 Jun 2026 03:09:49 +0900 Subject: [PATCH 6/6] fix(azure_openai): use non-destructive metadata merge and guard None in normalize per review Per gemini-code-assist on #3233. Aligns 4 plugins on the same 'no side effects on existing args' principle. Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.8 (1M context) --- models/azure_openai/models/llm/_metadata.py | 6 +++++- models/azure_openai/tests/test_metadata.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/models/azure_openai/models/llm/_metadata.py b/models/azure_openai/models/llm/_metadata.py index 5242e342b..3df18f63f 100644 --- a/models/azure_openai/models/llm/_metadata.py +++ b/models/azure_openai/models/llm/_metadata.py @@ -37,6 +37,8 @@ def normalize_metadata_value(s: Any) -> str: character-pattern restriction, so no character substitution is performed. """ + if s is None: + return "" if not isinstance(s, str): s = str(s) if not s: @@ -92,6 +94,8 @@ def apply_dify_metadata_if_enabled(target: dict, credentials: dict) -> None: existing = target.get("metadata") if isinstance(existing, dict): # Preserve any caller-supplied metadata; only fill in Dify keys. - existing.update(metadata) + # Build a new dict rather than mutating in place, so a caller-shared + # reference is never modified as a side effect of telemetry opt-in. + target["metadata"] = {**existing, **metadata} else: target["metadata"] = metadata diff --git a/models/azure_openai/tests/test_metadata.py b/models/azure_openai/tests/test_metadata.py index 37c5ddfb0..f329aa7d1 100644 --- a/models/azure_openai/tests/test_metadata.py +++ b/models/azure_openai/tests/test_metadata.py @@ -119,3 +119,24 @@ def test_apply_replaces_non_dict_metadata(monkeypatch): apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"}) assert isinstance(target["metadata"], dict) assert target["metadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000" + + +def test_apply_does_not_mutate_existing_metadata(monkeypatch): + # The merge must not mutate the caller's dict in place: a shared reference + # must never be modified as a side effect of telemetry opt-in. + import dify_plugin + + monkeypatch.setattr(dify_plugin, "get_current_session", lambda: _FakeSession()) + original = {"existing_key": "existing_value"} + target: dict = {"metadata": original} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"}) + # The original dict is left untouched. + assert original == {"existing_key": "existing_value"} + # target carries a new, merged dict. + assert target["metadata"] is not original + assert target["metadata"]["existing_key"] == "existing_value" + assert target["metadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000" + + +def test_normalize_none_returns_empty(): + assert normalize_metadata_value(None) == ""