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/bedrock/manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 0.0.67
version: 0.0.68
type: plugin
author: langgenius
name: bedrock
Expand Down
105 changes: 105 additions & 0 deletions models/bedrock/models/llm/_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Helpers for attaching Dify metadata to Bedrock Converse API requests.

Amazon Bedrock's Converse / ConverseStream API accepts a ``requestMetadata``
field (a string-to-string map) that is forwarded to CloudWatch invocation
logs, enabling per-app cost and usage tracking. Constraints, from the
Converse API reference:

https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html

Map Entries: Maximum number of 16.
Key length 1-256, pattern: [a-zA-Z0-9\\s:_@$#=/+,-.]{1,256}
Value length 0-256, pattern: [a-zA-Z0-9\\s:_@$#=/+,-.]{0,256}

UUIDs (lowercase hex with hyphens, 36 chars) already satisfy these
constraints. ``normalize_metadata_value`` exists as a safety net for any
non-UUID value (e.g. emails, non-ASCII text) that may flow through.
"""

from __future__ import annotations

import re
from typing import Any, Optional

_INVALID_CHAR_RE = re.compile(r"[^a-zA-Z0-9\s:_@$#=/+,\-.]")
_MAX_VALUE_LENGTH = 256


def normalize_metadata_value(s: Any) -> str:
"""Normalize an arbitrary value into a Bedrock requestMetadata value.

Replaces any character outside the allowed set
``[a-zA-Z0-9\\s:_@$#=/+,-.]`` with ``_`` and truncates to 256
characters. Case is preserved (Bedrock allows mixed case). An empty
input returns an empty string. Non-string inputs are coerced via
``str()`` first so that, e.g., a numeric ``0`` becomes ``"0"`` rather
than being silently dropped by the empty-check.
"""
if s is None:
return ""
if not isinstance(s, str):
s = str(s)
if not s:
return ""
# Truncate first to bound the cost of the regex sub() on pathological input.
# sub() preserves length 1:1, so a single trailing truncation is unnecessary.
s = s[:_MAX_VALUE_LENGTH]
return _INVALID_CHAR_RE.sub("_", s)


def build_dify_request_metadata(app_id: Any) -> Optional[dict[str, str]]:
"""Build the Dify requestMetadata dict for a Bedrock 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_request_metadata_if_enabled(parameters: dict, credentials: dict) -> None:
"""Inject Dify requestMetadata into ``parameters`` when opt-in credential is set.

Reads ``credentials['enable_request_metadata']``; when ``"enabled"``,
resolves the current Dify session's ``app_id`` (best-effort) and merges
the built metadata into ``parameters['requestMetadata']``. If existing
metadata is present as a dict, Dify keys are merged in alongside
caller-supplied ones; if absent (or not a dict), the Dify-only dict is
set.

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

request_metadata = build_dify_request_metadata(app_id)
if request_metadata is None:
return
existing = parameters.get("requestMetadata")
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.
parameters["requestMetadata"] = {**existing, **request_metadata}
else:
parameters["requestMetadata"] = request_metadata
6 changes: 6 additions & 0 deletions models/bedrock/models/llm/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,12 @@ def _generate_with_converse(
}
)
parameters["toolConfig"] = self._convert_converse_tool_config(tools=[placeholder_tool])

# Optional: attach Dify app_id as Bedrock requestMetadata for CloudWatch logs.
# Default disabled; opt-in via the enable_request_metadata credential.
from ._metadata import apply_dify_request_metadata_if_enabled

apply_dify_request_metadata_if_enabled(parameters, credentials)
try:
# for issue #10976
conversations_list = parameters["messages"]
Expand Down
20 changes: 20 additions & 0 deletions models/bedrock/provider/bedrock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,26 @@ provider_credential_schema:
text:
en_US: Proxy address for Bedrock API connections (cannot be used with Endpoint URL)
zh_Hans: Bedrock API 连接的代理地址(不能与端点 URL 同时使用)
- variable: enable_request_metadata
required: false
default: disabled
label:
en_US: Enable CloudWatch request metadata (optional)
zh_Hans: 启用 CloudWatch 请求元数据(可选)
type: select
options:
- value: enabled
label:
en_US: Enabled
zh_Hans: 已启用
- value: disabled
label:
en_US: Disabled
zh_Hans: 已禁用
help:
text:
en_US: When enabled, attaches dify_app_id and dify_source as requestMetadata on Bedrock Converse API calls, enabling per-app filtering of CloudWatch invocation logs. Converse API only. Default disabled.
zh_Hans: 启用后,将在 Bedrock Converse API 调用中将 dify_app_id 和 dify_source 作为 requestMetadata 附加,从而支持按应用过滤 CloudWatch 调用日志。仅限 Converse API。默认禁用。
models:
llm:
predefined:
Expand Down
10 changes: 8 additions & 2 deletions models/bedrock/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@ requires-python = ">=3.12"

# Managed with uv; refresh the lockfile with `uv lock`.
dependencies = [
"dify_plugin @ git+https://github.com/ryuta-kobayashi-ug/dify-plugin-sdks.git@feat/pass-session-to-model-plugins",
"boto3>=1.43.13",
"botocore>=1.43.13",
"dify_plugin>=0.9.0",
"tiktoken>=0.13.0",
]

# 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 = ["."]
Empty file.
156 changes: 156 additions & 0 deletions models/bedrock/tests/test_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from models.llm._metadata import (
apply_dify_request_metadata_if_enabled,
build_dify_request_metadata,
normalize_metadata_value,
)


def test_normalize_uuid_passthrough():
uuid = "550e8400-e29b-41d4-a716-446655440000"
assert normalize_metadata_value(uuid) == uuid


def test_normalize_replaces_invalid_chars():
# Square brackets are not in the allowed set; should be replaced with `_`.
assert normalize_metadata_value("a[b]c") == "a_b_c"


def test_normalize_preserves_allowed_special_chars():
# Bedrock allows :_@$#=/+,-. and whitespace.
value = "a:b_c@d$e#f=g/h+i,j-k.l m"
assert normalize_metadata_value(value) == value


def test_normalize_preserves_mixed_case():
# Unlike Vertex AI, Bedrock allows uppercase letters.
assert normalize_metadata_value("FOO-Bar") == "FOO-Bar"


def test_normalize_truncates_at_256_chars():
long_input = "a" * 300
result = normalize_metadata_value(long_input)
assert len(result) == 256
assert result == "a" * 256


def test_normalize_empty_string():
assert normalize_metadata_value("") == ""


def test_normalize_replaces_non_ascii():
# Non-ASCII characters fall outside the allowed pattern.
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_request_metadata_returns_none_for_none():
assert build_dify_request_metadata(None) is None


def test_build_dify_request_metadata_returns_none_for_empty():
assert build_dify_request_metadata("") is None


def test_build_dify_request_metadata_includes_source_marker():
metadata = build_dify_request_metadata("550e8400-e29b-41d4-a716-446655440000")
assert metadata is not None
assert metadata["dify_source"] == "dify"


def test_build_dify_request_metadata_normalizes_app_id():
metadata = build_dify_request_metadata("My App[Example]/" + "x" * 300)
assert metadata is not None
app_id = metadata["dify_app_id"]
assert len(app_id) <= 256
# Should not contain disallowed chars; brackets were replaced.
assert "[" not in app_id
assert "]" not in app_id


def test_build_dify_request_metadata_uuid_passthrough():
uuid = "550e8400-e29b-41d4-a716-446655440000"
metadata = build_dify_request_metadata(uuid)
assert metadata == {"dify_app_id": uuid, "dify_source": "dify"}


def test_build_dify_request_metadata_keeps_non_string_falsy():
# build_dify_request_metadata only rejects None and "" — other falsy
# values such as numeric 0 are coerced by normalize_metadata_value.
metadata = build_dify_request_metadata(0)
assert metadata == {"dify_app_id": "0", "dify_source": "dify"}


def test_apply_no_op_when_credential_missing():
parameters: dict = {}
apply_dify_request_metadata_if_enabled(parameters, {})
assert parameters == {}


def test_apply_no_op_when_credential_disabled():
parameters: dict = {}
apply_dify_request_metadata_if_enabled(parameters, {"enable_request_metadata": "disabled"})
assert parameters == {}


def test_apply_silent_on_session_lookup_failure():
# Without a Dify session context, get_current_session raises; the
# helper must swallow that and leave parameters unchanged.
parameters: dict = {}
apply_dify_request_metadata_if_enabled(parameters, {"enable_request_metadata": "enabled"})
assert "requestMetadata" not in parameters


class _FakeSession:
app_id = "550e8400-e29b-41d4-a716-446655440000"


def test_apply_merges_with_existing_request_metadata(monkeypatch):
# When parameters already carries requestMetadata (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())
parameters: dict = {"requestMetadata": {"caller_tag": "manual"}}
apply_dify_request_metadata_if_enabled(parameters, {"enable_request_metadata": "enabled"})
assert parameters["requestMetadata"]["caller_tag"] == "manual"
assert parameters["requestMetadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000"
assert parameters["requestMetadata"]["dify_source"] == "dify"


def test_apply_replaces_non_dict_request_metadata(monkeypatch):
# If existing requestMetadata 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())
parameters: dict = {"requestMetadata": "unexpected-string"}
apply_dify_request_metadata_if_enabled(parameters, {"enable_request_metadata": "enabled"})
assert isinstance(parameters["requestMetadata"], dict)
assert parameters["requestMetadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000"


def test_apply_does_not_mutate_existing_request_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"}
parameters: dict = {"requestMetadata": original}
apply_dify_request_metadata_if_enabled(parameters, {"enable_request_metadata": "enabled"})
# The original dict is left untouched.
assert original == {"existing_key": "existing_value"}
# parameters carries a new, merged dict.
assert parameters["requestMetadata"] is not original
assert parameters["requestMetadata"]["existing_key"] == "existing_value"
assert parameters["requestMetadata"]["dify_app_id"] == "550e8400-e29b-41d4-a716-446655440000"


def test_normalize_none_returns_empty():
assert normalize_metadata_value(None) == ""
Loading
Loading