mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 19:50:06 -04:00
fix(agent): use internal URLs for stub file downloads
This commit is contained in:
@@ -363,7 +363,10 @@ class FileAccessConfig(BaseSettings):
|
||||
INTERNAL_FILES_URL: str = Field(
|
||||
description="Internal base URL for file access within Docker network,"
|
||||
" used for plugin daemon and internal service communication."
|
||||
" Falls back to FILES_URL if not specified.",
|
||||
" Explicit INTERNAL_FILES_URL takes precedence; otherwise SERVER_CONSOLE_API_URL is used,"
|
||||
" then FILES_URL.",
|
||||
validation_alias=AliasChoices("INTERNAL_FILES_URL", "SERVER_CONSOLE_API_URL"),
|
||||
alias_priority=1,
|
||||
default="",
|
||||
)
|
||||
|
||||
|
||||
@@ -476,6 +476,7 @@ class PluginDownloadFileRequestApi(Resource):
|
||||
user_from=payload.user_from,
|
||||
invoke_from=payload.invoke_from,
|
||||
file_mapping=payload.file.model_dump(mode="python", exclude_none=True),
|
||||
for_external=payload.for_external,
|
||||
)
|
||||
return BaseBackwardsInvocationResponse(
|
||||
data={
|
||||
|
||||
@@ -276,6 +276,7 @@ class RequestRequestDownloadFile(BaseModel):
|
||||
"validation",
|
||||
]
|
||||
file: RequestDownloadFileMapping
|
||||
for_external: bool = True
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class FileRequestService:
|
||||
user_from: UserFrom | str,
|
||||
invoke_from: InvokeFrom | str,
|
||||
file_mapping: Mapping[str, Any],
|
||||
for_external: bool = True,
|
||||
) -> DownloadFileRequestResult:
|
||||
"""Resolve one file mapping into signed download metadata.
|
||||
|
||||
@@ -61,7 +62,7 @@ class FileRequestService:
|
||||
)
|
||||
with bind_file_access_scope(scope):
|
||||
file = self._build_file(mapping=file_mapping, tenant_id=tenant_id)
|
||||
download_url = file_helpers.resolve_file_url(file, for_external=True)
|
||||
download_url = file_helpers.resolve_file_url(file, for_external=for_external)
|
||||
|
||||
if not download_url:
|
||||
raise ValueError("file does not support signed download")
|
||||
|
||||
@@ -111,6 +111,25 @@ def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
|
||||
assert config.HTTP_REQUEST_MAX_WRITE_TIMEOUT == 600
|
||||
|
||||
|
||||
def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.INTERNAL_FILES_URL == "http://api:5001"
|
||||
|
||||
|
||||
def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
monkeypatch.setenv("INTERNAL_FILES_URL", "http://files-internal:5001")
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.INTERNAL_FILES_URL == "http://files-internal:5001"
|
||||
|
||||
|
||||
# NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected.
|
||||
# This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`.
|
||||
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@@ -318,6 +318,7 @@ class TestPluginDownloadFileRequestApi:
|
||||
mock_payload.user_id = "user-id"
|
||||
mock_payload.user_from = "account"
|
||||
mock_payload.invoke_from = "debugger"
|
||||
mock_payload.for_external = False
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
mock_payload.file.model_dump.return_value = {
|
||||
"transfer_method": "tool_file",
|
||||
@@ -333,6 +334,7 @@ class TestPluginDownloadFileRequestApi:
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": reference},
|
||||
for_external=False,
|
||||
)
|
||||
assert result["data"] == {
|
||||
"filename": "report.pdf",
|
||||
|
||||
@@ -22,6 +22,7 @@ def test_request_download_file_accepts_tool_file_reference() -> None:
|
||||
|
||||
assert payload.file.transfer_method == "tool_file"
|
||||
assert payload.file.reference == reference
|
||||
assert payload.for_external is True
|
||||
|
||||
|
||||
def test_request_download_file_accepts_remote_url() -> None:
|
||||
@@ -42,6 +43,25 @@ def test_request_download_file_accepts_remote_url() -> None:
|
||||
assert payload.file.url == "https://example.com/report.pdf"
|
||||
|
||||
|
||||
def test_request_download_file_accepts_internal_download_request() -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
payload = RequestRequestDownloadFile.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {
|
||||
"transfer_method": "tool_file",
|
||||
"reference": reference,
|
||||
},
|
||||
"for_external": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.for_external is False
|
||||
|
||||
|
||||
def test_request_download_file_rejects_remote_url_without_url() -> None:
|
||||
with pytest.raises(ValidationError, match="url is required"):
|
||||
_ = RequestRequestDownloadFile.model_validate(
|
||||
|
||||
@@ -59,6 +59,31 @@ def test_request_download_url_builds_file_under_bound_scope(
|
||||
assert result.download_url == "https://files.example.com/x"
|
||||
|
||||
|
||||
def test_request_download_url_supports_internal_download_urls() -> None:
|
||||
fake_file = MagicMock(filename="report.pdf", mime_type="application/pdf", size=123)
|
||||
service = FileRequestService(access_controller=MagicMock())
|
||||
|
||||
with (
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()),
|
||||
patch.object(service, "_build_file", return_value=fake_file),
|
||||
patch(
|
||||
"services.file_request_service.file_helpers.resolve_file_url",
|
||||
return_value="http://internal-files/report.pdf",
|
||||
) as resolve_file_url,
|
||||
):
|
||||
result = service.request_download_url(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:tool-file-1"},
|
||||
for_external=False,
|
||||
)
|
||||
|
||||
resolve_file_url.assert_called_once_with(fake_file, for_external=False)
|
||||
assert result.download_url == "http://internal-files/report.pdf"
|
||||
|
||||
|
||||
def test_request_download_url_rejects_unsupported_files() -> None:
|
||||
service = FileRequestService(access_controller=MagicMock())
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ message FileMapping {
|
||||
|
||||
message FileDownloadRequest {
|
||||
FileMapping file = 1;
|
||||
optional bool for_external = 2;
|
||||
}
|
||||
|
||||
message FileDownloadResponse {
|
||||
|
||||
@@ -143,6 +143,7 @@ def download_file_from_environment(
|
||||
url=environment.url,
|
||||
auth_jwe=environment.auth_jwe,
|
||||
file=file_mapping,
|
||||
for_external=False,
|
||||
)
|
||||
if not hasattr(download_request, "filename") or not isinstance(download_request.filename, str):
|
||||
raise AgentStubTransferError("signed file download response is missing filename")
|
||||
@@ -207,8 +208,10 @@ def _request_uploaded_tool_file_download_url(*, url: str, auth_jwe: str, referen
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=reference),
|
||||
),
|
||||
)
|
||||
if not hasattr(download_request, "download_url") or not isinstance(download_request.download_url, str):
|
||||
raise AgentStubTransferError("signed file download response is missing download_url")
|
||||
download_url = download_request.download_url
|
||||
if not isinstance(download_url, str) or not download_url:
|
||||
if not download_url:
|
||||
raise AgentStubTransferError("signed file download response is missing download_url")
|
||||
return download_url
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ def request_agent_stub_file_download_sync(
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
):
|
||||
@@ -109,12 +110,14 @@ def request_agent_stub_file_download_sync(
|
||||
url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
file=file,
|
||||
for_external=for_external,
|
||||
timeout=timeout,
|
||||
)
|
||||
return request_agent_stub_file_download_http_sync(
|
||||
base_url=endpoint.url,
|
||||
auth_jwe=auth_jwe,
|
||||
file=file,
|
||||
for_external=for_external,
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
|
||||
@@ -124,6 +124,7 @@ def request_agent_stub_file_download_grpc_sync(
|
||||
url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
):
|
||||
"""Request one signed download URL through the gRPC Agent Stub endpoint.
|
||||
@@ -144,7 +145,7 @@ def request_agent_stub_file_download_grpc_sync(
|
||||
auth_jwe=auth_jwe,
|
||||
method_name="CreateFileDownloadRequest",
|
||||
request_factory=lambda runtime: _require_conversions().proto_file_download_request(
|
||||
runtime.agent_stub_pb2, file=file
|
||||
runtime.agent_stub_pb2, file=file, for_external=for_external
|
||||
),
|
||||
response_parser=lambda response: _require_conversions().file_download_response_from_proto(response),
|
||||
timeout=timeout,
|
||||
|
||||
@@ -117,13 +117,14 @@ def request_agent_stub_file_download_http_sync(
|
||||
base_url: str,
|
||||
auth_jwe: str,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
timeout: float | httpx.Timeout = 30.0,
|
||||
sync_http_client: httpx.Client | None = None,
|
||||
) -> AgentStubFileDownloadResponse:
|
||||
"""Request one signed download URL from the HTTP Agent Stub endpoint."""
|
||||
|
||||
try:
|
||||
request_model = AgentStubFileDownloadRequest(file=file)
|
||||
request_model = AgentStubFileDownloadRequest(file=file, for_external=for_external)
|
||||
except ValidationError as exc:
|
||||
raise AgentStubValidationError("invalid Agent Stub file download request") from exc
|
||||
response = _post_agent_stub_json(
|
||||
@@ -131,7 +132,7 @@ def request_agent_stub_file_download_http_sync(
|
||||
auth_jwe=auth_jwe,
|
||||
endpoint_name="file download request",
|
||||
endpoint_url_factory=agent_stub_file_download_request_url,
|
||||
request_body=request_model.model_dump_json(exclude_none=True),
|
||||
request_body=request_model.model_dump_json(exclude_none=True, exclude_defaults=True),
|
||||
timeout=timeout,
|
||||
sync_http_client=sync_http_client,
|
||||
)
|
||||
|
||||
@@ -1,52 +1,50 @@
|
||||
# pyright: reportAttributeAccessIssue=false
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: dify/agent/stub/v1/agent_stub.proto
|
||||
# Protobuf Python Version: 6.33.5
|
||||
"""Generated protocol buffer code."""
|
||||
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
33,
|
||||
5,
|
||||
"",
|
||||
"dify/agent/stub/v1/agent_stub.proto",
|
||||
'',
|
||||
'dify/agent/stub/v1/agent_stub.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
|
||||
b'\n#dify/agent/stub/v1/agent_stub.proto\x12\x12\x64ify.agent.stub.v1"O\n\x0e\x43onnectRequest\x12\x18\n\x10protocol_version\x18\x01 \x01(\x05\x12\x0c\n\x04\x61rgv\x18\x02 \x03(\t\x12\x15\n\rmetadata_json\x18\x03 \x01(\t"8\n\x0f\x43onnectResponse\x12\x15\n\rconnection_id\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t"7\n\x11\x46ileUploadRequest\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x10\n\x08mimetype\x18\x02 \x01(\t"(\n\x12\x46ileUploadResponse\x12\x12\n\nupload_url\x18\x01 \x01(\t"f\n\x0b\x46ileMapping\x12\x17\n\x0ftransfer_method\x18\x01 \x01(\t\x12\x16\n\treference\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x03url\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_referenceB\x06\n\x04_url"D\n\x13\x46ileDownloadRequest\x12-\n\x04\x66ile\x18\x01 \x01(\x0b\x32\x1f.dify.agent.stub.v1.FileMapping"r\n\x14\x46ileDownloadResponse\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x16\n\tmime_type\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x14\n\x0c\x64ownload_url\x18\x04 \x01(\tB\x0c\n\n_mime_type2\xc0\x02\n\x10\x41gentStubService\x12R\n\x07\x43onnect\x12".dify.agent.stub.v1.ConnectRequest\x1a#.dify.agent.stub.v1.ConnectResponse\x12h\n\x17\x43reateFileUploadRequest\x12%.dify.agent.stub.v1.FileUploadRequest\x1a&.dify.agent.stub.v1.FileUploadResponse\x12n\n\x19\x43reateFileDownloadRequest\x12\'.dify.agent.stub.v1.FileDownloadRequest\x1a(.dify.agent.stub.v1.FileDownloadResponseb\x06proto3'
|
||||
)
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#dify/agent/stub/v1/agent_stub.proto\x12\x12\x64ify.agent.stub.v1\"O\n\x0e\x43onnectRequest\x12\x18\n\x10protocol_version\x18\x01 \x01(\x05\x12\x0c\n\x04\x61rgv\x18\x02 \x03(\t\x12\x15\n\rmetadata_json\x18\x03 \x01(\t\"8\n\x0f\x43onnectResponse\x12\x15\n\rconnection_id\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t\"7\n\x11\x46ileUploadRequest\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x10\n\x08mimetype\x18\x02 \x01(\t\"(\n\x12\x46ileUploadResponse\x12\x12\n\nupload_url\x18\x01 \x01(\t\"f\n\x0b\x46ileMapping\x12\x17\n\x0ftransfer_method\x18\x01 \x01(\t\x12\x16\n\treference\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x03url\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_referenceB\x06\n\x04_url\"p\n\x13\x46ileDownloadRequest\x12-\n\x04\x66ile\x18\x01 \x01(\x0b\x32\x1f.dify.agent.stub.v1.FileMapping\x12\x19\n\x0c\x66or_external\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\x0f\n\r_for_external\"r\n\x14\x46ileDownloadResponse\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x16\n\tmime_type\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x0c\n\x04size\x18\x03 \x01(\x03\x12\x14\n\x0c\x64ownload_url\x18\x04 \x01(\tB\x0c\n\n_mime_type2\xc0\x02\n\x10\x41gentStubService\x12R\n\x07\x43onnect\x12\".dify.agent.stub.v1.ConnectRequest\x1a#.dify.agent.stub.v1.ConnectResponse\x12h\n\x17\x43reateFileUploadRequest\x12%.dify.agent.stub.v1.FileUploadRequest\x1a&.dify.agent.stub.v1.FileUploadResponse\x12n\n\x19\x43reateFileDownloadRequest\x12\'.dify.agent.stub.v1.FileDownloadRequest\x1a(.dify.agent.stub.v1.FileDownloadResponseb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "dify_agent.agent_stub.grpc._generated.agent_stub_pb2", _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'dify.agent.stub.v1.agent_stub_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals["_CONNECTREQUEST"]._serialized_start = 59
|
||||
_globals["_CONNECTREQUEST"]._serialized_end = 138
|
||||
_globals["_CONNECTRESPONSE"]._serialized_start = 140
|
||||
_globals["_CONNECTRESPONSE"]._serialized_end = 196
|
||||
_globals["_FILEUPLOADREQUEST"]._serialized_start = 198
|
||||
_globals["_FILEUPLOADREQUEST"]._serialized_end = 253
|
||||
_globals["_FILEUPLOADRESPONSE"]._serialized_start = 255
|
||||
_globals["_FILEUPLOADRESPONSE"]._serialized_end = 295
|
||||
_globals["_FILEMAPPING"]._serialized_start = 297
|
||||
_globals["_FILEMAPPING"]._serialized_end = 399
|
||||
_globals["_FILEDOWNLOADREQUEST"]._serialized_start = 401
|
||||
_globals["_FILEDOWNLOADREQUEST"]._serialized_end = 469
|
||||
_globals["_FILEDOWNLOADRESPONSE"]._serialized_start = 471
|
||||
_globals["_FILEDOWNLOADRESPONSE"]._serialized_end = 585
|
||||
_globals["_AGENTSTUBSERVICE"]._serialized_start = 588
|
||||
_globals["_AGENTSTUBSERVICE"]._serialized_end = 908
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_CONNECTREQUEST']._serialized_start=59
|
||||
_globals['_CONNECTREQUEST']._serialized_end=138
|
||||
_globals['_CONNECTRESPONSE']._serialized_start=140
|
||||
_globals['_CONNECTRESPONSE']._serialized_end=196
|
||||
_globals['_FILEUPLOADREQUEST']._serialized_start=198
|
||||
_globals['_FILEUPLOADREQUEST']._serialized_end=253
|
||||
_globals['_FILEUPLOADRESPONSE']._serialized_start=255
|
||||
_globals['_FILEUPLOADRESPONSE']._serialized_end=295
|
||||
_globals['_FILEMAPPING']._serialized_start=297
|
||||
_globals['_FILEMAPPING']._serialized_end=399
|
||||
_globals['_FILEDOWNLOADREQUEST']._serialized_start=401
|
||||
_globals['_FILEDOWNLOADREQUEST']._serialized_end=513
|
||||
_globals['_FILEDOWNLOADRESPONSE']._serialized_start=515
|
||||
_globals['_FILEDOWNLOADRESPONSE']._serialized_end=629
|
||||
_globals['_AGENTSTUBSERVICE']._serialized_start=632
|
||||
_globals['_AGENTSTUBSERVICE']._serialized_end=952
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -1,71 +1,69 @@
|
||||
from __future__ import annotations
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
from collections.abc import Iterable
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
from google.protobuf.message import Message
|
||||
|
||||
|
||||
class ConnectRequest(Message):
|
||||
class ConnectRequest(_message.Message):
|
||||
__slots__ = ("protocol_version", "argv", "metadata_json")
|
||||
PROTOCOL_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
ARGV_FIELD_NUMBER: _ClassVar[int]
|
||||
METADATA_JSON_FIELD_NUMBER: _ClassVar[int]
|
||||
protocol_version: int
|
||||
argv: list[str]
|
||||
argv: _containers.RepeatedScalarFieldContainer[str]
|
||||
metadata_json: str
|
||||
def __init__(self, protocol_version: _Optional[int] = ..., argv: _Optional[_Iterable[str]] = ..., metadata_json: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
protocol_version: int = ...,
|
||||
argv: Iterable[str] = ...,
|
||||
metadata_json: str = ...,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class ConnectResponse(Message):
|
||||
class ConnectResponse(_message.Message):
|
||||
__slots__ = ("connection_id", "status")
|
||||
CONNECTION_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
STATUS_FIELD_NUMBER: _ClassVar[int]
|
||||
connection_id: str
|
||||
status: str
|
||||
def __init__(self, connection_id: _Optional[str] = ..., status: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, connection_id: str = ..., status: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileUploadRequest(Message):
|
||||
class FileUploadRequest(_message.Message):
|
||||
__slots__ = ("filename", "mimetype")
|
||||
FILENAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MIMETYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
filename: str
|
||||
mimetype: str
|
||||
def __init__(self, filename: _Optional[str] = ..., mimetype: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, filename: str = ..., mimetype: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileUploadResponse(Message):
|
||||
class FileUploadResponse(_message.Message):
|
||||
__slots__ = ("upload_url",)
|
||||
UPLOAD_URL_FIELD_NUMBER: _ClassVar[int]
|
||||
upload_url: str
|
||||
def __init__(self, upload_url: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, upload_url: str = ...) -> None: ...
|
||||
|
||||
|
||||
class FileMapping(Message):
|
||||
class FileMapping(_message.Message):
|
||||
__slots__ = ("transfer_method", "reference", "url")
|
||||
TRANSFER_METHOD_FIELD_NUMBER: _ClassVar[int]
|
||||
REFERENCE_FIELD_NUMBER: _ClassVar[int]
|
||||
URL_FIELD_NUMBER: _ClassVar[int]
|
||||
transfer_method: str
|
||||
reference: str
|
||||
url: str
|
||||
def __init__(self, transfer_method: _Optional[str] = ..., reference: _Optional[str] = ..., url: _Optional[str] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, transfer_method: str = ..., reference: str = ..., url: str = ...) -> None: ...
|
||||
def HasField(self, field_name: str) -> bool: ...
|
||||
|
||||
|
||||
class FileDownloadRequest(Message):
|
||||
class FileDownloadRequest(_message.Message):
|
||||
__slots__ = ("file", "for_external")
|
||||
FILE_FIELD_NUMBER: _ClassVar[int]
|
||||
FOR_EXTERNAL_FIELD_NUMBER: _ClassVar[int]
|
||||
file: FileMapping
|
||||
for_external: bool
|
||||
def __init__(self, file: _Optional[_Union[FileMapping, _Mapping]] = ..., for_external: _Optional[bool] = ...) -> None: ...
|
||||
|
||||
def __init__(self, *, file: FileMapping | None = ...) -> None: ...
|
||||
|
||||
|
||||
class FileDownloadResponse(Message):
|
||||
class FileDownloadResponse(_message.Message):
|
||||
__slots__ = ("filename", "mime_type", "size", "download_url")
|
||||
FILENAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MIME_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
SIZE_FIELD_NUMBER: _ClassVar[int]
|
||||
DOWNLOAD_URL_FIELD_NUMBER: _ClassVar[int]
|
||||
filename: str
|
||||
mime_type: str
|
||||
size: int
|
||||
download_url: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
filename: str = ...,
|
||||
mime_type: str = ...,
|
||||
size: int = ...,
|
||||
download_url: str = ...,
|
||||
) -> None: ...
|
||||
def HasField(self, field_name: str) -> bool: ...
|
||||
def __init__(self, filename: _Optional[str] = ..., mime_type: _Optional[str] = ..., size: _Optional[int] = ..., download_url: _Optional[str] = ...) -> None: ...
|
||||
|
||||
@@ -98,13 +98,19 @@ def file_download_request_from_proto(message: agent_stub_pb2.FileDownloadRequest
|
||||
"reference": message.file.reference if message.file.HasField("reference") else None,
|
||||
"url": message.file.url if message.file.HasField("url") else None,
|
||||
}
|
||||
return AgentStubFileDownloadRequest.model_validate({"file": file_mapping_kwargs})
|
||||
return AgentStubFileDownloadRequest.model_validate(
|
||||
{
|
||||
"file": file_mapping_kwargs,
|
||||
"for_external": message.for_external if message.HasField("for_external") else True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def proto_file_download_request(
|
||||
pb2_module,
|
||||
*,
|
||||
file: AgentStubFileMapping,
|
||||
for_external: bool = True,
|
||||
) -> agent_stub_pb2.FileDownloadRequest:
|
||||
"""Build one protobuf file-download request from the public DTO."""
|
||||
mapping = pb2_module.FileMapping(transfer_method=file.transfer_method)
|
||||
@@ -112,7 +118,9 @@ def proto_file_download_request(
|
||||
mapping.reference = file.reference
|
||||
if file.url is not None:
|
||||
mapping.url = file.url
|
||||
return pb2_module.FileDownloadRequest(file=mapping)
|
||||
request = pb2_module.FileDownloadRequest(file=mapping)
|
||||
request.for_external = for_external
|
||||
return request
|
||||
|
||||
|
||||
def file_download_response_from_proto(message: agent_stub_pb2.FileDownloadResponse) -> AgentStubFileDownloadResponse:
|
||||
|
||||
@@ -249,6 +249,7 @@ class AgentStubFileDownloadRequest(BaseModel):
|
||||
"""Request body for one signed download URL allocation."""
|
||||
|
||||
file: AgentStubFileMapping
|
||||
for_external: bool = True
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -161,6 +161,8 @@ class DifyApiAgentStubFileRequestHandler:
|
||||
"invoke_from": execution_context.invoke_from,
|
||||
"file": request.file.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
if request.for_external is False:
|
||||
payload["for_external"] = False
|
||||
data = await self._post_inner_api("/inner/api/download/file/request", payload)
|
||||
try:
|
||||
return AgentStubFileDownloadResponse.model_validate(data)
|
||||
|
||||
@@ -53,6 +53,7 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output(
|
||||
|
||||
def fake_request_agent_stub_file_download_sync(**kwargs):
|
||||
captured_download_request["file"] = kwargs["file"]
|
||||
captured_download_request["for_external"] = kwargs.get("for_external", True)
|
||||
return type(
|
||||
"Response",
|
||||
(),
|
||||
@@ -85,6 +86,7 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
)
|
||||
assert captured_download_request["for_external"] is True
|
||||
|
||||
|
||||
def test_upload_tool_file_resource_from_environment_preserves_tool_file_id(
|
||||
@@ -256,6 +258,7 @@ def test_download_file_from_environment_supports_mapping_json(
|
||||
|
||||
def fake_request_download(**kwargs):
|
||||
captured["file"] = kwargs["file"]
|
||||
captured["for_external"] = kwargs["for_external"]
|
||||
return type(
|
||||
"Response",
|
||||
(),
|
||||
@@ -283,10 +286,49 @@ def test_download_file_from_environment_supports_mapping_json(
|
||||
"reference": _reference("tool-file-1"),
|
||||
"url": None,
|
||||
}
|
||||
assert captured["for_external"] is False
|
||||
assert result.path == target_dir / "report.pdf"
|
||||
assert result.path.read_bytes() == b"downloaded"
|
||||
|
||||
|
||||
def test_download_file_from_environment_requests_internal_download_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target_dir = tmp_path / "downloads"
|
||||
target_dir.mkdir()
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub")
|
||||
monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_request_download(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 12,
|
||||
"download_url": "http://internal-files/report.pdf",
|
||||
},
|
||||
)()
|
||||
|
||||
monkeypatch.setattr("dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync", fake_request_download)
|
||||
monkeypatch.setattr(
|
||||
"dify_agent.agent_stub.cli._files.download_file_bytes_from_signed_url_sync",
|
||||
lambda **_kwargs: b"downloaded",
|
||||
)
|
||||
|
||||
_ = download_file_from_environment(
|
||||
transfer_method="tool_file",
|
||||
reference_or_url=_reference("tool-file-1"),
|
||||
local_dir=str(target_dir),
|
||||
)
|
||||
|
||||
assert captured["for_external"] is False
|
||||
|
||||
|
||||
def test_download_file_from_environment_requires_mapping_or_positional_pair() -> None:
|
||||
with pytest.raises(AgentStubValidationError, match="requires either --mapping or TRANSFER_METHOD REFERENCE_OR_URL"):
|
||||
_ = download_file_from_environment()
|
||||
|
||||
@@ -157,7 +157,7 @@ def test_request_agent_stub_file_download_sync_posts_download_request() -> None:
|
||||
assert request.method == "POST"
|
||||
assert str(request.url) == "https://agent.example.com/agent-stub/files/download-request"
|
||||
assert json.loads(request.content) == {
|
||||
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")}
|
||||
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")},
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
@@ -183,6 +183,39 @@ def test_request_agent_stub_file_download_sync_posts_download_request() -> None:
|
||||
assert response.download_url == "https://files.example.com/download"
|
||||
|
||||
|
||||
def test_request_agent_stub_file_download_sync_posts_internal_download_request() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert str(request.url) == "https://agent.example.com/agent-stub/files/download-request"
|
||||
assert json.loads(request.content) == {
|
||||
"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")},
|
||||
"for_external": False,
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "http://internal-files/report.pdf",
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
response = request_agent_stub_file_download_sync(
|
||||
url="https://agent.example.com/agent-stub",
|
||||
auth_jwe="test-jwe",
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
for_external=False,
|
||||
sync_http_client=http_client,
|
||||
)
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
assert response.download_url == "http://internal-files/report.pdf"
|
||||
|
||||
|
||||
def test_request_agent_stub_drive_manifest_sync_gets_manifest_request() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "GET"
|
||||
@@ -456,12 +489,14 @@ def test_request_agent_stub_file_download_sync_dispatches_grpc_urls(monkeypatch:
|
||||
url="grpc://agent.example.com:9091",
|
||||
auth_jwe="token",
|
||||
file=file_mapping,
|
||||
for_external=False,
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"url": "grpc://agent.example.com:9091",
|
||||
"auth_jwe": "token",
|
||||
"file": file_mapping,
|
||||
"for_external": False,
|
||||
"timeout": 30.0,
|
||||
}
|
||||
assert response.download_url == "https://files.example.com/download"
|
||||
@@ -613,7 +648,10 @@ def test_request_agent_stub_file_download_grpc_sync_maps_grpc_errors(monkeypatch
|
||||
grpc_module,
|
||||
"_require_conversions",
|
||||
lambda: SimpleNamespace(
|
||||
proto_file_download_request=lambda _pb2, *, file: {"file": file},
|
||||
proto_file_download_request=lambda _pb2, *, file, for_external: {
|
||||
"file": file,
|
||||
"for_external": for_external,
|
||||
},
|
||||
file_download_response_from_proto=lambda response: response,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,9 +13,14 @@ from dify_agent.agent_stub.grpc.conversions import (
|
||||
connect_response_from_proto,
|
||||
file_download_request_from_proto,
|
||||
proto_connect_request,
|
||||
proto_file_download_request,
|
||||
proto_file_download_response,
|
||||
)
|
||||
from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse, AgentStubFileDownloadResponse
|
||||
from dify_agent.agent_stub.protocol.agent_stub import (
|
||||
AgentStubConnectResponse,
|
||||
AgentStubFileDownloadResponse,
|
||||
AgentStubFileMapping,
|
||||
)
|
||||
|
||||
|
||||
def _reference(record_id: str) -> str:
|
||||
@@ -48,6 +53,32 @@ def test_file_download_request_from_proto_respects_optional_reference() -> None:
|
||||
|
||||
assert request.file.reference == _reference("tool-file-1")
|
||||
assert request.file.url is None
|
||||
assert request.for_external is True
|
||||
|
||||
|
||||
def test_file_download_request_from_proto_preserves_explicit_internal_audience() -> None:
|
||||
message = agent_stub_pb2.FileDownloadRequest(
|
||||
file=agent_stub_pb2.FileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
),
|
||||
for_external=False,
|
||||
)
|
||||
|
||||
request = file_download_request_from_proto(message)
|
||||
|
||||
assert request.for_external is False
|
||||
|
||||
|
||||
def test_proto_file_download_request_preserves_selected_audience() -> None:
|
||||
message = proto_file_download_request(
|
||||
agent_stub_pb2,
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
for_external=False,
|
||||
)
|
||||
|
||||
assert message.HasField("for_external") is True
|
||||
assert message.for_external is False
|
||||
|
||||
|
||||
def test_connect_request_from_proto_rejects_invalid_metadata_json() -> None:
|
||||
|
||||
@@ -115,6 +115,41 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_download
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_forwards_internal_download_audience(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == "https://api.example.com/inner/api/download/file/request"
|
||||
assert json.loads(request.content)["for_external"] is False
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "http://internal-files/report.pdf",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
_patch_async_client(monkeypatch, handler)
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
inner_api_key="inner-secret",
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
response = await file_handler.create_download_request(
|
||||
principal=_principal(),
|
||||
request=AgentStubFileDownloadRequest(
|
||||
file=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")),
|
||||
for_external=False,
|
||||
),
|
||||
)
|
||||
assert response.download_url == "http://internal-files/report.pdf"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_dify_api_agent_stub_file_handler_rejects_missing_user_id() -> None:
|
||||
file_handler = DifyApiAgentStubFileRequestHandler(
|
||||
inner_api_url="https://api.example.com",
|
||||
|
||||
@@ -130,6 +130,7 @@ def test_agent_stub_grpc_transport_delegates_file_download_requests() -> None:
|
||||
async def create_download_request(self, *, principal, request):
|
||||
assert principal.execution_context.user_id == "user-1"
|
||||
assert request.file.reference == _reference("tool-file-1")
|
||||
assert request.for_external is False
|
||||
return type(
|
||||
"Response",
|
||||
(),
|
||||
@@ -158,7 +159,8 @@ def test_agent_stub_grpc_transport_delegates_file_download_requests() -> None:
|
||||
file=agent_stub_pb2.FileMapping(
|
||||
transfer_method="tool_file",
|
||||
reference=_reference("tool-file-1"),
|
||||
)
|
||||
),
|
||||
for_external=False,
|
||||
),
|
||||
metadata=(("authorization", f"Bearer {token}"),),
|
||||
)
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ The root `.env.example` file contains the essential startup settings. Optional a
|
||||
1. **Common Variables**:
|
||||
|
||||
- `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `SERVICE_API_URL`, `APP_API_URL`, `APP_WEB_URL`: public URLs for the API and frontend services.
|
||||
- `SERVER_CONSOLE_API_URL`: internal URL used by the web service for server-side console API requests. Keep the default `http://api:5001` for standard Docker Compose deployments, and only change it when your web service must reach the API through a different internal address.
|
||||
- `SERVER_CONSOLE_API_URL`: internal API origin used by web server-side requests and, when `INTERNAL_FILES_URL` is unset, as the default fallback for API-side internal file URL generation. Keep the default `http://api:5001` for standard Docker Compose deployments, and only change it when services must reach the API through a different internal address.
|
||||
- `FILES_URL`, `INTERNAL_FILES_URL`: Public and internal base URLs for file downloads and previews.
|
||||
- `ENDPOINT_URL_TEMPLATE`, `NEXT_PUBLIC_SOCKET_URL`, `TRIGGER_URL`: Additional service URLs.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user