Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion flexus_client_kit/ckit_external_auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
69 changes: 60 additions & 9 deletions flexus_client_kit/integrations/fi_question.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +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).
Types: "single" (pick one), "multi" (pick several), "text" (free-form), "yesno", "credential" (collect API keys/tokens saved to workspace).

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": "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",
Expand All @@ -26,8 +29,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"],
},
Expand Down Expand Up @@ -68,7 +86,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"
Expand All @@ -77,7 +95,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
Expand All @@ -95,7 +139,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):
Expand Down
105 changes: 105 additions & 0 deletions flexus_client_kit/integrations/fi_saved_credentials.py
Original file line number Diff line number Diff line change
@@ -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