Files
dify/api/services/dify_builder/agent/resources.py
T
Charles YaoandClaude Opus 5 0a228b7c23 feat(dify-builder): report whether a recommended tool is actually authorized
The Builder recommended every installed tool as ready to use without
ever checking whether its provider actually has credentials configured.
A credentialed tool with no tool_builtin_providers row got bound into a
plan and then failed at test time with an authorization error the user
can't act on.

tool_catalogue.py now carries provider_controller.need_credentials onto
each ToolCatalogueEntry as needs_credentials. resources.py resolves
which providers are actually authorized for the tenant (mirroring
ToolTransformService.builtin_provider_to_user_provider's need_credentials
/ db_provider rule exactly, so the Builder and the console's tools page
can never disagree) and sets ResourceRef.readiness to "missing_config"
for a credentialed tool with no authorized provider row, "ready"
otherwise. Datasets and models are unaffected. If the authorization
query itself fails, readiness fails open to "ready" rather than
mass-labeling every credentialed tool unauthorized on a transient error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 16:04:48 -07:00

135 lines
5.0 KiB
Python

"""Read-only tenant resource inventory for Dify Builder cognition/grounding.
One entry point -- list_tenant_resources -- returns the tenant's configured
models, datasets, and tools normalized to id+label. Each source degrades to
an empty list on failure so a missing provider never breaks the advance.
"""
import logging
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.tools.tool_manager import ToolManager
from core.workflow.generator.tool_catalogue import build_tool_catalogue
from extensions.ext_database import db
from models.dataset import Dataset
from services.model_provider_service import ModelProviderService
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ResourceRef:
id: str
label: str
meta: str = ""
readiness: str = "ready"
@dataclass(frozen=True)
class TenantResources:
models: list[ResourceRef]
datasets: list[ResourceRef]
tools: list[ResourceRef]
def _list_models(tenant_id: str) -> list[Any]:
return ModelProviderService().get_models_by_model_type(tenant_id=tenant_id, model_type="llm")
def _list_datasets(tenant_id: str) -> list[Any]:
with Session(db.engine) as session:
return list(session.scalars(select(Dataset).where(Dataset.tenant_id == tenant_id)).all())
def _list_tools(tenant_id: str) -> list[dict]:
return list(build_tool_catalogue(tenant_id))
def _authorized_providers(tenant_id: str) -> set[str]:
"""Providers with a credential row, i.e. authorized for this tenant.
Mirrors ``ToolTransformService.builtin_provider_to_user_provider``
(api/services/tools/tools_transform_service.py) so the Builder and the
console's own tools page can never disagree about what is usable: a
provider is usable when it needs no credentials, or when a
``tool_builtin_providers`` row exists for it.
"""
return {str(p.provider) for p in ToolManager.list_default_builtin_providers(tenant_id)}
def _tool_readiness(entry: dict, authorized: set[str] | None) -> str:
""""ready" unless the tool needs credentials it doesn't have.
``authorized is None`` means the authorization lookup itself failed
(see the ``build_tools`` try/except below) -- distinct from a
successful lookup that simply found zero rows. We fail OPEN in that
case: reporting every credentialed tool "missing_config" on a
transient DB error would hide tools the tenant genuinely has
configured, which is a worse failure than occasionally recommending
one we can't currently confirm. A real credential gap still surfaces
the next time the lookup succeeds, and the actual authorization check
happens again at tool-execution time regardless of this label.
"""
if not entry.get("needs_credentials"):
return "ready"
if authorized is None:
return "ready"
return "ready" if entry["provider_name"] in authorized else "missing_config"
def _safe(build, source: str) -> list:
try:
return build()
except Exception: # a missing/mis-configured source degrades to empty, never raises
logger.warning(
"Dify Builder: resource source %r failed for the current tenant; treating as empty",
source,
exc_info=True,
)
return []
def list_tenant_resources(tenant_id: str) -> TenantResources:
def build_models():
models: list[ResourceRef] = []
for prov in _list_models(tenant_id):
for m in getattr(prov, "models", []):
name = getattr(m, "model", "")
model_id = f"{prov.provider}/{name}"
models.append(ResourceRef(id=model_id, label=model_id))
return models
def build_datasets():
return [ResourceRef(id=str(d.id), label=str(d.name)) for d in _list_datasets(tenant_id)]
def build_tools():
tools = _list_tools(tenant_id)
# Only pay for the authorization query when some listed tool would
# actually be gated on it -- a tenant with no credentialed tools
# installed never needs to touch tool_builtin_providers.
authorized: set[str] | None = None
if any(t.get("needs_credentials") for t in tools):
try:
authorized = _authorized_providers(tenant_id)
except Exception: # the lookup degrades to "unknown", not "none authorized"
logger.warning(
"Dify Builder: resource source %r failed for the current tenant; treating as empty",
"tool authorization",
exc_info=True,
)
authorized = None
return [
ResourceRef(id=f"{t['provider_name']}/{t['tool_name']}", label=str(t.get("tool_label") or t["tool_name"]),
meta=str(t.get("description") or ""), readiness=_tool_readiness(t, authorized))
for t in tools
]
models = _safe(build_models, "models")
datasets = _safe(build_datasets, "datasets")
tools = _safe(build_tools, "tools")
return TenantResources(models=models, datasets=datasets, tools=tools)