create-workframe 0.1.17 → 0.1.20
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/bin/create-workframe.js +73 -9
- package/package.json +1 -1
- package/scripts/apply-update-workframe.sh +21 -18
- package/scripts/compose-docker-host.sh +26 -0
- package/workframe-api/action_proxy.py +8 -0
- package/workframe-api/chat_sessions.py +37 -6
- package/workframe-api/chat_stream.py +16 -0
- package/workframe-api/db_schema.py +29 -0
- package/workframe-api/handler_modules/handler_admin.py +32 -0
- package/workframe-api/handler_modules/handler_auth.py +6 -4
- package/workframe-api/handler_modules/handler_chat.py +17 -26
- package/workframe-api/handler_modules/handler_provider.py +13 -14
- package/workframe-api/handler_modules/handler_workspace.py +60 -2
- package/workframe-api/hermes_profiles.py +31 -9
- package/workframe-api/llm_proxy.py +18 -0
- package/workframe-api/mention_invoke.py +29 -24
- package/workframe-api/message_reactions.py +139 -0
- package/workframe-api/model_surface.py +329 -113
- package/workframe-api/package.json +4 -4
- package/workframe-api/profile_gateway.py +14 -6
- package/workframe-api/provider_bindings.py +118 -17
- package/workframe-api/provider_bootstrap.py +74 -38
- package/workframe-api/provider_model_catalog.py +261 -0
- package/workframe-api/rooms.py +4 -3
- package/workframe-api/route_registry.py +4 -0
- package/workframe-api/runtime_cohort.py +118 -23
- package/workframe-api/scripts/run-python.mjs +28 -0
- package/workframe-api/server.py +44 -12
- package/workframe-api/test_agent_identity_authority.py +96 -0
- package/workframe-api/test_chat_attribution_history.py +59 -0
- package/workframe-api/test_enrich_room_messages.py +43 -2
- package/workframe-api/test_llm_proxy_encoding.py +33 -0
- package/workframe-api/test_message_reactions.py +151 -0
- package/workframe-api/test_model_routing_reconcile.py +122 -0
- package/workframe-api/test_model_surface_consistency.py +45 -5
- package/workframe-api/test_oauth_llm.py +1 -1
- package/workframe-api/test_profile_gateway_health.py +37 -0
- package/workframe-api/test_provider_bindings.py +115 -1
- package/workframe-api/test_provider_model_catalog.py +27 -0
- package/workframe-api/test_provider_model_surface.py +2 -2
- package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
- package/workframe-api/test_server_reexports.py +2 -0
- package/workframe-api/test_user_llm_prefs.py +27 -0
- package/workframe-api/test_workspace_file_actions.py +119 -0
- package/workframe-api/turn_credentials.py +41 -0
- package/workframe-api/turn_overlay.py +95 -34
- package/workframe-api/updates.py +14 -5
- package/workframe-api/user_prefs.py +15 -0
- package/workframe-api/workspace_bootstrap.py +1 -0
- package/workframe-api/workspace_files.py +124 -0
- package/workframe-supervisor/server.py +18 -2
- package/workframe-ui/public/assets/{arc-esSBm9C_.js → arc-BkATcoqt.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-yusDFKmK.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DVVz9RGn.js → architectureDiagram-3BPJPVTR-31_yAiUS.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-B6i10GMX.js → blockDiagram-GPEHLZMM-ClM6Si9T.js} +1 -1
- package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BqwJ7JoH.js → c4Diagram-AAUBKEIU-DJzEktF3.js} +1 -1
- package/workframe-ui/public/assets/channel-Nb_UjZ3O.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-CyhcedSa.js → chunk-2J33WTMH-DPFiyb5O.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-1Rr6WuvI.js → chunk-3OPIFGDE-DXNJzuCn.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-D2bRrARV.js → chunk-4BX2VUAB-DDNibGdr.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-CKXeZxSr.js → chunk-55IACEB6-Co3_ulwA.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DYQxxPZ4.js → chunk-5ZQYHXKU-Cmxz8o4d.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-CGpIgqvD.js → chunk-727SXJPM-CDAQGesU.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-B88NGQ7d.js → chunk-AQP2D5EJ-x-swy3kc.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CCymiI0O.js → chunk-BSJP7CBP-28nU0JOE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-DRgey03j.js → chunk-CSCIHK7Q-FUURFika.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-l1adn8gM.js → chunk-FMBD7UC4-B4RgRO2R.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CaEvDRPK.js → chunk-KSCS5N6A-CkuppW3T.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-BgCnvPTv.js → chunk-L5ZTLDWV-DyrPSaz3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-t2zokHpn.js → chunk-LZXEDZCA-DFaZSDNV.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-C3c9p0sv.js → chunk-ND2GUHAM-DXI0OJxg.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-DSujF-R8.js → chunk-NZK2D7GU-CyOp1ZG2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DJJTbM6p.js → chunk-O5CBEL6O-tZZMCfbx.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C_TtOXi5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-BjSJQEIY.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-D2Cxn3AG.js → chunk-XPW4576I-B7_2gS3x.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-JD6iZUGh.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-JD6iZUGh.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A---dQ-pFK.js → cose-bilkent-S5V4N54A-BWJaHw_L.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-B_qe1ODY.js → dagre-BM42HDAG-Bu5MadGV.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Bou8vEYl.js → diagram-2AECGRRQ-ckLZkDFD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-DEUEzONU.js → diagram-5GNKFQAL-Cf6Abx1Y.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Ctq571VA.js → diagram-KO2AKTUF-TLk7_5mJ.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CnKn89lm.js → diagram-LMA3HP47-ouNxsAb0.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-DnKlrMx2.js → diagram-OG6HWLK6-BMCBQ35D.js} +1 -1
- package/workframe-ui/public/assets/{dist-ubrEgQyZ.js → dist-C9bxMexz.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-PjqO7CJd.js → erDiagram-TEJ5UH35-BN3e6jb1.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bqd_sTz0.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-B2PN_nNG.js → flowDiagram-I6XJVG4X-BiinzZrd.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-ThyAtNQP.js → ganttDiagram-6RSMTGT7-AYE8oBBG.js} +1 -1
- package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-CB2UgoWy.js → gitGraph-WXDBUCRP-mIq_E2vs.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-DRJJ-zT-.js → gitGraphDiagram-PVQCEYII-Cf8IFEcO.js} +1 -1
- package/workframe-ui/public/assets/index-BnfFRGR3.js +133 -0
- package/workframe-ui/public/assets/index-COshPjF3.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-CLtR33Q_.js → info-J43DQDTF--_3kCaIQ.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-Cmunqfvi.js → infoDiagram-5YYISTIA-BuIOyYMu.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-DWE_84eL.js → ishikawaDiagram-YF4QCWOH-D3bbsXQ8.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BtBhpyRL.js → journeyDiagram-JHISSGLW-BrZ26C8L.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-B8QspDDL.js → kanban-definition-UN3LZRKU-CJiUyUOr.js} +1 -1
- package/workframe-ui/public/assets/{line-iOgDn4O8.js → line-Bee1snmU.js} +1 -1
- package/workframe-ui/public/assets/{linear-BBehZNVA.js → linear-dfD-zlI7.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-DQK5HcdU.js → mermaid-parser.core-D6IqqDSH.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Dsrdd1Wr.js → mermaid.core-CuuxYt2r.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Ds_BfsJA.js → mindmap-definition-RKZ34NQL-T7W2fkwA.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Bz3pi4i8.js → packet-YPE3B663-CiXMDasj.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-BFFah-6g.js → pie-LRSECV5Y-CUx1_96f.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CVCvLPcC.js → pieDiagram-4H26LBE5-DfR96FUO.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-Cx6rrc_7.js → quadrantDiagram-W4KKPZXB-Dj6mqgQE.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-DJ5vN_cg.js → radar-GUYGQ44K-DKT2q98y.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Cbry6b5c.js → requirementDiagram-4Y6WPE33-DYDnBVw9.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CICUr5En.js → sankeyDiagram-5OEKKPKP-BSkxpjjO.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsOuB3F-.js → sequenceDiagram-3UESZ5HK-Dz6SNH9S.js} +1 -1
- package/workframe-ui/public/assets/{src-TS8jZspq.js → src-DC521nE_.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-avBzMQy_.js → stateDiagram-AJRCARHV-D4ZoF5NP.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DknveEiR.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DHLm6UFN.js → timeline-definition-PNZ67QCA-d0ezg1Hu.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-B3Qd5t3j.js → treeView-BLDUP644-kXyaqwIy.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-D1dMQS7x.js → treemap-LRROVOQU-g168p7-O.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DZ6SoniY.js → vennDiagram-CIIHVFJN-C0_QsmRl.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-9gK17Tp3.js → wardley-L42UT6IY-BNFCdzxQ.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-FCU_wmRE.js → wardleyDiagram-YWT4CUSO-BOwUS9sY.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DWowvseN.js → xychartDiagram-2RQKCTM6-Cxr1ZiKh.js} +1 -1
- package/workframe-ui/public/index.html +2 -2
- package/workframe-ui/public/workframe-build.json +3 -3
- package/workframe-ui/public/assets/architecture-7EHR7CIX-j-DiBdGu.js +0 -1
- package/workframe-ui/public/assets/channel-CJ9pFXbx.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Bxk3enxo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DdbkX1rg.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-bwCRiiUl.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-bwCRiiUl.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-g-Xo_eS4.js +0 -1
- package/workframe-ui/public/assets/index-1euVfd3b.js +0 -132
- package/workframe-ui/public/assets/index-BLVAsAeO.css +0 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-BFRA5TYD.js +0 -1
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from http.server import BaseHTTPRequestHandler
|
|
21
21
|
|
|
22
|
-
import
|
|
22
|
+
import provider_model_catalog
|
|
23
23
|
import profile_config_yaml
|
|
24
24
|
import user_prefs
|
|
25
25
|
|
|
@@ -31,32 +31,50 @@ def _srv():
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def _resolve_models_profile(profile: str) -> str:
|
|
34
|
-
"""Hermes profile slug for model picker reads.
|
|
34
|
+
"""Hermes profile slug for model picker reads. Models belong to agent templates."""
|
|
35
35
|
raw = str(profile or "").strip()
|
|
36
36
|
if not raw:
|
|
37
37
|
return _srv()._primary_profile()
|
|
38
38
|
slug = _srv().safe_profile_slug(raw)
|
|
39
|
-
if _srv()._is_runtime_profile_slug(slug) and _srv()._runtime_profile_on_disk(slug):
|
|
40
|
-
return slug
|
|
41
39
|
if _srv()._is_runtime_profile_slug(slug):
|
|
42
40
|
return _srv().resolve_validated_profile(_srv()._runtime_template_slug(slug))
|
|
43
41
|
return _srv().resolve_validated_profile(slug)
|
|
44
42
|
|
|
45
43
|
|
|
44
|
+
def _agent_model_profile(profile: str) -> str:
|
|
45
|
+
"""Canonical write target for an agent's provider/model configuration."""
|
|
46
|
+
raw = str(profile or _srv()._primary_profile()).strip()
|
|
47
|
+
if not raw:
|
|
48
|
+
return ""
|
|
49
|
+
slug = _srv().safe_profile_slug(raw)
|
|
50
|
+
if _srv()._is_runtime_profile_slug(slug):
|
|
51
|
+
slug = _srv()._runtime_template_slug(slug)
|
|
52
|
+
return _srv().resolve_validated_profile(slug)
|
|
53
|
+
|
|
54
|
+
|
|
46
55
|
def _model_suggestion_row(
|
|
47
56
|
model_id: str,
|
|
48
57
|
*,
|
|
49
58
|
provider_label: str = "OpenRouter",
|
|
59
|
+
billing_provider: str = "",
|
|
50
60
|
label: str = "",
|
|
51
61
|
description: str = "",
|
|
52
62
|
) -> dict[str, str]:
|
|
53
63
|
slug = model_id.rsplit("/", 1)[-1] if "/" in model_id else model_id
|
|
54
|
-
|
|
64
|
+
row = {
|
|
55
65
|
"provider": provider_label,
|
|
56
66
|
"model": model_id,
|
|
57
67
|
"label": label or slug,
|
|
58
68
|
"description": description or model_id,
|
|
59
69
|
}
|
|
70
|
+
if billing_provider:
|
|
71
|
+
row["billing_provider"] = billing_provider
|
|
72
|
+
return row
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _model_suggestion_key(row: dict[str, str]) -> tuple[str, str]:
|
|
76
|
+
provider = str(row.get("billing_provider") or row.get("provider") or "").strip().lower()
|
|
77
|
+
return provider, str(row.get("model") or "").strip()
|
|
60
78
|
|
|
61
79
|
|
|
62
80
|
def _augment_model_suggestions(
|
|
@@ -64,22 +82,23 @@ def _augment_model_suggestions(
|
|
|
64
82
|
block: dict[str, Any],
|
|
65
83
|
) -> list[dict[str, str]]:
|
|
66
84
|
"""Ensure active primary and fallback chain models appear in the picker."""
|
|
67
|
-
seen = {
|
|
85
|
+
seen = {_model_suggestion_key(row) for row in suggestions}
|
|
68
86
|
extra: list[dict[str, str]] = []
|
|
69
87
|
primary = str(block.get("default") or "").strip()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
base_url = str(block.get("base_url") or "").strip()
|
|
89
|
+
configured_provider = str(block.get("provider") or "").strip()
|
|
90
|
+
bill = _billing_provider_from_block(base_url, configured_provider, configured_provider)
|
|
91
|
+
if primary and (bill.lower(), primary) not in seen:
|
|
74
92
|
extra.append(
|
|
75
93
|
_model_suggestion_row(
|
|
76
94
|
primary,
|
|
77
95
|
provider_label=_srv()._provider_display_label(bill),
|
|
78
|
-
|
|
96
|
+
billing_provider=bill,
|
|
97
|
+
label=primary.rsplit("/", 1)[-1],
|
|
79
98
|
description="Active model for this agent profile",
|
|
80
99
|
),
|
|
81
100
|
)
|
|
82
|
-
seen.add(primary)
|
|
101
|
+
seen.add((bill.lower(), primary))
|
|
83
102
|
for entry in block.get("fallback_chain") or []:
|
|
84
103
|
if not isinstance(entry, dict):
|
|
85
104
|
continue
|
|
@@ -88,18 +107,19 @@ def _augment_model_suggestions(
|
|
|
88
107
|
if not model:
|
|
89
108
|
continue
|
|
90
109
|
full = model if "/" in model else f"{prov}/{model}"
|
|
91
|
-
|
|
110
|
+
bill = _billing_provider_from_block(base_url, prov, prov)
|
|
111
|
+
if (bill.lower(), full) in seen:
|
|
92
112
|
continue
|
|
93
|
-
bill = _billing_provider_id_from_hermes_config(prov) or prov
|
|
94
113
|
extra.append(
|
|
95
114
|
_model_suggestion_row(
|
|
96
115
|
full,
|
|
97
116
|
provider_label=_srv()._provider_display_label(bill),
|
|
98
|
-
|
|
117
|
+
billing_provider=bill,
|
|
118
|
+
label=model.rsplit("/", 1)[-1],
|
|
99
119
|
description="Configured in this profile's fallback chain",
|
|
100
120
|
),
|
|
101
121
|
)
|
|
102
|
-
seen.add(full)
|
|
122
|
+
seen.add((bill.lower(), full))
|
|
103
123
|
return extra + suggestions
|
|
104
124
|
|
|
105
125
|
|
|
@@ -116,17 +136,45 @@ def _billing_provider_from_block(base_url: str, cfg_provider: str, hint: str) ->
|
|
|
116
136
|
"provider not recognized" bug where an OpenRouter-only user was billed to google.
|
|
117
137
|
"""
|
|
118
138
|
h = str(hint or "").strip().lower()
|
|
139
|
+
mapped_hint = _billing_provider_id_from_hermes_config(h)
|
|
140
|
+
if mapped_hint:
|
|
141
|
+
return mapped_hint
|
|
119
142
|
if h and h not in {"custom", "auto"}:
|
|
120
|
-
return h
|
|
143
|
+
return h.replace("_", "-")
|
|
121
144
|
m = _INTERNAL_LLM_PROXY_RE.search(str(base_url or ""))
|
|
122
145
|
if m:
|
|
123
146
|
return m.group(1).lower()
|
|
124
147
|
cfg = str(cfg_provider or "").strip().lower()
|
|
148
|
+
mapped_cfg = _billing_provider_id_from_hermes_config(cfg)
|
|
149
|
+
if mapped_cfg:
|
|
150
|
+
return mapped_cfg
|
|
125
151
|
if cfg and cfg not in {"custom", "auto"}:
|
|
126
|
-
return cfg
|
|
152
|
+
return cfg.replace("_", "-")
|
|
127
153
|
return "openrouter"
|
|
128
154
|
|
|
129
155
|
|
|
156
|
+
def _normalized_profile_fallback_chain(
|
|
157
|
+
block: dict[str, Any],
|
|
158
|
+
default_billing_provider: str,
|
|
159
|
+
) -> list[dict[str, str]]:
|
|
160
|
+
"""Expose billing providers, not Hermes' internal ``custom`` transport."""
|
|
161
|
+
base_url = str(block.get("base_url") or "").strip()
|
|
162
|
+
normalized: list[dict[str, str]] = []
|
|
163
|
+
for entry in block.get("fallback_chain") or []:
|
|
164
|
+
if not isinstance(entry, dict):
|
|
165
|
+
continue
|
|
166
|
+
model = str(entry.get("model") or "").strip()
|
|
167
|
+
configured = str(entry.get("provider") or "").strip()
|
|
168
|
+
if not model:
|
|
169
|
+
continue
|
|
170
|
+
provider = _billing_provider_from_block(base_url, configured, configured)
|
|
171
|
+
normalized.append({
|
|
172
|
+
"provider": provider or default_billing_provider,
|
|
173
|
+
"model": model,
|
|
174
|
+
})
|
|
175
|
+
return normalized
|
|
176
|
+
|
|
177
|
+
|
|
130
178
|
def _llm_billing_provider(
|
|
131
179
|
profile: str,
|
|
132
180
|
provider_hint: str = "",
|
|
@@ -147,10 +195,9 @@ def _llm_billing_provider(
|
|
|
147
195
|
candidate = _billing_provider_from_block(
|
|
148
196
|
str(block.get("base_url") or ""), cfg_provider, provider_hint,
|
|
149
197
|
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return connected[0]
|
|
198
|
+
# Provider/model are agent-owned. Credential availability decides whether
|
|
199
|
+
# the acting user may run the agent; it must never silently reroute the
|
|
200
|
+
# agent to a different provider and create DM/room drift.
|
|
154
201
|
return candidate or "openrouter"
|
|
155
202
|
|
|
156
203
|
|
|
@@ -216,9 +263,8 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
|
|
|
216
263
|
"fallbacks": [{"provider": "deepseek", "model": "deepseek-chat"}],
|
|
217
264
|
},
|
|
218
265
|
"codex": {
|
|
219
|
-
"primary": "gpt-5.4-
|
|
266
|
+
"primary": "gpt-5.4-mini",
|
|
220
267
|
"fallbacks": [
|
|
221
|
-
{"provider": "openai-codex", "model": "gpt-5.4-medium"},
|
|
222
268
|
{"provider": "openai-codex", "model": "gpt-5.4-mini"},
|
|
223
269
|
],
|
|
224
270
|
},
|
|
@@ -231,7 +277,8 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
|
|
|
231
277
|
|
|
232
278
|
# ponytail: billing id (connect catalog) ↔ Hermes config model.provider ↔ picker rows
|
|
233
279
|
_PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
|
|
234
|
-
|
|
280
|
+
# ChatGPT-account Codex has a narrower model surface than direct OpenAI API.
|
|
281
|
+
"codex": frozenset({"codex"}),
|
|
235
282
|
"openai": frozenset({"openai"}),
|
|
236
283
|
"openrouter": frozenset({"openrouter"}),
|
|
237
284
|
"anthropic": frozenset({"anthropic"}),
|
|
@@ -241,7 +288,6 @@ _PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
|
|
|
241
288
|
}
|
|
242
289
|
|
|
243
290
|
_CODEX_EXTRA_MODELS: tuple[tuple[str, str, str], ...] = (
|
|
244
|
-
("gpt-5.4-medium", "GPT-5.4 Medium", "Codex default — agentic, tool-capable."),
|
|
245
291
|
("gpt-5.4-mini", "GPT-5.4 Mini", "Faster Codex model on ChatGPT account."),
|
|
246
292
|
)
|
|
247
293
|
|
|
@@ -327,6 +373,69 @@ def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[s
|
|
|
327
373
|
return out
|
|
328
374
|
|
|
329
375
|
|
|
376
|
+
def _live_suggestions_for_connected_llm_providers(
|
|
377
|
+
user_id: str,
|
|
378
|
+
workspace_id: str,
|
|
379
|
+
connected: set[str],
|
|
380
|
+
) -> tuple[list[dict[str, str]], dict[str, dict[str, str]]]:
|
|
381
|
+
"""Discover the models each effective credential can use right now."""
|
|
382
|
+
credentials: dict[str, str] = {}
|
|
383
|
+
for provider in sorted(connected):
|
|
384
|
+
spec = _srv()._catalog_provider_for_llm(provider) or {}
|
|
385
|
+
if str(spec.get("connect_mode") or "") == "oauth":
|
|
386
|
+
auth_id = str(spec.get("hermes_auth_id") or provider)
|
|
387
|
+
auth = _srv()._load_user_hermes_auth(user_id) if user_id else None
|
|
388
|
+
credentials[provider] = provider_model_catalog.oauth_access_token(auth, auth_id)
|
|
389
|
+
continue
|
|
390
|
+
resolved = _srv()._resolve_credential(
|
|
391
|
+
user_id,
|
|
392
|
+
workspace_id,
|
|
393
|
+
provider,
|
|
394
|
+
user_only=_srv()._provider_user_only(provider),
|
|
395
|
+
)
|
|
396
|
+
credentials[provider] = _srv()._credential_secret(resolved or {}, user_id) if resolved else ""
|
|
397
|
+
return provider_model_catalog.discover_many(credentials, timeout=8)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _default_model_from_live_catalog(
|
|
401
|
+
connected: set[str],
|
|
402
|
+
suggestions: list[dict[str, str]],
|
|
403
|
+
) -> tuple[str, str]:
|
|
404
|
+
"""Choose a valid default from the first effective provider, never a disconnected one."""
|
|
405
|
+
catalog_order = [
|
|
406
|
+
str(spec["id"]).lower()
|
|
407
|
+
for spec in _srv().PROVIDER_CONNECT_CATALOG
|
|
408
|
+
if str(spec.get("category") or "") == "llm"
|
|
409
|
+
and str(spec["id"]).lower() in connected
|
|
410
|
+
]
|
|
411
|
+
for provider in catalog_order:
|
|
412
|
+
rows = [
|
|
413
|
+
row for row in suggestions
|
|
414
|
+
if str(row.get("billing_provider") or row.get("provider") or "").strip().lower() == provider
|
|
415
|
+
]
|
|
416
|
+
if not rows:
|
|
417
|
+
continue
|
|
418
|
+
preferred = str((PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or "").strip()
|
|
419
|
+
if preferred and any(str(row.get("model") or "") == preferred for row in rows):
|
|
420
|
+
return preferred, provider
|
|
421
|
+
return str(rows[0].get("model") or "").strip(), provider
|
|
422
|
+
return "", ""
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _effective_fallback_chain(chain: list[dict[str, str]], connected: set[str]) -> list[dict[str, str]]:
|
|
426
|
+
effective: list[dict[str, str]] = []
|
|
427
|
+
for entry in chain:
|
|
428
|
+
if not isinstance(entry, dict):
|
|
429
|
+
continue
|
|
430
|
+
provider = _billing_provider_id_from_hermes_config(str(entry.get("provider") or "")) or str(
|
|
431
|
+
entry.get("provider") or ""
|
|
432
|
+
).strip().lower()
|
|
433
|
+
model = str(entry.get("model") or "").strip()
|
|
434
|
+
if provider in connected and model:
|
|
435
|
+
effective.append({"provider": provider, "model": model})
|
|
436
|
+
return effective
|
|
437
|
+
|
|
438
|
+
|
|
330
439
|
def _model_id_vendor_and_bare(model_id: str) -> tuple[str, str]:
|
|
331
440
|
"""Split vendor/model ids (e.g. openai-codex/gpt-5.4-mini) into billing + bare model."""
|
|
332
441
|
mid = str(model_id or "").strip()
|
|
@@ -391,6 +500,64 @@ def _mirror_template_model_to_runtime(
|
|
|
391
500
|
)
|
|
392
501
|
|
|
393
502
|
|
|
503
|
+
def _sync_agent_model_to_runtimes(
|
|
504
|
+
template_slug: str,
|
|
505
|
+
*,
|
|
506
|
+
current_user_id: str = "",
|
|
507
|
+
current_workspace_id: str = "",
|
|
508
|
+
) -> tuple[bool, str, list[str]]:
|
|
509
|
+
"""Mirror one agent model surface to every existing per-user runtime.
|
|
510
|
+
|
|
511
|
+
Runtime profiles isolate credentials and gateways, not agent preferences.
|
|
512
|
+
Owner-specific credentials are refreshed when their identity is resolvable.
|
|
513
|
+
"""
|
|
514
|
+
template = _agent_model_profile(template_slug)
|
|
515
|
+
if not template:
|
|
516
|
+
return False, "no profile resolved", []
|
|
517
|
+
profiles_dir = _srv().HERMES_DATA / "profiles"
|
|
518
|
+
runtimes: set[str] = set()
|
|
519
|
+
if profiles_dir.is_dir():
|
|
520
|
+
for path in profiles_dir.iterdir():
|
|
521
|
+
if not path.is_dir() or not _srv()._is_runtime_profile_slug(path.name):
|
|
522
|
+
continue
|
|
523
|
+
try:
|
|
524
|
+
if _srv()._runtime_template_slug(path.name) == template:
|
|
525
|
+
runtimes.add(path.name)
|
|
526
|
+
except ValueError:
|
|
527
|
+
continue
|
|
528
|
+
user = str(current_user_id or "").strip()
|
|
529
|
+
if user:
|
|
530
|
+
candidate = _srv()._runtime_profile_slug(user, template)
|
|
531
|
+
if _srv()._runtime_profile_on_disk(candidate):
|
|
532
|
+
runtimes.add(candidate)
|
|
533
|
+
|
|
534
|
+
synced: list[str] = []
|
|
535
|
+
for runtime in sorted(runtimes):
|
|
536
|
+
owner = _srv()._resolve_runtime_owner(runtime)
|
|
537
|
+
owner_id = str(owner[0] if owner else "").strip()
|
|
538
|
+
workspace_id = str(owner[1] if owner else "").strip()
|
|
539
|
+
if not owner_id and user and runtime == _srv()._runtime_profile_slug(user, template):
|
|
540
|
+
owner_id = user
|
|
541
|
+
workspace_id = str(current_workspace_id or "").strip()
|
|
542
|
+
ok, err = _sync_runtime_model_from_template(
|
|
543
|
+
runtime,
|
|
544
|
+
template,
|
|
545
|
+
user_id=owner_id,
|
|
546
|
+
)
|
|
547
|
+
if not ok:
|
|
548
|
+
return False, err or f"runtime model sync failed: {runtime}", synced
|
|
549
|
+
if owner_id:
|
|
550
|
+
_srv()._prepare_runtime_profile_credentials(
|
|
551
|
+
runtime,
|
|
552
|
+
owner_id,
|
|
553
|
+
workspace_id,
|
|
554
|
+
wait_healthy=False,
|
|
555
|
+
)
|
|
556
|
+
_srv()._schedule_gateway_reload(runtime)
|
|
557
|
+
synced.append(runtime)
|
|
558
|
+
return True, "", synced
|
|
559
|
+
|
|
560
|
+
|
|
394
561
|
def _resolve_billing_provider_for_model(
|
|
395
562
|
model_id: str,
|
|
396
563
|
connected: set[str],
|
|
@@ -568,6 +735,10 @@ def _bootstrap_model_after_llm_connect(
|
|
|
568
735
|
_apply_mvp_model_for_provider(runtime, provider_key)
|
|
569
736
|
elif user:
|
|
570
737
|
_srv()._reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
|
|
738
|
+
if user:
|
|
739
|
+
_srv()._refresh_user_runtime_credentials(user, ws, wait_healthy=False)
|
|
740
|
+
elif ws:
|
|
741
|
+
_srv()._refresh_workspace_runtime_credentials(ws, wait_healthy=False)
|
|
571
742
|
|
|
572
743
|
|
|
573
744
|
# Curated snapshot of commonly-used models per provider. Surfaced in the
|
|
@@ -600,8 +771,6 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
|
|
|
600
771
|
"label": "o1 mini", "description": "Cheaper reasoning."},
|
|
601
772
|
{"provider": "OpenAI", "model": "gpt-5.4-mini",
|
|
602
773
|
"label": "GPT-5.4 Mini", "description": "Faster Codex / OpenAI model."},
|
|
603
|
-
{"provider": "Codex", "model": "gpt-5.4-medium",
|
|
604
|
-
"label": "GPT-5.4 Medium (Codex)", "description": "Codex ChatGPT account default."},
|
|
605
774
|
{"provider": "Codex", "model": "gpt-5.4-mini",
|
|
606
775
|
"label": "GPT-5.4 Mini (Codex)", "description": "Faster Codex model."},
|
|
607
776
|
{"provider": "Google", "model": "gemini-2.5-pro",
|
|
@@ -699,6 +868,7 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
|
|
|
699
868
|
in_model = False
|
|
700
869
|
in_fallback = False
|
|
701
870
|
fallback_indent = -1
|
|
871
|
+
fallback_item_indent = -1
|
|
702
872
|
saw_top_model = False
|
|
703
873
|
for raw in lines:
|
|
704
874
|
stripped = raw.rstrip()
|
|
@@ -748,6 +918,7 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
|
|
|
748
918
|
if indent == 0 and content.startswith("fallback_providers:"):
|
|
749
919
|
in_fallback = True
|
|
750
920
|
fallback_indent = indent
|
|
921
|
+
fallback_item_indent = -1
|
|
751
922
|
after = content.split(":", 1)[1].strip()
|
|
752
923
|
if after and (
|
|
753
924
|
after in {"{}", "[]"}
|
|
@@ -757,17 +928,14 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
|
|
|
757
928
|
in_fallback = False
|
|
758
929
|
continue
|
|
759
930
|
|
|
760
|
-
if
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if in_fallback and indent == fallback_indent + 2 and content.startswith("- "):
|
|
770
|
-
# Start of a new fallback entry. First key may be inline.
|
|
931
|
+
if (
|
|
932
|
+
in_fallback
|
|
933
|
+
and content.startswith("- ")
|
|
934
|
+
and indent in {fallback_indent, fallback_indent + 2}
|
|
935
|
+
):
|
|
936
|
+
# PyYAML emits root sequences without indentation by default,
|
|
937
|
+
# while hand-authored configs commonly indent the same item.
|
|
938
|
+
fallback_item_indent = indent
|
|
771
939
|
inline = content[2:].strip()
|
|
772
940
|
entry: dict[str, str] = {}
|
|
773
941
|
if ":" in inline:
|
|
@@ -776,7 +944,12 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
|
|
|
776
944
|
out["fallback_chain"].append(entry)
|
|
777
945
|
continue
|
|
778
946
|
|
|
779
|
-
if
|
|
947
|
+
if (
|
|
948
|
+
in_fallback
|
|
949
|
+
and fallback_item_indent >= 0
|
|
950
|
+
and indent > fallback_item_indent
|
|
951
|
+
and ":" in content
|
|
952
|
+
):
|
|
780
953
|
k, _, v = content.partition(":")
|
|
781
954
|
key = k.strip()
|
|
782
955
|
val = v.strip().strip('"').strip("'")
|
|
@@ -784,7 +957,16 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
|
|
|
784
957
|
out["fallback_chain"][-1][key] = val
|
|
785
958
|
continue
|
|
786
959
|
|
|
787
|
-
if in_fallback and indent
|
|
960
|
+
if in_fallback and indent == fallback_indent + 2 and ":" in content and not content.startswith("- "):
|
|
961
|
+
# ponytail: wizard once wrote flat `model:` keys (no list) — tolerate one entry per line
|
|
962
|
+
k, _, v = content.partition(":")
|
|
963
|
+
key = k.strip()
|
|
964
|
+
val = v.strip().strip('"').strip("'")
|
|
965
|
+
if key == "model" and val:
|
|
966
|
+
out["fallback_chain"].append({"model": val})
|
|
967
|
+
continue
|
|
968
|
+
|
|
969
|
+
if in_fallback and indent <= fallback_indent and not content.startswith("- "):
|
|
788
970
|
# Exited the fallback block.
|
|
789
971
|
in_fallback = False
|
|
790
972
|
return out
|
|
@@ -817,29 +999,41 @@ def _read_model_block(profile: str) -> dict[str, Any]:
|
|
|
817
999
|
return block
|
|
818
1000
|
|
|
819
1001
|
|
|
820
|
-
def _sync_runtime_model_from_template(
|
|
821
|
-
|
|
1002
|
+
def _sync_runtime_model_from_template(
|
|
1003
|
+
runtime: str,
|
|
1004
|
+
template: str,
|
|
1005
|
+
*,
|
|
1006
|
+
user_id: str = "",
|
|
1007
|
+
) -> tuple[bool, str]:
|
|
1008
|
+
"""Keep a credential-isolated runtime aligned with its agent template."""
|
|
822
1009
|
runtime_slug = _srv().safe_profile_slug(runtime)
|
|
823
1010
|
template_slug = _srv().resolve_validated_profile(template)
|
|
824
1011
|
if not _srv()._is_runtime_profile_slug(runtime_slug) or not _srv().profile_exists(runtime_slug):
|
|
825
|
-
return
|
|
1012
|
+
return False, "runtime profile not installed"
|
|
826
1013
|
tblock = _parse_model_block_from_disk(template_slug)
|
|
827
1014
|
default = str(tblock.get("default") or "").strip()
|
|
828
1015
|
if not default:
|
|
829
|
-
return
|
|
830
|
-
|
|
831
|
-
billing =
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1016
|
+
return False, "agent template has no model"
|
|
1017
|
+
configured_provider = str(tblock.get("provider") or "").strip()
|
|
1018
|
+
billing = _billing_provider_from_block(
|
|
1019
|
+
str(tblock.get("base_url") or ""),
|
|
1020
|
+
configured_provider,
|
|
1021
|
+
configured_provider,
|
|
1022
|
+
)
|
|
1023
|
+
ok, err = _apply_model_persisted(
|
|
1024
|
+
runtime_slug,
|
|
1025
|
+
billing,
|
|
1026
|
+
default,
|
|
1027
|
+
user_id=str(user_id or "").strip(),
|
|
1028
|
+
restart_gateway=False,
|
|
1029
|
+
)
|
|
1030
|
+
if not ok:
|
|
1031
|
+
return False, err
|
|
840
1032
|
chain = [e for e in (tblock.get("fallback_chain") or []) if isinstance(e, dict)]
|
|
841
|
-
|
|
842
|
-
|
|
1033
|
+
ok, err = _write_fallback_chain(runtime_slug, chain)
|
|
1034
|
+
if not ok:
|
|
1035
|
+
return False, err
|
|
1036
|
+
return True, ""
|
|
843
1037
|
|
|
844
1038
|
|
|
845
1039
|
def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bool, str]:
|
|
@@ -887,19 +1081,36 @@ def hermes_models(
|
|
|
887
1081
|
and str(spec.get("id")).lower() in {n.lower() for n in connected}
|
|
888
1082
|
for spec in _srv().PROVIDER_CONNECT_CATALOG
|
|
889
1083
|
)
|
|
890
|
-
connected_llm = picker_llm if user_id else {
|
|
1084
|
+
connected_llm = set(picker_llm) if user_id else {
|
|
891
1085
|
str(spec["id"]).lower()
|
|
892
1086
|
for spec in _srv().PROVIDER_CONNECT_CATALOG
|
|
893
1087
|
if str(spec.get("category") or "") == "llm"
|
|
894
1088
|
and str(spec.get("id")).lower() in {n.lower() for n in connected}
|
|
895
1089
|
}
|
|
1090
|
+
if user_id and workspace_id:
|
|
1091
|
+
connected_llm.update(
|
|
1092
|
+
str(spec["id"]).lower()
|
|
1093
|
+
for spec in _srv().PROVIDER_CONNECT_CATALOG
|
|
1094
|
+
if str(spec.get("category") or "") == "llm"
|
|
1095
|
+
and _srv()._user_can_use_llm(user_id, workspace_id, str(spec["id"]))
|
|
1096
|
+
)
|
|
1097
|
+
live_suggestions, catalog_status = (
|
|
1098
|
+
_live_suggestions_for_connected_llm_providers(user_id, workspace_id, connected_llm)
|
|
1099
|
+
if user_id and connected_llm
|
|
1100
|
+
else ([], {})
|
|
1101
|
+
)
|
|
896
1102
|
|
|
897
1103
|
if selection_only:
|
|
898
1104
|
user_primary, user_chain = user_prefs.read_user_llm_prefs(user_id) if user_id else ("", [])
|
|
899
|
-
primary = user_primary
|
|
1105
|
+
primary = user_primary
|
|
900
1106
|
billing = _resolve_billing_provider_for_model(primary, connected_llm) if primary else ""
|
|
1107
|
+
if primary and not billing:
|
|
1108
|
+
primary = ""
|
|
1109
|
+
if not primary and has_llm:
|
|
1110
|
+
primary, billing = _default_model_from_live_catalog(connected_llm, live_suggestions)
|
|
1111
|
+
user_chain = _effective_fallback_chain(user_chain, connected_llm)
|
|
901
1112
|
suggestions = _augment_model_suggestions(
|
|
902
|
-
|
|
1113
|
+
live_suggestions,
|
|
903
1114
|
{
|
|
904
1115
|
"default": primary,
|
|
905
1116
|
"provider": billing or "",
|
|
@@ -916,7 +1127,8 @@ def hermes_models(
|
|
|
916
1127
|
"base_url": "",
|
|
917
1128
|
"fallback_chain": user_chain if has_llm else [],
|
|
918
1129
|
"suggestions": suggestions,
|
|
919
|
-
"connected_providers": sorted(
|
|
1130
|
+
"connected_providers": sorted(connected_llm),
|
|
1131
|
+
"catalog_status": catalog_status,
|
|
920
1132
|
"has_llm_provider": has_llm,
|
|
921
1133
|
"billing_provider": billing if has_llm else "",
|
|
922
1134
|
"selection_only": True,
|
|
@@ -935,42 +1147,34 @@ def hermes_models(
|
|
|
935
1147
|
"providers": {}, "fallback_chain": [],
|
|
936
1148
|
}
|
|
937
1149
|
active_provider = block.get("provider", "").strip()
|
|
938
|
-
suggestions = _augment_model_suggestions(
|
|
939
|
-
_suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
|
|
940
|
-
block,
|
|
941
|
-
)
|
|
942
1150
|
primary_model = str(block.get("default") or "").strip()
|
|
943
|
-
billing =
|
|
1151
|
+
billing = _billing_provider_from_block(
|
|
1152
|
+
str(block.get("base_url") or ""),
|
|
1153
|
+
active_provider,
|
|
1154
|
+
active_provider,
|
|
1155
|
+
)
|
|
944
1156
|
if not billing and primary_model:
|
|
945
1157
|
billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
|
|
1158
|
+
if not billing and user_id and primary_profile:
|
|
1159
|
+
billing = _llm_billing_provider(
|
|
1160
|
+
primary_profile, user_id=user_id, workspace_id=workspace_id,
|
|
1161
|
+
)
|
|
1162
|
+
suggestions = _augment_model_suggestions(
|
|
1163
|
+
live_suggestions,
|
|
1164
|
+
{**block, "provider": billing or active_provider},
|
|
1165
|
+
)
|
|
946
1166
|
_, bare_primary = _model_id_vendor_and_bare(primary_model)
|
|
947
1167
|
if _srv()._oauth_llm_provider_spec(billing) and bare_primary:
|
|
948
1168
|
primary_model = bare_primary
|
|
949
|
-
|
|
950
|
-
if user_id and "openrouter" in connected_llm and billing in ("", "openrouter"):
|
|
951
|
-
resolved = _srv()._resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
|
|
952
|
-
secret = _srv()._credential_secret(resolved or {}, user_id) if resolved else ""
|
|
953
|
-
live = openrouter_catalog.live_suggestions(secret, limit=30, timeout=8)
|
|
954
|
-
if live:
|
|
955
|
-
seen = {str(r.get("model") or "") for r in suggestions}
|
|
956
|
-
suggestions = [
|
|
957
|
-
{**row, "label": f"Live · {row.get('label', row.get('model', ''))}"}
|
|
958
|
-
for row in live
|
|
959
|
-
if str(row.get("model") or "") not in seen
|
|
960
|
-
] + suggestions
|
|
961
|
-
seen_models: set[str] = set()
|
|
1169
|
+
seen_models: set[tuple[str, str]] = set()
|
|
962
1170
|
deduped: list[dict[str, str]] = []
|
|
963
1171
|
for row in suggestions:
|
|
964
|
-
|
|
965
|
-
if not
|
|
1172
|
+
key = _model_suggestion_key(row)
|
|
1173
|
+
if not key[1] or key in seen_models:
|
|
966
1174
|
continue
|
|
967
|
-
seen_models.add(
|
|
1175
|
+
seen_models.add(key)
|
|
968
1176
|
deduped.append(row)
|
|
969
1177
|
suggestions = deduped
|
|
970
|
-
if not billing and user_id and primary_profile:
|
|
971
|
-
billing = _llm_billing_provider(
|
|
972
|
-
primary_profile, user_id=user_id, workspace_id=workspace_id,
|
|
973
|
-
)
|
|
974
1178
|
display_provider = billing
|
|
975
1179
|
return {
|
|
976
1180
|
"ok": True,
|
|
@@ -978,9 +1182,10 @@ def hermes_models(
|
|
|
978
1182
|
"primary": primary_model if has_llm else "",
|
|
979
1183
|
"provider": display_provider if has_llm else "",
|
|
980
1184
|
"base_url": block.get("base_url", ""),
|
|
981
|
-
"fallback_chain": block
|
|
1185
|
+
"fallback_chain": _normalized_profile_fallback_chain(block, billing) if has_llm else [],
|
|
982
1186
|
"suggestions": suggestions,
|
|
983
|
-
"connected_providers": sorted(
|
|
1187
|
+
"connected_providers": sorted(connected_llm),
|
|
1188
|
+
"catalog_status": catalog_status,
|
|
984
1189
|
"has_llm_provider": has_llm,
|
|
985
1190
|
"billing_provider": billing if has_llm else "",
|
|
986
1191
|
"default_primary": HERMES_DEFAULT_PRIMARY,
|
|
@@ -1041,7 +1246,10 @@ def hermes_model_set(
|
|
|
1041
1246
|
"billing_provider": billing or "",
|
|
1042
1247
|
"selection_only": True,
|
|
1043
1248
|
}
|
|
1044
|
-
|
|
1249
|
+
try:
|
|
1250
|
+
target = _agent_model_profile(profile)
|
|
1251
|
+
except ValueError as exc:
|
|
1252
|
+
return {"ok": False, "error": str(exc)}
|
|
1045
1253
|
if not target:
|
|
1046
1254
|
return {"ok": False, "error": "no profile resolved"}
|
|
1047
1255
|
if not model_id or " " in model_id or "\n" in model_id:
|
|
@@ -1065,30 +1273,30 @@ def hermes_model_set(
|
|
|
1065
1273
|
)
|
|
1066
1274
|
if not ok:
|
|
1067
1275
|
return {"ok": False, "error": err}
|
|
1068
|
-
tpl = _srv().safe_profile_slug(target)
|
|
1069
|
-
ok, err = _mirror_template_model_to_runtime(user, tpl, billing, model_id)
|
|
1070
|
-
if not ok:
|
|
1071
|
-
return {"ok": False, "error": err}
|
|
1072
|
-
reload_prof = ""
|
|
1073
|
-
if _srv()._is_runtime_profile_slug(target):
|
|
1074
|
-
reload_prof = target
|
|
1075
|
-
elif user:
|
|
1076
|
-
runtime = _srv()._runtime_profile_slug(user, tpl)
|
|
1077
|
-
if _srv()._runtime_profile_on_disk(runtime):
|
|
1078
|
-
reload_prof = runtime
|
|
1079
|
-
if reload_prof:
|
|
1080
|
-
_srv()._schedule_gateway_reload(reload_prof)
|
|
1081
1276
|
block = _read_model_block(target)
|
|
1082
1277
|
resolved_billing = billing or _billing_provider_id_from_hermes_config(
|
|
1083
1278
|
str(block.get("provider") or "")
|
|
1084
1279
|
) or _llm_billing_provider(target, user_id=user, workspace_id=ws)
|
|
1085
1280
|
persisted = _persisted_model_id(model_id, billing or resolved_billing)
|
|
1281
|
+
_srv()._sync_agent_profile_db(
|
|
1282
|
+
target,
|
|
1283
|
+
{"model_provider": resolved_billing, "model_name": persisted},
|
|
1284
|
+
)
|
|
1285
|
+
ok, err, synced_runtimes = _sync_agent_model_to_runtimes(
|
|
1286
|
+
target,
|
|
1287
|
+
current_user_id=user,
|
|
1288
|
+
current_workspace_id=ws,
|
|
1289
|
+
)
|
|
1290
|
+
if not ok:
|
|
1291
|
+
return {"ok": False, "error": err}
|
|
1292
|
+
_srv()._schedule_gateway_reload(target)
|
|
1086
1293
|
return {
|
|
1087
1294
|
"ok": True,
|
|
1088
1295
|
"profile": target,
|
|
1089
1296
|
"model": persisted,
|
|
1090
1297
|
"provider": resolved_billing,
|
|
1091
1298
|
"billing_provider": resolved_billing,
|
|
1299
|
+
"synced_runtimes": synced_runtimes,
|
|
1092
1300
|
}
|
|
1093
1301
|
|
|
1094
1302
|
|
|
@@ -1098,6 +1306,7 @@ def hermes_fallback_chain_set(
|
|
|
1098
1306
|
*,
|
|
1099
1307
|
selection_only: bool = False,
|
|
1100
1308
|
user_id: str = "",
|
|
1309
|
+
workspace_id: str = "",
|
|
1101
1310
|
) -> dict[str, Any]:
|
|
1102
1311
|
"""Set the fallback chain for a profile. Each entry must have
|
|
1103
1312
|
`provider` and `model` keys. Order matters: Hermes tries them in
|
|
@@ -1121,19 +1330,26 @@ def hermes_fallback_chain_set(
|
|
|
1121
1330
|
return {"ok": False, "error": "unauthorized"}
|
|
1122
1331
|
user_prefs.write_user_llm_prefs(user, fallback_chain=normalized)
|
|
1123
1332
|
return {"ok": True, "profile": "", "fallback_chain": normalized, "selection_only": True}
|
|
1124
|
-
|
|
1333
|
+
try:
|
|
1334
|
+
target = _agent_model_profile(profile)
|
|
1335
|
+
except ValueError as exc:
|
|
1336
|
+
return {"ok": False, "error": str(exc)}
|
|
1125
1337
|
if not target:
|
|
1126
1338
|
return {"ok": False, "error": "no profile resolved"}
|
|
1127
1339
|
ok, err = _write_fallback_chain(target, normalized)
|
|
1128
1340
|
if not ok:
|
|
1129
1341
|
return {"ok": False, "error": err}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1342
|
+
ok, err, synced_runtimes = _sync_agent_model_to_runtimes(
|
|
1343
|
+
target,
|
|
1344
|
+
current_user_id=str(user_id or "").strip(),
|
|
1345
|
+
current_workspace_id=str(workspace_id or "").strip(),
|
|
1346
|
+
)
|
|
1347
|
+
if not ok:
|
|
1348
|
+
return {"ok": False, "error": err}
|
|
1349
|
+
_srv()._schedule_gateway_reload(target)
|
|
1350
|
+
return {
|
|
1351
|
+
"ok": True,
|
|
1352
|
+
"profile": target,
|
|
1353
|
+
"fallback_chain": normalized,
|
|
1354
|
+
"synced_runtimes": synced_runtimes,
|
|
1355
|
+
}
|