mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 03:07:57 +08:00
Compare commits
3 Commits
main
...
fix/mcp-fo
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c9ede82b | |||
| e52f3c0ea5 | |||
| f3edc4a4b7 |
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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__)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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__
|
||||
|
||||
@ -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}
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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": "قيمة الرأس",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "مقدار هدر",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "हेडर मान",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ヘッダーの値",
|
||||
|
||||
@ -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": "헤더 값",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Значение заголовка",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ค่าหัวข้อ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Значення заголовка",
|
||||
|
||||
@ -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 đề",
|
||||
|
||||
@ -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": "请求头值",
|
||||
|
||||
@ -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": "標題值",
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user