create-workframe 0.1.2 → 0.1.4

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.
Files changed (94) hide show
  1. package/README.md +14 -1
  2. package/SECURITY.md +3 -3
  3. package/bin/create-workframe.js +3 -3
  4. package/package.json +1 -1
  5. package/scripts/bundle-workframe-ui.mjs +13 -7
  6. package/workframe-api/README.md +3 -5
  7. package/workframe-api/concierge.py +173 -0
  8. package/workframe-api/llm_error_glossary.py +236 -0
  9. package/workframe-api/llm_proxy.py +3 -0
  10. package/workframe-api/openrouter_catalog.py +114 -0
  11. package/workframe-api/package.json +1 -1
  12. package/workframe-api/server.py +178 -50
  13. package/workframe-api/updates.py +107 -18
  14. package/workframe-supervisor/server.py +75 -2
  15. package/workframe-ui/public/assets/{arc-COAT3laO.js → arc-BskqxLmE.js} +1 -1
  16. package/workframe-ui/public/assets/architecture-7EHR7CIX-DOxsUI33.js +1 -0
  17. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BFjWV24l.js → architectureDiagram-3BPJPVTR-B3eGdqvq.js} +1 -1
  18. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DSQLPfrj.js → blockDiagram-GPEHLZMM-CzFZG7uX.js} +1 -1
  19. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DKEHv1t2.js → c4Diagram-AAUBKEIU-BS-gGyCH.js} +1 -1
  20. package/workframe-ui/public/assets/channel-Baa20pIs.js +1 -0
  21. package/workframe-ui/public/assets/{chunk-2J33WTMH-DHZg-DUi.js → chunk-2J33WTMH-Cjvqf7c9.js} +1 -1
  22. package/workframe-ui/public/assets/{chunk-3OPIFGDE-BB-OYTfp.js → chunk-3OPIFGDE--eUuL8AK.js} +1 -1
  23. package/workframe-ui/public/assets/{chunk-4BX2VUAB-C93q0YIm.js → chunk-4BX2VUAB-s9lHUFKL.js} +1 -1
  24. package/workframe-ui/public/assets/{chunk-55IACEB6-MAYniqik.js → chunk-55IACEB6-D1McA5nz.js} +1 -1
  25. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-ChgN6YJs.js → chunk-5ZQYHXKU-DIstLfU2.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-727SXJPM-B_FYwdAv.js → chunk-727SXJPM-Cd0GD2CO.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-1_Hw_h1A.js → chunk-AQP2D5EJ-gc5Ra6G4.js} +1 -1
  28. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CFiDQ1Rv.js → chunk-BSJP7CBP-Cg5QfihJ.js} +1 -1
  29. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-DZ9UMTlB.js → chunk-CSCIHK7Q-D_gobbhY.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DlMlyFgw.js → chunk-FMBD7UC4-B6eLY9_d.js} +1 -1
  31. package/workframe-ui/public/assets/{chunk-KSCS5N6A-DHXtQ_Hf.js → chunk-KSCS5N6A-BGroAeW2.js} +1 -1
  32. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CuQzg-QG.js → chunk-L5ZTLDWV-34xv7WgT.js} +1 -1
  33. package/workframe-ui/public/assets/{chunk-LZXEDZCA-BHzjzCGg.js → chunk-LZXEDZCA-XJBctFZw.js} +2 -2
  34. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DHXx05n2.js → chunk-ND2GUHAM-nTOZpln8.js} +1 -1
  35. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CV5pmDM_.js → chunk-NZK2D7GU-BBrzcaPH.js} +1 -1
  36. package/workframe-ui/public/assets/{chunk-O5CBEL6O-6tkCHxsV.js → chunk-O5CBEL6O-Cntncx6L.js} +1 -1
  37. package/workframe-ui/public/assets/chunk-QZHKN3VN-B7tPxs4H.js +1 -0
  38. package/workframe-ui/public/assets/chunk-WU5MYG2G-Dvh2By1o.js +1 -0
  39. package/workframe-ui/public/assets/{chunk-XPW4576I-BClwIiCp.js → chunk-XPW4576I-DSbtd0cg.js} +1 -1
  40. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BljHyDzF.js +1 -0
  41. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BljHyDzF.js +1 -0
  42. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DOrGV6DQ.js → cose-bilkent-S5V4N54A-lmYstEMQ.js} +1 -1
  43. package/workframe-ui/public/assets/{dagre-BM42HDAG-DXTPvJkX.js → dagre-BM42HDAG-Bdkbdul2.js} +1 -1
  44. package/workframe-ui/public/assets/{diagram-2AECGRRQ-xX_v-pbf.js → diagram-2AECGRRQ-Clb9kSXe.js} +1 -1
  45. package/workframe-ui/public/assets/{diagram-5GNKFQAL-Cd2pXbBe.js → diagram-5GNKFQAL-BApg0yec.js} +1 -1
  46. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df3XvUtk.js → diagram-KO2AKTUF-peZjDRV6.js} +1 -1
  47. package/workframe-ui/public/assets/{diagram-LMA3HP47-CsijIPaD.js → diagram-LMA3HP47-DZm_1pjc.js} +1 -1
  48. package/workframe-ui/public/assets/{diagram-OG6HWLK6-aq5fmfHd.js → diagram-OG6HWLK6-DP_N6fYL.js} +1 -1
  49. package/workframe-ui/public/assets/{dist-D1c0mkbB.js → dist-CqP43hXK.js} +1 -1
  50. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-DnFysVRY.js → erDiagram-TEJ5UH35-Blfs6kLv.js} +1 -1
  51. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C4xwO6dx.js +1 -0
  52. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C6Ebi3su.js → flowDiagram-I6XJVG4X-CcmMOkwH.js} +1 -1
  53. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-BQXQtUpa.js → ganttDiagram-6RSMTGT7-ByoO3dF7.js} +1 -1
  54. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-Dt0zIs_M.js → gitGraph-WXDBUCRP-DCyTpmPs.js} +1 -1
  55. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BF8gHzRn.js → gitGraphDiagram-PVQCEYII-CvLYB55b.js} +1 -1
  56. package/workframe-ui/public/assets/index-BIfRGM8p.js +133 -0
  57. package/workframe-ui/public/assets/{info-J43DQDTF-CSmszQJT.js → info-J43DQDTF-CfSRIfOI.js} +1 -1
  58. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CVTKGW6p.js → infoDiagram-5YYISTIA-DwS44DS6.js} +1 -1
  59. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js → ishikawaDiagram-YF4QCWOH-MRlyodyU.js} +1 -1
  60. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-r3wD68_T.js → journeyDiagram-JHISSGLW-sq7awk87.js} +1 -1
  61. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Il8VglqN.js → kanban-definition-UN3LZRKU-CH6HbcWu.js} +1 -1
  62. package/workframe-ui/public/assets/{line-oyjpfz2A.js → line-DRBjFmUW.js} +1 -1
  63. package/workframe-ui/public/assets/{linear-Cf7p5tVp.js → linear-BR0LunjE.js} +1 -1
  64. package/workframe-ui/public/assets/{mermaid-parser.core-YmbZ-AfY.js → mermaid-parser.core-Cl1GjHkT.js} +2 -2
  65. package/workframe-ui/public/assets/{mermaid.core-BFdCAqCo.js → mermaid.core-DsiKK1kE.js} +3 -3
  66. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Cy2iCtEl.js → mindmap-definition-RKZ34NQL-DBvi3OpW.js} +1 -1
  67. package/workframe-ui/public/assets/{packet-YPE3B663-DwOBZL6K.js → packet-YPE3B663-CPDgtUHg.js} +1 -1
  68. package/workframe-ui/public/assets/{pie-LRSECV5Y-04PPhnKK.js → pie-LRSECV5Y-BXJIPPDm.js} +1 -1
  69. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-LxIpgHqi.js → pieDiagram-4H26LBE5-CZwLEK-o.js} +1 -1
  70. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-0nBYfYm4.js → quadrantDiagram-W4KKPZXB-B65bEUIQ.js} +1 -1
  71. package/workframe-ui/public/assets/{radar-GUYGQ44K-D2-vBqps.js → radar-GUYGQ44K-vXHkZzRK.js} +1 -1
  72. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DbuU0nlu.js → requirementDiagram-4Y6WPE33-BGWdvDfG.js} +1 -1
  73. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-B2hQ6B2x.js → sankeyDiagram-5OEKKPKP-BT2oC8dV.js} +1 -1
  74. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BBrU30e1.js → sequenceDiagram-3UESZ5HK-45yj0Wtx.js} +1 -1
  75. package/workframe-ui/public/assets/{src-BJEDmV70.js → src-D-P8U2PB.js} +1 -1
  76. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-7FGO4kkH.js → stateDiagram-AJRCARHV-SBrBJBZG.js} +1 -1
  77. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B3im3So2.js +1 -0
  78. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-ptDm4rCN.js → timeline-definition-PNZ67QCA-DegmxXXj.js} +1 -1
  79. package/workframe-ui/public/assets/{treeView-BLDUP644-CS6Z-0q8.js → treeView-BLDUP644-BVy7CsCj.js} +1 -1
  80. package/workframe-ui/public/assets/{treemap-LRROVOQU-DqV4Y2VA.js → treemap-LRROVOQU-BD5Kb74J.js} +1 -1
  81. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-C0UrZJYt.js → vennDiagram-CIIHVFJN-hwAkNbl3.js} +1 -1
  82. package/workframe-ui/public/assets/{wardley-L42UT6IY-bNDN3_Sa.js → wardley-L42UT6IY-CBMPT8Y1.js} +1 -1
  83. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-jWiJsefM.js → wardleyDiagram-YWT4CUSO-DXqihVlE.js} +1 -1
  84. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-Dsh_fLCy.js → xychartDiagram-2RQKCTM6-Cbb4LBzj.js} +1 -1
  85. package/workframe-ui/public/index.html +1 -1
  86. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +0 -1
  87. package/workframe-ui/public/assets/channel-g7r_RGaY.js +0 -1
  88. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +0 -1
  89. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +0 -1
  90. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +0 -1
  91. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +0 -1
  92. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +0 -1
  93. package/workframe-ui/public/assets/index-lRpzpNPT.js +0 -133
  94. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workframe/workframe-api",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -46,6 +46,9 @@ import turn_credentials
46
46
  import llm_proxy
47
47
  import action_proxy
48
48
  import internal_proxy_auth
49
+ import concierge
50
+ import llm_error_glossary
51
+ import openrouter_catalog
49
52
  import updates as stack_updates
50
53
 
51
54
  HERMES_DATA = Path(os.environ.get("HERMES_DATA", "/opt/data"))
@@ -548,12 +551,26 @@ def _admin_write_allowed(handler: BaseHTTPRequestHandler) -> bool:
548
551
 
549
552
 
550
553
  def _apply_stack_update(target: str) -> dict[str, Any]:
554
+ workframe_version = ""
555
+ if target in {"workframe", "all"}:
556
+ try:
557
+ workframe_version = str(
558
+ stack_updates.updates_available().get("workframe", {}).get("latest") or ""
559
+ ).strip()
560
+ except Exception: # noqa: BLE001
561
+ workframe_version = ""
551
562
  try:
552
563
  return stack_updates.apply_update(target)
553
564
  except ValueError as exc:
554
- if str(exc) != "docker_unavailable" or not _supervisor_ready():
565
+ err = str(exc)
566
+ if not _supervisor_ready():
567
+ raise
568
+ if err not in ("docker_unavailable", "admin_updates_disabled"):
555
569
  raise
556
- status, data = _supervisor_request("POST", "/v1/stack.apply", {"target": target}, timeout=900.0)
570
+ body: dict[str, Any] = {"target": target}
571
+ if workframe_version:
572
+ body["workframe_version"] = workframe_version
573
+ status, data = _supervisor_request("POST", "/v1/stack.apply", body, timeout=900.0)
557
574
  if status >= 300 or not isinstance(data, dict) or not data.get("ok"):
558
575
  raise ValueError(str((data or {}).get("error") or "supervisor_apply_failed"))
559
576
  return data
@@ -2638,6 +2655,24 @@ def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container:
2638
2655
  """Start long-running `hermes auth add` detached in gateway (survives exec return)."""
2639
2656
  home = _hermes_user_home_container(user_id)
2640
2657
  _user_hermes_home(user_id).mkdir(parents=True, exist_ok=True)
2658
+ if SECURE_MODE and _supervisor_ready():
2659
+ status, data = _supervisor_request(
2660
+ "POST",
2661
+ "/v1/hermes.device_oauth_start",
2662
+ {"home": home, "hermes_auth_id": hermes_auth_id, "log_path": log_container},
2663
+ timeout=30.0,
2664
+ )
2665
+ if status >= 300:
2666
+ err = data.get("error") if isinstance(data, dict) else str(data)
2667
+ raise ValueError(err or "device_oauth_start_failed")
2668
+ if not isinstance(data, dict):
2669
+ raise ValueError("device_oauth_start_invalid")
2670
+ exit_code = data.get("exit_code")
2671
+ try:
2672
+ code = int(exit_code if exit_code is not None else 1)
2673
+ except (TypeError, ValueError):
2674
+ code = 1
2675
+ return code, str(data.get("output") or "")
2641
2676
  auth_cmd = " ".join(shlex.quote(part) for part in ["auth", "add", hermes_auth_id])
2642
2677
  shell = (
2643
2678
  f"mkdir -p {shlex.quote(home)}; "
@@ -12302,8 +12337,9 @@ def _provider_pool_entry(provider: str, env_var: str, *, base_url: str = "") ->
12302
12337
 
12303
12338
 
12304
12339
  def _runtime_provider_pool_entry(provider: str, env_var: str) -> dict[str, Any]:
12305
- """Credential pool row for u-* profiles — keys live in .env leases, upstream via API proxy."""
12306
- return _provider_pool_entry(provider, env_var, base_url=_llm_proxy_base_url(provider))
12340
+ """Credential pool row for u-* profiles — lease token lives in OPENAI_API_KEY."""
12341
+ lease_env = "OPENAI_API_KEY"
12342
+ return _provider_pool_entry(provider, lease_env, base_url=_llm_proxy_base_url(provider))
12307
12343
 
12308
12344
 
12309
12345
  def _provider_pool_has_entries(auth: dict[str, Any], provider: str) -> bool:
@@ -13313,23 +13349,75 @@ def _enrich_room_chat_payload(payload: dict[str, Any], user_id: str) -> dict[str
13313
13349
  return body
13314
13350
 
13315
13351
 
13316
- def _emit_stream_chat_error(handler: BaseHTTPRequestHandler, message: str) -> None:
13317
- """SSE error frame — keeps UI stream parser on the happy path."""
13318
- text = str(message or "").strip() or "Chat failed."
13319
- payload = json.dumps({"error": text, "text": text, "message": text})
13352
+ def _emit_stream_chat_error(
13353
+ handler: BaseHTTPRequestHandler,
13354
+ message: str = "",
13355
+ *,
13356
+ entry: dict[str, Any] | None = None,
13357
+ ) -> None:
13358
+ """SSE error frame — structured playbook when entry supplied."""
13359
+ payload = llm_error_glossary.notice_payload(entry) if entry else {}
13360
+ if not payload:
13361
+ text = str(message or "").strip() or "Chat failed."
13362
+ payload = {"error": text, "text": text, "message": text}
13320
13363
  try:
13321
13364
  handler.send_response(200)
13322
13365
  handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
13323
13366
  handler.send_header("Cache-Control", "no-cache")
13324
13367
  handler.send_header("Connection", "keep-alive")
13325
13368
  handler.end_headers()
13326
- handler.wfile.write(f"event: error\ndata: {payload}\n\n".encode("utf-8"))
13369
+ body = json.dumps(payload)
13370
+ handler.wfile.write(f"event: error\ndata: {body}\n\n".encode("utf-8"))
13327
13371
  handler.wfile.write(b"event: done\ndata: {}\n\n")
13328
13372
  handler.wfile.flush()
13329
13373
  except (BrokenPipeError, ConnectionResetError, OSError):
13330
13374
  return
13331
13375
 
13332
13376
 
13377
+ def _emit_stream_concierge(handler: BaseHTTPRequestHandler, entry: dict[str, Any]) -> None:
13378
+ """Deterministic assistant reply when LLM path is unavailable."""
13379
+ payload = llm_error_glossary.notice_payload(entry)
13380
+ text = str(payload.get("message") or "")
13381
+ hint = str(payload.get("hint") or "").strip()
13382
+ if hint:
13383
+ text = f"{text}\n\n{hint}"
13384
+ try:
13385
+ handler.send_response(200)
13386
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
13387
+ handler.send_header("Cache-Control", "no-cache")
13388
+ handler.send_header("Connection", "keep-alive")
13389
+ handler.end_headers()
13390
+ handler.wfile.write(
13391
+ f"event: concierge\ndata: {json.dumps(payload)}\n\n".encode("utf-8"),
13392
+ )
13393
+ handler.wfile.write(
13394
+ f"event: message.complete\ndata: {json.dumps({'content': text, 'text': text})}\n\n".encode(
13395
+ "utf-8",
13396
+ ),
13397
+ )
13398
+ handler.wfile.write(b"event: done\ndata: {}\n\n")
13399
+ handler.wfile.flush()
13400
+ except (BrokenPipeError, ConnectionResetError, OSError):
13401
+ return
13402
+
13403
+
13404
+ def _log_llm_failure(
13405
+ handler: BaseHTTPRequestHandler,
13406
+ code: str,
13407
+ *,
13408
+ provider: str = "",
13409
+ model: str = "",
13410
+ profile: str = "",
13411
+ ) -> None:
13412
+ if hasattr(handler, "_log_audit"):
13413
+ handler._log_audit( # type: ignore[attr-defined]
13414
+ "llm_failure",
13415
+ "llm",
13416
+ profile or provider or "unknown",
13417
+ f"code={code} provider={provider} model={model}",
13418
+ )
13419
+
13420
+
13333
13421
  def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload: dict[str, Any]) -> None:
13334
13422
  _triggering_user = str(payload.get("user_id", "") or "")
13335
13423
  _workspace_id = str(payload.get("workspace_id", "") or "")
@@ -13345,6 +13433,15 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13345
13433
  if not text:
13346
13434
  raise ValueError("text required")
13347
13435
  llm_provider = _llm_billing_provider(prof)
13436
+ llm_ready = bool(
13437
+ _triggering_user
13438
+ and _user_can_use_llm(_triggering_user, _workspace_id, llm_provider)
13439
+ )
13440
+ if _triggering_user and not llm_ready:
13441
+ entry = concierge.respond(text, situation="no_provider")
13442
+ _log_llm_failure(handler, str(entry.get("code") or "no_llm_provider"), provider=llm_provider, profile=prof)
13443
+ _emit_stream_concierge(handler, entry)
13444
+ return
13348
13445
  try:
13349
13446
  lifecycle = ensure_profile_api(
13350
13447
  prof,
@@ -13358,7 +13455,15 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13358
13455
  )
13359
13456
  _overlay_turn_user_env(prof, _triggering_user, _workspace_id, _turn_run_id)
13360
13457
  except ValueError as exc:
13361
- _emit_stream_chat_error(handler, str(exc))
13458
+ err_text = str(exc)
13459
+ if "no_llm_provider_for_user" in err_text:
13460
+ entry = concierge.respond(text, situation="no_provider")
13461
+ _log_llm_failure(handler, "no_llm_provider", provider=llm_provider, profile=prof)
13462
+ _emit_stream_concierge(handler, entry)
13463
+ return
13464
+ entry = llm_error_glossary.classify_exception_text(err_text)
13465
+ _log_llm_failure(handler, str(entry.get("code") or ""), provider=llm_provider, profile=prof)
13466
+ _emit_stream_chat_error(handler, entry=entry)
13362
13467
  return
13363
13468
  upstream_body = json.dumps(_profile_turn_payload(prof, text, _room_id)).encode("utf-8")
13364
13469
 
@@ -13487,34 +13592,21 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13487
13592
  handler.wfile.flush()
13488
13593
  if not saw_complete or not complete_text:
13489
13594
  config_key = _read_config_model_api_key(prof)
13595
+ _, model_name = _read_model_from_config(prof)
13490
13596
  if config_key.startswith(turn_credentials.LEASE_PREFIX) and not turn_credentials.validate_lease(
13491
13597
  config_key,
13492
13598
  ):
13493
- err_text = (
13494
- "Turn credential expired before the model replied. "
13495
- "Send the message again."
13496
- )
13497
- err_code = "invalid_lease"
13498
- elif not saw_complete:
13499
- err_text = (
13500
- "Model provider returned no reply. "
13501
- "The request may have failed at the gateway or provider."
13502
- )
13503
- err_code = "provider_empty_reply"
13599
+ entry = llm_error_glossary.playbook_entry("invalid_lease")
13504
13600
  else:
13505
- err_text = (
13506
- "Model provider returned an empty reply. "
13507
- "Check the model name in Settings or try again."
13508
- )
13509
- err_code = "provider_empty_reply"
13510
- err_payload = json.dumps(
13511
- {
13512
- "error": err_text,
13513
- "text": err_text,
13514
- "message": err_text,
13515
- "code": err_code,
13516
- },
13601
+ entry = llm_error_glossary.playbook_entry("provider_empty_reply")
13602
+ _log_llm_failure(
13603
+ handler,
13604
+ str(entry.get("code") or "provider_empty_reply"),
13605
+ provider=llm_provider,
13606
+ model=str(model_name or ""),
13607
+ profile=prof,
13517
13608
  )
13609
+ err_payload = json.dumps(llm_error_glossary.notice_payload(entry))
13518
13610
  handler.wfile.write(f"event: error\ndata: {err_payload}\n\n".encode("utf-8"))
13519
13611
  handler.wfile.flush()
13520
13612
  handler.wfile.write(b"event: done\ndata: {}\n\n")
@@ -14252,16 +14344,16 @@ def _resolve_command(token: str) -> dict[str, Any] | None:
14252
14344
  # overrides live in the profile's config.yaml under `model.default` and
14253
14345
  # `fallback_providers`; this constant is the install-wide default the
14254
14346
  # migration writes when a profile is missing either field.
14255
- HERMES_DEFAULT_PRIMARY = "openrouter/owl-alpha"
14347
+ HERMES_DEFAULT_PRIMARY = "google/gemini-2.5-flash"
14256
14348
  HERMES_DEFAULT_FALLBACK_CHAIN: list[dict[str, str]] = [
14257
- {"provider": "openrouter", "model": "openrouter/nex-agi"},
14258
- {"provider": "openrouter", "model": "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free"},
14349
+ {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.5"},
14350
+ {"provider": "openrouter", "model": "meta-llama/llama-3.3-70b-instruct:free"},
14259
14351
  ]
14260
14352
 
14261
14353
  # ponytail: minimum-viable agentic models per LLM provider; user can escalate in settings.
14262
14354
  PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
14263
14355
  "openrouter": {
14264
- "primary": "openrouter/owl-alpha",
14356
+ "primary": "google/gemini-2.5-flash",
14265
14357
  "fallbacks": list(HERMES_DEFAULT_FALLBACK_CHAIN),
14266
14358
  },
14267
14359
  "openai": {
@@ -14273,8 +14365,8 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
14273
14365
  "fallbacks": [{"provider": "anthropic", "model": "claude-3-5-haiku-20241022"}],
14274
14366
  },
14275
14367
  "google": {
14276
- "primary": "gemini-2.0-flash",
14277
- "fallbacks": [{"provider": "google", "model": "gemini-2.0-flash"}],
14368
+ "primary": "gemini-2.5-flash",
14369
+ "fallbacks": [{"provider": "google", "model": "gemini-2.5-flash-lite"}],
14278
14370
  },
14279
14371
  "deepseek": {
14280
14372
  "primary": "deepseek-chat",
@@ -14285,8 +14377,8 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
14285
14377
  "fallbacks": [{"provider": "openai-codex", "model": "gpt-5.4-medium"}],
14286
14378
  },
14287
14379
  "nous": {
14288
- "primary": "openrouter/owl-alpha",
14289
- "fallbacks": [{"provider": "nous", "model": "openrouter/owl-alpha"}],
14380
+ "primary": "nousresearch/hermes-4-70b",
14381
+ "fallbacks": [{"provider": "nous", "model": "nousresearch/hermes-4-70b"}],
14290
14382
  },
14291
14383
  }
14292
14384
 
@@ -14394,15 +14486,21 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
14394
14486
  {"provider": "OpenRouter", "model": "openrouter/auto",
14395
14487
  "label": "OpenRouter Auto",
14396
14488
  "description": "OpenRouter picks the best model per prompt."},
14397
- {"provider": "OpenRouter", "model": "openrouter/owl-alpha",
14398
- "label": "Owl Alpha",
14399
- "description": "Workframe default primary strong general agent."},
14400
- {"provider": "OpenRouter", "model": "openrouter/nex-agi",
14401
- "label": "Nex AGI",
14402
- "description": "Fallback-tier reasoning model on OpenRouter."},
14403
- {"provider": "OpenRouter", "model": "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free",
14404
- "label": "Nemotron 3 Ultra (free)",
14405
- "description": "OpenRouter free tier long-context agentic fallback."},
14489
+ {"provider": "OpenRouter", "model": "google/gemini-2.5-flash",
14490
+ "label": "Gemini 2.5 Flash",
14491
+ "description": "Fast, capable default for OpenRouter BYOK."},
14492
+ {"provider": "OpenRouter", "model": "anthropic/claude-sonnet-4.5",
14493
+ "label": "Claude Sonnet 4.5 (via OR)",
14494
+ "description": "Strong agentic fallback on OpenRouter."},
14495
+ {"provider": "OpenRouter", "model": "meta-llama/llama-3.3-70b-instruct:free",
14496
+ "label": "Llama 3.3 70B (free)",
14497
+ "description": "Free-tier OpenRouter fallback when available."},
14498
+ {"provider": "OpenRouter", "model": "z-ai/glm-5.1",
14499
+ "label": "GLM 5.1",
14500
+ "description": "High-availability model on OpenRouter."},
14501
+ {"provider": "OpenRouter", "model": "nousresearch/hermes-4-70b",
14502
+ "label": "Hermes 4 70B",
14503
+ "description": "Agent-tuned model on OpenRouter."},
14406
14504
  {"provider": "OpenRouter", "model": "openrouter/nvidia/llama-3.1-nemotron-70b-instruct",
14407
14505
  "label": "Nemotron 70B Instruct",
14408
14506
  "description": "NVIDIA Nemotron via OpenRouter."},
@@ -14780,6 +14878,17 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
14780
14878
  ] if (picker_llm or connected) else [],
14781
14879
  block,
14782
14880
  )
14881
+ if user_id and "openrouter" in {n.lower() for n in (picker_llm or connected)}:
14882
+ resolved = _resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
14883
+ secret = _credential_secret(resolved or {}, user_id) if resolved else ""
14884
+ live = openrouter_catalog.live_suggestions(secret, limit=30)
14885
+ if live:
14886
+ seen = {str(r.get("model") or "") for r in suggestions}
14887
+ suggestions = [
14888
+ {**row, "label": f"Live · {row.get('label', row.get('model', ''))}"}
14889
+ for row in live
14890
+ if str(row.get("model") or "") not in seen
14891
+ ] + suggestions
14783
14892
  return {
14784
14893
  "ok": True,
14785
14894
  "profile": primary_profile,
@@ -17391,11 +17500,29 @@ class Handler(BaseHTTPRequestHandler):
17391
17500
  VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
17392
17501
  (cred_id, None, user_id, None, provider, credential_type, cred_ref, label, 1, user_id, now, now),
17393
17502
  )
17503
+ conn.execute(
17504
+ """UPDATE credential_bindings
17505
+ SET is_active = 0, deleted_at = ?, updated_at = ?
17506
+ WHERE user_id = ? AND provider = ? AND deleted_at IS NULL AND id != ?""",
17507
+ (now, now, user_id, provider, cred_id),
17508
+ )
17394
17509
  conn.commit()
17395
17510
  conn.close()
17396
17511
  except sqlite3.Error as exc:
17397
17512
  return self._json(500, {"ok": False, "error": f"db_error: {exc}"})
17398
17513
  self._log_audit("credential_stored", "credential_binding", cred_id, f"provider={provider}")
17514
+ health: dict[str, Any] = {}
17515
+ if provider == "openrouter":
17516
+ secret_probe = _credential_secret(
17517
+ {
17518
+ "credential_ref": cred_ref,
17519
+ "scope": "user",
17520
+ "user_id": user_id,
17521
+ },
17522
+ user_id,
17523
+ )
17524
+ if secret_probe:
17525
+ health = openrouter_catalog.probe_account(secret_probe)
17399
17526
  _bootstrap_model_after_llm_connect(user_id, str(body.get("workspace_id") or ""), provider)
17400
17527
  return self._json(200, {
17401
17528
  "ok": True,
@@ -17407,6 +17534,7 @@ class Handler(BaseHTTPRequestHandler):
17407
17534
  "user_id": user_id,
17408
17535
  "created_at": now,
17409
17536
  "updated_at": now,
17537
+ "health": health,
17410
17538
  **payload,
17411
17539
  })
17412
17540
 
@@ -190,6 +190,38 @@ def _host_compose_ready() -> bool:
190
190
  return compose.joinpath("docker-compose.yml").is_file()
191
191
 
192
192
 
193
+ def _supervisor_configured() -> bool:
194
+ return bool(os.environ.get("WORKFRAME_SUPERVISOR_URL", "").strip()) and bool(
195
+ os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "").strip()
196
+ )
197
+
198
+
199
+ def _supervisor_gateway_image_digest() -> tuple[str, str]:
200
+ """Read gateway image digest via workframe-supervisor (SECURE_MODE API has no docker.sock)."""
201
+ base = str(os.environ.get("WORKFRAME_SUPERVISOR_URL", "")).rstrip("/")
202
+ token = str(os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "")).strip()
203
+ if not base or not token:
204
+ return "", ""
205
+ req = urllib.request.Request(
206
+ f"{base}/v1/gateway.image",
207
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "workframe-api"},
208
+ )
209
+ try:
210
+ with urllib.request.urlopen(req, timeout=15.0) as resp:
211
+ data = json.loads(resp.read().decode("utf-8"))
212
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
213
+ return "", ""
214
+ if not isinstance(data, dict) or not data.get("ok"):
215
+ return "", ""
216
+ return str(data.get("digest") or "").strip(), str(data.get("ref") or "").strip()
217
+
218
+
219
+ def _admin_stack_updates_enabled() -> bool:
220
+ if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") == "1":
221
+ return True
222
+ return _supervisor_configured()
223
+
224
+
193
225
  def _docker_apply_ready() -> tuple[bool, str | None]:
194
226
  if not Path(DOCKER_SOCK).exists():
195
227
  return False, "Docker socket is not available to the API container."
@@ -202,6 +234,29 @@ def _docker_apply_ready() -> tuple[bool, str | None]:
202
234
  return True, None
203
235
 
204
236
 
237
+ def _update_apply_channel() -> tuple[str, bool, str | None]:
238
+ """Returns (channel, ready, reason). channel: api_docker | supervisor | none."""
239
+ api_docker = Path(DOCKER_SOCK).exists()
240
+ if api_docker:
241
+ ok, reason = _docker_apply_ready()
242
+ if ok:
243
+ return "api_docker", True, None
244
+ if not _supervisor_configured():
245
+ return "none", False, reason
246
+ if _supervisor_configured():
247
+ if _script_path("apply-update-workframe.sh") is None and _script_path("apply-update-hermes.sh") is None:
248
+ return "supervisor", False, "Stack update scripts are missing from this install."
249
+ return "supervisor", True, None
250
+ if api_docker:
251
+ _, reason = _docker_apply_ready()
252
+ return "none", False, reason
253
+ return (
254
+ "none",
255
+ False,
256
+ "In-place updates need workframe-supervisor or Docker on the stack host.",
257
+ )
258
+
259
+
205
260
  def _product_state(*, update_available: bool, can_update: bool) -> str:
206
261
  if update_available and can_update:
207
262
  return "available"
@@ -238,7 +293,9 @@ def _releases_manifest() -> dict[str, Any]:
238
293
  def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
239
294
  compose_dir = _compose_dir()
240
295
  project_root = _project_root()
241
- docker_ok = Path(DOCKER_SOCK).exists()
296
+ api_docker = Path(DOCKER_SOCK).exists()
297
+ supervisor_ok = _supervisor_configured()
298
+ apply_channel, apply_ready, apply_reason = _update_apply_channel()
242
299
  installed = _read_installed_workframe_version(project_root)
243
300
 
244
301
  npm_latest = ""
@@ -254,15 +311,20 @@ def updates_available(*, desktop_version: str = "", hermes_agent_version: str =
254
311
  installed_pkg = installed.get("package") or installed.get("api") or ""
255
312
  workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
256
313
 
257
- hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
314
+ hermes_digest, hermes_ref = ("", "")
315
+ if api_docker:
316
+ hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
317
+ elif supervisor_ok:
318
+ hermes_digest, hermes_ref = _supervisor_gateway_image_digest()
258
319
  hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
259
320
  hermes_latest_digest = ""
260
321
  try:
261
322
  hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
262
323
  except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
263
324
  pass
325
+ hermes_image_known = bool(api_docker or supervisor_ok)
264
326
  hermes_update = bool(
265
- docker_ok
327
+ hermes_image_known
266
328
  and hermes_latest_digest
267
329
  and hermes_digest
268
330
  and hermes_digest != hermes_latest_digest,
@@ -275,15 +337,14 @@ def updates_available(*, desktop_version: str = "", hermes_agent_version: str =
275
337
  if len(digest_short) > 28:
276
338
  digest_short = digest_short[:28] + "…"
277
339
 
278
- docker_apply_ok, docker_apply_reason = _docker_apply_ready()
279
340
  hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
280
341
  workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
281
- hermes_can_update = bool(docker_apply_ok and hermes_script_ok)
282
- workframe_can_update = bool(docker_apply_ok and workframe_script_ok)
283
- hermes_reason = docker_apply_reason
342
+ hermes_can_update = bool(apply_ready and hermes_script_ok)
343
+ workframe_can_update = bool(apply_ready and workframe_script_ok)
344
+ hermes_reason = apply_reason
284
345
  if not hermes_reason and hermes_update and not hermes_script_ok:
285
346
  hermes_reason = "Hermes update script is missing from this install."
286
- workframe_reason = docker_apply_reason
347
+ workframe_reason = apply_reason
287
348
  if not workframe_reason and workframe_update and not workframe_script_ok:
288
349
  workframe_reason = "Workframe update script is missing from this install."
289
350
  if not workframe_reason and workframe_update and not workframe_latest:
@@ -294,7 +355,11 @@ def updates_available(*, desktop_version: str = "", hermes_agent_version: str =
294
355
 
295
356
  return {
296
357
  "ok": True,
297
- "docker_available": docker_ok,
358
+ "docker_available": apply_ready,
359
+ "docker_sock_on_api": api_docker,
360
+ "supervisor_configured": supervisor_ok,
361
+ "update_apply_channel": apply_channel if apply_ready else None,
362
+ "update_apply_ready": apply_ready,
298
363
  "compose_dir": str(compose_dir),
299
364
  "project_root": str(project_root),
300
365
  "workframe": {
@@ -323,7 +388,7 @@ def updates_available(*, desktop_version: str = "", hermes_agent_version: str =
323
388
  "reason": hermes_reason,
324
389
  "update_mode": "docker-compose-pull",
325
390
  "install_kind": "docker",
326
- "can_restart_gateway": bool(docker_apply_ok and _script_path("restart-gateway-hermes.sh") is not None),
391
+ "can_restart_gateway": bool(apply_ready and _script_path("restart-gateway-hermes.sh") is not None),
327
392
  },
328
393
  "desktop": {
329
394
  "current": desktop_installed,
@@ -339,12 +404,33 @@ def updates_available(*, desktop_version: str = "", hermes_agent_version: str =
339
404
  }
340
405
 
341
406
 
407
+ def _apply_env_for_target(target: str) -> dict[str, str]:
408
+ env = os.environ.copy()
409
+ env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
410
+ env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
411
+ if target in {"workframe", "all"}:
412
+ latest = ""
413
+ try:
414
+ latest = str(updates_available().get("workframe", {}).get("latest") or "").strip()
415
+ except Exception: # noqa: BLE001
416
+ latest = ""
417
+ if latest:
418
+ env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
419
+ env["WORKFRAME_UPDATE_VERSION"] = latest
420
+ return env
421
+
422
+
342
423
  def apply_update(target: str) -> dict[str, Any]:
343
- if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
424
+ if not _admin_stack_updates_enabled():
344
425
  raise ValueError("admin_updates_disabled")
345
426
  target = str(target or "all").strip().lower()
346
427
  if target not in {"hermes", "workframe", "all"}:
347
428
  raise ValueError("invalid_update_target")
429
+ _, apply_ready, apply_reason = _update_apply_channel()
430
+ if not apply_ready:
431
+ if not Path(DOCKER_SOCK).exists():
432
+ raise ValueError("docker_unavailable")
433
+ raise ValueError(str(apply_reason or "docker_apply_unavailable"))
348
434
  if not Path(DOCKER_SOCK).exists():
349
435
  raise ValueError("docker_unavailable")
350
436
 
@@ -360,9 +446,7 @@ def apply_update(target: str) -> dict[str, Any]:
360
446
  raise ValueError("update_script_missing:workframe")
361
447
  scripts.append(str(script))
362
448
 
363
- env = os.environ.copy()
364
- env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
365
- env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
449
+ env = _apply_env_for_target(target)
366
450
 
367
451
  logs: list[str] = []
368
452
  for script in scripts:
@@ -382,13 +466,15 @@ def apply_update(target: str) -> dict[str, Any]:
382
466
 
383
467
 
384
468
  def restart_gateway() -> dict[str, Any]:
385
- if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
469
+ if not _admin_stack_updates_enabled():
386
470
  raise ValueError("admin_updates_disabled")
471
+ _, apply_ready, apply_reason = _update_apply_channel()
472
+ if not apply_ready:
473
+ if not Path(DOCKER_SOCK).exists():
474
+ raise ValueError("docker_unavailable")
475
+ raise ValueError(str(apply_reason or "docker_apply_unavailable"))
387
476
  if not Path(DOCKER_SOCK).exists():
388
477
  raise ValueError("docker_unavailable")
389
- docker_apply_ok, docker_apply_reason = _docker_apply_ready()
390
- if not docker_apply_ok:
391
- raise ValueError(str(docker_apply_reason or "docker_apply_unavailable"))
392
478
  script = _script_path("restart-gateway-hermes.sh")
393
479
  if not script:
394
480
  raise ValueError("restart_script_missing:gateway")
@@ -414,4 +500,7 @@ if __name__ == "__main__":
414
500
  assert _version_lt("0.1.0", "0.1.1")
415
501
  assert not _version_lt("0.1.0", "0.1.0")
416
502
  assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
503
+ ch, ready, _ = _update_apply_channel()
504
+ assert ch in {"api_docker", "supervisor", "none"}
505
+ assert ready is False or ch != "none"
417
506
  print("updates module ok")