Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion models/azure_openai/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ resource:
model:
enabled: false
type: plugin
version: 0.0.59
version: 0.0.60
101 changes: 101 additions & 0 deletions models/azure_openai/models/llm/_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""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 s is None:
return ""
if not isinstance(s, str):
s = str(s)
if not s:
return ""
return s[:_MAX_VALUE_LENGTH]
Comment thread
mas-sakai marked this conversation as resolved.


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.
# 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
Comment thread
mas-sakai marked this conversation as resolved.
14 changes: 14 additions & 0 deletions models/azure_openai/models/llm/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}"
Expand Down
19 changes: 19 additions & 0 deletions models/azure_openai/provider/azure_openai.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: 基础模型
Expand Down
10 changes: 8 additions & 2 deletions models/azure_openai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = ["."]
142 changes: 142 additions & 0 deletions models/azure_openai/tests/test_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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_normalize_none_returns_empty():
assert normalize_metadata_value(None) == ""
Loading
Loading