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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +13 -7
- package/workframe-api/package.json +1 -1
- package/workframe-api/server.py +146 -22
- package/workframe-api/test_billing_provider.py +24 -0
- package/workframe-api/updates.py +107 -18
- package/workframe-supervisor/server.py +75 -2
- package/workframe-ui/public/assets/{arc-CRhulRPr.js → arc-CnriPN3G.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-B1Gq5onc.js → architectureDiagram-3BPJPVTR-DYYB1Ic2.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DtXEetq-.js → blockDiagram-GPEHLZMM-Bn-fFOyj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Bw7NEN3_.js → c4Diagram-AAUBKEIU-BPPDtAnD.js} +1 -1
- package/workframe-ui/public/assets/channel-Dok_-T0o.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-91lObBre.js → chunk-2J33WTMH-Bz2CJiC3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-D5IopWdW.js → chunk-3OPIFGDE-B8aMeE1B.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-DRHbh4Yd.js → chunk-4BX2VUAB-bLFHQTuT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-DcfKoh86.js → chunk-55IACEB6-BRxhx-lG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BMtcWXOU.js → chunk-5ZQYHXKU-BLyOgEJ8.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BW7tTfej.js → chunk-727SXJPM-DiuvBl7G.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CGoSQAgm.js → chunk-AQP2D5EJ-BE1_aBdC.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-RW1Jng9K.js → chunk-BSJP7CBP-BicGsmBs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-D8PwFfuF.js → chunk-CSCIHK7Q-C3VTns3d.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-rUp_DSZ2.js → chunk-FMBD7UC4-DSiXY3bY.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-C3eMeGne.js → chunk-KSCS5N6A-Cvw5s3fR.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DaE5LwXn.js → chunk-L5ZTLDWV-jJifGdIQ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-Cmn67nL3.js → chunk-LZXEDZCA-Maee1aqo.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-Bg-FPAN5.js → chunk-ND2GUHAM-C77N3VAM.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-orlL5R9g.js → chunk-NZK2D7GU-BY68lrPB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-BaPyh9SK.js → chunk-O5CBEL6O-D_c4kWzh.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-CXYFE8tT.js → chunk-XPW4576I-DlQgvqBp.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DDzKMTbp.js → cose-bilkent-S5V4N54A-CHCOHDSd.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DCa6EvS3.js → dagre-BM42HDAG-CKPcSYwU.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-C-WIQoE_.js → diagram-2AECGRRQ-C6qe0jP-.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-UDk1nRH6.js → diagram-5GNKFQAL-DhQhrieU.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DjLxUTPx.js → diagram-KO2AKTUF-Bqf6Iw1r.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-DNA9GSwJ.js → diagram-LMA3HP47-Bt1ms2D2.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-DO2nQu2f.js → diagram-OG6HWLK6-C0Rrxe1i.js} +1 -1
- package/workframe-ui/public/assets/{dist-DyW8oFIW.js → dist-BLcLhTn6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-DFtJLZx1.js → erDiagram-TEJ5UH35-Cy1GbZVu.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-B6K6EnPA.js → flowDiagram-I6XJVG4X-DL3ALqDG.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CbuWdupl.js → ganttDiagram-6RSMTGT7-CMpwBhXQ.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BW_jwUPU.js → gitGraph-WXDBUCRP-C0mwHUXL.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-B4MYuP9j.js → gitGraphDiagram-PVQCEYII-6gzhWaF0.js} +1 -1
- package/workframe-ui/public/assets/{index-DpoUZAxh.css → index-DyO6K0TD.css} +1 -1
- package/workframe-ui/public/assets/index-yvpSUs8T.js +133 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-Xa61PMDn.js → info-J43DQDTF-Bb_wZTxU.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-BNbQgdUG.js → infoDiagram-5YYISTIA-601evYFV.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BDfiCzHo.js → ishikawaDiagram-YF4QCWOH-BYtF-wHm.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-Dn52B8tT.js → journeyDiagram-JHISSGLW-BMizyHzC.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-rhwkS_g6.js → kanban-definition-UN3LZRKU-CLLJsdnh.js} +1 -1
- package/workframe-ui/public/assets/{line-BQJgrj-x.js → line-BFiSvu-q.js} +1 -1
- package/workframe-ui/public/assets/{linear-Bq2ZvWSJ.js → linear-D6HYZ18_.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-CwFK_i8p.js → mermaid-parser.core-Cid5yDBQ.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-C9CAm83c.js → mermaid.core-DqIiju5Z.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BXwEccNZ.js → mindmap-definition-RKZ34NQL-Bg7xJdsz.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-D754iSKT.js → packet-YPE3B663-1ebGt86D.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-BQ2V4UWk.js → pie-LRSECV5Y-DJ9wOBC_.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CNHecl4C.js → pieDiagram-4H26LBE5-BmK96Aus.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-Bu6ciL2O.js → quadrantDiagram-W4KKPZXB-ByUqtWPZ.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Dr8b9Qak.js → radar-GUYGQ44K-DN-Hkt96.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Beua6Xju.js → requirementDiagram-4Y6WPE33-ByU2r8gb.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CSDFJ0P_.js → sankeyDiagram-5OEKKPKP-elyWa0GK.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-KKziodNI.js → sequenceDiagram-3UESZ5HK-CzZwsAiK.js} +1 -1
- package/workframe-ui/public/assets/{src-PKTIkQR0.js → src-5vgsM_dl.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-Bu8FNMcj.js → stateDiagram-AJRCARHV-B0O5w5lJ.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DMa-RPqy.js → timeline-definition-PNZ67QCA-oyqa5w5y.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CzQeUY7D.js → treeView-BLDUP644-MCV12-eP.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BT4RYAhY.js → treemap-LRROVOQU-DiA1P9n5.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-nKaUH6GG.js → vennDiagram-CIIHVFJN-CO_4x7OR.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-eSpvH7au.js → wardley-L42UT6IY-CmQNv6Xv.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-B5u4s4a5.js → wardleyDiagram-YWT4CUSO-CJxDkbIj.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DcXo5hDi.js → xychartDiagram-2RQKCTM6-B02MxgzG.js} +1 -1
- package/workframe-ui/public/index.html +7 -10
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CwxC-UJz.js +0 -1
- package/workframe-ui/public/assets/channel-BaZSQvmW.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-DXcsnu-x.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B2O0DZxu.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-C_TyUBYX.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-C_TyUBYX.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BTmiKS74.js +0 -1
- package/workframe-ui/public/assets/index-BYacg3Nj.js +0 -133
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-E9A5RCa9.js +0 -1
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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'))) {
|
package/workframe-api/server.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
12487
|
-
|
|
12488
|
-
|
|
12489
|
-
|
|
12490
|
-
|
|
12491
|
-
|
|
12492
|
-
|
|
12493
|
-
|
|
12494
|
-
|
|
12495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
package/workframe-api/updates.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
282
|
-
workframe_can_update = bool(
|
|
283
|
-
hermes_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 =
|
|
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":
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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")
|