Compare commits

...

1 Commits

Author SHA1 Message Date
Timothy ba6e2a32df fix: auto-detect multi-vendor credentials 2026-02-27 19:03:16 -08:00
6 changed files with 367 additions and 48 deletions
+106 -20
View File
@@ -486,19 +486,56 @@ def _validate_tool_credentials(tools_list: list[str]) -> dict | None:
store = _get_credential_store()
# Build tool -> credential mapping
tool_to_cred: dict[str, str] = {}
# Build tool -> credential mapping (1:many for multi-provider tools)
tool_to_creds: dict[str, list[str]] = {}
for cred_name, spec in CREDENTIAL_SPECS.items():
for tool_name in spec.tools:
tool_to_cred[tool_name] = cred_name
tool_to_creds.setdefault(tool_name, []).append(cred_name)
# Find missing credentials
cred_errors = []
checked: set[str] = set()
for tool_name in tools_list:
cred_name = tool_to_cred.get(tool_name)
if cred_name is None or cred_name in checked:
cred_names = tool_to_creds.get(tool_name)
if cred_names is None:
continue
# Filter to credentials we haven't already checked
unchecked = [cn for cn in cred_names if cn not in checked]
if not unchecked:
continue
# Multi-provider tool (e.g. send_email → resend OR google):
# satisfied if ANY provider credential is available.
if len(unchecked) > 1:
any_available = False
for cn in unchecked:
checked.add(cn)
spec = CREDENTIAL_SPECS[cn]
cred_id = spec.credential_id or cn
if store.is_available(cred_id):
any_available = True
break
if any_available:
continue
# None available — report all alternatives
for cn in unchecked:
spec = CREDENTIAL_SPECS[cn]
affected_tools = [t for t in tools_list if t in spec.tools]
cred_errors.append(
{
"credential": cn,
"env_var": spec.env_var,
"tools_affected": affected_tools,
"help_url": spec.help_url,
"description": spec.description,
"alternative_group": tool_name,
}
)
continue
# Single provider
cred_name = unchecked[0]
checked.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
@@ -3264,12 +3301,12 @@ def check_missing_credentials(
info = runner.info()
node_types = list({node.node_type for node in runner.graph.nodes})
# Build reverse mappings: tool/node_type -> credential name
tool_to_cred: dict[str, str] = {}
# Build reverse mappings (1:many for multi-provider tools like send_email)
tool_to_creds: dict[str, list[str]] = {}
node_type_to_cred: dict[str, str] = {}
for cred_name, spec in CREDENTIAL_SPECS.items():
for tool_name in spec.tools:
tool_to_cred[tool_name] = cred_name
tool_to_creds.setdefault(tool_name, []).append(cred_name)
for nt in spec.node_types:
node_type_to_cred[nt] = cred_name
@@ -3277,27 +3314,76 @@ def check_missing_credentials(
seen: set[str] = set()
all_missing = []
for name_list, mapping in [
(info.required_tools, tool_to_cred),
(node_types, node_type_to_cred),
]:
for item_name in name_list:
cred_name = mapping.get(item_name)
if cred_name is None or cred_name in seen:
# Check tool credentials
for tool_name in info.required_tools:
cred_names = tool_to_creds.get(tool_name)
if cred_names is None:
continue
unchecked = [cn for cn in cred_names if cn not in seen]
if not unchecked:
continue
# Multi-provider: satisfied if ANY provider credential is available
if len(unchecked) > 1:
any_available = False
for cn in unchecked:
seen.add(cn)
spec = CREDENTIAL_SPECS[cn]
cred_id = spec.credential_id or cn
if store.is_available(cred_id):
any_available = True
break
if any_available:
continue
seen.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and not store.is_available(cred_id):
# None available — report all alternatives
for cn in unchecked:
spec = CREDENTIAL_SPECS[cn]
all_missing.append(
{
"credential_name": cred_name,
"credential_name": cn,
"env_var": spec.env_var,
"description": spec.description,
"help_url": spec.help_url,
"tools": spec.tools,
"alternative_group": tool_name,
}
)
continue
# Single provider
cred_name = unchecked[0]
seen.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and not store.is_available(cred_id):
all_missing.append(
{
"credential_name": cred_name,
"env_var": spec.env_var,
"description": spec.description,
"help_url": spec.help_url,
"tools": spec.tools,
}
)
# Check node type credentials
for nt in node_types:
cred_name = node_type_to_cred.get(nt)
if cred_name is None or cred_name in seen:
continue
seen.add(cred_name)
spec = CREDENTIAL_SPECS[cred_name]
cred_id = spec.credential_id or cred_name
if spec.required and not store.is_available(cred_id):
all_missing.append(
{
"credential_name": cred_name,
"env_var": spec.env_var,
"description": spec.description,
"help_url": spec.help_url,
"tools": spec.tools,
}
)
# Also check what's already set
available = []
+38 -10
View File
@@ -122,11 +122,11 @@ class CredentialManager:
self._specs = specs
self._overrides = _overrides or {}
self._dotenv_path = dotenv_path
# Build reverse mapping: tool_name -> credential_name
self._tool_to_cred: dict[str, str] = {}
# Build reverse mapping: tool_name -> credential_names (1:many for multi-provider)
self._tool_to_creds: dict[str, list[str]] = {}
for cred_name, spec in self._specs.items():
for tool_name in spec.tools:
self._tool_to_cred[tool_name] = cred_name
self._tool_to_creds.setdefault(tool_name, []).append(cred_name)
# Build reverse mapping: node_type -> credential_name
self._node_type_to_cred: dict[str, str] = {}
for cred_name, spec in self._specs.items():
@@ -234,18 +234,30 @@ class CredentialManager:
"""
Get the credential name required by a tool.
For multi-provider tools (e.g. send_email), returns the first
available provider's credential name, or the first one if none available.
Args:
tool_name: Name of the tool (e.g., "web_search")
Returns:
Credential name if tool requires one, None otherwise
"""
return self._tool_to_cred.get(tool_name)
cred_names = self._tool_to_creds.get(tool_name)
if not cred_names:
return None
for cn in cred_names:
if self.is_available(cn):
return cn
return cred_names[0]
def get_missing_for_tools(self, tool_names: list[str]) -> list[tuple[str, CredentialSpec]]:
"""
Get list of missing credentials for the given tools.
For multi-provider tools (e.g. send_email resend OR google),
satisfied if ANY provider credential is available.
Args:
tool_names: List of tool names to check
@@ -256,15 +268,31 @@ class CredentialManager:
checked: set[str] = set()
for tool_name in tool_names:
cred_name = self._tool_to_cred.get(tool_name)
if cred_name is None:
# Tool doesn't require credentials
cred_names = self._tool_to_creds.get(tool_name)
if cred_names is None:
continue
if cred_name in checked:
# Already checked this credential
unchecked = [cn for cn in cred_names if cn not in checked]
if not unchecked:
continue
checked.add(cred_name)
# Multi-provider: satisfied if ANY is available
if len(unchecked) > 1:
any_available = False
for cn in unchecked:
checked.add(cn)
if self.is_available(cn):
any_available = True
break
if not any_available:
for cn in unchecked:
spec = self._specs[cn]
if spec.required:
missing.append((cn, spec))
continue
# Single provider
cred_name = unchecked[0]
checked.add(cred_name)
spec = self._specs[cred_name]
if spec.required and not self.is_available(cred_name):
missing.append((cred_name, spec))
@@ -73,13 +73,13 @@ class CredentialStoreAdapter:
self._store = store
self._specs = specs
# Build reverse mappings for validation
self._tool_to_cred: dict[str, str] = {}
# Build reverse mappings for validation (1:many for multi-provider tools)
self._tool_to_creds: dict[str, list[str]] = {}
self._node_type_to_cred: dict[str, str] = {}
for cred_name, spec in self._specs.items():
for tool_name in spec.tools:
self._tool_to_cred[tool_name] = cred_name
self._tool_to_creds.setdefault(tool_name, []).append(cred_name)
for node_type in spec.node_types:
self._node_type_to_cred[node_type] = cred_name
@@ -135,18 +135,31 @@ class CredentialStoreAdapter:
"""
Get the credential name required by a tool.
For multi-provider tools (e.g. send_email), returns the first
available provider's credential name, or the first one if none available.
Args:
tool_name: Name of the tool (e.g., "web_search")
Returns:
Credential name if tool requires one, None otherwise
"""
return self._tool_to_cred.get(tool_name)
cred_names = self._tool_to_creds.get(tool_name)
if not cred_names:
return None
# Return first available, or first if none available
for cn in cred_names:
if self.is_available(cn):
return cn
return cred_names[0]
def get_missing_for_tools(self, tool_names: list[str]) -> list[tuple[str, CredentialSpec]]:
"""
Get list of missing credentials for the given tools.
For multi-provider tools (e.g. send_email resend OR google),
satisfied if ANY provider credential is available.
Args:
tool_names: List of tool names to check
@@ -157,13 +170,31 @@ class CredentialStoreAdapter:
checked: set[str] = set()
for tool_name in tool_names:
cred_name = self._tool_to_cred.get(tool_name)
if cred_name is None:
cred_names = self._tool_to_creds.get(tool_name)
if cred_names is None:
continue
if cred_name in checked:
unchecked = [cn for cn in cred_names if cn not in checked]
if not unchecked:
continue
checked.add(cred_name)
# Multi-provider: satisfied if ANY is available
if len(unchecked) > 1:
any_available = False
for cn in unchecked:
checked.add(cn)
if self.is_available(cn):
any_available = True
break
if not any_available:
for cn in unchecked:
spec = self._specs[cn]
if spec.required:
missing.append((cn, spec))
continue
# Single provider
cred_name = unchecked[0]
checked.add(cred_name)
spec = self._specs[cred_name]
if spec.required and not self.is_available(cred_name):
missing.append((cred_name, spec))
@@ -324,11 +355,20 @@ class CredentialStoreAdapter:
def get_tool_provider_map(self) -> dict[str, str]:
"""Map tool names to provider names for account routing.
For multi-provider tools, maps to the available provider's aden_provider_name.
Returns:
Dict mapping tool_name -> provider_name
(e.g. {"gmail_list_messages": "google", "slack_send_message": "slack"})
"""
return dict(self._tool_to_cred)
result: dict[str, str] = {}
for tool_name, cred_names in self._tool_to_creds.items():
for cn in cred_names:
spec = self._specs[cn]
if spec.aden_provider_name:
result[tool_name] = spec.aden_provider_name
break
return result
def get_by_alias(self, provider_name: str, alias: str) -> str | None:
"""Resolve a specific account's token by alias."""
@@ -130,6 +130,18 @@ def register_tools(
return credentials.get("resend")
return os.getenv("RESEND_API_KEY")
def _detect_provider(account: str = "") -> Literal["resend", "gmail"] | None:
"""Auto-detect email provider from available credentials.
Checks gmail first (more common), then resend.
Returns None if no provider credentials are found.
"""
if _get_credential("gmail", account):
return "gmail"
if _get_credential("resend"):
return "resend"
return None
def _resolve_from_email(from_email: str | None) -> str | None:
"""Resolve sender address: explicit param > EMAIL_FROM env var."""
if from_email:
@@ -151,13 +163,22 @@ def register_tools(
to: str | list[str],
subject: str,
html: str,
provider: Literal["resend", "gmail"],
provider: Literal["resend", "gmail"] | None = None,
from_email: str | None = None,
cc: str | list[str] | None = None,
bcc: str | list[str] | None = None,
account: str = "",
) -> dict:
"""Core email sending logic, callable by other tools."""
# Auto-detect provider if not specified
if provider is None:
provider = _detect_provider(account)
if provider is None:
return {
"error": "No email credentials configured",
"help": "Connect Gmail via hive.adenhq.com or set RESEND_API_KEY",
}
from_email = _resolve_from_email(from_email)
to_list = _normalize_recipients(to)
@@ -217,7 +238,7 @@ def register_tools(
to: str | list[str],
subject: str,
html: str,
provider: Literal["resend", "gmail"],
provider: Literal["resend", "gmail"] | None = None,
from_email: str | None = None,
cc: str | list[str] | None = None,
bcc: str | list[str] | None = None,
@@ -230,11 +251,15 @@ def register_tools(
- "gmail": Use Gmail API (requires Gmail OAuth2 via Aden)
- "resend": Use Resend API (requires RESEND_API_KEY)
If provider is not specified, auto-detects from available credentials
(prefers Gmail over Resend).
Args:
to: Recipient email address(es). Single string or list of strings.
subject: Email subject line (1-998 chars per RFC 2822).
html: Email body as HTML string.
provider: Email provider to use ("gmail" or "resend"). Required.
provider: Email provider to use ("gmail" or "resend"). Optional.
If omitted, auto-detects from configured credentials.
from_email: Sender email address. Falls back to EMAIL_FROM env var if not provided.
Optional for Gmail (defaults to authenticated user's address).
cc: CC recipient(s). Single string or list of strings. Optional.
+68
View File
@@ -149,6 +149,74 @@ class TestCredentialStoreAdapterToolMapping:
# Should only appear once even though two tools need it
assert len(missing) == 1
def test_multi_provider_satisfied_by_any(self, monkeypatch):
"""For multi-provider tools, having ANY provider credential is sufficient."""
custom_specs = {
"provider_a": CredentialSpec(
env_var="PROVIDER_A_KEY",
tools=["multi_tool"],
required=True,
),
"provider_b": CredentialSpec(
env_var="PROVIDER_B_KEY",
tools=["multi_tool"],
required=True,
),
}
monkeypatch.delenv("PROVIDER_A_KEY", raising=False)
monkeypatch.setenv("PROVIDER_B_KEY", "some-key")
creds = CredentialStoreAdapter.with_env_storage(specs=custom_specs)
missing = creds.get_missing_for_tools(["multi_tool"])
assert missing == []
def test_multi_provider_reports_all_when_none_available(self, monkeypatch):
"""For multi-provider tools, reports ALL alternatives when none available."""
custom_specs = {
"provider_a": CredentialSpec(
env_var="PROVIDER_A_KEY",
tools=["multi_tool"],
required=True,
),
"provider_b": CredentialSpec(
env_var="PROVIDER_B_KEY",
tools=["multi_tool"],
required=True,
),
}
monkeypatch.delenv("PROVIDER_A_KEY", raising=False)
monkeypatch.delenv("PROVIDER_B_KEY", raising=False)
creds = CredentialStoreAdapter.with_env_storage(specs=custom_specs)
missing = creds.get_missing_for_tools(["multi_tool"])
assert len(missing) == 2
cred_names = {name for name, _ in missing}
assert cred_names == {"provider_a", "provider_b"}
def test_get_credential_for_tool_prefers_available(self, monkeypatch):
"""get_credential_for_tool returns available provider for multi-provider tools."""
custom_specs = {
"provider_a": CredentialSpec(
env_var="PROVIDER_A_KEY",
tools=["multi_tool"],
required=True,
),
"provider_b": CredentialSpec(
env_var="PROVIDER_B_KEY",
tools=["multi_tool"],
required=True,
),
}
monkeypatch.delenv("PROVIDER_A_KEY", raising=False)
monkeypatch.setenv("PROVIDER_B_KEY", "some-key")
creds = CredentialStoreAdapter.with_env_storage(specs=custom_specs)
result = creds.get_credential_for_tool("multi_tool")
assert result == "provider_b"
class TestCredentialStoreAdapterValidation:
"""Tests for validate_for_tools() behavior."""
+78 -6
View File
@@ -488,13 +488,85 @@ class TestGmailProvider:
assert result["provider"] == "gmail"
class TestProviderRequired:
"""Tests that provider is a required parameter."""
class TestProviderAutoDetect:
"""Tests that provider auto-detects from available credentials."""
def test_missing_provider_raises_type_error(self, send_email_fn):
"""Calling send_email without provider raises TypeError."""
with pytest.raises(TypeError):
send_email_fn(to="test@example.com", subject="Test", html="<p>Hi</p>")
def test_no_credentials_returns_error(self, send_email_fn, monkeypatch):
"""Omitting provider with no credentials returns helpful error."""
monkeypatch.delenv("RESEND_API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_ACCESS_TOKEN", raising=False)
result = send_email_fn(to="test@example.com", subject="Test", html="<p>Hi</p>")
assert "error" in result
assert "No email credentials configured" in result["error"]
def test_auto_detects_gmail(self, send_email_fn, monkeypatch):
"""Omitting provider with Gmail credentials uses Gmail."""
monkeypatch.setenv("GOOGLE_ACCESS_TOKEN", "test_gmail_token")
monkeypatch.delenv("RESEND_API_KEY", raising=False)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": "gmail_auto"}
with patch(_HTTPX_POST, return_value=mock_response):
result = send_email_fn(
to="test@example.com", subject="Test", html="<p>Hi</p>"
)
assert result["success"] is True
assert result["provider"] == "gmail"
def test_auto_detects_resend(self, send_email_fn, monkeypatch):
"""Omitting provider with only Resend credentials uses Resend."""
monkeypatch.setenv("RESEND_API_KEY", "re_test_key")
monkeypatch.setenv("EMAIL_FROM", "test@example.com")
monkeypatch.delenv("GOOGLE_ACCESS_TOKEN", raising=False)
with patch("resend.Emails.send") as mock_send:
mock_send.return_value = {"id": "resend_auto"}
result = send_email_fn(
to="test@example.com", subject="Test", html="<p>Hi</p>"
)
assert result["success"] is True
assert result["provider"] == "resend"
def test_prefers_gmail_over_resend(self, send_email_fn, monkeypatch):
"""When both providers are available, prefers Gmail."""
monkeypatch.setenv("GOOGLE_ACCESS_TOKEN", "test_gmail_token")
monkeypatch.setenv("RESEND_API_KEY", "re_test_key")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": "gmail_preferred"}
with patch(_HTTPX_POST, return_value=mock_response):
result = send_email_fn(
to="test@example.com", subject="Test", html="<p>Hi</p>"
)
assert result["success"] is True
assert result["provider"] == "gmail"
def test_explicit_provider_still_works(self, send_email_fn, monkeypatch):
"""Explicit provider overrides auto-detection."""
monkeypatch.setenv("GOOGLE_ACCESS_TOKEN", "test_gmail_token")
monkeypatch.setenv("RESEND_API_KEY", "re_test_key")
monkeypatch.setenv("EMAIL_FROM", "test@example.com")
with patch("resend.Emails.send") as mock_send:
mock_send.return_value = {"id": "resend_explicit"}
result = send_email_fn(
to="test@example.com",
subject="Test",
html="<p>Hi</p>",
provider="resend",
)
assert result["success"] is True
assert result["provider"] == "resend"
_HTTPX_GET = "aden_tools.tools.email_tool.email_tool.httpx.get"