create-workframe 0.1.12 → 0.1.14
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 +2 -2
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +1 -0
- package/scripts/verify-public-deploy.sh +65 -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/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -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 +331 -191
- package/workframe-api/run-typecheck.mjs +2 -1
- 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 +1453 -19513
- 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 +1 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_route_registry.py +171 -35
- 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/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-B0OFRGmJ.js → arc-BT9iZUSd.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
- package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
- package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
- package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
- package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
- package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
- package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
- package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
- package/workframe-ui/public/index.html +11 -7
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
- package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
- package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
- package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""WF-032 extract: per-turn credential leases and LLM proxy profile overlays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import credential_vault
|
|
9
|
+
import internal_proxy_auth
|
|
10
|
+
import profile_config_yaml
|
|
11
|
+
import provider_catalog
|
|
12
|
+
import turn_credentials
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _srv():
|
|
16
|
+
import server as srv
|
|
17
|
+
|
|
18
|
+
return srv
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PROXY_HEADER_TEMPLATE = "${WORKFRAME_PROXY_TOKEN}"
|
|
22
|
+
|
|
23
|
+
def _llm_proxy_base_url(provider: str) -> str:
|
|
24
|
+
return f"{_srv().WORKFRAME_LLM_PROXY_INTERNAL}/internal/llm/{str(provider or 'openrouter').strip().lower()}/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _upsert_model_default_header(
|
|
28
|
+
lines: list[str],
|
|
29
|
+
header_key: str,
|
|
30
|
+
header_val: str,
|
|
31
|
+
) -> list[str]:
|
|
32
|
+
"""Ensure model.default_headers contains header_key: header_val (single block)."""
|
|
33
|
+
text = "".join(lines)
|
|
34
|
+
if f"{header_key}: {header_val}" in text or f"{header_key}: '{header_val}'" in text:
|
|
35
|
+
return lines
|
|
36
|
+
out: list[str] = []
|
|
37
|
+
in_model = False
|
|
38
|
+
in_headers = False
|
|
39
|
+
wrote = False
|
|
40
|
+
for line in lines:
|
|
41
|
+
stripped = line.lstrip()
|
|
42
|
+
if stripped.startswith("model:"):
|
|
43
|
+
in_model = True
|
|
44
|
+
in_headers = False
|
|
45
|
+
out.append(line)
|
|
46
|
+
continue
|
|
47
|
+
if in_model and stripped.startswith("default_headers:"):
|
|
48
|
+
in_headers = True
|
|
49
|
+
out.append(line)
|
|
50
|
+
continue
|
|
51
|
+
if in_model and in_headers and stripped.startswith(f"{header_key}:"):
|
|
52
|
+
indent = line[: len(line) - len(stripped)]
|
|
53
|
+
out.append(f"{indent}{header_key}: {header_val}\n")
|
|
54
|
+
wrote = True
|
|
55
|
+
continue
|
|
56
|
+
if in_model and in_headers and not wrote:
|
|
57
|
+
# ponytail: sibling model keys + top-level keys end the headers block — append before them
|
|
58
|
+
ends_headers = bool(
|
|
59
|
+
stripped and not line.startswith((" ", "\t")) and not stripped.startswith("#")
|
|
60
|
+
)
|
|
61
|
+
if ends_headers:
|
|
62
|
+
out.append(f" {header_key}: {header_val}\n")
|
|
63
|
+
wrote = True
|
|
64
|
+
in_headers = False
|
|
65
|
+
if in_model and stripped and not line.startswith((" ", "\t", "#")):
|
|
66
|
+
if not wrote and not in_headers:
|
|
67
|
+
out.append(" default_headers:\n")
|
|
68
|
+
out.append(f" {header_key}: {header_val}\n")
|
|
69
|
+
wrote = True
|
|
70
|
+
in_model = False
|
|
71
|
+
in_headers = False
|
|
72
|
+
out.append(line)
|
|
73
|
+
if not wrote:
|
|
74
|
+
rebuilt: list[str] = []
|
|
75
|
+
inserted = False
|
|
76
|
+
for line in out:
|
|
77
|
+
rebuilt.append(line)
|
|
78
|
+
if not inserted and line.lstrip().startswith("model:"):
|
|
79
|
+
rebuilt.append(" default_headers:\n")
|
|
80
|
+
rebuilt.append(f" {header_key}: {header_val}\n")
|
|
81
|
+
inserted = True
|
|
82
|
+
if not inserted:
|
|
83
|
+
rebuilt.insert(
|
|
84
|
+
0,
|
|
85
|
+
f"model:\n default_headers:\n {header_key}: {header_val}\n",
|
|
86
|
+
)
|
|
87
|
+
out = rebuilt
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ensure_profile_proxy_headers(profile: str) -> None:
|
|
92
|
+
"""Hermes default_headers: proxy token + profile slug for lease binding at /internal/*."""
|
|
93
|
+
prof = _srv().safe_profile_slug(profile)
|
|
94
|
+
_srv()._normalize_profile_config_yaml(prof)
|
|
95
|
+
try:
|
|
96
|
+
config_path = _srv()._ensure_gateway_config_file(prof)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return
|
|
99
|
+
header_specs: list[tuple[str, str]] = [(internal_proxy_auth.PROFILE_HEADER, prof)]
|
|
100
|
+
if internal_proxy_auth.proxy_token_configured():
|
|
101
|
+
header_specs.append((internal_proxy_auth.PROXY_TOKEN_HEADER, PROXY_HEADER_TEMPLATE))
|
|
102
|
+
try:
|
|
103
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
104
|
+
except OSError:
|
|
105
|
+
return
|
|
106
|
+
lines = raw.splitlines(keepends=True)
|
|
107
|
+
for header_key, header_val in header_specs:
|
|
108
|
+
lines = _upsert_model_default_header(lines, header_key, header_val)
|
|
109
|
+
try:
|
|
110
|
+
config_path.write_text("".join(lines), encoding="utf-8")
|
|
111
|
+
except OSError:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _set_profile_model_base_url(profile: str, base_url: str) -> tuple[bool, str]:
|
|
116
|
+
_srv()._normalize_profile_config_yaml(profile)
|
|
117
|
+
try:
|
|
118
|
+
config_path = _srv()._ensure_gateway_config_file(profile)
|
|
119
|
+
except ValueError as exc:
|
|
120
|
+
return False, str(exc)
|
|
121
|
+
base_url = str(base_url or "").strip()
|
|
122
|
+
if not base_url:
|
|
123
|
+
return False, "base_url required"
|
|
124
|
+
try:
|
|
125
|
+
profile_config_yaml.update_model_surface(config_path, base_url=base_url)
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
return False, f"write failed: {exc}"
|
|
128
|
+
return True, ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _proxy_fallback_chain(profile: str, billing_provider: str) -> None:
|
|
132
|
+
"""Runtime fallbacks use provider=custom so Hermes keeps the proxy + lease."""
|
|
133
|
+
if not _srv()._is_runtime_profile_slug(profile):
|
|
134
|
+
return
|
|
135
|
+
block = _srv()._read_model_block(profile)
|
|
136
|
+
chain = block.get("fallback_chain") or []
|
|
137
|
+
billing = str(billing_provider or "openrouter").strip().lower()
|
|
138
|
+
proxied: list[dict[str, str]] = []
|
|
139
|
+
changed = False
|
|
140
|
+
for entry in chain:
|
|
141
|
+
prov = str(entry.get("provider", "")).strip().lower()
|
|
142
|
+
model = str(entry.get("model", "")).strip()
|
|
143
|
+
if not prov or not model:
|
|
144
|
+
continue
|
|
145
|
+
if prov == billing and prov != "custom":
|
|
146
|
+
changed = True
|
|
147
|
+
proxied.append({"provider": "custom", "model": model})
|
|
148
|
+
else:
|
|
149
|
+
proxied.append({"provider": prov, "model": model})
|
|
150
|
+
if not proxied:
|
|
151
|
+
changed = True
|
|
152
|
+
proxied = [
|
|
153
|
+
{"provider": "custom", "model": str(entry.get("model", "")).strip()}
|
|
154
|
+
for entry in _srv().HERMES_DEFAULT_FALLBACK_CHAIN
|
|
155
|
+
if str(entry.get("model", "")).strip()
|
|
156
|
+
]
|
|
157
|
+
if changed:
|
|
158
|
+
_srv()._write_fallback_chain(profile, proxied)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _coalesce_profile_model_yaml(profile: str) -> None:
|
|
162
|
+
"""Merge duplicate top-level `model:` blocks left by incremental config writers."""
|
|
163
|
+
config_path = _srv()._profile_gateway_config_path(profile)
|
|
164
|
+
if not config_path or not config_path.is_file():
|
|
165
|
+
return
|
|
166
|
+
try:
|
|
167
|
+
lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
168
|
+
except OSError:
|
|
169
|
+
return
|
|
170
|
+
model_headers = sum(
|
|
171
|
+
1 for line in lines if line.lstrip().startswith("model:") and not line.startswith((" ", "\t"))
|
|
172
|
+
)
|
|
173
|
+
needs_coalesce = model_headers > 1
|
|
174
|
+
if not needs_coalesce:
|
|
175
|
+
in_model = False
|
|
176
|
+
for line in lines:
|
|
177
|
+
stripped = line.lstrip()
|
|
178
|
+
indent = len(line) - len(stripped)
|
|
179
|
+
if indent == 0 and stripped.startswith("model:"):
|
|
180
|
+
in_model = True
|
|
181
|
+
continue
|
|
182
|
+
if indent == 0 and stripped:
|
|
183
|
+
in_model = False
|
|
184
|
+
if in_model and indent == 2 and stripped.startswith("- "):
|
|
185
|
+
needs_coalesce = True
|
|
186
|
+
break
|
|
187
|
+
if not needs_coalesce:
|
|
188
|
+
return
|
|
189
|
+
block = _srv()._read_model_block(profile)
|
|
190
|
+
default = str(block.get("default") or _srv().HERMES_DEFAULT_PRIMARY).strip()
|
|
191
|
+
provider = str(block.get("provider") or "").strip()
|
|
192
|
+
base_url = str(block.get("base_url") or "").strip()
|
|
193
|
+
api_key = str(block.get("api_key") or "").strip()
|
|
194
|
+
chain = block.get("fallback_chain") if isinstance(block.get("fallback_chain"), list) else []
|
|
195
|
+
try:
|
|
196
|
+
profile_config_yaml.update_model_surface(
|
|
197
|
+
config_path,
|
|
198
|
+
default=default,
|
|
199
|
+
provider=provider or None,
|
|
200
|
+
base_url=base_url or None,
|
|
201
|
+
api_key=api_key or None,
|
|
202
|
+
fallback_chain=chain,
|
|
203
|
+
coalesce=True,
|
|
204
|
+
)
|
|
205
|
+
except OSError:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _profile_llm_proxy_ready(profile: str, provider: str) -> bool:
|
|
210
|
+
prof = _srv().safe_profile_slug(profile)
|
|
211
|
+
block = _srv()._read_model_block(prof)
|
|
212
|
+
llm_provider = str(provider or "openrouter").strip().lower()
|
|
213
|
+
if str(block.get("base_url") or "").strip() != _llm_proxy_base_url(llm_provider):
|
|
214
|
+
return False
|
|
215
|
+
if not _srv()._is_runtime_profile_slug(prof):
|
|
216
|
+
return True
|
|
217
|
+
if str(block.get("provider") or "").strip().lower() != "custom":
|
|
218
|
+
return False
|
|
219
|
+
for entry in block.get("fallback_chain") or []:
|
|
220
|
+
prov = str(entry.get("provider", "")).strip().lower()
|
|
221
|
+
model = str(entry.get("model", "")).strip()
|
|
222
|
+
if model and prov == llm_provider and prov != "custom":
|
|
223
|
+
return False
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _ensure_profile_llm_proxy(profile: str, provider: str) -> None:
|
|
228
|
+
"""Point Hermes at the internal LLM proxy (create/lease paths only — skip when ready)."""
|
|
229
|
+
llm_provider = str(provider or "openrouter").strip().lower()
|
|
230
|
+
prof = _srv().safe_profile_slug(profile)
|
|
231
|
+
if _srv()._oauth_llm_provider_spec(llm_provider):
|
|
232
|
+
_srv()._normalize_profile_config_yaml(prof)
|
|
233
|
+
_srv()._apply_mvp_model_for_provider(prof, llm_provider)
|
|
234
|
+
return
|
|
235
|
+
_srv()._normalize_profile_config_yaml(prof)
|
|
236
|
+
if _profile_llm_proxy_ready(prof, llm_provider):
|
|
237
|
+
_ensure_profile_proxy_headers(prof)
|
|
238
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
239
|
+
_proxy_fallback_chain(prof, llm_provider)
|
|
240
|
+
return
|
|
241
|
+
_coalesce_profile_model_yaml(prof)
|
|
242
|
+
_set_profile_model_base_url(prof, _llm_proxy_base_url(llm_provider))
|
|
243
|
+
_ensure_profile_proxy_headers(prof)
|
|
244
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
245
|
+
_srv()._set_profile_model_provider(prof, "custom")
|
|
246
|
+
_proxy_fallback_chain(prof, llm_provider)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _set_profile_model_api_key(profile: str, api_key: str) -> tuple[bool, str]:
|
|
250
|
+
_srv()._normalize_profile_config_yaml(profile)
|
|
251
|
+
try:
|
|
252
|
+
config_path = _srv()._ensure_gateway_config_file(profile)
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
return False, str(exc)
|
|
255
|
+
value = str(api_key or "").strip()
|
|
256
|
+
try:
|
|
257
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
258
|
+
except OSError as exc:
|
|
259
|
+
return False, f"read failed: {exc}"
|
|
260
|
+
lines = raw.splitlines(keepends=True)
|
|
261
|
+
out: list[str] = []
|
|
262
|
+
in_model = False
|
|
263
|
+
wrote = False
|
|
264
|
+
for line in lines:
|
|
265
|
+
stripped = line.lstrip()
|
|
266
|
+
if stripped.startswith("model:"):
|
|
267
|
+
if not in_model:
|
|
268
|
+
in_model = True
|
|
269
|
+
out.append(line)
|
|
270
|
+
continue
|
|
271
|
+
if in_model and stripped and not line.startswith((" ", "\t", "#")):
|
|
272
|
+
if not wrote:
|
|
273
|
+
out.append(f" api_key: {_srv()._quote_env_value(value)}\n" if value else " api_key: ''\n")
|
|
274
|
+
wrote = True
|
|
275
|
+
in_model = False
|
|
276
|
+
if in_model and stripped.startswith("api_key:"):
|
|
277
|
+
out.append(
|
|
278
|
+
f" api_key: {_srv()._quote_env_value(value)}\n" if value else " api_key: ''\n"
|
|
279
|
+
)
|
|
280
|
+
wrote = True
|
|
281
|
+
continue
|
|
282
|
+
out.append(line)
|
|
283
|
+
if not wrote:
|
|
284
|
+
rebuilt: list[str] = []
|
|
285
|
+
inserted = False
|
|
286
|
+
for line in out:
|
|
287
|
+
rebuilt.append(line)
|
|
288
|
+
if not inserted and line.lstrip().startswith("model:"):
|
|
289
|
+
rebuilt.append(
|
|
290
|
+
f" api_key: {_srv()._quote_env_value(value)}\n" if value else " api_key: ''\n"
|
|
291
|
+
)
|
|
292
|
+
inserted = True
|
|
293
|
+
if not inserted:
|
|
294
|
+
rebuilt.insert(0, f"model:\n api_key: {_srv()._quote_env_value(value)}\n" if value else "model:\n api_key: ''\n")
|
|
295
|
+
out = rebuilt
|
|
296
|
+
try:
|
|
297
|
+
config_path.write_text("".join(out), encoding="utf-8")
|
|
298
|
+
except OSError as exc:
|
|
299
|
+
return False, f"write failed: {exc}"
|
|
300
|
+
return True, ""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _clear_profile_model_api_key(profile: str) -> None:
|
|
304
|
+
_set_profile_model_api_key(profile, "")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _profile_lease_env_var(profile: str, provider: str) -> str:
|
|
308
|
+
prov, _model = _srv()._read_model_from_config(profile)
|
|
309
|
+
if str(prov or "").strip().lower() == "custom":
|
|
310
|
+
return "OPENAI_API_KEY"
|
|
311
|
+
return _srv()._provider_env_var(provider)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _read_profile_lease_token(profile: str, env_var: str) -> str:
|
|
315
|
+
return str(_srv()._read_env_map(_srv()._profile_dir(profile) / ".env").get(env_var) or "").strip()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _lease_reusable_for_turn(
|
|
319
|
+
token: str,
|
|
320
|
+
*,
|
|
321
|
+
user_id: str,
|
|
322
|
+
workspace_id: str,
|
|
323
|
+
provider: str,
|
|
324
|
+
binding_id: str,
|
|
325
|
+
) -> bool:
|
|
326
|
+
lease = turn_credentials.validate_lease(token)
|
|
327
|
+
if not lease:
|
|
328
|
+
return False
|
|
329
|
+
if str(lease.get("payer_user_id") or "") != str(user_id or "").strip():
|
|
330
|
+
return False
|
|
331
|
+
if str(lease.get("workspace_id") or "") != str(workspace_id or "").strip():
|
|
332
|
+
return False
|
|
333
|
+
if str(lease.get("provider") or "").strip().lower() != str(provider or "").strip().lower():
|
|
334
|
+
return False
|
|
335
|
+
lease_binding = str(lease.get("credential_binding_id") or "").strip()
|
|
336
|
+
want_binding = str(binding_id or "").strip()
|
|
337
|
+
if want_binding and lease_binding and lease_binding != want_binding:
|
|
338
|
+
return False
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _read_config_model_api_key(profile: str) -> str:
|
|
343
|
+
return str(_srv()._read_model_block(profile).get("api_key") or "").strip()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _sync_profile_model_api_key(profile: str, api_key: str) -> None:
|
|
349
|
+
value = str(api_key or "").strip()
|
|
350
|
+
if not value:
|
|
351
|
+
_clear_profile_model_api_key(profile)
|
|
352
|
+
_srv()._restart_runtime_profile_gateway(profile)
|
|
353
|
+
return
|
|
354
|
+
if _read_config_model_api_key(profile) == value:
|
|
355
|
+
return
|
|
356
|
+
ok, err = _set_profile_model_api_key(profile, value)
|
|
357
|
+
if not ok:
|
|
358
|
+
raise ValueError(err or "profile model api_key write failed")
|
|
359
|
+
_srv()._restart_runtime_profile_gateway(profile)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _apply_turn_credential_lease(
|
|
363
|
+
profile: str,
|
|
364
|
+
user_id: str,
|
|
365
|
+
workspace_id: str,
|
|
366
|
+
provider: str,
|
|
367
|
+
run_id: str,
|
|
368
|
+
) -> str:
|
|
369
|
+
"""Issue per-run lease token into profile env; real secret stays in API vault."""
|
|
370
|
+
provider = str(provider or "openrouter").strip().lower()
|
|
371
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
372
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
373
|
+
resolved = _require_runtime_owner_provider(user_id, workspace_id, provider)
|
|
374
|
+
else:
|
|
375
|
+
resolved = _require_user_provider(user_id, workspace_id, provider)
|
|
376
|
+
oauth_spec = _srv()._oauth_llm_provider_spec(provider)
|
|
377
|
+
if oauth_spec and str(resolved.get("credential_type") or "") == "oauth":
|
|
378
|
+
_srv()._sync_oauth_llm_to_profile(prof, user_id, provider)
|
|
379
|
+
if not _srv()._profile_routing_matches_billing(prof, provider):
|
|
380
|
+
current_model = str(_srv()._read_model_from_config(prof)[1] or "").strip()
|
|
381
|
+
if not _srv()._resolve_billing_provider_for_model(current_model, {provider}):
|
|
382
|
+
current_model = str(
|
|
383
|
+
(_srv().PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
|
|
384
|
+
).strip()
|
|
385
|
+
_srv()._apply_model_for_billing_provider(prof, provider, current_model, user_id)
|
|
386
|
+
return ""
|
|
387
|
+
binding_id = str(
|
|
388
|
+
resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
|
|
389
|
+
).strip()
|
|
390
|
+
vault_id = credential_vault.parse_vault_ref(str(resolved.get("credential_ref") or ""))
|
|
391
|
+
if vault_id:
|
|
392
|
+
binding_id = vault_id
|
|
393
|
+
if not binding_id and _srv()._credential_secret(resolved, user_id):
|
|
394
|
+
binding_id = str(uuid.uuid4())
|
|
395
|
+
credential_vault.store_secret(
|
|
396
|
+
binding_id,
|
|
397
|
+
_srv()._credential_secret(resolved, user_id),
|
|
398
|
+
env_var=str(resolved.get("env_var") or "") or _srv()._provider_env_var(provider),
|
|
399
|
+
provider=provider,
|
|
400
|
+
scope=str(resolved.get("scope") or "user"),
|
|
401
|
+
user_id=user_id,
|
|
402
|
+
workspace_id=workspace_id,
|
|
403
|
+
)
|
|
404
|
+
env_var = _profile_lease_env_var(prof, provider)
|
|
405
|
+
config_lease = _read_config_model_api_key(prof)
|
|
406
|
+
if config_lease and not turn_credentials.validate_lease(config_lease):
|
|
407
|
+
_clear_profile_model_api_key(prof)
|
|
408
|
+
existing_token = _read_profile_lease_token(prof, env_var)
|
|
409
|
+
if existing_token and not turn_credentials.validate_lease(existing_token):
|
|
410
|
+
_strip_profile_llm_env(prof, include_leases=True)
|
|
411
|
+
_clear_profile_model_api_key(prof)
|
|
412
|
+
existing_token = ""
|
|
413
|
+
if _lease_reusable_for_turn(
|
|
414
|
+
existing_token,
|
|
415
|
+
user_id=user_id,
|
|
416
|
+
workspace_id=workspace_id,
|
|
417
|
+
provider=provider,
|
|
418
|
+
binding_id=binding_id,
|
|
419
|
+
):
|
|
420
|
+
# ponytail: hot path — skip yaml/env rewrite when lease still valid
|
|
421
|
+
if _srv()._is_runtime_profile_slug(prof) and existing_token:
|
|
422
|
+
if _read_config_model_api_key(prof) != existing_token:
|
|
423
|
+
_sync_profile_model_api_key(prof, existing_token)
|
|
424
|
+
return existing_token
|
|
425
|
+
|
|
426
|
+
token = turn_credentials.issue_lease(
|
|
427
|
+
run_id,
|
|
428
|
+
user_id,
|
|
429
|
+
workspace_id,
|
|
430
|
+
provider,
|
|
431
|
+
prof,
|
|
432
|
+
binding_id or None,
|
|
433
|
+
)
|
|
434
|
+
if token != existing_token:
|
|
435
|
+
_srv()._upsert_env_secret(_srv()._profile_dir(prof) / ".env", env_var, token)
|
|
436
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
437
|
+
_ensure_profile_llm_proxy(prof, provider)
|
|
438
|
+
_sync_profile_model_api_key(prof, token)
|
|
439
|
+
return token
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _action_proxy_base_url(provider_id: str) -> str:
|
|
443
|
+
base = str(_srv().WORKFRAME_LLM_PROXY_INTERNAL or "http://workframe-api:8080").rstrip("/")
|
|
444
|
+
return f"{base}/internal/action/{str(provider_id or '').strip().lower()}"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _action_proxy_env_var(provider_id: str) -> str:
|
|
448
|
+
return {
|
|
449
|
+
"github": "GITHUB_API_URL",
|
|
450
|
+
"vercel": "VERCEL_API_URL",
|
|
451
|
+
"netlify": "NETLIFY_API_URL",
|
|
452
|
+
}.get(str(provider_id or "").strip().lower(), "")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _user_action_env_specs() -> list[tuple[str, str]]:
|
|
456
|
+
specs: list[tuple[str, str]] = []
|
|
457
|
+
for row in provider_catalog.PROVIDER_CONNECT_CATALOG:
|
|
458
|
+
if not row.get("user_only"):
|
|
459
|
+
continue
|
|
460
|
+
for env_var in provider_catalog.provider_env_vars(row):
|
|
461
|
+
specs.append((str(row["id"]), env_var))
|
|
462
|
+
return specs
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _overlay_turn_user_env(profile: str, user_id: str, workspace_id: str, run_id: str = "") -> None:
|
|
466
|
+
"""Issue per-run lease tokens for user-only tool credentials (never raw PATs on Agents mount)."""
|
|
467
|
+
user = str(user_id or "").strip()
|
|
468
|
+
if not user:
|
|
469
|
+
return
|
|
470
|
+
base_run = str(run_id or "").strip() or str(uuid.uuid4())
|
|
471
|
+
for provider_id, _env_var in _user_action_env_specs():
|
|
472
|
+
sub_run = f"{base_run}::{provider_id}"
|
|
473
|
+
try:
|
|
474
|
+
_apply_action_credential_lease(profile, user, workspace_id, provider_id, sub_run)
|
|
475
|
+
except ValueError:
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _apply_action_credential_lease(
|
|
480
|
+
profile: str,
|
|
481
|
+
user_id: str,
|
|
482
|
+
workspace_id: str,
|
|
483
|
+
provider_id: str,
|
|
484
|
+
run_id: str,
|
|
485
|
+
) -> str | None:
|
|
486
|
+
"""Lease token for user-only dev tools; upstream secret stays in API vault."""
|
|
487
|
+
provider_id = str(provider_id or "").strip().lower()
|
|
488
|
+
user = str(user_id or "").strip()
|
|
489
|
+
run_id = str(run_id or "").strip()
|
|
490
|
+
if not user or not provider_id or not run_id:
|
|
491
|
+
return None
|
|
492
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
493
|
+
resolved = _srv()._resolve_credential(user, workspace_id, provider_id, user_only=True)
|
|
494
|
+
if not resolved:
|
|
495
|
+
return None
|
|
496
|
+
if not _srv()._credential_secret(resolved, user):
|
|
497
|
+
return None
|
|
498
|
+
binding_id = str(
|
|
499
|
+
resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
|
|
500
|
+
).strip()
|
|
501
|
+
if not binding_id:
|
|
502
|
+
binding_id = str(uuid.uuid4())
|
|
503
|
+
credential_vault.store_secret(
|
|
504
|
+
binding_id,
|
|
505
|
+
_srv()._credential_secret(resolved, user),
|
|
506
|
+
env_var=str(resolved.get("env_var") or "") or _srv()._provider_env_var(provider_id),
|
|
507
|
+
provider=provider_id,
|
|
508
|
+
scope="user",
|
|
509
|
+
user_id=user,
|
|
510
|
+
workspace_id=workspace_id,
|
|
511
|
+
)
|
|
512
|
+
token = turn_credentials.issue_lease(
|
|
513
|
+
run_id,
|
|
514
|
+
user,
|
|
515
|
+
workspace_id,
|
|
516
|
+
provider_id,
|
|
517
|
+
prof,
|
|
518
|
+
binding_id or None,
|
|
519
|
+
)
|
|
520
|
+
env_var = str(resolved.get("env_var") or "") or _srv()._provider_env_var(provider_id)
|
|
521
|
+
env_path = _srv()._profile_dir(prof) / ".env"
|
|
522
|
+
_srv()._upsert_env_secret(env_path, env_var, token)
|
|
523
|
+
proxy_env = _action_proxy_env_var(provider_id)
|
|
524
|
+
if proxy_env:
|
|
525
|
+
_srv()._upsert_env_secret(env_path, proxy_env, _action_proxy_base_url(provider_id))
|
|
526
|
+
return token
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _revoke_turn_credential_lease(run_id: str, profile: str) -> None:
|
|
530
|
+
"""Revoke lease in DB; clear stale bearer from runtime profile config/gateway."""
|
|
531
|
+
run_id = str(run_id or "").strip()
|
|
532
|
+
if not run_id:
|
|
533
|
+
return
|
|
534
|
+
turn_credentials.revoke_lease(run_id)
|
|
535
|
+
for provider_id, _env_var in _user_action_env_specs():
|
|
536
|
+
turn_credentials.revoke_lease(f"{run_id}::{provider_id}")
|
|
537
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
538
|
+
if not _srv()._is_runtime_profile_slug(prof):
|
|
539
|
+
return
|
|
540
|
+
config_key = _read_config_model_api_key(prof)
|
|
541
|
+
if config_key.startswith(turn_credentials.LEASE_PREFIX):
|
|
542
|
+
_clear_profile_model_api_key(prof)
|
|
543
|
+
_srv()._restart_runtime_profile_gateway(prof)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _require_user_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
|
|
547
|
+
"""LLM billing uses the signed-in user's key only — same rule for chat and runtime agents."""
|
|
548
|
+
return _require_runtime_owner_provider(user_id, workspace_id, provider)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _require_runtime_owner_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
|
|
552
|
+
provider = str(provider or "openrouter").strip().lower()
|
|
553
|
+
oauth_spec = _srv()._oauth_llm_provider_spec(provider)
|
|
554
|
+
if oauth_spec and _srv()._hermes_oauth_tokens_present(user_id, _srv()._hermes_auth_id_for_spec(oauth_spec)):
|
|
555
|
+
hermes_auth_id = _srv()._hermes_auth_id_for_spec(oauth_spec)
|
|
556
|
+
return {
|
|
557
|
+
"credential_binding_id": None,
|
|
558
|
+
"credential_id": None,
|
|
559
|
+
"credential_ref": f"oauth:{hermes_auth_id}",
|
|
560
|
+
"scope": "user",
|
|
561
|
+
"provider": provider,
|
|
562
|
+
"credential_type": "oauth",
|
|
563
|
+
"label": hermes_auth_id,
|
|
564
|
+
"env_var": "",
|
|
565
|
+
"user_id": user_id,
|
|
566
|
+
"workspace_id": None,
|
|
567
|
+
"agent_profile_id": None,
|
|
568
|
+
"created_by": user_id,
|
|
569
|
+
"created_at": None,
|
|
570
|
+
"updated_at": None,
|
|
571
|
+
"expires_at": None,
|
|
572
|
+
}
|
|
573
|
+
resolved = _srv()._resolve_credential(user_id, workspace_id, provider, user_only=True)
|
|
574
|
+
if resolved and _srv()._credential_secret(resolved, user_id):
|
|
575
|
+
return resolved
|
|
576
|
+
if _srv()._workspace_credential_mode(None, workspace_id) != "workspace":
|
|
577
|
+
raise ValueError(
|
|
578
|
+
"no_llm_provider_for_user: Connect an LLM provider under Profile → Connected accts before chatting with agents."
|
|
579
|
+
)
|
|
580
|
+
resolved = _srv()._resolve_credential(user_id, workspace_id, provider, user_only=False)
|
|
581
|
+
if resolved and _srv()._credential_secret(resolved, user_id):
|
|
582
|
+
return resolved
|
|
583
|
+
raise ValueError(
|
|
584
|
+
"no_llm_provider_for_user: Connect an LLM provider under Profile → Connected accts before chatting with agents."
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _strip_profile_llm_env(profile: str, *, include_leases: bool = False) -> bool:
|
|
589
|
+
"""Remove shared-stack LLM keys from specialist profile .env."""
|
|
590
|
+
try:
|
|
591
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
592
|
+
except ValueError:
|
|
593
|
+
return False
|
|
594
|
+
if prof == _srv()._primary_profile() and not _srv()._is_runtime_profile_slug(prof):
|
|
595
|
+
return False
|
|
596
|
+
path = _srv()._profile_dir(prof) / ".env"
|
|
597
|
+
if not path.is_file():
|
|
598
|
+
return False
|
|
599
|
+
drop = set(_srv()._llm_provider_env_vars())
|
|
600
|
+
kept: list[str] = []
|
|
601
|
+
changed = False
|
|
602
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
603
|
+
stripped = line.strip()
|
|
604
|
+
if stripped and not stripped.startswith("#") and "=" in line:
|
|
605
|
+
key = line.partition("=")[0].strip()
|
|
606
|
+
_val = line.partition("=")[2].strip().strip('"').strip("'")
|
|
607
|
+
if include_leases and _val.startswith(turn_credentials.LEASE_PREFIX):
|
|
608
|
+
changed = True
|
|
609
|
+
continue
|
|
610
|
+
if key in drop:
|
|
611
|
+
# ponytail: keep active per-run lease tokens unless explicitly revoking
|
|
612
|
+
if not include_leases and _val.startswith(turn_credentials.LEASE_PREFIX):
|
|
613
|
+
kept.append(line)
|
|
614
|
+
continue
|
|
615
|
+
changed = True
|
|
616
|
+
continue
|
|
617
|
+
kept.append(line)
|
|
618
|
+
if changed:
|
|
619
|
+
path.write_text("\n".join(kept).rstrip() + ("\n" if kept else ""), encoding="utf-8")
|
|
620
|
+
return changed
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _strip_profile_action_env(profile: str) -> bool:
|
|
624
|
+
"""Remove dev PAT tokens from specialist profile .env (never shared-stack keys)."""
|
|
625
|
+
try:
|
|
626
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
627
|
+
except ValueError:
|
|
628
|
+
return False
|
|
629
|
+
if prof == _srv()._primary_profile() and not _srv()._is_runtime_profile_slug(prof):
|
|
630
|
+
return False
|
|
631
|
+
path = _srv()._profile_dir(prof) / ".env"
|
|
632
|
+
if not path.is_file():
|
|
633
|
+
return False
|
|
634
|
+
drop = set()
|
|
635
|
+
for _pid, env_var in _user_action_env_specs():
|
|
636
|
+
drop.add(env_var)
|
|
637
|
+
kept: list[str] = []
|
|
638
|
+
changed = False
|
|
639
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
640
|
+
stripped = line.strip()
|
|
641
|
+
if stripped and not stripped.startswith("#") and "=" in line:
|
|
642
|
+
key = line.partition("=")[0].strip()
|
|
643
|
+
if key in drop:
|
|
644
|
+
changed = True
|
|
645
|
+
continue
|
|
646
|
+
kept.append(line)
|
|
647
|
+
if changed:
|
|
648
|
+
path.write_text("\n".join(kept).rstrip() + ("\n" if kept else ""), encoding="utf-8")
|
|
649
|
+
return changed
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _overlay_turn_provider_env(
|
|
653
|
+
profile: str,
|
|
654
|
+
user_id: str,
|
|
655
|
+
workspace_id: str,
|
|
656
|
+
provider: str,
|
|
657
|
+
run_id: str = "",
|
|
658
|
+
) -> str:
|
|
659
|
+
"""Per-run lease into profile env; upstream key resolved only inside workframe-api."""
|
|
660
|
+
run_id = str(run_id or "").strip() or str(uuid.uuid4())
|
|
661
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
662
|
+
is_runtime = _srv()._is_runtime_profile_slug(prof)
|
|
663
|
+
config_before = _read_config_model_api_key(prof) if is_runtime else ""
|
|
664
|
+
token = _apply_turn_credential_lease(profile, user_id, workspace_id, provider, run_id)
|
|
665
|
+
if is_runtime and _read_config_model_api_key(prof) != config_before:
|
|
666
|
+
_srv()._invalidate_profile_health_cache(prof)
|
|
667
|
+
# ponytail: gateway restart after lease yaml sync can take ~40s on cold runtime profiles.
|
|
668
|
+
if not _srv()._wait_profile_api_healthy(prof, attempts=160, delay=0.25):
|
|
669
|
+
_srv().ensure_profile_api(prof, user_id, workspace_id, bootstrap_providers=False)
|
|
670
|
+
return token
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _try_overlay_turn_provider_env(
|
|
674
|
+
profile: str,
|
|
675
|
+
user_id: str,
|
|
676
|
+
workspace_id: str,
|
|
677
|
+
provider: str,
|
|
678
|
+
run_id: str = "",
|
|
679
|
+
) -> bool:
|
|
680
|
+
"""Best-effort LLM lease; returns False when the user has no key yet."""
|
|
681
|
+
try:
|
|
682
|
+
_overlay_turn_provider_env(profile, user_id, workspace_id, provider, run_id)
|
|
683
|
+
return True
|
|
684
|
+
except ValueError as exc:
|
|
685
|
+
if "no_llm_provider_for_user" in str(exc):
|
|
686
|
+
return False
|
|
687
|
+
raise
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _sync_profile_provider_env(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
|
|
691
|
+
"""Copy the user's own LLM secrets into a specialist profile .env (never stack keys)."""
|
|
692
|
+
prof = _srv().resolve_validated_profile(profile)
|
|
693
|
+
if prof == _srv()._primary_profile():
|
|
694
|
+
return False
|
|
695
|
+
user = str(user_id or "").strip()
|
|
696
|
+
if not user:
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
target = _srv()._profile_dir(prof) / ".env"
|
|
700
|
+
original = _srv()._read_env_map(target)
|
|
701
|
+
merged = dict(original)
|
|
702
|
+
needed = {key for key in _srv()._llm_provider_env_vars() if not merged.get(key)}
|
|
703
|
+
if not needed:
|
|
704
|
+
return False
|
|
705
|
+
|
|
706
|
+
for key, value in _srv()._read_env_map(_srv()._user_hermes_env_path(user)).items():
|
|
707
|
+
if key in needed and value:
|
|
708
|
+
merged[key] = value
|
|
709
|
+
needed.discard(key)
|
|
710
|
+
if merged == original:
|
|
711
|
+
return False
|
|
712
|
+
|
|
713
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
714
|
+
target.write_text("\n".join(f"{key}={merged[key]}" for key in sorted(merged)) + "\n", encoding="utf-8")
|
|
715
|
+
return True
|