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 diff --git a/models/azure_openai/models/llm/_metadata.py b/models/azure_openai/models/llm/_metadata.py new file mode 100644 index 000000000..abdec6962 --- /dev/null +++ b/models/azure_openai/models/llm/_metadata.py @@ -0,0 +1,111 @@ +"""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. + +The OpenAI/Azure OpenAI API only accepts ``metadata`` when ``store`` is +true — it rejects the request with ``BadRequestError: The 'metadata' +parameter is only allowed when 'store' is enabled.`` otherwise. Enabling +this feature therefore inherently requires ``store=true``, so +``apply_dify_metadata_if_enabled`` sets it alongside the metadata. This +means requests and responses are persisted to Stored Completions on the +Azure OpenAI resource; the credential's help text documents that storage +behavior. An explicit ``store`` value already present on the request is +respected rather than overwritten. +""" + +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 s is None: + return "" + 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), sets + ``target['metadata']`` to the built dict (if one is produced), and sets + ``target['store'] = True`` — the API only accepts ``metadata`` when + ``store`` is enabled. An explicit ``store`` value already on ``target`` + is left untouched. + + 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. + # 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 + # The API only accepts metadata when store=true. Don't overwrite an + # explicit store value already set by the caller. + if "store" not in target: + target["store"] = True diff --git a/models/azure_openai/models/llm/llm.py b/models/azure_openai/models/llm/llm.py index aa1e2e327..49bd0e995 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. When + # enabled, store=true is set alongside the metadata (the API only + # accepts metadata when store is enabled), persisting the request to + # Stored Completions on the Azure OpenAI resource. + apply_dify_metadata_if_enabled(extra_model_kwargs, credentials) + response = client.chat.completions.create( messages=messages, model=model, @@ -545,6 +554,12 @@ 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. When + # enabled, store=true is set alongside the metadata — see _metadata.py + # for why the API requires it. + apply_dify_metadata_if_enabled(responses_params, credentials) + logger.info( f"llm request with responses api: model={model}, stream={stream}, " f"parameters={responses_params}" diff --git a/models/azure_openai/provider/azure_openai.yaml b/models/azure_openai/provider/azure_openai.yaml index 29589f737..0889e6be7 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 both the Chat Completions and Responses routes, AND sets store=true so the metadata is recorded in Stored Completions (required by Azure OpenAI API: metadata is only accepted when store is true). Requests and responses are persisted on your Azure OpenAI resource — same data-storage context as Azure OpenAI's standard logging; data does not leave Azure and is not used to train foundation models. See: https://learn.microsoft.com/en-us/azure/foundry/responsible-ai/openai/data-privacy Default disabled." + zh_Hans: 启用后,在 Chat Completions 和 Responses 两条路由的调用中附加 dify_app_id 和 dify_source 作为 metadata,同时设置 store=true 以便 metadata 被记录到 Stored Completions(Azure OpenAI API 要求:仅当 store 为 true 时才接受 metadata)。请求和响应会保存在您的 Azure OpenAI 资源中 —— 与 Azure OpenAI 标准日志记录相同的数据存储上下文;数据不会离开 Azure,也不会用于训练基础模型。默认禁用。 - label: en_US: Base Model zh_Hans: 基础模型 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/tests/test_metadata.py b/models/azure_openai/tests/test_metadata.py new file mode 100644 index 000000000..83a3cc4c6 --- /dev/null +++ b/models/azure_openai/tests/test_metadata.py @@ -0,0 +1,176 @@ +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" + + +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_apply_sets_store_true(monkeypatch): + # The API only accepts metadata when store=true, so applying the metadata + # must also enable store. + import dify_plugin + + monkeypatch.setattr(dify_plugin, "get_current_session", lambda: _FakeSession()) + target: dict = {} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"}) + assert target["store"] is True + + +def test_apply_preserves_explicit_store(monkeypatch): + # An explicit store value set by the caller must be respected, not + # overwritten — even when it is False. + import dify_plugin + + monkeypatch.setattr(dify_plugin, "get_current_session", lambda: _FakeSession()) + target: dict = {"store": False} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"}) + assert target["store"] is False + assert target["metadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000" + + +def test_apply_disabled_does_not_touch_store(): + # When the feature is disabled or unset, store must not be touched. + target: dict = {} + apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "disabled"}) + assert "store" not in target + + target_unset: dict = {} + apply_dify_metadata_if_enabled(target_unset, {}) + assert "store" not in target_unset + + +def test_normalize_none_returns_empty(): + assert normalize_metadata_value(None) == "" 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"