From 7aee86a58ef7bb00b4421256115cfa18a25718f3 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 21 Apr 2026 14:06:47 +0930 Subject: [PATCH 1/2] feat: add saved_credentials tool and credential question type support - Add list_saved_credentials() and get_saved_credential_by_name() helpers in ckit_external_auth - Add fetch_resolved_persona_setup() for bot processes to resolve credential refs at startup - Extend fi_question with 'credential' type: provider, credential_name, fields validation - Add fi_saved_credentials integration: SAVED_CREDENTIALS_TOOL + handle_saved_credentials() --- flexus_client_kit/ckit_external_auth.py | 71 +++++++++++- flexus_client_kit/integrations/fi_question.py | 69 +++++++++++- .../integrations/fi_saved_credentials.py | 105 ++++++++++++++++++ 3 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 flexus_client_kit/integrations/fi_saved_credentials.py diff --git a/flexus_client_kit/ckit_external_auth.py b/flexus_client_kit/ckit_external_auth.py index ef5197f4..39cea6a6 100644 --- a/flexus_client_kit/ckit_external_auth.py +++ b/flexus_client_kit/ckit_external_auth.py @@ -1,6 +1,7 @@ import json import logging -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Optional import gql from flexus_client_kit import ckit_client @@ -110,3 +111,71 @@ async def start_external_auth_flow( } ) return r["external_auth_start"]["authorization_url"] + + +@dataclass +class SavedCredentialField: + key: str + label: str + masked_value: str + + +@dataclass +class SavedCredential: + auth_id: str + provider: str + credential_name: str + status: str + fields: list[SavedCredentialField] = field(default_factory=list) + + +async def list_saved_credentials( + http, + ws_id: str, + provider: Optional[str] = None, +) -> list[SavedCredential]: + r = await http.execute(gql.gql(""" + query ListSavedCredentials($ws_id: String!, $provider: String) { + workspace_saved_credentials(ws_id: $ws_id, provider: $provider) { + auth_id + provider + credential_name + status + fields { key label masked_value } + } + } + """), variable_values={"ws_id": ws_id, "provider": provider} + ) + results = [] + for item in (r.get("workspace_saved_credentials") or []): + results.append(SavedCredential( + auth_id=item["auth_id"], + provider=item["provider"], + credential_name=item["credential_name"], + status=item["status"], + fields=[SavedCredentialField(key=f["key"], label=f["label"], masked_value=f["masked_value"]) + for f in (item.get("fields") or [])], + )) + return results + + +async def fetch_resolved_persona_setup(http, persona_id: str) -> dict: + r = await http.execute(gql.gql(""" + query PersonaResolvedSetup($persona_id: String!) { + persona_resolved_setup(persona_id: $persona_id) + } + """), variable_values={"persona_id": persona_id}) + return r.get("persona_resolved_setup") or {} + + +async def get_saved_credential_by_name( + http, + ws_id: str, + provider: str, + credential_name: str, +) -> Optional[SavedCredential]: + all_creds = await list_saved_credentials(http, ws_id, provider=provider) + for cred in all_creds: + if cred.credential_name == credential_name: + return cred + return None diff --git a/flexus_client_kit/integrations/fi_question.py b/flexus_client_kit/integrations/fi_question.py index 36cadc89..d1cc22e7 100644 --- a/flexus_client_kit/integrations/fi_question.py +++ b/flexus_client_kit/integrations/fi_question.py @@ -7,14 +7,23 @@ name="ask_questions", description="""Ask the user one or more questions with interactive UI. Use this instead of numbered lists. -Types: "single" (pick one), "multi" (pick several), "text" (free-form), "yesno" (yes/no buttons). +Types: "single" (pick one), "multi" (pick several), "text" (free-form), "yesno" (yes/no buttons), +"credential" (collect one or more secret API keys / tokens and save them to the workspace). + +For "credential" questions, include: + - "provider": snake_case namespace, e.g. "openai", "tavily", "stripe" + - "credential_name": human label shown in the UI, e.g. "Production OpenAI" + - "fields": list of {key, label, required} describing what to collect Example: ask_questions(questions=[ {"text": "What kind of bot do you want?", "type": "single", "options": ["Support", "Sales", "Analytics", "Other"]}, {"text": "Which channels should it support?", "type": "multi", "options": ["Slack", "Email", "Discord", "Telegram"]}, {"text": "Should it run on a schedule?", "type": "yesno"}, - {"text": "Any special requirements?", "type": "text"} + {"text": "Any special requirements?", "type": "text"}, + {"text": "Please provide your OpenAI credentials", "type": "credential", + "provider": "openai", "credential_name": "Production OpenAI", + "fields": [{"key": "API_KEY", "label": "API Key", "required": true}]} ])""", parameters={ "type": "object", @@ -26,8 +35,23 @@ "type": "object", "properties": { "text": {"type": "string", "description": "The question text"}, - "type": {"type": "string", "enum": ["single", "multi", "text", "yesno"]}, + "type": {"type": "string", "enum": ["single", "multi", "text", "yesno", "credential"]}, "options": {"type": "array", "items": {"type": "string"}, "description": "Options for single/multi"}, + "provider": {"type": "string", "description": "For credential: snake_case provider namespace, e.g. 'openai'"}, + "credential_name": {"type": "string", "description": "For credential: human-readable name, e.g. 'Production OpenAI'"}, + "fields": { + "type": "array", + "description": "For credential: list of fields to collect", + "items": { + "type": "object", + "properties": { + "key": {"type": "string", "description": "Field key, e.g. 'API_KEY'"}, + "label": {"type": "string", "description": "Display label, e.g. 'API Key'"}, + "required": {"type": "boolean"}, + }, + "required": ["key", "label"], + }, + }, }, "required": ["text", "type"], }, @@ -68,7 +92,7 @@ def _validate_questions(raw: List[Dict[str, Any]]) -> tuple: q = item.get("q", "") qtype = item.get("type", "") options = item.get("options") - if not q or qtype not in ["single", "multi", "text", "yesno"]: + if not q or qtype not in ["single", "multi", "text", "yesno", "credential"]: return None, f"Error: question {i+1} invalid" if len(q) > MAX_TEXT_LEN: return None, f"Error: question {i+1} text too long" @@ -77,7 +101,33 @@ def _validate_questions(raw: List[Dict[str, Any]]) -> tuple: return None, f"Error: question {i+1} ({qtype}) requires options" if len(options) > MAX_OPTIONS: return None, f"Error: question {i+1} too many options" - validated.append({"q": q, "type": qtype, "options": options}) + if qtype == "credential": + provider = item.get("provider", "") + credential_name = item.get("credential_name", "") + fields = item.get("fields", []) + if not isinstance(provider, str) or not provider: + return None, f"Error: question {i+1} (credential) requires a non-empty string provider" + if not isinstance(credential_name, str) or not credential_name: + return None, f"Error: question {i+1} (credential) requires a non-empty string credential_name" + if not isinstance(fields, list) or not fields: + return None, f"Error: question {i+1} (credential) requires at least one field" + validated_fields = [] + for fi, f in enumerate(fields): + if not isinstance(f, dict): + return None, f"Error: question {i+1} field {fi+1} must be an object" + if not isinstance(f.get("key"), str) or not f["key"]: + return None, f"Error: question {i+1} field {fi+1} must have a non-empty string key" + if not isinstance(f.get("label"), str) or not f["label"]: + return None, f"Error: question {i+1} field {fi+1} must have a non-empty string label" + validated_fields.append({ + "key": f["key"], + "label": f["label"], + "required": bool(f.get("required", True)), + }) + entry: Dict[str, Any] = {"q": q, "type": qtype, "options": None, "provider": provider, "credential_name": credential_name, "fields": validated_fields} + else: + entry = {"q": q, "type": qtype, "options": options} + validated.append(entry) if not validated: return None, "Error: at least one valid question required" return validated, None @@ -95,7 +145,14 @@ async def handle_ask_questions( for item in model_produced_args["questions"]: if not isinstance(item, dict): continue - raw.append({"q": item.get("text", ""), "type": item.get("type", ""), "options": item.get("options")}) + raw.append({ + "q": item.get("text", ""), + "type": item.get("type", ""), + "options": item.get("options"), + "provider": item.get("provider"), + "credential_name": item.get("credential_name"), + "fields": item.get("fields"), + }) else: # legacy q1..q6 string format for i in range(1, MAX_QUESTIONS + 1): diff --git a/flexus_client_kit/integrations/fi_saved_credentials.py b/flexus_client_kit/integrations/fi_saved_credentials.py new file mode 100644 index 00000000..3a33ff9a --- /dev/null +++ b/flexus_client_kit/integrations/fi_saved_credentials.py @@ -0,0 +1,105 @@ +import logging +from typing import Any, Optional + +from flexus_client_kit import ckit_cloudtool, ckit_client, ckit_external_auth + +logger = logging.getLogger("fi_saved_credentials") + + +SAVED_CREDENTIALS_TOOL = ckit_cloudtool.CloudTool( + strict=True, + name="saved_credentials", + description=( + "List or find workspace-shared credentials saved by the user (API keys, tokens, etc.). " + "Values are always masked — use this to check what credentials exist and reference them by name. " + "op=list: list all saved credentials, optionally filtered by provider. " + "op=get: find one credential by provider + exact name." + ), + parameters={ + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": ["list", "get"], + "description": "list — show all saved credentials (optionally by provider); get — look up one by provider + name", + }, + "args": { + "type": "object", + "additionalProperties": False, + "properties": { + "provider": { + "type": ["string", "null"], + "description": "Filter by provider namespace, e.g. 'openai', 'tavily'. For op=get this is required.", + }, + "credential_name": { + "type": ["string", "null"], + "description": "For op=get: exact credential name to look up, e.g. 'Production OpenAI'.", + }, + }, + "required": ["provider", "credential_name"], + }, + }, + "required": ["op", "args"], + "additionalProperties": False, + }, +) + +SAVED_CREDENTIALS_HELP = """ +list - List all workspace-shared credentials, optionally filtered by provider. + args: provider (optional) + +get - Find one credential by provider + exact name (case-sensitive). + args: provider (required), credential_name (required) + +Fields are always masked. This tool is for discovery and referencing, not secret retrieval. + +Examples: + saved_credentials(op="list", args={"provider": null, "credential_name": null}) + saved_credentials(op="list", args={"provider": "openai", "credential_name": null}) + saved_credentials(op="get", args={"provider": "openai", "credential_name": "Production OpenAI"}) +""" + + +async def handle_saved_credentials( + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: dict[str, Any], + fclient: ckit_client.FlexusClient, + ws_id: str, +) -> str: + op = model_produced_args.get("op", "") + if not op: + return SAVED_CREDENTIALS_HELP + + args = model_produced_args.get("args") or {} + provider: Optional[str] = args.get("provider") or None + credential_name: Optional[str] = args.get("credential_name") or None + + http = await fclient.use_http_on_behalf(toolcall.connected_persona_id, toolcall.fcall_untrusted_key) + async with http as h: + if op == "list": + creds = await ckit_external_auth.list_saved_credentials(h, ws_id, provider=provider) + if not creds: + filter_msg = f" for provider '{provider}'" if provider else "" + return f"No saved credentials found{filter_msg}." + lines = [f"Found {len(creds)} saved credential(s):\n"] + for c in creds: + field_summary = ", ".join(f"{f.key}: {f.masked_value}" for f in c.fields) + lines.append(f"- **{c.credential_name}** (provider: {c.provider}, auth_id: {c.auth_id})\n Fields: {field_summary or '(none)'}") + return "\n".join(lines) + + elif op == "get": + if not provider or not credential_name: + return "Error: op=get requires both provider and credential_name\n\n" + SAVED_CREDENTIALS_HELP + cred = await ckit_external_auth.get_saved_credential_by_name(h, ws_id, provider, credential_name) + if not cred: + return f"No credential found with provider='{provider}' and name='{credential_name}'." + field_lines = [f" - {f.key} ({f.label}): {f.masked_value}" for f in cred.fields] + return ( + f"**{cred.credential_name}**\n" + f"Provider: {cred.provider}\n" + f"Auth ID: {cred.auth_id}\n" + f"Status: {cred.status}\n" + f"Fields:\n" + "\n".join(field_lines) + ) + + return f"Unknown op: {op}\n\n" + SAVED_CREDENTIALS_HELP From 5f5804343749cfd3bfc9f65423c9632121c67643 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 21 Apr 2026 14:13:56 +0930 Subject: [PATCH 2/2] fix: shorten ask_questions description to fit 1000-char tool limit --- flexus_client_kit/integrations/fi_question.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/flexus_client_kit/integrations/fi_question.py b/flexus_client_kit/integrations/fi_question.py index d1cc22e7..cde476b3 100644 --- a/flexus_client_kit/integrations/fi_question.py +++ b/flexus_client_kit/integrations/fi_question.py @@ -7,23 +7,17 @@ name="ask_questions", description="""Ask the user one or more questions with interactive UI. Use this instead of numbered lists. -Types: "single" (pick one), "multi" (pick several), "text" (free-form), "yesno" (yes/no buttons), -"credential" (collect one or more secret API keys / tokens and save them to the workspace). +Types: "single" (pick one), "multi" (pick several), "text" (free-form), "yesno", "credential" (collect API keys/tokens saved to workspace). -For "credential" questions, include: - - "provider": snake_case namespace, e.g. "openai", "tavily", "stripe" - - "credential_name": human label shown in the UI, e.g. "Production OpenAI" - - "fields": list of {key, label, required} describing what to collect +For "credential": include provider (e.g. "openai"), credential_name (e.g. "Production OpenAI"), and fields list [{key, label, required}]. Example: ask_questions(questions=[ - {"text": "What kind of bot do you want?", "type": "single", "options": ["Support", "Sales", "Analytics", "Other"]}, - {"text": "Which channels should it support?", "type": "multi", "options": ["Slack", "Email", "Discord", "Telegram"]}, - {"text": "Should it run on a schedule?", "type": "yesno"}, - {"text": "Any special requirements?", "type": "text"}, - {"text": "Please provide your OpenAI credentials", "type": "credential", - "provider": "openai", "credential_name": "Production OpenAI", - "fields": [{"key": "API_KEY", "label": "API Key", "required": true}]} + {"text": "Bot type?", "type": "single", "options": ["Support", "Sales"]}, + {"text": "Run on schedule?", "type": "yesno"}, + {"text": "Notes?", "type": "text"}, + {"text": "OpenAI credentials", "type": "credential", "provider": "openai", + "credential_name": "Production OpenAI", "fields": [{"key": "API_KEY", "label": "API Key", "required": true}]} ])""", parameters={ "type": "object",