feat(connectors): connect, repair, rename and list named accounts

connect sends the alias so the gateway adds or reuses that account.
reconnect with an alias checks that account's own row instead of the
connector-wide connected flag, which reports any account. rename resolves
the alias (or the label of an unnamed account) to its id and renames it
through Nous Portal; an unknown name lists the valid ones. status lists
each connector's accounts and stays usable when that read fails.
This commit is contained in:
Siddharth Balyan
2026-10-08 17:35:26 +05:30
committed by Siddharth Balyan
parent 6301bf4bc3
commit 0ca69688b0
4 changed files with 111 additions and 22 deletions

View File

@@ -103,12 +103,13 @@ class ConnectorClient:
return wire.ConnectorSchemasResponse.model_validate(payload).model_dump()
def connections(
self, connectors: Sequence[str], *, reinitiate: bool = False,
self, connectors: Sequence[str], *, reinitiate: bool = False, alias: Optional[str] = None,
return_to: Optional[str] = None, op: Optional[str] = None,
) -> dict[str, Any]:
"""Never retry: the gateway cannot deduplicate authorization starts."""
"""Never retry: the gateway cannot deduplicate authorization starts. ``alias`` names the one
account the request connects (or, with ``reinitiate``, repairs)."""
body = wire.ConnectorConnectionsRequest(
connectors=list(connectors), reinitiate=reinitiate, return_to=return_to, op=op,
connectors=list(connectors), reinitiate=reinitiate, alias=alias, return_to=return_to, op=op,
).model_dump(by_alias=True, exclude_none=True)
payload = self._post(wire.CONNECTOR_CONNECTIONS_PATH, body, retries=0)
return wire.ConnectorConnectionsResponse.model_validate(payload).model_dump()

View File

@@ -10,7 +10,7 @@ from tools.connectors.gateway.config import operation_session_key
from tools.connectors.gateway.errors import RateLimited
from tools.connectors.operation import ConnectionOperation, DetachedOperation, IllegalTransition, Target
from tools.connectors.run import Kind, run_operation
from tools.connectors.targets import catalog_names, hosted_names, misrouted_to_hosted_error
from tools.connectors.targets import HostedTarget, catalog_names, hosted_names, misrouted_to_hosted_error
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@@ -38,7 +38,8 @@ _ACCOUNT_OUTCOME: dict[str, tuple[TargetState, Actor]] = {
NOTE = (
"Settled once. connected → use the app now; skipped → the user chose Not now, do not connect it "
"or route around it; not_connected → ask the user what to do, never re-mint on your own. "
"A later request from the USER for that same app is not a re-ask — run it."
"A later request from the USER for that same app is not a re-ask — run it. "
"If you named an account, tell the user its name and that they can rename it."
)
@@ -66,7 +67,9 @@ def mint(client: Any, operation: ConnectionOperation, names: list[str], *, reini
if not names:
return
response = client.connections(names, reinitiate=reinitiate, **return_to_args(op=operation.op_id))
# One aliased target per call, so the alias of the first named target is the request's.
alias = next((t.alias for t in operation.targets if t.name in names and t.alias), None)
response = client.connections(names, reinitiate=reinitiate, alias=alias, **return_to_args(op=operation.op_id))
for entry in response.get("results", []):
name = str(entry.get("connector") or "").lower()
target = operation.target(name)
@@ -200,7 +203,8 @@ def _prepare(client: Any, action: str, force: bool) -> Callable[[ConnectionOpera
mint(client, operation, names, reinitiate=True, actor=Actor.backend_watcher)
_mark_misrouted(operation)
return
status = _status_by_slug(client)
# A named account is checked on its own row: the connector-wide flag reports any account.
status = _alias_status(operation.targets) or _status_by_slug(client)
repair = []
for name in names:
if status.get(name, {}).get("connected"):
@@ -214,8 +218,33 @@ def _prepare(client: Any, action: str, force: bool) -> Callable[[ConnectionOpera
return prepare
def _no_card_result(client: Any, action: str, names: list[str], force: bool, session_id: str) -> str:
operation = DetachedOperation([Target(n, "connector", action) for n in names], session_key=session_id)
def portal_accounts() -> list[dict[str, Any]]:
from tools.connectors.portal.client import PortalConnectorClient
return PortalConnectorClient().list_accounts()
def _alias_status(targets: list[Target]) -> Optional[dict[str, dict[str, Any]]]:
"""``{slug: {"connected": bool}}`` for an aliased target, read from that account's own row;
``None`` when no target is aliased. A failed read counts as not connected, so the repair mints."""
aliased = [t for t in targets if t.alias]
if not aliased:
return None
try:
rows = portal_accounts()
except Exception as exc:
logger.debug("connector accounts read for reconnect failed: %s", exc)
rows = []
return {t.name: {"connected": any(r.get("connector") == t.name and r.get("alias") == t.alias
and r.get("status") == "active" for r in rows)} for t in aliased}
def _targets(targets: list[HostedTarget], action: str) -> list[Target]:
return [Target(t.name, "connector", action, alias=t.alias) for t in targets]
def _no_card_result(client: Any, action: str, targets: list[HostedTarget], force: bool, session_id: str) -> str:
operation = DetachedOperation(_targets(targets, action), session_key=session_id)
_prepare(client, action, force)(operation)
payload = operation.result(with_urls=True)
payload["status"] = "initiated" if any(t.state == TargetState.initiated for t in operation.targets) else "settled"
@@ -223,12 +252,69 @@ def _no_card_result(client: Any, action: str, names: list[str], force: bool, ses
"Show each connect_url to the user; they open it in a browser to authorize. Ask them to tell you "
"when they are done, then check with action 'status'. Do not call connect again for the same app."
)
if any(t.alias for t in targets):
payload["note"] += " You named this account: tell the user its name and that they can rename it."
return json.dumps(payload, ensure_ascii=False)
def _status_result(client: Any, connectors: list[str]) -> str:
items = client.list_connectors()
if connectors:
wanted = set(connectors)
items = [i for i in items if str(i.get("connector", "")).lower() in wanted]
try:
rows = portal_accounts()
except Exception as exc:
# The account list is extra detail; the connector list alone still answers status.
logger.debug("connector accounts for status failed: %s", exc)
else:
by_slug: dict[str, list[dict[str, Any]]] = {}
for row in rows:
by_slug.setdefault(str(row.get("connector", "")).lower(), []).append(
{"alias": row.get("alias"), "label": row.get("label"), "status": row.get("status"),
"active": row.get("active")})
items = [dict(i, accounts=by_slug.get(str(i.get("connector", "")).lower(), [])) for i in items]
return json.dumps({"connectors": items, "hint": (
"connected=false means calls to that connector will return CONNECTION_REQUIRED. "
"Use action 'connect' to start an authorization. 'accounts' lists each account by its name "
"(alias) and the vendor's label.")}, ensure_ascii=False)
def _rename_result(target: HostedTarget) -> str:
"""Rename the one account of ``target.name`` called ``target.alias`` (or, when unnamed, labelled
so) to ``target.to``. Synchronous: no operation and no card."""
from tools.connectors.gateway.errors import GatewayUnavailable, IdempotencyConflict, ToolGatewayError
from tools.connectors.portal.client import PortalConnectorClient
rows = [r for r in portal_accounts() if str(r.get("connector", "")).lower() == target.name]
matches = [r for r in rows if r.get("alias") == target.alias] or [
r for r in rows if not r.get("alias") and r.get("label") == target.alias]
if len(matches) != 1:
names = sorted({str(r.get("alias") or r.get("label")) for r in rows})
found = f"Its accounts are: {', '.join(names)}." if names else f"The user has no {target.name} account."
what = "No" if not matches else "More than one"
return tool_error(f"{what} {target.name} account is called {target.alias!r}. {found}")
try:
account = PortalConnectorClient().rename_account(matches[0]["connectionId"], target.to)
except IdempotencyConflict:
return tool_error(f"The name {target.to!r} is already used by another {target.name} account. Pick another name.")
except GatewayUnavailable as exc:
if exc.code == "connection_not_found":
return tool_error(f"That {target.name} account no longer exists. Use action status to list accounts.")
return tool_error("This Nous Portal cannot rename connector accounts yet.")
except ToolGatewayError as exc:
if exc.status == 405:
return tool_error("This Nous Portal cannot rename connector accounts yet.")
raise
return json.dumps({
"renamed": {"connector": account["connector"], "alias": account.get("alias"), "label": account["label"]},
"notice": f"Tell the user their {target.name} account {target.alias!r} is now called {target.to!r}.",
}, ensure_ascii=False)
def run_managed_action(
action: str,
connectors: list[str],
targets: list[HostedTarget],
args: dict[str, Any],
*,
client_factory: Optional[Callable[[], Any]] = None,
@@ -240,16 +326,12 @@ def run_managed_action(
if connectors_available is not None and not connectors_available():
return tool_error("Connectors are not available in this session.")
try:
if action == "rename":
return _rename_result(targets[0])
client = (client_factory or managed_client)()
if action == "status":
items = client.list_connectors()
if connectors:
wanted = set(connectors)
items = [i for i in items if str(i.get("connector", "")).lower() in wanted]
return json.dumps({"connectors": items, "hint": (
"connected=false means calls to that connector will return CONNECTION_REQUIRED. "
"Use action 'connect' to start an authorization.")}, ensure_ascii=False)
if not connectors:
return _status_result(client, [t.name for t in targets])
if not targets:
return tool_error(
f"'{action}' requires 'connectors': the connector slugs to authorize (e.g. [\"gmail\"]). "
"Use action 'status' to list them."
@@ -257,9 +339,9 @@ def run_managed_action(
force = bool(args.get("force", False))
session_key = operation_session_key(session_id)
if connection_callback is None:
return _no_card_result(client, action, connectors, force, session_key)
return _no_card_result(client, action, targets, force, session_key)
return run_operation(
[Target(n, "connector", action) for n in connectors],
_targets(targets, action),
managed_kind(client, action, force),
session_key=session_key, tool_call_id=tool_call_id, tick_seconds=WATCH_TICK_SECONDS,
connection_callback=connection_callback, with_urls_in_result=False,

View File

@@ -15,7 +15,7 @@ from tools.connectors.gateway.errors import (
ToolGatewayError,
parse_gateway_error,
)
from tools.connectors.gateway.wire import ConnectorAccountsResponse, RemovedConnectorAccount
from tools.connectors.gateway.wire import ConnectorAccount, ConnectorAccountsResponse, RemovedConnectorAccount
from tools.connectors.portal.errors import InvalidConnectorSlug, PortalConnectorUnavailable, PortalToolsUnavailable
from tools.connectors.portal.wire import (
ConnectorCatalogResponse,
@@ -128,6 +128,12 @@ class PortalConnectorClient:
inventory = self._parse(ConnectorAccountsResponse, response, status, PortalConnectorUnavailable, "portal accounts unavailable")
return [account.model_dump(by_alias=True) for account in inventory.accounts]
def rename_account(self, connection_id: str, alias: str) -> dict[str, Any]:
path = f"/api/v1/connectors/accounts/{quote(connection_id, safe='')}"
response, status = self._request("PATCH", path, {"alias": alias})
account = self._parse(ConnectorAccount, response, status, PortalConnectorUnavailable, "portal accounts unavailable")
return account.model_dump(by_alias=True)
def delete_account(self, connection_id: str) -> dict[str, Any]:
path = f"/api/v1/connectors/accounts/{quote(connection_id, safe='')}"
response, status = self._request("DELETE", path)

View File

@@ -39,7 +39,7 @@ def manage_connections(
)
return run_managed_action(
action, [t.name for t in managed], args,
action, managed, args,
client_factory=client_factory, session_id=session_id, tool_call_id=tool_call_id,
connection_callback=connection_callback, connectors_available=connectors_available,
)