create-workframe 0.1.14 → 0.1.15
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/sync-canonical-to-package.mjs +3 -0
- package/workframe-api/activity_feed.py +53 -5
- package/workframe-api/api_errors.py +126 -0
- package/workframe-api/chat_bind.py +8 -1
- package/workframe-api/chat_stream.py +70 -26
- package/workframe-api/crew_registry.py +3 -6
- package/workframe-api/google_auth.py +20 -12
- package/workframe-api/handler_modules/handler_auth.py +66 -21
- package/workframe-api/handler_modules/handler_chat.py +25 -0
- package/workframe-api/handler_modules/handler_install.py +45 -0
- package/workframe-api/handler_modules/handler_provider.py +1 -0
- package/workframe-api/handler_modules/handler_workspace.py +30 -16
- package/workframe-api/hermes_admin.py +9 -2
- package/workframe-api/hermes_profiles.py +25 -5
- package/workframe-api/install_api.py +38 -4
- package/workframe-api/lane_bindings.py +24 -0
- package/workframe-api/llm_error_glossary.py +58 -4
- package/workframe-api/mention_invoke.py +89 -27
- package/workframe-api/model_surface.py +1 -1
- package/workframe-api/provider_bindings.py +44 -2
- package/workframe-api/provider_bootstrap.py +9 -0
- package/workframe-api/provider_catalog.py +3 -3
- package/workframe-api/rooms.py +3 -0
- package/workframe-api/route_registry.py +1 -0
- package/workframe-api/run_ledger.py +52 -8
- package/workframe-api/runtime_cohort.py +11 -1
- package/workframe-api/server.py +25 -1
- package/workframe-api/stack_config.py +4 -1
- package/workframe-api/test_api_errors.py +33 -0
- package/workframe-api/test_enrich_room_messages.py +72 -0
- package/workframe-api/test_exception_hygiene.py +4 -2
- package/workframe-api/test_hermes_admin.py +7 -0
- package/workframe-api/test_install_wizard_resume.py +39 -13
- package/workframe-api/test_model_surface_consistency.py +118 -100
- package/workframe-api/test_profile_model_yaml.py +4 -1
- package/workframe-api/test_provider_bindings.py +10 -0
- package/workframe-api/test_run_ledger.py +2 -1
- package/workframe-api/test_server_reexports.py +4 -0
- package/workframe-api/test_user_llm_prefs.py +99 -0
- package/workframe-api/time-overlay-split.py +58 -0
- package/workframe-api/time-ttft-quick.py +135 -0
- package/workframe-api/time-ttft.py +250 -0
- package/workframe-api/time-wf-stream.py +145 -0
- package/workframe-api/turn_overlay.py +96 -23
- package/workframe-api/updates.py +1 -2
- package/workframe-api/user_prefs.py +26 -5
- package/workframe-api/workspace_bootstrap.py +16 -10
- package/workframe-ui/public/assets/{arc-BT9iZUSd.js → arc-BARqpbNX.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CYE89P6J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-CaEDd3np.js → architectureDiagram-3BPJPVTR-bbpW6hfw.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DHJhMrIS.js → blockDiagram-GPEHLZMM-aG0oR9eA.js} +1 -1
- package/workframe-ui/public/assets/brave-B885JYnH.svg +11 -0
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Cdyz9hIV.js → c4Diagram-AAUBKEIU-BDi4E5jK.js} +1 -1
- package/workframe-ui/public/assets/channel-B4ex0Z2H.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-BbZx76LX.js → chunk-2J33WTMH-BBU5tRNe.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DcSxjTIP.js → chunk-3OPIFGDE-SKhe5YD8.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-B7F-kTY1.js → chunk-4BX2VUAB-D4uKMCPc.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-BwwEiRTK.js → chunk-55IACEB6-SnqJA0wG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DAi_NksX.js → chunk-5ZQYHXKU-ZyBWNsT2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BIaF4XCN.js → chunk-727SXJPM-DbxwKsiK.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DjYSk1dG.js → chunk-AQP2D5EJ-Bm2iWcL0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-_twnyfgV.js → chunk-BSJP7CBP-CGwyQh1d.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CkNmdy5s.js → chunk-CSCIHK7Q-XWSO96VW.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-CjLqd-gl.js → chunk-FMBD7UC4-D0OTpUqL.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-_6oNqFjj.js → chunk-KSCS5N6A-CM75wJ6j.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CeO9Am_D.js → chunk-L5ZTLDWV-BCgtwaGP.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-sRM4YeIe.js → chunk-LZXEDZCA-CZBc86Td.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-CH8tMb21.js → chunk-ND2GUHAM-DIq9-Qtg.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-DAI1bsII.js → chunk-NZK2D7GU-BTOOFYOR.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-B2e-5a2G.js → chunk-O5CBEL6O-CYriwTot.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-sPK7umZx.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DtLDSmiK.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-DM2-0q37.js → chunk-XPW4576I-6iEhdiBi.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DWLy8pDS.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DWLy8pDS.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-BnbbewLv.js → cose-bilkent-S5V4N54A-C716vjLB.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-C4zmB1Vt.js → dagre-BM42HDAG-Cs-MNVkM.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-n-BJmsiC.js → diagram-2AECGRRQ-BqoyTWyW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzQGQnZi.js → diagram-5GNKFQAL-CDRTV5fY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Cx_De-wS.js → diagram-KO2AKTUF-u9NjixEi.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CIOI3Lok.js → diagram-LMA3HP47-rDd6T2os.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-DkRuPLWY.js → diagram-OG6HWLK6-Bl4VX9RO.js} +1 -1
- package/workframe-ui/public/assets/{dist-XhCT1Q-V.js → dist-DSCcbFgV.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-C3GwhDU1.js → erDiagram-TEJ5UH35-C9Nv-5nl.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-B7kChV1b.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CLWH8z2e.js → flowDiagram-I6XJVG4X-D8Xhbd7s.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-D4h66Vy3.js → ganttDiagram-6RSMTGT7-CMycBZqm.js} +1 -1
- package/workframe-ui/public/assets/gemini-Cq-0-rmr.svg +105 -0
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-IW5F-Q6c.js → gitGraph-WXDBUCRP-DnIro8tZ.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BjNIbmRM.js → gitGraphDiagram-PVQCEYII-B74H9ISZ.js} +1 -1
- package/workframe-ui/public/assets/index-BLVAsAeO.css +1 -0
- package/workframe-ui/public/assets/index-s4167RRh.js +132 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-CcJspM79.js → info-J43DQDTF-B6mVw1B6.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-DP4xR9jt.js → infoDiagram-5YYISTIA-Ddtfq_xy.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-D4cZb21V.js → ishikawaDiagram-YF4QCWOH-DBb6veAL.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DsLuHkIJ.js → journeyDiagram-JHISSGLW-DMgdeS5h.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Wu65nOsR.js → kanban-definition-UN3LZRKU-DiV_LoDN.js} +1 -1
- package/workframe-ui/public/assets/{line-ByBDzRpy.js → line-BR9uEes_.js} +1 -1
- package/workframe-ui/public/assets/{linear-DzXi2vnq.js → linear-BhjkJfy9.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-CaB-1Ckn.js → mermaid-parser.core-CEWtaJIq.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-D7HTHC17.js → mermaid.core-B6xKLKp-.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BfKsC9aj.js → mindmap-definition-RKZ34NQL-DypZm9OU.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-ClpU98TO.js → packet-YPE3B663-CCxb0WDS.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-KeOyY_-u.js → pie-LRSECV5Y-pTkr4g8B.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CUpjnzbs.js → pieDiagram-4H26LBE5-BventgTb.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CJdPTszF.js → quadrantDiagram-W4KKPZXB-BQnvFJYk.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-C9BpandZ.js → radar-GUYGQ44K-EX9Pzmli.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-CHDZPwry.js → requirementDiagram-4Y6WPE33-Bnw4xecM.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CMJJh91s.js → sankeyDiagram-5OEKKPKP-spHh-EYp.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsV4GppP.js → sequenceDiagram-3UESZ5HK-CC97Qcbf.js} +1 -1
- package/workframe-ui/public/assets/{src-WHks82Up.js → src-DRcBNyeZ.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-eSX9P8z0.js → stateDiagram-AJRCARHV-C8r8rx81.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CnedwxVT.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-Dg1UkhGo.js → timeline-definition-PNZ67QCA-CA4JxR_w.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-B7yGsBuj.js → treeView-BLDUP644-BuGSo6xP.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-j_G2oyQ_.js → treemap-LRROVOQU-JyJMArE9.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BRrT0jqC.js → vennDiagram-CIIHVFJN-BZAUo7xL.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-DEdUqez2.js → wardley-L42UT6IY-CMgm8t9M.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-C-bBK9Bb.js → wardleyDiagram-YWT4CUSO-CqI1c2zN.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DhH5xuR2.js → xychartDiagram-2RQKCTM6-DhTdH-jJ.js} +1 -1
- package/workframe-ui/public/index.html +2 -2
- package/workframe-ui/public/workframe-build.json +3 -3
- package/docs/security.md +0 -85
- package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +0 -1
- package/workframe-ui/public/assets/channel-3M69ekE5.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +0 -1
- package/workframe-ui/public/assets/index-DL2vJP4L.css +0 -1
- package/workframe-ui/public/assets/index-ElS_D59P.js +0 -130
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +0 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""api_errors public payload self-check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import urllib.error
|
|
5
|
+
|
|
6
|
+
import api_errors
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
raw = (
|
|
11
|
+
"runtime profile create failed: Traceback (most recent call last):\n"
|
|
12
|
+
' File "/opt/hermes/hermes_cli/profiles.py", line 1\n'
|
|
13
|
+
"PermissionError: [Errno 13] Permission denied: '/opt/data/profiles/u-test-agent'"
|
|
14
|
+
)
|
|
15
|
+
payload = api_errors.public_api_error_payload(ValueError(raw))
|
|
16
|
+
assert payload["error"] == "runtime_profile_permission_denied"
|
|
17
|
+
assert "Traceback" not in payload["message"]
|
|
18
|
+
assert payload["message"]
|
|
19
|
+
assert payload["hint"]
|
|
20
|
+
|
|
21
|
+
assert api_errors.http_status_for_code("no_session") == 401
|
|
22
|
+
assert api_errors.http_status_for_code("room_not_found") == 404
|
|
23
|
+
|
|
24
|
+
refused = api_errors.public_api_error_payload(
|
|
25
|
+
urllib.error.URLError("[Errno 111] Connection refused"),
|
|
26
|
+
)
|
|
27
|
+
assert refused["error"] == "runtime_profile_api_config_failed"
|
|
28
|
+
|
|
29
|
+
print("test_api_errors: ok")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Regression: _enrich_room_messages must not reference undefined _srv in server.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
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 server # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_enrich_room_messages_with_agent_sender(tmp_path: Path) -> None:
|
|
23
|
+
db = tmp_path / "workframe.db"
|
|
24
|
+
conn = sqlite3.connect(db)
|
|
25
|
+
conn.row_factory = sqlite3.Row
|
|
26
|
+
now = "1700000000"
|
|
27
|
+
agent_id = str(uuid.uuid4())
|
|
28
|
+
room_id = str(uuid.uuid4())
|
|
29
|
+
msg_id = str(uuid.uuid4())
|
|
30
|
+
conn.executescript(
|
|
31
|
+
f"""
|
|
32
|
+
CREATE TABLE agent_profiles (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
slug TEXT,
|
|
35
|
+
display_name TEXT,
|
|
36
|
+
model_name TEXT,
|
|
37
|
+
model_provider TEXT,
|
|
38
|
+
deleted_at TEXT
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE messages (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
room_id TEXT,
|
|
43
|
+
sender_user_id TEXT,
|
|
44
|
+
sender_agent_id TEXT,
|
|
45
|
+
parent_message_id TEXT,
|
|
46
|
+
content TEXT,
|
|
47
|
+
content_type TEXT,
|
|
48
|
+
metadata TEXT,
|
|
49
|
+
is_edited INTEGER,
|
|
50
|
+
created_at TEXT,
|
|
51
|
+
updated_at TEXT,
|
|
52
|
+
deleted_at TEXT
|
|
53
|
+
);
|
|
54
|
+
INSERT INTO agent_profiles (id, slug, display_name, model_name, model_provider, deleted_at)
|
|
55
|
+
VALUES ('{agent_id}', 'test-agent', 'Test Agent', 'gpt-4o-mini', 'openai', NULL);
|
|
56
|
+
INSERT INTO messages (
|
|
57
|
+
id, room_id, sender_user_id, sender_agent_id, parent_message_id,
|
|
58
|
+
content, content_type, metadata, is_edited, created_at, updated_at, deleted_at
|
|
59
|
+
) VALUES (
|
|
60
|
+
'{msg_id}', '{room_id}', NULL, '{agent_id}', NULL,
|
|
61
|
+
'Hello!', 'text', '{{}}', 0, '{now}', '{now}', NULL
|
|
62
|
+
);
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
rows = conn.execute("SELECT * FROM messages").fetchall()
|
|
66
|
+
enriched = server._enrich_room_messages(conn, rows)
|
|
67
|
+
conn.close()
|
|
68
|
+
|
|
69
|
+
assert len(enriched) == 1
|
|
70
|
+
assert enriched[0]["content"] == "Hello!"
|
|
71
|
+
assert enriched[0]["sender_agent_slug"] == "test-agent"
|
|
72
|
+
assert enriched[0]["sender_agent_name"] == "Test Agent"
|
|
@@ -4,9 +4,11 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
ROOT = Path(__file__).resolve().parent
|
|
6
6
|
src = (ROOT / "server.py").read_text(encoding="utf-8")
|
|
7
|
+
auth_src = (ROOT / "handler_modules" / "handler_auth.py").read_text(encoding="utf-8")
|
|
8
|
+
admin_src = (ROOT / "handler_modules" / "handler_admin.py").read_text(encoding="utf-8")
|
|
7
9
|
messaging_src = (ROOT / "workspace_messaging.py").read_text(encoding="utf-8")
|
|
8
10
|
assert "_log_handler_error" in src
|
|
9
|
-
assert 'POST /api/auth/start' in
|
|
10
|
-
assert 'POST /api/admin/vault/init' in
|
|
11
|
+
assert 'POST /api/auth/start' in auth_src
|
|
12
|
+
assert 'POST /api/admin/vault/init' in admin_src
|
|
11
13
|
assert "_sync_workspace_messaging_gateway restart" in messaging_src
|
|
12
14
|
print("exception hygiene self-check ok")
|
|
@@ -41,6 +41,12 @@ def test_commands_catalog_shape() -> None:
|
|
|
41
41
|
assert isinstance(catalog["entries"], list)
|
|
42
42
|
assert all(entry.get("wired") is True for entry in catalog["entries"])
|
|
43
43
|
assert not any(entry.get("wip") for entry in catalog["entries"])
|
|
44
|
+
assert not any(entry.get("dispatch") in {"gateway", "client:noOp"} for entry in catalog["entries"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_unproven_gateway_commands_stay_hidden() -> None:
|
|
48
|
+
assert hermes_admin._resolve_command("/retry") is None
|
|
49
|
+
assert hermes_admin._resolve_command("/quit") is None
|
|
44
50
|
|
|
45
51
|
|
|
46
52
|
def test_commands_exec_client_dispatch() -> None:
|
|
@@ -66,6 +72,7 @@ if __name__ == "__main__":
|
|
|
66
72
|
test_resolve_command_alias()
|
|
67
73
|
test_resolve_command_unknown()
|
|
68
74
|
test_commands_catalog_shape()
|
|
75
|
+
test_unproven_gateway_commands_stay_hidden()
|
|
69
76
|
test_commands_exec_client_dispatch()
|
|
70
77
|
test_commands_exec_not_slash()
|
|
71
78
|
test_gquota_stub()
|
|
@@ -17,6 +17,33 @@ stack_config.DATA_DIR = td
|
|
|
17
17
|
stack_config.CONFIG_PATH = td / "stack_config.json"
|
|
18
18
|
db_path = str(td / "workframe.db")
|
|
19
19
|
|
|
20
|
+
assert install_api.resolve_install_wizard_step(db_path) == "intro"
|
|
21
|
+
|
|
22
|
+
# Registered admin + stale saved smtp must not skip deployment.
|
|
23
|
+
import sqlite3
|
|
24
|
+
|
|
25
|
+
conn = sqlite3.connect(db_path)
|
|
26
|
+
conn.executescript(
|
|
27
|
+
"""
|
|
28
|
+
CREATE TABLE workspaces (
|
|
29
|
+
id TEXT PRIMARY KEY, slug TEXT, display_name TEXT, owner_id TEXT,
|
|
30
|
+
settings_json TEXT, deleted_at INTEGER, status TEXT, created_at TEXT, updated_at TEXT
|
|
31
|
+
);
|
|
32
|
+
"""
|
|
33
|
+
)
|
|
34
|
+
conn.execute(
|
|
35
|
+
"INSERT INTO workspaces (id, slug, display_name, owner_id, settings_json, deleted_at, status, created_at, updated_at) "
|
|
36
|
+
"VALUES ('ws1', 'default', 'Workframe', 'u1', '{}', NULL, 'active', '1', '1')",
|
|
37
|
+
)
|
|
38
|
+
conn.commit()
|
|
39
|
+
conn.close()
|
|
40
|
+
|
|
41
|
+
stack_config.patch_stack_config(
|
|
42
|
+
{"smtp": {"admin_email": "owner@example.com"}, "wizard_step": "smtp"},
|
|
43
|
+
)
|
|
44
|
+
assert install_api.install_owner_claimed(db_path)
|
|
45
|
+
assert install_api.resolve_install_wizard_step(db_path) == "welcome"
|
|
46
|
+
|
|
20
47
|
# SMTP saved + tested
|
|
21
48
|
stack_config.patch_stack_config(
|
|
22
49
|
{
|
|
@@ -36,7 +63,11 @@ stack_config.patch_stack_config({"smtp": {"admin_email": "owner@example.com"}})
|
|
|
36
63
|
|
|
37
64
|
assert stack_config.smtp_setup_complete()
|
|
38
65
|
assert not stack_config.install_admin_verified()
|
|
39
|
-
assert install_api.resolve_install_wizard_step(db_path) == "
|
|
66
|
+
assert install_api.resolve_install_wizard_step(db_path) == "admin_auth"
|
|
67
|
+
|
|
68
|
+
# Registered owner before verify still resumes at OTP (register ≠ verify).
|
|
69
|
+
assert install_api.install_owner_claimed(db_path)
|
|
70
|
+
assert install_api.resolve_install_wizard_step(db_path) == "admin_auth"
|
|
40
71
|
|
|
41
72
|
stack_config.mark_install_admin_verified("owner@example.com")
|
|
42
73
|
assert stack_config.install_admin_verified()
|
|
@@ -50,24 +81,19 @@ assert payload["smtp"]["admin_verified"] is True
|
|
|
50
81
|
|
|
51
82
|
wizard = install_api.install_wizard_public_payload(db_path)
|
|
52
83
|
assert wizard["admin_verified"] is True
|
|
84
|
+
assert wizard["owner_claimed"] is True
|
|
53
85
|
assert wizard["resume_step"] == "workframe"
|
|
54
86
|
|
|
55
|
-
|
|
56
|
-
|
|
87
|
+
allowed, _ = install_api.install_auth_email_allowed("owner@example.com")
|
|
88
|
+
assert allowed
|
|
89
|
+
denied, deny = install_api.install_auth_email_allowed("other@example.com")
|
|
90
|
+
assert not denied and deny.get("error") == "install_admin_email_required"
|
|
57
91
|
|
|
92
|
+
# Workspace progress → billing
|
|
58
93
|
conn = sqlite3.connect(db_path)
|
|
59
|
-
conn.executescript(
|
|
60
|
-
"""
|
|
61
|
-
CREATE TABLE workspaces (
|
|
62
|
-
id TEXT PRIMARY KEY, slug TEXT, display_name TEXT, owner_id TEXT,
|
|
63
|
-
settings_json TEXT, deleted_at INTEGER, status TEXT, created_at TEXT, updated_at TEXT
|
|
64
|
-
);
|
|
65
|
-
"""
|
|
66
|
-
)
|
|
67
94
|
settings = {"admin_onboarding_done": True}
|
|
68
95
|
conn.execute(
|
|
69
|
-
"
|
|
70
|
-
"VALUES ('ws1', 'default', 'Workframe', 'u1', ?, NULL, 'active', '1', '1')",
|
|
96
|
+
"UPDATE workspaces SET settings_json = ? WHERE id = 'ws1'",
|
|
71
97
|
(json.dumps(settings),),
|
|
72
98
|
)
|
|
73
99
|
conn.commit()
|
|
@@ -10,108 +10,126 @@ import tempfile
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
ROOT = Path(__file__).resolve().parent
|
|
13
|
-
|
|
14
|
-
os.environ["WORKFRAME_AGENTS_DIR"] = tmpdir
|
|
15
|
-
os.environ["WORKFRAME_PROJECT"] = "ModelSurfaceTest"
|
|
16
|
-
os.environ["NATIVE_PROFILE"] = "surface-test-agent"
|
|
13
|
+
PROF = "surface-test-agent"
|
|
17
14
|
|
|
18
|
-
spec = importlib.util.spec_from_file_location("wf_server_model_surface", ROOT / "server.py")
|
|
19
|
-
server = importlib.util.module_from_spec(spec)
|
|
20
|
-
sys.modules["wf_server_model_surface"] = server
|
|
21
|
-
spec.loader.exec_module(server)
|
|
22
|
-
sys.modules["server"] = server # ponytail: extracted modules resolve _srv() to this instance
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
prof_dir = server._profile_dir(PROF)
|
|
26
|
-
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
-
(prof_dir / "config.yaml").write_text(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
31
|
-
cfg_path = prof_dir / "config.yaml"
|
|
32
|
-
before = cfg_path.read_bytes()
|
|
33
|
-
|
|
34
|
-
# A: GET models must not mutate config.yaml
|
|
35
|
-
server.hermes_models(PROF, user_id="", workspace_id="")
|
|
36
|
-
assert cfg_path.read_bytes() == before, "hermes_models must not rewrite config on read"
|
|
37
|
-
|
|
38
|
-
# B: reconcile idempotent when routing already matches codex oauth
|
|
39
|
-
assert server._profile_routing_matches_billing(PROF, "codex")
|
|
40
|
-
assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is False
|
|
41
|
-
|
|
42
|
-
# C: billing resolution honors prefer= when model exists in that catalog
|
|
43
|
-
connected = {"codex", "openrouter"}
|
|
44
|
-
assert server._resolve_billing_provider_for_model(
|
|
45
|
-
|
|
46
|
-
) == "codex"
|
|
47
|
-
assert server._resolve_billing_provider_for_model(
|
|
48
|
-
|
|
49
|
-
) == "openrouter"
|
|
50
|
-
assert server._resolve_billing_provider_for_model(
|
|
51
|
-
|
|
52
|
-
) == "codex"
|
|
53
|
-
|
|
54
|
-
# D: set with explicit billing_provider returns billing id in response
|
|
55
|
-
applied = server.hermes_model_set(
|
|
56
|
-
PROF, "gpt-5.4-medium", user_id="", workspace_id="", billing_provider="codex",
|
|
57
|
-
)
|
|
58
|
-
assert applied.get("ok"), applied
|
|
59
|
-
assert applied.get("billing_provider") == "codex"
|
|
60
|
-
assert applied.get("provider") == "codex"
|
|
61
|
-
|
|
62
|
-
# E: reconcile picks model billing over stale openrouter proxy yaml
|
|
63
|
-
(prof_dir / "config.yaml").write_text(
|
|
64
|
-
"model:\n default: openai-codex/gpt-5.4-mini\n provider: custom\n"
|
|
65
|
-
" base_url: http://workframe-api:8080/internal/llm/openrouter/v1\n",
|
|
66
|
-
encoding="utf-8",
|
|
67
|
-
)
|
|
68
|
-
_orig_picker = server._user_llm_providers_for_picker
|
|
69
|
-
_orig_can = server._user_can_use_llm
|
|
70
|
-
_orig_sync = server._sync_oauth_llm_to_profile
|
|
71
|
-
server._user_llm_providers_for_picker = lambda uid: {"codex"} if uid == "user-1" else set()
|
|
72
|
-
server._user_can_use_llm = lambda uid, ws, prov: uid == "user-1" and prov == "codex"
|
|
73
|
-
server._sync_oauth_llm_to_profile = lambda *a, **k: None
|
|
74
|
-
try:
|
|
75
|
-
assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is True
|
|
76
|
-
block = server._read_model_block(PROF)
|
|
77
|
-
assert block.get("provider") == "openai-codex"
|
|
78
|
-
assert block.get("default") == "gpt-5.4-mini"
|
|
79
|
-
assert not block.get("base_url")
|
|
80
|
-
finally:
|
|
81
|
-
server._user_llm_providers_for_picker = _orig_picker
|
|
82
|
-
server._user_can_use_llm = _orig_can
|
|
83
|
-
server._sync_oauth_llm_to_profile = _orig_sync
|
|
84
|
-
|
|
85
|
-
# F: runtime disk wins over template in _read_model_block
|
|
86
|
-
runtime_prof = "u-test-user-surface-test-agent"
|
|
87
|
-
runtime_dir = server._profile_dir(runtime_prof)
|
|
88
|
-
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
-
(runtime_dir / "config.yaml").write_text(
|
|
90
|
-
"model:\n default: gpt-5.4-mini\n provider: openai-codex\n",
|
|
91
|
-
encoding="utf-8",
|
|
92
|
-
)
|
|
93
|
-
block = server._read_model_block(runtime_prof)
|
|
94
|
-
assert block.get("default") == "gpt-5.4-mini", block
|
|
95
|
-
assert block.get("provider") == "openai-codex", block
|
|
96
|
-
|
|
97
|
-
# G: template model save mirrors to runtime (same model id, not stale template re-read)
|
|
98
|
-
runtime_prof = "u-test-user2-surface-test-agent"
|
|
99
|
-
runtime_dir = server._profile_dir(runtime_prof)
|
|
100
|
-
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
-
(runtime_dir / "config.yaml").write_text(
|
|
102
|
-
"model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
|
|
103
|
-
encoding="utf-8",
|
|
104
|
-
)
|
|
105
|
-
_orig_restart = server._restart_runtime_profile_gateway
|
|
106
|
-
server._restart_runtime_profile_gateway = lambda *a, **k: None
|
|
107
|
-
try:
|
|
16
|
+
def _run_checks(server) -> None:
|
|
17
|
+
prof_dir = server._profile_dir(PROF)
|
|
18
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
(prof_dir / "config.yaml").write_text(
|
|
20
|
+
"model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
|
|
21
|
+
encoding="utf-8",
|
|
22
|
+
)
|
|
23
|
+
cfg_path = prof_dir / "config.yaml"
|
|
24
|
+
before = cfg_path.read_bytes()
|
|
25
|
+
|
|
26
|
+
# A: GET models must not mutate config.yaml
|
|
27
|
+
server.hermes_models(PROF, user_id="", workspace_id="")
|
|
28
|
+
assert cfg_path.read_bytes() == before, "hermes_models must not rewrite config on read"
|
|
29
|
+
|
|
30
|
+
# B: reconcile idempotent when routing already matches codex oauth
|
|
31
|
+
assert server._profile_routing_matches_billing(PROF, "codex")
|
|
32
|
+
assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is False
|
|
33
|
+
|
|
34
|
+
# C: billing resolution honors prefer= when model exists in that catalog
|
|
35
|
+
connected = {"codex", "openrouter"}
|
|
36
|
+
assert server._resolve_billing_provider_for_model(
|
|
37
|
+
"gpt-5.4-medium", connected, prefer="codex",
|
|
38
|
+
) == "codex"
|
|
39
|
+
assert server._resolve_billing_provider_for_model(
|
|
40
|
+
"openrouter/owl-alpha", connected, prefer="openrouter",
|
|
41
|
+
) == "openrouter"
|
|
42
|
+
assert server._resolve_billing_provider_for_model(
|
|
43
|
+
"openai-codex/gpt-5.4-mini", {"codex", "openrouter"},
|
|
44
|
+
) == "codex"
|
|
45
|
+
|
|
46
|
+
# D: set with explicit billing_provider returns billing id in response
|
|
108
47
|
applied = server.hermes_model_set(
|
|
109
|
-
PROF, "gpt-5.4-
|
|
48
|
+
PROF, "gpt-5.4-medium", user_id="", workspace_id="", billing_provider="codex",
|
|
49
|
+
)
|
|
50
|
+
assert applied.get("ok"), applied
|
|
51
|
+
assert applied.get("billing_provider") == "codex"
|
|
52
|
+
assert applied.get("provider") == "codex"
|
|
53
|
+
|
|
54
|
+
# E: reconcile picks model billing over stale openrouter proxy yaml
|
|
55
|
+
(prof_dir / "config.yaml").write_text(
|
|
56
|
+
"model:\n default: openai-codex/gpt-5.4-mini\n provider: custom\n"
|
|
57
|
+
" base_url: http://workframe-api:8080/internal/llm/openrouter/v1\n",
|
|
58
|
+
encoding="utf-8",
|
|
110
59
|
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
60
|
+
_orig_picker = server._user_llm_providers_for_picker
|
|
61
|
+
_orig_can = server._user_can_use_llm
|
|
62
|
+
_orig_sync = server._sync_oauth_llm_to_profile
|
|
63
|
+
server._user_llm_providers_for_picker = lambda uid: {"codex"} if uid == "user-1" else set()
|
|
64
|
+
server._user_can_use_llm = lambda uid, ws, prov: uid == "user-1" and prov == "codex"
|
|
65
|
+
server._sync_oauth_llm_to_profile = lambda *a, **k: None
|
|
66
|
+
try:
|
|
67
|
+
assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is True
|
|
68
|
+
block = server._read_model_block(PROF)
|
|
69
|
+
assert block.get("provider") == "openai-codex"
|
|
70
|
+
assert block.get("default") == "gpt-5.4-mini"
|
|
71
|
+
assert not block.get("base_url")
|
|
72
|
+
finally:
|
|
73
|
+
server._user_llm_providers_for_picker = _orig_picker
|
|
74
|
+
server._user_can_use_llm = _orig_can
|
|
75
|
+
server._sync_oauth_llm_to_profile = _orig_sync
|
|
76
|
+
|
|
77
|
+
# F: runtime disk wins over template in _read_model_block
|
|
78
|
+
runtime_prof = "u-test-user-surface-test-agent"
|
|
79
|
+
runtime_dir = server._profile_dir(runtime_prof)
|
|
80
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
(runtime_dir / "config.yaml").write_text(
|
|
82
|
+
"model:\n default: gpt-5.4-mini\n provider: openai-codex\n",
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
)
|
|
85
|
+
block = server._read_model_block(runtime_prof)
|
|
86
|
+
assert block.get("default") == "gpt-5.4-mini", block
|
|
87
|
+
assert block.get("provider") == "openai-codex", block
|
|
88
|
+
|
|
89
|
+
# G: template model save mirrors to runtime (same model id, not stale template re-read)
|
|
90
|
+
runtime_prof = "u-test-user2-surface-test-agent"
|
|
91
|
+
runtime_dir = server._profile_dir(runtime_prof)
|
|
92
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
(runtime_dir / "config.yaml").write_text(
|
|
94
|
+
"model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
)
|
|
97
|
+
_orig_restart = server._restart_runtime_profile_gateway
|
|
98
|
+
server._restart_runtime_profile_gateway = lambda *a, **k: None
|
|
99
|
+
try:
|
|
100
|
+
applied = server.hermes_model_set(
|
|
101
|
+
PROF, "gpt-5.4-mini", user_id="test-user2", workspace_id="", billing_provider="codex",
|
|
102
|
+
)
|
|
103
|
+
finally:
|
|
104
|
+
server._restart_runtime_profile_gateway = _orig_restart
|
|
105
|
+
assert applied.get("ok"), applied
|
|
106
|
+
runtime_block = server._parse_model_block_from_disk(runtime_prof)
|
|
107
|
+
assert runtime_block.get("default") == "gpt-5.4-mini", runtime_block
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_self_check() -> None:
|
|
111
|
+
tmpdir = tempfile.mkdtemp(prefix="wf-model-surface-")
|
|
112
|
+
os.environ["WORKFRAME_AGENTS_DIR"] = tmpdir
|
|
113
|
+
os.environ["HERMES_DATA"] = tmpdir
|
|
114
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = str(Path(tmpdir) / "api")
|
|
115
|
+
os.environ["WORKFRAME_PROJECT"] = "ModelSurfaceTest"
|
|
116
|
+
os.environ["NATIVE_PROFILE"] = "surface-test-agent"
|
|
117
|
+
|
|
118
|
+
spec = importlib.util.spec_from_file_location("wf_server_model_surface", ROOT / "server.py")
|
|
119
|
+
server = importlib.util.module_from_spec(spec)
|
|
120
|
+
sys.modules["wf_server_model_surface"] = server
|
|
121
|
+
spec.loader.exec_module(server)
|
|
122
|
+
prev_server = sys.modules.get("server")
|
|
123
|
+
sys.modules["server"] = server # ponytail: extracted modules resolve _srv() to this instance
|
|
124
|
+
try:
|
|
125
|
+
_run_checks(server)
|
|
126
|
+
finally:
|
|
127
|
+
if prev_server is None:
|
|
128
|
+
sys.modules.pop("server", None)
|
|
129
|
+
else:
|
|
130
|
+
sys.modules["server"] = prev_server
|
|
131
|
+
|
|
116
132
|
|
|
117
|
-
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
_run_self_check()
|
|
135
|
+
print("model surface consistency self-check ok")
|
|
@@ -9,7 +9,10 @@ from pathlib import Path
|
|
|
9
9
|
import yaml
|
|
10
10
|
|
|
11
11
|
ROOT = Path(__file__).resolve().parent
|
|
12
|
-
|
|
12
|
+
_test_root = tempfile.mkdtemp(prefix="wf-yaml-test-")
|
|
13
|
+
os.environ["WORKFRAME_AGENTS_DIR"] = _test_root
|
|
14
|
+
os.environ["HERMES_DATA"] = _test_root
|
|
15
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = str(Path(_test_root) / "api")
|
|
13
16
|
os.environ.setdefault("WORKFRAME_PROJECT", "YamlTest")
|
|
14
17
|
os.environ.setdefault("NATIVE_PROFILE", "yaml-test-agent")
|
|
15
18
|
|
|
@@ -44,9 +44,19 @@ def test_parse_device_oauth_log() -> None:
|
|
|
44
44
|
assert parsed["user_code"] == "ABCD-1234"
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_device_oauth_error_from_log() -> None:
|
|
48
|
+
text = (
|
|
49
|
+
"hermes_cli.auth.AuthError: OpenAI is rate-limiting Codex login requests (HTTP 429). "
|
|
50
|
+
"Wait a minute and run the login again."
|
|
51
|
+
)
|
|
52
|
+
msg = provider_bindings._device_oauth_error_from_log(text)
|
|
53
|
+
assert msg and "429" in msg
|
|
54
|
+
|
|
55
|
+
|
|
47
56
|
if __name__ == "__main__":
|
|
48
57
|
test_hermes_oauth_auth_keys()
|
|
49
58
|
test_auth_json_has_oauth_material()
|
|
50
59
|
test_merge_oauth_auth_into_profile()
|
|
51
60
|
test_parse_device_oauth_log()
|
|
61
|
+
test_device_oauth_error_from_log()
|
|
52
62
|
print("test_provider_bindings: ok")
|
|
@@ -88,7 +88,8 @@ def test_schema_and_round_trip() -> None:
|
|
|
88
88
|
conn.close()
|
|
89
89
|
|
|
90
90
|
events = run_ledger.list_run_events_for_room("room-1")
|
|
91
|
-
assert len(events)
|
|
91
|
+
assert len(events) == 1
|
|
92
|
+
assert events[0]["event_type"] == "run.completed"
|
|
92
93
|
assert events[0]["source"] == "run_ledger"
|
|
93
94
|
|
|
94
95
|
|
|
@@ -86,6 +86,10 @@ _REQUIRED = (
|
|
|
86
86
|
"_promote_workspace_owner_if_unclaimed",
|
|
87
87
|
"_sync_workspace_home_room",
|
|
88
88
|
"_onboard_workspace_member_rooms",
|
|
89
|
+
"_profile_model",
|
|
90
|
+
"_USER_LLM_PICKER_TTL_SEC",
|
|
91
|
+
"_invalidate_profile_health_cache",
|
|
92
|
+
"_profile_toolsets_ready",
|
|
89
93
|
)
|
|
90
94
|
|
|
91
95
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""LLM prefs persist in users.platform_ids without messaging-only validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
API_DIR = Path(__file__).resolve().parent
|
|
16
|
+
if str(API_DIR) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(API_DIR))
|
|
18
|
+
|
|
19
|
+
os.environ.setdefault("WORKFRAME_API_DATA_DIR", str(API_DIR / ".tmp-test-data"))
|
|
20
|
+
os.environ.setdefault("HERMES_DATA", str(API_DIR / ".tmp-test-hermes"))
|
|
21
|
+
os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
|
|
22
|
+
|
|
23
|
+
import server # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture()
|
|
27
|
+
def llm_prefs_db(monkeypatch: pytest.MonkeyPatch):
|
|
28
|
+
with tempfile.TemporaryDirectory(prefix="wf-llm-prefs-") as tmp:
|
|
29
|
+
data_dir = Path(tmp) / "api"
|
|
30
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
monkeypatch.setattr(server, "DATA_DIR", data_dir)
|
|
32
|
+
monkeypatch.setattr(server, "AUTH_DB_PATH", data_dir / "auth.db")
|
|
33
|
+
server._ensure_workframe_db_schema()
|
|
34
|
+
yield data_dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _insert_user(*, email: str, display_name: str, platform_ids: str) -> str:
|
|
38
|
+
user_id = str(uuid.uuid4())
|
|
39
|
+
now = str(int(time.time()))
|
|
40
|
+
conn = server._workframe_db()
|
|
41
|
+
try:
|
|
42
|
+
conn.execute(
|
|
43
|
+
"INSERT INTO users (id, email, display_name, role, status, platform_ids, created_at, updated_at) "
|
|
44
|
+
"VALUES (?,?,?,?,?,?,?,?)",
|
|
45
|
+
(user_id, email, display_name, "owner", "active", platform_ids, now, now),
|
|
46
|
+
)
|
|
47
|
+
conn.commit()
|
|
48
|
+
finally:
|
|
49
|
+
conn.close()
|
|
50
|
+
return user_id
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_user_llm_prefs_round_trip(llm_prefs_db) -> None: # noqa: ARG001
|
|
54
|
+
import user_prefs # noqa: WPS433
|
|
55
|
+
|
|
56
|
+
user_id = _insert_user(email="prefs@test.local", display_name="Prefs", platform_ids="{}")
|
|
57
|
+
|
|
58
|
+
user_prefs.write_user_llm_prefs(
|
|
59
|
+
user_id,
|
|
60
|
+
primary="openrouter/google/gemini-2.5-flash",
|
|
61
|
+
fallback_chain=[{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.5"}],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
conn = server._workframe_db()
|
|
65
|
+
try:
|
|
66
|
+
row = conn.execute(
|
|
67
|
+
"SELECT platform_ids FROM users WHERE id = ?", (user_id,),
|
|
68
|
+
).fetchone()
|
|
69
|
+
finally:
|
|
70
|
+
conn.close()
|
|
71
|
+
pids = json.loads(row["platform_ids"])
|
|
72
|
+
assert pids["llm_primary"] == "openrouter/google/gemini-2.5-flash"
|
|
73
|
+
chain = json.loads(pids["llm_fallback_chain"])
|
|
74
|
+
assert chain[0]["model"] == "anthropic/claude-sonnet-4.5"
|
|
75
|
+
|
|
76
|
+
primary, fb = user_prefs.read_user_llm_prefs(user_id)
|
|
77
|
+
assert primary == "openrouter/google/gemini-2.5-flash"
|
|
78
|
+
assert fb[0]["model"] == "anthropic/claude-sonnet-4.5"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_user_llm_prefs_infers_primary_from_fallback_chain(llm_prefs_db) -> None: # noqa: ARG001
|
|
82
|
+
import user_prefs # noqa: WPS433
|
|
83
|
+
|
|
84
|
+
platform_ids = json.dumps({
|
|
85
|
+
"llm_fallback_chain": json.dumps([{"provider": "openrouter", "model": "google/gemini-2.5-flash"}]),
|
|
86
|
+
})
|
|
87
|
+
user_id = _insert_user(
|
|
88
|
+
email="fallback@test.local",
|
|
89
|
+
display_name="Fallback",
|
|
90
|
+
platform_ids=platform_ids,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
inferred, chain = user_prefs.read_user_llm_prefs(user_id)
|
|
94
|
+
assert inferred == "openrouter/google/gemini-2.5-flash"
|
|
95
|
+
assert chain[0]["model"] == "google/gemini-2.5-flash"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(pytest.main([__file__, "-q"]))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Split provider vs user overlay timing."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import server
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ms(t0: float) -> int:
|
|
12
|
+
return round((time.perf_counter() - t0) * 1000)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
conn = sqlite3.connect(str(server.DATA_DIR / "workframe.db"))
|
|
17
|
+
conn.row_factory = sqlite3.Row
|
|
18
|
+
room = conn.execute(
|
|
19
|
+
"""
|
|
20
|
+
SELECT r.id, r.workspace_id, ap.slug AS agent_slug
|
|
21
|
+
FROM rooms r
|
|
22
|
+
JOIN agent_profiles ap ON ap.id = r.agent_profile_id
|
|
23
|
+
JOIN room_memberships rm ON rm.room_id = r.id AND rm.status = 'active'
|
|
24
|
+
WHERE r.room_type = 'direct' AND r.deleted_at IS NULL
|
|
25
|
+
ORDER BY r.updated_at DESC
|
|
26
|
+
LIMIT 1
|
|
27
|
+
""",
|
|
28
|
+
).fetchone()
|
|
29
|
+
user = conn.execute(
|
|
30
|
+
"""
|
|
31
|
+
SELECT u.id FROM users u
|
|
32
|
+
JOIN room_memberships rm ON rm.user_id = u.id AND rm.room_id = ?
|
|
33
|
+
WHERE u.status = 'active'
|
|
34
|
+
LIMIT 1
|
|
35
|
+
""",
|
|
36
|
+
(str(room["id"]),),
|
|
37
|
+
).fetchone()
|
|
38
|
+
conn.close()
|
|
39
|
+
room_id = str(room["id"])
|
|
40
|
+
ws_id = str(room["workspace_id"])
|
|
41
|
+
user_id = str(user["id"])
|
|
42
|
+
prof = server._resolve_chat_hermes_profile(str(room["agent_slug"]), user_id, room_id, ws_id)
|
|
43
|
+
provider = server._llm_billing_provider(prof, user_id=user_id, workspace_id=ws_id)
|
|
44
|
+
|
|
45
|
+
for i in range(5):
|
|
46
|
+
t = time.perf_counter()
|
|
47
|
+
server._overlay_turn_provider_env(prof, user_id, ws_id, provider, f"p-{i}")
|
|
48
|
+
p_ms = ms(t)
|
|
49
|
+
t = time.perf_counter()
|
|
50
|
+
server._overlay_turn_user_env(prof, user_id, ws_id, f"u-{i}")
|
|
51
|
+
u_ms = ms(t)
|
|
52
|
+
print(f"turn_{i} provider_ms={p_ms} user_ms={u_ms} total_ms={p_ms + u_ms}")
|
|
53
|
+
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|