create-workframe 0.1.6 → 0.1.7
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/package.json +1 -1
- package/workframe-api/package.json +1 -1
- package/workframe-api/server.py +540 -111
- package/workframe-api/stack_config.py +11 -3
- 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 +51 -0
- package/workframe-ui/public/assets/{arc-DoYr2T5c.js → arc-Cn-lt9fT.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-Clher08G.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bdw9txrZ.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DlI_h_uo.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
- package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-ol-uwoeq.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-7eDhBen0.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-xPORD7Js.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-Cwd7dr1z.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Ckt4Sjy1.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-sMk1op_G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CMgSvr6H.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-Dx7GN0T4.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q--OCu86u0.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-CejmwJpz.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CvOj2Yvp.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-B0nGBjLU.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bdzvu227.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-CGR07jTY.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CBojDPNC.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DBin5MPF.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-TD7I7pcb.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bmm5x5xm.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-zU2r0aq2.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-AL01qukm.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-DKi1xSMg.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQJDQ-f-.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-B1oisGUj.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-DjpJKffk.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
- package/workframe-ui/public/assets/{dist-Jk20AnQG.js → dist-uZYeD3o1.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Vu8RtLUf.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CHAXEhbR.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DhiEkfH8.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-Cuq44XvU.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-B7kgReRt.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
- package/workframe-ui/public/assets/{index-72-j0sA5.js → index-DHLQNAXd.js} +4 -4
- package/workframe-ui/public/assets/{info-J43DQDTF-oV4b3M4f.js → info-J43DQDTF-BNMADxE2.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CthN4b6o.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-oEGS3TKg.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CXJsWmKF.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DTPo8yHW.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
- package/workframe-ui/public/assets/{line-Bm9Pmcnz.js → line-CNtIbnmt.js} +1 -1
- package/workframe-ui/public/assets/{linear-Cn-uD6eX.js → linear-pmSg_4uV.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-BBVSTQZ4.js → mermaid-parser.core-BX4tnojE.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-DE668nUy.js → mermaid.core-BtpBva5o.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-4qVsiwCf.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-CqJeoiFG.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DcOqVssg.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CosMnpAM.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-DtsnIK98.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BqLpJuWc.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Pb7cuX8_.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BJBlMM1m.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-i4HnjP39.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
- package/workframe-ui/public/assets/{src-DIhmc_XG.js → src-B3Axsi1U.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-Ba2iClPT.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-CL4X9vSF.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-BJrjnbMj.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CN6H54RF.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-Cee6sbUy.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-D0cXq60c.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CaH9XkCx.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BEisgFoI.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
- package/workframe-ui/public/index.html +1 -1
- 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/stateDiagram-v2-BHNVJYJU-aUpqRAhy.js +0 -1
package/workframe-api/server.py
CHANGED
|
@@ -1101,13 +1101,13 @@ def _provider_connected_for_user(
|
|
|
1101
1101
|
return False
|
|
1102
1102
|
|
|
1103
1103
|
|
|
1104
|
-
def
|
|
1104
|
+
def _user_hermes_dir_slug(user_id: str) -> str:
|
|
1105
1105
|
raw = str(user_id or "").strip()
|
|
1106
1106
|
return re.sub(r"[^A-Za-z0-9._-]+", "_", raw) or "user"
|
|
1107
1107
|
|
|
1108
1108
|
|
|
1109
1109
|
def _user_hermes_home(user_id: str) -> Path:
|
|
1110
|
-
return HERMES_DATA / "profiles" /
|
|
1110
|
+
return HERMES_DATA / "profiles" / _user_hermes_dir_slug(user_id)
|
|
1111
1111
|
|
|
1112
1112
|
|
|
1113
1113
|
def _user_hermes_env_path(user_id: str) -> Path:
|
|
@@ -2239,7 +2239,7 @@ def _remove_auth_metadata(auth_path: Path, credential_ref: str) -> None:
|
|
|
2239
2239
|
|
|
2240
2240
|
|
|
2241
2241
|
def _hermes_user_home_container(user_id: str) -> str:
|
|
2242
|
-
return f"/opt/data/profiles/{
|
|
2242
|
+
return f"/opt/data/profiles/{_user_hermes_dir_slug(user_id)}"
|
|
2243
2243
|
|
|
2244
2244
|
|
|
2245
2245
|
def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
|
|
@@ -2274,7 +2274,7 @@ def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
|
|
|
2274
2274
|
f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; "
|
|
2275
2275
|
f"/opt/hermes/bin/hermes {inner}"
|
|
2276
2276
|
)
|
|
2277
|
-
return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=
|
|
2277
|
+
return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=_user_hermes_dir_slug(user_id))
|
|
2278
2278
|
|
|
2279
2279
|
|
|
2280
2280
|
def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
|
|
@@ -2312,6 +2312,108 @@ def _hermes_auth_id_for_spec(spec: dict[str, Any]) -> str:
|
|
|
2312
2312
|
return str(spec.get("hermes_auth_id") or spec.get("id") or "").strip()
|
|
2313
2313
|
|
|
2314
2314
|
|
|
2315
|
+
def _hermes_oauth_auth_keys(hermes_auth_id: str) -> set[str]:
|
|
2316
|
+
raw = str(hermes_auth_id or "").strip().lower()
|
|
2317
|
+
if not raw:
|
|
2318
|
+
return set()
|
|
2319
|
+
keys = {raw, raw.replace("-", ""), raw.replace("-", "_")}
|
|
2320
|
+
if "-" in raw:
|
|
2321
|
+
keys.add(raw.split("-")[-1])
|
|
2322
|
+
return {key for key in keys if key}
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def _oauth_llm_provider_spec(provider: str) -> dict[str, Any] | None:
|
|
2326
|
+
spec = _catalog_provider_for_llm(provider)
|
|
2327
|
+
if spec and str(spec.get("connect_mode") or "") == "oauth":
|
|
2328
|
+
return spec
|
|
2329
|
+
return None
|
|
2330
|
+
|
|
2331
|
+
|
|
2332
|
+
def _auth_json_has_oauth_material(data: dict[str, Any]) -> bool:
|
|
2333
|
+
providers = data.get("providers")
|
|
2334
|
+
if isinstance(providers, dict) and providers:
|
|
2335
|
+
return True
|
|
2336
|
+
pool = data.get("credential_pool")
|
|
2337
|
+
if isinstance(pool, dict) and any(isinstance(entries, list) and entries for entries in pool.values()):
|
|
2338
|
+
return True
|
|
2339
|
+
creds = data.get("credentials")
|
|
2340
|
+
return isinstance(creds, list) and bool(creds)
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def _extract_oauth_block_from_auth(loaded: dict[str, Any], hermes_auth_id: str) -> dict[str, Any] | None:
|
|
2344
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
2345
|
+
providers = loaded.get("providers")
|
|
2346
|
+
if isinstance(providers, dict):
|
|
2347
|
+
for key, entry in providers.items():
|
|
2348
|
+
if str(key).lower() in keys and isinstance(entry, dict):
|
|
2349
|
+
tokens = entry.get("tokens")
|
|
2350
|
+
if isinstance(tokens, dict) and any(
|
|
2351
|
+
str(tokens.get(field) or "").strip()
|
|
2352
|
+
for field in ("access_token", "refresh_token", "api_key", "id_token")
|
|
2353
|
+
):
|
|
2354
|
+
return entry
|
|
2355
|
+
if entry:
|
|
2356
|
+
return entry
|
|
2357
|
+
creds = loaded.get("credentials")
|
|
2358
|
+
if isinstance(creds, list):
|
|
2359
|
+
for row in creds:
|
|
2360
|
+
if not isinstance(row, dict):
|
|
2361
|
+
continue
|
|
2362
|
+
pid = str(row.get("provider") or row.get("id") or "").lower()
|
|
2363
|
+
if pid in keys:
|
|
2364
|
+
return row
|
|
2365
|
+
return None
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
def _merge_oauth_auth_into_profile(
|
|
2369
|
+
auth: dict[str, Any],
|
|
2370
|
+
user_auth: dict[str, Any],
|
|
2371
|
+
hermes_auth_id: str,
|
|
2372
|
+
) -> bool:
|
|
2373
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
2374
|
+
changed = False
|
|
2375
|
+
block = _extract_oauth_block_from_auth(user_auth, hermes_auth_id)
|
|
2376
|
+
if isinstance(block, dict):
|
|
2377
|
+
merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
|
|
2378
|
+
merged[hermes_auth_id] = block
|
|
2379
|
+
auth["providers"] = merged
|
|
2380
|
+
changed = True
|
|
2381
|
+
user_pool = user_auth.get("credential_pool")
|
|
2382
|
+
if isinstance(user_pool, dict):
|
|
2383
|
+
pool = auth.get("credential_pool")
|
|
2384
|
+
if not isinstance(pool, dict):
|
|
2385
|
+
pool = {}
|
|
2386
|
+
auth["credential_pool"] = pool
|
|
2387
|
+
for key, entries in user_pool.items():
|
|
2388
|
+
if str(key).lower() in keys and isinstance(entries, list) and entries:
|
|
2389
|
+
pool[hermes_auth_id] = entries
|
|
2390
|
+
changed = True
|
|
2391
|
+
return changed
|
|
2392
|
+
|
|
2393
|
+
|
|
2394
|
+
def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> bool:
|
|
2395
|
+
spec = _oauth_llm_provider_spec(provider)
|
|
2396
|
+
if not spec:
|
|
2397
|
+
return False
|
|
2398
|
+
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
2399
|
+
if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
2400
|
+
return False
|
|
2401
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
2402
|
+
if not isinstance(user_auth, dict):
|
|
2403
|
+
return False
|
|
2404
|
+
prof = resolve_hermes_profile(profile)
|
|
2405
|
+
auth_path = _profile_dir(prof) / "auth.json"
|
|
2406
|
+
auth = _load_profile_auth_json(prof)
|
|
2407
|
+
if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
|
|
2408
|
+
return False
|
|
2409
|
+
auth["version"] = 1
|
|
2410
|
+
auth["updated_at"] = _utc_now()
|
|
2411
|
+
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2412
|
+
auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
2413
|
+
_publish_profile_gateway_secrets(prof)
|
|
2414
|
+
return True
|
|
2415
|
+
|
|
2416
|
+
|
|
2315
2417
|
def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
|
|
2316
2418
|
"""True when Hermes auth.json has live OAuth tokens for a provider."""
|
|
2317
2419
|
hermes_auth_id = str(hermes_auth_id or "").strip()
|
|
@@ -2320,23 +2422,13 @@ def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
|
|
|
2320
2422
|
loaded = _load_user_hermes_auth(user_id)
|
|
2321
2423
|
if not isinstance(loaded, dict):
|
|
2322
2424
|
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
|
|
2425
|
+
if _extract_oauth_block_from_auth(loaded, hermes_auth_id):
|
|
2426
|
+
return True
|
|
2427
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
2335
2428
|
pool = loaded.get("credential_pool")
|
|
2336
2429
|
if isinstance(pool, dict):
|
|
2337
|
-
for key
|
|
2338
|
-
|
|
2339
|
-
if isinstance(entries, list) and entries:
|
|
2430
|
+
for key, entries in pool.items():
|
|
2431
|
+
if str(key).lower() in keys and isinstance(entries, list) and entries:
|
|
2340
2432
|
return True
|
|
2341
2433
|
return False
|
|
2342
2434
|
|
|
@@ -2351,9 +2443,9 @@ def _load_user_hermes_auth(user_id: str) -> dict[str, Any] | None:
|
|
|
2351
2443
|
loaded = data
|
|
2352
2444
|
except (OSError, json.JSONDecodeError):
|
|
2353
2445
|
loaded = None
|
|
2354
|
-
if isinstance(loaded, dict) and
|
|
2446
|
+
if isinstance(loaded, dict) and _auth_json_has_oauth_material(loaded):
|
|
2355
2447
|
return loaded
|
|
2356
|
-
rel = f"profiles/{
|
|
2448
|
+
rel = f"profiles/{_user_hermes_dir_slug(user_id)}/auth.json"
|
|
2357
2449
|
text = _read_gateway_data_file(rel)
|
|
2358
2450
|
if text.strip():
|
|
2359
2451
|
try:
|
|
@@ -2488,7 +2580,7 @@ def _remove_hermes_oauth_provider(user_id: str, hermes_auth_id: str) -> None:
|
|
|
2488
2580
|
pool.pop(key, None)
|
|
2489
2581
|
loaded["updated_at"] = _utc_now()
|
|
2490
2582
|
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]+", "-",
|
|
2583
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
2492
2584
|
prefix = f"u-{user_part}-"
|
|
2493
2585
|
profiles_dir = HERMES_DATA / "profiles"
|
|
2494
2586
|
if profiles_dir.is_dir():
|
|
@@ -2594,25 +2686,12 @@ def _parse_device_oauth_log(text: str) -> dict[str, str | None]:
|
|
|
2594
2686
|
|
|
2595
2687
|
|
|
2596
2688
|
def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id: str) -> None:
|
|
2597
|
-
|
|
2598
|
-
if not src_path.is_file():
|
|
2689
|
+
if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
2599
2690
|
return
|
|
2600
|
-
|
|
2601
|
-
|
|
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):
|
|
2606
|
-
return
|
|
2607
|
-
block = None
|
|
2608
|
-
for key in (hermes_auth_id, hermes_auth_id.replace("-", "")):
|
|
2609
|
-
entry = providers.get(key)
|
|
2610
|
-
if isinstance(entry, dict):
|
|
2611
|
-
block = entry
|
|
2612
|
-
break
|
|
2613
|
-
if not block:
|
|
2691
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
2692
|
+
if not isinstance(user_auth, dict):
|
|
2614
2693
|
return
|
|
2615
|
-
user_part = re.sub(r"[^a-z0-9]+", "-",
|
|
2694
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
2616
2695
|
prefix = f"u-{user_part}-"
|
|
2617
2696
|
profiles_dir = HERMES_DATA / "profiles"
|
|
2618
2697
|
if not profiles_dir.is_dir():
|
|
@@ -2622,9 +2701,11 @@ def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id:
|
|
|
2622
2701
|
continue
|
|
2623
2702
|
auth_path = prof_dir / "auth.json"
|
|
2624
2703
|
auth = _load_profile_auth_json(prof_dir.name)
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2704
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
2705
|
+
if not isinstance(user_auth, dict):
|
|
2706
|
+
continue
|
|
2707
|
+
if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
|
|
2708
|
+
continue
|
|
2628
2709
|
auth["version"] = 1
|
|
2629
2710
|
auth["updated_at"] = _utc_now()
|
|
2630
2711
|
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -2743,7 +2824,7 @@ def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict
|
|
|
2743
2824
|
except OSError:
|
|
2744
2825
|
log_text = ""
|
|
2745
2826
|
if not log_text.strip() and log_path.name:
|
|
2746
|
-
log_text = _read_gateway_data_file(f"profiles/{
|
|
2827
|
+
log_text = _read_gateway_data_file(f"profiles/{_user_hermes_dir_slug(user_id)}/{log_path.name}")
|
|
2747
2828
|
parsed = _parse_device_oauth_log(log_text)
|
|
2748
2829
|
patch: dict[str, Any] = {}
|
|
2749
2830
|
if parsed.get("verification_uri"):
|
|
@@ -4990,6 +5071,8 @@ def bootstrap_agent_dm_lane(
|
|
|
4990
5071
|
"""Provision u-* runtime, model/proxy, DM room, and optional session bind — install/create-agent parity."""
|
|
4991
5072
|
user_id = str(user_id or "").strip()
|
|
4992
5073
|
workspace_id = str(workspace_id or "").strip()
|
|
5074
|
+
if not _native_profile_present():
|
|
5075
|
+
_ensure_native_hermes_profile()
|
|
4993
5076
|
template = resolve_validated_profile(str(template_slug or "").strip())
|
|
4994
5077
|
if not user_id or not workspace_id:
|
|
4995
5078
|
return {"ok": False, "error": "user_id and workspace_id required"}
|
|
@@ -5137,7 +5220,7 @@ def bootstrap_agent_dm_lane(
|
|
|
5137
5220
|
|
|
5138
5221
|
def _runtime_profile_slug(user_id: str, template_slug: str) -> str:
|
|
5139
5222
|
template_slug = safe_profile_slug(template_slug)
|
|
5140
|
-
user_part = re.sub(r"[^a-z0-9]+", "-",
|
|
5223
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
5141
5224
|
slug = f"u-{user_part}-{template_slug}"
|
|
5142
5225
|
if len(slug) > 64:
|
|
5143
5226
|
slug = slug[:64].rstrip("-")
|
|
@@ -5169,7 +5252,8 @@ def _prepare_runtime_profile_credentials(
|
|
|
5169
5252
|
return False
|
|
5170
5253
|
_strip_profile_llm_env(runtime)
|
|
5171
5254
|
_strip_profile_action_env(runtime)
|
|
5172
|
-
|
|
5255
|
+
_reconcile_profile_llm_for_user(runtime, user, workspace_id)
|
|
5256
|
+
prov = _llm_billing_provider(runtime, user_id=user, workspace_id=workspace_id)
|
|
5173
5257
|
_ensure_profile_llm_proxy(runtime, prov)
|
|
5174
5258
|
return _user_can_use_llm(user, workspace_id, prov)
|
|
5175
5259
|
|
|
@@ -7601,10 +7685,6 @@ def _bootstrap_after_setup(agent_personality: str = "") -> dict[str, Any]:
|
|
|
7601
7685
|
if personality:
|
|
7602
7686
|
_seed_native_user_overlay(profile, profile, user_soul=personality)
|
|
7603
7687
|
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
7688
|
return result
|
|
7609
7689
|
|
|
7610
7690
|
|
|
@@ -7858,6 +7938,110 @@ def _list_profiles() -> list[str]:
|
|
|
7858
7938
|
)
|
|
7859
7939
|
|
|
7860
7940
|
|
|
7941
|
+
def _native_profile_slug() -> str:
|
|
7942
|
+
return str(NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
|
|
7943
|
+
|
|
7944
|
+
|
|
7945
|
+
def _native_profile_present() -> bool:
|
|
7946
|
+
slug = _native_profile_slug()
|
|
7947
|
+
prof_dir = _profile_dir(slug)
|
|
7948
|
+
return (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file()
|
|
7949
|
+
|
|
7950
|
+
|
|
7951
|
+
def _hermes_data_uid_gid() -> tuple[int, int]:
|
|
7952
|
+
try:
|
|
7953
|
+
st = HERMES_DATA.stat()
|
|
7954
|
+
return int(st.st_uid), int(st.st_gid)
|
|
7955
|
+
except OSError:
|
|
7956
|
+
return 10000, 10000
|
|
7957
|
+
|
|
7958
|
+
|
|
7959
|
+
def _chown_profile_tree(prof_dir: Path) -> None:
|
|
7960
|
+
"""Gateway runs as the Agents volume owner — seeded files must match."""
|
|
7961
|
+
uid, gid = _hermes_data_uid_gid()
|
|
7962
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
7963
|
+
(prof_dir / "logs").mkdir(parents=True, exist_ok=True)
|
|
7964
|
+
for path in [prof_dir, *prof_dir.rglob("*")]:
|
|
7965
|
+
try:
|
|
7966
|
+
os.chown(path, uid, gid)
|
|
7967
|
+
except OSError:
|
|
7968
|
+
pass
|
|
7969
|
+
|
|
7970
|
+
|
|
7971
|
+
def _seed_native_profile_on_disk(slug: str) -> bool:
|
|
7972
|
+
"""Filesystem fallback when Hermes CLI cannot run (empty / crash-looping gateway)."""
|
|
7973
|
+
slug = safe_profile_slug(slug)
|
|
7974
|
+
prof_dir = _profile_dir(slug)
|
|
7975
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
7976
|
+
config = prof_dir / "config.yaml"
|
|
7977
|
+
if not config.is_file():
|
|
7978
|
+
proxy = _llm_proxy_base_url("openrouter")
|
|
7979
|
+
config.write_text(
|
|
7980
|
+
"model:\n"
|
|
7981
|
+
" default: google/gemini-2.5-flash\n"
|
|
7982
|
+
" provider: custom\n"
|
|
7983
|
+
f" base_url: {proxy}\n",
|
|
7984
|
+
encoding="utf-8",
|
|
7985
|
+
)
|
|
7986
|
+
soul = prof_dir / "SOUL.md"
|
|
7987
|
+
if not soul.is_file():
|
|
7988
|
+
soul.write_text(
|
|
7989
|
+
f"You are the {PROJECT_NAME} native agent — orchestrator and workspace admin.\n",
|
|
7990
|
+
encoding="utf-8",
|
|
7991
|
+
)
|
|
7992
|
+
_ensure_profile_toolsets(slug)
|
|
7993
|
+
_ensure_profile_terminal_cwd(slug)
|
|
7994
|
+
_register_profile_route(
|
|
7995
|
+
slug,
|
|
7996
|
+
{
|
|
7997
|
+
"display_name": f"{PROJECT_NAME} Agent",
|
|
7998
|
+
"role": f"{PROJECT_NAME} Manager",
|
|
7999
|
+
},
|
|
8000
|
+
)
|
|
8001
|
+
routes_path = ROUTES_JSON
|
|
8002
|
+
if routes_path.is_file():
|
|
8003
|
+
try:
|
|
8004
|
+
data = json.loads(routes_path.read_text(encoding="utf-8"))
|
|
8005
|
+
if isinstance(data, dict) and not data.get("default_profile"):
|
|
8006
|
+
data["default_profile"] = slug
|
|
8007
|
+
routes_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
8008
|
+
except (OSError, json.JSONDecodeError):
|
|
8009
|
+
pass
|
|
8010
|
+
_chown_profile_tree(prof_dir)
|
|
8011
|
+
return _native_profile_present()
|
|
8012
|
+
|
|
8013
|
+
|
|
8014
|
+
def _ensure_native_hermes_profile() -> bool:
|
|
8015
|
+
"""Idempotent: native workframe-agent must exist before gateway boot or DM bootstrap."""
|
|
8016
|
+
slug = _native_profile_slug()
|
|
8017
|
+
if _native_profile_present():
|
|
8018
|
+
return True
|
|
8019
|
+
shell = (
|
|
8020
|
+
"export HERMES_HOME=/opt/data HOME=/opt/data; "
|
|
8021
|
+
f"/opt/hermes/bin/hermes profile create {shlex.quote(slug)} --clone "
|
|
8022
|
+
f"--description {shlex.quote(f'{PROJECT_NAME} native agent')}"
|
|
8023
|
+
)
|
|
8024
|
+
try:
|
|
8025
|
+
code, out = _gateway_container_exec(["sh", "-lc", shell])
|
|
8026
|
+
if code == 0 and _native_profile_present():
|
|
8027
|
+
_register_profile_route(
|
|
8028
|
+
slug,
|
|
8029
|
+
{"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
|
|
8030
|
+
)
|
|
8031
|
+
_chown_profile_tree(_profile_dir(slug))
|
|
8032
|
+
return True
|
|
8033
|
+
if "already exists" in out.lower() and _native_profile_present():
|
|
8034
|
+
_register_profile_route(
|
|
8035
|
+
slug,
|
|
8036
|
+
{"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
|
|
8037
|
+
)
|
|
8038
|
+
_chown_profile_tree(_profile_dir(slug))
|
|
8039
|
+
return True
|
|
8040
|
+
except Exception:
|
|
8041
|
+
pass
|
|
8042
|
+
return _seed_native_profile_on_disk(slug)
|
|
8043
|
+
|
|
8044
|
+
|
|
7861
8045
|
def _primary_profile() -> str:
|
|
7862
8046
|
if NATIVE_PROFILE and (HERMES_DATA / "profiles" / NATIVE_PROFILE).is_dir():
|
|
7863
8047
|
return NATIVE_PROFILE
|
|
@@ -11297,6 +11481,10 @@ def _ensure_profile_llm_proxy(profile: str, provider: str) -> None:
|
|
|
11297
11481
|
"""Point Hermes at the internal LLM proxy (create/lease paths only — skip when ready)."""
|
|
11298
11482
|
llm_provider = str(provider or "openrouter").strip().lower()
|
|
11299
11483
|
prof = safe_profile_slug(profile)
|
|
11484
|
+
if _oauth_llm_provider_spec(llm_provider):
|
|
11485
|
+
_normalize_profile_config_yaml(prof)
|
|
11486
|
+
_apply_mvp_model_for_provider(prof, llm_provider)
|
|
11487
|
+
return
|
|
11300
11488
|
_normalize_profile_config_yaml(prof)
|
|
11301
11489
|
if _profile_llm_proxy_ready(prof, llm_provider):
|
|
11302
11490
|
_ensure_profile_proxy_headers(prof)
|
|
@@ -11449,6 +11637,16 @@ def _apply_turn_credential_lease(
|
|
|
11449
11637
|
resolved = _require_runtime_owner_provider(user_id, workspace_id, provider)
|
|
11450
11638
|
else:
|
|
11451
11639
|
resolved = _require_user_provider(user_id, workspace_id, provider)
|
|
11640
|
+
oauth_spec = _oauth_llm_provider_spec(provider)
|
|
11641
|
+
if oauth_spec and str(resolved.get("credential_type") or "") == "oauth":
|
|
11642
|
+
_sync_oauth_llm_to_profile(prof, user_id, provider)
|
|
11643
|
+
current_model = str(_read_model_from_config(prof)[1] or "").strip()
|
|
11644
|
+
if not _resolve_billing_provider_for_model(current_model, {provider}):
|
|
11645
|
+
current_model = str(
|
|
11646
|
+
(PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
|
|
11647
|
+
).strip()
|
|
11648
|
+
_apply_model_for_billing_provider(prof, provider, current_model, user_id)
|
|
11649
|
+
return ""
|
|
11452
11650
|
binding_id = str(
|
|
11453
11651
|
resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
|
|
11454
11652
|
).strip()
|
|
@@ -11591,6 +11789,26 @@ def _require_user_provider(user_id: str, workspace_id: str, provider: str) -> di
|
|
|
11591
11789
|
|
|
11592
11790
|
def _require_runtime_owner_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
|
|
11593
11791
|
provider = str(provider or "openrouter").strip().lower()
|
|
11792
|
+
oauth_spec = _oauth_llm_provider_spec(provider)
|
|
11793
|
+
if oauth_spec and _hermes_oauth_tokens_present(user_id, _hermes_auth_id_for_spec(oauth_spec)):
|
|
11794
|
+
hermes_auth_id = _hermes_auth_id_for_spec(oauth_spec)
|
|
11795
|
+
return {
|
|
11796
|
+
"credential_binding_id": None,
|
|
11797
|
+
"credential_id": None,
|
|
11798
|
+
"credential_ref": f"oauth:{hermes_auth_id}",
|
|
11799
|
+
"scope": "user",
|
|
11800
|
+
"provider": provider,
|
|
11801
|
+
"credential_type": "oauth",
|
|
11802
|
+
"label": hermes_auth_id,
|
|
11803
|
+
"env_var": "",
|
|
11804
|
+
"user_id": user_id,
|
|
11805
|
+
"workspace_id": None,
|
|
11806
|
+
"agent_profile_id": None,
|
|
11807
|
+
"created_by": user_id,
|
|
11808
|
+
"created_at": None,
|
|
11809
|
+
"updated_at": None,
|
|
11810
|
+
"expires_at": None,
|
|
11811
|
+
}
|
|
11594
11812
|
resolved = _resolve_credential(user_id, workspace_id, provider, user_only=True)
|
|
11595
11813
|
if resolved and _credential_secret(resolved, user_id):
|
|
11596
11814
|
return resolved
|
|
@@ -12346,8 +12564,19 @@ def _provider_pool_has_entries(auth: dict[str, Any], provider: str) -> bool:
|
|
|
12346
12564
|
pool = auth.get("credential_pool")
|
|
12347
12565
|
if not isinstance(pool, dict):
|
|
12348
12566
|
return False
|
|
12349
|
-
|
|
12350
|
-
|
|
12567
|
+
provider_key = str(provider or "").strip().lower()
|
|
12568
|
+
for key in _hermes_oauth_auth_keys(provider_key) | {provider_key}:
|
|
12569
|
+
entries = pool.get(key)
|
|
12570
|
+
if isinstance(entries, list) and len(entries) > 0:
|
|
12571
|
+
return True
|
|
12572
|
+
spec = _catalog_provider_for_llm(provider_key)
|
|
12573
|
+
if spec:
|
|
12574
|
+
auth_id = str(spec.get("hermes_auth_id") or "").strip().lower()
|
|
12575
|
+
if auth_id:
|
|
12576
|
+
entries = pool.get(auth_id)
|
|
12577
|
+
if isinstance(entries, list) and len(entries) > 0:
|
|
12578
|
+
return True
|
|
12579
|
+
return False
|
|
12351
12580
|
|
|
12352
12581
|
|
|
12353
12582
|
def _connected_provider_names(user_id: str = "", workspace_id: str = "") -> set[str]:
|
|
@@ -12550,13 +12779,14 @@ def _llm_billing_provider(
|
|
|
12550
12779
|
prof = resolve_hermes_profile(profile)
|
|
12551
12780
|
block = _read_model_block(prof)
|
|
12552
12781
|
cfg_provider = str(block.get("provider") or "").strip().lower()
|
|
12553
|
-
|
|
12554
|
-
|
|
12782
|
+
billing_cfg = _billing_provider_id_from_hermes_config(cfg_provider)
|
|
12783
|
+
user = str(user_id or "").strip()
|
|
12784
|
+
ws = str(workspace_id or "").strip()
|
|
12785
|
+
if billing_cfg and user and _user_can_use_llm(user, ws, billing_cfg):
|
|
12786
|
+
return billing_cfg
|
|
12555
12787
|
candidate = _billing_provider_from_block(
|
|
12556
12788
|
str(block.get("base_url") or ""), cfg_provider, provider_hint,
|
|
12557
12789
|
)
|
|
12558
|
-
user = str(user_id or "").strip()
|
|
12559
|
-
ws = str(workspace_id or "").strip()
|
|
12560
12790
|
if user and not _user_can_use_llm(user, ws, candidate):
|
|
12561
12791
|
connected = sorted(_user_llm_providers_for_picker(user))
|
|
12562
12792
|
if connected:
|
|
@@ -12591,6 +12821,12 @@ def _reconcile_profile_llm_for_user(
|
|
|
12591
12821
|
if not connected:
|
|
12592
12822
|
return False
|
|
12593
12823
|
billing = connected[0]
|
|
12824
|
+
if _oauth_llm_provider_spec(billing) and _user_can_use_llm(user, workspace_id, billing):
|
|
12825
|
+
current = str(_read_model_from_config(prof)[1] or "").strip()
|
|
12826
|
+
if not _resolve_billing_provider_for_model(current, {billing}):
|
|
12827
|
+
current = str((PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
|
|
12828
|
+
_apply_model_for_billing_provider(prof, billing, current, user_id=user)
|
|
12829
|
+
return True
|
|
12594
12830
|
if _user_can_use_llm(user, workspace_id, billing) and _profile_llm_proxy_matches_billing(prof, billing):
|
|
12595
12831
|
return False
|
|
12596
12832
|
changed = _apply_mvp_model_for_provider(prof, billing)
|
|
@@ -12637,26 +12873,9 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
|
|
|
12637
12873
|
llm_spec
|
|
12638
12874
|
and str(llm_spec.get("connect_mode") or "") == "oauth"
|
|
12639
12875
|
and user
|
|
12640
|
-
and
|
|
12876
|
+
and _sync_oauth_llm_to_profile(prof, user, llm_provider)
|
|
12641
12877
|
):
|
|
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
|
|
12878
|
+
return True
|
|
12660
12879
|
|
|
12661
12880
|
if _is_runtime_profile_slug(prof):
|
|
12662
12881
|
if not user or not _user_can_use_llm(user, workspace_id, llm_provider):
|
|
@@ -14454,7 +14673,10 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
|
|
|
14454
14673
|
},
|
|
14455
14674
|
"codex": {
|
|
14456
14675
|
"primary": "gpt-5.4-medium",
|
|
14457
|
-
"fallbacks": [
|
|
14676
|
+
"fallbacks": [
|
|
14677
|
+
{"provider": "openai-codex", "model": "gpt-5.4-medium"},
|
|
14678
|
+
{"provider": "openai-codex", "model": "gpt-5.4-mini"},
|
|
14679
|
+
],
|
|
14458
14680
|
},
|
|
14459
14681
|
"nous": {
|
|
14460
14682
|
"primary": "nousresearch/hermes-4-70b",
|
|
@@ -14463,6 +14685,185 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
|
|
|
14463
14685
|
}
|
|
14464
14686
|
|
|
14465
14687
|
|
|
14688
|
+
# ponytail: billing id (connect catalog) ↔ Hermes config model.provider ↔ picker rows
|
|
14689
|
+
_PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
|
|
14690
|
+
"codex": frozenset({"codex", "openai"}),
|
|
14691
|
+
"openai": frozenset({"openai"}),
|
|
14692
|
+
"openrouter": frozenset({"openrouter"}),
|
|
14693
|
+
"anthropic": frozenset({"anthropic"}),
|
|
14694
|
+
"google": frozenset({"google"}),
|
|
14695
|
+
"deepseek": frozenset({"deepseek"}),
|
|
14696
|
+
"nous": frozenset({"nous"}),
|
|
14697
|
+
}
|
|
14698
|
+
|
|
14699
|
+
_CODEX_EXTRA_MODELS: tuple[tuple[str, str, str], ...] = (
|
|
14700
|
+
("gpt-5.4-medium", "GPT-5.4 Medium", "Codex default — agentic, tool-capable."),
|
|
14701
|
+
("gpt-5.4-mini", "GPT-5.4 Mini", "Faster Codex model on ChatGPT account."),
|
|
14702
|
+
)
|
|
14703
|
+
|
|
14704
|
+
|
|
14705
|
+
def _billing_provider_id_from_hermes_config(cfg_provider: str) -> str:
|
|
14706
|
+
p = str(cfg_provider or "").strip().lower()
|
|
14707
|
+
if p in {"openai-codex", "openai_codex", "codex"}:
|
|
14708
|
+
return "codex"
|
|
14709
|
+
if p in PROVIDER_MVP_MODELS:
|
|
14710
|
+
return p
|
|
14711
|
+
return ""
|
|
14712
|
+
|
|
14713
|
+
|
|
14714
|
+
def _catalog_tags_for_billing_provider(provider_id: str) -> frozenset[str]:
|
|
14715
|
+
return _PROVIDER_CATALOG_TAGS.get(str(provider_id or "").strip().lower(), frozenset())
|
|
14716
|
+
|
|
14717
|
+
|
|
14718
|
+
def _catalog_row_for_billing_provider(row: dict[str, str], provider_id: str) -> bool:
|
|
14719
|
+
tags = _catalog_tags_for_billing_provider(provider_id)
|
|
14720
|
+
if not tags:
|
|
14721
|
+
return False
|
|
14722
|
+
row_tag = str(row.get("provider") or "").strip().lower()
|
|
14723
|
+
return row_tag in tags
|
|
14724
|
+
|
|
14725
|
+
|
|
14726
|
+
def _provider_display_label(provider_id: str) -> str:
|
|
14727
|
+
spec = _catalog_provider(provider_id) or {}
|
|
14728
|
+
return str(spec.get("label") or provider_id).strip() or provider_id
|
|
14729
|
+
|
|
14730
|
+
|
|
14731
|
+
def _model_catalog_rows_for_provider(provider_id: str) -> list[dict[str, str]]:
|
|
14732
|
+
"""Curated models a connected billing provider can run."""
|
|
14733
|
+
provider_key = str(provider_id or "").strip().lower()
|
|
14734
|
+
label = _provider_display_label(provider_key)
|
|
14735
|
+
rows: list[dict[str, str]] = []
|
|
14736
|
+
seen: set[str] = set()
|
|
14737
|
+
|
|
14738
|
+
def _add(model: str, row_label: str, description: str) -> None:
|
|
14739
|
+
mid = str(model or "").strip()
|
|
14740
|
+
if not mid or mid in seen:
|
|
14741
|
+
return
|
|
14742
|
+
seen.add(mid)
|
|
14743
|
+
rows.append(
|
|
14744
|
+
{
|
|
14745
|
+
"provider": label,
|
|
14746
|
+
"billing_provider": provider_key,
|
|
14747
|
+
"model": mid,
|
|
14748
|
+
"label": row_label,
|
|
14749
|
+
"description": description,
|
|
14750
|
+
}
|
|
14751
|
+
)
|
|
14752
|
+
|
|
14753
|
+
if provider_key == "codex":
|
|
14754
|
+
for model, row_label, description in _CODEX_EXTRA_MODELS:
|
|
14755
|
+
_add(model, row_label, description)
|
|
14756
|
+
mvp = PROVIDER_MVP_MODELS.get(provider_key) or {}
|
|
14757
|
+
primary = str(mvp.get("primary") or "").strip()
|
|
14758
|
+
if primary:
|
|
14759
|
+
_add(primary, primary, f"Default for {label}.")
|
|
14760
|
+
for fb in mvp.get("fallbacks") or []:
|
|
14761
|
+
if isinstance(fb, dict):
|
|
14762
|
+
_add(str(fb.get("model") or ""), str(fb.get("model") or ""), f"Fallback for {label}.")
|
|
14763
|
+
for row in HERMES_MODEL_CATALOG:
|
|
14764
|
+
if _catalog_row_for_billing_provider(row, provider_key):
|
|
14765
|
+
_add(
|
|
14766
|
+
str(row.get("model") or ""),
|
|
14767
|
+
str(row.get("label") or row.get("model") or ""),
|
|
14768
|
+
str(row.get("description") or ""),
|
|
14769
|
+
)
|
|
14770
|
+
return rows
|
|
14771
|
+
|
|
14772
|
+
|
|
14773
|
+
def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[str, str]]:
|
|
14774
|
+
out: list[dict[str, str]] = []
|
|
14775
|
+
seen: set[str] = set()
|
|
14776
|
+
for provider_id in sorted(connected):
|
|
14777
|
+
for row in _model_catalog_rows_for_provider(provider_id):
|
|
14778
|
+
mid = str(row.get("model") or "").strip()
|
|
14779
|
+
if not mid or mid in seen:
|
|
14780
|
+
continue
|
|
14781
|
+
seen.add(mid)
|
|
14782
|
+
out.append(row)
|
|
14783
|
+
return out
|
|
14784
|
+
|
|
14785
|
+
|
|
14786
|
+
def _resolve_billing_provider_for_model(model_id: str, connected: set[str]) -> str:
|
|
14787
|
+
mid = str(model_id or "").strip()
|
|
14788
|
+
if not mid or not connected:
|
|
14789
|
+
return ""
|
|
14790
|
+
for provider_id in sorted(connected):
|
|
14791
|
+
for row in _model_catalog_rows_for_provider(provider_id):
|
|
14792
|
+
if str(row.get("model") or "").strip() == mid:
|
|
14793
|
+
return provider_id
|
|
14794
|
+
low = mid.lower()
|
|
14795
|
+
if low.startswith("gpt-") and "codex" in connected:
|
|
14796
|
+
return "codex"
|
|
14797
|
+
if low.startswith("gpt-") and "openai" in connected:
|
|
14798
|
+
return "openai"
|
|
14799
|
+
if "/" in mid and "openrouter" in connected:
|
|
14800
|
+
return "openrouter"
|
|
14801
|
+
if low.startswith("claude") and "anthropic" in connected:
|
|
14802
|
+
return "anthropic"
|
|
14803
|
+
if "gemini" in low and "google" in connected:
|
|
14804
|
+
return "google"
|
|
14805
|
+
if "deepseek" in low and "deepseek" in connected:
|
|
14806
|
+
return "deepseek"
|
|
14807
|
+
return ""
|
|
14808
|
+
|
|
14809
|
+
|
|
14810
|
+
def _strip_profile_model_proxy_fields(profile: str) -> None:
|
|
14811
|
+
"""OAuth/native providers: drop internal proxy base_url + lease api_key from model block."""
|
|
14812
|
+
config_path = _profile_gateway_config_path(profile)
|
|
14813
|
+
if not config_path or not config_path.is_file():
|
|
14814
|
+
return
|
|
14815
|
+
try:
|
|
14816
|
+
lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
14817
|
+
except OSError:
|
|
14818
|
+
return
|
|
14819
|
+
out: list[str] = []
|
|
14820
|
+
in_model = False
|
|
14821
|
+
for line in lines:
|
|
14822
|
+
stripped = line.lstrip()
|
|
14823
|
+
if stripped.startswith("model:") and not line.startswith((" ", "\t")):
|
|
14824
|
+
in_model = True
|
|
14825
|
+
out.append(line)
|
|
14826
|
+
continue
|
|
14827
|
+
if in_model and stripped and not line.startswith((" ", "\t", "#")):
|
|
14828
|
+
in_model = False
|
|
14829
|
+
if in_model and stripped.startswith(("base_url:", "api_key:")):
|
|
14830
|
+
continue
|
|
14831
|
+
out.append(line)
|
|
14832
|
+
try:
|
|
14833
|
+
config_path.write_text("".join(out), encoding="utf-8")
|
|
14834
|
+
except OSError:
|
|
14835
|
+
return
|
|
14836
|
+
|
|
14837
|
+
|
|
14838
|
+
def _apply_model_for_billing_provider(
|
|
14839
|
+
profile: str,
|
|
14840
|
+
billing: str,
|
|
14841
|
+
model_id: str,
|
|
14842
|
+
user_id: str = "",
|
|
14843
|
+
) -> bool:
|
|
14844
|
+
billing = str(billing or "").strip().lower()
|
|
14845
|
+
ok, _ = _set_profile_model(profile, model_id)
|
|
14846
|
+
if not ok:
|
|
14847
|
+
return False
|
|
14848
|
+
if _oauth_llm_provider_spec(billing):
|
|
14849
|
+
ok_provider, _ = _set_profile_model_provider(profile, _hermes_config_provider_id(billing))
|
|
14850
|
+
if not ok_provider:
|
|
14851
|
+
return False
|
|
14852
|
+
_strip_profile_model_proxy_fields(profile)
|
|
14853
|
+
fallbacks = PROVIDER_MVP_MODELS.get(billing, {}).get("fallbacks") or []
|
|
14854
|
+
if isinstance(fallbacks, list):
|
|
14855
|
+
_write_fallback_chain(profile, fallbacks)
|
|
14856
|
+
user = str(user_id or "").strip()
|
|
14857
|
+
if user:
|
|
14858
|
+
_sync_oauth_llm_to_profile(profile, user, billing)
|
|
14859
|
+
return True
|
|
14860
|
+
ok_provider, _ = _set_profile_model_provider(profile, "custom")
|
|
14861
|
+
if not ok_provider:
|
|
14862
|
+
return False
|
|
14863
|
+
_set_profile_model_base_url(profile, _llm_proxy_base_url(billing))
|
|
14864
|
+
return True
|
|
14865
|
+
|
|
14866
|
+
|
|
14466
14867
|
def _hermes_config_provider_id(provider_key: str) -> str:
|
|
14467
14868
|
spec = _catalog_provider(provider_key)
|
|
14468
14869
|
if spec and spec.get("hermes_auth_id"):
|
|
@@ -14515,19 +14916,25 @@ def _bootstrap_model_after_llm_connect(
|
|
|
14515
14916
|
ws = str(workspace_id or "").strip()
|
|
14516
14917
|
primary = _primary_profile()
|
|
14517
14918
|
if primary:
|
|
14518
|
-
|
|
14519
|
-
if not str(model or "").strip():
|
|
14520
|
-
_apply_mvp_model_for_provider(primary, provider_key)
|
|
14521
|
-
elif user:
|
|
14919
|
+
if user and _oauth_llm_provider_spec(provider_key):
|
|
14522
14920
|
_reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
|
|
14921
|
+
else:
|
|
14922
|
+
_, model = _read_model_from_config(primary)
|
|
14923
|
+
if not str(model or "").strip():
|
|
14924
|
+
_apply_mvp_model_for_provider(primary, provider_key)
|
|
14925
|
+
elif user:
|
|
14926
|
+
_reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
|
|
14523
14927
|
template = primary or "workframe-agent"
|
|
14524
14928
|
runtime = _runtime_profile_slug(user_id, template)
|
|
14525
14929
|
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:
|
|
14930
|
+
if user and _oauth_llm_provider_spec(provider_key):
|
|
14530
14931
|
_reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
|
|
14932
|
+
else:
|
|
14933
|
+
_, model = _read_model_from_config(runtime)
|
|
14934
|
+
if not str(model or "").strip():
|
|
14935
|
+
_apply_mvp_model_for_provider(runtime, provider_key)
|
|
14936
|
+
elif user:
|
|
14937
|
+
_reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
|
|
14531
14938
|
|
|
14532
14939
|
|
|
14533
14940
|
# Curated snapshot of commonly-used models per provider. Surfaced in the
|
|
@@ -14558,6 +14965,12 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
|
|
|
14558
14965
|
"label": "o1", "description": "Reasoning model."},
|
|
14559
14966
|
{"provider": "OpenAI", "model": "o1-mini",
|
|
14560
14967
|
"label": "o1 mini", "description": "Cheaper reasoning."},
|
|
14968
|
+
{"provider": "OpenAI", "model": "gpt-5.4-mini",
|
|
14969
|
+
"label": "GPT-5.4 Mini", "description": "Faster Codex / OpenAI model."},
|
|
14970
|
+
{"provider": "Codex", "model": "gpt-5.4-medium",
|
|
14971
|
+
"label": "GPT-5.4 Medium (Codex)", "description": "Codex ChatGPT account default."},
|
|
14972
|
+
{"provider": "Codex", "model": "gpt-5.4-mini",
|
|
14973
|
+
"label": "GPT-5.4 Mini (Codex)", "description": "Faster Codex model."},
|
|
14561
14974
|
{"provider": "Google", "model": "gemini-2.5-pro",
|
|
14562
14975
|
"label": "Gemini 2.5 Pro",
|
|
14563
14976
|
"description": "Long context, strong reasoning."},
|
|
@@ -14941,27 +15354,17 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
|
|
|
14941
15354
|
and str(spec.get("id")).lower() in {n.lower() for n in connected}
|
|
14942
15355
|
for spec in PROVIDER_CONNECT_CATALOG
|
|
14943
15356
|
)
|
|
14944
|
-
|
|
14945
|
-
|
|
14946
|
-
|
|
14947
|
-
|
|
14948
|
-
|
|
14949
|
-
|
|
14950
|
-
if active_key and (row_key == active_key or active_key in row_key or row_key in active_key):
|
|
14951
|
-
return True
|
|
14952
|
-
return any(
|
|
14953
|
-
row_key == name or name in row_key or row_key in name
|
|
14954
|
-
for name in llm_set
|
|
14955
|
-
)
|
|
14956
|
-
|
|
15357
|
+
connected_llm = picker_llm if user_id else {
|
|
15358
|
+
str(spec["id"]).lower()
|
|
15359
|
+
for spec in PROVIDER_CONNECT_CATALOG
|
|
15360
|
+
if str(spec.get("category") or "") == "llm"
|
|
15361
|
+
and str(spec.get("id")).lower() in {n.lower() for n in connected}
|
|
15362
|
+
}
|
|
14957
15363
|
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 [],
|
|
15364
|
+
_suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
|
|
14962
15365
|
block,
|
|
14963
15366
|
)
|
|
14964
|
-
if user_id and "openrouter" in
|
|
15367
|
+
if user_id and "openrouter" in connected_llm:
|
|
14965
15368
|
resolved = _resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
|
|
14966
15369
|
secret = _credential_secret(resolved or {}, user_id) if resolved else ""
|
|
14967
15370
|
live = openrouter_catalog.live_suggestions(secret, limit=30)
|
|
@@ -15020,20 +15423,39 @@ def hermes_apply_default_model_config() -> dict[str, Any]:
|
|
|
15020
15423
|
}
|
|
15021
15424
|
|
|
15022
15425
|
|
|
15023
|
-
def hermes_model_set(
|
|
15024
|
-
|
|
15025
|
-
|
|
15026
|
-
|
|
15027
|
-
""
|
|
15426
|
+
def hermes_model_set(
|
|
15427
|
+
profile: str,
|
|
15428
|
+
model_id: str,
|
|
15429
|
+
user_id: str = "",
|
|
15430
|
+
workspace_id: str = "",
|
|
15431
|
+
) -> dict[str, Any]:
|
|
15432
|
+
"""Set primary model and align Hermes provider routing to a connected billing provider."""
|
|
15028
15433
|
target = profile or _primary_profile()
|
|
15029
15434
|
if not target:
|
|
15030
15435
|
return {"ok": False, "error": "no profile resolved"}
|
|
15031
15436
|
if not model_id or " " in model_id or "\n" in model_id:
|
|
15032
15437
|
return {"ok": False, "error": "invalid model id"}
|
|
15033
|
-
|
|
15034
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
15438
|
+
user = str(user_id or "").strip()
|
|
15439
|
+
ws = str(workspace_id or "").strip()
|
|
15440
|
+
connected = _user_llm_providers_for_picker(user) if user else set()
|
|
15441
|
+
billing = _resolve_billing_provider_for_model(model_id, connected)
|
|
15442
|
+
if billing:
|
|
15443
|
+
if not _apply_model_for_billing_provider(target, billing, model_id, user_id=user):
|
|
15444
|
+
return {"ok": False, "error": "model apply failed"}
|
|
15445
|
+
else:
|
|
15446
|
+
ok, err = _set_profile_model(target, model_id)
|
|
15447
|
+
if not ok:
|
|
15448
|
+
return {"ok": False, "error": err}
|
|
15449
|
+
block = _read_model_block(target)
|
|
15450
|
+
return {
|
|
15451
|
+
"ok": True,
|
|
15452
|
+
"profile": target,
|
|
15453
|
+
"model": model_id,
|
|
15454
|
+
"provider": str(block.get("provider") or ""),
|
|
15455
|
+
"billing_provider": billing or _billing_provider_id_from_hermes_config(
|
|
15456
|
+
str(block.get("provider") or "")
|
|
15457
|
+
),
|
|
15458
|
+
}
|
|
15037
15459
|
|
|
15038
15460
|
|
|
15039
15461
|
def hermes_fallback_chain_set(profile: str, chain: list[Any]) -> dict[str, Any]:
|
|
@@ -15844,6 +16266,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
15844
16266
|
except Exception:
|
|
15845
16267
|
return self._json(200, {"setup_complete": False})
|
|
15846
16268
|
if path == "/api/install/status":
|
|
16269
|
+
if install_api.install_window_open(str(_workframe_db_path())):
|
|
16270
|
+
try:
|
|
16271
|
+
_ensure_native_hermes_profile()
|
|
16272
|
+
except Exception:
|
|
16273
|
+
pass
|
|
15847
16274
|
payload = install_api.install_status_payload(
|
|
15848
16275
|
DEPLOYMENT_MODE,
|
|
15849
16276
|
SECURE_MODE,
|
|
@@ -16847,6 +17274,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
16847
17274
|
|
|
16848
17275
|
def _complete_install(self, user_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
16849
17276
|
global DEPLOYMENT_MODE
|
|
17277
|
+
if not _native_profile_present():
|
|
17278
|
+
_ensure_native_hermes_profile()
|
|
16850
17279
|
data = body if isinstance(body, dict) else {}
|
|
16851
17280
|
if not user_id and DEPLOYMENT_MODE == "single_user_local":
|
|
16852
17281
|
user_id = str(data.get("user_id") or secrets.token_hex(8))
|
|
@@ -18536,7 +18965,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
18536
18965
|
return self._json(400, {"error": "model required"})
|
|
18537
18966
|
if profile:
|
|
18538
18967
|
_bootstrap_profile_providers(resolve_validated_profile(profile), user_id, workspace_id)
|
|
18539
|
-
return self._json(200, hermes_model_set(profile, model_id))
|
|
18968
|
+
return self._json(200, hermes_model_set(profile, model_id, user_id, workspace_id))
|
|
18540
18969
|
if path == "/api/hermes/model/apply-default":
|
|
18541
18970
|
if not _check_auth(self):
|
|
18542
18971
|
return self._json(401, {"error": "unauthorized"})
|