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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared agent identity vs per-user runtime proxy regression checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
API_DIR = Path(__file__).resolve().parent
|
|
12
|
+
if str(API_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(API_DIR))
|
|
14
|
+
|
|
15
|
+
os.environ.setdefault("WORKFRAME_API_DATA_DIR", str(API_DIR / ".tmp-test-data"))
|
|
16
|
+
os.environ.setdefault("HERMES_DATA", str(API_DIR / ".tmp-test-hermes"))
|
|
17
|
+
os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
|
|
18
|
+
|
|
19
|
+
import crew_registry # noqa: E402
|
|
20
|
+
import hermes_profiles # noqa: E402
|
|
21
|
+
import runtime_cohort # noqa: E402
|
|
22
|
+
import server # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_runtime_display_name_comes_from_shared_agent() -> None:
|
|
26
|
+
with (
|
|
27
|
+
patch.object(server, "_agent_db_display_name", return_value="Shared Architect"),
|
|
28
|
+
patch.object(server, "_agent_registry_row", return_value={"display_name": "Template Architect"}),
|
|
29
|
+
):
|
|
30
|
+
assert (
|
|
31
|
+
runtime_cohort._runtime_display_label("bob-user", "architect", "workspace-1")
|
|
32
|
+
== "Shared Architect"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_native_display_name_comes_from_shared_agent() -> None:
|
|
37
|
+
with (
|
|
38
|
+
patch.object(server, "NATIVE_PROFILE", "workframe-agent"),
|
|
39
|
+
patch.object(hermes_profiles, "_agent_db_display_name", return_value="Shared Concierge"),
|
|
40
|
+
):
|
|
41
|
+
assert hermes_profiles._native_display_name() == "Shared Concierge"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_runtime_identity_fields_ignore_runtime_registry_metadata() -> None:
|
|
45
|
+
def registry_row(slug: str) -> dict[str, str]:
|
|
46
|
+
if slug.startswith("u-"):
|
|
47
|
+
return {"display_name": "Bob's Clone", "tagline": "Runtime drift"}
|
|
48
|
+
return {"display_name": "Shared Agent", "tagline": "Workspace identity", "role": "Architect"}
|
|
49
|
+
|
|
50
|
+
with (
|
|
51
|
+
patch.object(crew_registry, "_agent_registry_row", side_effect=registry_row),
|
|
52
|
+
patch.object(crew_registry, "_workspace_agent_identities", return_value={}),
|
|
53
|
+
patch.object(server, "_profile_display_name", return_value="Fallback"),
|
|
54
|
+
patch.object(server, "_profile_role", return_value="Fallback role"),
|
|
55
|
+
patch.object(server, "_resolve_avatar_fields", side_effect=lambda row: row),
|
|
56
|
+
):
|
|
57
|
+
identity = crew_registry._agent_identity_fields(
|
|
58
|
+
"u-bob-user-architect",
|
|
59
|
+
"workspace-1",
|
|
60
|
+
"bob-user",
|
|
61
|
+
)
|
|
62
|
+
assert identity["display_name"] == "Shared Agent"
|
|
63
|
+
assert identity["tagline"] == "Workspace identity"
|
|
64
|
+
assert identity["role"] == "Architect"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_runtime_identity_sync_preserves_credentials() -> None:
|
|
68
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
69
|
+
root = Path(tmp)
|
|
70
|
+
template_dir = root / "architect"
|
|
71
|
+
runtime_dir = root / "u-bob-architect"
|
|
72
|
+
template_dir.mkdir()
|
|
73
|
+
runtime_dir.mkdir()
|
|
74
|
+
(template_dir / "SOUL.md").write_text("shared soul\n", encoding="utf-8")
|
|
75
|
+
(template_dir / "AGENTS.md").write_text("shared agents\n", encoding="utf-8")
|
|
76
|
+
(runtime_dir / "SOUL.md").write_text("stale runtime soul\n", encoding="utf-8")
|
|
77
|
+
(runtime_dir / "AGENTS.md").write_text("stale runtime agents\n", encoding="utf-8")
|
|
78
|
+
(runtime_dir / ".env").write_text("OPENROUTER_API_KEY=user-secret\n", encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
with (
|
|
81
|
+
patch.object(server, "_profile_dir", side_effect=lambda slug: root / slug),
|
|
82
|
+
patch.object(runtime_cohort, "_ensure_profile_terminal_cwd", return_value=None),
|
|
83
|
+
):
|
|
84
|
+
runtime_cohort._backfill_runtime_identity("u-bob-architect", "architect")
|
|
85
|
+
|
|
86
|
+
assert (runtime_dir / "SOUL.md").read_text(encoding="utf-8") == "shared soul\n"
|
|
87
|
+
assert (runtime_dir / "AGENTS.md").read_text(encoding="utf-8") == "shared agents\n"
|
|
88
|
+
assert (runtime_dir / ".env").read_text(encoding="utf-8") == "OPENROUTER_API_KEY=user-secret\n"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
test_runtime_display_name_comes_from_shared_agent()
|
|
93
|
+
test_native_display_name_comes_from_shared_agent()
|
|
94
|
+
test_runtime_identity_fields_ignore_runtime_registry_metadata()
|
|
95
|
+
test_runtime_identity_sync_preserves_credentials()
|
|
96
|
+
print("test_agent_identity_authority: ok")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Historical chat badges use completed run evidence, not the current model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
import chat_sessions
|
|
8
|
+
import server
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_session_run_attributions_preserve_model_switch_history(tmp_path, monkeypatch) -> None:
|
|
12
|
+
db_path = tmp_path / "workframe.db"
|
|
13
|
+
conn = sqlite3.connect(db_path)
|
|
14
|
+
conn.executescript(
|
|
15
|
+
"""
|
|
16
|
+
CREATE TABLE runs (
|
|
17
|
+
run_id TEXT PRIMARY KEY,
|
|
18
|
+
session_id TEXT,
|
|
19
|
+
profile_slug TEXT,
|
|
20
|
+
status TEXT,
|
|
21
|
+
created_at TEXT
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE run_line_items (
|
|
24
|
+
line_item_id TEXT PRIMARY KEY,
|
|
25
|
+
run_id TEXT,
|
|
26
|
+
kind TEXT,
|
|
27
|
+
model TEXT,
|
|
28
|
+
provider TEXT,
|
|
29
|
+
created_at TEXT
|
|
30
|
+
);
|
|
31
|
+
""",
|
|
32
|
+
)
|
|
33
|
+
conn.executemany(
|
|
34
|
+
"INSERT INTO runs VALUES (?,?,?,?,?)",
|
|
35
|
+
[
|
|
36
|
+
("r1", "session-1", "u-user-agent", "completed", "2026-01-01T00:00:01Z"),
|
|
37
|
+
("r2", "session-1", "u-user-agent", "completed", "2026-01-01T00:00:02Z"),
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
conn.executemany(
|
|
41
|
+
"INSERT INTO run_line_items VALUES (?,?,?,?,?,?)",
|
|
42
|
+
[
|
|
43
|
+
("l1", "r1", "llm_turn", "openrouter/auto", "openrouter", "2026-01-01T00:00:01Z"),
|
|
44
|
+
("l2", "r2", "llm_turn", "google/gemini-2.5-flash", "openrouter", "2026-01-01T00:00:02Z"),
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
conn.commit()
|
|
48
|
+
conn.close()
|
|
49
|
+
|
|
50
|
+
def _connect():
|
|
51
|
+
db = sqlite3.connect(db_path)
|
|
52
|
+
db.row_factory = sqlite3.Row
|
|
53
|
+
return db
|
|
54
|
+
|
|
55
|
+
monkeypatch.setattr(server, "_workframe_db", _connect)
|
|
56
|
+
assert chat_sessions._session_run_attributions("u-user-agent", "session-1") == [
|
|
57
|
+
("openrouter/auto", "openrouter"),
|
|
58
|
+
("google/gemini-2.5-flash", "openrouter"),
|
|
59
|
+
]
|
|
@@ -19,7 +19,7 @@ os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
|
|
|
19
19
|
import server # noqa: E402
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def test_enrich_room_messages_preserve_turn_attribution(tmp_path: Path) -> None:
|
|
23
23
|
db = tmp_path / "workframe.db"
|
|
24
24
|
conn = sqlite3.connect(db)
|
|
25
25
|
conn.row_factory = sqlite3.Row
|
|
@@ -58,7 +58,8 @@ def test_enrich_room_messages_with_agent_sender(tmp_path: Path) -> None:
|
|
|
58
58
|
content, content_type, metadata, is_edited, created_at, updated_at, deleted_at
|
|
59
59
|
) VALUES (
|
|
60
60
|
'{msg_id}', '{room_id}', NULL, '{agent_id}', NULL,
|
|
61
|
-
'Hello!', 'text', '{{
|
|
61
|
+
'Hello!', 'text', '{{"model":"google/gemini-2.5-flash","llm_provider":"openrouter"}}',
|
|
62
|
+
0, '{now}', '{now}', NULL
|
|
62
63
|
);
|
|
63
64
|
"""
|
|
64
65
|
)
|
|
@@ -70,3 +71,43 @@ def test_enrich_room_messages_with_agent_sender(tmp_path: Path) -> None:
|
|
|
70
71
|
assert enriched[0]["content"] == "Hello!"
|
|
71
72
|
assert enriched[0]["sender_agent_slug"] == "test-agent"
|
|
72
73
|
assert enriched[0]["sender_agent_name"] == "Test Agent"
|
|
74
|
+
assert enriched[0]["model"] == "google/gemini-2.5-flash"
|
|
75
|
+
assert enriched[0]["llm_provider"] == "openrouter"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_enrich_room_messages_does_not_relabel_legacy_message(tmp_path: Path) -> None:
|
|
79
|
+
db = tmp_path / "workframe.db"
|
|
80
|
+
conn = sqlite3.connect(db)
|
|
81
|
+
conn.row_factory = sqlite3.Row
|
|
82
|
+
conn.executescript(
|
|
83
|
+
"""
|
|
84
|
+
CREATE TABLE agent_profiles (
|
|
85
|
+
id TEXT PRIMARY KEY, slug TEXT, display_name TEXT,
|
|
86
|
+
model_name TEXT, model_provider TEXT, deleted_at TEXT
|
|
87
|
+
);
|
|
88
|
+
CREATE TABLE messages (
|
|
89
|
+
id TEXT PRIMARY KEY, room_id TEXT, sender_user_id TEXT,
|
|
90
|
+
sender_agent_id TEXT, parent_message_id TEXT, content TEXT,
|
|
91
|
+
content_type TEXT, metadata TEXT, is_edited INTEGER,
|
|
92
|
+
created_at TEXT, updated_at TEXT, deleted_at TEXT
|
|
93
|
+
);
|
|
94
|
+
INSERT INTO agent_profiles VALUES (
|
|
95
|
+
'agent-1', 'test-agent', 'Test Agent',
|
|
96
|
+
'gpt-5.4-mini', 'codex', NULL
|
|
97
|
+
);
|
|
98
|
+
INSERT INTO messages VALUES (
|
|
99
|
+
'message-1', 'room-1', NULL, 'agent-1', NULL, 'Old reply',
|
|
100
|
+
'text', '{}', 0, '1700000000', '1700000000', NULL
|
|
101
|
+
);
|
|
102
|
+
"""
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
enriched = server._enrich_room_messages(
|
|
106
|
+
conn,
|
|
107
|
+
conn.execute("SELECT * FROM messages").fetchall(),
|
|
108
|
+
)
|
|
109
|
+
conn.close()
|
|
110
|
+
|
|
111
|
+
assert enriched[0]["sender_agent_name"] == "Test Agent"
|
|
112
|
+
assert "model" not in enriched[0]
|
|
113
|
+
assert "llm_provider" not in enriched[0]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Regression coverage for compressed upstream LLM responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import credential_broker
|
|
6
|
+
import llm_proxy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_upstream_request_forces_identity_encoding(monkeypatch) -> None:
|
|
10
|
+
monkeypatch.setattr(
|
|
11
|
+
credential_broker,
|
|
12
|
+
"authorize_broker_lease",
|
|
13
|
+
lambda *args, **kwargs: credential_broker.BrokerLeaseAuth(ok=True, secret="test-secret"),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
request, error = llm_proxy._build_upstream_request(
|
|
17
|
+
"openrouter",
|
|
18
|
+
"/chat/completions",
|
|
19
|
+
"POST",
|
|
20
|
+
{
|
|
21
|
+
"Authorization": "Bearer runtime-llm:test",
|
|
22
|
+
"Accept-Encoding": "gzip, deflate",
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
},
|
|
25
|
+
b"{}",
|
|
26
|
+
resolve_secret=lambda *_: ("OPENROUTER_API_KEY", "unused"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
assert error is None
|
|
30
|
+
assert request is not None
|
|
31
|
+
headers = {key.lower(): value for key, value in request.header_items()}
|
|
32
|
+
assert headers["accept-encoding"] == "identity"
|
|
33
|
+
assert headers["authorization"] == "Bearer test-secret"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import db_schema
|
|
8
|
+
import message_reactions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def reaction_db() -> sqlite3.Connection:
|
|
12
|
+
conn = sqlite3.connect(":memory:")
|
|
13
|
+
conn.row_factory = sqlite3.Row
|
|
14
|
+
conn.execute(
|
|
15
|
+
"CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, description TEXT, applied_at TEXT)"
|
|
16
|
+
)
|
|
17
|
+
conn.execute(
|
|
18
|
+
"CREATE TABLE messages (id TEXT PRIMARY KEY, room_id TEXT, deleted_at TEXT)"
|
|
19
|
+
)
|
|
20
|
+
conn.execute(
|
|
21
|
+
"CREATE TABLE users (id TEXT PRIMARY KEY, display_name TEXT, avatar_url TEXT)"
|
|
22
|
+
)
|
|
23
|
+
# Run-ledger already owns migration 14. This regression marker ensures
|
|
24
|
+
# reactions still migrate on installs that have that older row.
|
|
25
|
+
conn.execute(
|
|
26
|
+
"INSERT INTO schema_migrations VALUES ('14', 'runs, run_events, run_line_items ledger tables', 'now')"
|
|
27
|
+
)
|
|
28
|
+
db_schema._migrate_v15_message_reactions(conn)
|
|
29
|
+
assert conn.execute(
|
|
30
|
+
"SELECT 1 FROM schema_migrations WHERE version = '15'"
|
|
31
|
+
).fetchone()
|
|
32
|
+
conn.execute("INSERT INTO messages (id, room_id, deleted_at) VALUES ('message-1', 'room-1', NULL)")
|
|
33
|
+
conn.executemany(
|
|
34
|
+
"INSERT INTO users (id, display_name, avatar_url) VALUES (?, ?, ?)",
|
|
35
|
+
[
|
|
36
|
+
("user-1", "Alan", "/avatars/alan.png"),
|
|
37
|
+
("user-2", "Grace", None),
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
conn.commit()
|
|
41
|
+
return conn
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def can_access_room(_conn: sqlite3.Connection, room_id: str, user_id: str) -> bool:
|
|
45
|
+
return room_id == "room-1" and user_id == "user-1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_room_reaction_toggles_and_aggregates() -> None:
|
|
49
|
+
conn = reaction_db()
|
|
50
|
+
try:
|
|
51
|
+
first = message_reactions.toggle_reaction(
|
|
52
|
+
conn, "room:room-1", "message-1", "👍", "user-1", can_access_room
|
|
53
|
+
)
|
|
54
|
+
assert first["reacted"] is True
|
|
55
|
+
assert first["reactions"] == [
|
|
56
|
+
{
|
|
57
|
+
"message_id": "message-1",
|
|
58
|
+
"emoji": "👍",
|
|
59
|
+
"count": 1,
|
|
60
|
+
"reacted": True,
|
|
61
|
+
"reactors": [
|
|
62
|
+
{
|
|
63
|
+
"user_id": "user-1",
|
|
64
|
+
"display_name": "Alan",
|
|
65
|
+
"avatar_url": "/avatars/alan.png",
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
second = message_reactions.toggle_reaction(
|
|
72
|
+
conn, "room:room-1", "message-1", "👍", "user-1", can_access_room
|
|
73
|
+
)
|
|
74
|
+
assert second["reacted"] is False
|
|
75
|
+
assert second["reactions"] == []
|
|
76
|
+
finally:
|
|
77
|
+
conn.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_room_reactions_include_people_for_hover_details() -> None:
|
|
81
|
+
conn = reaction_db()
|
|
82
|
+
try:
|
|
83
|
+
message_reactions.toggle_reaction(
|
|
84
|
+
conn, "room:room-1", "message-1", "🎉", "user-1", can_access_room
|
|
85
|
+
)
|
|
86
|
+
conn.execute(
|
|
87
|
+
"""
|
|
88
|
+
INSERT INTO message_reactions (scope_key, message_id, user_id, emoji, created_at)
|
|
89
|
+
VALUES ('room:room-1', 'message-1', 'user-2', '🎉', '2')
|
|
90
|
+
"""
|
|
91
|
+
)
|
|
92
|
+
conn.commit()
|
|
93
|
+
|
|
94
|
+
listed = message_reactions.list_reactions(
|
|
95
|
+
conn, "room:room-1", "user-1", can_access_room
|
|
96
|
+
)
|
|
97
|
+
assert listed["reactions"] == [
|
|
98
|
+
{
|
|
99
|
+
"message_id": "message-1",
|
|
100
|
+
"emoji": "🎉",
|
|
101
|
+
"count": 2,
|
|
102
|
+
"reacted": True,
|
|
103
|
+
"reactors": [
|
|
104
|
+
{
|
|
105
|
+
"user_id": "user-1",
|
|
106
|
+
"display_name": "Alan",
|
|
107
|
+
"avatar_url": "/avatars/alan.png",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"user_id": "user-2",
|
|
111
|
+
"display_name": "Grace",
|
|
112
|
+
"avatar_url": None,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
finally:
|
|
118
|
+
conn.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_room_reactions_require_room_access_and_existing_message() -> None:
|
|
122
|
+
conn = reaction_db()
|
|
123
|
+
try:
|
|
124
|
+
with pytest.raises(PermissionError):
|
|
125
|
+
message_reactions.list_reactions(
|
|
126
|
+
conn, "room:room-1", "user-2", can_access_room
|
|
127
|
+
)
|
|
128
|
+
with pytest.raises(ValueError, match="message_not_found"):
|
|
129
|
+
message_reactions.toggle_reaction(
|
|
130
|
+
conn, "room:room-1", "missing", "🔥", "user-1", can_access_room
|
|
131
|
+
)
|
|
132
|
+
finally:
|
|
133
|
+
conn.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_dm_reactions_are_namespaced_by_authenticated_user() -> None:
|
|
137
|
+
conn = reaction_db()
|
|
138
|
+
try:
|
|
139
|
+
message_reactions.toggle_reaction(
|
|
140
|
+
conn, "dm:agent:session-1", "external-message", "✅", "user-1", can_access_room
|
|
141
|
+
)
|
|
142
|
+
mine = message_reactions.list_reactions(
|
|
143
|
+
conn, "dm:agent:session-1", "user-1", can_access_room
|
|
144
|
+
)
|
|
145
|
+
theirs = message_reactions.list_reactions(
|
|
146
|
+
conn, "dm:agent:session-1", "user-2", can_access_room
|
|
147
|
+
)
|
|
148
|
+
assert mine["reactions"][0]["count"] == 1
|
|
149
|
+
assert theirs["reactions"] == []
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Provider/model routing repairs for vault-backed API-key providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
API_DIR = Path(__file__).resolve().parent
|
|
13
|
+
if str(API_DIR) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(API_DIR))
|
|
15
|
+
|
|
16
|
+
import server # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def routing_data_dir():
|
|
21
|
+
with tempfile.TemporaryDirectory(prefix="wf-routing-") as tmp:
|
|
22
|
+
yield Path(tmp)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_native_openrouter_config_is_reconciled_to_internal_proxy(
|
|
26
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
27
|
+
routing_data_dir: Path,
|
|
28
|
+
) -> None:
|
|
29
|
+
tmp_path = routing_data_dir
|
|
30
|
+
profile = "dogfood-agent"
|
|
31
|
+
profile_dir = tmp_path / "profiles" / profile
|
|
32
|
+
profile_dir.mkdir(parents=True)
|
|
33
|
+
config_path = profile_dir / "config.yaml"
|
|
34
|
+
config_path.write_text(
|
|
35
|
+
"model:\n"
|
|
36
|
+
" default: google/gemini-2.5-flash\n"
|
|
37
|
+
" provider: openrouter\n"
|
|
38
|
+
" base_url: https://openrouter.ai/api/v1\n",
|
|
39
|
+
encoding="utf-8",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(server, "HERMES_DATA", tmp_path)
|
|
43
|
+
monkeypatch.setattr(server, "NATIVE_PROFILE", profile)
|
|
44
|
+
monkeypatch.setattr(server, "WORKFRAME_LLM_PROXY_INTERNAL", "http://workframe-api:8000")
|
|
45
|
+
monkeypatch.setattr(server, "_user_llm_providers_for_picker", lambda _user: {"openrouter"})
|
|
46
|
+
monkeypatch.setattr(server, "_user_can_use_llm", lambda *_args, **_kwargs: True)
|
|
47
|
+
monkeypatch.setattr(server, "_reload_runtime_profile_gateway", lambda *_args, **_kwargs: None)
|
|
48
|
+
|
|
49
|
+
assert server._reconcile_profile_llm_for_user(profile, "user-1", "workspace-1") is True
|
|
50
|
+
|
|
51
|
+
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
52
|
+
assert cfg["model"]["default"] == "google/gemini-2.5-flash"
|
|
53
|
+
assert cfg["model"]["provider"] == "custom"
|
|
54
|
+
assert cfg["model"]["base_url"] == "http://workframe-api:8000/internal/llm/openrouter/v1"
|
|
55
|
+
assert isinstance(cfg["fallback_providers"], list)
|
|
56
|
+
assert cfg["fallback_providers"]
|
|
57
|
+
assert all(entry["provider"] == "custom" for entry in cfg["fallback_providers"])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_native_openrouter_reconcile_is_idempotent(
|
|
61
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
62
|
+
routing_data_dir: Path,
|
|
63
|
+
) -> None:
|
|
64
|
+
tmp_path = routing_data_dir
|
|
65
|
+
profile = "dogfood-agent"
|
|
66
|
+
profile_dir = tmp_path / "profiles" / profile
|
|
67
|
+
profile_dir.mkdir(parents=True)
|
|
68
|
+
config_path = profile_dir / "config.yaml"
|
|
69
|
+
config_path.write_text(
|
|
70
|
+
"model:\n"
|
|
71
|
+
" default: google/gemini-2.5-flash\n"
|
|
72
|
+
" provider: custom\n"
|
|
73
|
+
" base_url: http://workframe-api:8000/internal/llm/openrouter/v1\n"
|
|
74
|
+
"fallback_providers:\n"
|
|
75
|
+
" - provider: custom\n"
|
|
76
|
+
" model: meta-llama/llama-3.3-70b-instruct:free\n",
|
|
77
|
+
encoding="utf-8",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
monkeypatch.setattr(server, "HERMES_DATA", tmp_path)
|
|
81
|
+
monkeypatch.setattr(server, "NATIVE_PROFILE", profile)
|
|
82
|
+
monkeypatch.setattr(server, "WORKFRAME_LLM_PROXY_INTERNAL", "http://workframe-api:8000")
|
|
83
|
+
monkeypatch.setattr(server, "_user_llm_providers_for_picker", lambda _user: {"openrouter"})
|
|
84
|
+
monkeypatch.setattr(server, "_user_can_use_llm", lambda *_args, **_kwargs: True)
|
|
85
|
+
|
|
86
|
+
assert server._reconcile_profile_llm_for_user(profile, "user-1", "workspace-1") is False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_openrouter_reconcile_accepts_pyyaml_indentless_fallback_sequence(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
91
|
+
routing_data_dir: Path,
|
|
92
|
+
) -> None:
|
|
93
|
+
profile = "dogfood-agent"
|
|
94
|
+
profile_dir = routing_data_dir / "profiles" / profile
|
|
95
|
+
profile_dir.mkdir(parents=True)
|
|
96
|
+
config_path = profile_dir / "config.yaml"
|
|
97
|
+
config_path.write_text(
|
|
98
|
+
"model:\n"
|
|
99
|
+
" default: openrouter/auto\n"
|
|
100
|
+
" provider: custom\n"
|
|
101
|
+
" base_url: http://workframe-api:8000/internal/llm/openrouter/v1\n"
|
|
102
|
+
"fallback_providers:\n"
|
|
103
|
+
"- provider: custom\n"
|
|
104
|
+
" model: anthropic/claude-sonnet-4.5\n"
|
|
105
|
+
"- provider: custom\n"
|
|
106
|
+
" model: meta-llama/llama-3.3-70b-instruct:free\n",
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(server, "HERMES_DATA", routing_data_dir)
|
|
111
|
+
monkeypatch.setattr(server, "NATIVE_PROFILE", profile)
|
|
112
|
+
monkeypatch.setattr(server, "WORKFRAME_LLM_PROXY_INTERNAL", "http://workframe-api:8000")
|
|
113
|
+
monkeypatch.setattr(server, "_user_llm_providers_for_picker", lambda _user: {"openrouter"})
|
|
114
|
+
monkeypatch.setattr(server, "_user_can_use_llm", lambda *_args, **_kwargs: True)
|
|
115
|
+
|
|
116
|
+
block = server._read_model_block(profile)
|
|
117
|
+
assert block["fallback_chain"] == [
|
|
118
|
+
{"provider": "custom", "model": "anthropic/claude-sonnet-4.5"},
|
|
119
|
+
{"provider": "custom", "model": "meta-llama/llama-3.3-70b-instruct:free"},
|
|
120
|
+
]
|
|
121
|
+
assert server._profile_llm_proxy_ready(profile, "openrouter") is True
|
|
122
|
+
assert server._reconcile_profile_llm_for_user(profile, "user-1", "workspace-1") is False
|
|
@@ -51,7 +51,7 @@ def _run_checks(server) -> None:
|
|
|
51
51
|
assert applied.get("billing_provider") == "codex"
|
|
52
52
|
assert applied.get("provider") == "codex"
|
|
53
53
|
|
|
54
|
-
# E:
|
|
54
|
+
# E: credential availability cannot silently change an agent-owned model/provider.
|
|
55
55
|
(prof_dir / "config.yaml").write_text(
|
|
56
56
|
"model:\n default: openai-codex/gpt-5.4-mini\n provider: custom\n"
|
|
57
57
|
" base_url: http://workframe-api:8080/internal/llm/openrouter/v1\n",
|
|
@@ -60,19 +60,24 @@ def _run_checks(server) -> None:
|
|
|
60
60
|
_orig_picker = server._user_llm_providers_for_picker
|
|
61
61
|
_orig_can = server._user_can_use_llm
|
|
62
62
|
_orig_sync = server._sync_oauth_llm_to_profile
|
|
63
|
+
_orig_reload = server._reload_runtime_profile_gateway
|
|
63
64
|
server._user_llm_providers_for_picker = lambda uid: {"codex"} if uid == "user-1" else set()
|
|
64
65
|
server._user_can_use_llm = lambda uid, ws, prov: uid == "user-1" and prov == "codex"
|
|
65
66
|
server._sync_oauth_llm_to_profile = lambda *a, **k: None
|
|
67
|
+
server._reload_runtime_profile_gateway = lambda *a, **k: None
|
|
66
68
|
try:
|
|
69
|
+
# Transport repair may add proxy-safe fallback rows, but it must not
|
|
70
|
+
# switch the configured OpenRouter billing provider to Codex.
|
|
67
71
|
assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is True
|
|
68
72
|
block = server._read_model_block(PROF)
|
|
69
|
-
assert block.get("provider") == "
|
|
70
|
-
assert block.get("default") == "gpt-5.4-mini"
|
|
71
|
-
assert
|
|
73
|
+
assert block.get("provider") == "custom"
|
|
74
|
+
assert block.get("default") == "openai-codex/gpt-5.4-mini"
|
|
75
|
+
assert str(block.get("base_url") or "").endswith("/internal/llm/openrouter/v1")
|
|
72
76
|
finally:
|
|
73
77
|
server._user_llm_providers_for_picker = _orig_picker
|
|
74
78
|
server._user_can_use_llm = _orig_can
|
|
75
79
|
server._sync_oauth_llm_to_profile = _orig_sync
|
|
80
|
+
server._reload_runtime_profile_gateway = _orig_reload
|
|
76
81
|
|
|
77
82
|
# F: runtime disk wins over template in _read_model_block
|
|
78
83
|
runtime_prof = "u-test-user-surface-test-agent"
|
|
@@ -85,6 +90,7 @@ def _run_checks(server) -> None:
|
|
|
85
90
|
block = server._read_model_block(runtime_prof)
|
|
86
91
|
assert block.get("default") == "gpt-5.4-mini", block
|
|
87
92
|
assert block.get("provider") == "openai-codex", block
|
|
93
|
+
assert server._resolve_models_profile(runtime_prof) == PROF
|
|
88
94
|
|
|
89
95
|
# G: template model save mirrors to runtime (same model id, not stale template re-read)
|
|
90
96
|
runtime_prof = "u-test-user2-surface-test-agent"
|
|
@@ -98,13 +104,47 @@ def _run_checks(server) -> None:
|
|
|
98
104
|
server._restart_runtime_profile_gateway = lambda *a, **k: None
|
|
99
105
|
try:
|
|
100
106
|
applied = server.hermes_model_set(
|
|
101
|
-
|
|
107
|
+
runtime_prof, "gpt-5.4-mini", user_id="test-user2", workspace_id="", billing_provider="codex",
|
|
102
108
|
)
|
|
103
109
|
finally:
|
|
104
110
|
server._restart_runtime_profile_gateway = _orig_restart
|
|
105
111
|
assert applied.get("ok"), applied
|
|
112
|
+
assert applied.get("profile") == PROF, applied
|
|
106
113
|
runtime_block = server._parse_model_block_from_disk(runtime_prof)
|
|
107
114
|
assert runtime_block.get("default") == "gpt-5.4-mini", runtime_block
|
|
115
|
+
template_block = server._parse_model_block_from_disk(PROF)
|
|
116
|
+
assert template_block.get("default") == "gpt-5.4-mini", template_block
|
|
117
|
+
assert template_block.get("provider") == "openai-codex", template_block
|
|
118
|
+
|
|
119
|
+
# H: missing user credentials deny execution elsewhere; provider resolution
|
|
120
|
+
# still reports the agent's configured Codex provider instead of OpenRouter.
|
|
121
|
+
_orig_picker = server._user_llm_providers_for_picker
|
|
122
|
+
_orig_can = server._user_can_use_llm
|
|
123
|
+
server._user_llm_providers_for_picker = lambda _uid: {"openrouter"}
|
|
124
|
+
server._user_can_use_llm = lambda *_args, **_kwargs: False
|
|
125
|
+
try:
|
|
126
|
+
assert server._llm_billing_provider(PROF, user_id="other-user") == "codex"
|
|
127
|
+
finally:
|
|
128
|
+
server._user_llm_providers_for_picker = _orig_picker
|
|
129
|
+
server._user_can_use_llm = _orig_can
|
|
130
|
+
|
|
131
|
+
# I: picker rows expose the billing provider encoded in the proxy URL,
|
|
132
|
+
# never Hermes' internal ``custom`` transport label.
|
|
133
|
+
proxy_block = {
|
|
134
|
+
"default": "google/gemini-2.5-flash",
|
|
135
|
+
"provider": "custom",
|
|
136
|
+
"base_url": "http://workframe-api:8080/internal/llm/openrouter/v1",
|
|
137
|
+
"fallback_chain": [
|
|
138
|
+
{"provider": "custom", "model": "anthropic/claude-sonnet-4.5"},
|
|
139
|
+
],
|
|
140
|
+
}
|
|
141
|
+
augmented = server._augment_model_suggestions([], proxy_block)
|
|
142
|
+
assert {row.get("billing_provider") for row in augmented} == {"openrouter"}, augmented
|
|
143
|
+
assert augmented[0].get("label") == "gemini-2.5-flash", augmented
|
|
144
|
+
normalized = server._normalized_profile_fallback_chain(proxy_block, "openrouter")
|
|
145
|
+
assert normalized == [
|
|
146
|
+
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.5"},
|
|
147
|
+
]
|
|
108
148
|
|
|
109
149
|
|
|
110
150
|
def _run_self_check() -> None:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import profile_gateway
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_wait_profile_api_health_bypasses_negative_cache(monkeypatch) -> None:
|
|
7
|
+
server = profile_gateway._srv()
|
|
8
|
+
monkeypatch.setattr(server, "resolve_hermes_profile", lambda profile: profile)
|
|
9
|
+
monkeypatch.setattr(profile_gateway.time, "sleep", lambda _delay: None)
|
|
10
|
+
|
|
11
|
+
cache_modes: list[bool] = []
|
|
12
|
+
|
|
13
|
+
def health(_profile: str, **kwargs) -> bool:
|
|
14
|
+
cache_modes.append(bool(kwargs.get("use_cache", True)))
|
|
15
|
+
return len(cache_modes) >= 3
|
|
16
|
+
|
|
17
|
+
monkeypatch.setattr(profile_gateway, "_profile_api_healthy", health)
|
|
18
|
+
|
|
19
|
+
assert profile_gateway._wait_profile_api_healthy("dogfood-agent", attempts=4, delay=0.01)
|
|
20
|
+
assert cache_modes == [False, False, False]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_wait_profile_api_health_stops_after_requested_attempts(monkeypatch) -> None:
|
|
24
|
+
server = profile_gateway._srv()
|
|
25
|
+
monkeypatch.setattr(server, "resolve_hermes_profile", lambda profile: profile)
|
|
26
|
+
sleeps: list[float] = []
|
|
27
|
+
monkeypatch.setattr(profile_gateway.time, "sleep", sleeps.append)
|
|
28
|
+
monkeypatch.setattr(
|
|
29
|
+
profile_gateway,
|
|
30
|
+
"_profile_api_healthy",
|
|
31
|
+
lambda _profile, **kwargs: False,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
assert not profile_gateway._wait_profile_api_healthy(
|
|
35
|
+
"dogfood-agent", attempts=3, delay=0.25,
|
|
36
|
+
)
|
|
37
|
+
assert sleeps == [0.25, 0.25, 0.25]
|