create-workframe 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1376 -19305
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""WF-032 extract: provider_bootstrap."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from http.server import BaseHTTPRequestHandler
|
|
22
|
+
|
|
23
|
+
import user_prefs
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _srv():
|
|
27
|
+
import server as srv
|
|
28
|
+
|
|
29
|
+
return srv
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_profile_auth_json(profile: str) -> dict[str, Any]:
|
|
33
|
+
path = _srv()._profile_dir(profile) / "auth.json"
|
|
34
|
+
fallback: dict[str, Any] = {"version": 1, "providers": {}, "credential_pool": {}}
|
|
35
|
+
if not path.is_file():
|
|
36
|
+
return dict(fallback)
|
|
37
|
+
try:
|
|
38
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
except (OSError, json.JSONDecodeError):
|
|
40
|
+
return dict(fallback)
|
|
41
|
+
return raw if isinstance(raw, dict) else dict(fallback)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _provider_pool_entry(provider: str, env_var: str, *, base_url: str = "") -> dict[str, Any]:
|
|
45
|
+
base_urls = {
|
|
46
|
+
"openrouter": "https://openrouter.ai/api/v1",
|
|
47
|
+
"openai": "https://api.openai.com/v1",
|
|
48
|
+
"anthropic": "https://api.anthropic.com",
|
|
49
|
+
}
|
|
50
|
+
resolved_base = str(base_url or "").strip() or base_urls.get(provider.lower(), "")
|
|
51
|
+
return {
|
|
52
|
+
"id": secrets.token_hex(3),
|
|
53
|
+
"label": env_var,
|
|
54
|
+
"auth_type": "api_key",
|
|
55
|
+
"priority": 0,
|
|
56
|
+
"source": f"env:{env_var}",
|
|
57
|
+
"last_status": "ok",
|
|
58
|
+
"base_url": resolved_base,
|
|
59
|
+
"request_count": 0,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _runtime_provider_pool_entry(provider: str, env_var: str) -> dict[str, Any]:
|
|
64
|
+
"""Credential pool row for u-* profiles — lease token lives in OPENAI_API_KEY."""
|
|
65
|
+
lease_env = "OPENAI_API_KEY"
|
|
66
|
+
return _provider_pool_entry(provider, lease_env, base_url=_srv()._llm_proxy_base_url(provider))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _provider_pool_has_entries(auth: dict[str, Any], provider: str) -> bool:
|
|
70
|
+
pool = auth.get("credential_pool")
|
|
71
|
+
if not isinstance(pool, dict):
|
|
72
|
+
return False
|
|
73
|
+
provider_key = str(provider or "").strip().lower()
|
|
74
|
+
for key in _srv()._hermes_oauth_auth_keys(provider_key) | {provider_key}:
|
|
75
|
+
entries = pool.get(key)
|
|
76
|
+
if isinstance(entries, list) and len(entries) > 0:
|
|
77
|
+
return True
|
|
78
|
+
spec = _srv()._catalog_provider_for_llm(provider_key)
|
|
79
|
+
if spec:
|
|
80
|
+
auth_id = str(spec.get("hermes_auth_id") or "").strip().lower()
|
|
81
|
+
if auth_id:
|
|
82
|
+
entries = pool.get(auth_id)
|
|
83
|
+
if isinstance(entries, list) and len(entries) > 0:
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _connected_provider_names(user_id: str = "", workspace_id: str = "") -> set[str]:
|
|
89
|
+
names: set[str] = set()
|
|
90
|
+
user = str(user_id or "").strip()
|
|
91
|
+
workspace = str(workspace_id or "").strip()
|
|
92
|
+
if user:
|
|
93
|
+
names.update(_srv()._user_provider_bindings(user).keys())
|
|
94
|
+
names.update(
|
|
95
|
+
str(spec["id"]).lower()
|
|
96
|
+
for spec in _srv().PROVIDER_CONNECT_CATALOG
|
|
97
|
+
if str(spec.get("env_var") or "") in _srv()._user_auth_env_keys(user)
|
|
98
|
+
)
|
|
99
|
+
if workspace:
|
|
100
|
+
for provider in ("openrouter", "openai", "anthropic", "google"):
|
|
101
|
+
if _srv()._resolve_credential("", workspace, provider):
|
|
102
|
+
names.add(provider)
|
|
103
|
+
if not user:
|
|
104
|
+
primary = _srv()._primary_profile()
|
|
105
|
+
if primary:
|
|
106
|
+
primary_pool = _load_profile_auth_json(primary).get("credential_pool")
|
|
107
|
+
if isinstance(primary_pool, dict):
|
|
108
|
+
for pname, entries in primary_pool.items():
|
|
109
|
+
if isinstance(entries, list) and entries:
|
|
110
|
+
names.add(str(pname).lower())
|
|
111
|
+
if not names:
|
|
112
|
+
names.add("openrouter")
|
|
113
|
+
return names
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _user_llm_providers_for_picker(user_id: str) -> set[str]:
|
|
117
|
+
user = str(user_id or "").strip()
|
|
118
|
+
if not user:
|
|
119
|
+
return set()
|
|
120
|
+
now = time.monotonic()
|
|
121
|
+
cached = _srv()._user_llm_picker_cache.get(user)
|
|
122
|
+
if cached and now - cached[1] < _srv()._USER_LLM_PICKER_TTL_SEC:
|
|
123
|
+
return set(cached[0])
|
|
124
|
+
connected = frozenset(
|
|
125
|
+
str(spec["id"]).lower()
|
|
126
|
+
for spec in _srv().PROVIDER_CONNECT_CATALOG
|
|
127
|
+
if str(spec.get("category") or "") == "llm" and _srv()._user_provider_connected(user, spec)
|
|
128
|
+
)
|
|
129
|
+
_srv()._user_llm_picker_cache[user] = (connected, now)
|
|
130
|
+
return set(connected)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _user_has_llm_provider(user_id: str) -> bool:
|
|
134
|
+
return bool(_srv()._user_llm_providers_for_picker(user_id))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _user_llm_has_provider(user_id: str, workspace_id: str = "") -> bool:
|
|
138
|
+
if _user_has_llm_provider(user_id):
|
|
139
|
+
return True
|
|
140
|
+
ws = str(workspace_id or "").strip()
|
|
141
|
+
return bool(ws and _workspace_has_llm_provider(ws))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _workspace_has_llm_provider(workspace_id: str) -> bool:
|
|
145
|
+
workspace_id = str(workspace_id or "").strip()
|
|
146
|
+
if not workspace_id:
|
|
147
|
+
return False
|
|
148
|
+
for spec in _srv().PROVIDER_CONNECT_CATALOG:
|
|
149
|
+
if str(spec.get("category") or "") != "llm":
|
|
150
|
+
continue
|
|
151
|
+
provider_id = str(spec["id"])
|
|
152
|
+
resolved = _srv()._resolve_credential("", workspace_id, provider_id)
|
|
153
|
+
if not resolved:
|
|
154
|
+
continue
|
|
155
|
+
env_var = str(spec.get("env_var") or "")
|
|
156
|
+
if env_var and _srv()._stack_profile_env().get(env_var):
|
|
157
|
+
return True
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _user_can_use_llm(user_id: str, workspace_id: str = "", provider: str = "openrouter") -> bool:
|
|
162
|
+
user = str(user_id or "").strip()
|
|
163
|
+
if not user:
|
|
164
|
+
return False
|
|
165
|
+
spec = _srv()._catalog_provider_for_llm(provider)
|
|
166
|
+
if spec and str(spec.get("connect_mode") or "") == "oauth":
|
|
167
|
+
return _srv()._hermes_oauth_tokens_present(user, _srv()._hermes_auth_id_for_spec(spec))
|
|
168
|
+
try:
|
|
169
|
+
resolved = _srv()._require_user_provider(user, workspace_id, provider)
|
|
170
|
+
return bool(_srv()._credential_secret(resolved, user))
|
|
171
|
+
except ValueError:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _overlay_chat_llm_env(
|
|
176
|
+
hermes_prof: str,
|
|
177
|
+
user_id: str,
|
|
178
|
+
workspace_id: str,
|
|
179
|
+
provider: str,
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""Overlay turn credentials; True when this turn can bill the acting user's key."""
|
|
182
|
+
user = str(user_id or "").strip()
|
|
183
|
+
if not user:
|
|
184
|
+
return False
|
|
185
|
+
prov = _srv()._llm_billing_provider(hermes_prof, provider, user_id=user, workspace_id=workspace_id)
|
|
186
|
+
if not _srv()._profile_llm_proxy_matches_billing(hermes_prof, prov):
|
|
187
|
+
_srv()._ensure_profile_llm_proxy(hermes_prof, prov)
|
|
188
|
+
slug = _srv().safe_profile_slug(hermes_prof)
|
|
189
|
+
turn_run = str(uuid.uuid4())
|
|
190
|
+
ready = _srv()._try_overlay_turn_provider_env(hermes_prof, user, workspace_id, prov, turn_run)
|
|
191
|
+
if ready:
|
|
192
|
+
_srv()._overlay_turn_user_env(hermes_prof, user, workspace_id, turn_run)
|
|
193
|
+
return True
|
|
194
|
+
if _srv()._is_runtime_profile_slug(slug):
|
|
195
|
+
return False
|
|
196
|
+
return _srv()._user_can_use_llm(user, workspace_id, prov)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _reconcile_profile_llm_for_user(
|
|
201
|
+
profile: str,
|
|
202
|
+
user_id: str,
|
|
203
|
+
workspace_id: str,
|
|
204
|
+
*,
|
|
205
|
+
prefer_provider: str = "",
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Point proxy + MVP model at a provider the acting user can bill."""
|
|
208
|
+
user = str(user_id or "").strip()
|
|
209
|
+
if not user:
|
|
210
|
+
return False
|
|
211
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
212
|
+
block = _srv()._read_model_block(prof)
|
|
213
|
+
model = str(block.get("default") or "").strip()
|
|
214
|
+
if model:
|
|
215
|
+
cfg = str(block.get("provider") or "").strip().lower()
|
|
216
|
+
quick_billing = _srv()._billing_provider_id_from_hermes_config(cfg)
|
|
217
|
+
# Direct oauth routing only — custom proxy may still need rebilling to user's provider.
|
|
218
|
+
if quick_billing and cfg != "custom" and _srv()._profile_routing_matches_billing(
|
|
219
|
+
prof, quick_billing, block=block,
|
|
220
|
+
):
|
|
221
|
+
return False
|
|
222
|
+
connected = _srv()._user_llm_providers_for_picker(user)
|
|
223
|
+
current_model = model
|
|
224
|
+
prefer = str(prefer_provider or "").strip().lower()
|
|
225
|
+
model_billing = (
|
|
226
|
+
_srv()._resolve_billing_provider_for_model(current_model, connected, prefer=prefer)
|
|
227
|
+
if current_model
|
|
228
|
+
else ""
|
|
229
|
+
)
|
|
230
|
+
if model_billing and _srv()._user_can_use_llm(user, workspace_id, model_billing):
|
|
231
|
+
billing = model_billing
|
|
232
|
+
elif prefer and _srv()._user_can_use_llm(user, workspace_id, prefer):
|
|
233
|
+
billing = prefer
|
|
234
|
+
else:
|
|
235
|
+
billing = _srv()._llm_billing_provider(prof, user_id=user, workspace_id=workspace_id)
|
|
236
|
+
if not _srv()._user_can_use_llm(user, workspace_id, billing):
|
|
237
|
+
connected_sorted = sorted(connected)
|
|
238
|
+
if not connected_sorted:
|
|
239
|
+
return False
|
|
240
|
+
billing = connected_sorted[0]
|
|
241
|
+
if _srv()._profile_routing_matches_billing(prof, billing):
|
|
242
|
+
return False
|
|
243
|
+
if _srv()._oauth_llm_provider_spec(billing) and _srv()._user_can_use_llm(user, workspace_id, billing):
|
|
244
|
+
current = str(_srv()._read_model_from_config(prof)[1] or "").strip()
|
|
245
|
+
if not _srv()._resolve_billing_provider_for_model(current, {billing}, prefer=billing):
|
|
246
|
+
current = str((_srv().PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
|
|
247
|
+
_srv()._apply_model_for_billing_provider(prof, billing, current, user_id=user)
|
|
248
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
249
|
+
_srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
|
|
250
|
+
return True
|
|
251
|
+
if _srv()._user_can_use_llm(user, workspace_id, billing) and _srv()._profile_llm_proxy_matches_billing(prof, billing):
|
|
252
|
+
return False
|
|
253
|
+
changed = _srv()._apply_mvp_model_for_provider(prof, billing)
|
|
254
|
+
block = _srv()._read_model_block(prof)
|
|
255
|
+
if _srv()._is_runtime_profile_slug(prof) or str(block.get("provider") or "").strip().lower() == "custom":
|
|
256
|
+
_srv()._ensure_profile_llm_proxy(prof, billing)
|
|
257
|
+
if changed and _srv()._is_runtime_profile_slug(prof):
|
|
258
|
+
_srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
|
|
259
|
+
return changed
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _ensure_profile_auth_pool(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
|
|
263
|
+
"""Sync auth.json credential pool — does not mutate model yaml (safe on GET /models)."""
|
|
264
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
265
|
+
if prof == _srv()._primary_profile():
|
|
266
|
+
return False
|
|
267
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
268
|
+
_srv()._strip_profile_llm_env(prof)
|
|
269
|
+
_srv()._strip_profile_action_env(prof)
|
|
270
|
+
else:
|
|
271
|
+
_srv()._strip_profile_llm_env(prof)
|
|
272
|
+
_srv()._strip_profile_action_env(prof)
|
|
273
|
+
_srv()._sync_profile_provider_env(prof, user_id, workspace_id)
|
|
274
|
+
|
|
275
|
+
llm_provider = _srv()._llm_billing_provider(prof, user_id=user_id, workspace_id=workspace_id)
|
|
276
|
+
auth_path = _srv()._profile_dir(prof) / "auth.json"
|
|
277
|
+
auth = _load_profile_auth_json(prof)
|
|
278
|
+
if _provider_pool_has_entries(auth, llm_provider):
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
user = str(user_id or "").strip()
|
|
282
|
+
llm_spec = _srv()._catalog_provider_for_llm(llm_provider)
|
|
283
|
+
if (
|
|
284
|
+
llm_spec
|
|
285
|
+
and str(llm_spec.get("connect_mode") or "") == "oauth"
|
|
286
|
+
and user
|
|
287
|
+
and _srv()._sync_oauth_llm_to_profile(prof, user, llm_provider)
|
|
288
|
+
):
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
292
|
+
if not user or not _srv()._user_can_use_llm(user, workspace_id, llm_provider):
|
|
293
|
+
return False
|
|
294
|
+
env_var = _srv()._provider_env_var(llm_provider)
|
|
295
|
+
template = _runtime_provider_pool_entry(llm_provider, env_var)
|
|
296
|
+
else:
|
|
297
|
+
resolved = _srv()._resolve_credential(
|
|
298
|
+
user,
|
|
299
|
+
str(workspace_id or "").strip(),
|
|
300
|
+
llm_provider,
|
|
301
|
+
user_only=_srv()._provider_user_only(llm_provider),
|
|
302
|
+
)
|
|
303
|
+
if not resolved:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
ref = str(resolved.get("credential_ref") or "")
|
|
307
|
+
env_var = str(resolved.get("env_var") or "") or (ref[4:] if ref.startswith("env:") else _srv()._provider_env_var(llm_provider))
|
|
308
|
+
template = _provider_pool_entry(llm_provider, env_var)
|
|
309
|
+
|
|
310
|
+
pool = auth.setdefault("credential_pool", {})
|
|
311
|
+
if not isinstance(pool, dict):
|
|
312
|
+
pool = {}
|
|
313
|
+
auth["credential_pool"] = pool
|
|
314
|
+
pool[llm_provider] = [template]
|
|
315
|
+
auth["version"] = 1
|
|
316
|
+
auth["providers"] = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
|
|
317
|
+
auth["updated_at"] = _srv()._utc_now()
|
|
318
|
+
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
320
|
+
_srv()._publish_profile_gateway_secrets(prof)
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
|
|
325
|
+
"""Seed profile auth + MVP model config from user, workspace, or primary stack."""
|
|
326
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
327
|
+
if prof == _srv()._primary_profile():
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
changed = _srv()._ensure_profile_auth_pool(prof, user_id, workspace_id)
|
|
331
|
+
|
|
332
|
+
provider, model_name = _srv()._read_model_from_config(prof)
|
|
333
|
+
llm_provider = _srv()._llm_billing_provider(prof, provider or "")
|
|
334
|
+
if not str(model_name or "").strip():
|
|
335
|
+
changed = _srv()._apply_mvp_model_for_provider(prof, llm_provider) or changed
|
|
336
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
337
|
+
_srv()._ensure_profile_llm_proxy(prof, llm_provider)
|
|
338
|
+
elif not str(_srv()._read_model_from_config(prof)[0] or "").strip():
|
|
339
|
+
ok_provider, _ = _srv()._set_profile_model_provider(prof, llm_provider)
|
|
340
|
+
changed = ok_provider or changed
|
|
341
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
342
|
+
_srv()._ensure_profile_llm_proxy(prof, llm_provider)
|
|
343
|
+
|
|
344
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
345
|
+
_srv()._prepare_runtime_profile_credentials(prof, user_id, workspace_id)
|
|
346
|
+
|
|
347
|
+
return changed
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""WF-032 extract: curated provider connect catalog and lookups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# ponytail: curated Hermes-native provider list; env vars match configuration docs.
|
|
8
|
+
PROVIDER_CONNECT_CATALOG: tuple[dict[str, Any], ...] = (
|
|
9
|
+
{
|
|
10
|
+
"id": "openrouter",
|
|
11
|
+
"label": "OpenRouter",
|
|
12
|
+
"category": "llm",
|
|
13
|
+
"connect_mode": "api_key",
|
|
14
|
+
"env_var": "OPENROUTER_API_KEY",
|
|
15
|
+
"description": "Primary LLM router — any model via one API key.",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "anthropic",
|
|
19
|
+
"label": "Anthropic",
|
|
20
|
+
"category": "llm",
|
|
21
|
+
"connect_mode": "api_key",
|
|
22
|
+
"env_var": "ANTHROPIC_API_KEY",
|
|
23
|
+
"description": "Direct Claude API access.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "openai",
|
|
27
|
+
"label": "OpenAI",
|
|
28
|
+
"category": "llm",
|
|
29
|
+
"connect_mode": "api_key",
|
|
30
|
+
"env_var": "OPENAI_API_KEY",
|
|
31
|
+
"description": "Direct OpenAI API access.",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "codex",
|
|
35
|
+
"label": "OpenAI Codex",
|
|
36
|
+
"category": "llm",
|
|
37
|
+
"connect_mode": "oauth",
|
|
38
|
+
"hermes_auth_id": "openai-codex",
|
|
39
|
+
"description": "Codex CLI OAuth session (hermes auth add openai-codex).",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "google",
|
|
43
|
+
"label": "Google Gemini",
|
|
44
|
+
"category": "llm",
|
|
45
|
+
"connect_mode": "api_key",
|
|
46
|
+
"env_var": "GEMINI_API_KEY",
|
|
47
|
+
"description": "Gemini models via API key.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "deepseek",
|
|
51
|
+
"label": "DeepSeek",
|
|
52
|
+
"category": "llm",
|
|
53
|
+
"connect_mode": "api_key",
|
|
54
|
+
"env_var": "DEEPSEEK_API_KEY",
|
|
55
|
+
"description": "DeepSeek chat models.",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "brave",
|
|
59
|
+
"label": "Brave Search",
|
|
60
|
+
"category": "search",
|
|
61
|
+
"connect_mode": "api_key",
|
|
62
|
+
"env_var": "BRAVE_API_KEY",
|
|
63
|
+
"description": "Web search API for agent tools.",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "nous",
|
|
67
|
+
"label": "Nous Portal",
|
|
68
|
+
"category": "llm",
|
|
69
|
+
"connect_mode": "oauth",
|
|
70
|
+
"hermes_auth_id": "nous",
|
|
71
|
+
"description": "OAuth bundle for models + tool gateway (hermes setup --portal).",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "discord",
|
|
75
|
+
"label": "Discord",
|
|
76
|
+
"category": "messaging",
|
|
77
|
+
"connect_mode": "bot_token",
|
|
78
|
+
"env_var": "DISCORD_BOT_TOKEN",
|
|
79
|
+
"description": "Bot token from the Discord Developer Portal.",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"id": "telegram",
|
|
83
|
+
"label": "Telegram",
|
|
84
|
+
"category": "messaging",
|
|
85
|
+
"connect_mode": "bot_token",
|
|
86
|
+
"env_var": "TELEGRAM_BOT_TOKEN",
|
|
87
|
+
"description": "Bot token from @BotFather.",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "slack",
|
|
91
|
+
"label": "Slack",
|
|
92
|
+
"category": "messaging",
|
|
93
|
+
"connect_mode": "bot_token",
|
|
94
|
+
"env_var": "SLACK_BOT_TOKEN",
|
|
95
|
+
"extra_env_vars": ("SLACK_APP_TOKEN",),
|
|
96
|
+
"description": "Slack bot + app tokens (Socket Mode).",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "github",
|
|
100
|
+
"label": "GitHub",
|
|
101
|
+
"category": "dev",
|
|
102
|
+
"connect_mode": "oauth",
|
|
103
|
+
"env_var": "GITHUB_TOKEN",
|
|
104
|
+
"oauth_provider": "github",
|
|
105
|
+
"hermes_auth_id": "github",
|
|
106
|
+
"user_only": True,
|
|
107
|
+
"description": "OAuth (admin registers app) or fine-grained PAT — repo push and GitHub API.",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "stripe",
|
|
111
|
+
"label": "Stripe",
|
|
112
|
+
"category": "payments",
|
|
113
|
+
"connect_mode": "oauth",
|
|
114
|
+
"env_var": "STRIPE_SECRET_KEY",
|
|
115
|
+
"oauth_provider": "stripe",
|
|
116
|
+
"user_only": True,
|
|
117
|
+
"description": "Connect your Stripe account — charges, customers, and billing tools.",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "vercel",
|
|
121
|
+
"label": "Vercel",
|
|
122
|
+
"category": "dev",
|
|
123
|
+
"connect_mode": "api_key",
|
|
124
|
+
"env_var": "VERCEL_TOKEN",
|
|
125
|
+
"user_only": True,
|
|
126
|
+
"description": "Personal access token (VERCEL_TOKEN) for deploy/CLI tools.",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"id": "netlify",
|
|
130
|
+
"label": "Netlify",
|
|
131
|
+
"category": "dev",
|
|
132
|
+
"connect_mode": "api_key",
|
|
133
|
+
"env_var": "NETLIFY_AUTH_TOKEN",
|
|
134
|
+
"user_only": True,
|
|
135
|
+
"description": "Netlify personal access token (NETLIFY_AUTH_TOKEN).",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Providers where agent actions must use the triggering user's credential only.
|
|
140
|
+
_USER_ONLY_PROVIDER_IDS: frozenset[str] = frozenset(
|
|
141
|
+
str(spec["id"]).lower()
|
|
142
|
+
for spec in PROVIDER_CONNECT_CATALOG
|
|
143
|
+
if spec.get("user_only")
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def catalog_provider(provider_id: str) -> dict[str, Any] | None:
|
|
148
|
+
needle = str(provider_id or "").strip().lower()
|
|
149
|
+
for row in PROVIDER_CONNECT_CATALOG:
|
|
150
|
+
if str(row.get("id", "")).lower() == needle:
|
|
151
|
+
return row
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def catalog_provider_for_llm(llm_provider: str) -> dict[str, Any] | None:
|
|
156
|
+
spec = catalog_provider(llm_provider)
|
|
157
|
+
if spec:
|
|
158
|
+
return spec
|
|
159
|
+
needle = str(llm_provider or "").strip().lower()
|
|
160
|
+
for row in PROVIDER_CONNECT_CATALOG:
|
|
161
|
+
auth_id = str(row.get("hermes_auth_id") or row.get("id") or "").strip().lower()
|
|
162
|
+
if auth_id == needle:
|
|
163
|
+
return row
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def provider_user_only(provider_id: str) -> bool:
|
|
168
|
+
return str(provider_id or "").strip().lower() in _USER_ONLY_PROVIDER_IDS
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def provider_env_vars(spec: dict[str, Any]) -> list[str]:
|
|
172
|
+
names: list[str] = []
|
|
173
|
+
primary = str(spec.get("env_var") or "").strip()
|
|
174
|
+
if primary:
|
|
175
|
+
names.append(primary)
|
|
176
|
+
for extra in spec.get("extra_env_vars") or ():
|
|
177
|
+
key = str(extra or "").strip()
|
|
178
|
+
if key and key not in names:
|
|
179
|
+
names.append(key)
|
|
180
|
+
return names
|