Skip to content

feat(openai): attach Dify app_id as request metadata (opt-in)#3203

Draft
mas-sakai wants to merge 7 commits into
langgenius:mainfrom
mas-sakai:feat/openai-app-id-metadata
Draft

feat(openai): attach Dify app_id as request metadata (opt-in)#3203
mas-sakai wants to merge 7 commits into
langgenius:mainfrom
mas-sakai:feat/openai-app-id-metadata

Conversation

@mas-sakai
Copy link
Copy Markdown

Summary

Adds opt-in support for attaching the Dify app_id as OpenAI request metadata, so usage on the OpenAI Usage Dashboard can be filtered per Dify app.

When the new enable_request_metadata credential is set to enabled, the plugin reads app_id from the current Dify session (via get_current_session() from the SDK) and attaches {dify_app_id, dify_source} as the metadata field on both chat.completions.create and responses.create. The default is disabled, 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:

Plugin What the plugin attaches What the operator must enable
Vertex AI labels on generateContent BigQuery billing export
Bedrock requestMetadata on Converse CloudWatch invocation logging
OpenAI metadata on Chat / Responses Stored Completions (account-level)

Design note: store is deliberately not set

The plugin does not set store=true on 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. Setting store from 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 in provider/openai.yaml documents this expectation in both English and Simplified Chinese.

Related:

Note

Draft / dependency. pyproject.toml temporarily pins dify_plugin to the fork branch ryuta-kobayashi-ug/dify-plugin-sdks@feat/pass-session-to-model-plugins because it carries the get_current_session() API used here (langgenius/dify-plugin-sdks#313). The pin will revert to a versioned dify_plugin spec once that SDK change merges and a release ships.

Change Type

  • Documentation / non-plugin change
  • Non-LLM plugin (tools, extensions, datasource, etc.)
  • LLM plugin

Screenshots / Videos

Before After
Usage Dashboard rows are anonymous; per-app filtering is impossible. With opt-in enabled and Stored Completions enabled on the OpenAI account, requests carry dify_app_id and dify_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
  • Message flow (system messages, user ↔ assistant turn-taking)
  • Tool interaction flow (multi-round usage, Agent App and Agent Node)
  • Multimodal input (images, PDFs, audio, video, etc.)
  • Multimodal output (images, audio, video, etc.)
  • Structured output (JSON, XML, etc.)
  • Token consumption metrics
  • Other LLM functionality (reasoning, grounding, prompt caching, etc.) — request metadata for billing/observability
  • New models / model parameter fixes

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_generate and _build_responses_api_params gain a one-line opt-in hook that mutates the request kwargs immediately before the OpenAI client call.

Version

  • Bumped top-level version in manifest.yaml (0.4.10.4.2)
  • dify_plugin declared in pyproject.toml and locked in uv.lock (currently pinned to the SDK branch carrying fix getnumtoken retrun list[int] #313 — see Draft note above)

Testing

  • Unit tests — 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), and apply_dify_metadata_if_enabled (no-op on missing or disabled credential, silent on session-lookup failure).
  • Local deployment — Dify version: pending (blocked on SDK fix getnumtoken retrun list[int] #313 release; will retest after the pin is reverted)
  • SaaS (cloud.dify.ai)

mas-sakai and others added 5 commits May 26, 2026 13:31
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>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread models/openai/models/llm/_metadata.py Outdated
Comment on lines +26 to +31
from typing import Optional

_MAX_VALUE_LENGTH = 512


def normalize_metadata_value(s: str) -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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:

Comment thread models/openai/models/llm/_metadata.py Outdated
return s[:_MAX_VALUE_LENGTH]


def build_dify_metadata(app_id: Optional[str]) -> Optional[dict[str, str]]:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
def build_dify_metadata(app_id: Optional[str]) -> Optional[dict[str, str]]:
def build_dify_metadata(app_id: Any) -> Optional[dict[str, str]]:

Comment thread models/openai/models/llm/_metadata.py Outdated
Comment on lines +54 to +55
if not app_id:
return None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 "".

Suggested change
if not app_id:
return None
if app_id is None or app_id == "":
return None

Comment thread models/openai/models/llm/_metadata.py Outdated
Comment on lines +88 to +89
if metadata is not None:
target["metadata"] = metadata
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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

Comment on lines +80 to +85
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant