feat(openai): attach Dify app_id as request metadata (opt-in)#3203
feat(openai): attach Dify app_id as request metadata (opt-in)#3203mas-sakai wants to merge 7 commits into
Conversation
Add models/llm/_metadata.py with normalize_metadata_value, build_dify_metadata, and apply_dify_metadata_if_enabled helpers, plus unit tests. Constraints match OpenAI's metadata spec (<=16 pairs, key <=64, value <=512, strings). No character pattern restriction is documented, so normalize only stringifies and truncates. The helper deliberately does not touch the store parameter; whether metadata surfaces in the Usage Dashboard is governed by the account's Stored Completions setting, which is the terminus owner's responsibility. Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s routes Insert apply_dify_metadata_if_enabled at two call sites: - _chat_generate, before client.chat.completions.create, mutating extra_model_kwargs. - _build_responses_api_params, so both _chat_generate_responses_api and _chat_generate_responses_api_stream receive metadata via the shared param builder. _build_responses_api_params now accepts an optional credentials kwarg threaded from both callers. Both routes are covered because the api_protocol credential lets users pick chat or responses per provider/model. Legacy _generate (completions.create) is intentionally out of scope. Session lookup is wrapped in a broad try/except — metadata is best-effort telemetry and must never break generation if the SDK is missing or the session context is not initialized. Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add enable_request_metadata to both model_credential_schema and provider_credential_schema as a select (enabled/disabled, default disabled), mirroring the existing api_protocol structure. Labels, options, and help text include en_US and zh_Hans. The help text documents that this plugin does not set store=true, so dashboard visibility requires the OpenAI account to have Stored Completions enabled separately. Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Point dify_plugin at the fork branch ryuta-kobayashi-ug/dify-plugin-sdks@feat/pass-session-to-model-plugins to obtain get_current_session() in model plugins. uv.lock refreshed. Adds pytest as a dev dependency with the matching pytest config used by other plugins (testpaths=tests, pythonpath=.). Once the upstream SDK release ships, the pin will revert to the versioned dify_plugin spec. Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311, langgenius/dify-plugin-sdks#313 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces an optional feature to attach Dify metadata (such as dify_app_id and dify_source) to OpenAI Chat Completions and Responses API requests, enabling per-app filtering in the OpenAI Usage Dashboard. The changes include a new helper module _metadata.py for metadata normalization and injection, integration within the LLM generation workflows, updated provider credential schemas, and a suite of unit tests. The review feedback highlights opportunities to improve type safety by using Any for coerced inputs, fix a logical inconsistency where falsy non-string values (like 0) are dropped, prevent overwriting existing metadata by merging dictionaries, and add a corresponding unit test.
| from typing import Optional | ||
|
|
||
| _MAX_VALUE_LENGTH = 512 | ||
|
|
||
|
|
||
| def normalize_metadata_value(s: str) -> str: |
There was a problem hiding this comment.
Since normalize_metadata_value is designed to coerce non-string inputs (such as integers) via str(), the type hint for s should be Any instead of str to avoid static analysis or type-checking warnings.
| from typing import Optional | |
| _MAX_VALUE_LENGTH = 512 | |
| def normalize_metadata_value(s: str) -> str: | |
| from typing import Any, Optional | |
| _MAX_VALUE_LENGTH = 512 | |
| def normalize_metadata_value(s: Any) -> str: |
| return s[:_MAX_VALUE_LENGTH] | ||
|
|
||
|
|
||
| def build_dify_metadata(app_id: Optional[str]) -> Optional[dict[str, str]]: |
There was a problem hiding this comment.
The app_id parameter in build_dify_metadata can be of any type (since it is passed to normalize_metadata_value which handles non-string coercion). We should update its type hint to Any as well.
| def build_dify_metadata(app_id: Optional[str]) -> Optional[dict[str, str]]: | |
| def build_dify_metadata(app_id: Any) -> Optional[dict[str, str]]: |
| if not app_id: | ||
| return None |
There was a problem hiding this comment.
There is an inconsistency between build_dify_metadata and normalize_metadata_value. The docstring and tests for normalize_metadata_value explicitly state that non-string falsy inputs like a numeric 0 should not be dropped. However, build_dify_metadata uses if not app_id:, which will evaluate to True for 0 (or 0.0, False), causing it to return None and silently drop the metadata.
To fix this and align with the design intent, we should explicitly check for None and empty string "".
| if not app_id: | |
| return None | |
| if app_id is None or app_id == "": | |
| return None |
| if metadata is not None: | ||
| target["metadata"] = metadata |
There was a problem hiding this comment.
If target already contains a "metadata" key (e.g., set by other parts of the application or user-defined parameters), assigning target["metadata"] = metadata will completely overwrite it, leading to data loss. We should merge the new metadata into the existing dictionary if it already exists.
| if metadata is not None: | |
| target["metadata"] = metadata | |
| if metadata is not None: | |
| if "metadata" in target and isinstance(target["metadata"], dict): | |
| target["metadata"].update(metadata) | |
| else: | |
| target["metadata"] = metadata |
| 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 |
There was a problem hiding this comment.
To ensure that existing metadata is not overwritten and is instead merged correctly, we should add a unit test covering this scenario.
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
def test_apply_merges_with_existing_metadata(monkeypatch):
# Mock get_current_session to return a session with an app_id
class MockSession:
app_id = "test_app"
monkeypatch.setattr("dify_plugin.get_current_session", lambda: MockSession())
target = {"metadata": {"existing_key": "existing_value"}}
apply_dify_metadata_if_enabled(target, {"enable_request_metadata": "enabled"})
assert target["metadata"] == {
"existing_key": "existing_value",
"dify_app_id": "test_app",
"dify_source": "dify",
}…adata per review - Widen `normalize_metadata_value` / `build_dify_metadata` type hints to `Any` to match the str() coercion the implementations already do. - Reject only `None` and `""` in `build_dify_metadata`; other falsy values (e.g. numeric 0) flow through to normalization rather than being silently dropped, matching the design intent for the helper. - Merge into existing `target["metadata"]` instead of overwriting it when the caller has already populated the field; fall back to direct assignment if the existing value is not a dict. - Add tests covering the merge-with-existing path, the non-dict fallback, and the falsy-but-non-empty input case. Refs: langgenius/dify#35772, langgenius/dify-plugin-sdks#311 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…malize per review Per gemini-code-assist on langgenius#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) <noreply@anthropic.com>
Summary
Adds opt-in support for attaching the Dify
app_idas OpenAI request metadata, so usage on the OpenAI Usage Dashboard can be filtered per Dify app.When the new
enable_request_metadatacredential is set toenabled, the plugin readsapp_idfrom the current Dify session (viaget_current_session()from the SDK) and attaches{dify_app_id, dify_source}as themetadatafield on bothchat.completions.createandresponses.create. The default isdisabled, so behavior is unchanged unless the operator opts in.This is the OpenAI counterpart of the same feature already shipped for Vertex AI (#3168) and Bedrock (#3201). The responsibility split mirrors those plugins:
generateContentrequestMetadataon Conversemetadataon Chat / ResponsesDesign note:
storeis deliberately not setThe plugin does not set
store=trueon requests. Whether metadata persists and surfaces in the OpenAI Usage Dashboard is governed entirely by the account-level Stored Completions setting, which the terminus owner controls. Settingstorefrom inside the plugin would change persistence behavior as a side effect of a telemetry opt-in, breaking the existing argument layout and storage semantics — same responsibility split as Bedrock (CloudWatch) and Vertex (billing export). The credential's help text inprovider/openai.yamldocuments this expectation in both English and Simplified Chinese.Related:
Note
Draft / dependency.
pyproject.tomltemporarily pinsdify_pluginto the fork branchryuta-kobayashi-ug/dify-plugin-sdks@feat/pass-session-to-model-pluginsbecause it carries theget_current_session()API used here (langgenius/dify-plugin-sdks#313). The pin will revert to a versioneddify_pluginspec once that SDK change merges and a release ships.Change Type
Screenshots / Videos
dify_app_idanddify_source, enabling per-app filtering.End-to-end screenshots will be added once the dependent SDK change (#313) ships and a Dify deployment can be exercised against a live OpenAI account with Stored Completions enabled.
LLM Plugin Checklist
Areas affected by this change
The change is additive and behind a credential that defaults to
disabled. No existing message flow, tool, multimodal, structured-output, or token-accounting code path is altered. Only_chat_generateand_build_responses_api_paramsgain a one-line opt-in hook that mutates the request kwargs immediately before the OpenAI client call.Version
versioninmanifest.yaml(0.4.1→0.4.2)dify_plugindeclared inpyproject.tomland locked inuv.lock(currently pinned to the SDK branch carrying fix getnumtoken retrun list[int] #313 — see Draft note above)Testing
uv run pytest tests/(14 passed): covers normalization (UUID passthrough, punctuation, mixed case, non-ASCII, 512-char truncation, empty string, non-string coercion),build_dify_metadata(None / empty / source marker / UUID passthrough / length normalization), andapply_dify_metadata_if_enabled(no-op on missing ordisabledcredential, silent on session-lookup failure).