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.
- package/README.md +14 -1
- package/SECURITY.md +3 -3
- package/bin/create-workframe.js +3 -3
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +13 -7
- package/workframe-api/README.md +3 -5
- package/workframe-api/concierge.py +173 -0
- package/workframe-api/llm_error_glossary.py +236 -0
- package/workframe-api/llm_proxy.py +3 -0
- package/workframe-api/openrouter_catalog.py +114 -0
- package/workframe-api/package.json +1 -1
- package/workframe-api/server.py +178 -50
- package/workframe-api/updates.py +107 -18
- package/workframe-supervisor/server.py +75 -2
- package/workframe-ui/public/assets/{arc-COAT3laO.js → arc-BskqxLmE.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DOxsUI33.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BFjWV24l.js → architectureDiagram-3BPJPVTR-B3eGdqvq.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DSQLPfrj.js → blockDiagram-GPEHLZMM-CzFZG7uX.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DKEHv1t2.js → c4Diagram-AAUBKEIU-BS-gGyCH.js} +1 -1
- package/workframe-ui/public/assets/channel-Baa20pIs.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-DHZg-DUi.js → chunk-2J33WTMH-Cjvqf7c9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-BB-OYTfp.js → chunk-3OPIFGDE--eUuL8AK.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-C93q0YIm.js → chunk-4BX2VUAB-s9lHUFKL.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-MAYniqik.js → chunk-55IACEB6-D1McA5nz.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-ChgN6YJs.js → chunk-5ZQYHXKU-DIstLfU2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-B_FYwdAv.js → chunk-727SXJPM-Cd0GD2CO.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-1_Hw_h1A.js → chunk-AQP2D5EJ-gc5Ra6G4.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CFiDQ1Rv.js → chunk-BSJP7CBP-Cg5QfihJ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-DZ9UMTlB.js → chunk-CSCIHK7Q-D_gobbhY.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DlMlyFgw.js → chunk-FMBD7UC4-B6eLY9_d.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-DHXtQ_Hf.js → chunk-KSCS5N6A-BGroAeW2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CuQzg-QG.js → chunk-L5ZTLDWV-34xv7WgT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-BHzjzCGg.js → chunk-LZXEDZCA-XJBctFZw.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-DHXx05n2.js → chunk-ND2GUHAM-nTOZpln8.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CV5pmDM_.js → chunk-NZK2D7GU-BBrzcaPH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-6tkCHxsV.js → chunk-O5CBEL6O-Cntncx6L.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-B7tPxs4H.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-Dvh2By1o.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-BClwIiCp.js → chunk-XPW4576I-DSbtd0cg.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BljHyDzF.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BljHyDzF.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DOrGV6DQ.js → cose-bilkent-S5V4N54A-lmYstEMQ.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DXTPvJkX.js → dagre-BM42HDAG-Bdkbdul2.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-xX_v-pbf.js → diagram-2AECGRRQ-Clb9kSXe.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-Cd2pXbBe.js → diagram-5GNKFQAL-BApg0yec.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df3XvUtk.js → diagram-KO2AKTUF-peZjDRV6.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CsijIPaD.js → diagram-LMA3HP47-DZm_1pjc.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-aq5fmfHd.js → diagram-OG6HWLK6-DP_N6fYL.js} +1 -1
- package/workframe-ui/public/assets/{dist-D1c0mkbB.js → dist-CqP43hXK.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-DnFysVRY.js → erDiagram-TEJ5UH35-Blfs6kLv.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-C4xwO6dx.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C6Ebi3su.js → flowDiagram-I6XJVG4X-CcmMOkwH.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-BQXQtUpa.js → ganttDiagram-6RSMTGT7-ByoO3dF7.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-Dt0zIs_M.js → gitGraph-WXDBUCRP-DCyTpmPs.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BF8gHzRn.js → gitGraphDiagram-PVQCEYII-CvLYB55b.js} +1 -1
- package/workframe-ui/public/assets/index-BIfRGM8p.js +133 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-CSmszQJT.js → info-J43DQDTF-CfSRIfOI.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CVTKGW6p.js → infoDiagram-5YYISTIA-DwS44DS6.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js → ishikawaDiagram-YF4QCWOH-MRlyodyU.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-r3wD68_T.js → journeyDiagram-JHISSGLW-sq7awk87.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Il8VglqN.js → kanban-definition-UN3LZRKU-CH6HbcWu.js} +1 -1
- package/workframe-ui/public/assets/{line-oyjpfz2A.js → line-DRBjFmUW.js} +1 -1
- package/workframe-ui/public/assets/{linear-Cf7p5tVp.js → linear-BR0LunjE.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-YmbZ-AfY.js → mermaid-parser.core-Cl1GjHkT.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-BFdCAqCo.js → mermaid.core-DsiKK1kE.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Cy2iCtEl.js → mindmap-definition-RKZ34NQL-DBvi3OpW.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-DwOBZL6K.js → packet-YPE3B663-CPDgtUHg.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-04PPhnKK.js → pie-LRSECV5Y-BXJIPPDm.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-LxIpgHqi.js → pieDiagram-4H26LBE5-CZwLEK-o.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-0nBYfYm4.js → quadrantDiagram-W4KKPZXB-B65bEUIQ.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-D2-vBqps.js → radar-GUYGQ44K-vXHkZzRK.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DbuU0nlu.js → requirementDiagram-4Y6WPE33-BGWdvDfG.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-B2hQ6B2x.js → sankeyDiagram-5OEKKPKP-BT2oC8dV.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BBrU30e1.js → sequenceDiagram-3UESZ5HK-45yj0Wtx.js} +1 -1
- package/workframe-ui/public/assets/{src-BJEDmV70.js → src-D-P8U2PB.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-7FGO4kkH.js → stateDiagram-AJRCARHV-SBrBJBZG.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B3im3So2.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-ptDm4rCN.js → timeline-definition-PNZ67QCA-DegmxXXj.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CS6Z-0q8.js → treeView-BLDUP644-BVy7CsCj.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DqV4Y2VA.js → treemap-LRROVOQU-BD5Kb74J.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-C0UrZJYt.js → vennDiagram-CIIHVFJN-hwAkNbl3.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-bNDN3_Sa.js → wardley-L42UT6IY-CBMPT8Y1.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-jWiJsefM.js → wardleyDiagram-YWT4CUSO-DXqihVlE.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-Dsh_fLCy.js → xychartDiagram-2RQKCTM6-Cbb4LBzj.js} +1 -1
- package/workframe-ui/public/index.html +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +0 -1
- package/workframe-ui/public/assets/channel-g7r_RGaY.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +0 -1
- package/workframe-ui/public/assets/index-lRpzpNPT.js +0 -133
- 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.
|
|
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 (
|
|
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
|
|
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
|
|
package/bin/create-workframe.js
CHANGED
|
@@ -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:
|
|
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:
|
|
2023
|
+
model: anthropic/claude-sonnet-4.5
|
|
2024
2024
|
- provider: openrouter
|
|
2025
|
-
model:
|
|
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
|
@@ -31,18 +31,24 @@ function copyTree(src, dst) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const skipBuild = process.argv.includes('--skip-build');
|
|
35
|
+
|
|
34
36
|
if (!fs.existsSync(UI_SRC)) {
|
|
35
37
|
console.error(`apps/web source not found: ${UI_SRC}`);
|
|
36
38
|
process.exit(1);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
if (!skipBuild) {
|
|
42
|
+
console.log('Building @workframe/web...');
|
|
43
|
+
const build = spawnSync(npmCmd(), ['run', 'build'], {
|
|
44
|
+
cwd: UI_SRC,
|
|
45
|
+
stdio: 'inherit',
|
|
46
|
+
shell: process.platform === 'win32',
|
|
47
|
+
});
|
|
48
|
+
if (build.status !== 0) process.exit(build.status ?? 1);
|
|
49
|
+
} else {
|
|
50
|
+
console.log('Skipping @workframe/web build (--skip-build)');
|
|
51
|
+
}
|
|
46
52
|
|
|
47
53
|
const dist = path.join(UI_SRC, 'dist');
|
|
48
54
|
if (!fs.existsSync(path.join(dist, 'index.html'))) {
|
package/workframe-api/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# Workframe API Service
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
- [
|
|
22
|
+
- [Operations](../../docs/public/operations.md)
|
|
25
23
|
- [Security](../../docs/public/security.md)
|
|
26
|
-
- [
|
|
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)")
|