create-workframe 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +14 -1
  2. package/SECURITY.md +3 -3
  3. package/bin/create-workframe.js +3 -3
  4. package/package.json +1 -1
  5. package/scripts/bundle-workframe-ui.mjs +13 -7
  6. package/workframe-api/README.md +3 -5
  7. package/workframe-api/concierge.py +173 -0
  8. package/workframe-api/llm_error_glossary.py +236 -0
  9. package/workframe-api/llm_proxy.py +3 -0
  10. package/workframe-api/openrouter_catalog.py +114 -0
  11. package/workframe-api/package.json +1 -1
  12. package/workframe-api/server.py +178 -50
  13. package/workframe-api/updates.py +107 -18
  14. package/workframe-supervisor/server.py +75 -2
  15. package/workframe-ui/public/assets/{arc-COAT3laO.js → arc-BskqxLmE.js} +1 -1
  16. package/workframe-ui/public/assets/architecture-7EHR7CIX-DOxsUI33.js +1 -0
  17. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BFjWV24l.js → architectureDiagram-3BPJPVTR-B3eGdqvq.js} +1 -1
  18. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DSQLPfrj.js → blockDiagram-GPEHLZMM-CzFZG7uX.js} +1 -1
  19. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DKEHv1t2.js → c4Diagram-AAUBKEIU-BS-gGyCH.js} +1 -1
  20. package/workframe-ui/public/assets/channel-Baa20pIs.js +1 -0
  21. package/workframe-ui/public/assets/{chunk-2J33WTMH-DHZg-DUi.js → chunk-2J33WTMH-Cjvqf7c9.js} +1 -1
  22. package/workframe-ui/public/assets/{chunk-3OPIFGDE-BB-OYTfp.js → chunk-3OPIFGDE--eUuL8AK.js} +1 -1
  23. package/workframe-ui/public/assets/{chunk-4BX2VUAB-C93q0YIm.js → chunk-4BX2VUAB-s9lHUFKL.js} +1 -1
  24. package/workframe-ui/public/assets/{chunk-55IACEB6-MAYniqik.js → chunk-55IACEB6-D1McA5nz.js} +1 -1
  25. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-ChgN6YJs.js → chunk-5ZQYHXKU-DIstLfU2.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-727SXJPM-B_FYwdAv.js → chunk-727SXJPM-Cd0GD2CO.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-1_Hw_h1A.js → chunk-AQP2D5EJ-gc5Ra6G4.js} +1 -1
  28. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CFiDQ1Rv.js → chunk-BSJP7CBP-Cg5QfihJ.js} +1 -1
  29. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-DZ9UMTlB.js → chunk-CSCIHK7Q-D_gobbhY.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DlMlyFgw.js → chunk-FMBD7UC4-B6eLY9_d.js} +1 -1
  31. package/workframe-ui/public/assets/{chunk-KSCS5N6A-DHXtQ_Hf.js → chunk-KSCS5N6A-BGroAeW2.js} +1 -1
  32. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CuQzg-QG.js → chunk-L5ZTLDWV-34xv7WgT.js} +1 -1
  33. package/workframe-ui/public/assets/{chunk-LZXEDZCA-BHzjzCGg.js → chunk-LZXEDZCA-XJBctFZw.js} +2 -2
  34. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DHXx05n2.js → chunk-ND2GUHAM-nTOZpln8.js} +1 -1
  35. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CV5pmDM_.js → chunk-NZK2D7GU-BBrzcaPH.js} +1 -1
  36. package/workframe-ui/public/assets/{chunk-O5CBEL6O-6tkCHxsV.js → chunk-O5CBEL6O-Cntncx6L.js} +1 -1
  37. package/workframe-ui/public/assets/chunk-QZHKN3VN-B7tPxs4H.js +1 -0
  38. package/workframe-ui/public/assets/chunk-WU5MYG2G-Dvh2By1o.js +1 -0
  39. package/workframe-ui/public/assets/{chunk-XPW4576I-BClwIiCp.js → chunk-XPW4576I-DSbtd0cg.js} +1 -1
  40. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BljHyDzF.js +1 -0
  41. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BljHyDzF.js +1 -0
  42. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DOrGV6DQ.js → cose-bilkent-S5V4N54A-lmYstEMQ.js} +1 -1
  43. package/workframe-ui/public/assets/{dagre-BM42HDAG-DXTPvJkX.js → dagre-BM42HDAG-Bdkbdul2.js} +1 -1
  44. package/workframe-ui/public/assets/{diagram-2AECGRRQ-xX_v-pbf.js → diagram-2AECGRRQ-Clb9kSXe.js} +1 -1
  45. package/workframe-ui/public/assets/{diagram-5GNKFQAL-Cd2pXbBe.js → diagram-5GNKFQAL-BApg0yec.js} +1 -1
  46. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df3XvUtk.js → diagram-KO2AKTUF-peZjDRV6.js} +1 -1
  47. package/workframe-ui/public/assets/{diagram-LMA3HP47-CsijIPaD.js → diagram-LMA3HP47-DZm_1pjc.js} +1 -1
  48. package/workframe-ui/public/assets/{diagram-OG6HWLK6-aq5fmfHd.js → diagram-OG6HWLK6-DP_N6fYL.js} +1 -1
  49. package/workframe-ui/public/assets/{dist-D1c0mkbB.js → dist-CqP43hXK.js} +1 -1
  50. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-DnFysVRY.js → erDiagram-TEJ5UH35-Blfs6kLv.js} +1 -1
  51. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C4xwO6dx.js +1 -0
  52. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C6Ebi3su.js → flowDiagram-I6XJVG4X-CcmMOkwH.js} +1 -1
  53. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-BQXQtUpa.js → ganttDiagram-6RSMTGT7-ByoO3dF7.js} +1 -1
  54. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-Dt0zIs_M.js → gitGraph-WXDBUCRP-DCyTpmPs.js} +1 -1
  55. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BF8gHzRn.js → gitGraphDiagram-PVQCEYII-CvLYB55b.js} +1 -1
  56. package/workframe-ui/public/assets/index-BIfRGM8p.js +133 -0
  57. package/workframe-ui/public/assets/{info-J43DQDTF-CSmszQJT.js → info-J43DQDTF-CfSRIfOI.js} +1 -1
  58. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CVTKGW6p.js → infoDiagram-5YYISTIA-DwS44DS6.js} +1 -1
  59. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js → ishikawaDiagram-YF4QCWOH-MRlyodyU.js} +1 -1
  60. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-r3wD68_T.js → journeyDiagram-JHISSGLW-sq7awk87.js} +1 -1
  61. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Il8VglqN.js → kanban-definition-UN3LZRKU-CH6HbcWu.js} +1 -1
  62. package/workframe-ui/public/assets/{line-oyjpfz2A.js → line-DRBjFmUW.js} +1 -1
  63. package/workframe-ui/public/assets/{linear-Cf7p5tVp.js → linear-BR0LunjE.js} +1 -1
  64. package/workframe-ui/public/assets/{mermaid-parser.core-YmbZ-AfY.js → mermaid-parser.core-Cl1GjHkT.js} +2 -2
  65. package/workframe-ui/public/assets/{mermaid.core-BFdCAqCo.js → mermaid.core-DsiKK1kE.js} +3 -3
  66. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Cy2iCtEl.js → mindmap-definition-RKZ34NQL-DBvi3OpW.js} +1 -1
  67. package/workframe-ui/public/assets/{packet-YPE3B663-DwOBZL6K.js → packet-YPE3B663-CPDgtUHg.js} +1 -1
  68. package/workframe-ui/public/assets/{pie-LRSECV5Y-04PPhnKK.js → pie-LRSECV5Y-BXJIPPDm.js} +1 -1
  69. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-LxIpgHqi.js → pieDiagram-4H26LBE5-CZwLEK-o.js} +1 -1
  70. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-0nBYfYm4.js → quadrantDiagram-W4KKPZXB-B65bEUIQ.js} +1 -1
  71. package/workframe-ui/public/assets/{radar-GUYGQ44K-D2-vBqps.js → radar-GUYGQ44K-vXHkZzRK.js} +1 -1
  72. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DbuU0nlu.js → requirementDiagram-4Y6WPE33-BGWdvDfG.js} +1 -1
  73. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-B2hQ6B2x.js → sankeyDiagram-5OEKKPKP-BT2oC8dV.js} +1 -1
  74. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BBrU30e1.js → sequenceDiagram-3UESZ5HK-45yj0Wtx.js} +1 -1
  75. package/workframe-ui/public/assets/{src-BJEDmV70.js → src-D-P8U2PB.js} +1 -1
  76. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-7FGO4kkH.js → stateDiagram-AJRCARHV-SBrBJBZG.js} +1 -1
  77. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B3im3So2.js +1 -0
  78. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-ptDm4rCN.js → timeline-definition-PNZ67QCA-DegmxXXj.js} +1 -1
  79. package/workframe-ui/public/assets/{treeView-BLDUP644-CS6Z-0q8.js → treeView-BLDUP644-BVy7CsCj.js} +1 -1
  80. package/workframe-ui/public/assets/{treemap-LRROVOQU-DqV4Y2VA.js → treemap-LRROVOQU-BD5Kb74J.js} +1 -1
  81. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-C0UrZJYt.js → vennDiagram-CIIHVFJN-hwAkNbl3.js} +1 -1
  82. package/workframe-ui/public/assets/{wardley-L42UT6IY-bNDN3_Sa.js → wardley-L42UT6IY-CBMPT8Y1.js} +1 -1
  83. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-jWiJsefM.js → wardleyDiagram-YWT4CUSO-DXqihVlE.js} +1 -1
  84. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-Dsh_fLCy.js → xychartDiagram-2RQKCTM6-Cbb4LBzj.js} +1 -1
  85. package/workframe-ui/public/index.html +1 -1
  86. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +0 -1
  87. package/workframe-ui/public/assets/channel-g7r_RGaY.js +0 -1
  88. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +0 -1
  89. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +0 -1
  90. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +0 -1
  91. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +0 -1
  92. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +0 -1
  93. package/workframe-ui/public/assets/index-lRpzpNPT.js +0 -133
  94. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +0 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Published on npm as **create-workframe**.
4
4
 
5
5
  ```bash
6
- npx create-workframe@0.1.2 MyProject
6
+ npx create-workframe@0.1.4 MyProject
7
7
  ```
8
8
 
9
9
  Scaffolds an isolated Workframe + Hermes project on Windows, macOS, and Linux.
@@ -14,14 +14,27 @@ Scaffolds an isolated Workframe + Hermes project on Windows, macOS, and Linux.
14
14
  MyProject/
15
15
  ├── Agents/
16
16
  ├── Files/
17
+ ├── scripts/ bootstrap, lifecycle, workframe.mjs CLI
17
18
  ├── docker-compose.yml
18
19
  ├── workframe-api/
19
20
  ├── workframe-ui/
21
+ ├── workframe-supervisor/
20
22
  └── workframe-manifest.json
21
23
  ```
22
24
 
25
+ ## Project CLI
26
+
27
+ From the generated project root:
28
+
29
+ ```bash
30
+ node scripts/workframe.mjs doctor
31
+ node scripts/workframe.mjs setup
32
+ ```
33
+
23
34
  ## Source
24
35
 
25
36
  [github.com/npx-workframe/workframe](https://github.com/npx-workframe/workframe)
26
37
 
38
+ Documentation: [docs/README.md](https://github.com/npx-workframe/workframe/blob/main/docs/README.md)
39
+
27
40
  Apache-2.0 — see `LICENSE`, `NOTICE`, `SECURITY.md`.
package/SECURITY.md CHANGED
@@ -16,7 +16,7 @@ direct contact with the repository owner).
16
16
 
17
17
  Include:
18
18
 
19
- - Affected component (BFF, supervisor, installer, UI, compose)
19
+ - Affected component (workframe-api, supervisor, installer, UI, compose)
20
20
  - Steps to reproduce
21
21
  - Impact assessment (data exposure, auth bypass, RCE, etc.)
22
22
  - Suggested fix if you have one
@@ -26,8 +26,8 @@ We aim to acknowledge reports within a few business days.
26
26
  ## Scope notes
27
27
 
28
28
  - Personal Hermes installs outside a Workframe compose stack are out of scope unless the issue is in shared Workframe source that affects all installs.
29
- - Production deployments should run with `WORKFRAME_MODE=team`, invite-only access, and
30
- without `DEV_LOCAL_UNSAFE`.
29
+ - Production deployments should use `WORKFRAME_DEPLOYMENT_MODE=trusted_team` or
30
+ `public_multi_user`, invite-only access, and without `DEV_LOCAL_UNSAFE`.
31
31
  - BYOK (bring-your-own-key) is the default credential mode; workspace company-pays is an
32
32
  explicit admin opt-in.
33
33
 
@@ -2014,15 +2014,15 @@ function nativeConfigYaml(projectName, agentName, personality) {
2014
2014
  const personalityLine = personality ? `# Personality: ${personality}
2015
2015
  ` : "";
2016
2016
  return `${personalityLine}model:
2017
- default: openrouter/owl-alpha
2017
+ default: google/gemini-2.5-flash
2018
2018
  provider: openrouter
2019
2019
  base_url: https://openrouter.ai/api/v1
2020
2020
  api_mode: chat_completions
2021
2021
  fallback_providers:
2022
2022
  - provider: openrouter
2023
- model: openrouter/nex-agi
2023
+ model: anthropic/claude-sonnet-4.5
2024
2024
  - provider: openrouter
2025
- model: openrouter/nvidia/nemotron-3-ultra-550b-a55b:free
2025
+ model: meta-llama/llama-3.3-70b-instruct:free
2026
2026
  providers: {}
2027
2027
  credential_pool_strategies: {}
2028
2028
  toolsets:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-workframe",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Scaffold a Workframe + Hermes workspace with guided onboarding",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,18 +31,24 @@ function copyTree(src, dst) {
31
31
  }
32
32
  }
33
33
 
34
+ const skipBuild = process.argv.includes('--skip-build');
35
+
34
36
  if (!fs.existsSync(UI_SRC)) {
35
37
  console.error(`apps/web source not found: ${UI_SRC}`);
36
38
  process.exit(1);
37
39
  }
38
40
 
39
- console.log('Building @workframe/web...');
40
- const build = spawnSync(npmCmd(), ['run', 'build'], {
41
- cwd: UI_SRC,
42
- stdio: 'inherit',
43
- shell: process.platform === 'win32',
44
- });
45
- if (build.status !== 0) process.exit(build.status ?? 1);
41
+ if (!skipBuild) {
42
+ console.log('Building @workframe/web...');
43
+ const build = spawnSync(npmCmd(), ['run', 'build'], {
44
+ cwd: UI_SRC,
45
+ stdio: 'inherit',
46
+ shell: process.platform === 'win32',
47
+ });
48
+ if (build.status !== 0) process.exit(build.status ?? 1);
49
+ } else {
50
+ console.log('Skipping @workframe/web build (--skip-build)');
51
+ }
46
52
 
47
53
  const dist = path.join(UI_SRC, 'dist');
48
54
  if (!fs.existsSync(path.join(dist, 'index.html'))) {
@@ -1,8 +1,6 @@
1
1
  # Workframe API Service
2
2
 
3
- Python BFF for the Workframe UI. See [docs/VERSION.md](../../docs/VERSION.md) for release version.
4
-
5
- Canonical implementation — not rewritten into a separate `apps/api` service.
3
+ HTTP API for the Workframe UI. See [docs/VERSION.md](../../docs/VERSION.md) for release version.
6
4
 
7
5
  ## Local (outside Docker)
8
6
 
@@ -21,6 +19,6 @@ HOST=0.0.0.0 PORT=8080 HERMES_DATA=/opt/data WORKSPACE=/workspace python3 server
21
19
 
22
20
  ## Docs
23
21
 
24
- - [Runtime operations](../../docs/public/runtime-operations.md)
22
+ - [Operations](../../docs/public/operations.md)
25
23
  - [Security](../../docs/public/security.md)
26
- - [BFF route map](../../docs/public/bff-route-map.md)
24
+ - [API reference](../../docs/public/api-reference.md)
@@ -0,0 +1,173 @@
1
+ """Deterministic concierge when LLM is unavailable — keyword intent router, no LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ import llm_error_glossary as glossary
9
+
10
+ # ponytail: (intent_id, patterns) — first match wins
11
+ _INTENTS: list[tuple[str, tuple[str, ...]]] = [
12
+ (
13
+ "add_provider_keys",
14
+ (
15
+ "add key",
16
+ "add keys",
17
+ "api key",
18
+ "connect openrouter",
19
+ "connect provider",
20
+ "where key",
21
+ "how do i connect",
22
+ "how to connect",
23
+ "openrouter key",
24
+ "llm key",
25
+ "provider key",
26
+ ),
27
+ ),
28
+ (
29
+ "change_model",
30
+ (
31
+ "set model",
32
+ "change model",
33
+ "pick model",
34
+ "which model",
35
+ "how do i set model",
36
+ "how to set model",
37
+ "choose model",
38
+ "switch model",
39
+ "model picker",
40
+ ),
41
+ ),
42
+ (
43
+ "provider_credits",
44
+ (
45
+ "out of credit",
46
+ "no credit",
47
+ "add credit",
48
+ "billing",
49
+ "top up",
50
+ "purchase credit",
51
+ ),
52
+ ),
53
+ (
54
+ "invalid_key",
55
+ (
56
+ "invalid key",
57
+ "key rejected",
58
+ "bad key",
59
+ "wrong key",
60
+ "401",
61
+ "unauthorized",
62
+ ),
63
+ ),
64
+ (
65
+ "diagnose",
66
+ (
67
+ "what's wrong",
68
+ "whats wrong",
69
+ "why no reply",
70
+ "not working",
71
+ "not replying",
72
+ "help me",
73
+ "what happened",
74
+ ),
75
+ ),
76
+ (
77
+ "getting_started",
78
+ (
79
+ "get started",
80
+ "how do i start",
81
+ "how to start",
82
+ "new here",
83
+ "first time",
84
+ ),
85
+ ),
86
+ ]
87
+
88
+ _INTENT_PLAYBOOK: dict[str, dict[str, Any]] = {
89
+ "add_provider_keys": {
90
+ "message": "Connect an LLM provider to enable chat.",
91
+ "hint": "OpenRouter is the fastest path — paste your API key under Connected accounts.",
92
+ "action": {"type": "open_settings", "tab": "providers"},
93
+ "action_label": "Add API key",
94
+ "code": "concierge_add_keys",
95
+ },
96
+ "change_model": {
97
+ "message": "Choose which model this agent uses.",
98
+ "hint": "Open the model picker and select a model your provider supports.",
99
+ "action": {"type": "open_settings", "tab": "model"},
100
+ "action_label": "Choose model",
101
+ "code": "concierge_change_model",
102
+ },
103
+ "provider_credits": glossary.playbook_entry("provider_no_credits"),
104
+ "invalid_key": glossary.playbook_entry("provider_invalid_key"),
105
+ "diagnose": {
106
+ "message": "I can help you troubleshoot setup.",
107
+ "hint": "Connect a provider first, then pick a live model. If chat still fails, check credits and the model name.",
108
+ "action": {"type": "open_settings", "tab": "providers"},
109
+ "action_label": "Connected accounts",
110
+ "secondary_action": {"type": "open_settings", "tab": "model"},
111
+ "secondary_action_label": "Choose model",
112
+ "code": "concierge_diagnose",
113
+ },
114
+ "getting_started": {
115
+ "message": "Welcome to Workframe. Three steps to your first agent reply:",
116
+ "hint": "1) Connect an LLM provider · 2) Pick a model · 3) Send a message in chat.",
117
+ "action": {"type": "open_settings", "tab": "providers"},
118
+ "action_label": "Connect provider",
119
+ "code": "concierge_getting_started",
120
+ },
121
+ "fallback_help": glossary.playbook_entry("no_llm_provider"),
122
+ }
123
+
124
+
125
+ def _normalize(text: str) -> str:
126
+ return re.sub(r"\s+", " ", str(text or "").lower().strip())
127
+
128
+
129
+ def match_intent(user_text: str) -> str:
130
+ norm = _normalize(user_text)
131
+ if not norm:
132
+ return "fallback_help"
133
+ for intent_id, patterns in _INTENTS:
134
+ for pat in patterns:
135
+ if pat in norm:
136
+ return intent_id
137
+ return "fallback_help"
138
+
139
+
140
+ def respond(
141
+ user_text: str,
142
+ *,
143
+ situation: str = "",
144
+ last_error: dict[str, Any] | None = None,
145
+ ) -> dict[str, Any]:
146
+ """Return concierge playbook for user message or classified error."""
147
+ if last_error and str(last_error.get("code") or "") not in ("", "concierge"):
148
+ entry = dict(last_error)
149
+ entry.setdefault("message", entry.get("text") or "")
150
+ hint = str(entry.get("hint") or "").strip()
151
+ if hint:
152
+ entry["hint"] = f"{hint} You can also ask how to connect keys or change models."
153
+ return glossary.notice_payload(entry)
154
+
155
+ if situation == "no_provider":
156
+ intent = match_intent(user_text)
157
+ if intent == "fallback_help":
158
+ return glossary.notice_payload(_INTENT_PLAYBOOK["getting_started"])
159
+ return glossary.notice_payload(_INTENT_PLAYBOOK.get(intent) or _INTENT_PLAYBOOK["fallback_help"])
160
+
161
+ intent = match_intent(user_text)
162
+ row = dict(_INTENT_PLAYBOOK.get(intent) or _INTENT_PLAYBOOK["fallback_help"])
163
+ return glossary.notice_payload(row)
164
+
165
+
166
+ if __name__ == "__main__":
167
+ r = respond("how do I add keys?", situation="no_provider")
168
+ assert r["code"] == "concierge_add_keys"
169
+ r2 = respond("how do I set model?", situation="no_provider")
170
+ assert r2["code"] == "concierge_change_model"
171
+ r3 = respond("hello", situation="no_provider")
172
+ assert r3["code"] == "concierge_getting_started"
173
+ print("concierge ok")
@@ -0,0 +1,236 @@
1
+ """Classify LLM/provider failures into didactic copy + UI actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ # ponytail: static playbook — extend rows, no LLM generation
10
+ _ACTIONS: dict[str, dict[str, Any]] = {
11
+ "open_providers": {"type": "open_settings", "tab": "providers"},
12
+ "open_model": {"type": "open_settings", "tab": "model"},
13
+ "retry": {"type": "retry"},
14
+ "openrouter_credits": {
15
+ "type": "external_link",
16
+ "url": "https://openrouter.ai/credits",
17
+ "label": "Add credits",
18
+ },
19
+ "openrouter_keys": {
20
+ "type": "external_link",
21
+ "url": "https://openrouter.ai/keys",
22
+ "label": "Manage keys",
23
+ },
24
+ }
25
+
26
+ PLAYBOOK: dict[str, dict[str, Any]] = {
27
+ "no_llm_provider": {
28
+ "message": "No model provider is connected for your account yet.",
29
+ "hint": "Connect OpenRouter or another LLM provider under Connected accounts, then try again.",
30
+ "action": _ACTIONS["open_providers"],
31
+ "action_label": "Connect provider",
32
+ },
33
+ "provider_invalid_key": {
34
+ "message": "Your API key was rejected by the provider.",
35
+ "hint": "Reset the key at your provider, then paste it again under Connected accounts.",
36
+ "action": _ACTIONS["open_providers"],
37
+ "action_label": "Update API key",
38
+ "secondary_action": _ACTIONS["openrouter_keys"],
39
+ "secondary_action_label": "Open provider keys",
40
+ },
41
+ "provider_no_credits": {
42
+ "message": "Your provider account has no credits remaining.",
43
+ "hint": "Add credits at your provider billing page, then send your message again.",
44
+ "action": _ACTIONS["openrouter_credits"],
45
+ "action_label": "Add credits",
46
+ "secondary_action": _ACTIONS["open_model"],
47
+ "secondary_action_label": "Choose another model",
48
+ },
49
+ "model_unavailable": {
50
+ "message": "The selected model is not available on your provider right now.",
51
+ "hint": "Pick a different model in Settings. Free models on OpenRouter rotate often.",
52
+ "action": _ACTIONS["open_model"],
53
+ "action_label": "Choose model",
54
+ },
55
+ "model_invalid_id": {
56
+ "message": "That model ID is not valid for your provider.",
57
+ "hint": "Open the model picker and select a model from the list, or type a known provider/model id.",
58
+ "action": _ACTIONS["open_model"],
59
+ "action_label": "Choose model",
60
+ },
61
+ "provider_rate_limited": {
62
+ "message": "The provider rate-limited this request.",
63
+ "hint": "Wait a minute or switch to another model.",
64
+ "action": _ACTIONS["open_model"],
65
+ "action_label": "Choose model",
66
+ },
67
+ "invalid_lease": {
68
+ "message": "Your session credential expired before the model replied.",
69
+ "hint": "Send the message again — Workframe will issue a fresh credential.",
70
+ "action": _ACTIONS["retry"],
71
+ "action_label": "Try again",
72
+ },
73
+ "provider_empty_reply": {
74
+ "message": "The model provider returned no reply.",
75
+ "hint": "Check your API key, credits, and model selection in Settings.",
76
+ "action": _ACTIONS["open_model"],
77
+ "action_label": "Check model",
78
+ "secondary_action": _ACTIONS["open_providers"],
79
+ "secondary_action_label": "Check API key",
80
+ },
81
+ "provider_gateway_error": {
82
+ "message": "Workframe could not reach the model gateway.",
83
+ "hint": "Confirm the stack is running and try again.",
84
+ "action": _ACTIONS["retry"],
85
+ "action_label": "Try again",
86
+ },
87
+ "unknown_provider_error": {
88
+ "message": "The model request failed.",
89
+ "hint": "Check Connected accounts and the model in Settings.",
90
+ "action": _ACTIONS["open_providers"],
91
+ "action_label": "Connected accounts",
92
+ "secondary_action": _ACTIONS["open_model"],
93
+ "secondary_action_label": "Choose model",
94
+ },
95
+ }
96
+
97
+
98
+ def playbook_entry(code: str, **overrides: Any) -> dict[str, Any]:
99
+ """Return a copy of a playbook row with optional string format overrides."""
100
+ base = dict(PLAYBOOK.get(code) or PLAYBOOK["unknown_provider_error"])
101
+ base["code"] = code
102
+ for key, value in overrides.items():
103
+ if value is not None and key in base and isinstance(base[key], str):
104
+ try:
105
+ base[key] = base[key].format(**overrides)
106
+ except (KeyError, ValueError):
107
+ base[key] = str(value)
108
+ elif value is not None:
109
+ base[key] = value
110
+ return base
111
+
112
+
113
+ def _parse_json_blob(raw: str) -> dict[str, Any] | None:
114
+ text = str(raw or "").strip()
115
+ if not text or text[0] not in "{[":
116
+ return None
117
+ try:
118
+ data = json.loads(text)
119
+ except json.JSONDecodeError:
120
+ return None
121
+ return data if isinstance(data, dict) else None
122
+
123
+
124
+ def _extract_error_message(data: dict[str, Any] | None, raw: str = "") -> str:
125
+ if data:
126
+ err = data.get("error")
127
+ if isinstance(err, dict):
128
+ msg = str(err.get("message") or "").strip()
129
+ if msg:
130
+ return msg
131
+ if isinstance(err, str) and err.strip():
132
+ return err.strip()
133
+ for key in ("message", "detail"):
134
+ val = data.get(key)
135
+ if isinstance(val, str) and val.strip():
136
+ return val.strip()
137
+ return str(raw or "").strip()
138
+
139
+
140
+ def _model_from_text(text: str) -> str:
141
+ m = re.search(r"for\s+([a-z0-9_.:/-]+)", text, re.I)
142
+ return m.group(1) if m else ""
143
+
144
+
145
+ def classify_upstream(
146
+ status: int,
147
+ body: bytes | str | None = None,
148
+ *,
149
+ provider: str = "openrouter",
150
+ model: str = "",
151
+ ) -> dict[str, Any]:
152
+ """Map upstream HTTP status + body to playbook entry."""
153
+ raw = body.decode("utf-8", errors="replace") if isinstance(body, (bytes, bytearray)) else str(body or "")
154
+ data = _parse_json_blob(raw)
155
+ msg = _extract_error_message(data, raw)
156
+ low = msg.lower()
157
+ model_id = model or _model_from_text(msg)
158
+
159
+ if status == 401 or "invalid api key" in low or "incorrect api key" in low or "unauthorized" in low:
160
+ return playbook_entry("provider_invalid_key", provider=provider)
161
+ if status == 402 or "insufficient" in low and "credit" in low or "payment required" in low:
162
+ return playbook_entry("provider_no_credits", provider=provider)
163
+ if status == 429 or "rate limit" in low:
164
+ return playbook_entry("provider_rate_limited", provider=provider)
165
+ if status == 400 and ("not a valid model" in low or "invalid model" in low):
166
+ entry = playbook_entry("model_invalid_id", model=model_id or "selected model")
167
+ if model_id:
168
+ entry["message"] = f"Model `{model_id}` is not a valid id on {provider}."
169
+ return entry
170
+ if status == 404 or "no endpoints found" in low or "model not found" in low:
171
+ entry = playbook_entry("model_unavailable", model=model_id or "selected model")
172
+ if model_id:
173
+ entry["message"] = f"Model `{model_id}` has no live endpoints on {provider} right now."
174
+ return entry
175
+ if status >= 500:
176
+ return playbook_entry("provider_gateway_error", provider=provider)
177
+ return playbook_entry("unknown_provider_error", provider=provider, detail=msg[:200] if msg else "")
178
+
179
+
180
+ def classify_exception_text(text: str) -> dict[str, Any]:
181
+ raw = str(text or "").strip()
182
+ if not raw:
183
+ return playbook_entry("provider_empty_reply")
184
+ if "no_llm_provider_for_user" in raw:
185
+ return playbook_entry("no_llm_provider")
186
+ if "invalid_lease" in raw or "credential expired" in raw.lower():
187
+ return playbook_entry("invalid_lease")
188
+ m = re.search(r"HTTP (\d{3})", raw)
189
+ if m:
190
+ return classify_upstream(int(m.group(1)), raw)
191
+ low = raw.lower()
192
+ if "no endpoints found" in low or "model" in low and "not found" in low:
193
+ return classify_upstream(404, raw)
194
+ if "not a valid model" in low:
195
+ return classify_upstream(400, raw)
196
+ if "rate limit" in low:
197
+ return classify_upstream(429, raw)
198
+ if "credit" in low:
199
+ return classify_upstream(402, raw)
200
+ if "api key" in low or "unauthorized" in low:
201
+ return classify_upstream(401, raw)
202
+ return playbook_entry("unknown_provider_error", detail=raw[:200])
203
+
204
+
205
+ def notice_payload(entry: dict[str, Any]) -> dict[str, Any]:
206
+ """SSE/JSON-safe dict for UI."""
207
+ out: dict[str, Any] = {
208
+ "code": str(entry.get("code") or "unknown_provider_error"),
209
+ "message": str(entry.get("message") or ""),
210
+ "hint": str(entry.get("hint") or ""),
211
+ "text": str(entry.get("message") or ""),
212
+ "error": str(entry.get("message") or ""),
213
+ }
214
+ action = entry.get("action")
215
+ if isinstance(action, dict):
216
+ out["action"] = action
217
+ label = entry.get("action_label")
218
+ if label:
219
+ out["action_label"] = str(label)
220
+ sec = entry.get("secondary_action")
221
+ if isinstance(sec, dict):
222
+ out["secondary_action"] = sec
223
+ sec_label = entry.get("secondary_action_label")
224
+ if sec_label:
225
+ out["secondary_action_label"] = str(sec_label)
226
+ return out
227
+
228
+
229
+ if __name__ == "__main__":
230
+ e = classify_upstream(404, b'{"error":{"message":"No endpoints found for openrouter/owl-alpha."}}', model="openrouter/owl-alpha")
231
+ assert e["code"] == "model_unavailable"
232
+ e2 = classify_exception_text("no_llm_provider_for_user: Connect an LLM provider")
233
+ assert e2["code"] == "no_llm_provider"
234
+ e3 = classify_upstream(401, b'{"error":"Invalid API key"}')
235
+ assert e3["code"] == "provider_invalid_key"
236
+ print("llm_error_glossary ok")
@@ -144,6 +144,9 @@ def _build_upstream_request(
144
144
  }
145
145
  }
146
146
  upstream_headers.update(upstream_auth_header(provider, secret))
147
+ if provider == "openrouter":
148
+ upstream_headers.setdefault("HTTP-Referer", "https://workfra.me")
149
+ upstream_headers.setdefault("X-Title", "Workframe")
147
150
 
148
151
  req = urllib.request.Request(url, data=body, headers=upstream_headers, method=method.upper())
149
152
  return req, None
@@ -0,0 +1,114 @@
1
+ """Live OpenRouter model list with short TTL cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ _CACHE: dict[str, Any] = {"at": 0.0, "rows": []}
12
+ _TTL_SEC = int(__import__("os").environ.get("WORKFRAME_OPENROUTER_CATALOG_TTL", "3600"))
13
+
14
+
15
+ def _fetch_models(api_key: str) -> list[dict[str, str]]:
16
+ req = urllib.request.Request(
17
+ "https://openrouter.ai/api/v1/models",
18
+ headers={
19
+ "Authorization": f"Bearer {api_key}",
20
+ "HTTP-Referer": "https://workfra.me",
21
+ "X-Title": "Workframe",
22
+ },
23
+ method="GET",
24
+ )
25
+ with urllib.request.urlopen(req, timeout=45) as resp:
26
+ data = json.loads(resp.read())
27
+ rows: list[dict[str, str]] = []
28
+ for item in data.get("data") or []:
29
+ if not isinstance(item, dict):
30
+ continue
31
+ mid = str(item.get("id") or "").strip()
32
+ if not mid:
33
+ continue
34
+ name = str(item.get("name") or mid.rsplit("/", 1)[-1]).strip()
35
+ pricing = item.get("pricing") if isinstance(item.get("pricing"), dict) else {}
36
+ prompt = str(pricing.get("prompt") or "0")
37
+ completion = str(pricing.get("completion") or "0")
38
+ free = prompt in ("0", "0.0") and completion in ("0", "0.0")
39
+ rows.append(
40
+ {
41
+ "provider": "OpenRouter",
42
+ "model": mid,
43
+ "label": name,
44
+ "description": f"{'Free tier · ' if free else ''}{mid}",
45
+ },
46
+ )
47
+ rows.sort(key=lambda r: (0 if ":free" in r["model"] else 1, r["label"].lower()))
48
+ return rows[:120]
49
+
50
+
51
+ def live_suggestions(api_key: str = "", *, limit: int = 40) -> list[dict[str, str]]:
52
+ """Return cached OpenRouter suggestions; empty without a key."""
53
+ key = str(api_key or "").strip()
54
+ if not key:
55
+ return []
56
+ now = time.time()
57
+ if _CACHE["rows"] and now - float(_CACHE["at"]) < _TTL_SEC:
58
+ return list(_CACHE["rows"])[:limit]
59
+ try:
60
+ rows = _fetch_models(key)
61
+ except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):
62
+ return list(_CACHE["rows"])[:limit]
63
+ _CACHE["at"] = now
64
+ _CACHE["rows"] = rows
65
+ return rows[:limit]
66
+
67
+
68
+ def probe_account(api_key: str) -> dict[str, Any]:
69
+ """OpenRouter /key + /credits health for connect-time feedback."""
70
+ key = str(api_key or "").strip()
71
+ if not key:
72
+ return {"ok": False, "error": "missing_key"}
73
+ headers = {
74
+ "Authorization": f"Bearer {key}",
75
+ "HTTP-Referer": "https://workfra.me",
76
+ "X-Title": "Workframe",
77
+ }
78
+ out: dict[str, Any] = {"ok": True, "provider": "openrouter"}
79
+ try:
80
+ with urllib.request.urlopen(
81
+ urllib.request.Request("https://openrouter.ai/api/v1/key", headers=headers, method="GET"),
82
+ timeout=30,
83
+ ) as resp:
84
+ key_data = json.loads(resp.read()).get("data") or {}
85
+ out["key_valid"] = True
86
+ out["label"] = str(key_data.get("label") or "")
87
+ out["usage"] = key_data.get("usage")
88
+ except urllib.error.HTTPError as exc:
89
+ out["ok"] = False
90
+ out["key_valid"] = False
91
+ out["error"] = f"HTTP {exc.code}"
92
+ return out
93
+ except OSError as exc:
94
+ return {"ok": False, "key_valid": False, "error": str(exc)}
95
+ try:
96
+ with urllib.request.urlopen(
97
+ urllib.request.Request("https://openrouter.ai/api/v1/credits", headers=headers, method="GET"),
98
+ timeout=30,
99
+ ) as resp:
100
+ cred = json.loads(resp.read()).get("data") or {}
101
+ total = cred.get("total_credits")
102
+ usage = cred.get("total_usage")
103
+ out["credits_total"] = total
104
+ out["credits_usage"] = usage
105
+ if total is not None and usage is not None:
106
+ out["credits_remaining"] = max(0.0, float(total) - float(usage))
107
+ except (urllib.error.HTTPError, OSError, json.JSONDecodeError, TypeError, ValueError):
108
+ pass
109
+ return out
110
+
111
+
112
+ if __name__ == "__main__":
113
+ assert live_suggestions("") == []
114
+ print("openrouter_catalog ok (no network without key)")