create-workframe 0.1.17 → 0.1.20
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 +73 -9
- package/package.json +1 -1
- package/scripts/apply-update-workframe.sh +21 -18
- package/scripts/compose-docker-host.sh +26 -0
- package/workframe-api/action_proxy.py +8 -0
- package/workframe-api/chat_sessions.py +37 -6
- package/workframe-api/chat_stream.py +16 -0
- package/workframe-api/db_schema.py +29 -0
- package/workframe-api/handler_modules/handler_admin.py +32 -0
- package/workframe-api/handler_modules/handler_auth.py +6 -4
- package/workframe-api/handler_modules/handler_chat.py +17 -26
- package/workframe-api/handler_modules/handler_provider.py +13 -14
- package/workframe-api/handler_modules/handler_workspace.py +60 -2
- package/workframe-api/hermes_profiles.py +31 -9
- package/workframe-api/llm_proxy.py +18 -0
- package/workframe-api/mention_invoke.py +29 -24
- package/workframe-api/message_reactions.py +139 -0
- package/workframe-api/model_surface.py +329 -113
- package/workframe-api/package.json +4 -4
- package/workframe-api/profile_gateway.py +14 -6
- package/workframe-api/provider_bindings.py +118 -17
- package/workframe-api/provider_bootstrap.py +74 -38
- package/workframe-api/provider_model_catalog.py +261 -0
- package/workframe-api/rooms.py +4 -3
- package/workframe-api/route_registry.py +4 -0
- package/workframe-api/runtime_cohort.py +118 -23
- package/workframe-api/scripts/run-python.mjs +28 -0
- package/workframe-api/server.py +44 -12
- package/workframe-api/test_agent_identity_authority.py +96 -0
- package/workframe-api/test_chat_attribution_history.py +59 -0
- package/workframe-api/test_enrich_room_messages.py +43 -2
- package/workframe-api/test_llm_proxy_encoding.py +33 -0
- package/workframe-api/test_message_reactions.py +151 -0
- package/workframe-api/test_model_routing_reconcile.py +122 -0
- package/workframe-api/test_model_surface_consistency.py +45 -5
- package/workframe-api/test_oauth_llm.py +1 -1
- package/workframe-api/test_profile_gateway_health.py +37 -0
- package/workframe-api/test_provider_bindings.py +115 -1
- package/workframe-api/test_provider_model_catalog.py +27 -0
- package/workframe-api/test_provider_model_surface.py +2 -2
- package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
- package/workframe-api/test_server_reexports.py +2 -0
- package/workframe-api/test_user_llm_prefs.py +27 -0
- package/workframe-api/test_workspace_file_actions.py +119 -0
- package/workframe-api/turn_credentials.py +41 -0
- package/workframe-api/turn_overlay.py +95 -34
- package/workframe-api/updates.py +14 -5
- package/workframe-api/user_prefs.py +15 -0
- package/workframe-api/workspace_bootstrap.py +1 -0
- package/workframe-api/workspace_files.py +124 -0
- package/workframe-supervisor/server.py +18 -2
- package/workframe-ui/public/assets/{arc-esSBm9C_.js → arc-BkATcoqt.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-yusDFKmK.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DVVz9RGn.js → architectureDiagram-3BPJPVTR-31_yAiUS.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-B6i10GMX.js → blockDiagram-GPEHLZMM-ClM6Si9T.js} +1 -1
- package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BqwJ7JoH.js → c4Diagram-AAUBKEIU-DJzEktF3.js} +1 -1
- package/workframe-ui/public/assets/channel-Nb_UjZ3O.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-CyhcedSa.js → chunk-2J33WTMH-DPFiyb5O.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-1Rr6WuvI.js → chunk-3OPIFGDE-DXNJzuCn.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-D2bRrARV.js → chunk-4BX2VUAB-DDNibGdr.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-CKXeZxSr.js → chunk-55IACEB6-Co3_ulwA.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DYQxxPZ4.js → chunk-5ZQYHXKU-Cmxz8o4d.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-CGpIgqvD.js → chunk-727SXJPM-CDAQGesU.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-B88NGQ7d.js → chunk-AQP2D5EJ-x-swy3kc.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CCymiI0O.js → chunk-BSJP7CBP-28nU0JOE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-DRgey03j.js → chunk-CSCIHK7Q-FUURFika.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-l1adn8gM.js → chunk-FMBD7UC4-B4RgRO2R.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CaEvDRPK.js → chunk-KSCS5N6A-CkuppW3T.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-BgCnvPTv.js → chunk-L5ZTLDWV-DyrPSaz3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-t2zokHpn.js → chunk-LZXEDZCA-DFaZSDNV.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-C3c9p0sv.js → chunk-ND2GUHAM-DXI0OJxg.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-DSujF-R8.js → chunk-NZK2D7GU-CyOp1ZG2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DJJTbM6p.js → chunk-O5CBEL6O-tZZMCfbx.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C_TtOXi5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-BjSJQEIY.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-D2Cxn3AG.js → chunk-XPW4576I-B7_2gS3x.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-JD6iZUGh.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-JD6iZUGh.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A---dQ-pFK.js → cose-bilkent-S5V4N54A-BWJaHw_L.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-B_qe1ODY.js → dagre-BM42HDAG-Bu5MadGV.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Bou8vEYl.js → diagram-2AECGRRQ-ckLZkDFD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-DEUEzONU.js → diagram-5GNKFQAL-Cf6Abx1Y.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Ctq571VA.js → diagram-KO2AKTUF-TLk7_5mJ.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CnKn89lm.js → diagram-LMA3HP47-ouNxsAb0.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-DnKlrMx2.js → diagram-OG6HWLK6-BMCBQ35D.js} +1 -1
- package/workframe-ui/public/assets/{dist-ubrEgQyZ.js → dist-C9bxMexz.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-PjqO7CJd.js → erDiagram-TEJ5UH35-BN3e6jb1.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bqd_sTz0.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-B2PN_nNG.js → flowDiagram-I6XJVG4X-BiinzZrd.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-ThyAtNQP.js → ganttDiagram-6RSMTGT7-AYE8oBBG.js} +1 -1
- package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-CB2UgoWy.js → gitGraph-WXDBUCRP-mIq_E2vs.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-DRJJ-zT-.js → gitGraphDiagram-PVQCEYII-Cf8IFEcO.js} +1 -1
- package/workframe-ui/public/assets/index-BnfFRGR3.js +133 -0
- package/workframe-ui/public/assets/index-COshPjF3.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-CLtR33Q_.js → info-J43DQDTF--_3kCaIQ.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-Cmunqfvi.js → infoDiagram-5YYISTIA-BuIOyYMu.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-DWE_84eL.js → ishikawaDiagram-YF4QCWOH-D3bbsXQ8.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BtBhpyRL.js → journeyDiagram-JHISSGLW-BrZ26C8L.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-B8QspDDL.js → kanban-definition-UN3LZRKU-CJiUyUOr.js} +1 -1
- package/workframe-ui/public/assets/{line-iOgDn4O8.js → line-Bee1snmU.js} +1 -1
- package/workframe-ui/public/assets/{linear-BBehZNVA.js → linear-dfD-zlI7.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-DQK5HcdU.js → mermaid-parser.core-D6IqqDSH.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Dsrdd1Wr.js → mermaid.core-CuuxYt2r.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Ds_BfsJA.js → mindmap-definition-RKZ34NQL-T7W2fkwA.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Bz3pi4i8.js → packet-YPE3B663-CiXMDasj.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-BFFah-6g.js → pie-LRSECV5Y-CUx1_96f.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CVCvLPcC.js → pieDiagram-4H26LBE5-DfR96FUO.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-Cx6rrc_7.js → quadrantDiagram-W4KKPZXB-Dj6mqgQE.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-DJ5vN_cg.js → radar-GUYGQ44K-DKT2q98y.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Cbry6b5c.js → requirementDiagram-4Y6WPE33-DYDnBVw9.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CICUr5En.js → sankeyDiagram-5OEKKPKP-BSkxpjjO.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsOuB3F-.js → sequenceDiagram-3UESZ5HK-Dz6SNH9S.js} +1 -1
- package/workframe-ui/public/assets/{src-TS8jZspq.js → src-DC521nE_.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-avBzMQy_.js → stateDiagram-AJRCARHV-D4ZoF5NP.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DknveEiR.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DHLm6UFN.js → timeline-definition-PNZ67QCA-d0ezg1Hu.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-B3Qd5t3j.js → treeView-BLDUP644-kXyaqwIy.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-D1dMQS7x.js → treemap-LRROVOQU-g168p7-O.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DZ6SoniY.js → vennDiagram-CIIHVFJN-C0_QsmRl.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-9gK17Tp3.js → wardley-L42UT6IY-BNFCdzxQ.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-FCU_wmRE.js → wardleyDiagram-YWT4CUSO-BOwUS9sY.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DWowvseN.js → xychartDiagram-2RQKCTM6-Cxr1ZiKh.js} +1 -1
- package/workframe-ui/public/index.html +2 -2
- package/workframe-ui/public/workframe-build.json +3 -3
- package/workframe-ui/public/assets/architecture-7EHR7CIX-j-DiBdGu.js +0 -1
- package/workframe-ui/public/assets/channel-CJ9pFXbx.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Bxk3enxo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DdbkX1rg.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-bwCRiiUl.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-bwCRiiUl.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-g-Xo_eS4.js +0 -1
- package/workframe-ui/public/assets/index-1euVfd3b.js +0 -132
- package/workframe-ui/public/assets/index-BLVAsAeO.css +0 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-BFRA5TYD.js +0 -1
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "
|
|
8
|
-
"start": "
|
|
9
|
-
"typecheck": "python -m py_compile server.py zk_auth.py email_sender.py profile_config_yaml.py route_registry.py domain/entities.py domain/__init__.py && python test_public_routes.py && python test_route_registry.py && python test_billing_provider.py && python test_model_surface_consistency.py && python test_profile_model_yaml.py && python test_domain_entities.py",
|
|
10
|
-
"build": "
|
|
7
|
+
"dev": "node scripts/run-python.mjs server.py",
|
|
8
|
+
"start": "node scripts/run-python.mjs server.py",
|
|
9
|
+
"typecheck": "node scripts/run-python.mjs -m py_compile server.py zk_auth.py email_sender.py profile_config_yaml.py route_registry.py domain/entities.py domain/__init__.py && node scripts/run-python.mjs test_public_routes.py && node scripts/run-python.mjs test_route_registry.py && node scripts/run-python.mjs test_billing_provider.py && node scripts/run-python.mjs test_model_surface_consistency.py && node scripts/run-python.mjs test_profile_model_yaml.py && node scripts/run-python.mjs test_domain_entities.py",
|
|
10
|
+
"build": "node scripts/run-python.mjs -m py_compile server.py zk_auth.py email_sender.py"
|
|
11
11
|
}
|
|
12
12
|
}
|
|
@@ -31,7 +31,13 @@ def _srv():
|
|
|
31
31
|
def _reload_runtime_profile_gateway(profile: str, *, wait_healthy: bool = True) -> None:
|
|
32
32
|
"""Reload Hermes after config.yaml change — gateway restart before stop+start."""
|
|
33
33
|
prof = _srv().resolve_hermes_profile(profile)
|
|
34
|
-
if
|
|
34
|
+
if prof == _srv()._primary_profile():
|
|
35
|
+
_srv()._invalidate_profile_health_cache(prof)
|
|
36
|
+
_srv()._restart_stack_gateway()
|
|
37
|
+
if wait_healthy:
|
|
38
|
+
_wait_profile_api_healthy(prof, attempts=120, delay=0.5)
|
|
39
|
+
return
|
|
40
|
+
if not _srv()._is_runtime_profile_slug(prof):
|
|
35
41
|
return
|
|
36
42
|
with _srv()._gateway_lifecycle_lock:
|
|
37
43
|
if _profile_api_healthy(prof, timeout=0.5, use_cache=False):
|
|
@@ -59,8 +65,7 @@ def _schedule_gateway_reload(profile: str) -> None:
|
|
|
59
65
|
|
|
60
66
|
def _run() -> None:
|
|
61
67
|
try:
|
|
62
|
-
|
|
63
|
-
_reload_runtime_profile_gateway(prof, wait_healthy=False)
|
|
68
|
+
_reload_runtime_profile_gateway(prof, wait_healthy=False)
|
|
64
69
|
except Exception as exc: # noqa: BLE001
|
|
65
70
|
print(f"[workframe-api] gateway reload failed for {prof}: {exc}")
|
|
66
71
|
|
|
@@ -363,15 +368,18 @@ def _profile_api_healthy(profile: str, timeout: float = 1.5, *, use_cache: bool
|
|
|
363
368
|
return ok
|
|
364
369
|
|
|
365
370
|
|
|
366
|
-
def _wait_profile_api_healthy(profile: str, attempts: int =
|
|
371
|
+
def _wait_profile_api_healthy(profile: str, attempts: int = 120, delay: float = 0.5) -> bool:
|
|
367
372
|
try:
|
|
368
373
|
prof = _srv().resolve_hermes_profile(profile)
|
|
369
374
|
except ValueError:
|
|
370
375
|
return False
|
|
371
|
-
|
|
376
|
+
# A compose-managed primary gateway can take ~40 seconds to restart. Do
|
|
377
|
+
# not let the short-lived negative health cache consume the polling window
|
|
378
|
+
# or the first post-install room turn can fail just before it comes up.
|
|
379
|
+
if _profile_api_healthy(prof, use_cache=False):
|
|
372
380
|
return True
|
|
373
381
|
for _ in range(attempts):
|
|
374
|
-
if _profile_api_healthy(prof):
|
|
382
|
+
if _profile_api_healthy(prof, use_cache=False):
|
|
375
383
|
return True
|
|
376
384
|
time.sleep(delay)
|
|
377
385
|
return False
|
|
@@ -45,6 +45,30 @@ def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
|
|
|
45
45
|
return by_provider
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def _workspace_provider_bindings(workspace_id: str) -> dict[str, dict[str, Any]]:
|
|
49
|
+
"""Return the active, newest shared credential for each workspace provider."""
|
|
50
|
+
by_provider: dict[str, dict[str, Any]] = {}
|
|
51
|
+
try:
|
|
52
|
+
conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
|
|
53
|
+
conn.row_factory = sqlite3.Row
|
|
54
|
+
rows = conn.execute(
|
|
55
|
+
"""SELECT id, provider, credential_type, credential_ref, label, is_active, updated_at
|
|
56
|
+
FROM credential_bindings
|
|
57
|
+
WHERE workspace_id = ? AND user_id IS NULL
|
|
58
|
+
AND deleted_at IS NULL AND is_active = 1
|
|
59
|
+
ORDER BY updated_at DESC, created_at DESC""",
|
|
60
|
+
(workspace_id,),
|
|
61
|
+
).fetchall()
|
|
62
|
+
conn.close()
|
|
63
|
+
except sqlite3.Error:
|
|
64
|
+
rows = []
|
|
65
|
+
for row in rows:
|
|
66
|
+
provider = str(row["provider"] or "").lower()
|
|
67
|
+
if provider and provider not in by_provider:
|
|
68
|
+
by_provider[provider] = dict(row)
|
|
69
|
+
return by_provider
|
|
70
|
+
|
|
71
|
+
|
|
48
72
|
_DEVICE_OAUTH_PROVIDER_IDS: frozenset[str] = frozenset({"codex", "nous"})
|
|
49
73
|
_oauth_device_lock = threading.Lock()
|
|
50
74
|
_oauth_device_sessions: dict[str, dict[str, Any]] = {}
|
|
@@ -146,15 +170,16 @@ def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> boo
|
|
|
146
170
|
return False
|
|
147
171
|
prof = _srv().resolve_hermes_profile(profile)
|
|
148
172
|
auth = _load_profile_auth_json(prof)
|
|
149
|
-
if _extract_oauth_block_from_auth(auth, hermes_auth_id):
|
|
150
|
-
# ponytail: steady-state turns — skip user auth read when profile already has oauth
|
|
151
|
-
return False
|
|
152
173
|
user_auth = _load_user_hermes_auth(user_id)
|
|
153
174
|
if not isinstance(user_auth, dict):
|
|
154
175
|
return False
|
|
155
176
|
auth_path = _srv()._profile_dir(prof) / "auth.json"
|
|
177
|
+
before = json.dumps(auth, sort_keys=True, separators=(",", ":"))
|
|
156
178
|
if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
|
|
157
179
|
return False
|
|
180
|
+
after = json.dumps(auth, sort_keys=True, separators=(",", ":"))
|
|
181
|
+
if after == before:
|
|
182
|
+
return False
|
|
158
183
|
auth["version"] = 1
|
|
159
184
|
auth["updated_at"] = _srv()._utc_now()
|
|
160
185
|
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -238,21 +263,47 @@ def _user_provider_connected(user_id: str, spec: dict[str, Any]) -> bool:
|
|
|
238
263
|
return _srv()._provider_connected_for_user(user_id, spec, bindings, env_keys)
|
|
239
264
|
|
|
240
265
|
|
|
241
|
-
def list_user_providers(
|
|
266
|
+
def list_user_providers(
|
|
267
|
+
user_id: str,
|
|
268
|
+
workspace_id: str = "",
|
|
269
|
+
credential_scope: str = "effective",
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
scope = str(credential_scope or "effective").strip().lower()
|
|
272
|
+
if scope not in {"effective", "user", "workspace"}:
|
|
273
|
+
scope = "effective"
|
|
242
274
|
bindings = _user_provider_bindings(user_id)
|
|
243
275
|
workspace = str(workspace_id or "").strip()
|
|
276
|
+
workspace_bindings = _workspace_provider_bindings(workspace) if workspace else {}
|
|
277
|
+
workspace_mode = bool(workspace and _srv()._workspace_credential_mode(None, workspace) == "workspace")
|
|
244
278
|
providers: list[dict[str, Any]] = []
|
|
245
279
|
for spec in _srv().PROVIDER_CONNECT_CATALOG:
|
|
246
280
|
provider_id = str(spec["id"])
|
|
247
281
|
env_var = str(spec.get("env_var") or "")
|
|
248
282
|
binding = bindings.get(provider_id)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
283
|
+
workspace_binding = workspace_bindings.get(provider_id)
|
|
284
|
+
connected = False
|
|
285
|
+
source: str | None = None
|
|
286
|
+
selected_binding: dict[str, Any] | None = None
|
|
287
|
+
if scope == "workspace":
|
|
288
|
+
selected_binding = workspace_binding
|
|
289
|
+
if selected_binding:
|
|
290
|
+
connected = bool(_srv()._credential_secret(selected_binding, user_id))
|
|
291
|
+
source = "workspace" if connected else None
|
|
292
|
+
else:
|
|
293
|
+
connected = _user_provider_connected(user_id, spec)
|
|
294
|
+
source = "user" if connected else None
|
|
295
|
+
selected_binding = binding if connected else None
|
|
296
|
+
if (
|
|
297
|
+
scope == "effective"
|
|
298
|
+
and not connected
|
|
299
|
+
and workspace_mode
|
|
300
|
+
and str(spec.get("category") or "") == "llm"
|
|
301
|
+
and workspace_binding
|
|
302
|
+
):
|
|
303
|
+
connected = bool(_srv()._credential_secret(workspace_binding, user_id))
|
|
304
|
+
if connected:
|
|
305
|
+
source = "workspace"
|
|
306
|
+
selected_binding = workspace_binding
|
|
256
307
|
oauth_configured = None
|
|
257
308
|
if str(spec.get("connect_mode") or "") == "oauth":
|
|
258
309
|
oauth_name = str(spec.get("oauth_provider") or provider_id)
|
|
@@ -264,13 +315,18 @@ def list_user_providers(user_id: str, workspace_id: str = "") -> dict[str, Any]:
|
|
|
264
315
|
**spec,
|
|
265
316
|
"connected": connected,
|
|
266
317
|
"source": source,
|
|
267
|
-
"credential_id": str(
|
|
268
|
-
"credential_ref": str(
|
|
318
|
+
"credential_id": str(selected_binding["id"]) if selected_binding else None,
|
|
319
|
+
"credential_ref": str(selected_binding["credential_ref"]) if selected_binding else (f"env:{env_var}" if env_var and connected else None),
|
|
269
320
|
"profile_home": str(_srv()._user_hermes_home(user_id)),
|
|
270
321
|
"oauth_configured": oauth_configured,
|
|
271
322
|
"user_only": bool(spec.get("user_only")),
|
|
272
323
|
})
|
|
273
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
"ok": True,
|
|
326
|
+
"credential_scope": scope,
|
|
327
|
+
"providers": providers,
|
|
328
|
+
"profile_home": str(_srv()._user_hermes_home(user_id)),
|
|
329
|
+
}
|
|
274
330
|
|
|
275
331
|
|
|
276
332
|
def disconnect_user_credential(user_id: str, credential_id: str) -> dict[str, Any]:
|
|
@@ -303,6 +359,11 @@ def disconnect_user_credential(user_id: str, credential_id: str) -> dict[str, An
|
|
|
303
359
|
conn.close()
|
|
304
360
|
except sqlite3.Error as exc:
|
|
305
361
|
return {"ok": False, "error": f"db_error: {exc}"}
|
|
362
|
+
_srv()._revoke_runtime_llm_leases(
|
|
363
|
+
payer_user_id=user_id,
|
|
364
|
+
provider=str(row["provider"]),
|
|
365
|
+
credential_binding_id=credential_id,
|
|
366
|
+
)
|
|
306
367
|
return {"ok": True, "credential_id": credential_id, "provider": str(row["provider"])}
|
|
307
368
|
|
|
308
369
|
|
|
@@ -439,14 +500,14 @@ def _device_oauth_error_from_log(log_text: str) -> str | None:
|
|
|
439
500
|
if not clean:
|
|
440
501
|
return None
|
|
441
502
|
lowered = clean.lower()
|
|
442
|
-
auth_err = re.search(r"AuthError:\s*(.+)", clean)
|
|
443
|
-
if auth_err:
|
|
444
|
-
return auth_err.group(1).strip()[:500]
|
|
445
503
|
if "rate-limit" in lowered or "rate limiting" in lowered or "http 429" in lowered or re.search(r"\b429\b", lowered):
|
|
446
504
|
return (
|
|
447
505
|
"OpenAI is rate-limiting Codex login requests (HTTP 429). "
|
|
448
506
|
"Wait a minute and try again."
|
|
449
507
|
)
|
|
508
|
+
auth_err = re.search(r"AuthError:\s*(.+)", clean)
|
|
509
|
+
if auth_err:
|
|
510
|
+
return auth_err.group(1).strip()[:500]
|
|
450
511
|
if any(token in lowered for token in ("login timed out", "login cancelled", "oauth_start_failed")):
|
|
451
512
|
lines = [line.strip() for line in clean.splitlines() if line.strip()]
|
|
452
513
|
return (lines[-1] if lines else "OAuth failed")[:500]
|
|
@@ -492,6 +553,11 @@ def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id:
|
|
|
492
553
|
def _finalize_hermes_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> None:
|
|
493
554
|
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
494
555
|
_sync_user_oauth_provider_to_runtime_profiles(user_id, hermes_auth_id)
|
|
556
|
+
# The picker caches connected providers for one minute. OAuth material is
|
|
557
|
+
# stored outside credential_bindings, so the ordinary secret-save
|
|
558
|
+
# invalidation path never runs; clear it before selecting/bootstraping the
|
|
559
|
+
# new provider or the completed Codex connection remains invisible.
|
|
560
|
+
_srv()._invalidate_user_llm_picker_cache(user_id)
|
|
495
561
|
_srv()._bootstrap_model_after_llm_connect(user_id, "", provider_id)
|
|
496
562
|
|
|
497
563
|
|
|
@@ -508,6 +574,24 @@ def _device_oauth_session_patch(session_id: str, patch: dict[str, Any]) -> None:
|
|
|
508
574
|
row.update(patch)
|
|
509
575
|
|
|
510
576
|
|
|
577
|
+
def _reusable_device_oauth_session(user_id: str, provider_id: str) -> tuple[str, dict[str, Any]] | None:
|
|
578
|
+
"""Reuse one live device flow instead of rate-limiting the upstream provider."""
|
|
579
|
+
now = time.time()
|
|
580
|
+
with _oauth_device_lock:
|
|
581
|
+
candidates = [
|
|
582
|
+
(session_id, dict(row))
|
|
583
|
+
for session_id, row in _oauth_device_sessions.items()
|
|
584
|
+
if isinstance(row, dict)
|
|
585
|
+
and row.get("user_id") == user_id
|
|
586
|
+
and row.get("provider_id") == provider_id
|
|
587
|
+
and row.get("status") == "pending"
|
|
588
|
+
and now - float(row.get("started_at") or 0.0) < 16 * 60
|
|
589
|
+
]
|
|
590
|
+
if not candidates:
|
|
591
|
+
return None
|
|
592
|
+
return max(candidates, key=lambda item: float(item[1].get("started_at") or 0.0))
|
|
593
|
+
|
|
594
|
+
|
|
511
595
|
def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container: str) -> tuple[int, str]:
|
|
512
596
|
"""Start long-running `hermes auth add` detached in gateway (survives exec return)."""
|
|
513
597
|
home = _srv()._hermes_user_home_container(user_id)
|
|
@@ -544,6 +628,23 @@ def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container:
|
|
|
544
628
|
|
|
545
629
|
def _start_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> dict[str, Any]:
|
|
546
630
|
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
631
|
+
reusable = _reusable_device_oauth_session(user_id, provider_id)
|
|
632
|
+
if reusable:
|
|
633
|
+
reusable_id, _ = reusable
|
|
634
|
+
current = device_oauth_status(user_id, provider_id, reusable_id)
|
|
635
|
+
if current.get("status") != "error":
|
|
636
|
+
return {
|
|
637
|
+
"ok": True,
|
|
638
|
+
"provider": provider_id,
|
|
639
|
+
"hermes_auth_id": hermes_auth_id,
|
|
640
|
+
"flow": "device_code",
|
|
641
|
+
"session_id": reusable_id,
|
|
642
|
+
"status": current.get("status") or "pending",
|
|
643
|
+
"verification_uri": current.get("verification_uri"),
|
|
644
|
+
"user_code": current.get("user_code"),
|
|
645
|
+
"message": current.get("message"),
|
|
646
|
+
"reused": True,
|
|
647
|
+
}
|
|
547
648
|
session_id = secrets.token_urlsafe(16)
|
|
548
649
|
log_name = f".oauth-{session_id}.log"
|
|
549
650
|
home = _srv()._hermes_user_home_container(user_id)
|
|
@@ -213,57 +213,85 @@ def _reconcile_profile_llm_for_user(
|
|
|
213
213
|
*,
|
|
214
214
|
prefer_provider: str = "",
|
|
215
215
|
) -> bool:
|
|
216
|
-
"""
|
|
216
|
+
"""Repair routing for the agent-owned model without changing that model.
|
|
217
|
+
|
|
218
|
+
Credentials authorize execution; they never silently select a different
|
|
219
|
+
provider and create DM/room model drift.
|
|
220
|
+
"""
|
|
217
221
|
user = str(user_id or "").strip()
|
|
218
222
|
if not user:
|
|
219
223
|
return False
|
|
220
224
|
prof = _srv().resolve_hermes_profile(profile)
|
|
221
225
|
block = _srv()._read_model_block(prof)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
connected = _srv()._user_llm_providers_for_picker(user)
|
|
232
|
-
current_model = model
|
|
233
|
-
prefer = str(prefer_provider or "").strip().lower()
|
|
234
|
-
model_billing = (
|
|
235
|
-
_srv()._resolve_billing_provider_for_model(current_model, connected, prefer=prefer)
|
|
236
|
-
if current_model
|
|
226
|
+
current_model = str(block.get("default") or "").strip()
|
|
227
|
+
cfg = str(block.get("provider") or "").strip().lower()
|
|
228
|
+
configured_billing = (
|
|
229
|
+
_srv()._billing_provider_from_block(
|
|
230
|
+
str(block.get("base_url") or ""),
|
|
231
|
+
cfg,
|
|
232
|
+
cfg,
|
|
233
|
+
)
|
|
234
|
+
if (cfg or block.get("base_url"))
|
|
237
235
|
else ""
|
|
238
236
|
)
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
connected = _srv()._user_llm_providers_for_picker(user)
|
|
238
|
+
prefer = str(prefer_provider or "").strip().lower()
|
|
239
|
+
if configured_billing and current_model:
|
|
240
|
+
billing = configured_billing
|
|
241
241
|
elif prefer and _srv()._user_can_use_llm(user, workspace_id, prefer):
|
|
242
242
|
billing = prefer
|
|
243
243
|
else:
|
|
244
|
-
|
|
245
|
-
if not
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if _srv().
|
|
244
|
+
connected_sorted = sorted(connected)
|
|
245
|
+
if not connected_sorted:
|
|
246
|
+
return False
|
|
247
|
+
billing = connected_sorted[0]
|
|
248
|
+
|
|
249
|
+
if not current_model:
|
|
250
|
+
current_model = str(
|
|
251
|
+
(_srv().PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or ""
|
|
252
|
+
).strip()
|
|
253
|
+
if not current_model:
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
if _srv()._oauth_llm_provider_spec(billing):
|
|
257
|
+
if _srv()._profile_routing_matches_billing(prof, billing, block=block):
|
|
258
|
+
auth_changed = bool(
|
|
259
|
+
_srv()._user_can_use_llm(user, workspace_id, billing)
|
|
260
|
+
and _srv()._sync_oauth_llm_to_profile(prof, user, billing)
|
|
261
|
+
)
|
|
262
|
+
if auth_changed:
|
|
263
|
+
_srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
|
|
264
|
+
return auth_changed
|
|
265
|
+
changed = _srv()._apply_model_for_billing_provider(
|
|
266
|
+
prof,
|
|
267
|
+
billing,
|
|
268
|
+
current_model,
|
|
269
|
+
user_id=user if _srv()._user_can_use_llm(user, workspace_id, billing) else "",
|
|
270
|
+
)
|
|
271
|
+
if changed:
|
|
258
272
|
_srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
|
|
259
|
-
return
|
|
260
|
-
|
|
273
|
+
return changed
|
|
274
|
+
|
|
275
|
+
if _srv()._profile_routing_matches_billing(prof, billing, block=block):
|
|
276
|
+
if not _srv()._profile_llm_proxy_ready(prof, billing):
|
|
277
|
+
before = _srv()._parse_model_block_from_disk(prof)
|
|
278
|
+
_srv()._ensure_profile_llm_proxy(prof, billing)
|
|
279
|
+
changed = before != _srv()._parse_model_block_from_disk(prof)
|
|
280
|
+
if changed:
|
|
281
|
+
_srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
|
|
282
|
+
return changed
|
|
261
283
|
return False
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
284
|
+
|
|
285
|
+
# API-key providers are vault-backed. Repair transport to the internal
|
|
286
|
+
# proxy while preserving the exact agent-selected model id.
|
|
287
|
+
changed = _srv()._apply_model_for_billing_provider(
|
|
288
|
+
prof,
|
|
289
|
+
billing,
|
|
290
|
+
current_model,
|
|
291
|
+
user_id=user,
|
|
292
|
+
)
|
|
293
|
+
if changed:
|
|
265
294
|
_srv()._ensure_profile_llm_proxy(prof, billing)
|
|
266
|
-
if changed and _srv()._is_runtime_profile_slug(prof):
|
|
267
295
|
_srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
|
|
268
296
|
return changed
|
|
269
297
|
|
|
@@ -340,6 +368,14 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
|
|
|
340
368
|
|
|
341
369
|
provider, model_name = _srv()._read_model_from_config(prof)
|
|
342
370
|
llm_provider = _srv()._llm_billing_provider(prof, provider or "")
|
|
371
|
+
# ChatGPT-account Codex rejects the historical gpt-5.4-medium default.
|
|
372
|
+
# Migrate generated profiles during ordinary bootstrap so existing installs
|
|
373
|
+
# converge without requiring users to delete their agent/session data.
|
|
374
|
+
if llm_provider == "codex" and str(model_name or "").strip() == "gpt-5.4-medium":
|
|
375
|
+
ok_model, _ = _srv()._set_profile_model(prof, "gpt-5.4-mini")
|
|
376
|
+
changed = ok_model or changed
|
|
377
|
+
model_name = "gpt-5.4-mini"
|
|
378
|
+
|
|
343
379
|
if not str(model_name or "").strip():
|
|
344
380
|
changed = _srv()._apply_mvp_model_for_provider(prof, llm_provider) or changed
|
|
345
381
|
if _srv()._is_runtime_profile_slug(prof):
|