create-workframe 0.1.6 → 0.1.8
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/package.json +1 -1
- package/workframe-api/package.json +1 -1
- package/workframe-api/server.py +841 -143
- package/workframe-api/stack_config.py +18 -10
- package/workframe-api/test_ensure_native_profile.py +29 -0
- package/workframe-api/test_oauth_llm.py +58 -0
- package/workframe-api/test_provider_model_surface.py +31 -0
- package/workframe-api/test_stack_config_install_smtp.py +56 -0
- package/workframe-ui/public/assets/{arc-DoYr2T5c.js → arc-DjpS9a2b.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-Clher08G.js → architectureDiagram-3BPJPVTR-puCKmKHV.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bdw9txrZ.js → blockDiagram-GPEHLZMM-C2P0g-eH.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DlI_h_uo.js → c4Diagram-AAUBKEIU-DdvYEITH.js} +1 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-ol-uwoeq.js → chunk-2J33WTMH-Cxe8QsEF.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-7eDhBen0.js → chunk-3OPIFGDE-DPNyTwR3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-xPORD7Js.js → chunk-4BX2VUAB-XKG9gfRN.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-Cwd7dr1z.js → chunk-55IACEB6-oPMTulgb.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Ckt4Sjy1.js → chunk-5ZQYHXKU-BWwXvpa_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-sMk1op_G.js → chunk-727SXJPM-Cml2twO_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CMgSvr6H.js → chunk-AQP2D5EJ-DzrRBhQu.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-Dx7GN0T4.js → chunk-BSJP7CBP-EZ7STU6h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q--OCu86u0.js → chunk-CSCIHK7Q-BGTQzWYw.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-CejmwJpz.js → chunk-FMBD7UC4-Cm4stfRa.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CvOj2Yvp.js → chunk-KSCS5N6A-exbMijjy.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-B0nGBjLU.js → chunk-L5ZTLDWV-C75kbQwN.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bdzvu227.js → chunk-LZXEDZCA-CsRdBqUB.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-CGR07jTY.js → chunk-ND2GUHAM-D84zyIu2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CBojDPNC.js → chunk-NZK2D7GU-CUNNo1bj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DBin5MPF.js → chunk-O5CBEL6O-DN6WEIh-.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-TD7I7pcb.js → chunk-XPW4576I-Ct5VQv2D.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bmm5x5xm.js → cose-bilkent-S5V4N54A-Bj4W5oMk.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-zU2r0aq2.js → dagre-BM42HDAG-DAjHBkAy.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-AL01qukm.js → diagram-2AECGRRQ-Ch2gGFPG.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-DKi1xSMg.js → diagram-5GNKFQAL-BzIIvgYH.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQJDQ-f-.js → diagram-KO2AKTUF-Df0Q8nv6.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-B1oisGUj.js → diagram-LMA3HP47-D-G3ekmF.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-DjpJKffk.js → diagram-OG6HWLK6-TBHhT9f5.js} +1 -1
- package/workframe-ui/public/assets/{dist-Jk20AnQG.js → dist-CPbiDkEZ.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Vu8RtLUf.js → erDiagram-TEJ5UH35-YxkUmLLV.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CHAXEhbR.js → flowDiagram-I6XJVG4X-kfZ7-RSd.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DhiEkfH8.js → ganttDiagram-6RSMTGT7-DmTx3hzR.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-Cuq44XvU.js → gitGraph-WXDBUCRP-BXa0ewp3.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-B7kgReRt.js → gitGraphDiagram-PVQCEYII-CQr2qMxU.js} +1 -1
- package/workframe-ui/public/assets/{index-auJ0bLMU.css → index-B08aShJy.css} +1 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +129 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-oV4b3M4f.js → info-J43DQDTF-t-Qlx8zv.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CthN4b6o.js → infoDiagram-5YYISTIA-CzkYofPx.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-oEGS3TKg.js → ishikawaDiagram-YF4QCWOH-djK3Oxgb.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CXJsWmKF.js → journeyDiagram-JHISSGLW-CWxYXxdF.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DTPo8yHW.js → kanban-definition-UN3LZRKU-D7leCAD9.js} +1 -1
- package/workframe-ui/public/assets/{line-Bm9Pmcnz.js → line-BSFj2OC5.js} +1 -1
- package/workframe-ui/public/assets/{linear-Cn-uD6eX.js → linear-pfPwnu-o.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-BBVSTQZ4.js → mermaid-parser.core-Dm2L9Ib_.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-DE668nUy.js → mermaid.core-Bevx9bif.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-4qVsiwCf.js → mindmap-definition-RKZ34NQL-UkjDssmP.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-CqJeoiFG.js → packet-YPE3B663-JO2ezH3_.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DcOqVssg.js → pie-LRSECV5Y-DdMFsfR8.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CosMnpAM.js → pieDiagram-4H26LBE5-CEPHFBU1.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-DtsnIK98.js → quadrantDiagram-W4KKPZXB-_RF9_86k.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BqLpJuWc.js → radar-GUYGQ44K-Cb-YfdaA.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Pb7cuX8_.js → requirementDiagram-4Y6WPE33-DBVOxmBn.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BJBlMM1m.js → sankeyDiagram-5OEKKPKP-BTpxsGUA.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-i4HnjP39.js → sequenceDiagram-3UESZ5HK-DHP_Bq3l.js} +1 -1
- package/workframe-ui/public/assets/{src-DIhmc_XG.js → src-CY_By-r7.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-Ba2iClPT.js → stateDiagram-AJRCARHV-BVtp_LPR.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-CL4X9vSF.js → timeline-definition-PNZ67QCA-D38HJhop.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-BJrjnbMj.js → treeView-BLDUP644-CFCG3Sc_.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CN6H54RF.js → treemap-LRROVOQU-BeiMs_dF.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-Cee6sbUy.js → vennDiagram-CIIHVFJN-CKMeT6PW.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-D0cXq60c.js → wardley-L42UT6IY-BljDy8ph.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CaH9XkCx.js → wardleyDiagram-YWT4CUSO-DEE8D94J.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BEisgFoI.js → xychartDiagram-2RQKCTM6-BRaAWV_g.js} +1 -1
- package/workframe-ui/public/index.html +2 -2
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CmDi28hh.js +0 -1
- package/workframe-ui/public/assets/channel-DZYVoEdD.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-DwiajAaK.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-Bz8vA5s7.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DQiq8od0.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DQiq8od0.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BXUnTe0c.js +0 -1
- package/workframe-ui/public/assets/index-72-j0sA5.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-aUpqRAhy.js +0 -1
package/workframe-api/server.py
CHANGED
|
@@ -908,8 +908,13 @@ def _auth_check(handler: BaseHTTPRequestHandler) -> bool:
|
|
|
908
908
|
if method == "OPTIONS":
|
|
909
909
|
return True
|
|
910
910
|
|
|
911
|
-
# GET public routes
|
|
911
|
+
# GET public routes — still attach session user when cookie/header present
|
|
912
|
+
# (e.g. /api/hermes/models needs auth_user for per-user provider detection).
|
|
912
913
|
if method == "GET" and _is_public_get(path):
|
|
914
|
+
if sid:
|
|
915
|
+
session_info = _zk.validate_session_token(sid)
|
|
916
|
+
if session_info:
|
|
917
|
+
_apply_session_user(handler, session_info["user_id"])
|
|
913
918
|
return True
|
|
914
919
|
|
|
915
920
|
# POST auth endpoints (start/verify/logout/refresh) are public
|
|
@@ -1101,13 +1106,13 @@ def _provider_connected_for_user(
|
|
|
1101
1106
|
return False
|
|
1102
1107
|
|
|
1103
1108
|
|
|
1104
|
-
def
|
|
1109
|
+
def _user_hermes_dir_slug(user_id: str) -> str:
|
|
1105
1110
|
raw = str(user_id or "").strip()
|
|
1106
1111
|
return re.sub(r"[^A-Za-z0-9._-]+", "_", raw) or "user"
|
|
1107
1112
|
|
|
1108
1113
|
|
|
1109
1114
|
def _user_hermes_home(user_id: str) -> Path:
|
|
1110
|
-
return HERMES_DATA / "profiles" /
|
|
1115
|
+
return HERMES_DATA / "profiles" / _user_hermes_dir_slug(user_id)
|
|
1111
1116
|
|
|
1112
1117
|
|
|
1113
1118
|
def _user_hermes_env_path(user_id: str) -> Path:
|
|
@@ -2076,6 +2081,53 @@ def _merge_user_platform_ids(user_id: str, patch: dict[str, str]) -> None:
|
|
|
2076
2081
|
_apply_me_profile_updates(user_id, {"platform_ids": merged})
|
|
2077
2082
|
|
|
2078
2083
|
|
|
2084
|
+
def _read_user_llm_prefs(user_id: str) -> tuple[str, list[dict[str, str]]]:
|
|
2085
|
+
"""User-level default model prefs (not agent profile config)."""
|
|
2086
|
+
user = _get_workframe_user(str(user_id or "").strip())
|
|
2087
|
+
pids = user.get("platform_ids") if user and isinstance(user.get("platform_ids"), dict) else {}
|
|
2088
|
+
primary = str(pids.get("llm_primary") or "").strip()
|
|
2089
|
+
chain_raw = pids.get("llm_fallback_chain")
|
|
2090
|
+
chain: list[dict[str, str]] = []
|
|
2091
|
+
if isinstance(chain_raw, str) and chain_raw.strip():
|
|
2092
|
+
try:
|
|
2093
|
+
parsed = json.loads(chain_raw)
|
|
2094
|
+
if isinstance(parsed, list):
|
|
2095
|
+
for entry in parsed:
|
|
2096
|
+
if isinstance(entry, dict):
|
|
2097
|
+
prov = str(entry.get("provider") or "").strip()
|
|
2098
|
+
model = str(entry.get("model") or "").strip()
|
|
2099
|
+
if prov and model:
|
|
2100
|
+
chain.append({"provider": prov, "model": model})
|
|
2101
|
+
except json.JSONDecodeError:
|
|
2102
|
+
pass
|
|
2103
|
+
return primary, chain
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _write_user_llm_prefs(
|
|
2107
|
+
user_id: str,
|
|
2108
|
+
*,
|
|
2109
|
+
primary: str = "",
|
|
2110
|
+
fallback_chain: list[dict[str, str]] | None = None,
|
|
2111
|
+
) -> None:
|
|
2112
|
+
user_id = str(user_id or "").strip()
|
|
2113
|
+
if not user_id:
|
|
2114
|
+
return
|
|
2115
|
+
patch: dict[str, str] = {}
|
|
2116
|
+
if primary:
|
|
2117
|
+
patch["llm_primary"] = primary.strip()
|
|
2118
|
+
if fallback_chain is not None:
|
|
2119
|
+
patch["llm_fallback_chain"] = json.dumps(fallback_chain, separators=(",", ":"))
|
|
2120
|
+
if patch:
|
|
2121
|
+
_merge_user_platform_ids(user_id, patch)
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _user_llm_has_provider(user_id: str, workspace_id: str = "") -> bool:
|
|
2125
|
+
if _user_has_llm_provider(user_id):
|
|
2126
|
+
return True
|
|
2127
|
+
ws = str(workspace_id or "").strip()
|
|
2128
|
+
return bool(ws and _workspace_has_llm_provider(ws))
|
|
2129
|
+
|
|
2130
|
+
|
|
2079
2131
|
def _start_discord_oauth(user_id: str, workspace_id: str = "") -> dict[str, Any]:
|
|
2080
2132
|
cfg = platform_auth.resolved_discord_oauth()
|
|
2081
2133
|
if not cfg.get("client_id") or not cfg.get("client_secret"):
|
|
@@ -2239,7 +2291,7 @@ def _remove_auth_metadata(auth_path: Path, credential_ref: str) -> None:
|
|
|
2239
2291
|
|
|
2240
2292
|
|
|
2241
2293
|
def _hermes_user_home_container(user_id: str) -> str:
|
|
2242
|
-
return f"/opt/data/profiles/{
|
|
2294
|
+
return f"/opt/data/profiles/{_user_hermes_dir_slug(user_id)}"
|
|
2243
2295
|
|
|
2244
2296
|
|
|
2245
2297
|
def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
|
|
@@ -2274,7 +2326,7 @@ def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
|
|
|
2274
2326
|
f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; "
|
|
2275
2327
|
f"/opt/hermes/bin/hermes {inner}"
|
|
2276
2328
|
)
|
|
2277
|
-
return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=
|
|
2329
|
+
return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=_user_hermes_dir_slug(user_id))
|
|
2278
2330
|
|
|
2279
2331
|
|
|
2280
2332
|
def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
|
|
@@ -2312,6 +2364,108 @@ def _hermes_auth_id_for_spec(spec: dict[str, Any]) -> str:
|
|
|
2312
2364
|
return str(spec.get("hermes_auth_id") or spec.get("id") or "").strip()
|
|
2313
2365
|
|
|
2314
2366
|
|
|
2367
|
+
def _hermes_oauth_auth_keys(hermes_auth_id: str) -> set[str]:
|
|
2368
|
+
raw = str(hermes_auth_id or "").strip().lower()
|
|
2369
|
+
if not raw:
|
|
2370
|
+
return set()
|
|
2371
|
+
keys = {raw, raw.replace("-", ""), raw.replace("-", "_")}
|
|
2372
|
+
if "-" in raw:
|
|
2373
|
+
keys.add(raw.split("-")[-1])
|
|
2374
|
+
return {key for key in keys if key}
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
def _oauth_llm_provider_spec(provider: str) -> dict[str, Any] | None:
|
|
2378
|
+
spec = _catalog_provider_for_llm(provider)
|
|
2379
|
+
if spec and str(spec.get("connect_mode") or "") == "oauth":
|
|
2380
|
+
return spec
|
|
2381
|
+
return None
|
|
2382
|
+
|
|
2383
|
+
|
|
2384
|
+
def _auth_json_has_oauth_material(data: dict[str, Any]) -> bool:
|
|
2385
|
+
providers = data.get("providers")
|
|
2386
|
+
if isinstance(providers, dict) and providers:
|
|
2387
|
+
return True
|
|
2388
|
+
pool = data.get("credential_pool")
|
|
2389
|
+
if isinstance(pool, dict) and any(isinstance(entries, list) and entries for entries in pool.values()):
|
|
2390
|
+
return True
|
|
2391
|
+
creds = data.get("credentials")
|
|
2392
|
+
return isinstance(creds, list) and bool(creds)
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
def _extract_oauth_block_from_auth(loaded: dict[str, Any], hermes_auth_id: str) -> dict[str, Any] | None:
|
|
2396
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
2397
|
+
providers = loaded.get("providers")
|
|
2398
|
+
if isinstance(providers, dict):
|
|
2399
|
+
for key, entry in providers.items():
|
|
2400
|
+
if str(key).lower() in keys and isinstance(entry, dict):
|
|
2401
|
+
tokens = entry.get("tokens")
|
|
2402
|
+
if isinstance(tokens, dict) and any(
|
|
2403
|
+
str(tokens.get(field) or "").strip()
|
|
2404
|
+
for field in ("access_token", "refresh_token", "api_key", "id_token")
|
|
2405
|
+
):
|
|
2406
|
+
return entry
|
|
2407
|
+
if entry:
|
|
2408
|
+
return entry
|
|
2409
|
+
creds = loaded.get("credentials")
|
|
2410
|
+
if isinstance(creds, list):
|
|
2411
|
+
for row in creds:
|
|
2412
|
+
if not isinstance(row, dict):
|
|
2413
|
+
continue
|
|
2414
|
+
pid = str(row.get("provider") or row.get("id") or "").lower()
|
|
2415
|
+
if pid in keys:
|
|
2416
|
+
return row
|
|
2417
|
+
return None
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
def _merge_oauth_auth_into_profile(
|
|
2421
|
+
auth: dict[str, Any],
|
|
2422
|
+
user_auth: dict[str, Any],
|
|
2423
|
+
hermes_auth_id: str,
|
|
2424
|
+
) -> bool:
|
|
2425
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
2426
|
+
changed = False
|
|
2427
|
+
block = _extract_oauth_block_from_auth(user_auth, hermes_auth_id)
|
|
2428
|
+
if isinstance(block, dict):
|
|
2429
|
+
merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
|
|
2430
|
+
merged[hermes_auth_id] = block
|
|
2431
|
+
auth["providers"] = merged
|
|
2432
|
+
changed = True
|
|
2433
|
+
user_pool = user_auth.get("credential_pool")
|
|
2434
|
+
if isinstance(user_pool, dict):
|
|
2435
|
+
pool = auth.get("credential_pool")
|
|
2436
|
+
if not isinstance(pool, dict):
|
|
2437
|
+
pool = {}
|
|
2438
|
+
auth["credential_pool"] = pool
|
|
2439
|
+
for key, entries in user_pool.items():
|
|
2440
|
+
if str(key).lower() in keys and isinstance(entries, list) and entries:
|
|
2441
|
+
pool[hermes_auth_id] = entries
|
|
2442
|
+
changed = True
|
|
2443
|
+
return changed
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> bool:
|
|
2447
|
+
spec = _oauth_llm_provider_spec(provider)
|
|
2448
|
+
if not spec:
|
|
2449
|
+
return False
|
|
2450
|
+
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
2451
|
+
if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
2452
|
+
return False
|
|
2453
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
2454
|
+
if not isinstance(user_auth, dict):
|
|
2455
|
+
return False
|
|
2456
|
+
prof = resolve_hermes_profile(profile)
|
|
2457
|
+
auth_path = _profile_dir(prof) / "auth.json"
|
|
2458
|
+
auth = _load_profile_auth_json(prof)
|
|
2459
|
+
if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
|
|
2460
|
+
return False
|
|
2461
|
+
auth["version"] = 1
|
|
2462
|
+
auth["updated_at"] = _utc_now()
|
|
2463
|
+
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2464
|
+
auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
2465
|
+
_publish_profile_gateway_secrets(prof)
|
|
2466
|
+
return True
|
|
2467
|
+
|
|
2468
|
+
|
|
2315
2469
|
def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
|
|
2316
2470
|
"""True when Hermes auth.json has live OAuth tokens for a provider."""
|
|
2317
2471
|
hermes_auth_id = str(hermes_auth_id or "").strip()
|
|
@@ -2320,23 +2474,13 @@ def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
|
|
|
2320
2474
|
loaded = _load_user_hermes_auth(user_id)
|
|
2321
2475
|
if not isinstance(loaded, dict):
|
|
2322
2476
|
return False
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
entry = providers.get(key)
|
|
2327
|
-
if not isinstance(entry, dict):
|
|
2328
|
-
continue
|
|
2329
|
-
tokens = entry.get("tokens")
|
|
2330
|
-
if isinstance(tokens, dict) and any(
|
|
2331
|
-
str(tokens.get(field) or "").strip()
|
|
2332
|
-
for field in ("access_token", "refresh_token", "api_key", "id_token")
|
|
2333
|
-
):
|
|
2334
|
-
return True
|
|
2477
|
+
if _extract_oauth_block_from_auth(loaded, hermes_auth_id):
|
|
2478
|
+
return True
|
|
2479
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
2335
2480
|
pool = loaded.get("credential_pool")
|
|
2336
2481
|
if isinstance(pool, dict):
|
|
2337
|
-
for key
|
|
2338
|
-
|
|
2339
|
-
if isinstance(entries, list) and entries:
|
|
2482
|
+
for key, entries in pool.items():
|
|
2483
|
+
if str(key).lower() in keys and isinstance(entries, list) and entries:
|
|
2340
2484
|
return True
|
|
2341
2485
|
return False
|
|
2342
2486
|
|
|
@@ -2351,9 +2495,9 @@ def _load_user_hermes_auth(user_id: str) -> dict[str, Any] | None:
|
|
|
2351
2495
|
loaded = data
|
|
2352
2496
|
except (OSError, json.JSONDecodeError):
|
|
2353
2497
|
loaded = None
|
|
2354
|
-
if isinstance(loaded, dict) and
|
|
2498
|
+
if isinstance(loaded, dict) and _auth_json_has_oauth_material(loaded):
|
|
2355
2499
|
return loaded
|
|
2356
|
-
rel = f"profiles/{
|
|
2500
|
+
rel = f"profiles/{_user_hermes_dir_slug(user_id)}/auth.json"
|
|
2357
2501
|
text = _read_gateway_data_file(rel)
|
|
2358
2502
|
if text.strip():
|
|
2359
2503
|
try:
|
|
@@ -2488,7 +2632,7 @@ def _remove_hermes_oauth_provider(user_id: str, hermes_auth_id: str) -> None:
|
|
|
2488
2632
|
pool.pop(key, None)
|
|
2489
2633
|
loaded["updated_at"] = _utc_now()
|
|
2490
2634
|
auth_path.write_text(json.dumps(loaded, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
2491
|
-
user_part = re.sub(r"[^a-z0-9]+", "-",
|
|
2635
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
2492
2636
|
prefix = f"u-{user_part}-"
|
|
2493
2637
|
profiles_dir = HERMES_DATA / "profiles"
|
|
2494
2638
|
if profiles_dir.is_dir():
|
|
@@ -2594,25 +2738,12 @@ def _parse_device_oauth_log(text: str) -> dict[str, str | None]:
|
|
|
2594
2738
|
|
|
2595
2739
|
|
|
2596
2740
|
def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id: str) -> None:
|
|
2597
|
-
|
|
2598
|
-
if not src_path.is_file():
|
|
2599
|
-
return
|
|
2600
|
-
try:
|
|
2601
|
-
user_auth = json.loads(src_path.read_text(encoding="utf-8"))
|
|
2602
|
-
except (OSError, json.JSONDecodeError):
|
|
2603
|
-
return
|
|
2604
|
-
providers = user_auth.get("providers") if isinstance(user_auth, dict) else None
|
|
2605
|
-
if not isinstance(providers, dict):
|
|
2741
|
+
if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
2606
2742
|
return
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
entry = providers.get(key)
|
|
2610
|
-
if isinstance(entry, dict):
|
|
2611
|
-
block = entry
|
|
2612
|
-
break
|
|
2613
|
-
if not block:
|
|
2743
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
2744
|
+
if not isinstance(user_auth, dict):
|
|
2614
2745
|
return
|
|
2615
|
-
user_part = re.sub(r"[^a-z0-9]+", "-",
|
|
2746
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
2616
2747
|
prefix = f"u-{user_part}-"
|
|
2617
2748
|
profiles_dir = HERMES_DATA / "profiles"
|
|
2618
2749
|
if not profiles_dir.is_dir():
|
|
@@ -2622,9 +2753,11 @@ def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id:
|
|
|
2622
2753
|
continue
|
|
2623
2754
|
auth_path = prof_dir / "auth.json"
|
|
2624
2755
|
auth = _load_profile_auth_json(prof_dir.name)
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2756
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
2757
|
+
if not isinstance(user_auth, dict):
|
|
2758
|
+
continue
|
|
2759
|
+
if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
|
|
2760
|
+
continue
|
|
2628
2761
|
auth["version"] = 1
|
|
2629
2762
|
auth["updated_at"] = _utc_now()
|
|
2630
2763
|
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -2743,7 +2876,7 @@ def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict
|
|
|
2743
2876
|
except OSError:
|
|
2744
2877
|
log_text = ""
|
|
2745
2878
|
if not log_text.strip() and log_path.name:
|
|
2746
|
-
log_text = _read_gateway_data_file(f"profiles/{
|
|
2879
|
+
log_text = _read_gateway_data_file(f"profiles/{_user_hermes_dir_slug(user_id)}/{log_path.name}")
|
|
2747
2880
|
parsed = _parse_device_oauth_log(log_text)
|
|
2748
2881
|
patch: dict[str, Any] = {}
|
|
2749
2882
|
if parsed.get("verification_uri"):
|
|
@@ -4990,6 +5123,8 @@ def bootstrap_agent_dm_lane(
|
|
|
4990
5123
|
"""Provision u-* runtime, model/proxy, DM room, and optional session bind — install/create-agent parity."""
|
|
4991
5124
|
user_id = str(user_id or "").strip()
|
|
4992
5125
|
workspace_id = str(workspace_id or "").strip()
|
|
5126
|
+
if not _native_profile_present():
|
|
5127
|
+
_ensure_native_hermes_profile()
|
|
4993
5128
|
template = resolve_validated_profile(str(template_slug or "").strip())
|
|
4994
5129
|
if not user_id or not workspace_id:
|
|
4995
5130
|
return {"ok": False, "error": "user_id and workspace_id required"}
|
|
@@ -5015,6 +5150,25 @@ def bootstrap_agent_dm_lane(
|
|
|
5015
5150
|
try:
|
|
5016
5151
|
_bootstrap_profile_providers(runtime, user_id, workspace_id)
|
|
5017
5152
|
steps.append({"step": "bootstrap_providers", "ok": True})
|
|
5153
|
+
user_primary, user_chain = _read_user_llm_prefs(user_id)
|
|
5154
|
+
if user_primary:
|
|
5155
|
+
applied = hermes_model_set(runtime, user_primary, user_id, workspace_id)
|
|
5156
|
+
steps.append(
|
|
5157
|
+
{
|
|
5158
|
+
"step": "user_default_model",
|
|
5159
|
+
"ok": bool(applied.get("ok")),
|
|
5160
|
+
"model": user_primary,
|
|
5161
|
+
},
|
|
5162
|
+
)
|
|
5163
|
+
if user_chain:
|
|
5164
|
+
chained = hermes_fallback_chain_set(runtime, user_chain, user_id=user_id)
|
|
5165
|
+
steps.append(
|
|
5166
|
+
{
|
|
5167
|
+
"step": "user_default_fallbacks",
|
|
5168
|
+
"ok": bool(chained.get("ok")),
|
|
5169
|
+
"count": len(user_chain),
|
|
5170
|
+
},
|
|
5171
|
+
)
|
|
5018
5172
|
except Exception as exc:
|
|
5019
5173
|
steps.append({"step": "bootstrap_providers", "ok": False, "error": str(exc)})
|
|
5020
5174
|
|
|
@@ -5137,7 +5291,7 @@ def bootstrap_agent_dm_lane(
|
|
|
5137
5291
|
|
|
5138
5292
|
def _runtime_profile_slug(user_id: str, template_slug: str) -> str:
|
|
5139
5293
|
template_slug = safe_profile_slug(template_slug)
|
|
5140
|
-
user_part = re.sub(r"[^a-z0-9]+", "-",
|
|
5294
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
5141
5295
|
slug = f"u-{user_part}-{template_slug}"
|
|
5142
5296
|
if len(slug) > 64:
|
|
5143
5297
|
slug = slug[:64].rstrip("-")
|
|
@@ -5169,7 +5323,8 @@ def _prepare_runtime_profile_credentials(
|
|
|
5169
5323
|
return False
|
|
5170
5324
|
_strip_profile_llm_env(runtime)
|
|
5171
5325
|
_strip_profile_action_env(runtime)
|
|
5172
|
-
|
|
5326
|
+
_reconcile_profile_llm_for_user(runtime, user, workspace_id)
|
|
5327
|
+
prov = _llm_billing_provider(runtime, user_id=user, workspace_id=workspace_id)
|
|
5173
5328
|
_ensure_profile_llm_proxy(runtime, prov)
|
|
5174
5329
|
return _user_can_use_llm(user, workspace_id, prov)
|
|
5175
5330
|
|
|
@@ -6085,6 +6240,7 @@ def ensure_runtime_profile(
|
|
|
6085
6240
|
if not _profile_toolsets_ready(runtime, _chat_toolsets_for_profile(runtime)):
|
|
6086
6241
|
_ensure_profile_toolsets(runtime)
|
|
6087
6242
|
_ensure_profile_terminal_cwd(runtime)
|
|
6243
|
+
_sync_runtime_model_from_template(runtime, template)
|
|
6088
6244
|
return
|
|
6089
6245
|
_purge_runtime_profile(runtime)
|
|
6090
6246
|
_register_runtime_profile(runtime, template)
|
|
@@ -7601,10 +7757,6 @@ def _bootstrap_after_setup(agent_personality: str = "") -> dict[str, Any]:
|
|
|
7601
7757
|
if personality:
|
|
7602
7758
|
_seed_native_user_overlay(profile, profile, user_soul=personality)
|
|
7603
7759
|
result["soul_seeded"] = True
|
|
7604
|
-
try:
|
|
7605
|
-
result["gateway"] = profile_gateway_lifecycle(profile, "status")
|
|
7606
|
-
except Exception as exc:
|
|
7607
|
-
result["gateway"] = {"ok": False, "error": str(exc)}
|
|
7608
7760
|
return result
|
|
7609
7761
|
|
|
7610
7762
|
|
|
@@ -7858,6 +8010,110 @@ def _list_profiles() -> list[str]:
|
|
|
7858
8010
|
)
|
|
7859
8011
|
|
|
7860
8012
|
|
|
8013
|
+
def _native_profile_slug() -> str:
|
|
8014
|
+
return str(NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
|
|
8015
|
+
|
|
8016
|
+
|
|
8017
|
+
def _native_profile_present() -> bool:
|
|
8018
|
+
slug = _native_profile_slug()
|
|
8019
|
+
prof_dir = _profile_dir(slug)
|
|
8020
|
+
return (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file()
|
|
8021
|
+
|
|
8022
|
+
|
|
8023
|
+
def _hermes_data_uid_gid() -> tuple[int, int]:
|
|
8024
|
+
try:
|
|
8025
|
+
st = HERMES_DATA.stat()
|
|
8026
|
+
return int(st.st_uid), int(st.st_gid)
|
|
8027
|
+
except OSError:
|
|
8028
|
+
return 10000, 10000
|
|
8029
|
+
|
|
8030
|
+
|
|
8031
|
+
def _chown_profile_tree(prof_dir: Path) -> None:
|
|
8032
|
+
"""Gateway runs as the Agents volume owner — seeded files must match."""
|
|
8033
|
+
uid, gid = _hermes_data_uid_gid()
|
|
8034
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
8035
|
+
(prof_dir / "logs").mkdir(parents=True, exist_ok=True)
|
|
8036
|
+
for path in [prof_dir, *prof_dir.rglob("*")]:
|
|
8037
|
+
try:
|
|
8038
|
+
os.chown(path, uid, gid)
|
|
8039
|
+
except OSError:
|
|
8040
|
+
pass
|
|
8041
|
+
|
|
8042
|
+
|
|
8043
|
+
def _seed_native_profile_on_disk(slug: str) -> bool:
|
|
8044
|
+
"""Filesystem fallback when Hermes CLI cannot run (empty / crash-looping gateway)."""
|
|
8045
|
+
slug = safe_profile_slug(slug)
|
|
8046
|
+
prof_dir = _profile_dir(slug)
|
|
8047
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
8048
|
+
config = prof_dir / "config.yaml"
|
|
8049
|
+
if not config.is_file():
|
|
8050
|
+
proxy = _llm_proxy_base_url("openrouter")
|
|
8051
|
+
config.write_text(
|
|
8052
|
+
"model:\n"
|
|
8053
|
+
" default: google/gemini-2.5-flash\n"
|
|
8054
|
+
" provider: custom\n"
|
|
8055
|
+
f" base_url: {proxy}\n",
|
|
8056
|
+
encoding="utf-8",
|
|
8057
|
+
)
|
|
8058
|
+
soul = prof_dir / "SOUL.md"
|
|
8059
|
+
if not soul.is_file():
|
|
8060
|
+
soul.write_text(
|
|
8061
|
+
f"You are the {PROJECT_NAME} native agent — orchestrator and workspace admin.\n",
|
|
8062
|
+
encoding="utf-8",
|
|
8063
|
+
)
|
|
8064
|
+
_ensure_profile_toolsets(slug)
|
|
8065
|
+
_ensure_profile_terminal_cwd(slug)
|
|
8066
|
+
_register_profile_route(
|
|
8067
|
+
slug,
|
|
8068
|
+
{
|
|
8069
|
+
"display_name": f"{PROJECT_NAME} Agent",
|
|
8070
|
+
"role": f"{PROJECT_NAME} Manager",
|
|
8071
|
+
},
|
|
8072
|
+
)
|
|
8073
|
+
routes_path = ROUTES_JSON
|
|
8074
|
+
if routes_path.is_file():
|
|
8075
|
+
try:
|
|
8076
|
+
data = json.loads(routes_path.read_text(encoding="utf-8"))
|
|
8077
|
+
if isinstance(data, dict) and not data.get("default_profile"):
|
|
8078
|
+
data["default_profile"] = slug
|
|
8079
|
+
routes_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
8080
|
+
except (OSError, json.JSONDecodeError):
|
|
8081
|
+
pass
|
|
8082
|
+
_chown_profile_tree(prof_dir)
|
|
8083
|
+
return _native_profile_present()
|
|
8084
|
+
|
|
8085
|
+
|
|
8086
|
+
def _ensure_native_hermes_profile() -> bool:
|
|
8087
|
+
"""Idempotent: native workframe-agent must exist before gateway boot or DM bootstrap."""
|
|
8088
|
+
slug = _native_profile_slug()
|
|
8089
|
+
if _native_profile_present():
|
|
8090
|
+
return True
|
|
8091
|
+
shell = (
|
|
8092
|
+
"export HERMES_HOME=/opt/data HOME=/opt/data; "
|
|
8093
|
+
f"/opt/hermes/bin/hermes profile create {shlex.quote(slug)} --clone "
|
|
8094
|
+
f"--description {shlex.quote(f'{PROJECT_NAME} native agent')}"
|
|
8095
|
+
)
|
|
8096
|
+
try:
|
|
8097
|
+
code, out = _gateway_container_exec(["sh", "-lc", shell])
|
|
8098
|
+
if code == 0 and _native_profile_present():
|
|
8099
|
+
_register_profile_route(
|
|
8100
|
+
slug,
|
|
8101
|
+
{"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
|
|
8102
|
+
)
|
|
8103
|
+
_chown_profile_tree(_profile_dir(slug))
|
|
8104
|
+
return True
|
|
8105
|
+
if "already exists" in out.lower() and _native_profile_present():
|
|
8106
|
+
_register_profile_route(
|
|
8107
|
+
slug,
|
|
8108
|
+
{"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
|
|
8109
|
+
)
|
|
8110
|
+
_chown_profile_tree(_profile_dir(slug))
|
|
8111
|
+
return True
|
|
8112
|
+
except Exception:
|
|
8113
|
+
pass
|
|
8114
|
+
return _seed_native_profile_on_disk(slug)
|
|
8115
|
+
|
|
8116
|
+
|
|
7861
8117
|
def _primary_profile() -> str:
|
|
7862
8118
|
if NATIVE_PROFILE and (HERMES_DATA / "profiles" / NATIVE_PROFILE).is_dir():
|
|
7863
8119
|
return NATIVE_PROFILE
|
|
@@ -10494,6 +10750,28 @@ def _profile_model(profile: str) -> str:
|
|
|
10494
10750
|
conn.close()
|
|
10495
10751
|
|
|
10496
10752
|
|
|
10753
|
+
def _llm_attribution_for_profile(profile: str, session_model: str = "") -> tuple[str, str]:
|
|
10754
|
+
"""Resolve model id + billing provider for chat turn footers."""
|
|
10755
|
+
_ = session_model # ponytail: Hermes sessions.model is profile slug, not LLM id
|
|
10756
|
+
try:
|
|
10757
|
+
prof = resolve_hermes_profile(str(profile or "").strip())
|
|
10758
|
+
except ValueError:
|
|
10759
|
+
return "", ""
|
|
10760
|
+
block = _read_model_block(prof)
|
|
10761
|
+
model = str(block.get("default") or "").strip()
|
|
10762
|
+
billing = _billing_provider_id_from_hermes_config(str(block.get("provider") or ""))
|
|
10763
|
+
if not billing:
|
|
10764
|
+
billing = _llm_billing_provider(prof)
|
|
10765
|
+
if not billing and model:
|
|
10766
|
+
infer_llm = {
|
|
10767
|
+
str(spec["id"]).lower()
|
|
10768
|
+
for spec in PROVIDER_CONNECT_CATALOG
|
|
10769
|
+
if str(spec.get("category") or "") == "llm"
|
|
10770
|
+
}
|
|
10771
|
+
billing = _resolve_billing_provider_for_model(model, infer_llm)
|
|
10772
|
+
return model, str(billing or "").strip()
|
|
10773
|
+
|
|
10774
|
+
|
|
10497
10775
|
def _latest_session_id(profile: str) -> str:
|
|
10498
10776
|
"""Most recent session — prefer active (ended_at IS NULL), then by started_at."""
|
|
10499
10777
|
db = _profile_dir(profile) / "state.db"
|
|
@@ -10720,13 +10998,16 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
|
|
|
10720
10998
|
_runtime_template_slug(profile) if _is_runtime_profile_slug(profile) else profile
|
|
10721
10999
|
)
|
|
10722
11000
|
display = _profile_display_name(profile)
|
|
11001
|
+
attr_model, attr_provider = _llm_attribution_for_profile(profile)
|
|
10723
11002
|
try:
|
|
10724
11003
|
raw = conn.execute(
|
|
10725
11004
|
"""
|
|
10726
|
-
SELECT id, role, content, reasoning_content, tool_name,
|
|
10727
|
-
|
|
10728
|
-
|
|
10729
|
-
|
|
11005
|
+
SELECT m.id, m.role, m.content, m.reasoning_content, m.tool_name,
|
|
11006
|
+
m.timestamp, m.token_count, s.model AS session_model
|
|
11007
|
+
FROM messages m
|
|
11008
|
+
LEFT JOIN sessions s ON s.id = m.session_id
|
|
11009
|
+
WHERE m.role IN ('user', 'assistant', 'tool') AND m.session_id = ?
|
|
11010
|
+
ORDER BY m.timestamp ASC, m.id ASC
|
|
10730
11011
|
LIMIT 200
|
|
10731
11012
|
""",
|
|
10732
11013
|
(sid,),
|
|
@@ -10759,6 +11040,9 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
|
|
|
10759
11040
|
if current and current["role"] == "agent":
|
|
10760
11041
|
current["segments"].extend(segments)
|
|
10761
11042
|
current["tokens"] += tokens
|
|
11043
|
+
if attr_model:
|
|
11044
|
+
current["model"] = attr_model
|
|
11045
|
+
current["llm_provider"] = attr_provider
|
|
10762
11046
|
else:
|
|
10763
11047
|
if current:
|
|
10764
11048
|
turns.append(current)
|
|
@@ -10770,6 +11054,8 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
|
|
|
10770
11054
|
"segments": list(segments),
|
|
10771
11055
|
"timestamp": ts,
|
|
10772
11056
|
"tokens": tokens,
|
|
11057
|
+
"model": attr_model,
|
|
11058
|
+
"llm_provider": attr_provider,
|
|
10773
11059
|
}
|
|
10774
11060
|
if current:
|
|
10775
11061
|
turns.append(current)
|
|
@@ -11297,6 +11583,10 @@ def _ensure_profile_llm_proxy(profile: str, provider: str) -> None:
|
|
|
11297
11583
|
"""Point Hermes at the internal LLM proxy (create/lease paths only — skip when ready)."""
|
|
11298
11584
|
llm_provider = str(provider or "openrouter").strip().lower()
|
|
11299
11585
|
prof = safe_profile_slug(profile)
|
|
11586
|
+
if _oauth_llm_provider_spec(llm_provider):
|
|
11587
|
+
_normalize_profile_config_yaml(prof)
|
|
11588
|
+
_apply_mvp_model_for_provider(prof, llm_provider)
|
|
11589
|
+
return
|
|
11300
11590
|
_normalize_profile_config_yaml(prof)
|
|
11301
11591
|
if _profile_llm_proxy_ready(prof, llm_provider):
|
|
11302
11592
|
_ensure_profile_proxy_headers(prof)
|
|
@@ -11449,6 +11739,16 @@ def _apply_turn_credential_lease(
|
|
|
11449
11739
|
resolved = _require_runtime_owner_provider(user_id, workspace_id, provider)
|
|
11450
11740
|
else:
|
|
11451
11741
|
resolved = _require_user_provider(user_id, workspace_id, provider)
|
|
11742
|
+
oauth_spec = _oauth_llm_provider_spec(provider)
|
|
11743
|
+
if oauth_spec and str(resolved.get("credential_type") or "") == "oauth":
|
|
11744
|
+
_sync_oauth_llm_to_profile(prof, user_id, provider)
|
|
11745
|
+
current_model = str(_read_model_from_config(prof)[1] or "").strip()
|
|
11746
|
+
if not _resolve_billing_provider_for_model(current_model, {provider}):
|
|
11747
|
+
current_model = str(
|
|
11748
|
+
(PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
|
|
11749
|
+
).strip()
|
|
11750
|
+
_apply_model_for_billing_provider(prof, provider, current_model, user_id)
|
|
11751
|
+
return ""
|
|
11452
11752
|
binding_id = str(
|
|
11453
11753
|
resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
|
|
11454
11754
|
).strip()
|
|
@@ -11591,6 +11891,26 @@ def _require_user_provider(user_id: str, workspace_id: str, provider: str) -> di
|
|
|
11591
11891
|
|
|
11592
11892
|
def _require_runtime_owner_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
|
|
11593
11893
|
provider = str(provider or "openrouter").strip().lower()
|
|
11894
|
+
oauth_spec = _oauth_llm_provider_spec(provider)
|
|
11895
|
+
if oauth_spec and _hermes_oauth_tokens_present(user_id, _hermes_auth_id_for_spec(oauth_spec)):
|
|
11896
|
+
hermes_auth_id = _hermes_auth_id_for_spec(oauth_spec)
|
|
11897
|
+
return {
|
|
11898
|
+
"credential_binding_id": None,
|
|
11899
|
+
"credential_id": None,
|
|
11900
|
+
"credential_ref": f"oauth:{hermes_auth_id}",
|
|
11901
|
+
"scope": "user",
|
|
11902
|
+
"provider": provider,
|
|
11903
|
+
"credential_type": "oauth",
|
|
11904
|
+
"label": hermes_auth_id,
|
|
11905
|
+
"env_var": "",
|
|
11906
|
+
"user_id": user_id,
|
|
11907
|
+
"workspace_id": None,
|
|
11908
|
+
"agent_profile_id": None,
|
|
11909
|
+
"created_by": user_id,
|
|
11910
|
+
"created_at": None,
|
|
11911
|
+
"updated_at": None,
|
|
11912
|
+
"expires_at": None,
|
|
11913
|
+
}
|
|
11594
11914
|
resolved = _resolve_credential(user_id, workspace_id, provider, user_only=True)
|
|
11595
11915
|
if resolved and _credential_secret(resolved, user_id):
|
|
11596
11916
|
return resolved
|
|
@@ -12346,8 +12666,19 @@ def _provider_pool_has_entries(auth: dict[str, Any], provider: str) -> bool:
|
|
|
12346
12666
|
pool = auth.get("credential_pool")
|
|
12347
12667
|
if not isinstance(pool, dict):
|
|
12348
12668
|
return False
|
|
12349
|
-
|
|
12350
|
-
|
|
12669
|
+
provider_key = str(provider or "").strip().lower()
|
|
12670
|
+
for key in _hermes_oauth_auth_keys(provider_key) | {provider_key}:
|
|
12671
|
+
entries = pool.get(key)
|
|
12672
|
+
if isinstance(entries, list) and len(entries) > 0:
|
|
12673
|
+
return True
|
|
12674
|
+
spec = _catalog_provider_for_llm(provider_key)
|
|
12675
|
+
if spec:
|
|
12676
|
+
auth_id = str(spec.get("hermes_auth_id") or "").strip().lower()
|
|
12677
|
+
if auth_id:
|
|
12678
|
+
entries = pool.get(auth_id)
|
|
12679
|
+
if isinstance(entries, list) and len(entries) > 0:
|
|
12680
|
+
return True
|
|
12681
|
+
return False
|
|
12351
12682
|
|
|
12352
12683
|
|
|
12353
12684
|
def _connected_provider_names(user_id: str = "", workspace_id: str = "") -> set[str]:
|
|
@@ -12449,13 +12780,13 @@ def _overlay_chat_llm_env(
|
|
|
12449
12780
|
|
|
12450
12781
|
|
|
12451
12782
|
def _resolve_models_profile(profile: str) -> str:
|
|
12452
|
-
"""
|
|
12783
|
+
"""Template profile slug for model picker reads — never per-user runtime u-*."""
|
|
12453
12784
|
raw = str(profile or "").strip()
|
|
12454
12785
|
if not raw:
|
|
12455
12786
|
return _primary_profile()
|
|
12456
12787
|
slug = safe_profile_slug(raw)
|
|
12457
12788
|
if _is_runtime_profile_slug(slug):
|
|
12458
|
-
return
|
|
12789
|
+
return resolve_validated_profile(_runtime_template_slug(slug))
|
|
12459
12790
|
return resolve_validated_profile(slug)
|
|
12460
12791
|
|
|
12461
12792
|
|
|
@@ -12484,10 +12815,13 @@ def _augment_model_suggestions(
|
|
|
12484
12815
|
extra: list[dict[str, str]] = []
|
|
12485
12816
|
primary = str(block.get("default") or "").strip()
|
|
12486
12817
|
if primary and primary not in seen:
|
|
12818
|
+
bill = _billing_provider_id_from_hermes_config(str(block.get("provider") or "")) or str(
|
|
12819
|
+
block.get("provider") or "openrouter"
|
|
12820
|
+
)
|
|
12487
12821
|
extra.append(
|
|
12488
12822
|
_model_suggestion_row(
|
|
12489
12823
|
primary,
|
|
12490
|
-
provider_label=
|
|
12824
|
+
provider_label=_provider_display_label(bill),
|
|
12491
12825
|
label="Current primary",
|
|
12492
12826
|
description="Active model for this agent profile",
|
|
12493
12827
|
),
|
|
@@ -12503,10 +12837,11 @@ def _augment_model_suggestions(
|
|
|
12503
12837
|
full = model if "/" in model else f"{prov}/{model}"
|
|
12504
12838
|
if full in seen:
|
|
12505
12839
|
continue
|
|
12840
|
+
bill = _billing_provider_id_from_hermes_config(prov) or prov
|
|
12506
12841
|
extra.append(
|
|
12507
12842
|
_model_suggestion_row(
|
|
12508
12843
|
full,
|
|
12509
|
-
provider_label=
|
|
12844
|
+
provider_label=_provider_display_label(bill),
|
|
12510
12845
|
label=f"Fallback · {model}",
|
|
12511
12846
|
description="Configured in this profile's fallback chain",
|
|
12512
12847
|
),
|
|
@@ -12550,13 +12885,14 @@ def _llm_billing_provider(
|
|
|
12550
12885
|
prof = resolve_hermes_profile(profile)
|
|
12551
12886
|
block = _read_model_block(prof)
|
|
12552
12887
|
cfg_provider = str(block.get("provider") or "").strip().lower()
|
|
12553
|
-
|
|
12554
|
-
|
|
12888
|
+
billing_cfg = _billing_provider_id_from_hermes_config(cfg_provider)
|
|
12889
|
+
user = str(user_id or "").strip()
|
|
12890
|
+
ws = str(workspace_id or "").strip()
|
|
12891
|
+
if billing_cfg and user and _user_can_use_llm(user, ws, billing_cfg):
|
|
12892
|
+
return billing_cfg
|
|
12555
12893
|
candidate = _billing_provider_from_block(
|
|
12556
12894
|
str(block.get("base_url") or ""), cfg_provider, provider_hint,
|
|
12557
12895
|
)
|
|
12558
|
-
user = str(user_id or "").strip()
|
|
12559
|
-
ws = str(workspace_id or "").strip()
|
|
12560
12896
|
if user and not _user_can_use_llm(user, ws, candidate):
|
|
12561
12897
|
connected = sorted(_user_llm_providers_for_picker(user))
|
|
12562
12898
|
if connected:
|
|
@@ -12591,6 +12927,12 @@ def _reconcile_profile_llm_for_user(
|
|
|
12591
12927
|
if not connected:
|
|
12592
12928
|
return False
|
|
12593
12929
|
billing = connected[0]
|
|
12930
|
+
if _oauth_llm_provider_spec(billing) and _user_can_use_llm(user, workspace_id, billing):
|
|
12931
|
+
current = str(_read_model_from_config(prof)[1] or "").strip()
|
|
12932
|
+
if not _resolve_billing_provider_for_model(current, {billing}):
|
|
12933
|
+
current = str((PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
|
|
12934
|
+
_apply_model_for_billing_provider(prof, billing, current, user_id=user)
|
|
12935
|
+
return True
|
|
12594
12936
|
if _user_can_use_llm(user, workspace_id, billing) and _profile_llm_proxy_matches_billing(prof, billing):
|
|
12595
12937
|
return False
|
|
12596
12938
|
changed = _apply_mvp_model_for_provider(prof, billing)
|
|
@@ -12637,26 +12979,9 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
|
|
|
12637
12979
|
llm_spec
|
|
12638
12980
|
and str(llm_spec.get("connect_mode") or "") == "oauth"
|
|
12639
12981
|
and user
|
|
12640
|
-
and
|
|
12982
|
+
and _sync_oauth_llm_to_profile(prof, user, llm_provider)
|
|
12641
12983
|
):
|
|
12642
|
-
|
|
12643
|
-
try:
|
|
12644
|
-
user_auth = json.loads(src_path.read_text(encoding="utf-8"))
|
|
12645
|
-
except (OSError, json.JSONDecodeError):
|
|
12646
|
-
user_auth = {}
|
|
12647
|
-
hermes_id = _hermes_auth_id_for_spec(llm_spec)
|
|
12648
|
-
providers = user_auth.get("providers") if isinstance(user_auth, dict) else None
|
|
12649
|
-
block = providers.get(hermes_id) if isinstance(providers, dict) else None
|
|
12650
|
-
if isinstance(block, dict):
|
|
12651
|
-
merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
|
|
12652
|
-
merged[hermes_id] = block
|
|
12653
|
-
auth["providers"] = merged
|
|
12654
|
-
auth["version"] = 1
|
|
12655
|
-
auth["updated_at"] = _utc_now()
|
|
12656
|
-
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
12657
|
-
auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
12658
|
-
_publish_profile_gateway_secrets(prof)
|
|
12659
|
-
return True
|
|
12984
|
+
return True
|
|
12660
12985
|
|
|
12661
12986
|
if _is_runtime_profile_slug(prof):
|
|
12662
12987
|
if not user or not _user_can_use_llm(user, workspace_id, llm_provider):
|
|
@@ -13513,6 +13838,8 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
|
|
|
13513
13838
|
if _triggering_user:
|
|
13514
13839
|
_reconcile_profile_llm_for_user(prof, _triggering_user, _workspace_id)
|
|
13515
13840
|
llm_provider = _llm_billing_provider(prof, user_id=_triggering_user, workspace_id=_workspace_id)
|
|
13841
|
+
model_block = _read_model_block(prof)
|
|
13842
|
+
model_used = str(model_block.get("default") or "").strip()
|
|
13516
13843
|
llm_ready = bool(
|
|
13517
13844
|
_triggering_user
|
|
13518
13845
|
and _user_can_use_llm(_triggering_user, _workspace_id, llm_provider)
|
|
@@ -13591,8 +13918,10 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
|
|
|
13591
13918
|
handler.send_header("X-Workframe-Api-Port", str(lifecycle["api_port"]))
|
|
13592
13919
|
handler.end_headers()
|
|
13593
13920
|
handler.wfile.write(
|
|
13594
|
-
|
|
13595
|
-
|
|
13921
|
+
(
|
|
13922
|
+
"event: run.started\n"
|
|
13923
|
+
f"data: {json.dumps({'text': 'Contacting model...', 'model': model_used, 'llm_provider': llm_provider})}\n\n"
|
|
13924
|
+
).encode("utf-8")
|
|
13596
13925
|
)
|
|
13597
13926
|
handler.wfile.flush()
|
|
13598
13927
|
buffer = b""
|
|
@@ -14454,7 +14783,10 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
|
|
|
14454
14783
|
},
|
|
14455
14784
|
"codex": {
|
|
14456
14785
|
"primary": "gpt-5.4-medium",
|
|
14457
|
-
"fallbacks": [
|
|
14786
|
+
"fallbacks": [
|
|
14787
|
+
{"provider": "openai-codex", "model": "gpt-5.4-medium"},
|
|
14788
|
+
{"provider": "openai-codex", "model": "gpt-5.4-mini"},
|
|
14789
|
+
],
|
|
14458
14790
|
},
|
|
14459
14791
|
"nous": {
|
|
14460
14792
|
"primary": "nousresearch/hermes-4-70b",
|
|
@@ -14463,6 +14795,185 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
|
|
|
14463
14795
|
}
|
|
14464
14796
|
|
|
14465
14797
|
|
|
14798
|
+
# ponytail: billing id (connect catalog) ↔ Hermes config model.provider ↔ picker rows
|
|
14799
|
+
_PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
|
|
14800
|
+
"codex": frozenset({"codex", "openai"}),
|
|
14801
|
+
"openai": frozenset({"openai"}),
|
|
14802
|
+
"openrouter": frozenset({"openrouter"}),
|
|
14803
|
+
"anthropic": frozenset({"anthropic"}),
|
|
14804
|
+
"google": frozenset({"google"}),
|
|
14805
|
+
"deepseek": frozenset({"deepseek"}),
|
|
14806
|
+
"nous": frozenset({"nous"}),
|
|
14807
|
+
}
|
|
14808
|
+
|
|
14809
|
+
_CODEX_EXTRA_MODELS: tuple[tuple[str, str, str], ...] = (
|
|
14810
|
+
("gpt-5.4-medium", "GPT-5.4 Medium", "Codex default — agentic, tool-capable."),
|
|
14811
|
+
("gpt-5.4-mini", "GPT-5.4 Mini", "Faster Codex model on ChatGPT account."),
|
|
14812
|
+
)
|
|
14813
|
+
|
|
14814
|
+
|
|
14815
|
+
def _billing_provider_id_from_hermes_config(cfg_provider: str) -> str:
|
|
14816
|
+
p = str(cfg_provider or "").strip().lower()
|
|
14817
|
+
if p in {"openai-codex", "openai_codex", "codex"}:
|
|
14818
|
+
return "codex"
|
|
14819
|
+
if p in PROVIDER_MVP_MODELS:
|
|
14820
|
+
return p
|
|
14821
|
+
return ""
|
|
14822
|
+
|
|
14823
|
+
|
|
14824
|
+
def _catalog_tags_for_billing_provider(provider_id: str) -> frozenset[str]:
|
|
14825
|
+
return _PROVIDER_CATALOG_TAGS.get(str(provider_id or "").strip().lower(), frozenset())
|
|
14826
|
+
|
|
14827
|
+
|
|
14828
|
+
def _catalog_row_for_billing_provider(row: dict[str, str], provider_id: str) -> bool:
|
|
14829
|
+
tags = _catalog_tags_for_billing_provider(provider_id)
|
|
14830
|
+
if not tags:
|
|
14831
|
+
return False
|
|
14832
|
+
row_tag = str(row.get("provider") or "").strip().lower()
|
|
14833
|
+
return row_tag in tags
|
|
14834
|
+
|
|
14835
|
+
|
|
14836
|
+
def _provider_display_label(provider_id: str) -> str:
|
|
14837
|
+
spec = _catalog_provider(provider_id) or {}
|
|
14838
|
+
return str(spec.get("label") or provider_id).strip() or provider_id
|
|
14839
|
+
|
|
14840
|
+
|
|
14841
|
+
def _model_catalog_rows_for_provider(provider_id: str) -> list[dict[str, str]]:
|
|
14842
|
+
"""Curated models a connected billing provider can run."""
|
|
14843
|
+
provider_key = str(provider_id or "").strip().lower()
|
|
14844
|
+
label = _provider_display_label(provider_key)
|
|
14845
|
+
rows: list[dict[str, str]] = []
|
|
14846
|
+
seen: set[str] = set()
|
|
14847
|
+
|
|
14848
|
+
def _add(model: str, row_label: str, description: str) -> None:
|
|
14849
|
+
mid = str(model or "").strip()
|
|
14850
|
+
if not mid or mid in seen:
|
|
14851
|
+
return
|
|
14852
|
+
seen.add(mid)
|
|
14853
|
+
rows.append(
|
|
14854
|
+
{
|
|
14855
|
+
"provider": label,
|
|
14856
|
+
"billing_provider": provider_key,
|
|
14857
|
+
"model": mid,
|
|
14858
|
+
"label": row_label,
|
|
14859
|
+
"description": description,
|
|
14860
|
+
}
|
|
14861
|
+
)
|
|
14862
|
+
|
|
14863
|
+
if provider_key == "codex":
|
|
14864
|
+
for model, row_label, description in _CODEX_EXTRA_MODELS:
|
|
14865
|
+
_add(model, row_label, description)
|
|
14866
|
+
mvp = PROVIDER_MVP_MODELS.get(provider_key) or {}
|
|
14867
|
+
primary = str(mvp.get("primary") or "").strip()
|
|
14868
|
+
if primary:
|
|
14869
|
+
_add(primary, primary, f"Default for {label}.")
|
|
14870
|
+
for fb in mvp.get("fallbacks") or []:
|
|
14871
|
+
if isinstance(fb, dict):
|
|
14872
|
+
_add(str(fb.get("model") or ""), str(fb.get("model") or ""), f"Fallback for {label}.")
|
|
14873
|
+
for row in HERMES_MODEL_CATALOG:
|
|
14874
|
+
if _catalog_row_for_billing_provider(row, provider_key):
|
|
14875
|
+
_add(
|
|
14876
|
+
str(row.get("model") or ""),
|
|
14877
|
+
str(row.get("label") or row.get("model") or ""),
|
|
14878
|
+
str(row.get("description") or ""),
|
|
14879
|
+
)
|
|
14880
|
+
return rows
|
|
14881
|
+
|
|
14882
|
+
|
|
14883
|
+
def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[str, str]]:
|
|
14884
|
+
out: list[dict[str, str]] = []
|
|
14885
|
+
seen: set[str] = set()
|
|
14886
|
+
for provider_id in sorted(connected):
|
|
14887
|
+
for row in _model_catalog_rows_for_provider(provider_id):
|
|
14888
|
+
mid = str(row.get("model") or "").strip()
|
|
14889
|
+
if not mid or mid in seen:
|
|
14890
|
+
continue
|
|
14891
|
+
seen.add(mid)
|
|
14892
|
+
out.append(row)
|
|
14893
|
+
return out
|
|
14894
|
+
|
|
14895
|
+
|
|
14896
|
+
def _resolve_billing_provider_for_model(model_id: str, connected: set[str]) -> str:
|
|
14897
|
+
mid = str(model_id or "").strip()
|
|
14898
|
+
if not mid or not connected:
|
|
14899
|
+
return ""
|
|
14900
|
+
for provider_id in sorted(connected):
|
|
14901
|
+
for row in _model_catalog_rows_for_provider(provider_id):
|
|
14902
|
+
if str(row.get("model") or "").strip() == mid:
|
|
14903
|
+
return provider_id
|
|
14904
|
+
low = mid.lower()
|
|
14905
|
+
if low.startswith("gpt-") and "codex" in connected:
|
|
14906
|
+
return "codex"
|
|
14907
|
+
if low.startswith("gpt-") and "openai" in connected:
|
|
14908
|
+
return "openai"
|
|
14909
|
+
if "/" in mid and "openrouter" in connected:
|
|
14910
|
+
return "openrouter"
|
|
14911
|
+
if low.startswith("claude") and "anthropic" in connected:
|
|
14912
|
+
return "anthropic"
|
|
14913
|
+
if "gemini" in low and "google" in connected:
|
|
14914
|
+
return "google"
|
|
14915
|
+
if "deepseek" in low and "deepseek" in connected:
|
|
14916
|
+
return "deepseek"
|
|
14917
|
+
return ""
|
|
14918
|
+
|
|
14919
|
+
|
|
14920
|
+
def _strip_profile_model_proxy_fields(profile: str) -> None:
|
|
14921
|
+
"""OAuth/native providers: drop internal proxy base_url + lease api_key from model block."""
|
|
14922
|
+
config_path = _profile_gateway_config_path(profile)
|
|
14923
|
+
if not config_path or not config_path.is_file():
|
|
14924
|
+
return
|
|
14925
|
+
try:
|
|
14926
|
+
lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
14927
|
+
except OSError:
|
|
14928
|
+
return
|
|
14929
|
+
out: list[str] = []
|
|
14930
|
+
in_model = False
|
|
14931
|
+
for line in lines:
|
|
14932
|
+
stripped = line.lstrip()
|
|
14933
|
+
if stripped.startswith("model:") and not line.startswith((" ", "\t")):
|
|
14934
|
+
in_model = True
|
|
14935
|
+
out.append(line)
|
|
14936
|
+
continue
|
|
14937
|
+
if in_model and stripped and not line.startswith((" ", "\t", "#")):
|
|
14938
|
+
in_model = False
|
|
14939
|
+
if in_model and stripped.startswith(("base_url:", "api_key:")):
|
|
14940
|
+
continue
|
|
14941
|
+
out.append(line)
|
|
14942
|
+
try:
|
|
14943
|
+
config_path.write_text("".join(out), encoding="utf-8")
|
|
14944
|
+
except OSError:
|
|
14945
|
+
return
|
|
14946
|
+
|
|
14947
|
+
|
|
14948
|
+
def _apply_model_for_billing_provider(
|
|
14949
|
+
profile: str,
|
|
14950
|
+
billing: str,
|
|
14951
|
+
model_id: str,
|
|
14952
|
+
user_id: str = "",
|
|
14953
|
+
) -> bool:
|
|
14954
|
+
billing = str(billing or "").strip().lower()
|
|
14955
|
+
ok, _ = _set_profile_model(profile, model_id)
|
|
14956
|
+
if not ok:
|
|
14957
|
+
return False
|
|
14958
|
+
if _oauth_llm_provider_spec(billing):
|
|
14959
|
+
ok_provider, _ = _set_profile_model_provider(profile, _hermes_config_provider_id(billing))
|
|
14960
|
+
if not ok_provider:
|
|
14961
|
+
return False
|
|
14962
|
+
_strip_profile_model_proxy_fields(profile)
|
|
14963
|
+
fallbacks = PROVIDER_MVP_MODELS.get(billing, {}).get("fallbacks") or []
|
|
14964
|
+
if isinstance(fallbacks, list):
|
|
14965
|
+
_write_fallback_chain(profile, fallbacks)
|
|
14966
|
+
user = str(user_id or "").strip()
|
|
14967
|
+
if user:
|
|
14968
|
+
_sync_oauth_llm_to_profile(profile, user, billing)
|
|
14969
|
+
return True
|
|
14970
|
+
ok_provider, _ = _set_profile_model_provider(profile, "custom")
|
|
14971
|
+
if not ok_provider:
|
|
14972
|
+
return False
|
|
14973
|
+
_set_profile_model_base_url(profile, _llm_proxy_base_url(billing))
|
|
14974
|
+
return True
|
|
14975
|
+
|
|
14976
|
+
|
|
14466
14977
|
def _hermes_config_provider_id(provider_key: str) -> str:
|
|
14467
14978
|
spec = _catalog_provider(provider_key)
|
|
14468
14979
|
if spec and spec.get("hermes_auth_id"):
|
|
@@ -14515,19 +15026,25 @@ def _bootstrap_model_after_llm_connect(
|
|
|
14515
15026
|
ws = str(workspace_id or "").strip()
|
|
14516
15027
|
primary = _primary_profile()
|
|
14517
15028
|
if primary:
|
|
14518
|
-
|
|
14519
|
-
if not str(model or "").strip():
|
|
14520
|
-
_apply_mvp_model_for_provider(primary, provider_key)
|
|
14521
|
-
elif user:
|
|
15029
|
+
if user and _oauth_llm_provider_spec(provider_key):
|
|
14522
15030
|
_reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
|
|
15031
|
+
else:
|
|
15032
|
+
_, model = _read_model_from_config(primary)
|
|
15033
|
+
if not str(model or "").strip():
|
|
15034
|
+
_apply_mvp_model_for_provider(primary, provider_key)
|
|
15035
|
+
elif user:
|
|
15036
|
+
_reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
|
|
14523
15037
|
template = primary or "workframe-agent"
|
|
14524
15038
|
runtime = _runtime_profile_slug(user_id, template)
|
|
14525
15039
|
if _runtime_profile_on_disk(runtime):
|
|
14526
|
-
|
|
14527
|
-
if not str(model or "").strip():
|
|
14528
|
-
_apply_mvp_model_for_provider(runtime, provider_key)
|
|
14529
|
-
elif user:
|
|
15040
|
+
if user and _oauth_llm_provider_spec(provider_key):
|
|
14530
15041
|
_reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
|
|
15042
|
+
else:
|
|
15043
|
+
_, model = _read_model_from_config(runtime)
|
|
15044
|
+
if not str(model or "").strip():
|
|
15045
|
+
_apply_mvp_model_for_provider(runtime, provider_key)
|
|
15046
|
+
elif user:
|
|
15047
|
+
_reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
|
|
14531
15048
|
|
|
14532
15049
|
|
|
14533
15050
|
# Curated snapshot of commonly-used models per provider. Surfaced in the
|
|
@@ -14558,6 +15075,12 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
|
|
|
14558
15075
|
"label": "o1", "description": "Reasoning model."},
|
|
14559
15076
|
{"provider": "OpenAI", "model": "o1-mini",
|
|
14560
15077
|
"label": "o1 mini", "description": "Cheaper reasoning."},
|
|
15078
|
+
{"provider": "OpenAI", "model": "gpt-5.4-mini",
|
|
15079
|
+
"label": "GPT-5.4 Mini", "description": "Faster Codex / OpenAI model."},
|
|
15080
|
+
{"provider": "Codex", "model": "gpt-5.4-medium",
|
|
15081
|
+
"label": "GPT-5.4 Medium (Codex)", "description": "Codex ChatGPT account default."},
|
|
15082
|
+
{"provider": "Codex", "model": "gpt-5.4-mini",
|
|
15083
|
+
"label": "GPT-5.4 Mini (Codex)", "description": "Faster Codex model."},
|
|
14561
15084
|
{"provider": "Google", "model": "gemini-2.5-pro",
|
|
14562
15085
|
"label": "Gemini 2.5 Pro",
|
|
14563
15086
|
"description": "Long context, strong reasoning."},
|
|
@@ -14709,7 +15232,7 @@ def _set_profile_model_provider(profile: str, provider: str) -> tuple[bool, str]
|
|
|
14709
15232
|
return True, ""
|
|
14710
15233
|
|
|
14711
15234
|
|
|
14712
|
-
def
|
|
15235
|
+
def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
|
|
14713
15236
|
"""Parse the model surface from the profile's config.yaml:
|
|
14714
15237
|
`model.default`, `model.provider`, etc. (siblings of `model:`) AND
|
|
14715
15238
|
the top-level `fallback_providers:` block. Pure text scan — no
|
|
@@ -14818,6 +15341,48 @@ def _read_model_block(profile: str) -> dict[str, Any]:
|
|
|
14818
15341
|
return out
|
|
14819
15342
|
|
|
14820
15343
|
|
|
15344
|
+
def _read_model_block(profile: str) -> dict[str, Any]:
|
|
15345
|
+
"""Model surface for a profile. Runtime u-* slugs inherit default/chain from template."""
|
|
15346
|
+
raw = str(profile or "").strip()
|
|
15347
|
+
if not raw:
|
|
15348
|
+
return _parse_model_block_from_disk("")
|
|
15349
|
+
try:
|
|
15350
|
+
prof = resolve_hermes_profile(raw)
|
|
15351
|
+
except ValueError:
|
|
15352
|
+
return _parse_model_block_from_disk(raw)
|
|
15353
|
+
block = _parse_model_block_from_disk(prof)
|
|
15354
|
+
if not _is_runtime_profile_slug(prof):
|
|
15355
|
+
return block
|
|
15356
|
+
template = resolve_validated_profile(_runtime_template_slug(prof))
|
|
15357
|
+
tblock = _parse_model_block_from_disk(template)
|
|
15358
|
+
if str(tblock.get("default") or "").strip():
|
|
15359
|
+
block["default"] = tblock["default"]
|
|
15360
|
+
if tblock.get("fallback_chain"):
|
|
15361
|
+
block["fallback_chain"] = list(tblock["fallback_chain"])
|
|
15362
|
+
tpl_provider = str(tblock.get("provider") or "").strip()
|
|
15363
|
+
if tpl_provider:
|
|
15364
|
+
block["provider"] = tpl_provider
|
|
15365
|
+
return block
|
|
15366
|
+
|
|
15367
|
+
|
|
15368
|
+
def _sync_runtime_model_from_template(runtime: str, template: str) -> None:
|
|
15369
|
+
"""Keep per-user runtime Hermes config aligned with agent template model picks."""
|
|
15370
|
+
runtime_slug = safe_profile_slug(runtime)
|
|
15371
|
+
template_slug = resolve_validated_profile(template)
|
|
15372
|
+
if not _is_runtime_profile_slug(runtime_slug) or not profile_exists(runtime_slug):
|
|
15373
|
+
return
|
|
15374
|
+
tblock = _parse_model_block_from_disk(template_slug)
|
|
15375
|
+
default = str(tblock.get("default") or "").strip()
|
|
15376
|
+
if default:
|
|
15377
|
+
_set_profile_model(runtime_slug, default)
|
|
15378
|
+
chain = [e for e in (tblock.get("fallback_chain") or []) if isinstance(e, dict)]
|
|
15379
|
+
if chain:
|
|
15380
|
+
_write_fallback_chain(runtime_slug, chain)
|
|
15381
|
+
billing = _billing_provider_id_from_hermes_config(str(tblock.get("provider") or ""))
|
|
15382
|
+
if billing and default:
|
|
15383
|
+
_apply_model_for_billing_provider(runtime_slug, billing, default)
|
|
15384
|
+
|
|
15385
|
+
|
|
14821
15386
|
def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bool, str]:
|
|
14822
15387
|
"""Rewrite the `fallback_providers` block in config.yaml to match the
|
|
14823
15388
|
supplied chain. Preserves all other keys, comments, and ordering.
|
|
@@ -14919,49 +15484,79 @@ def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bo
|
|
|
14919
15484
|
return True, ""
|
|
14920
15485
|
|
|
14921
15486
|
|
|
14922
|
-
def hermes_models(
|
|
15487
|
+
def hermes_models(
|
|
15488
|
+
profile: str = "",
|
|
15489
|
+
user_id: str = "",
|
|
15490
|
+
workspace_id: str = "",
|
|
15491
|
+
*,
|
|
15492
|
+
selection_only: bool = False,
|
|
15493
|
+
) -> dict[str, Any]:
|
|
14923
15494
|
"""Return a profile's model surface: primary, fallback chain,
|
|
14924
15495
|
and curated suggestions scoped to the active provider. The catalog
|
|
14925
15496
|
is a hint list, not a hardcoded menu — the BFF accepts any
|
|
14926
15497
|
`provider/model` id and the picker always lets the user type a
|
|
14927
15498
|
custom one.
|
|
14928
15499
|
"""
|
|
14929
|
-
primary_profile = _resolve_models_profile(profile) if profile else _primary_profile()
|
|
14930
|
-
if primary_profile:
|
|
14931
|
-
_bootstrap_profile_providers(primary_profile, user_id, workspace_id)
|
|
14932
|
-
block = _read_model_block(primary_profile) if primary_profile else {
|
|
14933
|
-
"default": "", "provider": "", "base_url": "",
|
|
14934
|
-
"providers": {}, "fallback_chain": [],
|
|
14935
|
-
}
|
|
14936
|
-
active_provider = block.get("provider", "").strip()
|
|
14937
15500
|
picker_llm = _user_llm_providers_for_picker(user_id) if user_id else set()
|
|
14938
15501
|
connected = _connected_provider_names(user_id, workspace_id)
|
|
14939
|
-
has_llm =
|
|
15502
|
+
has_llm = _user_llm_has_provider(user_id, workspace_id) if user_id else any(
|
|
14940
15503
|
str(spec.get("category") or "") == "llm"
|
|
14941
15504
|
and str(spec.get("id")).lower() in {n.lower() for n in connected}
|
|
14942
15505
|
for spec in PROVIDER_CONNECT_CATALOG
|
|
14943
15506
|
)
|
|
14944
|
-
|
|
14945
|
-
|
|
14946
|
-
|
|
14947
|
-
|
|
14948
|
-
|
|
14949
|
-
|
|
14950
|
-
|
|
14951
|
-
|
|
14952
|
-
|
|
14953
|
-
|
|
14954
|
-
|
|
15507
|
+
connected_llm = picker_llm if user_id else {
|
|
15508
|
+
str(spec["id"]).lower()
|
|
15509
|
+
for spec in PROVIDER_CONNECT_CATALOG
|
|
15510
|
+
if str(spec.get("category") or "") == "llm"
|
|
15511
|
+
and str(spec.get("id")).lower() in {n.lower() for n in connected}
|
|
15512
|
+
}
|
|
15513
|
+
|
|
15514
|
+
if selection_only:
|
|
15515
|
+
user_primary, user_chain = _read_user_llm_prefs(user_id) if user_id else ("", [])
|
|
15516
|
+
primary = user_primary or (HERMES_DEFAULT_PRIMARY if has_llm else "")
|
|
15517
|
+
billing = _resolve_billing_provider_for_model(primary, connected_llm) if primary else ""
|
|
15518
|
+
suggestions = _augment_model_suggestions(
|
|
15519
|
+
_suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
|
|
15520
|
+
{
|
|
15521
|
+
"default": primary,
|
|
15522
|
+
"provider": billing or "",
|
|
15523
|
+
"base_url": "",
|
|
15524
|
+
"providers": {},
|
|
15525
|
+
"fallback_chain": user_chain,
|
|
15526
|
+
},
|
|
14955
15527
|
)
|
|
15528
|
+
return {
|
|
15529
|
+
"ok": True,
|
|
15530
|
+
"profile": "",
|
|
15531
|
+
"primary": primary if has_llm else "",
|
|
15532
|
+
"provider": billing if has_llm else "",
|
|
15533
|
+
"base_url": "",
|
|
15534
|
+
"fallback_chain": user_chain if has_llm else [],
|
|
15535
|
+
"suggestions": suggestions,
|
|
15536
|
+
"connected_providers": sorted(picker_llm if user_id else connected),
|
|
15537
|
+
"has_llm_provider": has_llm,
|
|
15538
|
+
"billing_provider": billing if has_llm else "",
|
|
15539
|
+
"selection_only": True,
|
|
15540
|
+
"default_primary": HERMES_DEFAULT_PRIMARY,
|
|
15541
|
+
"default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
|
|
15542
|
+
}
|
|
14956
15543
|
|
|
15544
|
+
try:
|
|
15545
|
+
primary_profile = _resolve_models_profile(profile) if profile else _primary_profile()
|
|
15546
|
+
except ValueError as exc:
|
|
15547
|
+
return {"ok": False, "error": str(exc)}
|
|
15548
|
+
if primary_profile:
|
|
15549
|
+
_bootstrap_profile_providers(primary_profile, user_id, workspace_id)
|
|
15550
|
+
block = _read_model_block(primary_profile) if primary_profile else {
|
|
15551
|
+
"default": "", "provider": "", "base_url": "",
|
|
15552
|
+
"providers": {}, "fallback_chain": [],
|
|
15553
|
+
}
|
|
15554
|
+
active_provider = block.get("provider", "").strip()
|
|
14957
15555
|
suggestions = _augment_model_suggestions(
|
|
14958
|
-
[
|
|
14959
|
-
row for row in HERMES_MODEL_CATALOG
|
|
14960
|
-
if _provider_matches(str(row.get("provider") or ""))
|
|
14961
|
-
] if (picker_llm or connected) else [],
|
|
15556
|
+
_suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
|
|
14962
15557
|
block,
|
|
14963
15558
|
)
|
|
14964
|
-
if user_id and "openrouter" in
|
|
15559
|
+
if user_id and "openrouter" in connected_llm:
|
|
14965
15560
|
resolved = _resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
|
|
14966
15561
|
secret = _credential_secret(resolved or {}, user_id) if resolved else ""
|
|
14967
15562
|
live = openrouter_catalog.live_suggestions(secret, limit=30)
|
|
@@ -14981,16 +15576,25 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
|
|
|
14981
15576
|
seen_models.add(mid)
|
|
14982
15577
|
deduped.append(row)
|
|
14983
15578
|
suggestions = deduped
|
|
15579
|
+
primary_model = str(block.get("default") or "").strip()
|
|
15580
|
+
billing = _billing_provider_id_from_hermes_config(active_provider)
|
|
15581
|
+
if not billing and primary_model:
|
|
15582
|
+
billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
|
|
15583
|
+
if str(active_provider or "").lower() == "custom" and billing:
|
|
15584
|
+
display_provider = billing
|
|
15585
|
+
else:
|
|
15586
|
+
display_provider = active_provider
|
|
14984
15587
|
return {
|
|
14985
15588
|
"ok": True,
|
|
14986
15589
|
"profile": primary_profile,
|
|
14987
|
-
"primary":
|
|
14988
|
-
"provider":
|
|
15590
|
+
"primary": primary_model if has_llm else "",
|
|
15591
|
+
"provider": display_provider if has_llm else "",
|
|
14989
15592
|
"base_url": block.get("base_url", ""),
|
|
14990
15593
|
"fallback_chain": block.get("fallback_chain", []) if has_llm else [],
|
|
14991
15594
|
"suggestions": suggestions,
|
|
14992
15595
|
"connected_providers": sorted(picker_llm if user_id else connected),
|
|
14993
15596
|
"has_llm_provider": has_llm,
|
|
15597
|
+
"billing_provider": billing if has_llm else "",
|
|
14994
15598
|
"default_primary": HERMES_DEFAULT_PRIMARY,
|
|
14995
15599
|
"default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
|
|
14996
15600
|
}
|
|
@@ -15020,30 +15624,76 @@ def hermes_apply_default_model_config() -> dict[str, Any]:
|
|
|
15020
15624
|
}
|
|
15021
15625
|
|
|
15022
15626
|
|
|
15023
|
-
def hermes_model_set(
|
|
15024
|
-
|
|
15025
|
-
|
|
15026
|
-
|
|
15027
|
-
""
|
|
15627
|
+
def hermes_model_set(
|
|
15628
|
+
profile: str,
|
|
15629
|
+
model_id: str,
|
|
15630
|
+
user_id: str = "",
|
|
15631
|
+
workspace_id: str = "",
|
|
15632
|
+
*,
|
|
15633
|
+
selection_only: bool = False,
|
|
15634
|
+
) -> dict[str, Any]:
|
|
15635
|
+
"""Set primary model and align Hermes provider routing to a connected billing provider."""
|
|
15636
|
+
user = str(user_id or "").strip()
|
|
15637
|
+
if selection_only:
|
|
15638
|
+
if not user:
|
|
15639
|
+
return {"ok": False, "error": "unauthorized"}
|
|
15640
|
+
connected = _user_llm_providers_for_picker(user)
|
|
15641
|
+
billing = _resolve_billing_provider_for_model(model_id, connected)
|
|
15642
|
+
if not billing and not _user_llm_has_provider(user, workspace_id):
|
|
15643
|
+
return {"ok": False, "error": "connect an LLM provider first"}
|
|
15644
|
+
_write_user_llm_prefs(user, primary=model_id)
|
|
15645
|
+
return {
|
|
15646
|
+
"ok": True,
|
|
15647
|
+
"profile": "",
|
|
15648
|
+
"model": model_id,
|
|
15649
|
+
"provider": billing or "",
|
|
15650
|
+
"billing_provider": billing or "",
|
|
15651
|
+
"selection_only": True,
|
|
15652
|
+
}
|
|
15028
15653
|
target = profile or _primary_profile()
|
|
15029
15654
|
if not target:
|
|
15030
15655
|
return {"ok": False, "error": "no profile resolved"}
|
|
15031
15656
|
if not model_id or " " in model_id or "\n" in model_id:
|
|
15032
15657
|
return {"ok": False, "error": "invalid model id"}
|
|
15033
|
-
|
|
15034
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
15658
|
+
user = str(user_id or "").strip()
|
|
15659
|
+
ws = str(workspace_id or "").strip()
|
|
15660
|
+
connected = _user_llm_providers_for_picker(user) if user else set()
|
|
15661
|
+
billing = _resolve_billing_provider_for_model(model_id, connected)
|
|
15662
|
+
if billing:
|
|
15663
|
+
if not _apply_model_for_billing_provider(target, billing, model_id, user_id=user):
|
|
15664
|
+
return {"ok": False, "error": "model apply failed"}
|
|
15665
|
+
else:
|
|
15666
|
+
ok, err = _set_profile_model(target, model_id)
|
|
15667
|
+
if not ok:
|
|
15668
|
+
return {"ok": False, "error": err}
|
|
15669
|
+
tpl = safe_profile_slug(target)
|
|
15670
|
+
if user and not _is_runtime_profile_slug(tpl):
|
|
15671
|
+
runtime = _runtime_profile_slug(user, tpl)
|
|
15672
|
+
if _runtime_profile_on_disk(runtime):
|
|
15673
|
+
_sync_runtime_model_from_template(runtime, tpl)
|
|
15674
|
+
block = _read_model_block(target)
|
|
15675
|
+
return {
|
|
15676
|
+
"ok": True,
|
|
15677
|
+
"profile": target,
|
|
15678
|
+
"model": model_id,
|
|
15679
|
+
"provider": str(block.get("provider") or ""),
|
|
15680
|
+
"billing_provider": billing or _billing_provider_id_from_hermes_config(
|
|
15681
|
+
str(block.get("provider") or "")
|
|
15682
|
+
),
|
|
15683
|
+
}
|
|
15037
15684
|
|
|
15038
15685
|
|
|
15039
|
-
def hermes_fallback_chain_set(
|
|
15686
|
+
def hermes_fallback_chain_set(
|
|
15687
|
+
profile: str,
|
|
15688
|
+
chain: list[Any],
|
|
15689
|
+
*,
|
|
15690
|
+
selection_only: bool = False,
|
|
15691
|
+
user_id: str = "",
|
|
15692
|
+
) -> dict[str, Any]:
|
|
15040
15693
|
"""Set the fallback chain for a profile. Each entry must have
|
|
15041
15694
|
`provider` and `model` keys. Order matters: Hermes tries them in
|
|
15042
15695
|
sequence when the primary fails.
|
|
15043
15696
|
"""
|
|
15044
|
-
target = profile or _primary_profile()
|
|
15045
|
-
if not target:
|
|
15046
|
-
return {"ok": False, "error": "no profile resolved"}
|
|
15047
15697
|
normalized: list[dict[str, str]] = []
|
|
15048
15698
|
for entry in chain:
|
|
15049
15699
|
if not isinstance(entry, dict):
|
|
@@ -15056,9 +15706,24 @@ def hermes_fallback_chain_set(profile: str, chain: list[Any]) -> dict[str, Any]:
|
|
|
15056
15706
|
"error": f"invalid fallback entry: provider={provider!r} model={model!r}",
|
|
15057
15707
|
}
|
|
15058
15708
|
normalized.append({"provider": provider, "model": model})
|
|
15709
|
+
if selection_only:
|
|
15710
|
+
user = str(user_id or "").strip()
|
|
15711
|
+
if not user:
|
|
15712
|
+
return {"ok": False, "error": "unauthorized"}
|
|
15713
|
+
_write_user_llm_prefs(user, fallback_chain=normalized)
|
|
15714
|
+
return {"ok": True, "profile": "", "fallback_chain": normalized, "selection_only": True}
|
|
15715
|
+
target = profile or _primary_profile()
|
|
15716
|
+
if not target:
|
|
15717
|
+
return {"ok": False, "error": "no profile resolved"}
|
|
15059
15718
|
ok, err = _write_fallback_chain(target, normalized)
|
|
15060
15719
|
if not ok:
|
|
15061
15720
|
return {"ok": False, "error": err}
|
|
15721
|
+
user = str(user_id or "").strip()
|
|
15722
|
+
tpl = safe_profile_slug(target)
|
|
15723
|
+
if user and not _is_runtime_profile_slug(tpl):
|
|
15724
|
+
runtime = _runtime_profile_slug(user, tpl)
|
|
15725
|
+
if _runtime_profile_on_disk(runtime):
|
|
15726
|
+
_sync_runtime_model_from_template(runtime, tpl)
|
|
15062
15727
|
return {"ok": True, "profile": target, "fallback_chain": normalized}
|
|
15063
15728
|
|
|
15064
15729
|
|
|
@@ -15844,6 +16509,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
15844
16509
|
except Exception:
|
|
15845
16510
|
return self._json(200, {"setup_complete": False})
|
|
15846
16511
|
if path == "/api/install/status":
|
|
16512
|
+
if install_api.install_window_open(str(_workframe_db_path())):
|
|
16513
|
+
try:
|
|
16514
|
+
_ensure_native_hermes_profile()
|
|
16515
|
+
except Exception:
|
|
16516
|
+
pass
|
|
15847
16517
|
payload = install_api.install_status_payload(
|
|
15848
16518
|
DEPLOYMENT_MODE,
|
|
15849
16519
|
SECURE_MODE,
|
|
@@ -16531,7 +17201,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
16531
17201
|
profile = qs.get("profile", [""])[0]
|
|
16532
17202
|
user_id = str(getattr(self, "auth_user", "") or "")
|
|
16533
17203
|
workspace_id = qs.get("workspace_id", [""])[0]
|
|
16534
|
-
|
|
17204
|
+
selection_only = qs.get("selection_only", ["0"])[0] in {"1", "true", "yes"}
|
|
17205
|
+
payload = hermes_models(profile, user_id, workspace_id, selection_only=selection_only)
|
|
17206
|
+
status = 200 if payload.get("ok", True) else 400
|
|
17207
|
+
return self._json(status, payload)
|
|
16535
17208
|
if path == "/api/hermes/profiles/status":
|
|
16536
17209
|
profile = resolve_validated_profile(qs.get("profile", [""])[0] or _primary_profile())
|
|
16537
17210
|
if SUPERVISOR_URL:
|
|
@@ -16847,6 +17520,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
16847
17520
|
|
|
16848
17521
|
def _complete_install(self, user_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
16849
17522
|
global DEPLOYMENT_MODE
|
|
17523
|
+
if not _native_profile_present():
|
|
17524
|
+
_ensure_native_hermes_profile()
|
|
16850
17525
|
data = body if isinstance(body, dict) else {}
|
|
16851
17526
|
if not user_id and DEPLOYMENT_MODE == "single_user_local":
|
|
16852
17527
|
user_id = str(data.get("user_id") or secrets.token_hex(8))
|
|
@@ -18532,11 +19207,24 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
18532
19207
|
profile = str(body.get("profile", "")).strip()
|
|
18533
19208
|
user_id = str(getattr(self, "auth_user", "") or "")
|
|
18534
19209
|
workspace_id = str(body.get("workspace_id", "")).strip()
|
|
19210
|
+
selection_only = bool(body.get("selection_only"))
|
|
18535
19211
|
if not model_id:
|
|
18536
19212
|
return self._json(400, {"error": "model required"})
|
|
18537
|
-
if profile:
|
|
18538
|
-
|
|
18539
|
-
|
|
19213
|
+
if profile and not selection_only:
|
|
19214
|
+
try:
|
|
19215
|
+
_bootstrap_profile_providers(_resolve_models_profile(profile), user_id, workspace_id)
|
|
19216
|
+
except ValueError as exc:
|
|
19217
|
+
return self._json(400, {"ok": False, "error": str(exc)})
|
|
19218
|
+
return self._json(
|
|
19219
|
+
200,
|
|
19220
|
+
hermes_model_set(
|
|
19221
|
+
profile,
|
|
19222
|
+
model_id,
|
|
19223
|
+
user_id,
|
|
19224
|
+
workspace_id,
|
|
19225
|
+
selection_only=selection_only,
|
|
19226
|
+
),
|
|
19227
|
+
)
|
|
18540
19228
|
if path == "/api/hermes/model/apply-default":
|
|
18541
19229
|
if not _check_auth(self):
|
|
18542
19230
|
return self._json(401, {"error": "unauthorized"})
|
|
@@ -18546,13 +19234,23 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
18546
19234
|
if path == "/api/hermes/fallback-chain":
|
|
18547
19235
|
if not _check_auth(self):
|
|
18548
19236
|
return self._json(401, {"error": "unauthorized"})
|
|
18549
|
-
if not _role_allows(self, OWNER_ADMIN_ROLES):
|
|
18550
|
-
return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
|
|
18551
19237
|
profile = str(body.get("profile", "")).strip()
|
|
18552
19238
|
chain = body.get("chain", [])
|
|
19239
|
+
selection_only = bool(body.get("selection_only"))
|
|
19240
|
+
user_id = str(getattr(self, "auth_user", "") or "")
|
|
18553
19241
|
if not isinstance(chain, list):
|
|
18554
19242
|
return self._json(400, {"error": "chain must be a list"})
|
|
18555
|
-
|
|
19243
|
+
if not selection_only and not _role_allows(self, OWNER_ADMIN_ROLES):
|
|
19244
|
+
return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
|
|
19245
|
+
return self._json(
|
|
19246
|
+
200,
|
|
19247
|
+
hermes_fallback_chain_set(
|
|
19248
|
+
profile,
|
|
19249
|
+
chain,
|
|
19250
|
+
selection_only=selection_only,
|
|
19251
|
+
user_id=user_id,
|
|
19252
|
+
),
|
|
19253
|
+
)
|
|
18556
19254
|
if path == "/api/board":
|
|
18557
19255
|
if not _check_auth(self):
|
|
18558
19256
|
return self._json(401, {"error": "unauthorized"})
|