-
Notifications
You must be signed in to change notification settings - Fork 835
feat(azure_openai): attach Dify app_id as request metadata (opt-in) #3233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
mas-sakai
wants to merge
6
commits into
langgenius:main
Choose a base branch
from
mas-sakai:feat/azure-openai-app-id-metadata
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+336
−10
Draft
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
e205255
feat(azure_openai): add request metadata normalization helper
mas-sakai 5ba7696
feat(azure_openai): attach app_id metadata in chat completions and re…
mas-sakai e627f44
feat(azure_openai): add enable_request_metadata credential schema
mas-sakai e16b021
chore(azure_openai): bump version
mas-sakai c56764c
chore(azure_openai): pin dify_plugin to sdks #313 branch
mas-sakai c852367
fix(azure_openai): use non-destructive metadata merge and guard None …
mas-sakai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,4 +24,4 @@ resource: | |
| model: | ||
| enabled: false | ||
| type: plugin | ||
| version: 0.0.59 | ||
| version: 0.0.60 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] | ||
|
|
||
|
|
||
| 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 | ||
|
mas-sakai marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) == "" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.