create-workframe 0.1.3 → 0.1.5

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 (89) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/scripts/bundle-workframe-ui.mjs +13 -7
  4. package/workframe-api/package.json +1 -1
  5. package/workframe-api/server.py +146 -22
  6. package/workframe-api/test_billing_provider.py +24 -0
  7. package/workframe-api/updates.py +107 -18
  8. package/workframe-supervisor/server.py +75 -2
  9. package/workframe-ui/public/assets/{arc-CRhulRPr.js → arc-CnriPN3G.js} +1 -1
  10. package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +1 -0
  11. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-B1Gq5onc.js → architectureDiagram-3BPJPVTR-DYYB1Ic2.js} +1 -1
  12. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DtXEetq-.js → blockDiagram-GPEHLZMM-Bn-fFOyj.js} +1 -1
  13. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Bw7NEN3_.js → c4Diagram-AAUBKEIU-BPPDtAnD.js} +1 -1
  14. package/workframe-ui/public/assets/channel-Dok_-T0o.js +1 -0
  15. package/workframe-ui/public/assets/{chunk-2J33WTMH-91lObBre.js → chunk-2J33WTMH-Bz2CJiC3.js} +1 -1
  16. package/workframe-ui/public/assets/{chunk-3OPIFGDE-D5IopWdW.js → chunk-3OPIFGDE-B8aMeE1B.js} +1 -1
  17. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DRHbh4Yd.js → chunk-4BX2VUAB-bLFHQTuT.js} +1 -1
  18. package/workframe-ui/public/assets/{chunk-55IACEB6-DcfKoh86.js → chunk-55IACEB6-BRxhx-lG.js} +1 -1
  19. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BMtcWXOU.js → chunk-5ZQYHXKU-BLyOgEJ8.js} +1 -1
  20. package/workframe-ui/public/assets/{chunk-727SXJPM-BW7tTfej.js → chunk-727SXJPM-DiuvBl7G.js} +1 -1
  21. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CGoSQAgm.js → chunk-AQP2D5EJ-BE1_aBdC.js} +1 -1
  22. package/workframe-ui/public/assets/{chunk-BSJP7CBP-RW1Jng9K.js → chunk-BSJP7CBP-BicGsmBs.js} +1 -1
  23. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-D8PwFfuF.js → chunk-CSCIHK7Q-C3VTns3d.js} +1 -1
  24. package/workframe-ui/public/assets/{chunk-FMBD7UC4-rUp_DSZ2.js → chunk-FMBD7UC4-DSiXY3bY.js} +1 -1
  25. package/workframe-ui/public/assets/{chunk-KSCS5N6A-C3eMeGne.js → chunk-KSCS5N6A-Cvw5s3fR.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DaE5LwXn.js → chunk-L5ZTLDWV-jJifGdIQ.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Cmn67nL3.js → chunk-LZXEDZCA-Maee1aqo.js} +2 -2
  28. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Bg-FPAN5.js → chunk-ND2GUHAM-C77N3VAM.js} +1 -1
  29. package/workframe-ui/public/assets/{chunk-NZK2D7GU-orlL5R9g.js → chunk-NZK2D7GU-BY68lrPB.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-O5CBEL6O-BaPyh9SK.js → chunk-O5CBEL6O-D_c4kWzh.js} +1 -1
  31. package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +1 -0
  32. package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +1 -0
  33. package/workframe-ui/public/assets/{chunk-XPW4576I-CXYFE8tT.js → chunk-XPW4576I-DlQgvqBp.js} +1 -1
  34. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +1 -0
  35. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +1 -0
  36. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DDzKMTbp.js → cose-bilkent-S5V4N54A-CHCOHDSd.js} +1 -1
  37. package/workframe-ui/public/assets/{dagre-BM42HDAG-DCa6EvS3.js → dagre-BM42HDAG-CKPcSYwU.js} +1 -1
  38. package/workframe-ui/public/assets/{diagram-2AECGRRQ-C-WIQoE_.js → diagram-2AECGRRQ-C6qe0jP-.js} +1 -1
  39. package/workframe-ui/public/assets/{diagram-5GNKFQAL-UDk1nRH6.js → diagram-5GNKFQAL-DhQhrieU.js} +1 -1
  40. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DjLxUTPx.js → diagram-KO2AKTUF-Bqf6Iw1r.js} +1 -1
  41. package/workframe-ui/public/assets/{diagram-LMA3HP47-DNA9GSwJ.js → diagram-LMA3HP47-Bt1ms2D2.js} +1 -1
  42. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DO2nQu2f.js → diagram-OG6HWLK6-C0Rrxe1i.js} +1 -1
  43. package/workframe-ui/public/assets/{dist-DyW8oFIW.js → dist-BLcLhTn6.js} +1 -1
  44. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-DFtJLZx1.js → erDiagram-TEJ5UH35-Cy1GbZVu.js} +1 -1
  45. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +1 -0
  46. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-B6K6EnPA.js → flowDiagram-I6XJVG4X-DL3ALqDG.js} +1 -1
  47. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CbuWdupl.js → ganttDiagram-6RSMTGT7-CMpwBhXQ.js} +1 -1
  48. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BW_jwUPU.js → gitGraph-WXDBUCRP-C0mwHUXL.js} +1 -1
  49. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-B4MYuP9j.js → gitGraphDiagram-PVQCEYII-6gzhWaF0.js} +1 -1
  50. package/workframe-ui/public/assets/{index-DpoUZAxh.css → index-DyO6K0TD.css} +1 -1
  51. package/workframe-ui/public/assets/index-yvpSUs8T.js +133 -0
  52. package/workframe-ui/public/assets/{info-J43DQDTF-Xa61PMDn.js → info-J43DQDTF-Bb_wZTxU.js} +1 -1
  53. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-BNbQgdUG.js → infoDiagram-5YYISTIA-601evYFV.js} +1 -1
  54. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BDfiCzHo.js → ishikawaDiagram-YF4QCWOH-BYtF-wHm.js} +1 -1
  55. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-Dn52B8tT.js → journeyDiagram-JHISSGLW-BMizyHzC.js} +1 -1
  56. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-rhwkS_g6.js → kanban-definition-UN3LZRKU-CLLJsdnh.js} +1 -1
  57. package/workframe-ui/public/assets/{line-BQJgrj-x.js → line-BFiSvu-q.js} +1 -1
  58. package/workframe-ui/public/assets/{linear-Bq2ZvWSJ.js → linear-D6HYZ18_.js} +1 -1
  59. package/workframe-ui/public/assets/{mermaid-parser.core-CwFK_i8p.js → mermaid-parser.core-Cid5yDBQ.js} +2 -2
  60. package/workframe-ui/public/assets/{mermaid.core-C9CAm83c.js → mermaid.core-DqIiju5Z.js} +3 -3
  61. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BXwEccNZ.js → mindmap-definition-RKZ34NQL-Bg7xJdsz.js} +1 -1
  62. package/workframe-ui/public/assets/{packet-YPE3B663-D754iSKT.js → packet-YPE3B663-1ebGt86D.js} +1 -1
  63. package/workframe-ui/public/assets/{pie-LRSECV5Y-BQ2V4UWk.js → pie-LRSECV5Y-DJ9wOBC_.js} +1 -1
  64. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CNHecl4C.js → pieDiagram-4H26LBE5-BmK96Aus.js} +1 -1
  65. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-Bu6ciL2O.js → quadrantDiagram-W4KKPZXB-ByUqtWPZ.js} +1 -1
  66. package/workframe-ui/public/assets/{radar-GUYGQ44K-Dr8b9Qak.js → radar-GUYGQ44K-DN-Hkt96.js} +1 -1
  67. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Beua6Xju.js → requirementDiagram-4Y6WPE33-ByU2r8gb.js} +1 -1
  68. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CSDFJ0P_.js → sankeyDiagram-5OEKKPKP-elyWa0GK.js} +1 -1
  69. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-KKziodNI.js → sequenceDiagram-3UESZ5HK-CzZwsAiK.js} +1 -1
  70. package/workframe-ui/public/assets/{src-PKTIkQR0.js → src-5vgsM_dl.js} +1 -1
  71. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-Bu8FNMcj.js → stateDiagram-AJRCARHV-B0O5w5lJ.js} +1 -1
  72. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +1 -0
  73. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DMa-RPqy.js → timeline-definition-PNZ67QCA-oyqa5w5y.js} +1 -1
  74. package/workframe-ui/public/assets/{treeView-BLDUP644-CzQeUY7D.js → treeView-BLDUP644-MCV12-eP.js} +1 -1
  75. package/workframe-ui/public/assets/{treemap-LRROVOQU-BT4RYAhY.js → treemap-LRROVOQU-DiA1P9n5.js} +1 -1
  76. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-nKaUH6GG.js → vennDiagram-CIIHVFJN-CO_4x7OR.js} +1 -1
  77. package/workframe-ui/public/assets/{wardley-L42UT6IY-eSpvH7au.js → wardley-L42UT6IY-CmQNv6Xv.js} +1 -1
  78. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-B5u4s4a5.js → wardleyDiagram-YWT4CUSO-CJxDkbIj.js} +1 -1
  79. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DcXo5hDi.js → xychartDiagram-2RQKCTM6-B02MxgzG.js} +1 -1
  80. package/workframe-ui/public/index.html +7 -10
  81. package/workframe-ui/public/assets/architecture-7EHR7CIX-CwxC-UJz.js +0 -1
  82. package/workframe-ui/public/assets/channel-BaZSQvmW.js +0 -1
  83. package/workframe-ui/public/assets/chunk-QZHKN3VN-DXcsnu-x.js +0 -1
  84. package/workframe-ui/public/assets/chunk-WU5MYG2G-B2O0DZxu.js +0 -1
  85. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-C_TyUBYX.js +0 -1
  86. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-C_TyUBYX.js +0 -1
  87. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BTmiKS74.js +0 -1
  88. package/workframe-ui/public/assets/index-BYacg3Nj.js +0 -133
  89. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-E9A5RCa9.js +0 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Published on npm as **create-workframe**.
4
4
 
5
5
  ```bash
6
- npx create-workframe@0.1.3 MyProject
6
+ npx create-workframe@0.1.5 MyProject
7
7
  ```
8
8
 
9
9
  Scaffolds an isolated Workframe + Hermes project on Windows, macOS, and Linux.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-workframe",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Scaffold a Workframe + Hermes workspace with guided onboarding",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,18 +31,24 @@ function copyTree(src, dst) {
31
31
  }
32
32
  }
33
33
 
34
+ const skipBuild = process.argv.includes('--skip-build');
35
+
34
36
  if (!fs.existsSync(UI_SRC)) {
35
37
  console.error(`apps/web source not found: ${UI_SRC}`);
36
38
  process.exit(1);
37
39
  }
38
40
 
39
- console.log('Building @workframe/web...');
40
- const build = spawnSync(npmCmd(), ['run', 'build'], {
41
- cwd: UI_SRC,
42
- stdio: 'inherit',
43
- shell: process.platform === 'win32',
44
- });
45
- if (build.status !== 0) process.exit(build.status ?? 1);
41
+ if (!skipBuild) {
42
+ console.log('Building @workframe/web...');
43
+ const build = spawnSync(npmCmd(), ['run', 'build'], {
44
+ cwd: UI_SRC,
45
+ stdio: 'inherit',
46
+ shell: process.platform === 'win32',
47
+ });
48
+ if (build.status !== 0) process.exit(build.status ?? 1);
49
+ } else {
50
+ console.log('Skipping @workframe/web build (--skip-build)');
51
+ }
46
52
 
47
53
  const dist = path.join(UI_SRC, 'dist');
48
54
  if (!fs.existsSync(path.join(dist, 'index.html'))) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workframe/workframe-api",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -551,12 +551,26 @@ def _admin_write_allowed(handler: BaseHTTPRequestHandler) -> bool:
551
551
 
552
552
 
553
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 = ""
554
562
  try:
555
563
  return stack_updates.apply_update(target)
556
564
  except ValueError as exc:
557
- 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"):
558
569
  raise
559
- 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)
560
574
  if status >= 300 or not isinstance(data, dict) or not data.get("ok"):
561
575
  raise ValueError(str((data or {}).get("error") or "supervisor_apply_failed"))
562
576
  return data
@@ -2641,6 +2655,24 @@ def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container:
2641
2655
  """Start long-running `hermes auth add` detached in gateway (survives exec return)."""
2642
2656
  home = _hermes_user_home_container(user_id)
2643
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 "")
2644
2676
  auth_cmd = " ".join(shlex.quote(part) for part in ["auth", "add", hermes_auth_id])
2645
2677
  shell = (
2646
2678
  f"mkdir -p {shlex.quote(home)}; "
@@ -12402,7 +12434,9 @@ def _overlay_chat_llm_env(
12402
12434
  user = str(user_id or "").strip()
12403
12435
  if not user:
12404
12436
  return False
12405
- prov = _llm_billing_provider(hermes_prof, provider)
12437
+ prov = _llm_billing_provider(hermes_prof, provider, user_id=user, workspace_id=workspace_id)
12438
+ if not _profile_llm_proxy_matches_billing(hermes_prof, prov):
12439
+ _ensure_profile_llm_proxy(hermes_prof, prov)
12406
12440
  slug = safe_profile_slug(hermes_prof)
12407
12441
  turn_run = str(uuid.uuid4())
12408
12442
  ready = _try_overlay_turn_provider_env(hermes_prof, user, workspace_id, prov, turn_run)
@@ -12481,21 +12515,91 @@ def _augment_model_suggestions(
12481
12515
  return extra + suggestions
12482
12516
 
12483
12517
 
12484
- def _llm_billing_provider(profile: str, provider_hint: str = "") -> str:
12485
- """Map Hermes routing provider (often custom for proxy) to vault/billing provider id."""
12486
- hint = str(provider_hint or "").strip().lower()
12487
- if hint and hint not in {"custom", "auto"}:
12488
- return hint
12489
- cfg_provider, model = _read_model_from_config(profile)
12490
- cfg_provider = str(cfg_provider or "").strip().lower()
12491
- if cfg_provider and cfg_provider not in {"custom", "auto"}:
12492
- return cfg_provider
12493
- model_id = str(model or "").strip()
12494
- if "/" in model_id:
12495
- return model_id.split("/", 1)[0].strip().lower() or "openrouter"
12518
+ _INTERNAL_LLM_PROXY_RE = re.compile(r"/internal/llm/([a-z0-9_-]+)/v1", re.I)
12519
+
12520
+
12521
+ def _billing_provider_from_block(base_url: str, cfg_provider: str, hint: str) -> str:
12522
+ """Pure resolver: the billing provider is the proxy URL segment (the stored fact).
12523
+
12524
+ Order: explicit hint > proxy URL segment > cfg_provider > "openrouter".
12525
+ The model-id prefix (e.g. ``google/`` in ``google/gemini-2.5-flash``) is an
12526
+ OpenRouter vendor namespace, NOT a billing provider, so it is never used here.
12527
+ ponytail: removing model-prefix inference is the root-cause fix for the
12528
+ "provider not recognized" bug where an OpenRouter-only user was billed to google.
12529
+ """
12530
+ h = str(hint or "").strip().lower()
12531
+ if h and h not in {"custom", "auto"}:
12532
+ return h
12533
+ m = _INTERNAL_LLM_PROXY_RE.search(str(base_url or ""))
12534
+ if m:
12535
+ return m.group(1).lower()
12536
+ cfg = str(cfg_provider or "").strip().lower()
12537
+ if cfg and cfg not in {"custom", "auto"}:
12538
+ return cfg
12496
12539
  return "openrouter"
12497
12540
 
12498
12541
 
12542
+ def _llm_billing_provider(
12543
+ profile: str,
12544
+ provider_hint: str = "",
12545
+ *,
12546
+ user_id: str = "",
12547
+ workspace_id: str = "",
12548
+ ) -> str:
12549
+ """Map Hermes routing provider (often custom for proxy) to vault/billing provider id."""
12550
+ prof = resolve_hermes_profile(profile)
12551
+ block = _read_model_block(prof)
12552
+ cfg_provider = str(block.get("provider") or "").strip().lower()
12553
+ if not cfg_provider:
12554
+ cfg_provider = str(_read_model_from_config(prof)[0] or "").strip().lower()
12555
+ candidate = _billing_provider_from_block(
12556
+ str(block.get("base_url") or ""), cfg_provider, provider_hint,
12557
+ )
12558
+ user = str(user_id or "").strip()
12559
+ ws = str(workspace_id or "").strip()
12560
+ if user and not _user_can_use_llm(user, ws, candidate):
12561
+ connected = sorted(_user_llm_providers_for_picker(user))
12562
+ if connected:
12563
+ return connected[0]
12564
+ return candidate or "openrouter"
12565
+
12566
+
12567
+ def _profile_llm_proxy_matches_billing(profile: str, billing_provider: str) -> bool:
12568
+ prof = resolve_hermes_profile(profile)
12569
+ base = str(_read_model_block(prof).get("base_url") or "").strip()
12570
+ return bool(base) and base == _llm_proxy_base_url(billing_provider)
12571
+
12572
+
12573
+ def _reconcile_profile_llm_for_user(
12574
+ profile: str,
12575
+ user_id: str,
12576
+ workspace_id: str,
12577
+ *,
12578
+ prefer_provider: str = "",
12579
+ ) -> bool:
12580
+ """Point proxy + MVP model at a provider the acting user can bill."""
12581
+ user = str(user_id or "").strip()
12582
+ if not user:
12583
+ return False
12584
+ prof = resolve_hermes_profile(profile)
12585
+ billing = _llm_billing_provider(prof, user_id=user, workspace_id=workspace_id)
12586
+ prefer = str(prefer_provider or "").strip().lower()
12587
+ if prefer and _user_can_use_llm(user, workspace_id, prefer):
12588
+ billing = prefer
12589
+ elif not _user_can_use_llm(user, workspace_id, billing):
12590
+ connected = sorted(_user_llm_providers_for_picker(user))
12591
+ if not connected:
12592
+ return False
12593
+ billing = connected[0]
12594
+ if _user_can_use_llm(user, workspace_id, billing) and _profile_llm_proxy_matches_billing(prof, billing):
12595
+ return False
12596
+ changed = _apply_mvp_model_for_provider(prof, billing)
12597
+ block = _read_model_block(prof)
12598
+ if _is_runtime_profile_slug(prof) or str(block.get("provider") or "").strip().lower() == "custom":
12599
+ _ensure_profile_llm_proxy(prof, billing)
12600
+ return changed
12601
+
12602
+
12499
12603
  def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
12500
12604
  """Seed profile auth + MVP model config from user, workspace, or primary stack."""
12501
12605
  prof = resolve_hermes_profile(profile)
@@ -12724,7 +12828,9 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
12724
12828
  profile, user_id, "", workspace_id,
12725
12829
  )
12726
12830
  payer = user_id
12727
- llm_provider = _llm_billing_provider(hermes_prof)
12831
+ if payer:
12832
+ _reconcile_profile_llm_for_user(hermes_prof, payer, workspace_id)
12833
+ llm_provider = _llm_billing_provider(hermes_prof, user_id=payer, workspace_id=workspace_id)
12728
12834
  lifecycle = ensure_profile_api(hermes_prof, user_id, workspace_id)
12729
12835
  llm_ready = _user_can_use_llm(payer, workspace_id, llm_provider) if payer else False
12730
12836
  session_extra = {"llm_ready": llm_ready, "has_llm_provider": llm_ready}
@@ -13032,7 +13138,9 @@ def profile_chat_activate_room_session(
13032
13138
  gateway_sid,
13033
13139
  )
13034
13140
 
13035
- llm_provider = _llm_billing_provider(hermes_prof)
13141
+ if payer:
13142
+ _reconcile_profile_llm_for_user(hermes_prof, payer, workspace_id)
13143
+ llm_provider = _llm_billing_provider(hermes_prof, user_id=payer, workspace_id=workspace_id)
13036
13144
  llm_ready = _overlay_chat_llm_env(hermes_prof, user_id, workspace_id, llm_provider)
13037
13145
 
13038
13146
  history = chat_messages(hermes_prof, session_id)
@@ -13245,7 +13353,9 @@ def profile_chat_message(profile: str, payload: dict[str, Any]) -> dict[str, Any
13245
13353
  hermes_prof, _template_prof = _resolve_bind_profile_arg(
13246
13354
  profile, payer, room_id, workspace_id,
13247
13355
  )
13248
- llm_provider = _llm_billing_provider(hermes_prof)
13356
+ if payer:
13357
+ _reconcile_profile_llm_for_user(hermes_prof, payer, workspace_id)
13358
+ llm_provider = _llm_billing_provider(hermes_prof, user_id=payer, workspace_id=workspace_id)
13249
13359
  lifecycle = ensure_profile_api(
13250
13360
  hermes_prof,
13251
13361
  payer,
@@ -13400,7 +13510,9 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13400
13510
  raise ValueError("session_id required")
13401
13511
  if not text:
13402
13512
  raise ValueError("text required")
13403
- llm_provider = _llm_billing_provider(prof)
13513
+ if _triggering_user:
13514
+ _reconcile_profile_llm_for_user(prof, _triggering_user, _workspace_id)
13515
+ llm_provider = _llm_billing_provider(prof, user_id=_triggering_user, workspace_id=_workspace_id)
13404
13516
  llm_ready = bool(
13405
13517
  _triggering_user
13406
13518
  and _user_can_use_llm(_triggering_user, _workspace_id, llm_provider)
@@ -14399,17 +14511,23 @@ def _bootstrap_model_after_llm_connect(
14399
14511
  if not spec or str(spec.get("category") or "") != "llm":
14400
14512
  return
14401
14513
  provider_key = str(provider or "").strip().lower()
14514
+ user = str(user_id or "").strip()
14515
+ ws = str(workspace_id or "").strip()
14402
14516
  primary = _primary_profile()
14403
14517
  if primary:
14404
14518
  _, model = _read_model_from_config(primary)
14405
14519
  if not str(model or "").strip():
14406
14520
  _apply_mvp_model_for_provider(primary, provider_key)
14521
+ elif user:
14522
+ _reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
14407
14523
  template = primary or "workframe-agent"
14408
14524
  runtime = _runtime_profile_slug(user_id, template)
14409
14525
  if _runtime_profile_on_disk(runtime):
14410
14526
  _, model = _read_model_from_config(runtime)
14411
14527
  if not str(model or "").strip():
14412
14528
  _apply_mvp_model_for_provider(runtime, provider_key)
14529
+ elif user:
14530
+ _reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
14413
14531
 
14414
14532
 
14415
14533
  # Curated snapshot of commonly-used models per provider. Surfaced in the
@@ -14472,9 +14590,6 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
14472
14590
  {"provider": "OpenRouter", "model": "openrouter/nvidia/llama-3.1-nemotron-70b-instruct",
14473
14591
  "label": "Nemotron 70B Instruct",
14474
14592
  "description": "NVIDIA Nemotron via OpenRouter."},
14475
- {"provider": "OpenRouter", "model": "openrouter/anthropic/claude-sonnet-4-5",
14476
- "label": "Claude Sonnet 4.5 (via OR)",
14477
- "description": "Sonnet through OpenRouter."},
14478
14593
  {"provider": "OpenRouter", "model": "openrouter/openai/gpt-4o",
14479
14594
  "label": "GPT-4o (via OR)",
14480
14595
  "description": "GPT-4o through OpenRouter."},
@@ -14857,6 +14972,15 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
14857
14972
  for row in live
14858
14973
  if str(row.get("model") or "") not in seen
14859
14974
  ] + suggestions
14975
+ seen_models: set[str] = set()
14976
+ deduped: list[dict[str, str]] = []
14977
+ for row in suggestions:
14978
+ mid = str(row.get("model") or "").strip()
14979
+ if not mid or mid in seen_models:
14980
+ continue
14981
+ seen_models.add(mid)
14982
+ deduped.append(row)
14983
+ suggestions = deduped
14860
14984
  return {
14861
14985
  "ok": True,
14862
14986
  "profile": primary_profile,
@@ -0,0 +1,24 @@
1
+ """ponytail self-check: billing provider source of truth = proxy URL, never model-id prefix.
2
+
3
+ Run: python services/workframe-api/test_billing_provider.py
4
+ No framework, no fixtures. Fails fast if the inference regression returns.
5
+ """
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
10
+
11
+ import server # noqa: E402 (module import is side-effect-free; main() is guarded)
12
+
13
+ # The bug case: profile model is google/gemini-2.5-flash but proxy routes via openrouter.
14
+ # The model-id prefix MUST NOT leak as the billing provider.
15
+ assert server._billing_provider_from_block("/internal/llm/openrouter/v1", "custom", "") == "openrouter", \
16
+ "proxy URL segment must win over model-id prefix"
17
+ # Explicit hint honored.
18
+ assert server._billing_provider_from_block("", "", "anthropic") == "anthropic", "hint honored"
19
+ # Empty block defaults to openrouter (no inference).
20
+ assert server._billing_provider_from_block("", "", "") == "openrouter", "empty-block default"
21
+ # cfg_provider honored only when no proxy URL is set.
22
+ assert server._billing_provider_from_block("", "openai", "") == "openai", "cfg_provider honored without proxy"
23
+
24
+ print("billing provider self-check ok")
@@ -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")