Compare commits

..

3 Commits

Author SHA1 Message Date
50c9ede82b fix(mcp): rename forward-identity header to X-Dify-SSO-Token
The forwarded value is the caller's SSO **id_token** (a signed JWT) — the
enterprise side prefers resp.IDToken over the access_token — so the old name
"X-Dify-SSO-Access-Token" was misleading: it led integrators to validate the
JWT via the OAuth2 userinfo/introspection path (access-token only), which
returns 401 for an id_token. Rename to the type-neutral X-Dify-SSO-Token
across the backend constant (FORWARDED_IDENTITY_HEADER), the forwarding +
enterprise_service comments, the unit test, and the forward-identity tip in
all 23 locales (value-only; i18n stays in sync). The historical migration
comment is intentionally left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:02:05 +02:00
e52f3c0ea5 fix(web): name X-Dify-SSO-Access-Token in forward-identity tip across all locales
Extends the en-US/zh-Hans copy fix to the remaining 21 locales so none keep the
incorrect "Authorization Bearer" wording — the backend forwards the identity in
the X-Dify-SSO-Access-Token header, not Authorization. Value-only change per
file; check-i18n passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:02:05 +02:00
f3edc4a4b7 fix(web): correct MCP forward-identity header copy; guard toggle hydration
Issue 1 — wrong header in the tip copy:
The "forward user identity" helper text said the SSO token is forwarded as an
"Authorization Bearer token", but the backend
(api/core/tools/mcp_tool/tool.py: FORWARDED_IDENTITY_HEADER) sends it in the
custom `X-Dify-SSO-Access-Token` header — the `Authorization` header is reserved
for the provider's own workspace OAuth. An integrator configuring token
verification on `Authorization` per the old copy would fail to read the
identity. Updated en-US and zh-Hans copy to name the actual header; the other
locales follow via the i18n sync from en-US.

Issue 2 — toggle hydration on edit:
Editing a provider saved with identity_mode="idp_token" must render the toggle
ON. This already works on main (use-mcp-modal-form hydrates forwardUserIdentity
from data.identity_mode, and the backend always emits identity_mode for MCP),
but there was no integration-level test. Added modal tests asserting the
rendered switch reflects the persisted identity_mode (idp_token -> checked,
off -> unchecked) so the reported repro can't silently regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:02:05 +02:00
64 changed files with 409 additions and 1630 deletions

View File

@ -1,7 +1,6 @@
from flask import Blueprint
from flask_restx import Namespace
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter
from libs.device_flow_security import attach_anti_framing
from libs.external_api import ExternalApi
@ -13,14 +12,13 @@ api = ExternalApi(
version="1.0",
title="OpenAPI",
description="User-scoped programmatic API (bearer auth)",
error_body_formatter=OpenApiErrorFormatter(),
)
openapi_ns = Namespace("openapi", description="User-scoped operations", path="/")
# Register response/query models BEFORE importing controller modules so that
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.openapi._models import (
AccountPayload,
AccountResponse,
@ -91,7 +89,6 @@ register_schema_models(
)
register_response_schema_models(
openapi_ns,
ErrorBody,
TagItem,
UsageInfo,
MessageMetadata,
@ -127,9 +124,6 @@ register_response_schema_models(
ServerVersionResponse,
HealthResponse,
)
# Standalone definition for contract codegen; ErrorBody.code stays an open
# string on the wire so old clients keep parsing future codes.
register_enum_models(openapi_ns, OpenApiErrorCode)
from . import (
_meta,

View File

@ -21,7 +21,6 @@ from pydantic import BaseModel, ValidationError
from controllers.common.schema import query_params_from_model, query_params_from_request
from controllers.openapi import openapi_ns
from controllers.openapi._errors import ErrorBody
def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable:
@ -52,8 +51,6 @@ def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | Non
openapi_ns.doc(params=query_params_from_model(query))(wrapper)
if body is not None:
openapi_ns.expect(openapi_ns.models[body.__name__])(wrapper)
if query is not None or body is not None:
openapi_ns.response(422, "Validation error", openapi_ns.models[ErrorBody.__name__])(wrapper)
return wrapper
return decorator
@ -79,7 +76,6 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) -
return result
openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper)
openapi_ns.response("default", "Error", openapi_ns.models[ErrorBody.__name__])(wrapper)
return wrapper
return decorator

View File

@ -1,241 +0,0 @@
"""Canonical error contract for the /openapi/v1 surface.
``ErrorBody`` is the only wire shape an /openapi/v1 endpoint may emit for a
non-2xx response (RFC 8628 device-flow responses excepted — that shape is
mandated by the OAuth spec)::
code str semantic error code (OpenApiErrorCode member)
message str human-readable summary
status int HTTP status, duplicated in the body
hint str | None actionable next step for the caller
details list[ErrorDetail] per-field validation breakdown {type, loc, msg}
``OpenApiErrorFormatter`` is injected into ``ExternalApi`` so every
error-handler path funnels through one builder, and it also rewrites
``e.data`` because flask-restx ``Api.handle_error`` lets a pre-existing
``e.data`` override the registered handler's return value.
The transport-generic enum members, ``_CODE_BY_STATUS`` and the
``OpenApiError``/``OpenApiErrorFormatter`` bases are openapi-only today;
promote them to ``libs`` if a second surface adopts ``ErrorBody``.
"""
import logging
from enum import StrEnum
from typing import Any
from pydantic import BaseModel
from werkzeug.exceptions import HTTPException
from libs.external_api import http_status_message
class OpenApiErrorCode(StrEnum):
# transport-generic (resolved from HTTP status for plain werkzeug raises)
BAD_REQUEST = "bad_request"
UNAUTHORIZED = "unauthorized"
FORBIDDEN = "forbidden"
NOT_FOUND = "not_found"
METHOD_NOT_ALLOWED = "method_not_allowed"
NOT_ACCEPTABLE = "not_acceptable"
CONFLICT = "conflict"
REQUEST_TOO_LARGE = "request_entity_too_large"
UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
INVALID_PARAM = "invalid_param"
TOO_MANY_REQUESTS = "too_many_requests"
INTERNAL_ERROR = "internal_server_error"
BAD_GATEWAY = "bad_gateway"
UNKNOWN = "unknown"
# domain codes (must match the error_code attribute of the exception
# classes raised on the openapi surface)
APP_UNAVAILABLE = "app_unavailable"
CONVERSATION_COMPLETED = "conversation_completed"
PROVIDER_NOT_INITIALIZE = "provider_not_initialize"
PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded"
MODEL_NOT_SUPPORTED = "model_currently_not_support"
COMPLETION_REQUEST_ERROR = "completion_request_error"
RATE_LIMIT_ERROR = "rate_limit_error"
FILE_TOO_LARGE = "file_too_large"
UNSUPPORTED_FILE_TYPE = "unsupported_file_type"
NO_FILE_UPLOADED = "no_file_uploaded"
TOO_MANY_FILES = "too_many_files"
FILENAME_NOT_EXISTS = "filename_not_exists"
FILE_EXTENSION_BLOCKED = "file_extension_blocked"
MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded"
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
class ErrorDetail(BaseModel):
type: str
loc: list[str | int] = []
msg: str
class ErrorBody(BaseModel):
"""Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
generated client schema stays an open enum — old CLIs keep parsing when a
future server adds a code. Formatter tests pin emitted values to the enum."""
code: str
message: str
status: int
hint: str | None = None
details: list[ErrorDetail] | None = None
_CODE_BY_STATUS: dict[int, OpenApiErrorCode] = {
400: OpenApiErrorCode.BAD_REQUEST,
401: OpenApiErrorCode.UNAUTHORIZED,
403: OpenApiErrorCode.FORBIDDEN,
404: OpenApiErrorCode.NOT_FOUND,
405: OpenApiErrorCode.METHOD_NOT_ALLOWED,
406: OpenApiErrorCode.NOT_ACCEPTABLE,
409: OpenApiErrorCode.CONFLICT,
413: OpenApiErrorCode.REQUEST_TOO_LARGE,
415: OpenApiErrorCode.UNSUPPORTED_MEDIA_TYPE,
422: OpenApiErrorCode.INVALID_PARAM,
429: OpenApiErrorCode.TOO_MANY_REQUESTS,
500: OpenApiErrorCode.INTERNAL_ERROR,
502: OpenApiErrorCode.BAD_GATEWAY,
}
_GENERIC_500_MESSAGE = "Internal Server Error"
logger = logging.getLogger(__name__)
class OpenApiError(HTTPException):
"""Dedicated throwable for the /openapi/v1 surface.
A subclass declares ``code`` (HTTP status), ``error_code`` and
``description`` exactly once; call sites just ``raise SomeError()`` —
no per-site dict building, no duplicated message constants. The
formatter emits all three (plus optional ``hint``/``details``) verbatim.
"""
code = 400
error_code: OpenApiErrorCode = OpenApiErrorCode.UNKNOWN
hint: str | None = None
def __init__(
self,
message: str | None = None,
*,
hint: str | None = None,
details: list[ErrorDetail] | None = None,
) -> None:
super().__init__(description=message)
if hint is not None:
self.hint = hint
self.details = details
class OpenApiErrorFormatter:
"""Builds the canonical ErrorBody from whatever the shared handlers computed.
Resolution order for ``code``: explicit ``error_code`` class attribute
(BaseHTTPException subclasses and OpenApiError subclasses) → HTTP status
map → ``unknown``. Class-name-derived codes from the shared handler are
deliberately ignored — they are not a stable contract.
"""
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
exc_data = getattr(e, "data", None)
merged: dict[str, Any] = {**data, **exc_data} if isinstance(exc_data, dict) else dict(data)
# finalize runs inside the framework error handler: raising here would
# replace the response with an unformatted 500, so fall back instead
try:
body = ErrorBody(
code=self._resolve_code(e, status_code),
message=self._resolve_message(merged, status_code),
status=status_code,
hint=self._resolve_hint(e),
details=self._extract_details(e, merged),
)
wire = body.model_dump(mode="json", exclude_none=True)
except Exception:
logger.exception("error-body build failed; emitting fallback body")
wire = {
"code": str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)),
"message": http_status_message(status_code) or "request failed",
"status": status_code,
}
# flask-restx Api.handle_error does `data = getattr(e, "data", default_data)`
# AFTER our handler returns, so a pre-existing e.data (flask_restx.abort,
# BaseHTTPException) would override the canonical body. Rewrite it.
try:
e.data = wire # type: ignore[attr-defined]
except AttributeError:
pass
return wire
def _resolve_code(self, e: Exception, status_code: int) -> str:
explicit = getattr(type(e), "error_code", None)
if isinstance(explicit, (OpenApiErrorCode, str)) and str(explicit) != "unknown":
return str(explicit)
return str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN))
def _resolve_message(self, merged: dict[str, Any], status_code: int) -> str:
if status_code >= 500:
return _GENERIC_500_MESSAGE
message = merged.get("message")
if isinstance(message, str) and message:
return message
return http_status_message(status_code) or "request failed"
def _resolve_hint(self, e: Exception) -> str | None:
hint = getattr(e, "hint", None)
return hint if isinstance(hint, str) and hint else None
def _extract_details(self, e: Exception, merged: dict[str, Any]) -> list[ErrorDetail] | None:
explicit = getattr(e, "details", None)
if isinstance(explicit, list) and explicit and all(isinstance(d, ErrorDetail) for d in explicit):
return explicit
# an already-canonical body (e.g. e.data rewritten by a prior finalize)
# carries "details"; re-validate so finalize stays idempotent
canonical = merged.get("details")
if isinstance(canonical, list) and canonical and all(isinstance(d, dict) for d in canonical):
return [ErrorDetail.model_validate(d) for d in canonical]
errors = merged.get("errors")
if isinstance(errors, list) and errors:
details = [
ErrorDetail(
type=str(item.get("type", "invalid")),
loc=[part for part in item.get("loc", []) if self._is_loc_part(part)],
msg=str(item.get("msg", "")),
)
for item in errors
if isinstance(item, dict)
]
return details or None
params = merged.get("params")
if isinstance(params, str) and params:
return [ErrorDetail(type="invalid", loc=[params], msg=str(merged.get("message", "")))]
return None
@staticmethod
def _is_loc_part(part: Any) -> bool:
# bool is an int subclass but is not a valid path segment
return isinstance(part, (str, int)) and not isinstance(part, bool)
class FilenameNotExists(OpenApiError): # noqa: N818
code = 400
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
description = "The specified filename does not exist."
class MemberLimitExceeded(OpenApiError): # noqa: N818
code = 403
error_code = OpenApiErrorCode.MEMBER_LIMIT_EXCEEDED
description = "Subscription member limit reached."
hint = "Upgrade your plan to invite more members or remove an existing member first."
class MemberLicenseExceeded(OpenApiError): # noqa: N818
code = 403
error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED
description = "Workspace member license capacity reached."
hint = "Contact your workspace administrator to expand the license seat count."

View File

@ -10,6 +10,7 @@ from werkzeug.exceptions import BadRequest
import services
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
@ -17,7 +18,6 @@ from controllers.common.errors import (
)
from controllers.openapi import openapi_ns
from controllers.openapi._contract import returns
from controllers.openapi._errors import FilenameNotExists
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
@ -52,7 +52,7 @@ class AppFileUploadApi(Resource):
if not file.mimetype:
raise UnsupportedFileTypeError()
if not file.filename:
raise FilenameNotExists()
raise FilenameNotExistsError()
try:
upload_file = FileService(db.engine).upload_file(

View File

@ -14,13 +14,13 @@ from __future__ import annotations
from itertools import starmap
from urllib import parse
from flask import jsonify, make_response
from flask_restx import Resource
from werkzeug.exceptions import BadRequest, NotFound
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from configs import dify_config
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
from controllers.openapi._models import (
MemberActionResponse,
MemberInvitePayload,
@ -77,16 +77,34 @@ def _load_account(account_id: object) -> Account:
return account
def _quota_error(*, code: str, message: str, hint: str) -> Forbidden:
err = Forbidden(message)
err.response = make_response(
jsonify({"code": code, "message": message, "hint": hint}),
403,
)
return err
def _check_member_invite_quota(tenant_id: str) -> None:
features = FeatureService.get_features(tenant_id)
if features.billing.enabled:
members = features.members
if 0 < members.limit <= members.size:
raise MemberLimitExceeded()
raise _quota_error(
code="members.limit_exceeded",
message="Subscription member limit reached.",
hint="Upgrade your plan to invite more members or remove an existing member first.",
)
if features.workspace_members.enabled and not features.workspace_members.is_available(1):
raise MemberLicenseExceeded()
if features.workspace_members.enabled:
if not features.workspace_members.is_available(1):
raise _quota_error(
code="workspace_members.license_exceeded",
message="Workspace member license capacity reached.",
hint="Contact your workspace administrator to expand the license seat count.",
)
@openapi_ns.route("/workspaces")

View File

@ -208,13 +208,12 @@ class CotAgentRunner(BaseAgentRunner, ABC):
if scratchpad.action.action_name.lower() == "final answer":
# action is final answer, return final answer directly
try:
match scratchpad.action.action_input:
case dict():
final_answer = json.dumps(scratchpad.action.action_input, ensure_ascii=False)
case str():
final_answer = scratchpad.action.action_input
case _:
final_answer = f"{scratchpad.action.action_input}"
if isinstance(scratchpad.action.action_input, dict):
final_answer = json.dumps(scratchpad.action.action_input, ensure_ascii=False)
elif isinstance(scratchpad.action.action_input, str):
final_answer = scratchpad.action.action_input
else:
final_answer = f"{scratchpad.action.action_input}"
except TypeError:
final_answer = f"{scratchpad.action.action_input}"
else:

View File

@ -43,14 +43,13 @@ class CotCompletionAgentRunner(CotAgentRunner):
case UserPromptMessage():
historic_prompt += f"Question: {message.content}\n\n"
case AssistantPromptMessage():
match message.content:
case str():
historic_prompt += message.content + "\n\n"
case list():
for content in message.content:
if not isinstance(content, TextPromptMessageContent):
continue
historic_prompt += content.data
if isinstance(message.content, str):
historic_prompt += message.content + "\n\n"
elif isinstance(message.content, list):
for content in message.content:
if not isinstance(content, TextPromptMessageContent):
continue
historic_prompt += content.data
return historic_prompt

View File

@ -301,32 +301,31 @@ class AppRunner:
queue_manager.publish(QueueAgentMessageEvent(chunk=result), PublishFrom.APPLICATION_MANAGER)
message = result.delta.message
match message.content:
case str():
text += message.content
case list():
for content in message.content:
match content:
case str():
text += content
case TextPromptMessageContent():
text += content.data
case ImagePromptMessageContent():
if message_id and user_id and tenant_id:
try:
self._handle_multimodal_image_content(
content=content,
message_id=message_id,
user_id=user_id,
tenant_id=tenant_id,
queue_manager=queue_manager,
)
except Exception:
_logger.exception("Failed to handle multimodal image output")
else:
_logger.warning("Received multimodal output but missing required parameters")
case _:
text += content.data if hasattr(content, "data") else str(content)
if isinstance(message.content, str):
text += message.content
elif isinstance(message.content, list):
for content in message.content:
match content:
case str():
text += content
case TextPromptMessageContent():
text += content.data
case ImagePromptMessageContent():
if message_id and user_id and tenant_id:
try:
self._handle_multimodal_image_content(
content=content,
message_id=message_id,
user_id=user_id,
tenant_id=tenant_id,
queue_manager=queue_manager,
)
except Exception:
_logger.exception("Failed to handle multimodal image output")
else:
_logger.warning("Received multimodal output but missing required parameters")
case _:
text += content.data if hasattr(content, "data") else str(content)
if not model:
model = result.model

View File

@ -166,13 +166,12 @@ def invoke_llm_with_structured_output(
prompt_messages = event.prompt_messages
system_fingerprint = event.system_fingerprint
match event.delta.message.content:
case str():
result_text += event.delta.message.content
case list():
for item in event.delta.message.content:
if isinstance(item, TextPromptMessageContent):
result_text += item.data
if isinstance(event.delta.message.content, str):
result_text += event.delta.message.content
elif isinstance(event.delta.message.content, list):
for item in event.delta.message.content:
if isinstance(item, TextPromptMessageContent):
result_text += item.data
yield LLMResultChunkWithStructuredOutput(
model=model_schema.model,

View File

@ -211,13 +211,12 @@ class SSETransport:
except queue.Empty:
raise ValueError("failed to get endpoint URL")
match status:
case _StatusReady():
return status.endpoint_url
case _StatusError():
raise status.exc
case _:
raise ValueError("failed to get endpoint URL")
if isinstance(status, _StatusReady):
return status.endpoint_url
elif isinstance(status, _StatusError):
raise status.exc
else:
raise ValueError("failed to get endpoint URL")
def connect(
self,

View File

@ -41,11 +41,10 @@ class MessageHandlerFnT(Protocol):
def _default_message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
):
match message:
case Exception():
raise ValueError(str(message))
case types.ServerNotification() | RequestResponder():
pass
if isinstance(message, Exception):
raise ValueError(str(message))
elif isinstance(message, (types.ServerNotification | RequestResponder)):
pass
def _default_sampling_callback(

View File

@ -62,16 +62,15 @@ class BaseTraceInfo(BaseModel):
parent_span_id_source is the outer node_execution_id.
"""
parent_ctx = self.metadata.get("parent_trace_context")
match parent_ctx:
case ParentTraceContext():
context = parent_ctx
case Mapping():
try:
context = ParentTraceContext.model_validate(parent_ctx)
except ValueError:
return None, None
case _:
if isinstance(parent_ctx, ParentTraceContext):
context = parent_ctx
elif isinstance(parent_ctx, Mapping):
try:
context = ParentTraceContext.model_validate(parent_ctx)
except ValueError:
return None, None
else:
return None, None
return (
context.parent_workflow_run_id,
context.parent_node_execution_id,

View File

@ -38,19 +38,18 @@ def measure_time():
def replace_text_with_content(data):
match data:
case dict():
new_data = {}
for key, value in data.items():
if key == "text":
new_data["content"] = value
else:
new_data[key] = replace_text_with_content(value)
return new_data
case list():
return [replace_text_with_content(item) for item in data]
case _:
return data
if isinstance(data, dict):
new_data = {}
for key, value in data.items():
if key == "text":
new_data["content"] = value
else:
new_data[key] = replace_text_with_content(value)
return new_data
elif isinstance(data, list):
return [replace_text_with_content(item) for item in data]
else:
return data
def generate_dotted_order(run_id: str, start_time: Union[str, datetime], parent_dotted_order: str | None = None) -> str:

View File

@ -45,13 +45,12 @@ _plugin_daemon_timeout_config = cast(
getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 600.0),
)
plugin_daemon_request_timeout: httpx.Timeout | None
match _plugin_daemon_timeout_config:
case None:
plugin_daemon_request_timeout = None
case httpx.Timeout():
plugin_daemon_request_timeout = _plugin_daemon_timeout_config
case _:
plugin_daemon_request_timeout = httpx.Timeout(_plugin_daemon_timeout_config)
if _plugin_daemon_timeout_config is None:
plugin_daemon_request_timeout = None
elif isinstance(_plugin_daemon_timeout_config, httpx.Timeout):
plugin_daemon_request_timeout = _plugin_daemon_timeout_config
else:
plugin_daemon_request_timeout = httpx.Timeout(_plugin_daemon_timeout_config)
logger = logging.getLogger(__name__)

View File

@ -50,33 +50,32 @@ class AdvancedPromptTransform(PromptTransform):
) -> list[PromptMessage]:
prompt_messages = []
match prompt_template:
case CompletionModelPromptTemplate():
prompt_messages = self._get_completion_model_prompt_messages(
prompt_template=prompt_template,
inputs=inputs,
query=query,
files=files,
context=context,
memory_config=memory_config,
memory=memory,
model_config=model_config,
model_instance=model_instance,
image_detail_config=image_detail_config,
)
case list() if all(isinstance(item, ChatModelMessage) for item in prompt_template):
prompt_messages = self._get_chat_model_prompt_messages(
prompt_template=prompt_template,
inputs=inputs,
query=query,
files=files,
context=context,
memory_config=memory_config,
memory=memory,
model_config=model_config,
model_instance=model_instance,
image_detail_config=image_detail_config,
)
if isinstance(prompt_template, CompletionModelPromptTemplate):
prompt_messages = self._get_completion_model_prompt_messages(
prompt_template=prompt_template,
inputs=inputs,
query=query,
files=files,
context=context,
memory_config=memory_config,
memory=memory,
model_config=model_config,
model_instance=model_instance,
image_detail_config=image_detail_config,
)
elif isinstance(prompt_template, list) and all(isinstance(item, ChatModelMessage) for item in prompt_template):
prompt_messages = self._get_chat_model_prompt_messages(
prompt_template=prompt_template,
inputs=inputs,
query=query,
files=files,
context=context,
memory_config=memory_config,
memory=memory,
model_config=model_config,
model_instance=model_instance,
image_detail_config=image_detail_config,
)
return prompt_messages

View File

@ -61,15 +61,14 @@ class CeleryWorkflowExecutionRepository(WorkflowExecutionRepository):
triggered_from: Source of the execution trigger (DEBUGGING or APP_RUN)
"""
# Store session factory for fallback operations
match session_factory:
case Engine():
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
case sessionmaker():
self._session_factory = session_factory
case _:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
if isinstance(session_factory, Engine):
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
elif isinstance(session_factory, sessionmaker):
self._session_factory = session_factory
else:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
# Extract tenant_id from user
tenant_id = extract_tenant_id(user)

View File

@ -68,15 +68,14 @@ class CeleryWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository):
triggered_from: Source of the execution trigger (SINGLE_STEP or WORKFLOW_RUN)
"""
# Store session factory for fallback operations
match session_factory:
case Engine():
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
case sessionmaker():
self._session_factory = session_factory
case _:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
if isinstance(session_factory, Engine):
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
elif isinstance(session_factory, sessionmaker):
self._session_factory = session_factory
else:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
# Extract tenant_id from user
tenant_id = extract_tenant_id(user)

View File

@ -288,25 +288,24 @@ class HumanInputFormRepositoryImpl:
channel_payload=delivery_method.model_dump_json(),
)
recipients: list[HumanInputFormRecipient] = []
match delivery_method:
case InteractiveSurfaceDeliveryMethod():
recipient_model = HumanInputFormRecipient(
if isinstance(delivery_method, InteractiveSurfaceDeliveryMethod):
recipient_model = HumanInputFormRecipient(
form_id=form_id,
delivery_id=delivery_id,
recipient_type=RecipientType.STANDALONE_WEB_APP,
recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(),
)
recipients.append(recipient_model)
elif isinstance(delivery_method, EmailDeliveryMethod):
email_recipients_config = delivery_method.config.recipients
recipients.extend(
self._build_email_recipients(
session=session,
form_id=form_id,
delivery_id=delivery_id,
recipient_type=RecipientType.STANDALONE_WEB_APP,
recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(),
)
recipients.append(recipient_model)
case EmailDeliveryMethod():
email_recipients_config = delivery_method.config.recipients
recipients.extend(
self._build_email_recipients(
session=session,
form_id=form_id,
delivery_id=delivery_id,
recipients_config=email_recipients_config,
)
recipients_config=email_recipients_config,
)
)
return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients)

View File

@ -54,15 +54,14 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository):
triggered_from: Source of the execution trigger (DEBUGGING or APP_RUN)
"""
# If an engine is provided, create a sessionmaker from it
match session_factory:
case Engine():
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
case sessionmaker():
self._session_factory = session_factory
case _:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
if isinstance(session_factory, Engine):
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
elif isinstance(session_factory, sessionmaker):
self._session_factory = session_factory
else:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
# Extract tenant_id from user
tenant_id = extract_tenant_id(user)

View File

@ -77,15 +77,14 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
triggered_from: Source of the execution trigger (SINGLE_STEP or WORKFLOW_RUN)
"""
# If an engine is provided, create a sessionmaker from it
match session_factory:
case Engine():
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
case sessionmaker():
self._session_factory = session_factory
case _:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
if isinstance(session_factory, Engine):
self._session_factory = sessionmaker(bind=session_factory, expire_on_commit=False)
elif isinstance(session_factory, sessionmaker):
self._session_factory = session_factory
else:
raise ValueError(
f"Invalid session_factory type {type(session_factory).__name__}; expected sessionmaker or Engine"
)
# Extract tenant_id from user
tenant_id = extract_tenant_id(user)

View File

@ -125,11 +125,10 @@ class SchemaResolver:
def _process_queue_item(self, queue: deque, item: QueueItem) -> None:
"""Process a single queue item"""
match item.current:
case dict():
self._process_dict(queue, item)
case list():
self._process_list(queue, item)
if isinstance(item.current, dict):
self._process_dict(queue, item)
elif isinstance(item.current, list):
self._process_list(queue, item)
def _process_dict(self, queue: deque, item: QueueItem) -> None:
"""Process a dictionary item"""

View File

@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
# Custom header used to carry the forwarded SSO access token. Picked to avoid
# stomping on the workspace-scoped Authorization header (provider OAuth /
# user-supplied custom credentials), which would silently break those flows.
FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Access-Token"
FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Token"
class MCPTool(Tool):
@ -305,7 +305,7 @@ class MCPTool(Tool):
# Forwarded identity rides in a custom header so workspace-scoped
# provider credentials (Authorization / custom Headers) keep working
# untouched. The MCP server is expected to read X-Dify-SSO-Access-Token
# untouched. The MCP server is expected to read X-Dify-SSO-Token
# when identity forwarding is configured.
forward_identity_active = False
if self._forwarding_requested and user_id:
@ -338,7 +338,7 @@ class MCPTool(Tool):
audience: str,
) -> None:
"""Call the enterprise IssueMCPToken endpoint and stamp the issued
token into X-Dify-SSO-Access-Token.
token into X-Dify-SSO-Token.
A custom header is used (rather than Authorization) so it composes
with workspace-scoped provider credentials — the user may have OAuth

View File

@ -106,26 +106,24 @@ def _dict_to_workflow_run(data: dict[str, Any]) -> WorkflowRun:
# Handle datetime fields
started_at = data.get("started_at") or data.get("created_at")
if started_at:
match started_at:
case str():
model.created_at = datetime.fromisoformat(started_at)
case int() | float():
model.created_at = datetime.fromtimestamp(started_at)
case _:
model.created_at = started_at
if isinstance(started_at, str):
model.created_at = datetime.fromisoformat(started_at)
elif isinstance(started_at, (int, float)):
model.created_at = datetime.fromtimestamp(started_at)
else:
model.created_at = started_at
else:
# Provide default created_at if missing
model.created_at = datetime.now()
finished_at = data.get("finished_at")
if finished_at:
match finished_at:
case str():
model.finished_at = datetime.fromisoformat(finished_at)
case int() | float():
model.finished_at = datetime.fromtimestamp(finished_at)
case _:
model.finished_at = finished_at
if isinstance(finished_at, str):
model.finished_at = datetime.fromisoformat(finished_at)
elif isinstance(finished_at, (int, float)):
model.finished_at = datetime.fromtimestamp(finished_at)
else:
model.finished_at = finished_at
# Compute elapsed_time from started_at and finished_at
# LogStore doesn't store elapsed_time, it's computed in WorkflowExecution domain entity

View File

@ -1,8 +1,8 @@
import re
from collections.abc import Mapping
from typing import Any, Protocol, override
from typing import Any
from flask import Blueprint, Flask, current_app, got_request_exception, request
from flask import Blueprint, Flask, current_app, got_request_exception
from flask_restx import Api
from werkzeug.exceptions import HTTPException
from werkzeug.http import HTTP_STATUS_CODES
@ -17,24 +17,11 @@ def http_status_message(code):
return HTTP_STATUS_CODES.get(code, "")
class ErrorBodyFormatter(Protocol):
"""Last-touch hook over an error body before it goes on the wire."""
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]: ...
def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatter | None = None):
def _finalize(e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
if body_formatter is None:
return data
return body_formatter.finalize(e, data, status_code)
def register_external_error_handlers(api: Api):
def handle_http_exception(e: HTTPException):
got_request_exception.send(current_app, exception=e)
# If Werkzeug already prepared a Response, just use it. This bypasses
# body_formatter entirely — surfaces with a formatter must not raise
# exceptions carrying a pre-built response.
# If Werkzeug already prepared a Response, just use it.
if e.response is not None:
return e.response
@ -58,7 +45,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
# Payload per status
if status_code == 406 and api.default_mediatype is None:
data = {"code": "not_acceptable", "message": default_data["message"], "status": status_code}
return _finalize(e, data, status_code), status_code, headers
return data, status_code, headers
elif status_code == 400:
msg = default_data["message"]
if isinstance(msg, Mapping) and msg:
@ -73,7 +60,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
else:
data = {**default_data}
data.setdefault("code", "unknown")
return _finalize(e, data, status_code), status_code, headers
return data, status_code, headers
else:
data = {**default_data}
data.setdefault("code", "unknown")
@ -85,20 +72,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
if error_code == "unauthorized_and_force_logout":
# Add Set-Cookie headers to clear auth cookies
headers["Set-Cookie"] = build_force_logout_cookie_headers()
return _finalize(e, data, status_code), status_code, headers
return data, status_code, headers
def handle_value_error(e: ValueError):
got_request_exception.send(current_app, exception=e)
current_app.logger.exception("value_error in request handler")
status_code = 400
data = {"code": "invalid_param", "message": str(e), "status": status_code}
return _finalize(e, data, status_code), status_code
return data, status_code
def handle_quota_exceeded(e: AppInvokeQuotaExceededError):
got_request_exception.send(current_app, exception=e)
status_code = 429
data = {"code": "too_many_requests", "message": str(e), "status": status_code}
return _finalize(e, data, status_code), status_code
return data, status_code
def handle_general_exception(e: Exception):
got_request_exception.send(current_app, exception=e)
@ -116,7 +103,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
# Note: Exception logging is handled by Flask/Flask-RESTX framework automatically
# Explicit log_exception call removed to avoid duplicate log entries
return _finalize(e, data, status_code), status_code
return data, status_code
api.errorhandler(HTTPException)(handle_http_exception)
api.errorhandler(ValueError)(handle_value_error)
@ -134,46 +121,14 @@ class ExternalApi(Api):
}
}
def __init__(self, app: Blueprint | Flask, *args, error_body_formatter: ErrorBodyFormatter | None = None, **kwargs):
self._error_body_formatter = error_body_formatter
def __init__(self, app: Blueprint | Flask, *args, **kwargs):
patch_swagger_for_inline_nested_dicts()
kwargs.setdefault("authorizations", self._authorizations)
kwargs.setdefault("security", "Bearer")
kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED
kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False
if error_body_formatter is not None:
kwargs.setdefault("catch_all_404s", True)
# the overrides below patch private flask-restx methods; fail at
# startup (not at the first 404) if an upgrade removes them
for private_hook in ("_should_use_fr_error_handler", "_help_on_404"):
if not callable(getattr(Api, private_hook, None)):
raise RuntimeError(f"flask-restx no longer exposes {private_hook}; update ExternalApi overrides")
# manual separate call on construction and init_app to ensure configs in kwargs effective
super().__init__(app=None, *args, **kwargs)
self.init_app(app, **kwargs)
register_external_error_handlers(self, body_formatter=error_body_formatter)
@override
def _should_use_fr_error_handler(self):
# catch_all_404s makes flask-restx claim NotFound for ANY app path
# (it wraps the app-level handle_exception), so scope the claim to
# this blueprint's url prefix; other surfaces keep their own 404s.
if self._error_body_formatter is not None and not self._request_under_own_prefix():
return False
return super()._should_use_fr_error_handler()
def _request_under_own_prefix(self) -> bool:
prefix = self.blueprint.url_prefix if self.blueprint is not None else None
if not prefix:
return True
return request.path == prefix or request.path.startswith(prefix.rstrip("/") + "/")
@override
def _help_on_404(self, message: str | None = None) -> str | None:
# flask-restx appends route suggestions post-handler; with a canonical
# formatter installed, that would corrupt the contract and enumerate
# routes to unauthenticated callers.
if self._error_body_formatter is not None:
return message
return super()._help_on_404(message)
register_external_error_handlers(self)

View File

@ -24,7 +24,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Health check | [HealthResponse](#healthresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /_version
@ -34,7 +33,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Server version | [ServerVersionResponse](#serverversionresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /account
@ -44,7 +42,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Account info | [AccountResponse](#accountresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /account/sessions
@ -61,8 +58,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session list | [SessionListResponse](#sessionlistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /account/sessions/self
@ -72,7 +67,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /account/sessions/{session_id}
@ -88,7 +82,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /apps
@ -109,8 +102,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | App list | [AppListResponse](#applistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/check-dependencies
@ -126,7 +117,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/describe
@ -143,8 +133,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | App description | [AppDescribeResponse](#appdescriberesponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/export
@ -162,8 +150,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/files/upload
@ -187,7 +173,6 @@ Upload a file to use as an input variable when running the app
| 401 | Unauthorized — invalid or expired bearer token | |
| 413 | File too large | |
| 415 | Unsupported file type or blocked extension | |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/form/human_input/{form_token}
@ -219,8 +204,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Form submitted | [FormSubmitResponse](#formsubmitresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/run
@ -234,10 +217,9 @@ Upload a file to use as an input variable when running the app
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Run result (SSE stream) | |
| 422 | Validation error | [ErrorBody](#errorbody) |
| Code | Description |
| ---- | ----------- |
| 200 | Run result (SSE stream) |
### /apps/{app_id}/tasks/{task_id}/events
@ -270,7 +252,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Task stopped | [TaskStopResponse](#taskstopresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /oauth/device/approve
@ -364,8 +345,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Permitted external apps list | [PermittedExternalAppsListResponse](#permittedexternalappslistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces
@ -375,7 +354,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace list | [WorkspaceListResponse](#workspacelistresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}
@ -391,7 +369,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/apps/imports
@ -410,8 +387,6 @@ Upload a file to use as an input variable when running the app
| 200 | Import completed | [Import](#import) |
| 202 | Import pending confirmation | [Import](#import) |
| 400 | Import failed | [Import](#import) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
@ -429,7 +404,6 @@ Upload a file to use as an input variable when running the app
| ---- | ----------- | ------ |
| 200 | Import confirmed | [Import](#import) |
| 400 | Import failed | [Import](#import) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/members
@ -447,8 +421,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member list | [MemberListResponse](#memberlistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
#### POST
##### Parameters
@ -463,8 +435,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/members/{member_id}
@ -481,7 +451,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member removed | [MemberActionResponse](#memberactionresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/members/{member_id}/role
@ -499,8 +468,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Role updated | [MemberActionResponse](#memberactionresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/switch
@ -516,7 +483,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
| default | Error | [ErrorBody](#errorbody) |
---
### Models
@ -727,28 +693,6 @@ mode is a closed enum.
| client_id | string | | Yes |
| device_code | string | | Yes |
#### ErrorBody
Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
generated client schema stays an open enum — old CLIs keep parsing when a
future server adds a code. Formatter tests pin emitted values to the enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| code | string | | Yes |
| details | [ [ErrorDetail](#errordetail) ] | | No |
| hint | string | | No |
| message | string | | Yes |
| status | integer | | Yes |
#### ErrorDetail
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| loc | [ ] | | No |
| msg | string | | Yes |
| type | string | | Yes |
#### FileResponse
| Name | Type | Description | Required |
@ -900,12 +844,6 @@ Strict (extra='forbid').
| retriever_resources | [ object ] | | No |
| usage | [UsageInfo](#usageinfo) | | No |
#### OpenApiErrorCode
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| OpenApiErrorCode | string | | |
#### Package
| Name | Type | Description | Required |

View File

@ -141,7 +141,7 @@ class EnterpriseService:
the calling Dify user, audience-scoped to the given MCP server identifier.
Used by MCPTool.invoke_remote_mcp_tool to stamp the
X-Dify-SSO-Access-Token header on outbound MCP requests when the
X-Dify-SSO-Token header on outbound MCP requests when the
provider's identity_mode is set to "idp_token".
Returns:

View File

@ -208,41 +208,3 @@ def test_accepts_body_emits_expect_through_guard_stack():
apidoc = getattr(view, "__apidoc__", {})
assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect
def _response_model_name(entry) -> str:
"""Extract the model name from a flask-restx __apidoc__ response entry.
flask-restx stores responses as ``(description, model, kwargs)`` tuples
where ``model.name`` is the registered schema name.
"""
if isinstance(entry, tuple) and len(entry) >= 2:
model = entry[1]
return getattr(model, "name", "") or ""
return ""
def test_accepts_documents_422_error_response(app):
from controllers.openapi._errors import ErrorBody
@accepts(query=ContractQuery)
def view(*, query):
return query
doc = getattr(view, "__apidoc__", {})
responses = doc.get("responses", {})
assert "422" in responses
assert _response_model_name(responses["422"]) == ErrorBody.__name__
def test_returns_documents_default_error_response(app):
from controllers.openapi._errors import ErrorBody
@returns(200, ContractResp)
def view():
return ContractResp(value=1)
doc = getattr(view, "__apidoc__", {})
responses = doc.get("responses", {})
assert "default" in responses
assert _response_model_name(responses["default"]) == ErrorBody.__name__

View File

@ -1,349 +0,0 @@
"""Wire-contract tests for the canonical /openapi/v1 error body."""
from unittest.mock import MagicMock, patch
import pytest
from werkzeug.exceptions import (
BadGateway,
BadRequest,
Conflict,
Forbidden,
InternalServerError,
NotFound,
Unauthorized,
UnprocessableEntity,
)
from controllers.common.errors import (
BlockedFileExtensionError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.openapi._errors import (
ErrorBody,
ErrorDetail,
FilenameNotExists,
MemberLicenseExceeded,
MemberLimitExceeded,
OpenApiError,
OpenApiErrorCode,
OpenApiErrorFormatter,
)
from controllers.service_api.app.error import (
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
@pytest.fixture
def fmt() -> OpenApiErrorFormatter:
return OpenApiErrorFormatter()
class TestErrorBodyModel:
def test_minimal_body_serializes_without_optional_fields(self):
body = ErrorBody(code=OpenApiErrorCode.NOT_FOUND, message="app not found", status=404)
wire = body.model_dump(mode="json", exclude_none=True)
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
def test_full_body_round_trips(self):
body = ErrorBody(
code=OpenApiErrorCode.INVALID_PARAM,
message="Request validation failed",
status=422,
hint="check the request payload",
details=[ErrorDetail(type="int_parsing", loc=["page"], msg="must be >= 1")],
)
wire = body.model_dump(mode="json", exclude_none=True)
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
assert ErrorBody.model_validate(wire) == body
def test_code_field_is_open_string_for_forward_compat(self):
# Old CLIs must not hard-fail when a future server adds a code, so the
# schema type is str; enum membership is enforced by the formatter tests.
body = ErrorBody.model_validate({"code": "some_future_code", "message": "x", "status": 400})
assert body.code == "some_future_code"
class TestOpenApiErrorFormatter:
def test_plain_werkzeug_exception_maps_code_from_status(self, fmt):
e = NotFound("app not found")
data = {"code": "not_found", "message": "app not found", "status": 404}
wire = fmt.finalize(e, data, 404)
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
def test_422_maps_to_invalid_param(self, fmt):
e = UnprocessableEntity("workspace_id is required for name-based lookup")
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
wire = fmt.finalize(e, data, 422)
assert wire["code"] == "invalid_param"
def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt):
# Simulates _contract.py's abort(422, message=..., errors=...): flask_restx
# attaches kwargs to e.data, which handle_error would otherwise put on the
# wire verbatim (no code/status).
e = UnprocessableEntity()
e.data = {
"message": "Request validation failed",
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1", "extra": "drop me"}],
}
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
wire = fmt.finalize(e, data, 422)
assert wire["code"] == "invalid_param"
assert wire["message"] == "Request validation failed"
assert wire["status"] == 422
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
# the override channel now carries the canonical body
assert e.data == wire
def test_finalize_is_idempotent(self, fmt):
e = UnprocessableEntity()
e.data = {
"message": "Request validation failed",
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}],
}
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
first = fmt.finalize(e, data, 422)
second = fmt.finalize(e, data, 422)
assert second == first
def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt):
# finalize runs inside the framework error handler; a ValidationError
# escaping it would replace the response with an unformatted 500
e = UnprocessableEntity()
e.data = {"message": "broken", "details": [{"bad": "shape"}]}
data = {"code": "unprocessable_entity", "message": "broken", "status": 422}
wire = fmt.finalize(e, data, 422)
assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422}
def test_base_http_exception_error_code_wins_over_status_map(self, fmt):
e = ProviderQuotaExceededError()
data = dict(e.data)
wire = fmt.finalize(e, data, 400)
assert wire["code"] == "provider_quota_exceeded"
assert wire["status"] == 400
def test_hint_attribute_is_emitted(self, fmt):
e = Conflict("seat limit")
e.hint = "remove a member first"
data = {"code": "conflict", "message": "seat limit", "status": 409}
wire = fmt.finalize(e, data, 409)
assert wire["hint"] == "remove a member first"
def test_params_shape_becomes_details(self, fmt):
e = ValueError("is required")
data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400}
wire = fmt.finalize(e, data, 400)
assert "params" not in wire
assert wire["details"] == [{"type": "invalid", "loc": ["email"], "msg": "is required"}]
def test_catch_all_exception_never_leaks_str_e(self, fmt):
e = RuntimeError("postgres password=hunter2 connection refused")
data = {"message": str(e), "code": "unknown", "status": 500}
wire = fmt.finalize(e, data, 500)
assert wire["code"] == "internal_server_error"
assert "hunter2" not in wire["message"]
def test_unmapped_status_falls_back_to_unknown(self, fmt):
from werkzeug.exceptions import Gone
e = Gone()
data = {"code": "gone", "message": e.description, "status": 410}
wire = fmt.finalize(e, data, 410)
assert wire["code"] == "unknown"
def test_openapi_error_subclass_is_throw_and_done(self, fmt):
# The dedicated throwable: subclass declares status + code + message once,
# call sites just `raise`; the formatter emits everything verbatim.
class TeapotError(OpenApiError):
code = 418
error_code = OpenApiErrorCode.INVALID_PARAM
description = "kettle says no"
e = TeapotError(details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")])
data = {"code": "im_a_teapot", "message": e.description, "status": 418}
wire = fmt.finalize(e, data, 418)
assert wire["code"] == OpenApiErrorCode.INVALID_PARAM
assert wire["message"] == TeapotError.description
assert wire["details"] == [{"type": "invalid", "loc": ["kettle"], "msg": "too hot"}]
def test_openapi_error_message_override(self, fmt):
e = OpenApiError("custom reason")
data = {"code": "bad_request", "message": e.description, "status": 400}
wire = fmt.finalize(e, data, 400)
assert wire["message"] == "custom reason"
assert wire["code"] == "bad_request"
def test_every_emitted_code_is_an_enum_member(self, fmt):
# Guard against the formatter inventing codes outside the contract.
cases = [
(NotFound("x"), {"code": "not_found", "message": "x", "status": 404}, 404),
(ProviderQuotaExceededError(), dict(ProviderQuotaExceededError().data), 400),
(ValueError("x"), {"code": "invalid_param", "message": "x", "status": 400}, 400),
]
for e, data, status in cases:
wire = fmt.finalize(e, data, status)
assert wire["code"] in {c.value for c in OpenApiErrorCode}
class TestQuotaExceptions:
@pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded])
def test_quota_exception_carries_declared_code_and_message(self, fmt, exc_class):
# Single source: assertions read the class attributes, no re-typed strings.
e = exc_class()
data = {"code": "forbidden", "message": e.description, "status": 403}
wire = fmt.finalize(e, data, 403)
assert wire["code"] == exc_class.error_code
assert wire["message"] == exc_class.description
assert wire["hint"] == exc_class.hint
assert wire["status"] == 403
class TestWireContract:
"""End-to-end: request in, canonical JSON out, through the real openapi blueprint."""
def test_accepts_422_carries_code_status_details(self, openapi_app, bypass_pipeline):
client = openapi_app.test_client()
resp = client.get("/openapi/v1/apps?page=0")
assert resp.status_code == 422
wire = resp.get_json()
ErrorBody.model_validate(wire)
assert wire["code"] == "invalid_param"
assert wire["status"] == 422
assert wire["details"]
def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app):
client = openapi_app.test_client()
resp = client.get("/openapi/v1/definitely-not-a-route")
assert resp.status_code == 404
wire = resp.get_json()
ErrorBody.model_validate(wire)
assert wire["code"] == "not_found"
assert "did you mean" not in wire["message"].lower()
def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app):
# catch_all_404s wraps the app-level exception handler; the prefix
# guard must keep non-/openapi/v1 paths on the app's own 404 handling
client = openapi_app.test_client()
resp = client.get("/console/definitely-not-a-route")
assert resp.status_code == 404
# not intercepted → Flask's default HTML 404, not the canonical JSON body
assert "application/json" not in (resp.content_type or "")
@patch("controllers.openapi.oauth_device.DeviceFlowRedis")
def test_oauth_device_token_keeps_rfc8628_shape(self, mock_redis_cls, openapi_app):
store = MagicMock()
mock_redis_cls.return_value = store
store.record_poll.return_value = None # not SlowDownDecision.SLOW_DOWN
store.load_by_device_code.return_value = None # unknown code → expired_token
client = openapi_app.test_client()
resp = client.post(
"/openapi/v1/oauth/device/token",
json={"client_id": "difyctl", "device_code": "nope"},
)
assert resp.status_code == 400
wire = resp.get_json()
assert wire == {"error": "expired_token"}
ERROR_MATRIX = [
(BadRequest("x"), 400, "bad_request"),
(Unauthorized("x"), 401, "unauthorized"),
(Forbidden("x"), 403, "forbidden"),
(NotFound("x"), 404, "not_found"),
(Conflict("x"), 409, "conflict"),
(UnprocessableEntity("x"), 422, "invalid_param"),
(InternalServerError(), 500, "internal_server_error"),
(BadGateway("x"), 502, "bad_gateway"),
(AppUnavailableError(), 400, "app_unavailable"),
(ConversationCompletedError(), 400, "conversation_completed"),
(ProviderNotInitializeError(), 400, "provider_not_initialize"),
(ProviderQuotaExceededError(), 400, "provider_quota_exceeded"),
(ProviderModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
(CompletionRequestError(), 400, "completion_request_error"),
(InvokeRateLimitHttpError(), 429, "rate_limit_error"),
(FileTooLargeError(), 413, "file_too_large"),
(UnsupportedFileTypeError(), 415, "unsupported_file_type"),
(NoFileUploadedError(), 400, "no_file_uploaded"),
(TooManyFilesError(), 400, "too_many_files"),
(FilenameNotExists(), 400, "filename_not_exists"),
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
]
class TestErrorMatrix:
@pytest.mark.parametrize(
("exc", "status", "expected_code"),
ERROR_MATRIX,
ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v),
)
def test_every_known_error_path_yields_canonical_code(self, fmt, exc, status, expected_code):
data = dict(getattr(exc, "data", None) or {"message": str(exc), "status": status})
wire = fmt.finalize(exc, data, status)
assert wire["code"] == expected_code
assert wire["status"] == status
assert wire["code"] in {c.value for c in OpenApiErrorCode}
ErrorBody.model_validate(wire)
class TestErrorCodeEnumRegistration:
def test_enum_registered_with_all_values(self):
from controllers.openapi import openapi_ns
from controllers.openapi._errors import OpenApiErrorCode
model = openapi_ns.models.get("OpenApiErrorCode")
assert model is not None
schema = model.__schema__
assert schema["type"] == "string"
assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode}

View File

@ -29,10 +29,9 @@ import pytest
from flask import Flask
from flask.views import MethodView
from pydantic import ValidationError
from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, UnprocessableEntity
from controllers.openapi import bp as openapi_bp
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload
from controllers.openapi.workspaces import (
WorkspaceMemberApi,
@ -508,7 +507,11 @@ def _invite_request(app, ws_id: str, acct_id: uuid.UUID):
def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
"""SaaS billing plan member cap → MemberLimitExceeded (403)."""
"""SaaS billing plan member cap → 403 with `members.limit_exceeded`.
Verifies the envelope shape the CLI error-mapper relies on (code +
message + hint on the wire body).
"""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
@ -535,14 +538,18 @@ def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
with _invite_request(app, ws_id, acct_id):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(MemberLimitExceeded):
with pytest.raises(Forbidden) as exc_info:
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
body = exc_info.value.response.json
assert body["code"] == "members.limit_exceeded"
assert "Subscription member limit" in body["message"]
assert body["hint"]
invite_mock.assert_not_called()
def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch):
"""EE License workspace_members cap → MemberLicenseExceeded (403).
"""EE License workspace_members cap → 403 with `workspace_members.license_exceeded`.
Note: billing.enabled is False (EE without SaaS billing); only the
license cap fires.
@ -577,9 +584,13 @@ def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, mo
with _invite_request(app, ws_id, acct_id):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(MemberLicenseExceeded):
with pytest.raises(Forbidden) as exc_info:
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
body = exc_info.value.response.json
assert body["code"] == "workspace_members.license_exceeded"
assert "license" in body["message"].lower()
assert body["hint"]
invite_mock.assert_not_called()

View File

@ -177,7 +177,7 @@ def _build_forwarding_tool(*, mode: str = "idp_token") -> MCPTool:
def test_inject_forwarded_identity_stamps_custom_header():
"""The minted SSO token must be placed in X-Dify-SSO-Access-Token; the
"""The minted SSO token must be placed in X-Dify-SSO-Token; the
workspace-scoped Authorization header and any other custom headers must
pass through untouched so provider credentials keep working."""
from core.tools.mcp_tool.tool import FORWARDED_IDENTITY_HEADER

View File

@ -1,4 +1,3 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import type { ErrorCodeValue, ExitCodeValue } from './codes'
import type { ErrorEnvelope, PrintableError } from './format'
import { ErrorCode, exitFor } from './codes'
@ -84,7 +83,6 @@ type HttpClientErrorOptions = BaseErrorOptions & {
readonly method?: string
readonly url?: string
readonly rawResponse?: string
readonly serverError?: ErrorBody
}
export class HttpClientError extends BaseError {
@ -92,7 +90,6 @@ export class HttpClientError extends BaseError {
readonly method?: string
readonly url?: string
readonly rawResponse?: string
readonly serverError?: ErrorBody
constructor(opts: HttpClientErrorOptions) {
super(opts)
@ -100,7 +97,6 @@ export class HttpClientError extends BaseError {
this.method = opts.method
this.url = opts.url
this.rawResponse = opts.rawResponse
this.serverError = opts.serverError
}
override toEnvelope(): ErrorEnvelope {
@ -113,8 +109,6 @@ export class HttpClientError extends BaseError {
envelope.error.url = this.url
if (this.rawResponse !== undefined)
envelope.error.raw_response = this.rawResponse
if (this.serverError !== undefined)
envelope.error.server = this.serverError
return envelope
}
@ -125,7 +119,6 @@ export class HttpClientError extends BaseError {
method: this.method,
url: this.url,
rawResponse: this.rawResponse,
serverError: this.serverError,
}
}
@ -152,8 +145,4 @@ export class HttpClientError extends BaseError {
}
return new HttpClientError({ ...this.snapshot(), rawResponse })
}
withServerError(serverError: ErrorBody): HttpClientError {
return new HttpClientError({ ...this.snapshot(), serverError })
}
}

View File

@ -1,168 +0,0 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import { afterEach, describe, expect, it } from 'vitest'
import { setVerbose } from '@/framework/context'
import { HttpClientError } from './base'
import { ErrorCode } from './codes'
import { formatErrorForCli } from './format'
type ValidationErrorOverrides = {
readonly cliHint?: string
readonly serverHint?: string
readonly details?: ErrorBody['details']
}
function validationError(overrides: ValidationErrorOverrides = {}): HttpClientError {
const details
= overrides.details
?? [
{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' },
{ type: 'missing', loc: ['inputs', 'query'], msg: 'field required' },
]
return new HttpClientError({
code: ErrorCode.Server4xxOther,
message: 'Request validation failed',
httpStatus: 422,
hint: overrides.cliHint,
serverError: {
code: 'invalid_param',
message: 'Request validation failed',
status: 422,
hint: overrides.serverHint,
details,
},
})
}
afterEach(() => {
setVerbose(false)
})
describe('formatErrorForCli — human', () => {
it('prints server code, message, and details without verbose', () => {
const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { isErrTTY: false })
expect(out).toContain('invalid_param: Request validation failed')
expect(out).toContain('- page: must be >= 1 (int_parsing)')
expect(out).toContain('- inputs.query: field required (missing)')
expect(out).toContain('check the page parameter')
expect(out).not.toContain('raw_response')
})
it('falls back to cli code when no server code', () => {
const err = new HttpClientError({ code: ErrorCode.Server5xx, message: 'server error (HTTP 502)', httpStatus: 502 })
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('server_5xx: server error (HTTP 502)')
})
it('cli hint wins over server hint; server hint fills when cli sent none', () => {
const withBothHints = validationError({ cliHint: 'cli local hint', serverHint: 'check the page parameter', details: [] })
expect(formatErrorForCli(withBothHints, { isErrTTY: false })).toContain('cli local hint')
expect(formatErrorForCli(withBothHints, { isErrTTY: false })).not.toContain('check the page parameter')
// no cli hint → server hint shown
const noCliHint = validationError({ serverHint: 'check the page parameter', details: [] })
expect(formatErrorForCli(noCliHint, { isErrTTY: false })).toContain('check the page parameter')
// no server hint → cli hint shown
const noServerHint = new HttpClientError({
code: ErrorCode.AuthExpired,
message: 'session expired',
hint: 'run difyctl auth login',
})
expect(formatErrorForCli(noServerHint, { isErrTTY: false })).toContain('run difyctl auth login')
})
it('omits the loc prefix when a detail has no loc', () => {
const out = formatErrorForCli(
validationError({ details: [{ type: 'invalid', loc: [], msg: 'body required' }] }),
{ isErrTTY: false },
)
expect(out).toContain('- body required (invalid)')
expect(out).not.toContain('- : body required')
})
it('hints at -v when a raw response exists but is hidden', () => {
const err = new HttpClientError({
code: ErrorCode.Server4xxOther,
message: 'request failed (HTTP 400)',
httpStatus: 400,
rawResponse: '<html>not json</html>',
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('run again with -v to see the raw server response')
expect(out).not.toContain('raw_response')
})
it('no -v hint when the server body parsed', () => {
const err = new HttpClientError({
code: ErrorCode.Server4xxOther,
message: 'Request validation failed',
httpStatus: 422,
rawResponse: '{"code":"invalid_param","message":"Request validation failed","status":422}',
serverError: { code: 'invalid_param', message: 'Request validation failed', status: 422 },
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).not.toContain('run again with -v')
})
it('existing hints win over the -v hint', () => {
const err = new HttpClientError({
code: ErrorCode.Server4xxOther,
message: 'request failed (HTTP 400)',
httpStatus: 400,
hint: 'cli hint',
rawResponse: '<html>not json</html>',
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('cli hint')
expect(out).not.toContain('run again with -v')
})
it('shows raw_response instead of the -v hint when verbose', () => {
setVerbose(true)
const err = new HttpClientError({
code: ErrorCode.Server4xxOther,
message: 'request failed (HTTP 400)',
httpStatus: 400,
rawResponse: '<html>not json</html>',
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('raw_response: <html>not json</html>')
expect(out).not.toContain('run again with -v')
})
it('renders request and http_status lines', () => {
const err = new HttpClientError({
code: ErrorCode.Server5xx,
message: 'upstream boom',
httpStatus: 502,
method: 'GET',
url: 'https://api.dify.ai/v1/me',
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('request: GET https://api.dify.ai/v1/me')
expect(out).toContain('http_status: 502')
})
})
describe('formatErrorForCli — json', () => {
it('envelope nests the whole server error', () => {
const out = JSON.parse(formatErrorForCli(validationError(), { format: 'json' }))
expect(out.error.server.code).toBe('invalid_param')
expect(out.error.server.details).toHaveLength(2)
expect(out.error.code).toBe('server_4xx_other')
})
})

View File

@ -1,10 +1,7 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import { isVerbose } from '@/framework/context'
import { redactBearer } from '@/http/sanitize'
import { colorEnabled, colorScheme } from '@/sys/io/color'
const RAW_RESPONSE_HINT = 'run again with -v to see the raw server response'
export type FormatErrorOptions = {
readonly format?: string
readonly isErrTTY?: boolean
@ -19,7 +16,6 @@ export type ErrorEnvelope = {
method?: string
url?: string
raw_response?: string
server?: ErrorBody
}
}
@ -46,30 +42,12 @@ function renderEnvelope(env: ErrorEnvelope): string {
return JSON.stringify(env)
}
// CLI-authored hint wins: it knows local remediation (e.g. which command to
// run); the server hint fills in when the CLI has nothing for this error.
function resolveHint(e: ErrorEnvelope['error']): string | undefined {
if (e.hint !== undefined)
return e.hint
if (e.server?.hint != null)
return e.server.hint
const rawHiddenAndUnparsed = e.server === undefined && Boolean(e.raw_response) && !isVerbose()
return rawHiddenAndUnparsed ? RAW_RESPONSE_HINT : undefined
}
function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string {
const cs = colorScheme(colorEnabled(isErrTTY))
const e = env.error
const server = e.server
const headerCode = server?.code ?? e.code
const lines: string[] = [`${headerCode}: ${e.message}`]
for (const d of server?.details ?? []) {
const loc = (d.loc ?? []).join('.')
lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`)
}
const hint = resolveHint(e)
if (hint !== undefined)
lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`)
const lines: string[] = [`${e.code}: ${e.message}`]
if (e.hint !== undefined)
lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`)
if (e.method !== undefined && e.url !== undefined)
lines.push(`request: ${e.method} ${e.url}`)
if (e.http_status !== undefined)

View File

@ -1,73 +0,0 @@
import type { HttpClientError } from '@/errors/base'
import { describe, expect, it } from 'vitest'
import { ErrorCode } from '@/errors/codes'
import { classifyResponse } from './error-mapper'
function res(status: number, body: unknown): Response {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
const req = new Request('https://dify.test/openapi/v1/apps')
function classified(status: number, body: unknown): Promise<HttpClientError> {
return classifyResponse(req, res(status, body))
}
describe('classifyResponse — canonical ErrorBody', () => {
it('attaches the parsed body whole as serverError', async () => {
const body = {
code: 'invalid_param',
message: 'Request validation failed',
status: 422,
hint: 'check the page parameter',
details: [{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }],
}
const err = await classified(422, body)
expect(err.serverError).toEqual(body)
expect(err.message).toBe('Request validation failed')
expect(err.code).toBe(ErrorCode.Server4xxOther)
})
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
const err = await classified(401, {
code: 'unauthorized',
message: 'session expired or revoked',
status: 401,
})
expect(err.code).toBe(ErrorCode.AuthExpired)
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
})
it('unknown future server code is data, not behavior — status bucket decides', async () => {
const err = await classified(409, {
code: 'some_future_code',
message: 'nope',
status: 409,
})
expect(err.code).toBe(ErrorCode.Server4xxOther)
expect(err.serverError?.code).toBe('some_future_code')
})
})
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
it('non-JSON body yields no serverError, classification by status', async () => {
const err = await classified(502, '<html>bad gateway</html>')
expect(err.code).toBe(ErrorCode.Server5xx)
expect(err.serverError).toBeUndefined()
})
it('RFC 8628 string error field yields no serverError and a generic message', async () => {
const err = await classified(400, { error: 'slow_down' })
expect(err.message).toBe('request failed (HTTP 400)')
expect(err.serverError).toBeUndefined()
})
})

View File

@ -1,85 +1,70 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import type { ErrorCodeValue } from '@/errors/codes'
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { redactBearer } from './sanitize'
const AUTH_EXPIRED_MESSAGE = 'session expired or revoked'
const AUTH_LOGIN_HINT = 'run \'difyctl auth login\' to sign in again'
// How one HTTP status bucket classifies: CLI code, message fallback when the
// body is not a canonical ErrorBody, optional CLI hint, raw-body retention.
type StatusClass = {
readonly code: ErrorCodeValue
readonly fallbackMessage: (status: number) => string
readonly hint?: string
readonly includeRaw: boolean
type WireFields = {
code?: string
message?: string
hint?: string
}
const AUTH_EXPIRED_CLASS: StatusClass = {
code: ErrorCode.AuthExpired,
fallbackMessage: () => AUTH_EXPIRED_MESSAGE,
hint: AUTH_LOGIN_HINT,
includeRaw: false,
type WireEnvelope = WireFields & {
error?: WireFields
}
const SERVER_5XX_CLASS: StatusClass = {
code: ErrorCode.Server5xx,
fallbackMessage: status => `server error (HTTP ${status})`,
includeRaw: true,
}
const SERVER_4XX_CLASS: StatusClass = {
code: ErrorCode.Server4xxOther,
fallbackMessage: status => `request failed (HTTP ${status})`,
includeRaw: true,
}
function statusClass(status: number): StatusClass {
if (status === 401)
return AUTH_EXPIRED_CLASS
if (status >= 500)
return SERVER_5XX_CLASS
return SERVER_4XX_CLASS
}
function parseServerError(raw: string): ErrorBody | undefined {
if (raw === '')
return undefined
let parsed: unknown
try {
parsed = JSON.parse(raw)
}
catch {
return undefined
}
const result = zErrorBody.safeParse(parsed)
return result.success ? result.data : undefined
}
export async function classifyResponse(request: Request, response: Response): Promise<HttpClientError> {
async function readBody(response: Response): Promise<{ raw: string, parsed?: WireEnvelope }> {
let raw = ''
try {
raw = await response.clone().text()
raw = await response.text()
}
catch {
// ignore read errors; raw stays ''
return { raw: '' }
}
if (raw === '')
return { raw }
try {
return { raw, parsed: JSON.parse(raw) as WireEnvelope }
}
catch {
return { raw }
}
}
export async function classifyResponse(request: Request, response: Response): Promise<BaseError> {
const { parsed, raw } = await readBody(response.clone())
const wire: WireFields = parsed?.error ?? parsed ?? {}
const status = response.status
const url = redactBearer(response.url || request.url)
const method = request.method
if (status === 401) {
return HttpClientError.from(newError(
ErrorCode.AuthExpired,
wire.message ?? 'session expired or revoked',
))
.withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again')
.withHttpStatus(status)
.withRequest(method, url)
}
const serverError = parseServerError(raw)
const status = response.status
const c = statusClass(status)
return new HttpClientError({
code: c.code,
message: serverError?.message ?? c.fallbackMessage(status),
hint: c.hint,
httpStatus: status,
method: request.method,
url: redactBearer(response.url || request.url),
rawResponse: c.includeRaw && raw !== '' ? raw : undefined,
serverError,
})
if (status >= 500) {
return HttpClientError.from(newError(
ErrorCode.Server5xx,
wire.message ?? `server error (HTTP ${status})`,
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
}
const err = HttpClientError.from(newError(
ErrorCode.Server4xxOther,
wire.message ?? `request failed (HTTP ${status})`,
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
return wire.hint !== undefined ? err.withHint(wire.hint) : err
}
export function classifyTransportError(err: unknown): BaseError {

View File

@ -1,5 +1,4 @@
import type { StubServer } from '@test/fixtures/stub-server'
import type { HttpClientError } from '@/errors/base'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isHttpClientError } from '@/errors/base'
@ -34,49 +33,66 @@ describe('createOpenApiClient error mapping', () => {
await stub?.stop()
})
async function classifiedError(status: number, body: unknown): Promise<HttpClientError> {
stub = await startStubServer(cap => jsonResponder(status, body, cap))
it('recovers Dify message + hint from a top-level 4xx envelope', async () => {
stub = await startStubServer(cap => jsonResponder(403, { message: 'no access', hint: 'ask an admin' }, cap))
const orpc = orpcClient(stub.url)
const caught = await catchErr(() => orpc.account.get())
if (!isHttpClientError(caught))
throw new Error(`expected HttpClientError, got: ${String(caught)}`)
return caught
}
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 })
expect(caught.code).toBe(ErrorCode.Server4xxOther)
expect(caught.httpStatus).toBe(403)
expect(caught.message).toBe('no access')
// Parity with the transport path: the migrated endpoint's error keeps the request
// method/url and the raw body, so formatted errors still print the `request:` line
// and the raw-response dump (not just message/hint).
expect(caught.method).toBe('GET')
expect(caught.url).toContain('/account')
expect(caught.rawResponse).toContain('no access')
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.Server4xxOther)
expect(caught.httpStatus).toBe(403)
expect(caught.message).toBe('no access')
expect(caught.hint).toBe('ask an admin')
// Parity with the transport path: the migrated endpoint's error keeps the request
// method/url and the raw body, so formatted errors still print the `request:` line
// and the raw-response dump (not just message/hint).
expect(caught.method).toBe('GET')
expect(caught.url).toContain('/account')
expect(caught.rawResponse).toContain('no access')
}
})
it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => {
const caught = await classifiedError(401, { code: 'unauthorized', message: 'expired', status: 401 })
it('recovers from a nested { error: { message, hint } } envelope and keeps the auth code on 401', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: { message: 'expired', hint: 'relogin' } }, cap))
const orpc = orpcClient(stub.url)
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.httpStatus).toBe(401)
expect(caught.message).toBe('expired')
const caught = await catchErr(() => orpc.account.get())
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.httpStatus).toBe(401)
expect(caught.message).toBe('expired')
expect(caught.hint).toBe('relogin')
}
})
it('uses CLI default auth-login hint for non-conforming 401 body', async () => {
const caught = await classifiedError(401, { error: 'expired' })
it('falls back to the default auth-login hint when the body carries none', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
const orpc = orpcClient(stub.url)
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.hint).toContain('difyctl auth login')
const caught = await catchErr(() => orpc.account.get())
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.hint).toContain('difyctl auth login')
}
})
it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => {
const caught = await classifiedError(503, { code: 'service_unavailable', message: 'down for maintenance', status: 503 })
it('maps 5xx to Server5xx', async () => {
stub = await startStubServer(cap => jsonResponder(503, { message: 'down for maintenance' }, cap))
const orpc = orpcClient(stub.url)
expect(caught.code).toBe(ErrorCode.Server5xx)
expect(caught.httpStatus).toBe(503)
expect(caught.message).toBe('down for maintenance')
const caught = await catchErr(() => orpc.account.get())
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.Server5xx)
expect(caught.httpStatus).toBe(503)
expect(caught.message).toBe('down for maintenance')
}
})
})

View File

@ -168,20 +168,6 @@ export type DevicePollRequest = {
device_code: string
}
export type ErrorBody = {
code: string
details?: Array<ErrorDetail> | null
hint?: string | null
message: string
status: number
}
export type ErrorDetail = {
loc?: Array<unknown>
msg: string
type: string
}
export type FileResponse = {
conversation_id?: string | null
created_at?: number | null
@ -292,37 +278,6 @@ export type MessageMetadata = {
usage?: UsageInfo
}
export type OpenApiErrorCode
= | 'app_unavailable'
| 'bad_gateway'
| 'bad_request'
| 'completion_request_error'
| 'conflict'
| 'conversation_completed'
| 'file_extension_blocked'
| 'file_too_large'
| 'filename_not_exists'
| 'forbidden'
| 'internal_server_error'
| 'invalid_param'
| 'member_license_exceeded'
| 'member_limit_exceeded'
| 'method_not_allowed'
| 'model_currently_not_support'
| 'no_file_uploaded'
| 'not_acceptable'
| 'not_found'
| 'provider_not_initialize'
| 'provider_quota_exceeded'
| 'rate_limit_error'
| 'request_entity_too_large'
| 'too_many_files'
| 'too_many_requests'
| 'unauthorized'
| 'unknown'
| 'unsupported_file_type'
| 'unsupported_media_type'
export type Package = {
plugin_unique_identifier: string
version?: string | null
@ -446,12 +401,6 @@ export type GetHealthData = {
url: '/_health'
}
export type GetHealthErrors = {
default: ErrorBody
}
export type GetHealthError = GetHealthErrors[keyof GetHealthErrors]
export type GetHealthResponses = {
200: HealthResponse
}
@ -465,12 +414,6 @@ export type GetVersionData = {
url: '/_version'
}
export type GetVersionErrors = {
default: ErrorBody
}
export type GetVersionError = GetVersionErrors[keyof GetVersionErrors]
export type GetVersionResponses = {
200: ServerVersionResponse
}
@ -484,12 +427,6 @@ export type GetAccountData = {
url: '/account'
}
export type GetAccountErrors = {
default: ErrorBody
}
export type GetAccountError = GetAccountErrors[keyof GetAccountErrors]
export type GetAccountResponses = {
200: AccountResponse
}
@ -506,13 +443,6 @@ export type GetAccountSessionsData = {
url: '/account/sessions'
}
export type GetAccountSessionsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAccountSessionsError = GetAccountSessionsErrors[keyof GetAccountSessionsErrors]
export type GetAccountSessionsResponses = {
200: SessionListResponse
}
@ -527,13 +457,6 @@ export type DeleteAccountSessionsSelfData = {
url: '/account/sessions/self'
}
export type DeleteAccountSessionsSelfErrors = {
default: ErrorBody
}
export type DeleteAccountSessionsSelfError
= DeleteAccountSessionsSelfErrors[keyof DeleteAccountSessionsSelfErrors]
export type DeleteAccountSessionsSelfResponses = {
200: RevokeResponse
}
@ -550,13 +473,6 @@ export type DeleteAccountSessionsBySessionIdData = {
url: '/account/sessions/{session_id}'
}
export type DeleteAccountSessionsBySessionIdErrors = {
default: ErrorBody
}
export type DeleteAccountSessionsBySessionIdError
= DeleteAccountSessionsBySessionIdErrors[keyof DeleteAccountSessionsBySessionIdErrors]
export type DeleteAccountSessionsBySessionIdResponses = {
200: RevokeResponse
}
@ -578,13 +494,6 @@ export type GetAppsData = {
url: '/apps'
}
export type GetAppsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsError = GetAppsErrors[keyof GetAppsErrors]
export type GetAppsResponses = {
200: AppListResponse
}
@ -600,13 +509,6 @@ export type GetAppsByAppIdCheckDependenciesData = {
url: '/apps/{app_id}/check-dependencies'
}
export type GetAppsByAppIdCheckDependenciesErrors = {
default: ErrorBody
}
export type GetAppsByAppIdCheckDependenciesError
= GetAppsByAppIdCheckDependenciesErrors[keyof GetAppsByAppIdCheckDependenciesErrors]
export type GetAppsByAppIdCheckDependenciesResponses = {
200: CheckDependenciesResult
}
@ -625,14 +527,6 @@ export type GetAppsByAppIdDescribeData = {
url: '/apps/{app_id}/describe'
}
export type GetAppsByAppIdDescribeErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsByAppIdDescribeError
= GetAppsByAppIdDescribeErrors[keyof GetAppsByAppIdDescribeErrors]
export type GetAppsByAppIdDescribeResponses = {
200: AppDescribeResponse
}
@ -652,13 +546,6 @@ export type GetAppsByAppIdExportData = {
url: '/apps/{app_id}/export'
}
export type GetAppsByAppIdExportErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors]
export type GetAppsByAppIdExportResponses = {
200: AppDslExportResponse
}
@ -688,7 +575,6 @@ export type PostAppsByAppIdFilesUploadErrors = {
415: {
[key: string]: unknown
}
default: ErrorBody
}
export type PostAppsByAppIdFilesUploadError
@ -730,14 +616,6 @@ export type PostAppsByAppIdFormHumanInputByFormTokenData = {
url: '/apps/{app_id}/form/human_input/{form_token}'
}
export type PostAppsByAppIdFormHumanInputByFormTokenErrors = {
422: ErrorBody
default: ErrorBody
}
export type PostAppsByAppIdFormHumanInputByFormTokenError
= PostAppsByAppIdFormHumanInputByFormTokenErrors[keyof PostAppsByAppIdFormHumanInputByFormTokenErrors]
export type PostAppsByAppIdFormHumanInputByFormTokenResponses = {
200: FormSubmitResponse
}
@ -754,12 +632,6 @@ export type PostAppsByAppIdRunData = {
url: '/apps/{app_id}/run'
}
export type PostAppsByAppIdRunErrors = {
422: ErrorBody
}
export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors]
export type PostAppsByAppIdRunResponses = {
200: {
[key: string]: unknown
@ -798,13 +670,6 @@ export type PostAppsByAppIdTasksByTaskIdStopData = {
url: '/apps/{app_id}/tasks/{task_id}/stop'
}
export type PostAppsByAppIdTasksByTaskIdStopErrors = {
default: ErrorBody
}
export type PostAppsByAppIdTasksByTaskIdStopError
= PostAppsByAppIdTasksByTaskIdStopErrors[keyof PostAppsByAppIdTasksByTaskIdStopErrors]
export type PostAppsByAppIdTasksByTaskIdStopResponses = {
200: TaskStopResponse
}
@ -898,14 +763,6 @@ export type GetPermittedExternalAppsData = {
url: '/permitted-external-apps'
}
export type GetPermittedExternalAppsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetPermittedExternalAppsError
= GetPermittedExternalAppsErrors[keyof GetPermittedExternalAppsErrors]
export type GetPermittedExternalAppsResponses = {
200: PermittedExternalAppsListResponse
}
@ -920,12 +777,6 @@ export type GetWorkspacesData = {
url: '/workspaces'
}
export type GetWorkspacesErrors = {
default: ErrorBody
}
export type GetWorkspacesError = GetWorkspacesErrors[keyof GetWorkspacesErrors]
export type GetWorkspacesResponses = {
200: WorkspaceListResponse
}
@ -941,13 +792,6 @@ export type GetWorkspacesByWorkspaceIdData = {
url: '/workspaces/{workspace_id}'
}
export type GetWorkspacesByWorkspaceIdErrors = {
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdError
= GetWorkspacesByWorkspaceIdErrors[keyof GetWorkspacesByWorkspaceIdErrors]
export type GetWorkspacesByWorkspaceIdResponses = {
200: WorkspaceDetailResponse
}
@ -966,8 +810,6 @@ export type PostWorkspacesByWorkspaceIdAppsImportsData = {
export type PostWorkspacesByWorkspaceIdAppsImportsErrors = {
400: Import
422: ErrorBody
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdAppsImportsError
@ -993,7 +835,6 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
400: Import
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError
@ -1018,14 +859,6 @@ export type GetWorkspacesByWorkspaceIdMembersData = {
url: '/workspaces/{workspace_id}/members'
}
export type GetWorkspacesByWorkspaceIdMembersErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdMembersError
= GetWorkspacesByWorkspaceIdMembersErrors[keyof GetWorkspacesByWorkspaceIdMembersErrors]
export type GetWorkspacesByWorkspaceIdMembersResponses = {
200: MemberListResponse
}
@ -1042,14 +875,6 @@ export type PostWorkspacesByWorkspaceIdMembersData = {
url: '/workspaces/{workspace_id}/members'
}
export type PostWorkspacesByWorkspaceIdMembersErrors = {
422: ErrorBody
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdMembersError
= PostWorkspacesByWorkspaceIdMembersErrors[keyof PostWorkspacesByWorkspaceIdMembersErrors]
export type PostWorkspacesByWorkspaceIdMembersResponses = {
201: MemberInviteResponse
}
@ -1067,13 +892,6 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = {
url: '/workspaces/{workspace_id}/members/{member_id}'
}
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors = {
default: ErrorBody
}
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdError
= DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors]
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
200: MemberActionResponse
}
@ -1091,14 +909,6 @@ export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = {
url: '/workspaces/{workspace_id}/members/{member_id}/role'
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors = {
422: ErrorBody
default: ErrorBody
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleError
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors]
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = {
200: MemberActionResponse
}
@ -1115,13 +925,6 @@ export type PostWorkspacesByWorkspaceIdSwitchData = {
url: '/workspaces/{workspace_id}/switch'
}
export type PostWorkspacesByWorkspaceIdSwitchErrors = {
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdSwitchError
= PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors]
export type PostWorkspacesByWorkspaceIdSwitchResponses = {
200: WorkspaceDetailResponse
}

View File

@ -156,30 +156,6 @@ export const zDevicePollRequest = z.object({
device_code: z.string(),
})
/**
* ErrorDetail
*/
export const zErrorDetail = z.object({
loc: z.array(z.unknown()).optional().default([]),
msg: z.string(),
type: z.string(),
})
/**
* ErrorBody
*
* Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
* generated client schema stays an open enum — old CLIs keep parsing when a
* future server adds a code. Formatter tests pin emitted values to the enum.
*/
export const zErrorBody = z.object({
code: z.string(),
details: z.array(zErrorDetail).nullish(),
hint: z.string().nullish(),
message: z.string(),
status: z.int(),
})
/**
* FileResponse
*/
@ -332,41 +308,6 @@ export const zMemberRoleUpdatePayload = z.object({
role: z.enum(['admin', 'normal']),
})
/**
* OpenApiErrorCode
*/
export const zOpenApiErrorCode = z.enum([
'app_unavailable',
'bad_gateway',
'bad_request',
'completion_request_error',
'conflict',
'conversation_completed',
'file_extension_blocked',
'file_too_large',
'filename_not_exists',
'forbidden',
'internal_server_error',
'invalid_param',
'member_license_exceeded',
'member_limit_exceeded',
'method_not_allowed',
'model_currently_not_support',
'no_file_uploaded',
'not_acceptable',
'not_found',
'provider_not_initialize',
'provider_quota_exceeded',
'rate_limit_error',
'request_entity_too_large',
'too_many_files',
'too_many_requests',
'unauthorized',
'unknown',
'unsupported_file_type',
'unsupported_media_type',
])
/**
* Package
*/

View File

@ -864,5 +864,49 @@ describe('MCPModal', () => {
)
})
})
// Regression: editing a provider saved with identity_mode="idp_token" must
// hydrate the toggle ON (issue: it showed off despite the persisted value).
it('hydrates the toggle ON when editing a provider with identity_mode="idp_token"', () => {
enableRefreshCapableSSO()
const mockData = {
id: 'existing-idp',
name: 'srv',
server_url: 'https://example.com/mcp',
server_identifier: 'srv-id',
icon: { content: '🔗', background: '#6366F1' },
identity_mode: 'idp_token',
} as unknown as ToolWithProvider
render(
<MCPModal {...defaultProps} data={mockData} />,
{ wrapper: createWrapper() },
)
expect(
screen.getByRole('switch', { name: 'tools.mcp.modal.forwardUserIdentity' }),
).toBeChecked()
})
it('hydrates the toggle OFF when editing a provider with identity_mode="off"', () => {
enableRefreshCapableSSO()
const mockData = {
id: 'existing-off',
name: 'srv',
server_url: 'https://example.com/mcp',
server_identifier: 'srv-id',
icon: { content: '🔗', background: '#6366F1' },
identity_mode: 'off',
} as unknown as ToolWithProvider
render(
<MCPModal {...defaultProps} data={mockData} />,
{ wrapper: createWrapper() },
)
expect(
screen.getByRole('switch', { name: 'tools.mcp.modal.forwardUserIdentity' }),
).not.toBeChecked()
})
})
})

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "إضافة وتفويض",
"mcp.modal.editTitle": "تعديل خادم MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "إعادة توجيه هوية المستخدم",
"mcp.modal.forwardUserIdentityTip": "أرسل هوية المستخدم المُتحقَّق منها عبر SSO إلى خادم MCP هذا كرمز Authorization Bearer. يتطلب Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "أرسل هوية المستخدم المُتحقَّق منها عبر SSO إلى خادم MCP هذا في ترويسة X-Dify-SSO-Token. يتطلب Dify Enterprise SSO.",
"mcp.modal.headerKey": "اسم الرأس",
"mcp.modal.headerKeyPlaceholder": "على سبيل المثال، Authorization",
"mcp.modal.headerValue": "قيمة الرأس",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Hinzufügen & Autorisieren",
"mcp.modal.editTitle": "MCP-Server bearbeiten (HTTP)",
"mcp.modal.forwardUserIdentity": "Benutzeridentität weiterleiten",
"mcp.modal.forwardUserIdentityTip": "Sendet die verifizierte SSO-Identität des aufrufenden Benutzers als Authorization-Bearer-Token an diesen MCP-Server. Erfordert Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Sendet die verifizierte SSO-Identität des aufrufenden Benutzers im X-Dify-SSO-Token-Header an diesen MCP-Server. Erfordert Dify Enterprise SSO.",
"mcp.modal.headerKey": "Kopfzeilenname",
"mcp.modal.headerKeyPlaceholder": "z.B., Autorisierung",
"mcp.modal.headerValue": "Header-Wert",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Add & Authorize",
"mcp.modal.editTitle": "Edit MCP Server (HTTP)",
"mcp.modal.forwardUserIdentity": "Forward user identity",
"mcp.modal.forwardUserIdentityTip": "Send the calling user's verified SSO identity to this MCP server as an Authorization Bearer token. Requires Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Forward the calling user's verified SSO identity to this MCP server in the X-Dify-SSO-Token header. Requires Dify Enterprise SSO.",
"mcp.modal.headerKey": "Header Name",
"mcp.modal.headerKeyPlaceholder": "e.g., Authorization",
"mcp.modal.headerValue": "Header Value",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Añadir y Autorizar",
"mcp.modal.editTitle": "Editar servidor MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Reenviar identidad del usuario",
"mcp.modal.forwardUserIdentityTip": "Envía la identidad SSO verificada del usuario que realiza la llamada a este servidor MCP como un token Authorization Bearer. Requiere Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Envía la identidad SSO verificada del usuario que realiza la llamada a este servidor MCP en el encabezado X-Dify-SSO-Token. Requiere Dify Enterprise SSO.",
"mcp.modal.headerKey": "Nombre del encabezado",
"mcp.modal.headerKeyPlaceholder": "por ejemplo, Autorización",
"mcp.modal.headerValue": "Valor del encabezado",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "افزودن و مجوزدهی",
"mcp.modal.editTitle": "ویرایش سرور MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "ارسال هویت کاربر",
"mcp.modal.forwardUserIdentityTip": "هویت SSO تأییدشدهٔ کاربر فراخواننده را به‌عنوان توکن Authorization Bearer به این سرور MCP ارسال می‌کند. به Dify Enterprise SSO نیاز دارد.",
"mcp.modal.forwardUserIdentityTip": "هویت SSO تأییدشدهٔ کاربر فراخواننده را در هدر X-Dify-SSO-Token به این سرور MCP ارسال می‌کند. به Dify Enterprise SSO نیاز دارد.",
"mcp.modal.headerKey": "نام هدر",
"mcp.modal.headerKeyPlaceholder": "Authorization",
"mcp.modal.headerValue": "مقدار هدر",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Ajouter & Authoriser",
"mcp.modal.editTitle": "Modifier le Serveur MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Transférer l'identité de l'utilisateur",
"mcp.modal.forwardUserIdentityTip": "Envoie l'identité SSO vérifiée de l'utilisateur appelant à ce serveur MCP en tant que jeton Authorization Bearer. Nécessite Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Envoie l'identité SSO vérifiée de l'utilisateur appelant à ce serveur MCP dans l'en-tête X-Dify-SSO-Token. Nécessite Dify Enterprise SSO.",
"mcp.modal.headerKey": "Nom de l'en-tête",
"mcp.modal.headerKeyPlaceholder": "par exemple, Autorisation",
"mcp.modal.headerValue": "Valeur d'en-tête",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "जोड़ें और अधिकृत करें",
"mcp.modal.editTitle": "MCP सर्वर संपादित करें (HTTP)",
"mcp.modal.forwardUserIdentity": "उपयोगकर्ता पहचान अग्रेषित करें",
"mcp.modal.forwardUserIdentityTip": "कॉल करने वाले उपयोगकर्ता की सत्यापित SSO पहचान को इस MCP सर्वर पर Authorization Bearer टोकन के रूप में भेजें। Dify Enterprise SSO आवश्यक है।",
"mcp.modal.forwardUserIdentityTip": "कॉल करने वाले उपयोगकर्ता की सत्यापित SSO पहचान को इस MCP सर्वर पर X-Dify-SSO-Token हेडर में भेजें। Dify Enterprise SSO आवश्यक है।",
"mcp.modal.headerKey": "हेडर नाम",
"mcp.modal.headerKeyPlaceholder": "उदाहरण के लिए, प्राधिकरण",
"mcp.modal.headerValue": "हेडर मान",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Tambahkan & Otorisasi",
"mcp.modal.editTitle": "Edit Server MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Teruskan identitas pengguna",
"mcp.modal.forwardUserIdentityTip": "Mengirim identitas SSO terverifikasi pengguna pemanggil ke server MCP ini sebagai token Authorization Bearer. Membutuhkan Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Mengirim identitas SSO terverifikasi pengguna pemanggil ke server MCP ini di header X-Dify-SSO-Token. Membutuhkan Dify Enterprise SSO.",
"mcp.modal.headerKey": "Nama Header",
"mcp.modal.headerKeyPlaceholder": "Authorization",
"mcp.modal.headerValue": "Nilai Header",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Aggiungi & Autorizza",
"mcp.modal.editTitle": "Modifica Server MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Inoltra identità utente",
"mcp.modal.forwardUserIdentityTip": "Invia l'identità SSO verificata dell'utente chiamante a questo server MCP come token Authorization Bearer. Richiede Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Invia l'identità SSO verificata dell'utente chiamante a questo server MCP nell'intestazione X-Dify-SSO-Token. Richiede Dify Enterprise SSO.",
"mcp.modal.headerKey": "Nome intestazione",
"mcp.modal.headerKeyPlaceholder": "ad es., Autorizzazione",
"mcp.modal.headerValue": "Valore dell'intestazione",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "追加して承認",
"mcp.modal.editTitle": "MCP サーバーHTTPを編集",
"mcp.modal.forwardUserIdentity": "ユーザー ID を転送",
"mcp.modal.forwardUserIdentityTip": "呼び出し元ユーザーの検証済み SSO ID を Authorization Bearer トークンとしてこの MCP サーバーに送信します。Dify Enterprise SSO が必要です。",
"mcp.modal.forwardUserIdentityTip": "呼び出し元ユーザーの検証済み SSO ID を X-Dify-SSO-Token ヘッダーでこの MCP サーバーに送信します。Dify Enterprise SSO が必要です。",
"mcp.modal.headerKey": "ヘッダー名",
"mcp.modal.headerKeyPlaceholder": "例えば、承認",
"mcp.modal.headerValue": "ヘッダーの値",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "추가 및 승인",
"mcp.modal.editTitle": "MCP 서버 수정 (HTTP)",
"mcp.modal.forwardUserIdentity": "사용자 ID 전달",
"mcp.modal.forwardUserIdentityTip": "호출한 사용자의 검증된 SSO ID를 Authorization Bearer 토큰으로 이 MCP 서버에 전송합니다. Dify Enterprise SSO가 필요합니다.",
"mcp.modal.forwardUserIdentityTip": "호출한 사용자의 검증된 SSO ID를 X-Dify-SSO-Token 헤더로 이 MCP 서버에 전송합니다. Dify Enterprise SSO가 필요합니다.",
"mcp.modal.headerKey": "헤더 이름",
"mcp.modal.headerKeyPlaceholder": "예: 승인",
"mcp.modal.headerValue": "헤더 값",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Add & Authorize",
"mcp.modal.editTitle": "Edit MCP Server (HTTP)",
"mcp.modal.forwardUserIdentity": "Gebruikersidentiteit doorsturen",
"mcp.modal.forwardUserIdentityTip": "Stuur de geverifieerde SSO-identiteit van de aanroepende gebruiker als een Authorization Bearer-token naar deze MCP-server. Vereist Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Stuur de geverifieerde SSO-identiteit van de aanroepende gebruiker in de X-Dify-SSO-Token-header naar deze MCP-server. Vereist Dify Enterprise SSO.",
"mcp.modal.headerKey": "Header Name",
"mcp.modal.headerKeyPlaceholder": "e.g., Authorization",
"mcp.modal.headerValue": "Header Value",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Dodaj i autoryzuj",
"mcp.modal.editTitle": "Edytuj serwer MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Przekaż tożsamość użytkownika",
"mcp.modal.forwardUserIdentityTip": "Wysyła zweryfikowaną tożsamość SSO wywołującego użytkownika do tego serwera MCP jako token Authorization Bearer. Wymaga Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Wysyła zweryfikowaną tożsamość SSO wywołującego użytkownika do tego serwera MCP w nagłówku X-Dify-SSO-Token. Wymaga Dify Enterprise SSO.",
"mcp.modal.headerKey": "Nazwa nagłówka",
"mcp.modal.headerKeyPlaceholder": "np. Autoryzacja",
"mcp.modal.headerValue": "Wartość nagłówka",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Adicionar e Autorizar",
"mcp.modal.editTitle": "Editar Servidor MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Encaminhar identidade do usuário",
"mcp.modal.forwardUserIdentityTip": "Envia a identidade SSO verificada do usuário chamador para este servidor MCP como um token Authorization Bearer. Requer Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Envia a identidade SSO verificada do usuário chamador para este servidor MCP no cabeçalho X-Dify-SSO-Token. Requer Dify Enterprise SSO.",
"mcp.modal.headerKey": "Nome do Cabeçalho",
"mcp.modal.headerKeyPlaceholder": "por exemplo, Autorização",
"mcp.modal.headerValue": "Valor do Cabeçalho",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Adăugare și Autorizare",
"mcp.modal.editTitle": "Editare Server MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Redirecționează identitatea utilizatorului",
"mcp.modal.forwardUserIdentityTip": "Trimite identitatea SSO verificată a utilizatorului apelant către acest server MCP ca un token Authorization Bearer. Necesită Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Trimite identitatea SSO verificată a utilizatorului apelant către acest server MCP în antetul X-Dify-SSO-Token. Necesită Dify Enterprise SSO.",
"mcp.modal.headerKey": "Numele antetului",
"mcp.modal.headerKeyPlaceholder": "de exemplu, Autorizație",
"mcp.modal.headerValue": "Valoare Antet",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Добавить и авторизовать",
"mcp.modal.editTitle": "Редактировать MCP сервер (HTTP)",
"mcp.modal.forwardUserIdentity": "Передавать идентификацию пользователя",
"mcp.modal.forwardUserIdentityTip": "Отправляет подтверждённую SSO-идентификацию вызывающего пользователя на этот MCP сервер в качестве токена Authorization Bearer. Требуется Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Отправляет подтверждённую SSO-идентификацию вызывающего пользователя на этот MCP сервер в заголовке X-Dify-SSO-Token. Требуется Dify Enterprise SSO.",
"mcp.modal.headerKey": "Название заголовка",
"mcp.modal.headerKeyPlaceholder": "например, Авторизация",
"mcp.modal.headerValue": "Значение заголовка",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Dodaj in avtoriziraj",
"mcp.modal.editTitle": "Uredi strežnik MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Posreduj identiteto uporabnika",
"mcp.modal.forwardUserIdentityTip": "Pošlje preverjeno SSO identiteto klicajočega uporabnika temu strežniku MCP kot žeton Authorization Bearer. Zahteva Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Pošlje preverjeno SSO identiteto klicajočega uporabnika temu strežniku MCP v glavi X-Dify-SSO-Token. Zahteva Dify Enterprise SSO.",
"mcp.modal.headerKey": "Ime glave",
"mcp.modal.headerKeyPlaceholder": "npr., Authorization",
"mcp.modal.headerValue": "Vrednost glave",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "เพิ่มและอนุญาต",
"mcp.modal.editTitle": "แก้ไขเซิร์ฟเวอร์ MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "ส่งต่อข้อมูลตัวตนของผู้ใช้",
"mcp.modal.forwardUserIdentityTip": "ส่งข้อมูลตัวตน SSO ที่ผ่านการตรวจสอบของผู้ใช้ที่เรียกใช้ไปยังเซิร์ฟเวอร์ MCP นี้เป็น Authorization Bearer token ต้องใช้ Dify Enterprise SSO",
"mcp.modal.forwardUserIdentityTip": "ส่งข้อมูลตัวตน SSO ที่ผ่านการตรวจสอบของผู้ใช้ที่เรียกใช้ไปยังเซิร์ฟเวอร์ MCP นี้ในส่วนหัว X-Dify-SSO-Token ต้องใช้ Dify Enterprise SSO",
"mcp.modal.headerKey": "ชื่อหัวเรื่อง",
"mcp.modal.headerKeyPlaceholder": "เช่น การอนุญาต",
"mcp.modal.headerValue": "ค่าหัวข้อ",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Ekle ve Yetkilendir",
"mcp.modal.editTitle": "MCP Sunucusunu Düzenle (HTTP)",
"mcp.modal.forwardUserIdentity": "Kullanıcı kimliğini ilet",
"mcp.modal.forwardUserIdentityTip": "Çağıran kullanıcının doğrulanmış SSO kimliğini bu MCP sunucusuna Authorization Bearer token olarak gönderir. Dify Enterprise SSO gerektirir.",
"mcp.modal.forwardUserIdentityTip": "Çağıran kullanıcının doğrulanmış SSO kimliğini bu MCP sunucusuna X-Dify-SSO-Token başlığında gönderir. Dify Enterprise SSO gerektirir.",
"mcp.modal.headerKey": "Başlık Adı",
"mcp.modal.headerKeyPlaceholder": "örneğin, Yetkilendirme",
"mcp.modal.headerValue": "Başlık Değeri",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Додати та Авторизувати",
"mcp.modal.editTitle": "Редагувати сервер MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Передавати ідентичність користувача",
"mcp.modal.forwardUserIdentityTip": "Надсилає підтверджену SSO ідентичність користувача, що викликає, на цей сервер MCP як токен Authorization Bearer. Потребує Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Надсилає підтверджену SSO ідентичність користувача, що викликає, на цей сервер MCP у заголовку X-Dify-SSO-Token. Потребує Dify Enterprise SSO.",
"mcp.modal.headerKey": "Назва заголовка",
"mcp.modal.headerKeyPlaceholder": "наприклад, Авторизація",
"mcp.modal.headerValue": "Значення заголовка",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "Thêm & Ủy quyền",
"mcp.modal.editTitle": "Sửa Máy chủ MCP (HTTP)",
"mcp.modal.forwardUserIdentity": "Chuyển tiếp danh tính người dùng",
"mcp.modal.forwardUserIdentityTip": "Gửi danh tính SSO đã xác minh của người dùng gọi đến máy chủ MCP này dưới dạng token Authorization Bearer. Yêu cầu Dify Enterprise SSO.",
"mcp.modal.forwardUserIdentityTip": "Gửi danh tính SSO đã xác minh của người dùng gọi đến máy chủ MCP này trong header X-Dify-SSO-Token. Yêu cầu Dify Enterprise SSO.",
"mcp.modal.headerKey": "Tên tiêu đề",
"mcp.modal.headerKeyPlaceholder": "ví dụ, Ủy quyền",
"mcp.modal.headerValue": "Giá trị tiêu đề",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "添加并授权",
"mcp.modal.editTitle": "修改 MCP 服务 (HTTP)",
"mcp.modal.forwardUserIdentity": "转发用户身份",
"mcp.modal.forwardUserIdentityTip": "将调用用户的已验证 SSO 身份作为 Authorization Bearer token 转发到该 MCP 服务器。需要 Dify Enterprise SSO。",
"mcp.modal.forwardUserIdentityTip": "将调用用户的已验证 SSO 身份通过 X-Dify-SSO-Token 请求头转发到该 MCP 服务器。需要 Dify Enterprise SSO。",
"mcp.modal.headerKey": "请求头名称",
"mcp.modal.headerKeyPlaceholder": "例如Authorization",
"mcp.modal.headerValue": "请求头值",

View File

@ -121,7 +121,7 @@
"mcp.modal.confirm": "新增並授權",
"mcp.modal.editTitle": "編輯 MCP 伺服器 (HTTP)",
"mcp.modal.forwardUserIdentity": "轉發使用者身份",
"mcp.modal.forwardUserIdentityTip": "將呼叫使用者已驗證的 SSO 身份作為 Authorization Bearer token 轉發至此 MCP 伺服器。需要 Dify Enterprise SSO。",
"mcp.modal.forwardUserIdentityTip": "將呼叫使用者已驗證的 SSO 身份透過 X-Dify-SSO-Token 請求標頭轉發至此 MCP 伺服器。需要 Dify Enterprise SSO。",
"mcp.modal.headerKey": "標題名稱",
"mcp.modal.headerKeyPlaceholder": "例如,授權",
"mcp.modal.headerValue": "標題值",

View File

@ -6,7 +6,7 @@ import { renderHook } from '@testing-library/react'
* and automatic error handling when context is used outside of its provider.
*
* Two variants are provided:
* - createCtx: Standard React context using React's use/createContext
* - createCtx: Standard React context using useContext/createContext
* - createSelectorCtx: Context with selector support using use-context-selector library
*/
import * as React from 'react'

View File

@ -1,11 +1,9 @@
import type { Context, Provider } from 'react'
import { createContext, use } from 'react'
import { createContext, useContext } from 'react'
import * as selector from 'use-context-selector'
type UseContextImpl = <T>(context: Context<T>) => T
const createCreateCtxFunction = (
useContextImpl: UseContextImpl,
useContextImpl: typeof useContext,
createContextImpl: typeof createContext,
) => {
return function<T>({ name, defaultValue }: CreateCtxOptions<T> = {}): CreateCtxReturn<T> {
@ -41,9 +39,9 @@ type CreateCtxReturn<T> = [Provider<T>, () => T, Context<T>] & {
// example
// const [AppProvider, useApp, AppContext] = createCtx<AppContextValue>()
export const createCtx = createCreateCtxFunction(use, createContext)
export const createCtx = createCreateCtxFunction(useContext, createContext)
export const createSelectorCtx = createCreateCtxFunction(
selector.useContext as UseContextImpl,
selector.useContext,
selector.createContext as typeof createContext,
)