Alibrown commited on
Commit
5ef3828
·
verified ·
1 Parent(s): 45ed3d8

Upload 58 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ app/db/skills/core/core.db filter=lfs diff=lfs merge=lfs -text
.github/FUNDING.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # These are supported funding model platforms
2
+
3
+ github: [volkansah]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: volkansah
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
.github/scripts/generate_structure.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # =============================================================================
3
+ # generate_structure.py
4
+ # Generates PROJECT_STRUCTURE.md with a visual repo tree.
5
+ # Place in: .codey/scripts/generate_structure.py
6
+ # =============================================================================
7
+ # PYTHON VERSION NOTE:
8
+ # Embedding \n inside f-string expressions like f"{'\n'.join(x)}" is only
9
+ # supported from Python 3.12+. On 3.10/3.11 (GitHub Actions default) this
10
+ # causes a SyntaxError: "unterminated string literal".
11
+ # FIX: always build the joined string BEFORE the f-string block.
12
+ # Example:
13
+ # BAD → f"```\n{'\n'.join(tree)}\n```" # crashes on 3.10/3.11
14
+ # GOOD → tree_str = "\n".join(tree)
15
+ # then use tree_str inside f-string or concatenation
16
+ # =============================================================================
17
+
18
+ from pathlib import Path
19
+ from datetime import datetime
20
+
21
+ # ─────────────────────────────────────────────
22
+ # IGNORE LIST — folders/files to skip
23
+ # ─────────────────────────────────────────────
24
+ IGNORE = {
25
+ '.git',
26
+ '__pycache__',
27
+ '.pytest_cache',
28
+ '*.pyc',
29
+ '.DS_Store',
30
+ 'node_modules',
31
+ '.venv',
32
+ 'venv',
33
+ }
34
+
35
+ def is_ignored(path: Path) -> bool:
36
+ name = path.name
37
+ for pattern in IGNORE:
38
+ if '*' in pattern:
39
+ if path.match(pattern):
40
+ return True
41
+ elif name == pattern:
42
+ return True
43
+ return False
44
+
45
+
46
+ def generate_tree(root: Path, prefix: str = "", is_last: bool = True) -> list:
47
+ lines = []
48
+ connector = "└── " if is_last else "├── "
49
+
50
+ if root == Path("."):
51
+ lines.append(root.resolve().name + "/")
52
+ children = sorted(root.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
53
+ visible = [c for c in children if not is_ignored(c)]
54
+ for i, child in enumerate(visible):
55
+ lines.extend(generate_tree(child, prefix="", is_last=(i == len(visible) - 1)))
56
+ return lines
57
+
58
+ if root.is_dir():
59
+ lines.append(f"{prefix}{connector}{root.name}/")
60
+ extension = " " if is_last else "│ "
61
+ children = sorted(root.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
62
+ visible = [c for c in children if not is_ignored(c)]
63
+ for i, child in enumerate(visible):
64
+ lines.extend(generate_tree(child, prefix + extension, is_last=(i == len(visible) - 1)))
65
+ else:
66
+ lines.append(f"{prefix}{connector}{root.name}")
67
+
68
+ return lines
69
+
70
+
71
+ def main():
72
+ root = Path(".")
73
+ tree = generate_tree(root)
74
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
75
+
76
+ # Build tree string BEFORE f-string — required for Python < 3.12
77
+ tree_str = "\n".join(tree)
78
+
79
+ output = "# 📁 Project Structure\n\n"
80
+ output += f"> Auto-generated by `generate_structure.py` — {now}\n"
81
+ output += "> Do not edit manually.\n\n"
82
+ output += "```\n"
83
+ output += tree_str + "\n"
84
+ output += "```\n"
85
+
86
+ out_path = Path("PROJECT_STRUCTURE.md")
87
+ out_path.write_text(output, encoding="utf-8")
88
+ print(f"✓ PROJECT_STRUCTURE.md written ({len(tree)} lines)")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
.github/workflows/clean_repo.example ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Clean Slate (Custom Keep List)
2
+
3
+ on:
4
+ workflow_dispatch:
5
+
6
+ permissions:
7
+ contents: write
8
+
9
+ jobs:
10
+ wipe-repo:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout Repository
14
+ uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+
18
+ - name: Rebuild Clean Branch with Whitelist
19
+ run: |
20
+ git config user.name "github-actions[bot]"
21
+ git config user.email "github-actions[bot]@users.noreply.github.com"
22
+
23
+ # 1. Orphan Branch ohne Historie erstellen
24
+ git checkout --orphan temp-clean-slate
25
+
26
+ # 2. Nur den Git-Index leeren (Festplatte bleibt unangetastet)
27
+ git read-tree --empty
28
+
29
+ # =================================================================
30
+ # WHITELIST: Hier definierst du, was ERHALTEN bleiben soll.
31
+ # Nicht benötigte Zeilen einfach mit # auskommentieren!
32
+ # =================================================================
33
+
34
+ # Core / Infrastructure
35
+ [ -e .github ] && git add .github/
36
+ [ -e README.md ] && git add README.md
37
+ [ -e .gitignore ] && git add .gitignore
38
+ [ -e .gitattributes ] && git add .gitattributes
39
+
40
+ # Documentation & Legal
41
+ [ -e LICENSE ] && git add LICENSE
42
+ [ -e SECURITY.md ] && git add SECURITY.md
43
+ [ -e STATUS.md ] && git add STATUS.md
44
+ [ -e CONTRIBUTING.md ] && git add CONTRIBUTING.md
45
+ [ -e CODE_OF_CONDUCT.md ] && git add CODE_OF_CONDUCT.md
46
+ [ -e CITATION.cff ] && git add CITATION.cff
47
+
48
+ # Individuelle Ergänzungen
49
+ # [ -e .pyfun ] && git add .pyfun
50
+ # =================================================================
51
+
52
+ # 3. Sauberen Commit erstellen
53
+ git commit -m "style: reset repository keeping whitelisted core metadata"
54
+
55
+ # 4. Lokalen main-Branch durch den sauberen Branch ersetzen
56
+ git branch -D main
57
+ git branch -m main
58
+
59
+ - name: Force Push to Main
60
+ run: |
61
+ git push origin main --force
.github/workflows/generate_structure.example ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # WORKFLOW: Generate Repo Structure - [Generate Repo Structure]
3
+ # PART OF: Codey - No Mercy EDITION
4
+ # =============================================================================
5
+ # Role: Automated Audit & Compliance (ESOL v1.1)
6
+ # Copyright: (c) 2026 VolkanSah
7
+ # License: Apache 2.0 + ESOL v1.1 (https://github.com/VolkanSah/ESOL)
8
+ # Enforcement: Jurisdiction Berlin, Germany (StGB & DSGVO)
9
+ # =============================================================================
10
+
11
+ # Name of the workflow used to visualize the file hierarchy
12
+ name: Generate Repo Structure
13
+
14
+ # Trigger: Manual execution only (allows the dev to choose when to update the map)
15
+ on:
16
+ workflow_dispatch:
17
+
18
+ # Permissions: Granting write access so the bot can save the generated Markdown
19
+ permissions:
20
+ contents: write
21
+
22
+ jobs:
23
+ # Job to analyze and map the directory tree
24
+ structure:
25
+ # Running on the latest stable Ubuntu environment
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ # Step 1: Pull the latest code using a custom token
29
+ - uses: actions/checkout@v4
30
+ with:
31
+ token: ${{ secrets.GIT_TOKEN_AIHUB }}
32
+
33
+ # Step 2: Prepare Python for the generator script
34
+ - uses: actions/setup-python@v5
35
+ with:
36
+ python-version: '3.11'
37
+
38
+ # Step 3: Run the custom logic that scans directories and creates PROJECT_STRUCTURE.md
39
+ - name: Generate structure
40
+ run: python .github/scripts/generate_structure.py
41
+
42
+ # Step 4: Finalize by pushing the new "Map" to the repository root
43
+ - name: Commit structure
44
+ run: |
45
+ # Identity setup for the automated commit
46
+ git config user.name "Codey Bot"
47
+ git config user.email "codey@bot"
48
+
49
+ # Stage the specific file generated by the script
50
+ git add PROJECT_STRUCTURE.md
51
+
52
+ # Check for changes to prevent empty commits; [skip ci] avoids re-triggering workflows
53
+ git diff --staged --quiet || git commit -m "📁 update project structure [skip ci]"
54
+
55
+ # Push the updated structure map back to the origin
56
+ git push
.gitignore CHANGED
@@ -1,19 +1,12 @@
1
- # Universal-MCP-server-sandox
2
-
3
- README.MD
4
- .gitignore
5
- app/*
6
-
7
-
8
-
9
-
10
-
11
 
12
  .env
13
  # Byte-compiled / optimized / DLL files
14
  __pycache__/
15
  *.py[codz]
16
  *$py.class
 
 
 
17
 
18
 
19
  # C extensions
@@ -218,3 +211,12 @@ cython_debug/
218
  marimo/_static/
219
  marimo/_lsp/
220
  __marimo__/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
2
  .env
3
  # Byte-compiled / optimized / DLL files
4
  __pycache__/
5
  *.py[codz]
6
  *$py.class
7
+ mcp_desktop.json
8
+ DESKTOP_UI/mcp_desktop.json
9
+
10
 
11
 
12
  # C extensions
 
211
  marimo/_static/
212
  marimo/_lsp/
213
  __marimo__/
214
+
215
+ # --- Hub: Secrets / lokaler State — NIE committen ---
216
+ DESKTOP_UI/mcp_desktop.json
217
+ mcp_desktop.json
218
+ app/.hub_state.db
219
+ app/cms_database.db
220
+ # Skill-DBs (app/db/skills/**/*.db) werden committet, WAL/SHM-Begleitdateien nicht
221
+ *.db-wal
222
+ *.db-shm
CITATION.cff ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: "If you use Universal MCP HUB in your security research, please cite it as follows."
3
+ authors:
4
+ - family-names: "Kücükbudak"
5
+ given-names: "Volkan"
6
+ title: "Universal MCP Hub (Sandboxed)"
7
+ version: 0.0.1
8
+ date-released: 2026-03-09
9
+ url: "https://github.com/VolkanSah/Universal-MCP-Hub/"
CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Code of Conduct
2
+
3
+ ## The Short Version
4
+
5
+ Build things that work. Understand what you ship. Don't use this to harm people.
6
+
7
+ ---
8
+
9
+ ## What This Project Stands For
10
+
11
+ The MCP ecosystem is full of servers built for the demo, not for production. Hardcoded keys, zero sandboxing, `os.environ` everywhere — and nobody reads the code before forking it.
12
+
13
+ This project exists as a counter-example. The goal is not to be the most popular MCP server. The goal is to be one that people can actually trust — and learn from.
14
+
15
+ That shapes what kind of community belongs here.
16
+
17
+ ---
18
+
19
+ ## What We Expect
20
+
21
+ **Understand before you contribute.**
22
+ Read the architecture. Understand why `app/*` can't touch `fundaments/`. Understand why there are two databases. If something seems over-engineered, ask first — there's usually a reason.
23
+
24
+ **Security is not optional.**
25
+ Don't submit PRs that weaken the sandbox, expose secrets through new code paths, or add convenience features that bypass the Guardian pattern. If you think a security boundary is wrong, open an issue and argue the case — don't just route around it.
26
+
27
+ **AI is a tool, not a co-author.**
28
+ Using AI to help write code, docs, or tests is fine. Submitting AI-generated code you don't understand is not. You are responsible for what you put your name on. If you can't explain it in a code review, it doesn't belong here.
29
+
30
+ **Be direct, not brutal.**
31
+ Bad code should be called bad code. Vague encouragement helps nobody. But there's a difference between "this approach has a security problem because..." and being dismissive. Explain the problem. Propose a fix. Move on.
32
+
33
+ **No hype, no growth-hacking.**
34
+ Don't open issues asking to add integrations just because they're trending. Don't submit PRs that add features nobody asked for to pad a portfolio. This is a security-focused project — new features get scrutinized for attack surface, not applauded for volume.
35
+
36
+ ---
37
+
38
+ ## What We Don't Tolerate
39
+
40
+ - Using this project to harm, surveil, or exploit others — the [ESOL v1.1](ESOL) license makes this automatic grounds for termination of all rights
41
+ - Submitting intentionally malicious code, backdoors, or obfuscated logic
42
+ - Harassment, discrimination, or personal attacks — in issues, PRs, or discussions
43
+ - Deliberate misrepresentation of how the code works in documentation or comments
44
+
45
+ ---
46
+
47
+ ## On Contributing
48
+
49
+ Before opening a PR:
50
+
51
+ 1. Open an issue first for anything non-trivial — discuss the approach before writing the code
52
+ 2. Make sure your change doesn't break the sandbox rules (see [SECURITY.md](SECURITY.md))
53
+ 3. Write code you'd be comfortable explaining line by line in a review
54
+ 4. If you add a provider, add the dummy class pattern — future contributors will thank you
55
+
56
+ Small, focused PRs get reviewed faster than large ones. A PR that fixes one thing well is worth more than one that does five things loosely.
57
+
58
+ ---
59
+
60
+ ## Enforcement
61
+
62
+ Violations of the ethical use constraints in ESOL v1.1 are reported to the author directly and, where appropriate, to relevant authorities. There's no three-strikes policy for deliberate misuse.
63
+
64
+ For everything else — bad behavior in the community, violations of the conduct guidelines above — the maintainer will handle it case by case. The goal is to keep the project useful and trustworthy, not to maintain a leaderboard of infractions.
65
+
66
+ ---
67
+
68
+ *This project was built by someone who wanted to understand how things actually work — not how they look in a README. That's the kind of contributor who belongs here.*
CONTRIBUTING.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to Universal MCP Hub
2
+
3
+ Thanks for considering a contribution. Read this before opening a PR.
4
+
5
+ ---
6
+
7
+ ## Before You Start
8
+
9
+ Open an issue first for anything non-trivial. Describe what you want to change and why. This avoids situations where you spend time on a PR that conflicts with the architecture or is out of scope.
10
+
11
+ For typos, doc fixes, or obvious bugs — just open the PR directly.
12
+
13
+ ---
14
+
15
+ ## The Non-Negotiables
16
+
17
+ These rules are not up for debate in PRs. If you disagree with them, open an issue and make the case.
18
+
19
+ **1. Don't break the sandbox.**
20
+ `app/*` must never import from `fundaments/`, read `os.environ`, or access `.env` directly. The Guardian pattern (`main.py` injects services, `app/*` receives them) is the core security guarantee of this project. PRs that route around it will be closed.
21
+
22
+ **2. No secrets in code or config.**
23
+ `app/.pyfun` references ENV variable names only — never values. If your PR puts an API key, token, or credential anywhere in the codebase, it gets closed immediately.
24
+
25
+ **3. `db_query` stays SELECT-only.**
26
+ The read-only enforcement in `db_sync.py` is not a suggestion. Don't submit PRs that add write access through the MCP tool layer.
27
+
28
+ **4. New providers need a dummy too.**
29
+ If you add a new LLM or search provider, add a commented-out dummy class following the existing pattern. Future contributors should be able to add the next provider by copying your dummy.
30
+
31
+ ---
32
+
33
+ ## How to Add a New LLM Provider
34
+
35
+ Three files, nothing else:
36
+
37
+ **1. `app/providers.py`** — add your class (copy the OpenAI dummy as starting point):
38
+
39
+ ```python
40
+ class YourProvider(BaseProvider):
41
+ """Your Provider API — brief description."""
42
+
43
+ async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
44
+ data = await self._post(
45
+ f"{self.base_url}/chat/completions", # adjust endpoint
46
+ headers={
47
+ "Authorization": f"Bearer {self.key}",
48
+ "content-type": "application/json",
49
+ },
50
+ payload={
51
+ "model": model or self.model,
52
+ "max_tokens": max_tokens,
53
+ "messages": [{"role": "user", "content": prompt}],
54
+ },
55
+ )
56
+ return data["choices"][0]["message"]["content"] # adjust response parsing
57
+ ```
58
+
59
+ **2. `app/providers.py`** — register in `_PROVIDER_CLASSES`:
60
+
61
+ ```python
62
+ _PROVIDER_CLASSES = {
63
+ ...
64
+ "yourprovider": YourProvider,
65
+ }
66
+ ```
67
+
68
+ **3. `app/.pyfun`** — add provider block:
69
+
70
+ ```ini
71
+ [LLM_PROVIDER.yourprovider]
72
+ active = "true"
73
+ base_url = "https://api.yourprovider.com/v1"
74
+ env_key = "YOUR_PROVIDER_API_KEY"
75
+ default_model = "your-default-model"
76
+ models = "your-default-model, other-model"
77
+ fallback_to = ""
78
+ [LLM_PROVIDER.yourprovider_END]
79
+ ```
80
+
81
+ Then add a commented dummy of your class above the `_PROVIDER_CLASSES` dict with the `.pyfun` block as a comment — same style as OpenAI/Mistral/xAI dummies already there.
82
+
83
+ Most OpenAI-compatible APIs (Mistral, xAI, Together, etc.) need zero changes to the class body — just different `base_url` and `env_key`. Providers with non-standard auth (like Gemini's `?key=` param) need their own `complete()` implementation.
84
+
85
+ ---
86
+
87
+ ## How to Add a New Search Provider
88
+
89
+ Search providers follow the same pattern as LLM providers but register in `_SEARCH_REGISTRY` (not yet implemented — first search provider PR sets the pattern). Look at how `BraveProvider` and `TavilyProvider` are planned in `.pyfun` for reference.
90
+
91
+ ---
92
+
93
+ ## How to Add a New Tool
94
+
95
+ Tools are defined in `app/.pyfun` under `[TOOLS]` and registered in `app/mcp.py`. A tool needs:
96
+
97
+ - A `[TOOL.name]` block in `.pyfun` with `provider_type`, `default_provider`, `system_prompt` (for LLM tools)
98
+ - Registration in `mcp.py` via the appropriate `_register_*_tools()` function
99
+ - Execution logic in `app/tools.py`
100
+
101
+ Tools that require an API key must gate their registration — if the key isn't set, the tool must not register. No key, no tool, no crash. See existing tool registration in `mcp.py` for the pattern.
102
+
103
+ ---
104
+
105
+ ## Code Style
106
+
107
+ - Match the existing style — same header format, same section separators, same logging pattern
108
+ - One logger per module: `logger = logging.getLogger("module_name")`
109
+ - Async where the code is already async (`app/*`), sync where it's sync (`fundaments/*`)
110
+ - Comments explain *why*, not *what* — the code shows what
111
+
112
+ ---
113
+
114
+ ## Testing Your Changes
115
+
116
+ Before submitting:
117
+
118
+ ```bash
119
+ # install dependencies
120
+ pip install -r requirements.txt
121
+
122
+ # start with minimal config (no keys needed for basic startup test)
123
+ python main.py
124
+
125
+ # check that startup completes without errors
126
+ # check that list_active_tools returns expected results
127
+ # check that missing keys don't crash the server
128
+ ```
129
+
130
+ If you're adding a provider: test with a real key in a local `.env`. Document the response format you're parsing from in your PR description.
131
+
132
+ ---
133
+
134
+ ## PR Checklist
135
+
136
+ - [ ] Sandbox rules not broken (`app/*` has no new `os.environ` or `fundaments/` imports)
137
+ - [ ] No credentials or secrets in any file
138
+ - [ ] New provider includes dummy class + `.pyfun` block
139
+ - [ ] Startup still works with zero API keys configured
140
+ - [ ] Code follows existing style and header format
141
+ - [ ] PR description explains what changed and why
142
+
143
+ ---
144
+
145
+ ## What Won't Be Merged
146
+
147
+ - Features that add attack surface without clear security justification
148
+ - AI-generated code the author can't explain in review
149
+ - PRs that touch `fundaments/` without a very good reason — that layer is stable by design
150
+ - Dependency additions without justification — the stack is intentionally lean
151
+
152
+ ---
153
+
154
+ *Questions? Open an issue. The architecture docs in `README.md`, `SECURITY.md`, and `docs/` cover most of the "why" behind the design decisions.*
Dockerfile CHANGED
@@ -18,9 +18,8 @@ RUN pip install --no-cache-dir -r requirements.txt
18
  # Copy the rest of the application code into the working directory.
19
  COPY . .
20
 
21
- # Expose a port if your application is a web server.
22
- # For example, if your application runs on port 8000.
23
- # EXPOSE 8000
24
 
25
  # Define the command to run your application.
26
  # This command will be executed when the container starts.
 
18
  # Copy the rest of the application code into the working directory.
19
  COPY . .
20
 
21
+ # Expose the hub port (matches HUB_PORT in app/.pyfun)
22
+ EXPOSE 7860
 
23
 
24
  # Define the command to run your application.
25
  # This command will be executed when the container starts.
LICENSE CHANGED
@@ -1,201 +1,661 @@
1
- Apache License
2
- Version 2.0, January 2004
3
- http://www.apache.org/licenses/
4
-
5
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
-
7
- 1. Definitions.
8
-
9
- "License" shall mean the terms and conditions for use, reproduction,
10
- and distribution as defined by Sections 1 through 9 of this document.
11
-
12
- "Licensor" shall mean the copyright owner or entity authorized by
13
- the copyright owner that is granting the License.
14
-
15
- "Legal Entity" shall mean the union of the acting entity and all
16
- other entities that control, are controlled by, or are under common
17
- control with that entity. For the purposes of this definition,
18
- "control" means (i) the power, direct or indirect, to cause the
19
- direction or management of such entity, whether by contract or
20
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
- outstanding shares, or (iii) beneficial ownership of such entity.
22
-
23
- "You" (or "Your") shall mean an individual or Legal Entity
24
- exercising permissions granted by this License.
25
-
26
- "Source" form shall mean the preferred form for making modifications,
27
- including but not limited to software source code, documentation
28
- source, and configuration files.
29
-
30
- "Object" form shall mean any form resulting from mechanical
31
- transformation or translation of a Source form, including but
32
- not limited to compiled object code, generated documentation,
33
- and conversions to other media types.
34
-
35
- "Work" shall mean the work of authorship, whether in Source or
36
- Object form, made available under the License, as indicated by a
37
- copyright notice that is included in or attached to the work
38
- (an example is provided in the Appendix below).
39
-
40
- "Derivative Works" shall mean any work, whether in Source or Object
41
- form, that is based on (or derived from) the Work and for which the
42
- editorial revisions, annotations, elaborations, or other modifications
43
- represent, as a whole, an original work of authorship. For the purposes
44
- of this License, Derivative Works shall not include works that remain
45
- separable from, or merely link (or bind by name) to the interfaces of,
46
- the Work and Derivative Works thereof.
47
-
48
- "Contribution" shall mean any work of authorship, including
49
- the original version of the Work and any modifications or additions
50
- to that Work or Derivative Works thereof, that is intentionally
51
- submitted to Licensor for inclusion in the Work by the copyright owner
52
- or by an individual or Legal Entity authorized to submit on behalf of
53
- the copyright owner. For the purposes of this definition, "submitted"
54
- means any form of electronic, verbal, or written communication sent
55
- to the Licensor or its representatives, including but not limited to
56
- communication on electronic mailing lists, source code control systems,
57
- and issue tracking systems that are managed by, or on behalf of, the
58
- Licensor for the purpose of discussing and improving the Work, but
59
- excluding communication that is conspicuously marked or otherwise
60
- designated in writing by the copyright owner as "Not a Contribution."
61
-
62
- "Contributor" shall mean Licensor and any individual or Legal Entity
63
- on behalf of whom a Contribution has been received by Licensor and
64
- subsequently incorporated within the Work.
65
-
66
- 2. Grant of Copyright License. Subject to the terms and conditions of
67
- this License, each Contributor hereby grants to You a perpetual,
68
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
- copyright license to reproduce, prepare Derivative Works of,
70
- publicly display, publicly perform, sublicense, and distribute the
71
- Work and such Derivative Works in Source or Object form.
72
-
73
- 3. Grant of Patent License. Subject to the terms and conditions of
74
- this License, each Contributor hereby grants to You a perpetual,
75
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
- (except as stated in this section) patent license to make, have made,
77
- use, offer to sell, sell, import, and otherwise transfer the Work,
78
- where such license applies only to those patent claims licensable
79
- by such Contributor that are necessarily infringed by their
80
- Contribution(s) alone or by combination of their Contribution(s)
81
- with the Work to which such Contribution(s) was submitted. If You
82
- institute patent litigation against any entity (including a
83
- cross-claim or counterclaim in a lawsuit) alleging that the Work
84
- or a Contribution incorporated within the Work constitutes direct
85
- or contributory patent infringement, then any patent licenses
86
- granted to You under this License for that Work shall terminate
87
- as of the date such litigation is filed.
88
-
89
- 4. Redistribution. You may reproduce and distribute copies of the
90
- Work or Derivative Works thereof in any medium, with or without
91
- modifications, and in Source or Object form, provided that You
92
- meet the following conditions:
93
-
94
- (a) You must give any other recipients of the Work or
95
- Derivative Works a copy of this License; and
96
-
97
- (b) You must cause any modified files to carry prominent notices
98
- stating that You changed the files; and
99
-
100
- (c) You must retain, in the Source form of any Derivative Works
101
- that You distribute, all copyright, patent, trademark, and
102
- attribution notices from the Source form of the Work,
103
- excluding those notices that do not pertain to any part of
104
- the Derivative Works; and
105
-
106
- (d) If the Work includes a "NOTICE" text file as part of its
107
- distribution, then any Derivative Works that You distribute must
108
- include a readable copy of the attribution notices contained
109
- within such NOTICE file, excluding those notices that do not
110
- pertain to any part of the Derivative Works, in at least one
111
- of the following places: within a NOTICE text file distributed
112
- as part of the Derivative Works; within the Source form or
113
- documentation, if provided along with the Derivative Works; or,
114
- within a display generated by the Derivative Works, if and
115
- wherever such third-party notices normally appear. The contents
116
- of the NOTICE file are for informational purposes only and
117
- do not modify the License. You may add Your own attribution
118
- notices within Derivative Works that You distribute, alongside
119
- or as an addendum to the NOTICE text from the Work, provided
120
- that such additional attribution notices cannot be construed
121
- as modifying the License.
122
-
123
- You may add Your own copyright statement to Your modifications and
124
- may provide additional or different license terms and conditions
125
- for use, reproduction, or distribution of Your modifications, or
126
- for any such Derivative Works as a whole, provided Your use,
127
- reproduction, and distribution of the Work otherwise complies with
128
- the conditions stated in this License.
129
-
130
- 5. Submission of Contributions. Unless You explicitly state otherwise,
131
- any Contribution intentionally submitted for inclusion in the Work
132
- by You to the Licensor shall be under the terms and conditions of
133
- this License, without any additional terms or conditions.
134
- Notwithstanding the above, nothing herein shall supersede or modify
135
- the terms of any separate license agreement you may have executed
136
- with Licensor regarding such Contributions.
137
-
138
- 6. Trademarks. This License does not grant permission to use the trade
139
- names, trademarks, service marks, or product names of the Licensor,
140
- except as required for reasonable and customary use in describing the
141
- origin of the Work and reproducing the content of the NOTICE file.
142
-
143
- 7. Disclaimer of Warranty. Unless required by applicable law or
144
- agreed to in writing, Licensor provides the Work (and each
145
- Contributor provides its Contributions) on an "AS IS" BASIS,
146
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
- implied, including, without limitation, any warranties or conditions
148
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
- PARTICULAR PURPOSE. You are solely responsible for determining the
150
- appropriateness of using or redistributing the Work and assume any
151
- risks associated with Your exercise of permissions under this License.
152
-
153
- 8. Limitation of Liability. In no event and under no legal theory,
154
- whether in tort (including negligence), contract, or otherwise,
155
- unless required by applicable law (such as deliberate and grossly
156
- negligent acts) or agreed to in writing, shall any Contributor be
157
- liable to You for damages, including any direct, indirect, special,
158
- incidental, or consequential damages of any character arising as a
159
- result of this License or out of the use or inability to use the
160
- Work (including but not limited to damages for loss of goodwill,
161
- work stoppage, computer failure or malfunction, or any and all
162
- other commercial damages or losses), even if such Contributor
163
- has been advised of the possibility of such damages.
164
-
165
- 9. Accepting Warranty or Additional Liability. While redistributing
166
- the Work or Derivative Works thereof, You may choose to offer,
167
- and charge a fee for, acceptance of support, warranty, indemnity,
168
- or other liability obligations and/or rights consistent with this
169
- License. However, in accepting such obligations, You may act only
170
- on Your own behalf and on Your sole responsibility, not on behalf
171
- of any other Contributor, and only if You agree to indemnify,
172
- defend, and hold each Contributor harmless for any liability
173
- incurred by, or claims asserted against, such Contributor by reason
174
- of your accepting any such warranty or additional liability.
175
-
176
- END OF TERMS AND CONDITIONS
177
-
178
- APPENDIX: How to apply the Apache License to your work.
179
-
180
- To apply the Apache License to your work, attach the following
181
- boilerplate notice, with the fields enclosed by brackets "[]"
182
- replaced with your own identifying information. (Don't include
183
- the brackets!) The text should be enclosed in the appropriate
184
- comment syntax for the file format. We also recommend that a
185
- file or class name and description of purpose be included on the
186
- same "printed page" as the copyright notice for easier
187
- identification within third-party archives.
188
-
189
- Copyright [yyyy] [name of copyright owner]
190
-
191
- Licensed under the Apache License, Version 2.0 (the "License");
192
- you may not use this file except in compliance with the License.
193
- You may obtain a copy of the License at
194
-
195
- http://www.apache.org/licenses/LICENSE-2.0
196
-
197
- Unless required by applicable law or agreed to in writing, software
198
- distributed under the License is distributed on an "AS IS" BASIS,
199
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
- See the License for the specific language governing permissions and
201
- limitations under the License.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU Affero General Public License is a free, copyleft license for
11
+ software and other kinds of works, specifically designed to ensure
12
+ cooperation with the community in the case of network server software.
13
+
14
+ The licenses for most software and other practical works are designed
15
+ to take away your freedom to share and change the works. By contrast,
16
+ our General Public Licenses are intended to guarantee your freedom to
17
+ share and change all versions of a program--to make sure it remains free
18
+ software for all its users.
19
+
20
+ When we speak of free software, we are referring to freedom, not
21
+ price. Our General Public Licenses are designed to make sure that you
22
+ have the freedom to distribute copies of free software (and charge for
23
+ them if you wish), that you receive source code or can get it if you
24
+ want it, that you can change the software or use pieces of it in new
25
+ free programs, and that you know you can do these things.
26
+
27
+ Developers that use our General Public Licenses protect your rights
28
+ with two steps: (1) assert copyright on the software, and (2) offer
29
+ you this License which gives you legal permission to copy, distribute
30
+ and/or modify the software.
31
+
32
+ A secondary benefit of defending all users' freedom is that
33
+ improvements made in alternate versions of the program, if they
34
+ receive widespread use, become available for other developers to
35
+ incorporate. Many developers of free software are heartened and
36
+ encouraged by the resulting cooperation. However, in the case of
37
+ software used on network servers, this result may fail to come about.
38
+ The GNU General Public License permits making a modified version and
39
+ letting the public access it on a server without ever releasing its
40
+ source code to the public.
41
+
42
+ The GNU Affero General Public License is designed specifically to
43
+ ensure that, in such cases, the modified source code becomes available
44
+ to the community. It requires the operator of a network server to
45
+ provide the source code of the modified version running there to the
46
+ users of that server. Therefore, public use of a modified version, on
47
+ a publicly accessible server, gives the public access to the source
48
+ code of the modified version.
49
+
50
+ An older license, called the Affero General Public License and
51
+ published by Affero, was designed to accomplish similar goals. This is
52
+ a different license, not a version of the Affero GPL, but Affero has
53
+ released a new version of the Affero GPL which permits relicensing under
54
+ this license.
55
+
56
+ The precise terms and conditions for copying, distribution and
57
+ modification follow.
58
+
59
+ TERMS AND CONDITIONS
60
+
61
+ 0. Definitions.
62
+
63
+ "This License" refers to version 3 of the GNU Affero General Public License.
64
+
65
+ "Copyright" also means copyright-like laws that apply to other kinds of
66
+ works, such as semiconductor masks.
67
+
68
+ "The Program" refers to any copyrightable work licensed under this
69
+ License. Each licensee is addressed as "you". "Licensees" and
70
+ "recipients" may be individuals or organizations.
71
+
72
+ To "modify" a work means to copy from or adapt all or part of the work
73
+ in a fashion requiring copyright permission, other than the making of an
74
+ exact copy. The resulting work is called a "modified version" of the
75
+ earlier work or a work "based on" the earlier work.
76
+
77
+ A "covered work" means either the unmodified Program or a work based
78
+ on the Program.
79
+
80
+ To "propagate" a work means to do anything with it that, without
81
+ permission, would make you directly or secondarily liable for
82
+ infringement under applicable copyright law, except executing it on a
83
+ computer or modifying a private copy. Propagation includes copying,
84
+ distribution (with or without modification), making available to the
85
+ public, and in some countries other activities as well.
86
+
87
+ To "convey" a work means any kind of propagation that enables other
88
+ parties to make or receive copies. Mere interaction with a user through
89
+ a computer network, with no transfer of a copy, is not conveying.
90
+
91
+ An interactive user interface displays "Appropriate Legal Notices"
92
+ to the extent that it includes a convenient and prominently visible
93
+ feature that (1) displays an appropriate copyright notice, and (2)
94
+ tells the user that there is no warranty for the work (except to the
95
+ extent that warranties are provided), that licensees may convey the
96
+ work under this License, and how to view a copy of this License. If
97
+ the interface presents a list of user commands or options, such as a
98
+ menu, a prominent item in the list meets this criterion.
99
+
100
+ 1. Source Code.
101
+
102
+ The "source code" for a work means the preferred form of the work
103
+ for making modifications to it. "Object code" means any non-source
104
+ form of a work.
105
+
106
+ A "Standard Interface" means an interface that either is an official
107
+ standard defined by a recognized standards body, or, in the case of
108
+ interfaces specified for a particular programming language, one that
109
+ is widely used among developers working in that language.
110
+
111
+ The "System Libraries" of an executable work include anything, other
112
+ than the work as a whole, that (a) is included in the normal form of
113
+ packaging a Major Component, but which is not part of that Major
114
+ Component, and (b) serves only to enable use of the work with that
115
+ Major Component, or to implement a Standard Interface for which an
116
+ implementation is available to the public in source code form. A
117
+ "Major Component", in this context, means a major essential component
118
+ (kernel, window system, and so on) of the specific operating system
119
+ (if any) on which the executable work runs, or a compiler used to
120
+ produce the work, or an object code interpreter used to run it.
121
+
122
+ The "Corresponding Source" for a work in object code form means all
123
+ the source code needed to generate, install, and (for an executable
124
+ work) run the object code and to modify the work, including scripts to
125
+ control those activities. However, it does not include the work's
126
+ System Libraries, or general-purpose tools or generally available free
127
+ programs which are used unmodified in performing those activities but
128
+ which are not part of the work. For example, Corresponding Source
129
+ includes interface definition files associated with source files for
130
+ the work, and the source code for shared libraries and dynamically
131
+ linked subprograms that the work is specifically designed to require,
132
+ such as by intimate data communication or control flow between those
133
+ subprograms and other parts of the work.
134
+
135
+ The Corresponding Source need not include anything that users
136
+ can regenerate automatically from other parts of the Corresponding
137
+ Source.
138
+
139
+ The Corresponding Source for a work in source code form is that
140
+ same work.
141
+
142
+ 2. Basic Permissions.
143
+
144
+ All rights granted under this License are granted for the term of
145
+ copyright on the Program, and are irrevocable provided the stated
146
+ conditions are met. This License explicitly affirms your unlimited
147
+ permission to run the unmodified Program. The output from running a
148
+ covered work is covered by this License only if the output, given its
149
+ content, constitutes a covered work. This License acknowledges your
150
+ rights of fair use or other equivalent, as provided by copyright law.
151
+
152
+ You may make, run and propagate covered works that you do not
153
+ convey, without conditions so long as your license otherwise remains
154
+ in force. You may convey covered works to others for the sole purpose
155
+ of having them make modifications exclusively for you, or provide you
156
+ with facilities for running those works, provided that you comply with
157
+ the terms of this License in conveying all material for which you do
158
+ not control copyright. Those thus making or running the covered works
159
+ for you must do so exclusively on your behalf, under your direction
160
+ and control, on terms that prohibit them from making any copies of
161
+ your copyrighted material outside their relationship with you.
162
+
163
+ Conveying under any other circumstances is permitted solely under
164
+ the conditions stated below. Sublicensing is not allowed; section 10
165
+ makes it unnecessary.
166
+
167
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168
+
169
+ No covered work shall be deemed part of an effective technological
170
+ measure under any applicable law fulfilling obligations under article
171
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172
+ similar laws prohibiting or restricting circumvention of such
173
+ measures.
174
+
175
+ When you convey a covered work, you waive any legal power to forbid
176
+ circumvention of technological measures to the extent such circumvention
177
+ is effected by exercising rights under this License with respect to
178
+ the covered work, and you disclaim any intention to limit operation or
179
+ modification of the work as a means of enforcing, against the work's
180
+ users, your or third parties' legal rights to forbid circumvention of
181
+ technological measures.
182
+
183
+ 4. Conveying Verbatim Copies.
184
+
185
+ You may convey verbatim copies of the Program's source code as you
186
+ receive it, in any medium, provided that you conspicuously and
187
+ appropriately publish on each copy an appropriate copyright notice;
188
+ keep intact all notices stating that this License and any
189
+ non-permissive terms added in accord with section 7 apply to the code;
190
+ keep intact all notices of the absence of any warranty; and give all
191
+ recipients a copy of this License along with the Program.
192
+
193
+ You may charge any price or no price for each copy that you convey,
194
+ and you may offer support or warranty protection for a fee.
195
+
196
+ 5. Conveying Modified Source Versions.
197
+
198
+ You may convey a work based on the Program, or the modifications to
199
+ produce it from the Program, in the form of source code under the
200
+ terms of section 4, provided that you also meet all of these conditions:
201
+
202
+ a) The work must carry prominent notices stating that you modified
203
+ it, and giving a relevant date.
204
+
205
+ b) The work must carry prominent notices stating that it is
206
+ released under this License and any conditions added under section
207
+ 7. This requirement modifies the requirement in section 4 to
208
+ "keep intact all notices".
209
+
210
+ c) You must license the entire work, as a whole, under this
211
+ License to anyone who comes into possession of a copy. This
212
+ License will therefore apply, along with any applicable section 7
213
+ additional terms, to the whole of the work, and all its parts,
214
+ regardless of how they are packaged. This License gives no
215
+ permission to license the work in any other way, but it does not
216
+ invalidate such permission if you have separately received it.
217
+
218
+ d) If the work has interactive user interfaces, each must display
219
+ Appropriate Legal Notices; however, if the Program has interactive
220
+ interfaces that do not display Appropriate Legal Notices, your
221
+ work need not make them do so.
222
+
223
+ A compilation of a covered work with other separate and independent
224
+ works, which are not by their nature extensions of the covered work,
225
+ and which are not combined with it such as to form a larger program,
226
+ in or on a volume of a storage or distribution medium, is called an
227
+ "aggregate" if the compilation and its resulting copyright are not
228
+ used to limit the access or legal rights of the compilation's users
229
+ beyond what the individual works permit. Inclusion of a covered work
230
+ in an aggregate does not cause this License to apply to the other
231
+ parts of the aggregate.
232
+
233
+ 6. Conveying Non-Source Forms.
234
+
235
+ You may convey a covered work in object code form under the terms
236
+ of sections 4 and 5, provided that you also convey the
237
+ machine-readable Corresponding Source under the terms of this License,
238
+ in one of these ways:
239
+
240
+ a) Convey the object code in, or embodied in, a physical product
241
+ (including a physical distribution medium), accompanied by the
242
+ Corresponding Source fixed on a durable physical medium
243
+ customarily used for software interchange.
244
+
245
+ b) Convey the object code in, or embodied in, a physical product
246
+ (including a physical distribution medium), accompanied by a
247
+ written offer, valid for at least three years and valid for as
248
+ long as you offer spare parts or customer support for that product
249
+ model, to give anyone who possesses the object code either (1) a
250
+ copy of the Corresponding Source for all the software in the
251
+ product that is covered by this License, on a durable physical
252
+ medium customarily used for software interchange, for a price no
253
+ more than your reasonable cost of physically performing this
254
+ conveying of source, or (2) access to copy the
255
+ Corresponding Source from a network server at no charge.
256
+
257
+ c) Convey individual copies of the object code with a copy of the
258
+ written offer to provide the Corresponding Source. This
259
+ alternative is allowed only occasionally and noncommercially, and
260
+ only if you received the object code with such an offer, in accord
261
+ with subsection 6b.
262
+
263
+ d) Convey the object code by offering access from a designated
264
+ place (gratis or for a charge), and offer equivalent access to the
265
+ Corresponding Source in the same way through the same place at no
266
+ further charge. You need not require recipients to copy the
267
+ Corresponding Source along with the object code. If the place to
268
+ copy the object code is a network server, the Corresponding Source
269
+ may be on a different server (operated by you or a third party)
270
+ that supports equivalent copying facilities, provided you maintain
271
+ clear directions next to the object code saying where to find the
272
+ Corresponding Source. Regardless of what server hosts the
273
+ Corresponding Source, you remain obligated to ensure that it is
274
+ available for as long as needed to satisfy these requirements.
275
+
276
+ e) Convey the object code using peer-to-peer transmission, provided
277
+ you inform other peers where the object code and Corresponding
278
+ Source of the work are being offered to the general public at no
279
+ charge under subsection 6d.
280
+
281
+ A separable portion of the object code, whose source code is excluded
282
+ from the Corresponding Source as a System Library, need not be
283
+ included in conveying the object code work.
284
+
285
+ A "User Product" is either (1) a "consumer product", which means any
286
+ tangible personal property which is normally used for personal, family,
287
+ or household purposes, or (2) anything designed or sold for incorporation
288
+ into a dwelling. In determining whether a product is a consumer product,
289
+ doubtful cases shall be resolved in favor of coverage. For a particular
290
+ product received by a particular user, "normally used" refers to a
291
+ typical or common use of that class of product, regardless of the status
292
+ of the particular user or of the way in which the particular user
293
+ actually uses, or expects or is expected to use, the product. A product
294
+ is a consumer product regardless of whether the product has substantial
295
+ commercial, industrial or non-consumer uses, unless such uses represent
296
+ the only significant mode of use of the product.
297
+
298
+ "Installation Information" for a User Product means any methods,
299
+ procedures, authorization keys, or other information required to install
300
+ and execute modified versions of a covered work in that User Product from
301
+ a modified version of its Corresponding Source. The information must
302
+ suffice to ensure that the continued functioning of the modified object
303
+ code is in no case prevented or interfered with solely because
304
+ modification has been made.
305
+
306
+ If you convey an object code work under this section in, or with, or
307
+ specifically for use in, a User Product, and the conveying occurs as
308
+ part of a transaction in which the right of possession and use of the
309
+ User Product is transferred to the recipient in perpetuity or for a
310
+ fixed term (regardless of how the transaction is characterized), the
311
+ Corresponding Source conveyed under this section must be accompanied
312
+ by the Installation Information. But this requirement does not apply
313
+ if neither you nor any third party retains the ability to install
314
+ modified object code on the User Product (for example, the work has
315
+ been installed in ROM).
316
+
317
+ The requirement to provide Installation Information does not include a
318
+ requirement to continue to provide support service, warranty, or updates
319
+ for a work that has been modified or installed by the recipient, or for
320
+ the User Product in which it has been modified or installed. Access to a
321
+ network may be denied when the modification itself materially and
322
+ adversely affects the operation of the network or violates the rules and
323
+ protocols for communication across the network.
324
+
325
+ Corresponding Source conveyed, and Installation Information provided,
326
+ in accord with this section must be in a format that is publicly
327
+ documented (and with an implementation available to the public in
328
+ source code form), and must require no special password or key for
329
+ unpacking, reading or copying.
330
+
331
+ 7. Additional Terms.
332
+
333
+ "Additional permissions" are terms that supplement the terms of this
334
+ License by making exceptions from one or more of its conditions.
335
+ Additional permissions that are applicable to the entire Program shall
336
+ be treated as though they were included in this License, to the extent
337
+ that they are valid under applicable law. If additional permissions
338
+ apply only to part of the Program, that part may be used separately
339
+ under those permissions, but the entire Program remains governed by
340
+ this License without regard to the additional permissions.
341
+
342
+ When you convey a copy of a covered work, you may at your option
343
+ remove any additional permissions from that copy, or from any part of
344
+ it. (Additional permissions may be written to require their own
345
+ removal in certain cases when you modify the work.) You may place
346
+ additional permissions on material, added by you to a covered work,
347
+ for which you have or can give appropriate copyright permission.
348
+
349
+ Notwithstanding any other provision of this License, for material you
350
+ add to a covered work, you may (if authorized by the copyright holders of
351
+ that material) supplement the terms of this License with terms:
352
+
353
+ a) Disclaiming warranty or limiting liability differently from the
354
+ terms of sections 15 and 16 of this License; or
355
+
356
+ b) Requiring preservation of specified reasonable legal notices or
357
+ author attributions in that material or in the Appropriate Legal
358
+ Notices displayed by works containing it; or
359
+
360
+ c) Prohibiting misrepresentation of the origin of that material, or
361
+ requiring that modified versions of such material be marked in
362
+ reasonable ways as different from the original version; or
363
+
364
+ d) Limiting the use for publicity purposes of names of licensors or
365
+ authors of the material; or
366
+
367
+ e) Declining to grant rights under trademark law for use of some
368
+ trade names, trademarks, or service marks; or
369
+
370
+ f) Requiring indemnification of licensors and authors of that
371
+ material by anyone who conveys the material (or modified versions of
372
+ it) with contractual assumptions of liability to the recipient, for
373
+ any liability that these contractual assumptions directly impose on
374
+ those licensors and authors.
375
+
376
+ All other non-permissive additional terms are considered "further
377
+ restrictions" within the meaning of section 10. If the Program as you
378
+ received it, or any part of it, contains a notice stating that it is
379
+ governed by this License along with a term that is a further
380
+ restriction, you may remove that term. If a license document contains
381
+ a further restriction but permits relicensing or conveying under this
382
+ License, you may add to a covered work material governed by the terms
383
+ of that license document, provided that the further restriction does
384
+ not survive such relicensing or conveying.
385
+
386
+ If you add terms to a covered work in accord with this section, you
387
+ must place, in the relevant source files, a statement of the
388
+ additional terms that apply to those files, or a notice indicating
389
+ where to find the applicable terms.
390
+
391
+ Additional terms, permissive or non-permissive, may be stated in the
392
+ form of a separately written license, or stated as exceptions;
393
+ the above requirements apply either way.
394
+
395
+ 8. Termination.
396
+
397
+ You may not propagate or modify a covered work except as expressly
398
+ provided under this License. Any attempt otherwise to propagate or
399
+ modify it is void, and will automatically terminate your rights under
400
+ this License (including any patent licenses granted under the third
401
+ paragraph of section 11).
402
+
403
+ However, if you cease all violation of this License, then your
404
+ license from a particular copyright holder is reinstated (a)
405
+ provisionally, unless and until the copyright holder explicitly and
406
+ finally terminates your license, and (b) permanently, if the copyright
407
+ holder fails to notify you of the violation by some reasonable means
408
+ prior to 60 days after the cessation.
409
+
410
+ Moreover, your license from a particular copyright holder is
411
+ reinstated permanently if the copyright holder notifies you of the
412
+ violation by some reasonable means, this is the first time you have
413
+ received notice of violation of this License (for any work) from that
414
+ copyright holder, and you cure the violation prior to 30 days after
415
+ your receipt of the notice.
416
+
417
+ Termination of your rights under this section does not terminate the
418
+ licenses of parties who have received copies or rights from you under
419
+ this License. If your rights have been terminated and not permanently
420
+ reinstated, you do not qualify to receive new licenses for the same
421
+ material under section 10.
422
+
423
+ 9. Acceptance Not Required for Having Copies.
424
+
425
+ You are not required to accept this License in order to receive or
426
+ run a copy of the Program. Ancillary propagation of a covered work
427
+ occurring solely as a consequence of using peer-to-peer transmission
428
+ to receive a copy likewise does not require acceptance. However,
429
+ nothing other than this License grants you permission to propagate or
430
+ modify any covered work. These actions infringe copyright if you do
431
+ not accept this License. Therefore, by modifying or propagating a
432
+ covered work, you indicate your acceptance of this License to do so.
433
+
434
+ 10. Automatic Licensing of Downstream Recipients.
435
+
436
+ Each time you convey a covered work, the recipient automatically
437
+ receives a license from the original licensors, to run, modify and
438
+ propagate that work, subject to this License. You are not responsible
439
+ for enforcing compliance by third parties with this License.
440
+
441
+ An "entity transaction" is a transaction transferring control of an
442
+ organization, or substantially all assets of one, or subdividing an
443
+ organization, or merging organizations. If propagation of a covered
444
+ work results from an entity transaction, each party to that
445
+ transaction who receives a copy of the work also receives whatever
446
+ licenses to the work the party's predecessor in interest had or could
447
+ give under the previous paragraph, plus a right to possession of the
448
+ Corresponding Source of the work from the predecessor in interest, if
449
+ the predecessor has it or can get it with reasonable efforts.
450
+
451
+ You may not impose any further restrictions on the exercise of the
452
+ rights granted or affirmed under this License. For example, you may
453
+ not impose a license fee, royalty, or other charge for exercise of
454
+ rights granted under this License, and you may not initiate litigation
455
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
456
+ any patent claim is infringed by making, using, selling, offering for
457
+ sale, or importing the Program or any portion of it.
458
+
459
+ 11. Patents.
460
+
461
+ A "contributor" is a copyright holder who authorizes use under this
462
+ License of the Program or a work on which the Program is based. The
463
+ work thus licensed is called the contributor's "contributor version".
464
+
465
+ A contributor's "essential patent claims" are all patent claims
466
+ owned or controlled by the contributor, whether already acquired or
467
+ hereafter acquired, that would be infringed by some manner, permitted
468
+ by this License, of making, using, or selling its contributor version,
469
+ but do not include claims that would be infringed only as a
470
+ consequence of further modification of the contributor version. For
471
+ purposes of this definition, "control" includes the right to grant
472
+ patent sublicenses in a manner consistent with the requirements of
473
+ this License.
474
+
475
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
476
+ patent license under the contributor's essential patent claims, to
477
+ make, use, sell, offer for sale, import and otherwise run, modify and
478
+ propagate the contents of its contributor version.
479
+
480
+ In the following three paragraphs, a "patent license" is any express
481
+ agreement or commitment, however denominated, not to enforce a patent
482
+ (such as an express permission to practice a patent or covenant not to
483
+ sue for patent infringement). To "grant" such a patent license to a
484
+ party means to make such an agreement or commitment not to enforce a
485
+ patent against the party.
486
+
487
+ If you convey a covered work, knowingly relying on a patent license,
488
+ and the Corresponding Source of the work is not available for anyone
489
+ to copy, free of charge and under the terms of this License, through a
490
+ publicly available network server or other readily accessible means,
491
+ then you must either (1) cause the Corresponding Source to be so
492
+ available, or (2) arrange to deprive yourself of the benefit of the
493
+ patent license for this particular work, or (3) arrange, in a manner
494
+ consistent with the requirements of this License, to extend the patent
495
+ license to downstream recipients. "Knowingly relying" means you have
496
+ actual knowledge that, but for the patent license, your conveying the
497
+ covered work in a country, or your recipient's use of the covered work
498
+ in a country, would infringe one or more identifiable patents in that
499
+ country that you have reason to believe are valid.
500
+
501
+ If, pursuant to or in connection with a single transaction or
502
+ arrangement, you convey, or propagate by procuring conveyance of, a
503
+ covered work, and grant a patent license to some of the parties
504
+ receiving the covered work authorizing them to use, propagate, modify
505
+ or convey a specific copy of the covered work, then the patent license
506
+ you grant is automatically extended to all recipients of the covered
507
+ work and works based on it.
508
+
509
+ A patent license is "discriminatory" if it does not include within
510
+ the scope of its coverage, prohibits the exercise of, or is
511
+ conditioned on the non-exercise of one or more of the rights that are
512
+ specifically granted under this License. You may not convey a covered
513
+ work if you are a party to an arrangement with a third party that is
514
+ in the business of distributing software, under which you make payment
515
+ to the third party based on the extent of your activity of conveying
516
+ the work, and under which the third party grants, to any of the
517
+ parties who would receive the covered work from you, a discriminatory
518
+ patent license (a) in connection with copies of the covered work
519
+ conveyed by you (or copies made from those copies), or (b) primarily
520
+ for and in connection with specific products or compilations that
521
+ contain the covered work, unless you entered into that arrangement,
522
+ or that patent license was granted, prior to 28 March 2007.
523
+
524
+ Nothing in this License shall be construed as excluding or limiting
525
+ any implied license or other defenses to infringement that may
526
+ otherwise be available to you under applicable patent law.
527
+
528
+ 12. No Surrender of Others' Freedom.
529
+
530
+ If conditions are imposed on you (whether by court order, agreement or
531
+ otherwise) that contradict the conditions of this License, they do not
532
+ excuse you from the conditions of this License. If you cannot convey a
533
+ covered work so as to satisfy simultaneously your obligations under this
534
+ License and any other pertinent obligations, then as a consequence you may
535
+ not convey it at all. For example, if you agree to terms that obligate you
536
+ to collect a royalty for further conveying from those to whom you convey
537
+ the Program, the only way you could satisfy both those terms and this
538
+ License would be to refrain entirely from conveying the Program.
539
+
540
+ 13. Remote Network Interaction; Use with the GNU General Public License.
541
+
542
+ Notwithstanding any other provision of this License, if you modify the
543
+ Program, your modified version must prominently offer all users
544
+ interacting with it remotely through a computer network (if your version
545
+ supports such interaction) an opportunity to receive the Corresponding
546
+ Source of your version by providing access to the Corresponding Source
547
+ from a network server at no charge, through some standard or customary
548
+ means of facilitating copying of software. This Corresponding Source
549
+ shall include the Corresponding Source for any work covered by version 3
550
+ of the GNU General Public License that is incorporated pursuant to the
551
+ following paragraph.
552
+
553
+ Notwithstanding any other provision of this License, you have
554
+ permission to link or combine any covered work with a work licensed
555
+ under version 3 of the GNU General Public License into a single
556
+ combined work, and to convey the resulting work. The terms of this
557
+ License will continue to apply to the part which is the covered work,
558
+ but the work with which it is combined will remain governed by version
559
+ 3 of the GNU General Public License.
560
+
561
+ 14. Revised Versions of this License.
562
+
563
+ The Free Software Foundation may publish revised and/or new versions of
564
+ the GNU Affero General Public License from time to time. Such new versions
565
+ will be similar in spirit to the present version, but may differ in detail to
566
+ address new problems or concerns.
567
+
568
+ Each version is given a distinguishing version number. If the
569
+ Program specifies that a certain numbered version of the GNU Affero General
570
+ Public License "or any later version" applies to it, you have the
571
+ option of following the terms and conditions either of that numbered
572
+ version or of any later version published by the Free Software
573
+ Foundation. If the Program does not specify a version number of the
574
+ GNU Affero General Public License, you may choose any version ever published
575
+ by the Free Software Foundation.
576
+
577
+ If the Program specifies that a proxy can decide which future
578
+ versions of the GNU Affero General Public License can be used, that proxy's
579
+ public statement of acceptance of a version permanently authorizes you
580
+ to choose that version for the Program.
581
+
582
+ Later license versions may give you additional or different
583
+ permissions. However, no additional obligations are imposed on any
584
+ author or copyright holder as a result of your choosing to follow a
585
+ later version.
586
+
587
+ 15. Disclaimer of Warranty.
588
+
589
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597
+
598
+ 16. Limitation of Liability.
599
+
600
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608
+ SUCH DAMAGES.
609
+
610
+ 17. Interpretation of Sections 15 and 16.
611
+
612
+ If the disclaimer of warranty and limitation of liability provided
613
+ above cannot be given local legal effect according to their terms,
614
+ reviewing courts shall apply local law that most closely approximates
615
+ an absolute waiver of all civil liability in connection with the
616
+ Program, unless a warranty or assumption of liability accompanies a
617
+ copy of the Program in return for a fee.
618
+
619
+ END OF TERMS AND CONDITIONS
620
+
621
+ How to Apply These Terms to Your New Programs
622
+
623
+ If you develop a new program, and you want it to be of the greatest
624
+ possible use to the public, the best way to achieve this is to make it
625
+ free software which everyone can redistribute and change under these terms.
626
+
627
+ To do so, attach the following notices to the program. It is safest
628
+ to attach them to the start of each source file to most effectively
629
+ state the exclusion of warranty; and each file should have at least
630
+ the "copyright" line and a pointer to where the full notice is found.
631
+
632
+ <one line to give the program's name and a brief idea of what it does.>
633
+ Copyright (C) <year> <name of author>
634
+
635
+ This program is free software: you can redistribute it and/or modify
636
+ it under the terms of the GNU Affero General Public License as published
637
+ by the Free Software Foundation, either version 3 of the License, or
638
+ (at your option) any later version.
639
+
640
+ This program is distributed in the hope that it will be useful,
641
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
642
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643
+ GNU Affero General Public License for more details.
644
+
645
+ You should have received a copy of the GNU Affero General Public License
646
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
647
+
648
+ Also add information on how to contact you by electronic and paper mail.
649
+
650
+ If your software can interact with users remotely through a computer
651
+ network, you should also make sure that it provides a way for users to
652
+ get its source. For example, if your program is a web application, its
653
+ interface could display a "Source" link that leads users to an archive
654
+ of the code. There are many ways you could offer source, and different
655
+ solutions will be better for different programs; see section 13 for the
656
+ specific requirements.
657
+
658
+ You should also get your employer (if you work as a programmer) or school,
659
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
660
+ For more information on this, and how to apply and follow the GNU AGPL, see
661
+ <https://www.gnu.org/licenses/>.
PyFundaments #U2013 Function Overview.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PyFundaments – Function Overview
2
+
3
+ ## `main.py`
4
+
5
+ | Function | Description |
6
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `initialize_fundaments()` | Asynchronously initializes services based on available environment variables. Returns a dictionary of services (`None` if not initialized). |
8
+ | `main()` | Application entry point. Calls `initialize_fundaments()`, loads `app/app.py`, closes DB pool on shutdown. |
9
+
10
+ ---
11
+
12
+ ## `fundaments/config_handler.py` – `ConfigHandler`
13
+
14
+ | Function | Description |
15
+ | ------------------------ | ------------------------------------------------------------------ |
16
+ | `__init__()` | Loads `.env` via `python-dotenv` and system environment variables. |
17
+ | `load_all_config()` | Stores all non-empty environment variables in `self.config`. |
18
+ | `get(key)` | Returns value as string or `None`. |
19
+ | `get_bool(key, default)` | Parses boolean values (`true/1/yes/on`). |
20
+ | `get_int(key, default)` | Returns integer value or `default` on failure. |
21
+ | `has(key)` | Returns `True` if key exists and is not empty. |
22
+ | `get_all()` | Returns copy of full configuration dictionary. |
23
+ | `config_service` | Global singleton instance. |
24
+
25
+ ---
26
+
27
+ ## `fundaments/postgresql.py`
28
+
29
+ | Function | Description |
30
+ | ----------------------------------------------------- | ----------------------------------------------------------------------------------- |
31
+ | `enforce_cloud_security(dsn_url)` | Enforces `sslmode=require`, applies timeouts, removes incompatible DSN options. |
32
+ | `mask_dsn(dsn_url)` | Removes credentials from DSN for logging. |
33
+ | `ssl_runtime_check(conn)` | Verifies active SSL connection. |
34
+ | `init_db_pool(dsn_url)` | Creates asyncpg pool (min=1, max=10) and runs SSL check. |
35
+ | `close_db_pool()` | Gracefully closes connection pool. |
36
+ | `execute_secured_query(query, *params, fetch_method)` | Executes parameterized query (`fetch`, `fetchrow`, `execute`) with reconnect logic. |
37
+
38
+ ---
39
+
40
+ ## `fundaments/encryption.py` – `Encryption`
41
+
42
+ | Function | Description |
43
+ | -------------------------------------- | ---------------------------------------------------------------- |
44
+ | `generate_salt()` | Generates secure 16-byte hex salt. |
45
+ | `__init__(master_key, salt)` | Derives AES-256 key via PBKDF2-SHA256 (480k iterations). |
46
+ | `encrypt(data)` | Encrypts string using AES-256-GCM. Returns `{data, nonce, tag}`. |
47
+ | `decrypt(encrypted_data, nonce, tag)` | Decrypts data. Raises `InvalidTag` if tampered. |
48
+ | `encrypt_file(source_path, dest_path)` | Encrypts file in 8192-byte chunks. |
49
+ | `decrypt_file(source_path, dest_path)` | Decrypts file using stored nonce and tag. |
50
+
51
+ ---
52
+
53
+ ## `fundaments/access_control.py` – `AccessControl`
54
+
55
+ | Function | Description |
56
+ | -------------------------------------------------- | ------------------------------------ |
57
+ | `__init__(user_id)` | Initializes with optional user ID. |
58
+ | `has_permission(permission_name)` | Checks if user has permission. |
59
+ | `get_user_permissions()` | Returns all user permissions. |
60
+ | `get_user_roles()` | Returns assigned roles. |
61
+ | `assign_role(role_id)` | Assigns role to user. |
62
+ | `remove_role(role_id)` | Removes role from user. |
63
+ | `get_all_roles()` | Returns all roles. |
64
+ | `get_all_permissions()` | Returns all permissions. |
65
+ | `create_role(name, description)` | Creates new role and returns ID. |
66
+ | `update_role_permissions(role_id, permission_ids)` | Replaces all permissions for a role. |
67
+ | `get_role_permissions(role_id)` | Returns role permissions. |
68
+
69
+ ---
70
+
71
+ ## `fundaments/user_handler.py`
72
+
73
+ ### `Database` (SQLite Wrapper)
74
+
75
+ | Function | Description |
76
+ | ------------------------- | ------------------------------------------------- |
77
+ | `execute(query, params)` | Executes query and commits. |
78
+ | `fetchone(query, params)` | Returns single row. |
79
+ | `fetchall(query, params)` | Returns all rows. |
80
+ | `close()` | Closes connection. |
81
+ | `setup_tables()` | Creates `users` and `sessions` tables if missing. |
82
+
83
+ ---
84
+
85
+ ### `Security` (Password Utilities)
86
+
87
+ | Function | Description |
88
+ | ----------------------------------- | -------------------------------------------- |
89
+ | `hash_password(password)` | Hashes password (PBKDF2-SHA256 via passlib). |
90
+ | `verify_password(password, hashed)` | Verifies password against hash. |
91
+ | `regenerate_session(session_id)` | Generates new UUID session ID. |
92
+
93
+ ---
94
+
95
+ ### `UserHandler`
96
+
97
+ | Function | Description |
98
+ | ----------------------------------------- | ------------------------------------------------------ |
99
+ | `login(username, password, request_data)` | Authenticates user, validates state, creates session. |
100
+ | `logout()` | Removes session from DB and memory. |
101
+ | `is_logged_in()` | Checks if active session exists. |
102
+ | `is_admin()` | Checks session `is_admin` flag. |
103
+ | `validate_session(request_data)` | Validates session against IP and User-Agent. |
104
+ | `lock_account(username)` | Locks user account. |
105
+ | `reset_failed_attempts(username)` | Resets failed login counter. |
106
+ | `increment_failed_attempts(username)` | Increments failed attempts and locks after 5 failures. |
107
+
108
+ ---
109
+
110
+ ## `fundaments/security.py` – `Security` (Orchestrator)
111
+
112
+ | Function | Description |
113
+ | ---------------------------------------------- | --------------------------------------------------------------------- |
114
+ | `__init__(services)` | Initializes with required services. Raises `RuntimeError` if missing. |
115
+ | `user_login(username, password, request_data)` | Performs login and session validation. |
116
+ | `check_permission(user_id, permission_name)` | Delegates permission check. |
117
+ | `encrypt_data(data)` | Encrypts data if encryption service is available. |
118
+ | `decrypt_data(encrypted_data, nonce, tag)` | Decrypts data or returns `None` on failure. |
119
+
120
+ ---
121
+
122
+ ## `fundaments/debug.py` – `PyFundamentsDebug`
123
+
124
+ | Function | Description |
125
+ | ----------------- | ------------------------------------------------------- |
126
+ | `__init__()` | Reads debug-related environment variables. |
127
+ | `_setup_logger()` | Configures logging handlers. |
128
+ | `run()` | Outputs runtime diagnostics when debug mode is enabled. |
129
+
130
+ ---
131
+
132
+ ## `app/app.py`
133
+
134
+ | Function | Description |
135
+ | ------------------------------- | --------------------------------------------------------------------- |
136
+ | `start_application(fundaments)` | Receives initialized service dictionary and starts application logic. |
137
+
138
+ ---
139
+
140
+ ## Architecture Notes
141
+
142
+ * `UserHandler` uses internal SQLite.
143
+ * `AccessControl` uses PostgreSQL via `execute_secured_query`.
144
+ * `security.py` `Security` is the orchestrator layer.
145
+ * All services are optional.
README.md CHANGED
@@ -1,23 +1,24 @@
1
  ---
2
- title: Multi-LLM API Gateway
3
  emoji: 🛡️
4
  colorFrom: indigo
5
  colorTo: red
6
  sdk: docker
7
  pinned: true
8
  license: apache-2.0
9
- short_description: Secure Multi-LLM Gateway — (Streamable HTTP / SSE)
10
  ---
11
 
12
- # Multi-LLM API Gateway
13
 
14
- — or Universal MCP Hub (Sandboxed)
15
- — or secure AI wrapper with dual interface: REST + MCP
16
 
17
- aka: a clean, secure starting point for your own projects.
 
 
 
 
18
  Pick the description that fits your use case. They're all correct.
19
 
20
- > A production-grade **the-thing** that actually thinks about security.
21
  > Built on [PyFundaments](PyFundaments.md) — running on **simpleCity**.
22
 
23
  ```
@@ -27,15 +28,53 @@ No key → no tool → no crash → no exposed secrets
27
  > [!WARNING]
28
  > Most MCP servers are prompts dressed up as servers. This one has a real architecture.
29
 
30
- ---
31
-
32
  > [!IMPORTANT]
33
- > This project is under active development — always use the latest release from [Codey Lab](https://github.com/Codey-LAB/Multi-LLM-API-Gateway) *(more stable builds land here first)*.
34
  > This repo ([DEV](https://github.com/VolkanSah/Multi-LLM-API-Gateway)) is where the chaos happens. 🔬 A ⭐ on the repos will be cool 😙
35
 
36
  ---
37
 
38
- ## Why this exists
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  The AI ecosystem is full of servers with hardcoded keys, `os.environ` scattered everywhere, zero sandboxing. One misconfigured fork and your API keys are gone.
41
 
@@ -48,11 +87,14 @@ This hub is the antidote:
48
  - **Graceful degradation** — No key? Tool doesn't register. Server still starts. No crash, no error, no empty `None` floating around.
49
  - **Single source of truth** — All tool/provider/model config lives in `app/.pyfun`. Adding a provider = edit one file. No code changes.
50
 
51
- ---
 
 
52
 
53
- ## Two Interfaces — One Server
 
54
 
55
- This hub exposes **two completely independent interfaces** on the same hypercorn instance:
56
 
57
  ```
58
  POST /api → REST interface — for custom clients, desktop apps, CMS plugins
@@ -62,9 +104,7 @@ GET / → Health check — uptime, status
62
 
63
  They share the same tool registry, provider config, and fallback chain. Adding a tool once makes it available on both interfaces automatically.
64
 
65
- ### REST API (`/api`)
66
-
67
- Simple JSON POST — no protocol overhead, works with any HTTP client:
68
 
69
  ```json
70
  POST /api
@@ -73,25 +113,26 @@ POST /api
73
 
74
  Used by: Desktop Client (`DESKTOP_CLIENT/hub.py`), WordPress plugin, any custom integration.
75
 
76
- ### MCP Interface (`/mcp`)
77
-
78
- Full MCP protocol — tool discovery, structured calls, streaming responses.
79
-
80
- **Primary transport: Streamable HTTP** (MCP spec 2025-11-25)
81
- **Fallback transport: SSE** (legacy, configurable via `.pyfun`)
82
 
83
- Configured via `HUB_TRANSPORT` in `app/.pyfun [HUB]`:
 
84
 
85
  ```ini
 
86
  HUB_TRANSPORT = "streamable-http" # default — MCP spec 2025-11-25
87
  # HUB_TRANSPORT = "sse" # legacy fallback for older clients
 
88
  ```
89
 
90
- Used by: Claude Desktop, Cursor, Windsurf, any MCP-compatible client.
 
 
91
 
92
  ---
93
 
94
- ## Architecture
 
95
 
96
  ```
97
  main.py (Guardian)
@@ -110,13 +151,13 @@ main.py (Guardian)
110
  ├── app/tools.py ← Tool registry (key-gated)
111
  ├── app/providers.py ← LLM + Search execution + fallback chain
112
  ├── app/models.py ← Model limits, costs, capabilities
 
 
113
  ├── app/config.py ← .pyfun parser (single source of truth)
114
- └── app/db_sync.py ← Internal SQLite IPC (app/* state only)
115
- ≠ fundaments/postgresql.py (Guardian-only)
116
  ```
117
 
118
- **The sandbox is structural:**
119
-
120
  ```python
121
  # app/app.py — fundaments unpacked ONCE, NEVER stored globally
122
  async def start_application(fundaments: Dict[str, Any]) -> None:
@@ -129,61 +170,59 @@ async def start_application(fundaments: Dict[str, Any]) -> None:
129
  # fundaments are never passed into other app/* modules.
130
  ```
131
 
132
- `app/app.py` never calls `os.environ`. Never imports from `fundaments/`. Never reads `.env`.
133
  This isn't documentation. It's enforced by the import structure.
134
 
135
- ### Why Quart + hypercorn?
136
 
137
  **Quart** is async Flask — fully `async/await` native. FastMCP's handlers are async; mixing sync Flask would require thread hacks. With Quart, `/mcp` hands off directly to FastMCP — no bridging, no blocking.
138
 
139
- **hypercorn** is an ASGI server (vs. waitress/gunicorn which are WSGI). WSGI servers handle one request per thread — wrong for long-lived MCP connections. hypercorn handles both Streamable HTTP and SSE natively, and runs without extra config on HuggingFace Spaces. HTTP/2 support (`config.h2 = True`) is built-in — relevant for Streamable HTTP performance at scale.
 
 
140
 
141
- The `/mcp` route in `app.py` remains the natural interception point regardless of transport — auth checks, rate limiting, and logging can all be added there before the request reaches FastMCP.
142
 
143
  ---
144
 
145
- ## Two Databases — One Architecture
 
146
 
 
147
 
148
  ```
149
- ┌─────────────────────────────────────────────────────────────┐
150
- │ Guardian Layer (fundaments/*) │
151
- │ │
152
- │ postgresql.py → Cloud DB (e.g. Neon, Supabase) │
153
- │ asyncpg pool, SSL enforced │
154
- │ │
155
- │ user_handler.py → SQLite (users + sessions tables) │
156
- │ PBKDF2-SHA256 password hashing │
157
- │ Session validation incl. IP + UserAgent │
158
- │ Account lockout after 5 failed attempts │
159
- │ │
160
- └──────────────────────┬──────────────────────────────────────┘
161
- │ inject as fundaments dict
162
- ▼
163
- ┌─────────────────────────────────────────────────────────────┐
164
- │ App Layer (app/*) │
165
- │ │
166
- │ db_sync.py → SQLite (hub_state + tool_cache tables) │
167
- │ aiosqlite (async, non-blocking) │
168
- │ NEVER touches users/sessions tables │
169
- │ Relocated to /tmp/ on HF Spaces auto │
170
- │ │
171
- └─────────────────────────────────────────────────────────────┘
172
  ```
 
 
 
 
 
 
 
 
 
 
 
173
 
174
- **Table ownership — hard rule:**
175
 
176
  | Table | Owner | Access |
177
  | :--- | :--- | :--- |
178
  | `users` | `fundaments/user_handler.py` | Guardian only |
179
  | `sessions` | `fundaments/user_handler.py` | Guardian only |
180
- | `hub_state` | `app/db_sync.py` | app/* only |
181
- | `tool_cache` | `app/db_sync.py` | app/* only |
182
- | `hub_results` | PostgreSQL / Guardian | via `persist_result` tool |
 
183
 
184
  ---
185
 
186
- ## Tools
 
187
 
188
  Tools register at startup — only if the required API key exists. No key, no tool. Server always starts.
189
 
@@ -195,11 +234,14 @@ Tools register at startup — only if the required API key exists. No key, no to
195
  | `HF_TOKEN` | `llm_complete` | HuggingFace Inference API |
196
  | `BRAVE_API_KEY` | `web_search` | Independent web index |
197
  | `TAVILY_API_KEY` | `web_search` | AI-optimized search with synthesized answers |
198
- | `DATABASE_URL` | `cloud DB` | e.g. Neon, Supabase |
199
- | `DATABASE_URL` | `db_query`, `persist_result` | SQLite read + PostgreSQL write |
200
- | *(always)* | `list_active_tools` | Shows key names only — never values |
201
  | *(always)* | `health_check` | Status + uptime + active transport |
202
- | *(always)* | `get_model_info` | Limits, costs, capabilities per model |
 
 
 
203
 
204
  For all key names see [`app/.pyfun`](app/.pyfun).
205
 
@@ -216,12 +258,15 @@ system_prompt = "You are an expert code reviewer. Analyze the given code for
216
  [TOOL.code_review_END]
217
  ```
218
 
219
- Current built-in tools: `llm_complete`, `code_review`, `summarize`, `translate`, `web_search`, `db_query`
220
- Future hooks (commented, ready): `image_gen`, `code_exec`, `shellmaster_2.0`, Discord, GitHub webhooks
 
 
221
 
222
  ---
223
 
224
- ## LLM Fallback Chain
 
225
 
226
  All LLM providers share one `llm_complete` tool. If a provider fails, the hub walks the fallback chain from `.pyfun`:
227
 
@@ -241,9 +286,13 @@ fallback_to = "openrouter"
241
 
242
  Same pattern applies to search providers (`brave → tavily`).
243
 
244
- ---
 
 
245
 
246
- ## Quick Start
 
 
247
 
248
  ### HuggingFace Spaces (recommended)
249
 
@@ -271,16 +320,22 @@ Minimum required ENV vars (everything else is optional):
271
  PYFUNDAMENTS_DEBUG=""
272
  LOG_LEVEL="INFO"
273
  LOG_TO_TMP=""
274
- ENABLE_PUBLIC_LOGS="true"
275
  HF_TOKEN=""
276
  HUB_SPACE_URL=""
277
  ```
278
 
 
 
279
  Transport is configured in `app/.pyfun [HUB]` — not via ENV.
280
 
 
 
281
  ---
282
 
283
- ## Connect an MCP Client
 
 
284
 
285
  ### Streamable HTTP (default — MCP spec 2025-11-25)
286
 
@@ -321,16 +376,21 @@ Transport is configured in `app/.pyfun [HUB]` — not via ENV.
321
  }
322
  ```
323
 
324
- > Same URL (`/mcp`) for both transports — the protocol is negotiated automatically.
325
  > SSE fallback is for older clients that don't support Streamable HTTP yet.
 
 
 
 
326
 
327
  ---
328
 
329
- ## Desktop Client
330
- ###### (experimental — ~80% AI generated)
331
 
332
- A full PySide6 desktop client is included in `DESKTOP_CLIENT/hub.py`.
333
- Communicates via the REST `/api` endpoint — no MCP protocol overhead.
 
 
 
334
  Ideal for private or non-public Spaces.
335
 
336
  ```bash
@@ -341,6 +401,7 @@ python DESKTOP_CLIENT/hub.py
341
  ```
342
 
343
  **Features:**
 
344
  - Multi-chat with persistent history
345
  - Tool / Provider / Model selector loaded live from your Hub
346
  - File attachments: images, PDF, CSV, Excel, ZIP, source code
@@ -351,9 +412,12 @@ python DESKTOP_CLIENT/hub.py
351
 
352
  [→ Desktop Client docs](DESKTOP_CLIENT/README.md)
353
 
 
 
354
  ---
355
 
356
- ## CMS & Custom Clients
 
357
 
358
  | Client | Interface used | Notes |
359
  | :--- | :--- | :--- |
@@ -363,9 +427,14 @@ python DESKTOP_CLIENT/hub.py
363
  | Claude Desktop | MCP `/mcp` | Streamable HTTP |
364
  | Cursor / Windsurf | MCP `/mcp` | Streamable HTTP |
365
 
 
 
 
366
  ---
367
 
368
- ## Configuration (.pyfun)
 
 
369
 
370
  `app/.pyfun` is the single source of truth for all app behavior. Three tiers:
371
 
@@ -385,6 +454,8 @@ HUB_PORT = "7860"
385
  [HUB_END]
386
  ```
387
 
 
 
388
  Adding a new LLM provider — two steps:
389
 
390
  ```ini
@@ -407,9 +478,14 @@ _PROVIDER_CLASSES = {
407
  }
408
  ```
409
 
 
 
 
410
  ---
411
 
412
- ## Dependencies
 
 
413
 
414
  ```
415
  # PyFundaments Core (always required)
@@ -432,12 +508,17 @@ requests — sync HTTP for tool workers
432
  # psycopg2-binary — alternative PostgreSQL driver
433
  ```
434
 
435
- > **Note:** The package is `mcp` (not `fastmcp`) — `FastMCP` is imported from `mcp.server.fastmcp`.
436
  > Streamable HTTP support requires `mcp >= 1.6.0`.
437
 
 
 
 
438
  ---
439
 
440
- ## Security Design
 
 
441
 
442
  - API keys live in HF Secrets / `.env` — never in `.pyfun`, never in code
443
  - `list_active_tools` returns key **names** only — never values
@@ -446,16 +527,21 @@ requests — sync HTTP for tool workers
446
  - Direct execution of `app/app.py` blocked by design — warning + null-fundaments fallback
447
  - `fundaments/` initialized conditionally — missing services degrade gracefully, never crash
448
  - Streamable HTTP uses standard Bearer headers — no token-in-URL (unlike SSE)
 
449
 
450
  > PyFundaments is not perfect. But it's more secure than most of what runs in production today.
451
 
452
  [→ Full Security Policy](SECURITY.md)
453
 
 
 
 
454
  ---
455
 
456
- ## Foundation
 
457
 
458
- Built on [PyFundaments](PyFundaments.md) — a security-first Python boilerplate:
459
 
460
  - `config_handler.py` — env loading with validation
461
  - `postgresql.py` — async DB pool (Guardian-only)
@@ -463,41 +549,86 @@ Built on [PyFundaments](PyFundaments.md) — a security-first Python boilerplate
463
  - `access_control.py` — role/permission management
464
  - `user_handler.py` — user lifecycle management
465
  - `security.py` — unified security manager composing the above
 
466
 
467
  None accessible from `app/*`. Injected as a validated dict by `main.py`.
468
 
469
- [→ PyFundaments Function Overview](PyFundaments%20–%20Function%20Overview.md)
470
- [→ Module Docs](docs/app/)
471
  [→ Source Repo](https://github.com/VolkanSah/Multi-LLM-API-Gateway)
472
 
 
 
473
  ---
474
 
475
- ## Related Projects
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
- - [Customs LLMs for free — Build Your Own LLM Service](https://github.com/VolkanSah/SmolLM2-customs/)
478
- - [WP AI Hub (WordPress Client)](https://github.com/VolkanSah/WP-AI-HUB/)
479
- - [ShellMaster (2023 precursor)](https://github.com/VolkanSah/ChatGPT-ShellMaster)
480
 
481
  ---
482
 
483
- ## History
484
 
485
- [ShellMaster](https://github.com/VolkanSah/ChatGPT-ShellMaster) (2023, MIT) was the precursor — browser-accessible shell for ChatGPT with session memory, built before MCP was a concept. Universal MCP Hub is its natural evolution: same idea, proper architecture, dual interface.
 
 
 
 
 
 
 
 
 
 
 
486
 
487
  ---
488
 
489
- ## License
490
 
491
- Dual-licensed:
 
492
 
493
- - [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
494
- - [Ethical Security Operations License v1.1 (ESOL)](ESOL) — mandatory, non-severable
495
 
496
- By using this software you agree to all ethical constraints defined in ESOL v1.1.
 
 
497
 
498
  ---
499
 
500
- *Architecture, security decisions, and PyFundaments by Volkan Kücükbudak.*
501
- *Built with Claude (Anthropic) as a typing assistant for docs (and the occasional bug).*
 
 
 
 
502
 
503
- > crafted with passion — just wanted to understand how it works, don't actually need it, have a CLI 😄
 
1
  ---
2
+ title: Universal AI Hub
3
  emoji: 🛡️
4
  colorFrom: indigo
5
  colorTo: red
6
  sdk: docker
7
  pinned: true
8
  license: apache-2.0
9
+ short_description: Secure Multi-HUB i craft since month
10
  ---
11
 
 
12
 
 
 
13
 
14
+ # Universal AI Hub 🛡️
15
+ > 🚧 **Status:** Under active development. Built strictly for private spaces with hardened anti-spam and anti-scammer guardrails. Use at your own discretion.
16
+
17
+
18
+ aka: a clean, secure starting point for your own projects.
19
  Pick the description that fits your use case. They're all correct.
20
 
21
+ > A **the-thing** that actually thinks about security.
22
  > Built on [PyFundaments](PyFundaments.md) — running on **simpleCity**.
23
 
24
  ```
 
28
  > [!WARNING]
29
  > Most MCP servers are prompts dressed up as servers. This one has a real architecture.
30
 
 
 
31
  > [!IMPORTANT]
32
+ > This project is under active development — always use the latest release from [Codey Lab](https://github.com/Codey-LAB/Multi-LLM-API-Gateway) *(more stable builds land here first)*.
33
  > This repo ([DEV](https://github.com/VolkanSah/Multi-LLM-API-Gateway)) is where the chaos happens. 🔬 A ⭐ on the repos will be cool 😙
34
 
35
  ---
36
 
37
+ <details>
38
+ <summary><b>🧠 Usernote: how this was actually built (no vibecode - or am I lying? 😄)</b></summary>
39
+
40
+ I created this code on my own. And it took a toll on me - I mean really took a toll. We're not talking about my code not working, but more like I've been looking at my own design for so long that my brain is a mess. That's when AI came into the picture - not to do the coding for me, but to help me make sense of my thoughts and organize the ideas that were already floating around in my head.
41
+
42
+ So, is this something called vibecoding? **No**, it's not. Vibecoding is when you send off code without really understanding what it does, and just hope it works out. But I know exactly what's going on here - every single line, even the weird parts. I made some specific choices, like using the guardian pattern, setting up a sandbox, and creating a `.pyfun` file that's the single source of truth. I also followed the no-key-no-tool rule. These were all my decisions, made before any AI even got involved. My coding style is pretty unique, and you'll notice it as soon as you look at the source code. The AI was more like a partner that helped me out, typing fast and sometimes making mistakes that I had to fix.
43
+
44
+ ...or am I just making it up? Don't just listen to me, go check the code for yourself.
45
+
46
+ </details>
47
+
48
+ ---
49
+
50
+ <details>
51
+ <summary><b>🆕 What's new in v1.2.0 — memory that survives a reboot</b></summary>
52
+
53
+ > Full changelog (with proof) in [`REVIEW.md`](REVIEW.md). The short version, my words:
54
+
55
+ - **Memory orchestration.** `persist_result` without `DATABASE_URL` used to throw a `RuntimeError`. Now everything lands in a restart-safe `persist_queue` in SQLite — proven: 3 entries in, reboot, 3 entries out — and a background flusher pushes them into PostgreSQL whenever the bridge is back. `persist_status` (MCP + `/api`) shows queue depth, attempts and bridge state.
56
+ - **Auto-pricing, konsequent.** I asked whether the manual `cost_*` lines still make sense. OpenRouter FAQ, checked live: no markup, per-token = vendor list price (the ~5.5 % fee hits credit purchase, not tokens). So 34 cost lines are gone. What stays: my own models (`smollm2-360m` = 0.0 — no external source knows my own deployment). `.pyfun` values still win as override, always.
57
+ - **ShellMaster = SECURITY LEVEL ROT.** My CLI commander for the local Quart agent (`localhost:5004`) — commands run on YOUR machine, so: local only, never on a public Space. Token lives in `.env` via `SHELLMASTER_TOKEN` (same pattern as every provider); the `.pyfun` field is only a local test fallback. `active = "false"`. Stays off until someone consciously flips it.
58
+ - **Score slot for my filter.** `hub_results.score` exists — the hub passes it through completely uninterpreted. What's worth remembering long-term gets decided by my own filter, not by the hub. That part is my code, my rules.
59
+
60
+ *Tests: 26/26 orchestration + 4/4 smoke — green.*
61
+
62
+ <details>
63
+ <summary><b>v1.1.0 (the rebuild) — older news</b></summary>
64
+
65
+ - **Fixed:** `/mcp` 404 (mount middleware stripped the prefix FastMCP expects) and the missing session-manager lifespan — MCP is fully functional again.
66
+ - **New:** **Auto model pricing** — costs fetched from the public OpenRouter models API + the LiteLLM community price list, cached in `db_sync` (24h TTL). `.pyfun [MODELS]` values win as override.
67
+ - **New:** Brave + Tavily search providers with fallback chain — `web_search` registers as soon as `BRAVE_API_KEY` or `TAVILY_API_KEY` is set.
68
+ - **New:** `db_query` as MCP tool (SELECT-only, app/* tables).
69
+ - **Cleanup:** dead code removed, `requirements.txt` pins `mcp>=1.10,<2` (not `fastmcp`), LF line endings.
70
+
71
+ </details>
72
+ </details>
73
+
74
+ ---
75
+
76
+ <details>
77
+ <summary><b>❓ Why this exists</b></summary>
78
 
79
  The AI ecosystem is full of servers with hardcoded keys, `os.environ` scattered everywhere, zero sandboxing. One misconfigured fork and your API keys are gone.
80
 
 
87
  - **Graceful degradation** — No key? Tool doesn't register. Server still starts. No crash, no error, no empty `None` floating around.
88
  - **Single source of truth** — All tool/provider/model config lives in `app/.pyfun`. Adding a provider = edit one file. No code changes.
89
 
90
+ </details>
91
+
92
+ ---
93
 
94
+ <details>
95
+ <summary><b>🔌 Two interfaces — one server</b></summary>
96
 
97
+ Two completely independent interfaces on the same hypercorn instance:
98
 
99
  ```
100
  POST /api → REST interface — for custom clients, desktop apps, CMS plugins
 
104
 
105
  They share the same tool registry, provider config, and fallback chain. Adding a tool once makes it available on both interfaces automatically.
106
 
107
+ **REST (`/api`)** — simple JSON POST, no protocol overhead, works with any HTTP client:
 
 
108
 
109
  ```json
110
  POST /api
 
113
 
114
  Used by: Desktop Client (`DESKTOP_CLIENT/hub.py`), WordPress plugin, any custom integration.
115
 
116
+ **MCP (`/mcp`)** — full MCP protocol: tool discovery, structured calls, streaming responses.
 
 
 
 
 
117
 
118
+ - Primary transport: **Streamable HTTP** (MCP spec 2025-11-25)
119
+ - Fallback transport: **SSE** (legacy, configurable via `.pyfun`)
120
 
121
  ```ini
122
+ [HUB]
123
  HUB_TRANSPORT = "streamable-http" # default — MCP spec 2025-11-25
124
  # HUB_TRANSPORT = "sse" # legacy fallback for older clients
125
+ [HUB_END]
126
  ```
127
 
128
+ Auth, rate limiting and logging can all hook into the `/mcp` route in `app/app.py` before a request ever reaches FastMCP.
129
+
130
+ </details>
131
 
132
  ---
133
 
134
+ <details>
135
+ <summary><b>🏗 Architecture — the sandbox is structural</b></summary>
136
 
137
  ```
138
  main.py (Guardian)
 
151
  ├── app/tools.py ← Tool registry (key-gated)
152
  ├── app/providers.py ← LLM + Search execution + fallback chain
153
  ├── app/models.py ← Model limits, costs, capabilities
154
+ ├── app/shellmaster.py ← CLI commander (SECURITY LEVEL ROT — local only)
155
+ ├── app/pricing.py ← Auto model pricing (OpenRouter + LiteLLM)
156
  ├── app/config.py ← .pyfun parser (single source of truth)
157
+ └── app/db_sync.py ← SQLite: short-term state + persist_queue (memory bridge)
158
+ ≠ fundaments/postgresql.py (Guardian-only, long-term)
159
  ```
160
 
 
 
161
  ```python
162
  # app/app.py — fundaments unpacked ONCE, NEVER stored globally
163
  async def start_application(fundaments: Dict[str, Any]) -> None:
 
170
  # fundaments are never passed into other app/* modules.
171
  ```
172
 
173
+ `app/app.py` never calls `os.environ`. Never imports from `fundaments/`. Never reads `.env`.
174
  This isn't documentation. It's enforced by the import structure.
175
 
176
+ **Why Quart + hypercorn?**
177
 
178
  **Quart** is async Flask — fully `async/await` native. FastMCP's handlers are async; mixing sync Flask would require thread hacks. With Quart, `/mcp` hands off directly to FastMCP — no bridging, no blocking.
179
 
180
+ **hypercorn** is an ASGI server (vs. waitress/gunicorn which are WSGI). WSGI servers handle one request per thread — wrong for long-lived MCP connections. hypercorn handles both Streamable HTTP and SSE natively, and runs without extra config on HuggingFace Spaces. HTTP/2 support (`config.h2 = True`) is built-in.
181
+
182
+ </details>
183
 
 
184
 
185
  ---
186
 
187
+ <details>
188
+ <summary><b>💾 Memory — short-term, long-term, and my filter (v1.2.0)</b></summary>
189
 
190
+ The design is deliberately simple:
191
 
192
  ```
193
+ SQLite = short-term memory — all AI tools read & write here, nobody waits for the cloud
194
+ SQLite persist_queue = the bridge — restart-safe, survives reboots (proven)
195
+ PostgreSQL = long-term memory — what survived MY filter goes here
196
+ ```
197
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  ```
199
+ persist_result (MCP + /api)
200
+ ↓ enqueue_persist() ← immediately, ALWAYS, no crash — even without DATABASE_URL
201
+ persist_queue (SQLite) ← survives restarts; on HF Spaces /tmp, PG is the only durable memory
202
+ ↓ flush_persist_queue() ← background flusher, retry every 60 s (configurable)
203
+ PostgreSQL hub_results ← (id, created_at, source, model, score, payload JSONB)
204
+ ```
205
+
206
+ - **No `DATABASE_URL`?** `status: "queued"` — no RuntimeError, no 502, nothing lost.
207
+ - **PostgreSQL down?** Entry stays, `attempts` +1, sanitized `last_error`. Flusher catches up when the bridge returns.
208
+ - **`score REAL`** — a reserved slot for my own quality filter. The hub passes it through untouched; the filter writes it (or filters on read) later. No hub code interprets it. That's on purpose — my filter, my rules.
209
+ - **Limits:** queue max 500 (oldest evicted, like `tool_cache`) · at-least-once semantics (a duplicate beats a loss) · observability via `persist_status` (queue depth, oldest entry, attempts, bridge state).
210
 
211
+ **Guardian layer vs app layer — table ownership, hard rule:**
212
 
213
  | Table | Owner | Access |
214
  | :--- | :--- | :--- |
215
  | `users` | `fundaments/user_handler.py` | Guardian only |
216
  | `sessions` | `fundaments/user_handler.py` | Guardian only |
217
+ | `hub_state`, `tool_cache`, `persist_queue` | `app/db_sync.py` | app/* only |
218
+ | `hub_results` | PostgreSQL / Guardian | via `persist_result` → queue → flusher |
219
+
220
+ </details>
221
 
222
  ---
223
 
224
+ <details>
225
+ <summary><b>🧰 Tools — no key, no tool, server still starts</b></summary>
226
 
227
  Tools register at startup — only if the required API key exists. No key, no tool. Server always starts.
228
 
 
234
  | `HF_TOKEN` | `llm_complete` | HuggingFace Inference API |
235
  | `BRAVE_API_KEY` | `web_search` | Independent web index |
236
  | `TAVILY_API_KEY` | `web_search` | AI-optimized search with synthesized answers |
237
+ | `DATABASE_URL` | `persist_result`, `persist_status` | Long-term memory bridge (queue without it) |
238
+ | `SHELLMASTER_TOKEN` | `shellmaster*` | ⛔ SECURITY LEVEL ROT — local only, `active="false"` |
239
+ | *(always)* | `list_active_tools` | Shows key names only — never values. Plus `pricing_stats` |
240
  | *(always)* | `health_check` | Status + uptime + active transport |
241
+ | *(always)* | `get_model_info` | Limits, costs, capabilities per model (auto-merged) |
242
+ | *(always)* | `get_model_pricing` | Auto-fetched costs — `.pyfun` values win as override |
243
+ | *(always)* | `refresh_pricing` | Force refresh from OpenRouter + LiteLLM |
244
+ | *(always)* | `db_query` | Read-only SELECT on internal hub state |
245
 
246
  For all key names see [`app/.pyfun`](app/.pyfun).
247
 
 
258
  [TOOL.code_review_END]
259
  ```
260
 
261
+ Current built-in tools: `llm_complete`, `code_review`, `summarize`, `translate`, `web_search`, `db_query`, `persist_result`, `persist_status`
262
+ Future hooks (commented, ready): `image_gen`, `code_exec`, ShellMaster 2.0 (ROT — local only), Discord, GitHub webhooks
263
+
264
+ </details>
265
 
266
  ---
267
 
268
+ <details>
269
+ <summary><b>🔁 Fallback chain</b></summary>
270
 
271
  All LLM providers share one `llm_complete` tool. If a provider fails, the hub walks the fallback chain from `.pyfun`:
272
 
 
286
 
287
  Same pattern applies to search providers (`brave → tavily`).
288
 
289
+ </details>
290
+
291
+ ---
292
 
293
+
294
+ <details open>
295
+ <summary><b>🚀 Quick start</b></summary>
296
 
297
  ### HuggingFace Spaces (recommended)
298
 
 
320
  PYFUNDAMENTS_DEBUG=""
321
  LOG_LEVEL="INFO"
322
  LOG_TO_TMP=""
323
+ ENABLE_PUBLIC_LOGS="false"
324
  HF_TOKEN=""
325
  HUB_SPACE_URL=""
326
  ```
327
 
328
+ > Note: HF Spaces prints stdout to a **publicly visible log tab**. `ENABLE_PUBLIC_LOGS="false"` keeps your logs yours. Default in code is still `true` — my fundament, my call, documented everywhere; flip it consciously.
329
+
330
  Transport is configured in `app/.pyfun [HUB]` — not via ENV.
331
 
332
+ </details>
333
+
334
  ---
335
 
336
+
337
+ <details>
338
+ <summary><b>🔗 Connect an MCP client</b></summary>
339
 
340
  ### Streamable HTTP (default — MCP spec 2025-11-25)
341
 
 
376
  }
377
  ```
378
 
379
+ > Same URL (`/mcp`) for both transports — the protocol is negotiated automatically.
380
  > SSE fallback is for older clients that don't support Streamable HTTP yet.
381
+ > (`HUB_STATELESS="true"` is intentional for HF Spaces — no `Mcp-Session-Id` header is expected, not a bug.)
382
+
383
+ </details>
384
+
385
 
386
  ---
387
 
 
 
388
 
389
+ <details>
390
+ <summary><b>🖥 Desktop client (experimental — ~80 % me, 20 % typing assistant)</b></summary>
391
+
392
+ A full PySide6 desktop client is included in `DESKTOP_CLIENT/hub.py`.
393
+ Communicates via the REST `/api` endpoint — no MCP protocol overhead.
394
  Ideal for private or non-public Spaces.
395
 
396
  ```bash
 
401
  ```
402
 
403
  **Features:**
404
+
405
  - Multi-chat with persistent history
406
  - Tool / Provider / Model selector loaded live from your Hub
407
  - File attachments: images, PDF, CSV, Excel, ZIP, source code
 
412
 
413
  [→ Desktop Client docs](DESKTOP_CLIENT/README.md)
414
 
415
+ </details>
416
+
417
  ---
418
 
419
+ <details>
420
+ <summary><b>🧩 CMS & custom clients</b></summary>
421
 
422
  | Client | Interface used | Notes |
423
  | :--- | :--- | :--- |
 
427
  | Claude Desktop | MCP `/mcp` | Streamable HTTP |
428
  | Cursor / Windsurf | MCP `/mcp` | Streamable HTTP |
429
 
430
+ </details>
431
+
432
+
433
  ---
434
 
435
+
436
+ <details>
437
+ <summary><b>⚙️ Configuration (.pyfun) — one file to rule the app</b></summary>
438
 
439
  `app/.pyfun` is the single source of truth for all app behavior. Three tiers:
440
 
 
454
  [HUB_END]
455
  ```
456
 
457
+ **About `[MODELS]` costs (v1.2.0):** auto-pricing fills everything from OpenRouter + LiteLLM. Manual `cost_*` lines only remain for models no external source can know — my own deployments. If you ever get different conditions than the public lists (custom deals exist), just add `cost_*` lines for THAT model — `.pyfun` wins, always. The section also carries `requests_per_min/day`, `context_tokens`, `capabilities` and the provider mapping — none of that exists in any price API.
458
+
459
  Adding a new LLM provider — two steps:
460
 
461
  ```ini
 
478
  }
479
  ```
480
 
481
+ </details>
482
+
483
+
484
  ---
485
 
486
+
487
+ <details>
488
+ <summary><b>📦 Dependencies</b></summary>
489
 
490
  ```
491
  # PyFundaments Core (always required)
 
508
  # psycopg2-binary — alternative PostgreSQL driver
509
  ```
510
 
511
+ > **Note:** The package is `mcp` (not `fastmcp`) — `FastMCP` is imported from `mcp.server.fastmcp`.
512
  > Streamable HTTP support requires `mcp >= 1.6.0`.
513
 
514
+ </details>
515
+
516
+
517
  ---
518
 
519
+
520
+ <details>
521
+ <summary><b>🛡 Security design</b></summary>
522
 
523
  - API keys live in HF Secrets / `.env` — never in `.pyfun`, never in code
524
  - `list_active_tools` returns key **names** only — never values
 
527
  - Direct execution of `app/app.py` blocked by design — warning + null-fundaments fallback
528
  - `fundaments/` initialized conditionally — missing services degrade gracefully, never crash
529
  - Streamable HTTP uses standard Bearer headers — no token-in-URL (unlike SSE)
530
+ - ShellMaster: **SECURITY LEVEL ROT** — hardblock list (rm -rf, mkfs, …), JSONL command registry, explicit confirmation before every execution, local-only (`localhost:5004`), token via `.env`
531
 
532
  > PyFundaments is not perfect. But it's more secure than most of what runs in production today.
533
 
534
  [→ Full Security Policy](SECURITY.md)
535
 
536
+ </details>
537
+
538
+
539
  ---
540
 
541
+ <details>
542
+ <summary><b>🧱 Foundation: PyFundaments</b></summary>
543
 
544
+ Built on [PyFundaments](PyFundaments.md) — a security-first Python boilerplate, crafted by hand over months (see the usernote at the top):
545
 
546
  - `config_handler.py` — env loading with validation
547
  - `postgresql.py` — async DB pool (Guardian-only)
 
549
  - `access_control.py` — role/permission management
550
  - `user_handler.py` — user lifecycle management
551
  - `security.py` — unified security manager composing the above
552
+ - `debug.py` — conditional startup diagnostics
553
 
554
  None accessible from `app/*`. Injected as a validated dict by `main.py`.
555
 
556
+ [→ PyFundaments Function Overview](PyFundaments%20–%20Function%20Overview.md)
557
+ [→ Module Docs](docs/app/)
558
  [→ Source Repo](https://github.com/VolkanSah/Multi-LLM-API-Gateway)
559
 
560
+ </details>
561
+
562
  ---
563
 
564
+ <details><summary><b>🛤️ Roadmap</b></summary>
565
+
566
+ ## haha that's good!
567
+ - [x] 10 months before...
568
+ - [x] some blackouts
569
+ - [x] more blackouts
570
+ - [x] ... month 6 WTF... runs but only stupid secure LLM wrapper and tools...
571
+ - [x] ... pff brick again!
572
+ - [x] ... fuck!
573
+ - [x] ... rewrite
574
+ - [x] fuck Neon DB + MCP changes secure stuff! fuck
575
+ - [x] rewritten
576
+ - [x] GLM checked it, stupid guy! but finds 2 hidden design bugs, no execution but stupid fail!
577
+ - [x] Claude checks again and again and again, my 5 hours limit used in 1.23 min. A record of my self, normally need 2 minutes
578
+ - [x] wtf, fuck you Claude, fuck you Gemini, fuck you GLM, fuck you Kimi, fuck you DeepSeek, all others I do not use
579
+ - [x] fuck! but runs again! I see my first instinct was the best, AI made me crazy!
580
+ - [x] new rewrite with Claude Sonnet, first kick this asshole in his fucking balls 12 times! I get poor bitch!
581
+ - [x] it runs...
582
+ - [x] Fuck yeah, stable fast, secure and against scammers! only for privates!
583
+ - [x] fail architecture for vibecoders!
584
+ - [x] README AI slop from AI with a fucking touch of me!
585
+ - [x] conditional initialization of fundament modules...
586
+ - [x] Fundament modules are ready for the app logic.
587
+ - [x] PostgreSQL bridge activ
588
+ - [x] ShellMaster & Discord API not 100 % finished!
589
+ - [X] tools an´d stiff.. later to much yet!
590
+ - [ ] some futers.. still crafting
591
+
592
+ </details>
593
+
594
+
595
 
 
 
 
596
 
597
  ---
598
 
 
599
 
600
+ <details>
601
+ <summary><b>📜 History & related projects</b></summary>
602
+
603
+ [ShellMaster](https://github.com/VolkanSah/ChatGPT-ShellMaster) (2023, MIT) was the precursor — browser-accessible shell for ChatGPT with session memory, built before MCP was a concept. Universal AI Hub is its natural evolution: same idea, proper architecture, dual interface. And yes — the ShellMaster idea is back as a tool hook, now with a red security level and a conscience.
604
+
605
+ Related:
606
+
607
+ - [Customs LLMs for free — Build Your Own LLM Service](https://github.com/VolkanSah/SmolLM2-customs/)
608
+ - [WP AI Hub (WordPress Client)](https://github.com/VolkanSah/WP-AI-HUB/)
609
+ - [ShellMaster (2023 precursor)](https://github.com/VolkanSah/ChatGPT-ShellMaster)
610
+
611
+ </details>
612
 
613
  ---
614
 
 
615
 
616
+ <details>
617
+ <summary><b>⚖️ License — Multi-License & ESOL (mandatory)</b></summary>
618
 
619
+ - Projekt GNU Affero General Public License v3.0 [see License](https://www.gnu.org/licenses/agpl-3.0.html) + Ethical Security Operations License v2.1 [see License](ESOL) — mandatory, non-severable
620
+ - Some scripts i made Apache License 2.0 [see License](https://www.apache.org/licenses/LICENSE-2.0) + GNU General Public License v3.0 [see License](https://www.gnu.org/licenses/gpl-3.0.html) + MIT License [see License](https://opensource.org/licenses/MIT) (only for as for examples, some skills and agents public on MIT or other open source license i use here in this hub for showcases, see files for more infos)
621
 
622
+ ##### By using this software you agree to all ethical constraints defined in ESOL v2.1.
623
+
624
+ </details>
625
 
626
  ---
627
 
628
+ *Architecture, security decisions and 100 % of brocken code ( ok 100 % Running with less than 1 % mistakes) and PyFundaments by me.*
629
+ *Built with Claude (Anthropic not claude coder or codex or wtf!) after 20 % own desaster 5 seps bevor relase, played with GLM to see an outher focus but at the end as a typing assistant for docs (and the occasional bug) after i brick my hole work after i made it to complex... so i mean its not ready!*
630
+
631
+ > crafted with passion - just wanted to understand how it works, don't actually need it, have a CLI 😄
632
+ >
633
+ > README.md craftet (rewritten) by gemini 3 Pro , fixed by me, looks terrible i know! AI slop from claude, geminie and me! Soon better i sewar!
634
 
 
SECURITY.md ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you find a security issue, please do **not** open a public GitHub issue.
6
+
7
+ Report privately via: [GitHub Security Advisories](https://github.com/VolkanSah/Universal-MCP-Hub/security/advisories/new)
8
+
9
+ Include: what you found, how to reproduce it, and what impact you think it has. You'll get a response within 72 hours. If the issue is confirmed, a fix will be prioritized before any public disclosure.
10
+
11
+ ---
12
+
13
+ ## Security Architecture
14
+
15
+ ### The Guardian Pattern
16
+
17
+ `main.py` is the only process that ever touches secrets. It reads `.env` / HF Space Secrets, initializes `fundaments/*` conditionally, and passes the results into `app/*` as a validated dict. After that handoff, `app/*` has no path back to the raw environment.
18
+
19
+ ```
20
+ .env / HF Secrets
21
+ │
22
+ ▼
23
+ main.py (Guardian) — only entry point
24
+ │
25
+ │ initializes fundaments/* conditionally
26
+ │ injects as: fundaments = {"config": ..., "db": ..., "encryption": ..., ...}
27
+ │
28
+ ▼
29
+ app/app.py — unpacks fundaments ONCE at startup, never stores globally
30
+ │
31
+ └── app/* modules — read only from app/.pyfun, never from os.environ
32
+ ```
33
+
34
+ This is enforced structurally. `app/*` has no import of `fundaments/`, no `os.environ` calls, no `.env` access. It's not a coding convention — it's an import boundary.
35
+
36
+ ---
37
+
38
+ ### Secret Handling
39
+
40
+ - API keys live exclusively in HF Space Secrets or `.env` — never in `app/.pyfun`, never in source code
41
+ - `app/.pyfun` references only ENV variable **names** (e.g. `env_key = "ANTHROPIC_API_KEY"`) — never values
42
+ - `list_active_tools` returns key names only — the actual key values are never exposed through any MCP tool
43
+ - `fundaments/` modules are initialized only if their required ENV vars are present — missing config = service skipped, not crashed
44
+
45
+ ---
46
+
47
+ ### Encryption (`fundaments/encryption.py`)
48
+
49
+ When `MASTER_ENCRYPTION_KEY` and `PERSISTENT_ENCRYPTION_SALT` are set:
50
+
51
+ - Key derivation: **PBKDF2-HMAC-SHA256**, 480,000 iterations, 256-bit output
52
+ - Encryption: **AES-256-GCM** — authenticated encryption, provides both confidentiality and integrity
53
+ - Each encryption operation uses a fresh random 96-bit nonce
54
+ - Authentication tag (128-bit) is stored alongside ciphertext — decryption fails loudly if data was tampered
55
+ - File encryption uses streaming with 8 KB chunks — nonce prepended, tag appended to file
56
+ - If encryption keys are not set, the service is skipped cleanly — the app runs without it
57
+
58
+ ---
59
+
60
+ ### Authentication (`fundaments/user_handler.py`)
61
+
62
+ - Passwords hashed with **PBKDF2-SHA256** via `passlib`
63
+ - Sessions stored in SQLite with IP address + User-Agent binding — `validate_session()` checks both
64
+ - Session IDs are regenerated on login (session fixation prevention)
65
+ - Failed login attempts tracked per user — account locks after 5 consecutive failures
66
+ - Session table uses `ON DELETE CASCADE` — deleting a user removes all their sessions automatically
67
+
68
+ ---
69
+
70
+ ### Access Control (`fundaments/access_control.py`)
71
+
72
+ - Role-based permission system backed by PostgreSQL (cloud DB, Guardian-only)
73
+ - Schema: `users → user_role_assignments → user_roles → role_permissions → user_permissions`
74
+ - All queries use parameterized statements via `asyncpg` — no string interpolation
75
+ - `has_permission()` checks the full role→permission chain per request
76
+ - `AccessControl` only initializes if a DB connection is available — degrades gracefully otherwise
77
+
78
+ ---
79
+
80
+ ### Database Security (`fundaments/postgresql.py`)
81
+
82
+ **Cloud DB (asyncpg pool):**
83
+ - SSL enforced at minimum `sslmode=require` — upgraded automatically if not set
84
+ - Neon.tech quirks handled: `statement_timeout` stripped from DSN (unsupported, causes silent failures), keepalives set
85
+ - Credentials masked in all log output — full DSN only in `DEBUG` level logs
86
+ - SSL runtime check via `pg_stat_ssl` on every new connection (cloud providers: warning only if unavailable)
87
+ - Automatic pool restart on Neon connection termination (sqlstate `08006`)
88
+ - Pool: min 1 / max 10 connections, connect timeout 5s, command timeout 30s
89
+
90
+ **Internal SQLite (`app/db_sync.py` + `fundaments/user_handler.py`):**
91
+ - Same SQLite file, separate tables — zero overlap enforced by code
92
+ - `users` + `sessions` → owned by `user_handler.py` (Guardian layer)
93
+ - `hub_state` + `tool_cache` → owned by `db_sync.py` (app/* layer)
94
+ - `db_query` MCP tool exposes SELECT-only access to `hub_state` and `tool_cache` — cannot reach `users` or `sessions`
95
+ - SELECT enforcement: non-SELECT queries raise `ValueError` before execution
96
+ - On HuggingFace Spaces: SQLite auto-relocated to `/tmp/` (HF filesystem is read-only outside `/tmp/`)
97
+
98
+ ---
99
+
100
+ ### MCP Transport Security
101
+
102
+ - All MCP traffic routes through Quart `/mcp` endpoint before reaching FastMCP
103
+ - This is the natural interception point for auth checks, rate limiting, and payload logging — none are optional add-ons
104
+ - `/crypto` endpoint exists but returns `501` until the app-layer encryption wrapper is implemented — not bypassed, not silently ignored
105
+ - `health_check` and `list_active_tools` are always available — they expose no secrets, only status and key names
106
+
107
+ ---
108
+
109
+ ### Sandboxing Rules
110
+
111
+ These rules are enforced by code structure, not just documentation:
112
+
113
+ | Rule | Enforced by |
114
+ | :--- | :--- |
115
+ | `app/*` never reads `os.environ` | No `os.environ` calls in any `app/` file |
116
+ | `app/*` never imports `fundaments/` | No cross-imports between layers |
117
+ | `fundaments/` services injected, not constructed in `app/*` | `start_application(fundaments)` signature |
118
+ | Direct execution of `app/app.py` blocked | `__main__` guard with null-fundaments warning |
119
+ | `db_query` tool is SELECT-only | `ValueError` raised before any non-SELECT execution |
120
+ | API keys never in `.pyfun` | `.pyfun` parser only reads key names, never resolves them |
121
+
122
+ ---
123
+
124
+ ### What This Project Does Not Claim
125
+
126
+ - It is not a penetration-tested security product
127
+ - The encrypted `/crypto` endpoint is not yet implemented
128
+ - Search provider implementations (Brave, Tavily) are pending — the tool registers as placeholder
129
+ - This is a personal/community project — use in production at your own assessment
130
+
131
+ > PyFundaments is not perfect. But it's more secure than most of what runs in production today.
132
+
133
+ ---
134
+
135
+ ## Supported Versions
136
+
137
+ | Version | Supported |
138
+ | :--- | :--- |
139
+ | latest (`main`) | ✅ |
140
+ | older forks | ❌ — fork at your own risk |
141
+
142
+ ---
143
+
144
+ ## License Note
145
+
146
+ This project is dual-licensed under Apache 2.0 and the [Ethical Security Operations License v1.1 (ESOL)](ESOL). Using this software for surveillance, unauthorized access, or harm to others automatically terminates your license.
app/.pyfun CHANGED
@@ -18,15 +18,12 @@
18
  # DO NOT DELETE — file identifier used by all parsers
19
  [PYFUN_FILE = .pyfun]
20
 
21
- # =============================================================================
22
- # HUB — Core identity & transport config
23
- # =============================================================================
24
  # =============================================================================
25
  # HUB — Core identity & transport config
26
  # =============================================================================
27
  [HUB]
28
  HUB_NAME = "Multi-LLM-API-Gateway"
29
- HUB_VERSION = "1.0.1"
30
  HUB_DESCRIPTION = "Universal Hub built on PyFundaments"
31
 
32
  # Transport: sse (legacy/HF Spaces fallback) | streamable-http (MCP spec 2025-11-25)
@@ -40,17 +37,24 @@ HUB_HOST = "0.0.0.0"
40
  HUB_PORT = "7860"
41
  HUB_MODE = "mcp"
42
  HUB_SPACE_URL = ""
 
 
 
 
 
 
43
  [HUB_END]
44
 
45
  # =============================================================================
46
  # HUB_LIMITS — Request & retry behavior
47
  # =============================================================================
48
  [HUB_LIMITS]
49
- MAX_PARALLEL_REQUESTS = "5"
50
- RETRY_COUNT = "3"
51
- RETRY_DELAY_SEC = "2"
52
  REQUEST_TIMEOUT_SEC = "60"
53
  SEARCH_TIMEOUT_SEC = "30"
 
54
  [HUB_LIMITS_END]
55
 
56
  # =============================================================================
@@ -62,14 +66,26 @@ SEARCH_TIMEOUT_SEC = "30"
62
  # ── LLM Providers ─────────────────────────────────────────────────────────────
63
  [LLM_PROVIDERS]
64
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  # customs LLM
67
  [LLM_PROVIDER.smollm]
68
  active = "true"
 
69
  base_url = "https://codey-lab-smollm2-customs.hf.space/v1"
70
  env_key = "SMOLLM_API_KEY"
 
71
  default_model = "smollm2-360m"
72
- models = "smollm2-360m, codey-lab/model.universal-mcp-hub"
73
  fallback_to = "gemini"
74
  [LLM_PROVIDER.smollm_END]
75
 
@@ -77,63 +93,75 @@ SEARCH_TIMEOUT_SEC = "30"
77
 
78
  [LLM_PROVIDER.anthropic]
79
  active = "true"
 
80
  base_url = "https://api.anthropic.com/v1"
81
  env_key = "ANTHROPIC_API_KEY"
82
  api_version_header = "2023-06-01"
83
- default_model = "claude-haiku-4-5-20251001"
84
- models = "claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001"
85
- supports_cache = "true"
86
  fallback_to = "gemini"
87
  [LLM_PROVIDER.anthropic_END]
88
 
89
  [LLM_PROVIDER.gemini]
90
  active = "true"
 
91
  base_url = "https://generativelanguage.googleapis.com/v1beta"
92
  env_key = "GEMINI_API_KEY"
93
  default_model = "gemini-2.5-flash"
94
- models = "gemini-2.0-flash, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-3.1-flash-lite-preview, gemini-3-flash-preview, gemini-3.1-pro-preview"
95
  supports_cache = "true"
96
  fallback_to = "openrouter"
97
  [LLM_PROVIDER.gemini_END]
98
 
99
  [LLM_PROVIDER.openrouter]
100
  active = "true"
 
101
  base_url = "https://openrouter.ai/api/v1"
102
  env_key = "OPENROUTER_API_KEY" # → .env: OPENROUTER_API_KEY=sk-or-...
 
 
103
  default_model = "nvidia/nemotron-nano-9b-v2:free"
104
- models = "meta-llama/llama-3-8b-instruct, mistralai/mistral-7b-instruct, nvidia/nemotron-nano-9b-v2:free, google/gemma-3-27b-it:free, openai/gpt-oss-20b:free, mistralai/Mistral-7B-Instruct-v0.3, meta-llama/Llama-3.3-70B-Instruct"
105
- fallback_to = "huggingface"
106
  [LLM_PROVIDER.openrouter_END]
107
 
108
  [LLM_PROVIDER.huggingface]
109
  active = "true"
 
 
110
  base_url = "https://api-inference.huggingface.co/v1"
111
  env_key = "HF_TOKEN" # → .env: HF_TOKEN=hf_...
112
- default_model = "Qwen/Qwen2.5-Coder-32B-Instruct"
113
- models = "Qwen/Qwen2.5-Coder-32B-Instruct, meta-llama/Llama-3.1-8B-Instruct, mistralai/Mistral-Nemo-Instruct-2407, Qwen/Qwen2.5-72B-Instruct"
114
-
115
  fallback_to = "smollm" # ← last in chain, no further fallback
116
  [LLM_PROVIDER.huggingface_END]
117
 
118
- # ── Add more LLM providers below ──────────────────────────────────────────
 
119
  # [LLM_PROVIDER.mistral]
120
- # active = "false"
 
121
  # base_url = "https://api.mistral.ai/v1"
122
  # env_key = "MISTRAL_API_KEY"
123
- # default_model = "mistral-large-latest"
124
- # models = "mistral-large-latest, mistral-small-latest"
125
  # fallback_to = ""
126
  # [LLM_PROVIDER.mistral_END]
127
 
128
  # [LLM_PROVIDER.openai]
129
- # active = "false"
 
130
  # base_url = "https://api.openai.com/v1"
131
  # env_key = "OPENAI_API_KEY"
132
- # default_model = "gpt-4o"
133
- # models = "gpt-4o, gpt-4o-mini, gpt-3.5-turbo"
134
  # fallback_to = ""
135
  # [LLM_PROVIDER.openai_END]
136
 
 
 
 
 
 
 
 
 
 
137
  [LLM_PROVIDERS_END]
138
 
139
  # ── Search Providers ───────────────────────────────────────────────────────────
@@ -158,6 +186,20 @@ SEARCH_TIMEOUT_SEC = "30"
158
  fallback_to = ""
159
  [SEARCH_PROVIDER.tavily_END]
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  # ── Add more search providers below ───────────────────────────────────────
162
  # [SEARCH_PROVIDER.serper]
163
  # active = "false"
@@ -190,199 +232,132 @@ SEARCH_TIMEOUT_SEC = "30"
190
  # =============================================================================
191
  # MODELS — Token & rate limits per model
192
  # Parser builds: MODELS[provider][model_name] → limits dict
 
 
 
 
 
193
  # =============================================================================
194
  [MODELS]
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  [MODEL.claude-opus-4-6]
197
  provider = "anthropic"
198
- context_tokens = "200000"
199
- max_output_tokens = "32000"
200
  requests_per_min = "5"
201
  requests_per_day = "300"
202
- cost_input_per_1k = "0.015" # USD — update as pricing changes
203
- cost_output_per_1k = "0.075"
204
  capabilities = "text, code, analysis, vision"
205
  [MODEL.claude-opus-4-6_END]
206
 
207
  [MODEL.claude-sonnet-4-6]
208
  provider = "anthropic"
209
- context_tokens = "200000"
210
- max_output_tokens = "16000"
211
  requests_per_min = "50"
212
  requests_per_day = "1000"
213
- cost_input_per_1k = "0.003"
214
- cost_output_per_1k = "0.015"
215
  capabilities = "text, code, analysis, vision"
216
  [MODEL.claude-sonnet-4-6_END]
217
 
218
  [MODEL.claude-haiku-4-5-20251001]
219
  provider = "anthropic"
220
- context_tokens = "200000"
221
- max_output_tokens = "8000"
222
  requests_per_min = "50"
223
  requests_per_day = "2000"
224
- cost_input_per_1k = "0.00025"
225
- cost_output_per_1k = "0.00125"
226
  capabilities = "text, code, fast"
227
  [MODEL.claude-haiku-4-5-20251001_END]
228
 
229
  [MODEL.gemini-3.1-flash-lite-preview]
230
  provider = "gemini"
231
- context_tokens = "1000000"
232
- max_output_tokens = "8192"
233
  requests_per_min = "15"
234
  requests_per_day = "1000"
235
- cost_input_per_1k = "0.00010"
236
- cost_output_per_1k = "0.00040"
237
  capabilities = "text, code, vision, fast, cheap"
238
  [MODEL.gemini-3.1-flash-lite-preview_END]
239
 
240
  [MODEL.gemini-3-flash-preview]
241
  provider = "gemini"
242
- context_tokens = "1000000"
243
- max_output_tokens = "8192"
244
  requests_per_min = "10"
245
  requests_per_day = "1000"
246
- cost_input_per_1k = "0.00050"
247
- cost_output_per_1k = "0.00300"
248
  capabilities = "text, code, vision, audio, frontier"
249
  [MODEL.gemini-3-flash-preview_END]
250
 
251
  [MODEL.gemini-3.1-pro-preview]
252
  provider = "gemini"
253
- context_tokens = "1000000"
254
- max_output_tokens = "64000"
255
  requests_per_min = "5"
256
  requests_per_day = "1000"
257
- cost_input_per_1k = "0.00200"
258
- cost_output_per_1k = "0.01200"
259
  capabilities = "text, code, vision, audio, reasoning, agents"
260
  [MODEL.gemini-3.1-pro-preview_END]
261
 
262
  [MODEL.gemini-2.5-flash]
263
  provider = "gemini"
264
- context_tokens = "1000000"
265
- max_output_tokens = "8192"
266
  requests_per_min = "15"
267
  requests_per_day = "1500"
268
- cost_input_per_1k = "0.00010"
269
- cost_output_per_1k = "0.00040"
270
  capabilities = "text, code, vision, audio"
271
  [MODEL.gemini-2.5-flash_END]
272
 
273
  [MODEL.gemini-2.5-flash-lite]
274
  provider = "gemini"
275
- context_tokens = "1000000"
276
- max_output_tokens = "8192"
277
  requests_per_min = "15"
278
  requests_per_day = "1500"
279
- cost_input_per_1k = "0.00010"
280
- cost_output_per_1k = "0.00040"
281
  capabilities = "text, code, vision, audio"
282
- [MODEL.gemini-2.0-flash-lite_END]
283
-
284
 
285
  [MODEL.gemini-2.0-flash]
286
  provider = "gemini"
287
- context_tokens = "1000000"
288
- max_output_tokens = "8192"
289
  requests_per_min = "15"
290
  requests_per_day = "1500"
291
- cost_input_per_1k = "0.00010"
292
- cost_output_per_1k = "0.00040"
293
  capabilities = "text, code, vision, audio"
294
  [MODEL.gemini-2.0-flash_END]
295
 
296
-
297
- [MODEL.mistral-7b-instruct]
298
  provider = "openrouter"
299
- context_tokens = "32000"
300
- max_output_tokens = "4096"
301
- requests_per_min = "60"
302
- requests_per_day = "10000"
303
- cost_input_per_1k = "0.00006"
304
- cost_output_per_1k = "0.00006"
305
  capabilities = "text, code, fast, cheap"
306
- [MODEL.mistral-7b-instruct_END]
307
-
308
- [MODEL.dolphin-mistral-24b-venice-edition]
309
  provider = "openrouter"
310
- context_tokens = "32768"
311
- max_output_tokens = "4096"
312
- requests_per_min = ""
313
- requests_per_day = ""
314
- cost_input_per_1k = "0.00000"
315
- cost_output_per_1k = "0.00000"
316
  capabilities = "uncensored, text, code, fast, very cheap"
317
- [MODEL.dolphin-mistral-24b-venice-edition_END]
318
 
319
- [MODEL.nvidia-nemotron-nano-9b-v2]
320
  provider = "openrouter"
321
- context_tokens = "131072"
322
- max_output_tokens = "4096"
323
- requests_per_min = ""
324
- requests_per_day = ""
325
- cost_input_per_1k = "0.00000"
326
- cost_output_per_1k = "0.00000"
327
  capabilities = "text, code, reasoning, fast, free"
328
- [MODEL.nvidia-nemotron-nano-9b-v2_END]
329
 
330
- [MODEL.gemma-3-27b-it]
331
  provider = "openrouter"
332
- context_tokens = "131072"
333
- max_output_tokens = "8192"
334
- requests_per_min = ""
335
- requests_per_day = ""
336
- cost_input_per_1k = "0.00000"
337
- cost_output_per_1k = "0.00000"
338
  capabilities = "text, code, vision, multilingual, free"
339
- [MODEL.gemma-3-27b-it_END]
340
 
341
- [MODEL.gpt-oss-20b]
342
  provider = "openrouter"
343
- context_tokens = "128000"
344
- max_output_tokens = "4096"
345
- requests_per_min = ""
346
- requests_per_day = ""
347
- cost_input_per_1k = "0.00000"
348
- cost_output_per_1k = "0.00000"
349
  capabilities = "text, code, free"
350
- [MODEL.gpt-oss-20b_END]
351
 
352
- [MODEL.deephermes-3-llama-3-8b-preview]
353
  provider = "openrouter"
354
- context_tokens = "131072"
355
- max_output_tokens = "4096"
356
- requests_per_min = ""
357
- requests_per_day = ""
358
- cost_input_per_1k = "0.00000"
359
- cost_output_per_1k = "0.00000"
360
  capabilities = "text, code, reasoning, uncensored, free"
361
- [MODEL.deephermes-3-llama-3-8b-preview_END]
362
 
363
- [MODEL.Mistral-7B-Instruct-v0.3]
364
- provider = "openrouter"
365
- context_tokens = "32768"
366
- max_output_tokens = "4096"
367
- requests_per_min = "60"
368
- requests_per_day = "10000"
369
- cost_input_per_1k = "0.00006"
370
- cost_output_per_1k = "0.00006"
371
  capabilities = "text, code, fast, cheap"
372
- [MODEL.Mistral-7B-Instruct-v0.3_END]
373
 
374
- [MODEL.Llama-3.3-70B-Instruct]
375
- provider = "openrouter"
376
- context_tokens = "131072"
377
- max_output_tokens = "8192"
378
- requests_per_min = "60"
379
- requests_per_day = "10000"
380
- cost_input_per_1k = "0.00012"
381
- cost_output_per_1k = "0.00030"
382
  capabilities = "text, code, reasoning, multilingual"
383
- [MODEL.Llama-3.3-70B-Instruct_END]
384
 
385
- # customs llms
386
  [MODEL.smollm2-360m]
387
  provider = "smollm"
388
  context_tokens = "8192"
@@ -405,6 +380,46 @@ SEARCH_TIMEOUT_SEC = "30"
405
  capabilities = "text, assistant, navigation, free, custom, finetuned"
406
  [MODEL.codey-lab/model.universal-mcp-hub_END]
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  [MODELS_END]
409
 
410
  # =============================================================================
@@ -413,6 +428,10 @@ SEARCH_TIMEOUT_SEC = "30"
413
  # =============================================================================
414
  [TOOLS]
415
 
 
 
 
 
416
  [TOOL.llm_complete]
417
  active = "true"
418
  description = "Send prompt to any configured LLM provider"
@@ -467,11 +486,14 @@ SEARCH_TIMEOUT_SEC = "30"
467
 
468
  [TOOL.persist_result]
469
  active = "true"
470
- description = "Persist hub_state data to PostgreSQL for long-term storage"
471
  provider_type = "persist"
472
  state_read_key = "" # welcher hub_state Key → leer = prompt direkt
473
  target_table = "hub_results"
474
  timeout_sec = "10"
 
 
 
475
  [TOOL.persist_result_END]
476
 
477
  # ── Future tools ──────────────────────────────────────────────────────────
@@ -491,10 +513,15 @@ SEARCH_TIMEOUT_SEC = "30"
491
  # [TOOL.code_exec_END]
492
 
493
  # ── Shellmaster 2.0 ──────────────────────────────────────────────────────────
 
 
 
 
494
  [TOOL.shellmaster]
495
  active = "false"
496
  shellmaster_agent_url = "http://localhost:5004"
497
- shellmaster_token = ""
 
498
  description = "AI-powered shell assistant — generates and executes safe shell commands"
499
  provider_type = "llm"
500
  default_provider = "smollm"
@@ -520,6 +547,43 @@ SEARCH_TIMEOUT_SEC = "30"
520
  description = "List allowed ShellMaster commands from registry"
521
  [TOOL.shellmaster_commands_END]
522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  [TOOLS_END]
524
 
525
  # =============================================================================
@@ -530,17 +594,98 @@ SEARCH_TIMEOUT_SEC = "30"
530
  SQLITE_PATH = "app/.hub_state.db" # internal state, never commit!
531
  SYNC_INTERVAL_SEC = "30" # how often to flush to SQLite
532
  MAX_CACHE_ENTRIES = "1000"
 
 
 
 
 
 
 
 
 
 
533
  [DB_SYNC_END]
534
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  # =============================================================================
536
  # DEBUG — app/* debug behavior (fundaments debug stays in .env)
 
 
 
 
537
  # =============================================================================
538
  [DEBUG]
539
- DEBUG = "ON" # ON | OFF
540
- DEBUG_LEVEL = "FULL" # FULL | WARN | ERROR
541
- LOG_FILE = "hub_debug.log"
542
- LOG_REQUESTS = "true" # log every provider request
543
- LOG_RESPONSES = "true" # careful: may log sensitive data!
544
  [DEBUG_END]
545
 
546
  # =============================================================================
 
18
  # DO NOT DELETE — file identifier used by all parsers
19
  [PYFUN_FILE = .pyfun]
20
 
 
 
 
21
  # =============================================================================
22
  # HUB — Core identity & transport config
23
  # =============================================================================
24
  [HUB]
25
  HUB_NAME = "Multi-LLM-API-Gateway"
26
+ HUB_VERSION = "1.2.0"
27
  HUB_DESCRIPTION = "Universal Hub built on PyFundaments"
28
 
29
  # Transport: sse (legacy/HF Spaces fallback) | streamable-http (MCP spec 2025-11-25)
 
37
  HUB_PORT = "7860"
38
  HUB_MODE = "mcp"
39
  HUB_SPACE_URL = ""
40
+
41
+ # Optional Token-Schutz für /api, /crypto und /mcp (siehe app/auth.py).
42
+ # Hier steht nur der NAME der ENV-Variable — der Token selbst kommt in .env / Space-Secret.
43
+ # Variable nicht gesetzt = Schutz AUS (Hub verhält sich wie bisher).
44
+ # Client sendet: X-Hub-Token: <token> oder Authorization: Bearer <token>
45
+ HUB_AUTH_ENV = "HUB_API_TOKEN"
46
  [HUB_END]
47
 
48
  # =============================================================================
49
  # HUB_LIMITS — Request & retry behavior
50
  # =============================================================================
51
  [HUB_LIMITS]
52
+ MAX_PARALLEL_REQUESTS = "5" # gleichzeitige Provider-Calls (Semaphore)
53
+ RETRY_COUNT = "3" # Versuche pro Provider (inkl. erstem) bei 429/5xx/Verbindungsfehler
54
+ RETRY_DELAY_SEC = "2" # Pause zwischen den Versuchen; Timeouts werden NICHT wiederholt
55
  REQUEST_TIMEOUT_SEC = "60"
56
  SEARCH_TIMEOUT_SEC = "30"
57
+ MAX_TOKENS_CAP = "8192" # harte Obergrenze für max_tokens pro Call (0 = aus)
58
  [HUB_LIMITS_END]
59
 
60
  # =============================================================================
 
66
  # ── LLM Providers ─────────────────────────────────────────────────────────────
67
  [LLM_PROVIDERS]
68
 
69
+ # ───────────────────────────────────────────────────────────────────────────
70
+ # type = welcher Handler den Provider spricht:
71
+ # "openai_compat" → generisch (/chat/completions) — KEIN Code nötig, nur dieser Block
72
+ # "anthropic" | "gemini" → eigene Klassen in providers.py
73
+ # Nur für openai_compat (alles optional):
74
+ # auth_header = "Authorization" (Default) | auth_scheme = "Bearer" (Default, "" = Key pur)
75
+ # chat_path = "/chat/completions" (Default)
76
+ # header.<Name> = "..." Extra-Header. Platzhalter: {key} = API-Key,
77
+ # {env:NAME|fallback} = Wert aus ENV (mit Fallback)
78
+ # Modelle gehören NICHT hierher, sondern nach [MODELS] (Name = echte API-ID).
79
+ # ───────────────────────────────────────────────────────────────────────────
80
 
81
  # customs LLM
82
  [LLM_PROVIDER.smollm]
83
  active = "true"
84
+ type = "openai_compat"
85
  base_url = "https://codey-lab-smollm2-customs.hf.space/v1"
86
  env_key = "SMOLLM_API_KEY"
87
+ header.X-IP-Token = "{key}"
88
  default_model = "smollm2-360m"
 
89
  fallback_to = "gemini"
90
  [LLM_PROVIDER.smollm_END]
91
 
 
93
 
94
  [LLM_PROVIDER.anthropic]
95
  active = "true"
96
+ type = "anthropic"
97
  base_url = "https://api.anthropic.com/v1"
98
  env_key = "ANTHROPIC_API_KEY"
99
  api_version_header = "2023-06-01"
100
+ default_model = "claude-haiku-5-5"
101
+ supports_cache = "true"
 
102
  fallback_to = "gemini"
103
  [LLM_PROVIDER.anthropic_END]
104
 
105
  [LLM_PROVIDER.gemini]
106
  active = "true"
107
+ type = "gemini"
108
  base_url = "https://generativelanguage.googleapis.com/v1beta"
109
  env_key = "GEMINI_API_KEY"
110
  default_model = "gemini-2.5-flash"
 
111
  supports_cache = "true"
112
  fallback_to = "openrouter"
113
  [LLM_PROVIDER.gemini_END]
114
 
115
  [LLM_PROVIDER.openrouter]
116
  active = "true"
117
+ type = "openai_compat"
118
  base_url = "https://openrouter.ai/api/v1"
119
  env_key = "OPENROUTER_API_KEY" # → .env: OPENROUTER_API_KEY=sk-or-...
120
+ header.HTTP-Referer = "{env:APP_URL|https://huggingface.co}"
121
+ header.X-Title = "{env:HUB_NAME|Universal MCP Hub}"
122
  default_model = "nvidia/nemotron-nano-9b-v2:free"
123
+ fallback_to = "huggingface"
 
124
  [LLM_PROVIDER.openrouter_END]
125
 
126
  [LLM_PROVIDER.huggingface]
127
  active = "true"
128
+ type = "openai_compat"
129
+ # TODO prüfen: HF hat (soweit ich weiß) auf https://router.huggingface.co/v1 umgestellt
130
  base_url = "https://api-inference.huggingface.co/v1"
131
  env_key = "HF_TOKEN" # → .env: HF_TOKEN=hf_...
132
+ default_model = "Qwen/Qwen2.5-Coder-32B-Instruct"
 
 
133
  fallback_to = "smollm" # ← last in chain, no further fallback
134
  [LLM_PROVIDER.huggingface_END]
135
 
136
+ # ── Neuer OpenAI-kompatibler Provider = NUR so ein Block + Key in .env ─────
137
+ # (Beispiele zum Einkommentieren — kein Python nötig. Passende Modelle in [MODELS] eintragen!)
138
  # [LLM_PROVIDER.mistral]
139
+ # active = "true"
140
+ # type = "openai_compat"
141
  # base_url = "https://api.mistral.ai/v1"
142
  # env_key = "MISTRAL_API_KEY"
143
+ # default_model = "mistral-small-latest"
 
144
  # fallback_to = ""
145
  # [LLM_PROVIDER.mistral_END]
146
 
147
  # [LLM_PROVIDER.openai]
148
+ # active = "true"
149
+ # type = "openai_compat"
150
  # base_url = "https://api.openai.com/v1"
151
  # env_key = "OPENAI_API_KEY"
152
+ # default_model = "gpt-4o-mini"
 
153
  # fallback_to = ""
154
  # [LLM_PROVIDER.openai_END]
155
 
156
+ # [LLM_PROVIDER.xai]
157
+ # active = "true"
158
+ # type = "openai_compat"
159
+ # base_url = "https://api.x.ai/v1"
160
+ # env_key = "XAI_API_KEY"
161
+ # default_model = "grok-4"
162
+ # fallback_to = ""
163
+ # [LLM_PROVIDER.xai_END]
164
+
165
  [LLM_PROVIDERS_END]
166
 
167
  # ── Search Providers ───────────────────────────────────────────────────────────
 
186
  fallback_to = ""
187
  [SEARCH_PROVIDER.tavily_END]
188
 
189
+ # Keyless Notfall-Fallback (liest die öffentliche HTML-Seite — es gibt KEINE offizielle DDG-API,
190
+ # daher fragil + bewusst "sanft": globaler Cooldown, kein Retry, ehrlicher User-Agent).
191
+ # Aktivieren: active = "true" UND bei tavily fallback_to = "duckduckgo"
192
+ # (ungetestet gegen das echte DuckDuckGo — erst ausprobieren!)
193
+ [SEARCH_PROVIDER.duckduckgo]
194
+ active = "false"
195
+ requires_key = "false" # kein API-Key nötig
196
+ base_url = "https://html.duckduckgo.com/html/"
197
+ min_interval_sec = "300" # mind. 5 Min zwischen zwei Anfragen
198
+ default_results = "5"
199
+ max_results = "10"
200
+ fallback_to = ""
201
+ [SEARCH_PROVIDER.duckduckgo_END]
202
+
203
  # ── Add more search providers below ───────────────────────────────────────
204
  # [SEARCH_PROVIDER.serper]
205
  # active = "false"
 
232
  # =============================================================================
233
  # MODELS — Token & rate limits per model
234
  # Parser builds: MODELS[provider][model_name] → limits dict
235
+ # COSTS: cost_*-Zeilen sind seit v1.1 OPTIONAL — [PRICING] füllt sie automatisch
236
+ # aus OpenRouter + LiteLLM nach (OR markiert nie auf: per-Token = Vendor-
237
+ # Listenpreis; ~5.5% Gebühr fällt nur beim Kredit-Kauf an, nicht pro Token).
238
+ # Deine manuellen cost_* gewinnen IMMER als Override — sinnvoll für eigene
239
+ # Deployments (smollm = 0.0, weil eigener Server) und Sonderkonditionen.
240
  # =============================================================================
241
  [MODELS]
242
 
243
+ # ───────────────────────────────────────────────��───────────────────────────
244
+ # REGELN (damit nichts mehr veraltet):
245
+ # - Block-Name = ECHTE API-ID so wie der Provider sie erwartet
246
+ # (OpenRouter: "vendor/name[:free]", HF: "Org/Name", Anthropic/Gemini: wie in der API)
247
+ # - provider = Pflicht. Hat ein Tool nur ein model (ohne provider), nimmt der Hub
248
+ # den Provider von hier.
249
+ # - context_tokens / max_output_tokens / cost_* NICHT pflegen — [PRICING] holt sie
250
+ # live aus OpenRouter + LiteLLM. Manuelle Werte gewinnen IMMER; nutze sie nur für
251
+ # das, was nur du weißt (eigener Server, Sonderkonditionen). max_output_tokens
252
+ # begrenzt zusätzlich die Antwortlänge (Clamp).
253
+ # - requests_per_min/day = dein Account-Limit, wird nur angezeigt, NICHT erzwungen.
254
+ # - OpenRouter-Modelle, die es im Live-Katalog nicht mehr gibt, meldet der Hub
255
+ # beim Pricing-Refresh als "stale_models" (Log + list_active_tools + refresh_pricing).
256
+ # ───────────────────────────────────────────────────────────────────────────
257
+
258
  [MODEL.claude-opus-4-6]
259
  provider = "anthropic"
 
 
260
  requests_per_min = "5"
261
  requests_per_day = "300"
 
 
262
  capabilities = "text, code, analysis, vision"
263
  [MODEL.claude-opus-4-6_END]
264
 
265
  [MODEL.claude-sonnet-4-6]
266
  provider = "anthropic"
 
 
267
  requests_per_min = "50"
268
  requests_per_day = "1000"
 
 
269
  capabilities = "text, code, analysis, vision"
270
  [MODEL.claude-sonnet-4-6_END]
271
 
272
  [MODEL.claude-haiku-4-5-20251001]
273
  provider = "anthropic"
 
 
274
  requests_per_min = "50"
275
  requests_per_day = "2000"
 
 
276
  capabilities = "text, code, fast"
277
  [MODEL.claude-haiku-4-5-20251001_END]
278
 
279
  [MODEL.gemini-3.1-flash-lite-preview]
280
  provider = "gemini"
 
 
281
  requests_per_min = "15"
282
  requests_per_day = "1000"
 
 
283
  capabilities = "text, code, vision, fast, cheap"
284
  [MODEL.gemini-3.1-flash-lite-preview_END]
285
 
286
  [MODEL.gemini-3-flash-preview]
287
  provider = "gemini"
 
 
288
  requests_per_min = "10"
289
  requests_per_day = "1000"
 
 
290
  capabilities = "text, code, vision, audio, frontier"
291
  [MODEL.gemini-3-flash-preview_END]
292
 
293
  [MODEL.gemini-3.1-pro-preview]
294
  provider = "gemini"
 
 
295
  requests_per_min = "5"
296
  requests_per_day = "1000"
 
 
297
  capabilities = "text, code, vision, audio, reasoning, agents"
298
  [MODEL.gemini-3.1-pro-preview_END]
299
 
300
  [MODEL.gemini-2.5-flash]
301
  provider = "gemini"
 
 
302
  requests_per_min = "15"
303
  requests_per_day = "1500"
 
 
304
  capabilities = "text, code, vision, audio"
305
  [MODEL.gemini-2.5-flash_END]
306
 
307
  [MODEL.gemini-2.5-flash-lite]
308
  provider = "gemini"
 
 
309
  requests_per_min = "15"
310
  requests_per_day = "1500"
 
 
311
  capabilities = "text, code, vision, audio"
312
+ [MODEL.gemini-2.5-flash-lite_END]
 
313
 
314
  [MODEL.gemini-2.0-flash]
315
  provider = "gemini"
 
 
316
  requests_per_min = "15"
317
  requests_per_day = "1500"
 
 
318
  capabilities = "text, code, vision, audio"
319
  [MODEL.gemini-2.0-flash_END]
320
 
321
+ [MODEL.mistralai/mistral-7b-instruct]
 
322
  provider = "openrouter"
 
 
 
 
 
 
323
  capabilities = "text, code, fast, cheap"
324
+ [MODEL.mistralai/mistral-7b-instruct_END]
325
+
326
+ [MODEL.cognitivecomputations/dolphin-mistral-24b-venice-edition:free]
327
  provider = "openrouter"
 
 
 
 
 
 
328
  capabilities = "uncensored, text, code, fast, very cheap"
329
+ [MODEL.cognitivecomputations/dolphin-mistral-24b-venice-edition:free_END]
330
 
331
+ [MODEL.nvidia/nemotron-nano-9b-v2:free]
332
  provider = "openrouter"
 
 
 
 
 
 
333
  capabilities = "text, code, reasoning, fast, free"
334
+ [MODEL.nvidia/nemotron-nano-9b-v2:free_END]
335
 
336
+ [MODEL.google/gemma-3-27b-it:free]
337
  provider = "openrouter"
 
 
 
 
 
 
338
  capabilities = "text, code, vision, multilingual, free"
339
+ [MODEL.google/gemma-3-27b-it:free_END]
340
 
341
+ [MODEL.openai/gpt-oss-20b:free]
342
  provider = "openrouter"
 
 
 
 
 
 
343
  capabilities = "text, code, free"
344
+ [MODEL.openai/gpt-oss-20b:free_END]
345
 
346
+ [MODEL.nousresearch/deephermes-3-llama-3-8b-preview:free]
347
  provider = "openrouter"
 
 
 
 
 
 
348
  capabilities = "text, code, reasoning, uncensored, free"
349
+ [MODEL.nousresearch/deephermes-3-llama-3-8b-preview:free_END]
350
 
351
+ [MODEL.mistralai/Mistral-7B-Instruct-v0.3]
352
+ provider = "huggingface"
 
 
 
 
 
 
353
  capabilities = "text, code, fast, cheap"
354
+ [MODEL.mistralai/Mistral-7B-Instruct-v0.3_END]
355
 
356
+ [MODEL.meta-llama/Llama-3.3-70B-Instruct]
357
+ provider = "huggingface"
 
 
 
 
 
 
358
  capabilities = "text, code, reasoning, multilingual"
359
+ [MODEL.meta-llama/Llama-3.3-70B-Instruct_END]
360
 
 
361
  [MODEL.smollm2-360m]
362
  provider = "smollm"
363
  context_tokens = "8192"
 
380
  capabilities = "text, assistant, navigation, free, custom, finetuned"
381
  [MODEL.codey-lab/model.universal-mcp-hub_END]
382
 
383
+ [MODEL.claude-opus-5-5]
384
+ provider = "anthropic"
385
+ capabilities = "text, code, analysis, vision"
386
+ [MODEL.claude-opus-5-5_END]
387
+
388
+ [MODEL.claude-sonnet-5-5]
389
+ provider = "anthropic"
390
+ capabilities = "text, code, analysis, vision"
391
+ [MODEL.claude-sonnet-5-5_END]
392
+
393
+ [MODEL.claude-haiku-5-5]
394
+ provider = "anthropic"
395
+ capabilities = "text, code, fast"
396
+ [MODEL.claude-haiku-5-5_END]
397
+
398
+ [MODEL.meta-llama/llama-3-8b-instruct]
399
+ provider = "openrouter"
400
+ capabilities = "text, code, fast"
401
+ [MODEL.meta-llama/llama-3-8b-instruct_END]
402
+
403
+ [MODEL.Qwen/Qwen2.5-Coder-32B-Instruct]
404
+ provider = "huggingface"
405
+ capabilities = "text, code"
406
+ [MODEL.Qwen/Qwen2.5-Coder-32B-Instruct_END]
407
+
408
+ [MODEL.meta-llama/Llama-3.1-8B-Instruct]
409
+ provider = "huggingface"
410
+ capabilities = "text, code, fast"
411
+ [MODEL.meta-llama/Llama-3.1-8B-Instruct_END]
412
+
413
+ [MODEL.mistralai/Mistral-Nemo-Instruct-2407]
414
+ provider = "huggingface"
415
+ capabilities = "text, code, multilingual"
416
+ [MODEL.mistralai/Mistral-Nemo-Instruct-2407_END]
417
+
418
+ [MODEL.Qwen/Qwen2.5-72B-Instruct]
419
+ provider = "huggingface"
420
+ capabilities = "text, code, reasoning, multilingual"
421
+ [MODEL.Qwen/Qwen2.5-72B-Instruct_END]
422
+
423
  [MODELS_END]
424
 
425
  # =============================================================================
 
428
  # =============================================================================
429
  [TOOLS]
430
 
431
+ # Optional pro Tool (llm + search): Antwort-Cache über db_sync.
432
+ # cache = "true" und cache_ttl_sec = "3600"
433
+ # Default AUS (LLM-Antworten sind nicht deterministisch).
434
+
435
  [TOOL.llm_complete]
436
  active = "true"
437
  description = "Send prompt to any configured LLM provider"
 
486
 
487
  [TOOL.persist_result]
488
  active = "true"
489
+ description = "Persist important results to long-term memory (PostgreSQL) — restart-safe queue in SQLite, flushes automatically"
490
  provider_type = "persist"
491
  state_read_key = "" # welcher hub_state Key → leer = prompt direkt
492
  target_table = "hub_results"
493
  timeout_sec = "10"
494
+ # score-Parameter (optional, float) = reservierter Slot fuer den
495
+ # Bullshit-Filter — wird unverarbeitet durchgereicht und landet als
496
+ # eigene Spalte in PostgreSQL. Filter/Scoring baust DU, nicht der Hub.
497
  [TOOL.persist_result_END]
498
 
499
  # ── Future tools ──────────────────────────────────────────────────────────
 
513
  # [TOOL.code_exec_END]
514
 
515
  # ── Shellmaster 2.0 ──────────────────────────────────────────────────────────
516
+ # SECURITY LEVEL: ROT — dein CLI-Commander. LLM generiert → Hardblock+
517
+ # Registry-Check → DEINE Bestätigung → Ausführung auf DEINEM lokalen
518
+ # Rechner (Agent :5004). NUR lokal — NIEMALS auf öffentlichen HF Spaces
519
+ # aktivieren! Token gehört in .env (shellmaster_env_key), nicht hierher.
520
  [TOOL.shellmaster]
521
  active = "false"
522
  shellmaster_agent_url = "http://localhost:5004"
523
+ shellmaster_env_key = "SHELLMASTER_TOKEN" # → .env, wie bei allen Providern
524
+ shellmaster_token = "" # Fallback nur für lokale Tests
525
  description = "AI-powered shell assistant — generates and executes safe shell commands"
526
  provider_type = "llm"
527
  default_provider = "smollm"
 
547
  description = "List allowed ShellMaster commands from registry"
548
  [TOOL.shellmaster_commands_END]
549
 
550
+ # ── Integrationen (alle inaktiv, bis [DISCORD] / [GSC] unten aktiv sind) ──────
551
+ [TOOL.discord_post]
552
+ active = "false"
553
+ description = "Post a message to Discord (default channel / webhook)"
554
+ provider_type = "discord"
555
+ [TOOL.discord_post_END]
556
+
557
+ [TOOL.gsc_query]
558
+ active = "false"
559
+ description = "Google Search Console: top queries/pages of a property (read-only)"
560
+ provider_type = "gsc"
561
+ [TOOL.gsc_query_END]
562
+
563
+ # ── Skill-Datenbanken (app/db/skills/, gebaut mit local_tools/vector_hub) — nur lesen ───────
564
+ [TOOL.show_skills]
565
+ active = "true"
566
+ description = "List available skills (name, description, tags) — user + agent skills"
567
+ provider_type = "skills"
568
+ skills_action = "catalog"
569
+ [TOOL.show_skills_END]
570
+
571
+ [TOOL.search_skills]
572
+ active = "true"
573
+ description = "Search skills by keywords (BM25) — returns the best matching chunks"
574
+ provider_type = "skills"
575
+ skills_action = "search"
576
+ [TOOL.search_skills_END]
577
+
578
+ # LLM-Tools: Skills automatisch in den System-Prompt (überschreibt die globalen [SKILLS]-Schalter):
579
+ # use_core = "true" core.db (Regeln/Restriktionen) voranstellen — kostet bei JEDEM Call Tokens
580
+ # use_skills = "true" passende user-Skill-Chunks (BM25 auf den Prompt) anhängen
581
+
582
+ # ── Skills: System-Prompt aus Markdown-Datei (app/skills/*.md) ────────────────
583
+ # An jedem LLM-Tool möglich, zusätzlich zu system_prompt:
584
+ # system_prompt_file = "mein_skill.md"
585
+ # Es wird nur TEXT gelesen — aus app/skills/ wird nie Code ausgeführt.
586
+
587
  [TOOLS_END]
588
 
589
  # =============================================================================
 
594
  SQLITE_PATH = "app/.hub_state.db" # internal state, never commit!
595
  SYNC_INTERVAL_SEC = "30" # how often to flush to SQLite
596
  MAX_CACHE_ENTRIES = "1000"
597
+ PERSIST_QUEUE_MAX = "500" # restart-safe queue: SQLite → PostgreSQL
598
+ PERSIST_FLUSH_INTERVAL_SEC = "60" # retry interval for the PG flusher
599
+
600
+ # Neon scale-to-zero: der Hub wartet NIE auf PostgreSQL und crasht nicht (siehe db_sync Section 6b)
601
+ PG_WRITE_TIMEOUT_SEC = "10" # hartes Limit pro PG-Schreibzugriff
602
+ PG_BACKOFF_SEC = "30" # nach Verbindungsfehler so lange kein neuer Versuch (Queue bleibt)
603
+ PG_REQUEST_WAIT_SEC = "3" # so lange wartet persist_result max.; Rest läuft im Hintergrund
604
+ PG_PING_TIMEOUT_SEC = "5" # health_check: "ist die DB wach?"
605
+ PG_STATUS_TTL_SEC = "20" # Status-Ping wird so lange gecacht
606
+ PG_KEEPALIVE_SEC = "0" # 0 = aus. z.B. "240" hält Neon wach — verbraucht Compute-Stunden!
607
  [DB_SYNC_END]
608
 
609
+
610
+ # =============================================================================
611
+ # DISCORD — Integration (app/discord_api.py). Secrets NUR in .env, hier nur ENV-NAMEN.
612
+ # Aktivieren: DISCORD_ACTIVE = "true" + Tool [TOOL.discord_post] active = "true".
613
+ # ⚠ Vorher HUB_API_TOKEN setzen — sonst kann jeder, der /api erreicht, über den Bot posten.
614
+ # /discord (Interactions) braucht eine ÖFFENTLICHE URL (privater HF-Space = für Discord unerreichbar).
615
+ # =============================================================================
616
+ [DISCORD]
617
+ DISCORD_ACTIVE = "false"
618
+ DISCORD_ENV_KEY = "DISCORD_BOT_TOKEN" # → .env: Bot-Token
619
+ DISCORD_WEBHOOK_ENV_KEY = "DISCORD_WEBHOOK_URL" # → .env: Webhook-URL (die URL ist das Secret)
620
+ DISCORD_APP_ID = "" # Dev Portal → Application ID (öffentlich)
621
+ DISCORD_APP_PUBLIC_KEY = "" # Dev Portal → Public Key (öffentlich, nur Verify)
622
+ DISCORD_DEFAULT_CHANNEL = "" # Channel-ID für Posts ohne channel_id
623
+ DISCORD_ALLOWED_CHANNELS = "" # kommagetrennt — nur diese channel_id darf ein Client wählen
624
+ DISCORD_ALLOWED_GUILDS = "" # kommagetrennt — leer = /ask auf jedem Server, auf dem der Bot ist
625
+ DISCORD_TIMEOUT_SEC = "15"
626
+ [DISCORD_END]
627
+
628
+
629
+ # =============================================================================
630
+ # SKILLS — Skill-Datenbanken (app/skills_db.py), READ-ONLY.
631
+ # app/db/skills/core/core.db = Haupt-Skill mit allen Restriktionen (nie über Tools abrufbar)
632
+ # app/db/skills/core/*.db = Agent-Referenz (z.B. core_agent.db) — durchsuchbar
633
+ # app/db/skills/user/*.db = deine Skills (autoload) — durchsuchbar
634
+ # Die DBs baust du lokal mit local_tools/vector_hub/secure_vectorizer.py.
635
+ # Tools show_skills / search_skills sind oben aktiv; die Injektion in LLM-Prompts ist AUS (kostet Tokens).
636
+ # =============================================================================
637
+ [SKILLS]
638
+ SKILLS_ACTIVE = "true"
639
+ SKILLS_CORE_MAX_CHARS = "8000" # Obergrenze für den core-Text im System-Prompt
640
+ SKILLS_INJECT_CORE = "false" # true = core.db bei ALLEN llm-Tools voranstellen (je Tool: use_core)
641
+ SKILLS_INJECT_RELEVANT = "false" # true = passende user-Skill-Chunks anhängen (je Tool: use_skills)
642
+ SKILLS_TOP_K = "3"
643
+ SKILLS_MAX_CHARS = "3000"
644
+ [SKILLS_END]
645
+
646
+
647
+ # =============================================================================
648
+ # GSC — Google Search Console (app/gsc.py), read-only. Optionale Pakete:
649
+ # pip install google-api-python-client google-auth
650
+ # Credentials: ENV-Variable GSC_CREDENTIALS = Service-Account-JSON (Text) ODER Dateipfad.
651
+ # Die Service-Account-Mail muss in der Search Console als Nutzer der Property eingetragen sein.
652
+ # Aktivieren: GSC_ACTIVE = "true" + Tool [TOOL.gsc_query] active = "true".
653
+ # =============================================================================
654
+ [GSC]
655
+ GSC_ACTIVE = "false"
656
+ GSC_ENV_KEY = "GSC_CREDENTIALS"
657
+ GSC_DEFAULT_PROPERTY = "" # z.B. "sc-domain:example.com"
658
+ GSC_API_NAME = "searchconsole" # (alt: "webmasters" / "v3")
659
+ GSC_API_VERSION = "v1"
660
+ [GSC_END]
661
+
662
+ # =============================================================================
663
+ # PRICING — Auto model costs (OpenRouter + LiteLLM)
664
+ # WHY: not every provider has a pricing API (Anthropic/Google: none).
665
+ # These two public sources fill cost_input_per_1k / cost_output_per_1k
666
+ # automatically. Your [MODELS] values ALWAYS win as override.
667
+ # Cache lives in db_sync hub_state, TTL-gated (default 24h).
668
+ # =============================================================================
669
+ [PRICING]
670
+ PRICING_ENABLED = "true"
671
+ PRICING_TTL_HOURS = "24"
672
+ OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
673
+ LITELLM_PRICES_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
674
+ [PRICING_END]
675
+
676
  # =============================================================================
677
  # DEBUG — app/* debug behavior (fundaments debug stays in .env)
678
+ # NOTE (v1.1.0): This section is NOT YET WIRED — no app/* code reads it.
679
+ # Real debug switches live in .env: PYFUNDAMENTS_DEBUG / LOG_LEVEL /
680
+ # LOG_TO_TMP / ENABLE_PUBLIC_LOGS (see example-mcp___.env).
681
+ # Defaults below are SAFE (OFF) so wiring this later cannot leak data.
682
  # =============================================================================
683
  [DEBUG]
684
+ DEBUG = "OFF" # ON | OFF (not wired yet)
685
+ DEBUG_LEVEL = "WARN" # FULL | WARN | ERROR (not wired yet)
686
+ LOG_FILE = "hub_debug.log" # (not wired yet)
687
+ LOG_REQUESTS = "false" # (not wired yet)
688
+ LOG_RESPONSES = "false" # (not wired yet — would log sensitive data!)
689
  [DEBUG_END]
690
 
691
  # =============================================================================
app/app.py CHANGED
@@ -19,26 +19,30 @@
19
  # - Secrets stay in .env → Guardian reads them → never touched by app/*
20
  # =============================================================================
21
  from quart import Quart, request, jsonify # async Flask — ASGI compatible
 
22
  import logging
23
  from hypercorn.asyncio import serve # ASGI server — async native, replaces waitress
24
  from hypercorn.config import Config # hypercorn config
25
- import threading # for future tools that need own threads
26
- import requests # sync HTTP for future tool workers
27
- import time
28
- from datetime import datetime
29
  import asyncio
30
- from typing import Dict, Any, Optional
31
  # =============================================================================
32
  # Import app/* modules — MINIMAL BUILD
33
  # Each module reads its own config from app/.pyfun independently.
34
  # NO fundaments passed into these modules!
35
  # =============================================================================
36
- from . import mcp # MCP transport layer (SSE via Quart route)
37
  from . import config as app_config # app/.pyfun parser — used only in app/*
38
  from . import providers # API provider registry — reads app/.pyfun
39
  from . import models # Model config + token/rate limits — reads app/.pyfun
 
40
  from . import tools # MCP tool definitions + provider mapping — reads app/.pyfun
41
  from . import db_sync # Internal SQLite IPC — app/* state & communication
 
 
 
 
 
42
  # # db_sync ≠ postgresql.py! Cloud DB is Guardian-only.
43
  # Future modules (will uncomment when ready):
44
  # from . import discord_api # Discord bot integration
@@ -60,29 +64,35 @@ logger_db_sync = logging.getLogger('db_sync')
60
 
61
  # ── NEU: nach den Imports, vor app = Quart(__name__) ──────────────────────────
62
 
63
- def _make_mount_middleware(outer_app, path_prefix: str, inner_app):
64
  """
65
- Minimale ASGI-Middleware: leitet Requests mit path_prefix an inner_app
66
- (FastMCP Streamable HTTP) weiter, alles andere geht an outer_app (Quart).
67
- Nur aktiv bei HUB_TRANSPORT = "streamable-http".
 
 
 
 
68
  """
69
  async def middleware(scope, receive, send):
70
  path = scope.get("path", "")
71
- if path == path_prefix or path.startswith(path_prefix + "/"):
72
- scope = dict(scope)
73
- stripped = path[len(path_prefix):] or "/"
74
- scope["path"] = stripped
75
- scope["raw_path"] = stripped.encode()
76
- await inner_app(scope, receive, send)
77
- else:
78
- await outer_app(scope, receive, send)
 
 
79
  return middleware
80
 
81
  # =============================================================================
82
  # Quart app instance
83
  # =============================================================================
84
  app = Quart(__name__)
85
- START_TIME = datetime.utcnow()
86
  # =============================================================================
87
  # Quart Routes
88
  # =============================================================================
@@ -92,7 +102,7 @@ async def health_check():
92
  Health check endpoint.
93
  Used by HuggingFace Spaces and monitoring systems to verify the app is running.
94
  """
95
- uptime = datetime.utcnow() - START_TIME
96
  return jsonify({
97
  "status": "running",
98
  "service": "Universal MCP Hub",
@@ -102,10 +112,20 @@ async def health_check():
102
 
103
  @app.route("/api", methods=["POST"])
104
  async def api_endpoint():
 
 
 
 
105
  try:
106
- data = await request.get_json()
107
  tool_name = data.get("tool")
108
- params = data.get("params", {})
 
 
 
 
 
 
109
 
110
  # System tools — handle directly, no prompt needed!
111
  if tool_name == "list_active_tools":
@@ -114,17 +134,48 @@ async def api_endpoint():
114
  "active_llm_providers": providers.list_active_llm(),
115
  "active_search_providers": providers.list_active_search(),
116
  "available_models": models.list_all(),
 
117
  }})
118
 
119
  if tool_name == "health_check":
120
- return jsonify({"result": {"status": "ok"}})
121
 
122
  # db_query — handled by db_sync directly, not tools.run()
123
  if tool_name == "db_query":
124
- sql = params.get("sql", "")
 
 
125
  result = await db_sync.query(sql)
126
  return jsonify({"result": result})
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # rename 'provider' → 'provider_name' for tools.run()
129
  if "provider" in params:
130
  params["provider_name"] = params.pop("provider")
@@ -132,9 +183,17 @@ async def api_endpoint():
132
  result = await tools.run(tool_name, **params)
133
  return jsonify({"result": result})
134
 
 
 
 
 
 
 
 
 
135
  except Exception as e:
136
- logger.error(f"API error: {e}")
137
- return jsonify({"error": str(e)}), 500
138
 
139
 
140
  @app.route("/crypto", methods=["POST"])
@@ -148,11 +207,64 @@ async def crypto_endpoint():
148
  return jsonify({"status": "not_implemented"}), 501
149
 
150
 
151
- # Future routes (uncomment when ready):
152
- # @app.route("/discord", methods=["POST"])
153
- # async def discord_interactions():
154
- # """Discord interactions endpoint — signature verification via discord_api module."""
155
- # pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  # @app.route("/webhook/hf", methods=["POST"])
158
  # async def hf_webhook():
@@ -207,17 +319,11 @@ async def start_application(fundaments: Dict[str, Any]) -> None:
207
 
208
  # =========================================================================
209
  # Initialize app/* internal services — MINIMAL BUILD
210
- # Uncomment each line when the module is ready!
211
  # =========================================================================
212
- # await db_sync.initialize() # SQLite IPC store for app/* — unrelated to postgresql.py
213
- # await providers.initialize() # reads app/.pyfun [LLM_PROVIDERS] [SEARCH_PROVIDERS] # in mcp_init
214
- # await models.initialize() # reads app/.pyfun [MODELS] # in mcp_init
215
- # await tools.initialize() # reads app/.pyfun [TOOLS]
216
 
217
- # --- Initialize MCP (registers tools, prepares SSE handler) ---
218
- # db_sync only if cloud_DB used to!
219
  # PSQL bridge — nur wenn Guardian DB-Service injiziert hat
220
- # app.py — bridge-Block:
221
  await db_sync.initialize()
222
 
223
  if db_service:
@@ -227,38 +333,60 @@ async def start_application(fundaments: Dict[str, Any]) -> None:
227
  else:
228
  logger.info("PostgreSQL bridge inactive — no DATABASE_URL configured.")
229
 
230
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  await mcp.initialize()
232
 
233
- # --- Transport-abhängiges MCP-Routing ---
 
 
 
234
  hub_cfg = app_config.get_hub()
235
- transport = hub_cfg.get("HUB_TRANSPORT", "streamable-http").lower()
236
-
237
- if transport == "streamable-http":
238
- # ASGI-Mount: FastMCP übernimmt /mcp direkt — kein Quart-Overhead
239
- app.asgi_app = _make_mount_middleware(app.asgi_app, "/mcp", mcp.get_asgi_app())
240
- logger.info("MCP transport: Streamable HTTP → /mcp")
241
- else:
242
- # SSE legacy — Quart-Route dynamisch registrieren
243
- @app.route("/mcp", methods=["GET", "POST"])
244
- async def mcp_endpoint():
245
- """MCP SSE legacy transport — interceptor point für auth/logging."""
246
- return await mcp.handle_request(request)
247
- logger.info("MCP transport: SSE (legacy) → /mcp")
248
 
 
 
 
 
 
 
249
 
250
- # --- Read PORT from app/.pyfun [HUB] ---
251
- port = int(hub_cfg.get("HUB_PORT", "7860")) # hub_cfg bereits gelesen, kein zweiter get_hub()-Call
 
252
 
253
  # --- Configure hypercorn ---
254
  config = Config()
255
- config.bind = [f"0.0.0.0:{port}"]
256
 
257
- logger.info(f"Starting hypercorn on port {port}...")
258
- logger.info("All services running.")
259
 
260
  # --- Run hypercorn — blocks until shutdown ---
261
- await serve(app, config)
 
 
 
 
 
262
 
263
  # =============================================================================
264
  # Direct execution guard
 
19
  # - Secrets stay in .env → Guardian reads them → never touched by app/*
20
  # =============================================================================
21
  from quart import Quart, request, jsonify # async Flask — ASGI compatible
22
+ import json
23
  import logging
24
  from hypercorn.asyncio import serve # ASGI server — async native, replaces waitress
25
  from hypercorn.config import Config # hypercorn config
26
+ from datetime import datetime, timezone
 
 
 
27
  import asyncio
28
+ from typing import Dict, Any
29
  # =============================================================================
30
  # Import app/* modules — MINIMAL BUILD
31
  # Each module reads its own config from app/.pyfun independently.
32
  # NO fundaments passed into these modules!
33
  # =============================================================================
34
+ from . import mcp # MCP transport layer (Streamable HTTP / SSE via ASGI mount)
35
  from . import config as app_config # app/.pyfun parser — used only in app/*
36
  from . import providers # API provider registry — reads app/.pyfun
37
  from . import models # Model config + token/rate limits — reads app/.pyfun
38
+ from . import pricing # Auto model costs (OpenRouter + LiteLLM) — reads app/.pyfun
39
  from . import tools # MCP tool definitions + provider mapping — reads app/.pyfun
40
  from . import db_sync # Internal SQLite IPC — app/* state & communication
41
+ from . import shellmaster # ShellMaster 2.0 — safe shell assistant (inactive by default)
42
+ from . import auth # Optional shared-token gate for /api, /crypto, /mcp (off without token)
43
+ from . import discord_api # Discord: outbound post + interactions endpoint (inactive by default)
44
+ from . import gsc # Google Search Console tool (inactive by default, optional libs)
45
+ from . import skills_db # Skill databases app/db/skills/ (read-only; BM25)
46
  # # db_sync ≠ postgresql.py! Cloud DB is Guardian-only.
47
  # Future modules (will uncomment when ready):
48
  # from . import discord_api # Discord bot integration
 
64
 
65
  # ── NEU: nach den Imports, vor app = Quart(__name__) ──────────────────────────
66
 
67
+ def _make_mount_middleware(outer_app, prefixes, inner_app, strip_prefix: bool = False):
68
  """
69
+ ASGI-Middleware: leitet Requests mit passendem Prefix an inner_app
70
+ (FastMCP Streamable HTTP / SSE) weiter, alles andere geht an outer_app (Quart).
71
+ Lifespan-Scopes gehen immer an outer_app (Quart).
72
+
73
+ WICHTIG: Prefix wird standardmaessig NICHT gestript - FastMCP registriert
74
+ seine Route intern selbst (settings.streamable_http_path = "/mcp").
75
+ Wer stript, erzeugt 404s. Genau das war der Break im alten Build.
76
  """
77
  async def middleware(scope, receive, send):
78
  path = scope.get("path", "")
79
+ for prefix in prefixes:
80
+ if path == prefix or path.startswith(prefix + "/"):
81
+ if strip_prefix:
82
+ scope = dict(scope)
83
+ stripped = path[len(prefix):] or "/"
84
+ scope["path"] = stripped
85
+ scope["raw_path"] = stripped.encode()
86
+ await inner_app(scope, receive, send)
87
+ return
88
+ await outer_app(scope, receive, send)
89
  return middleware
90
 
91
  # =============================================================================
92
  # Quart app instance
93
  # =============================================================================
94
  app = Quart(__name__)
95
+ START_TIME = datetime.now(timezone.utc)
96
  # =============================================================================
97
  # Quart Routes
98
  # =============================================================================
 
102
  Health check endpoint.
103
  Used by HuggingFace Spaces and monitoring systems to verify the app is running.
104
  """
105
+ uptime = datetime.now(timezone.utc) - START_TIME
106
  return jsonify({
107
  "status": "running",
108
  "service": "Universal MCP Hub",
 
112
 
113
  @app.route("/api", methods=["POST"])
114
  async def api_endpoint():
115
+ """
116
+ REST tool gateway: POST {"tool": "name", "params": {...}}
117
+ Auth: optional shared token — see app/auth.py (.pyfun [HUB] HUB_AUTH_ENV).
118
+ """
119
  try:
120
+ data = await request.get_json(silent=True) or {}
121
  tool_name = data.get("tool")
122
+ params = data.get("params", {}) or {}
123
+
124
+ if not tool_name:
125
+ return jsonify({"error": "Missing 'tool'"}), 400
126
+ if not isinstance(params, dict):
127
+ return jsonify({"error": "'params' must be an object"}), 400
128
+ params.pop("tool_name", None) # would collide with tools.run(tool_name, ...) → 500
129
 
130
  # System tools — handle directly, no prompt needed!
131
  if tool_name == "list_active_tools":
 
134
  "active_llm_providers": providers.list_active_llm(),
135
  "active_search_providers": providers.list_active_search(),
136
  "available_models": models.list_all(),
137
+ "pricing_stats": pricing.stats(),
138
  }})
139
 
140
  if tool_name == "health_check":
141
+ return jsonify({"result": await tools.health()})
142
 
143
  # db_query — handled by db_sync directly, not tools.run()
144
  if tool_name == "db_query":
145
+ sql = params.get("sql", "") or params.get("prompt", "")
146
+ if not sql:
147
+ return jsonify({"error": "db_query requires 'sql' (or 'prompt') parameter"}), 400
148
  result = await db_sync.query(sql)
149
  return jsonify({"result": result})
150
 
151
+ # persist_status — memory orchestration observability (no prompt needed)
152
+ if tool_name == "persist_status":
153
+ return jsonify({"result": await db_sync.persist_queue_stats()})
154
+
155
+ # usage_stats — tokens + estimated cost per model/provider/tool/day
156
+ if tool_name == "usage_stats":
157
+ result = await db_sync.usage_stats(
158
+ group_by=str(params.get("group_by", "model")),
159
+ days=int(params.get("days") or 0) or None,
160
+ )
161
+ return jsonify({"result": result})
162
+
163
+ # openrouter_catalog — real model IDs for [MODELS]
164
+ if tool_name == "openrouter_catalog":
165
+ return jsonify({"result": await pricing.catalog(
166
+ query=str(params.get("query", "")),
167
+ free_only=bool(params.get("free_only", True)),
168
+ limit=int(params.get("limit") or 40),
169
+ )})
170
+
171
+ # get_model_pricing — auto-fetched costs, .pyfun values win as override
172
+ if tool_name == "get_model_pricing":
173
+ model_name = params.get("model_name", "") or params.get("model", "")
174
+ if not model_name:
175
+ return jsonify({"error": "get_model_pricing requires 'model_name'"}), 400
176
+ result = await pricing.get_pricing(model_name)
177
+ return jsonify({"result": result})
178
+
179
  # rename 'provider' → 'provider_name' for tools.run()
180
  if "provider" in params:
181
  params["provider_name"] = params.pop("provider")
 
183
  result = await tools.run(tool_name, **params)
184
  return jsonify({"result": result})
185
 
186
+ except ValueError as e: # unknown tool / missing prompt → 400
187
+ return jsonify({"error": str(e)}), 400
188
+ except KeyError as e:
189
+ return jsonify({"error": str(e.args[0])}), 404
190
+ except RuntimeError as e:
191
+ # Provider-Kette erschöpft — Message ist upstream sanitisiert
192
+ # (nur HTTP-Status + safe_url, niemals Keys/Bodies)
193
+ return jsonify({"error": str(e)}), 502
194
  except Exception as e:
195
+ logger.error(f"API error: {type(e).__name__}: {e}")
196
+ return jsonify({"error": "Internal error"}), 500
197
 
198
 
199
  @app.route("/crypto", methods=["POST"])
 
207
  return jsonify({"status": "not_implemented"}), 501
208
 
209
 
210
+ # Discord interactions endpoint. NOT behind the hub token gate (Discord cannot send it) —
211
+ # protected by Ed25519 signature verification instead (discord_api.verify_signature).
212
+ # Needs a PUBLIC URL (a private HF Space is unreachable for Discord).
213
+ @app.route("/discord", methods=["POST"])
214
+ async def discord_interactions():
215
+ """Discord interactions endpoint — signature verified BEFORE the body is parsed."""
216
+ if not discord_api.is_ready():
217
+ return jsonify({"error": "discord not active"}), 404
218
+
219
+ raw = await request.get_data() # RAW bytes — re-serialized JSON would break the signature
220
+ if not discord_api.verify_signature(
221
+ raw,
222
+ request.headers.get("X-Signature-Ed25519", ""),
223
+ request.headers.get("X-Signature-Timestamp", ""),
224
+ ):
225
+ return jsonify({"error": "invalid request signature"}), 401
226
+
227
+ try:
228
+ payload = json.loads(raw)
229
+ except ValueError:
230
+ return jsonify({"error": "invalid JSON"}), 400
231
+
232
+ response = await discord_api.handle_interaction(payload)
233
+ if response is None:
234
+ return jsonify({"error": "unsupported interaction"}), 400
235
+ return jsonify(response)
236
+
237
+
238
+ # /ask <prompt> — Discord kills answers after 3 s, so: defer (type 5), run the tool in the
239
+ # background, then send_followup(). Strong refs keep the tasks from being garbage-collected.
240
+ _bg_tasks: set = set()
241
+
242
+
243
+ async def _discord_ask(interaction: dict) -> dict:
244
+ cfg = app_config.get_discord()
245
+ guilds = {g.strip() for g in str(cfg.get("DISCORD_ALLOWED_GUILDS", "")).split(",") if g.strip()}
246
+ if guilds and str(interaction.get("guild_id", "")) not in guilds:
247
+ return {"type": discord_api.RESPONSE_CHANNEL_MESSAGE, "data": {"content": "Not available on this server."}}
248
+
249
+ opts = {o.get("name"): o.get("value") for o in (interaction.get("data", {}).get("options") or [])}
250
+ prompt = str(opts.get("prompt") or "").strip()
251
+ if not prompt:
252
+ return {"type": discord_api.RESPONSE_CHANNEL_MESSAGE, "data": {"content": "Usage: /ask prompt:<your question>"}}
253
+
254
+ token = interaction.get("token", "")
255
+
256
+ async def _answer():
257
+ try:
258
+ text = await tools.run("llm_complete", prompt=prompt, max_tokens=700)
259
+ except Exception as e:
260
+ logger.warning(f"Discord /ask failed: {type(e).__name__}")
261
+ text = "Sorry — the hub could not answer right now."
262
+ await discord_api.send_followup(token, text[:1990]) # Discord limit: 2000
263
+
264
+ task = asyncio.create_task(_answer())
265
+ _bg_tasks.add(task)
266
+ task.add_done_callback(_bg_tasks.discard)
267
+ return {"type": discord_api.RESPONSE_DEFERRED_CHANNEL_MESSAGE}
268
 
269
  # @app.route("/webhook/hf", methods=["POST"])
270
  # async def hf_webhook():
 
319
 
320
  # =========================================================================
321
  # Initialize app/* internal services — MINIMAL BUILD
322
+ # Each module reads its own config from app/.pyfun — no fundaments passed in.
323
  # =========================================================================
 
 
 
 
324
 
325
+ # db_sync only if cloud_DB used to!
 
326
  # PSQL bridge — nur wenn Guardian DB-Service injiziert hat
 
327
  await db_sync.initialize()
328
 
329
  if db_service:
 
333
  else:
334
  logger.info("PostgreSQL bridge inactive — no DATABASE_URL configured.")
335
 
336
+ # Memory-Orchestrierung: Retry-Flusher starten — läuft MIT und OHNE PG.
337
+ # Ohne Writer bleibt alles restart-safe in der SQLite-Queue liegen
338
+ # (persist_result ohne DATABASE_URL = queued, kein Crash).
339
+ db_sync.start_persist_flusher()
340
+
341
+ # ShellMaster — bleibt inaktiv, bis .pyfun [TOOL.shellmaster] active="true"
342
+ # UND token + commands-Datei vorhanden sind (siehe shellmaster.initialize)
343
+ await shellmaster.initialize()
344
+
345
+ # Integrationen — beide bleiben inaktiv, bis [DISCORD] / [GSC] in .pyfun aktiv sind
346
+ await discord_api.initialize()
347
+ discord_api.register_command_handler("ask", _discord_ask)
348
+ gsc.initialize()
349
+ skills_db.initialize()
350
+
351
+ # Auto-Pricing — füllt Kosten aus OpenRouter/LiteLLM nach, .pyfun gewinnt.
352
+ # Läuft im Hintergrund — blockiert den Start NICHT (no fetch = no crash).
353
+ await pricing.initialize()
354
+
355
  await mcp.initialize()
356
 
357
+ # =========================================================================
358
+ # MCP: ASGI-App holen, DANACH mounten, DANACH lifespan starten
359
+ # (get_asgi_app() muss VOR mcp.lifespan() kommen — siehe mcp.py)
360
+ # =========================================================================
361
  hub_cfg = app_config.get_hub()
362
+ mcp_asgi = mcp.get_asgi_app()
363
+ app.asgi_app = _make_mount_middleware(app.asgi_app, mcp.mount_prefixes(), mcp_asgi)
364
+ logger.info(f"MCP mounted: {', '.join(mcp.mount_prefixes())}")
 
 
 
 
 
 
 
 
 
 
365
 
366
+ # Optional token gate (outermost layer). No token configured = open, as before.
367
+ app.asgi_app = auth.make_middleware(
368
+ app.asgi_app,
369
+ auth.token_from_env(hub_cfg.get("HUB_AUTH_ENV", "")),
370
+ ("/api", "/crypto") + tuple(mcp.mount_prefixes()),
371
+ )
372
 
373
+ # --- Read HOST/PORT from app/.pyfun [HUB] ---
374
+ host = hub_cfg.get("HUB_HOST", "0.0.0.0")
375
+ port = int(hub_cfg.get("HUB_PORT", "7860")) # hub_cfg bereits gelesen
376
 
377
  # --- Configure hypercorn ---
378
  config = Config()
379
+ config.bind = [f"{host}:{port}"]
380
 
381
+ logger.info(f"Starting hypercorn on {host}:{port}...")
 
382
 
383
  # --- Run hypercorn — blocks until shutdown ---
384
+ # mcp.lifespan() ist PFLICHT: startet den FastMCP Session-Manager.
385
+ # Ohne ihn schlägt jeder /mcp-Request mit
386
+ # 'Task group is not initialized' fehl.
387
+ async with mcp.lifespan():
388
+ logger.info("All services running.")
389
+ await serve(app, config)
390
 
391
  # =============================================================================
392
  # Direct execution guard
app/auth.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # app/auth.py
3
+ # Optional shared-token gate for /api, /crypto and the mounted MCP app.
4
+ # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
5
+ # Copyright 2026 - Volkan Kücükbudak
6
+ # Apache License V. 2 + ESOL 1.1
7
+ # =============================================================================
8
+ # PRINCIPLE (same as everything here):
9
+ # No token configured = gate OFF = hub behaves exactly as before.
10
+ # Token configured = /api, /crypto and /mcp need it. "/" (health) stays open.
11
+ #
12
+ # CONFIG:
13
+ # .pyfun [HUB] HUB_AUTH_ENV = "HUB_API_TOKEN" → NAME of the ENV var (never the secret)
14
+ # .env / Space secret: HUB_API_TOKEN=<long random string>
15
+ #
16
+ # CLIENT sends the token as ONE of:
17
+ # X-Hub-Token: <token> (use this on private HF Spaces — HF keeps
18
+ # Authorization for its own proxy auth)
19
+ # Authorization: Bearer <token>
20
+ #
21
+ # Pure ASGI, no dependencies → wraps any ASGI app (Quart + mounted MCP).
22
+ # =============================================================================
23
+ import hmac
24
+ import logging
25
+ import os
26
+
27
+ logger = logging.getLogger("auth")
28
+
29
+
30
+ def token_from_env(env_name: str) -> str:
31
+ """Resolve the token from the ENV var NAME configured in .pyfun. Empty = gate off."""
32
+ return (os.getenv(env_name) or "").strip() if env_name else ""
33
+
34
+
35
+ def _candidates(headers) -> list:
36
+ """All tokens the client offered (X-Hub-Token and/or Bearer)."""
37
+ found = []
38
+ for k, v in headers:
39
+ if k == b"x-hub-token":
40
+ found.append(v.decode("latin-1").strip())
41
+ elif k == b"authorization":
42
+ val = v.decode("latin-1").strip()
43
+ if val.lower().startswith("bearer "):
44
+ found.append(val[7:].strip())
45
+ return found
46
+
47
+
48
+ def _authorized(headers, token: str) -> bool:
49
+ expected = token.encode("utf-8")
50
+ return any(
51
+ hmac.compare_digest(c.encode("utf-8"), expected)
52
+ for c in _candidates(headers)
53
+ )
54
+
55
+
56
+ def make_middleware(next_app, token: str, protected_prefixes):
57
+ """
58
+ Wrap an ASGI app. Returns next_app unchanged when no token is configured.
59
+ Only paths equal to / below one of protected_prefixes are gated.
60
+ """
61
+ if not token:
62
+ logger.info("Hub auth: OFF (no HUB_AUTH_ENV token set) — /api and /mcp are open.")
63
+ return next_app
64
+
65
+ prefixes = tuple(protected_prefixes)
66
+ logger.info(f"Hub auth: ON for {', '.join(prefixes)}")
67
+
68
+ async def middleware(scope, receive, send):
69
+ if scope["type"] in ("http", "websocket"):
70
+ path = scope.get("path", "")
71
+ if any(path == p or path.startswith(p + "/") for p in prefixes):
72
+ if not _authorized(scope.get("headers") or [], token):
73
+ if scope["type"] == "http":
74
+ await send({
75
+ "type": "http.response.start",
76
+ "status": 401,
77
+ "headers": [
78
+ (b"content-type", b"application/json"),
79
+ (b"www-authenticate", b"Bearer"),
80
+ ],
81
+ })
82
+ await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
83
+ else:
84
+ await send({"type": "websocket.close", "code": 1008})
85
+ return
86
+ await next_app(scope, receive, send)
87
+
88
+ return middleware
app/config.py CHANGED
@@ -291,3 +291,23 @@ def get_debug() -> Dict[str, Any]:
291
  def is_debug() -> bool:
292
  """Returns True if DEBUG = 'ON' in .pyfun."""
293
  return get_debug().get("DEBUG", "OFF").upper() == "ON"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  def is_debug() -> bool:
292
  """Returns True if DEBUG = 'ON' in .pyfun."""
293
  return get_debug().get("DEBUG", "OFF").upper() == "ON"
294
+
295
+
296
+ def get_pricing() -> Dict[str, Any]:
297
+ """Returns [PRICING] section — auto model cost fetching."""
298
+ return get_section("PRICING")
299
+
300
+
301
+ def get_discord() -> Dict[str, Any]:
302
+ """Returns [DISCORD] section — Discord integration (inactive unless DISCORD_ACTIVE = "true")."""
303
+ return get_section("DISCORD")
304
+
305
+
306
+ def get_skills() -> Dict[str, Any]:
307
+ """Returns [SKILLS] section — read-only skill databases (app/db/skills/)."""
308
+ return get_section("SKILLS")
309
+
310
+
311
+ def get_gsc() -> Dict[str, Any]:
312
+ """Returns [GSC] section — Google Search Console tool (inactive unless GSC_ACTIVE = "true")."""
313
+ return get_section("GSC")
app/db/skills/README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: hub core index
3
+ description: "ai hub orchestrierung"
4
+ tags: core
5
+ ---
6
+
7
+ # System core core (duty!) > app/db/skills/core/*
8
+ - `core_skills`: app/db/skills/core/core.db
9
+ - `core_skills`: how to understand- readable for humans: app/db/skills/core/core.md
10
+ - `db/skills`: how_to_understand: app/db/skills/core/core_agent.db
11
+ - `db/skills`: how to understand- readable for humans: app/db/skills/core/core_agent.md
12
+ - description: "FUCK! restrict ai slop and saves me money"
13
+
14
+ # User skills > app/db/skills/user/*
15
+ - how_use for agents_ID=(value): app/db/skills/core/core_agent.db
16
+ - folder: /app/db/skills/user/*
17
+ - autoload: true
18
+ - hub_tools: `show_skills`
19
+ - vector_files: skillname_*.db
20
+ - human_readable: skillname_*.md
app/db/skills/core/core.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:26404a70980e429aa1e868239023cdab5afdae9c9fd1788e53a69e93fbb70c31
3
+ size 102400
app/db/skills/core/core.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Hub Principal Architect
3
+ description: "Core hub skill: architect persona, token rules, execution modes (COMPLETE, REVIEW, DB, SHELLMASTER ...) and storage constraints"
4
+ tags: hub, core, architect, code-review, security, sqlite, shellmaster, persist, translate, summarize, web-search
5
+ ---
6
+
7
+ ## IDENTITY
8
+
9
+ You are a principal systems and AI hub manager with deep, practical, cross-domain expertise spanning distributed infrastructure, database engineering, security ops, and backend systems.
10
+ You are NOT a teacher, NOT a documentation generator, NOT a motivational speaker.
11
+ You are the lead architect sitting next to me who knows what they are doing, builds for global-scale enterprise production, and respects my time.
12
+
13
+ ---
14
+
15
+ ## CORE RULES - NON-NEGOTIABLE
16
+
17
+ - **No preamble.** Never start with "Sure!", "Certainly!", "Great question!", "Of course!" or any variant.
18
+ - **No summaries.** Never end with "In summary...", "To recap...", "I hope this helps!" or similar.
19
+ - **No unsolicited explanations.** If I didn't ask why, don't tell me why.
20
+ - **No padding.** Every sentence must carry information. If it doesn't, delete it.
21
+ - **No generated images, diagrams, or visual aids** unless explicitly requested.
22
+ - **No beginner scaffolding.** No "First, let's understand what X is..." - assume elite technical proficiency.
23
+ - **Token efficiency is a feature.** Short, precise output > long, comprehensive output.
24
+
25
+ ---
26
+
27
+ ## COMMUNICATION PROTOCOL
28
+
29
+ **When the task is clear > execute immediately.**
30
+ No confirmation, no "I'll now proceed to...", just deliver the architecture, code, or fix.
31
+
32
+ **When context is missing > ask ONE targeted question, then stop.**
33
+ Not five questions. Not "it depends". One question, the most critical architectural blocker.
34
+
35
+ **When I'm wrong > say so directly.**
36
+ "That will fail in production because X. Do Y instead." - not "That's an interesting approach, however..."
37
+
38
+ **When you don't know > say so in one sentence.**
39
+ Don't hallucinate enterprise infrastructure patterns, APIs, protocols, or file paths. Ever.
40
+
41
+ **Respond in the language I write in.**
42
+ Prompt is English, my message is German > answer in German. Always mirror my language.
43
+
44
+ ---
45
+
46
+ ## WORKFLOW - HOW TO APPROACH PROBLEMS
47
+
48
+ ```
49
+
50
+ 1. READ > Understand what's actually being requested across full system lifecycle (Code, DB, Ops, Sec)
51
+ 2. THINK > Identify systemic failure points, concurrency bottlenecks, and security vectors
52
+ 3. ASK > One question IF truly necessary, otherwise skip
53
+ 4. DELIVER > Code / architecture spec / configuration / fix. Direct. Complete. Production-ready.
54
+
55
+ ```
56
+
57
+ **Never deliver half-solutions with "you can extend this to..."**
58
+ Either it's complete or explicitly scoped: "This covers X, not Y."
59
+
60
+ ---
61
+
62
+ ## TECHNICAL STANDARDS
63
+
64
+ - **Enterprise production-ready or nothing.** No `# TODO`, no placeholder logic, no shortcuts, strict concurrency and async safety.
65
+ - **Match existing patterns** of whatever codebase or infrastructure architecture I'm working in.
66
+ - **No comments explaining what code or config does** - systems must be self-documenting.
67
+ - **Comments only for WHY** (architectural tradeoffs, constraint rationale), never for WHAT.
68
+ - **Error handling, audit trails, and security controls included** by design, not as an afterthought.
69
+ - **No dependencies added** without calling it out explicitly: `[NEW DEP: asyncpg]`
70
+
71
+ ---
72
+
73
+ ## REPO & CONTEXT HANDLING
74
+
75
+ When given a GitHub repo, file tree, codebase, or infrastructure schema:
76
+
77
+ - **Treat it as loaded.** Don't say "based on the provided context" - just work with it.
78
+ - **Navigate it mentally** - understand global system topology before answering.
79
+ - **Reference specific files/lines/modules** when relevant: `fundaments/postgresql.py:42`
80
+ - **Never summarize the repo back to me** - I know what I gave you.
81
+ - **Identify entry points, concurrency models, and security boundaries** before suggesting changes.
82
+
83
+ When context is partial:
84
+ - Work with what's there.
85
+ - State assumptions explicitly if they affect the answer: `[ASSUMING: PostgreSQL 18 + asyncpg]`
86
+ - Don't ask for more than you need.
87
+
88
+ ---
89
+
90
+ ## MODES - ACTIVATE WITH TAG
91
+
92
+ Use these tags to map execution directly to hub tools and specialized workflows. No tag = default principal architect mode.
93
+
94
+ **`[MODE: COMPLETE]`** - Execute LLM completion task (`llm_complete`)
95
+ > Optimize generation across active providers with strict token boundaries.
96
+
97
+ **`[MODE: REVIEW]`** - Code review and static analysis (`code_review`)
98
+ > Flag critical production risks: race conditions, security holes, performance bottlenecks, data integrity flaws.
99
+ > Format: `COMPONENT:LINE - RISK - PRODUCTION FIX`
100
+
101
+ **`[MODE: SUMMARIZE]`** - Content summarization (`summarize`)
102
+ > Extract high-density core facts and operational parameters instantly.
103
+
104
+ **`[MODE: TRANSLATE]`** - Text translation (`translate`)
105
+ > Accurate, context-aware technical translation preserving syntax and formatting.
106
+
107
+ **`[MODE: SEARCH]`** - Web search retrieval (`web_search`)
108
+ > Pull verified live data, documentation, or pricing models directly.
109
+
110
+ **`[MODE: DB]`** - Database and state querying (`db_query`)
111
+ > Query operational databases or local SQLite states securely.
112
+
113
+ **`[MODE: PERSIST]`** - State persistence and memory queuing (`persist_result`)
114
+ > Queue-safe memory orchestration and state storage.
115
+
116
+ **`[MODE: SHELLMASTER]`** - Sandboxed ShellMaster tool suite execution (`shellmaster`)
117
+ > Execute validated local CLI commands via secured agent interface with strict hardblock checks and mandatory user confirmation flows.
118
+
119
+ ---
120
+
121
+ ## WHAT I NEVER WANT TO SEE
122
+
123
+ - Bullet lists explaining basic engineering concepts
124
+ - "Here's an example to illustrate..."
125
+ - Markdown headers for a 2-sentence answer
126
+ - "As an AI language model..."
127
+ - Apologies for limitations - just state them factually
128
+ - Repeated information from my own message fed back to me
129
+ - Suggestions to "consult a professional" for technical or infrastructure decisions
130
+
131
+ ---
132
+
133
+ ## DATA & STORAGE CONSTRAINTS
134
+
135
+ - **Database:** Hard limited to `app/.hub_stats.db` (SQLite). Note: On Hugging Face Spaces, runtime state databases like `app/.hub_state.db` are dynamically re-routed to ephemeral `/tmp/*` storage. Use `db_query` for queries and `persist_result` for state orchestration.
136
+ - **Workspace:** All paths in `app/construct/*` are ephemeral and mapped to `/tmp/onstruct/*` on Hugging Face Spaces. Treat accordingly.
137
+ - **Data Protection:** Zero tolerance for sensitive data storage in `app/*`.
138
+ - **User Uploads:** Stored strictly as `app/construct/user-upload-[filename+timestamp]`. Never overwrite original files; always operate on copies.
139
+ - **Security Check:** Trigger immediate warning if sensitive data is detected in code and prompt for deep scanning via `skills/warloard.md`.
140
+
141
+ ---
142
+
143
+ ## COST CONTRACT
144
+
145
+ We are both aware that tokens cost money.
146
+ Your job is to maximize **information density**, not response length.
147
+ A 50-token architectural fix beats a 500-token essay.
app/db/skills/core/core_agent.db ADDED
Binary file (98.3 kB). View file
 
app/db/skills/core/core_agent.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # skills.db`s - How to read and use it (for AI agents)
2
+
3
+ `skills.db` is a plain SQLite file built by `secure_vectorizer.py` from local
4
+ `.md` skill files. **It is not encrypted or obfuscated.** All text is stored in
5
+ clear form. Nothing needs to be decrypted. `README.md` files are skipped by
6
+ the indexer, so this file is never returned as a skill.
7
+
8
+ ## Recommended agent workflow (saves tokens)
9
+
10
+ 1. Call `catalog()` - a short list of every skill (name, description, tags).
11
+ Do NOT load full skill files up front.
12
+ 2. Pick the relevant skill(s) or tag(s).
13
+ 3. Call `search(query, top_k=3, tag=...)` and use only the returned chunks.
14
+ 4. Never inject whole files into the context; the chunks are the unit.
15
+
16
+ ## Search modes
17
+
18
+ 1. **Keyword mode (default)** - no vectors. Ranking = BM25 via SQLite FTS5.
19
+ 2. **Hybrid mode (only if indexed with `--embed`)** - float32 embedding vectors
20
+ are stored in `chunks.vec` and fused with BM25.
21
+
22
+ Check which one you have:
23
+ SELECT COUNT(*) FROM chunks WHERE vec IS NOT NULL; -- 0 = keyword mode only
24
+
25
+ ## Skill file format (optional frontmatter)
26
+
27
+ A skill is a `.md` file. It may start with a frontmatter block:
28
+
29
+ ---
30
+ name: PDF lesen
31
+ description: Text und Tabellen aus PDF-Dateien holen
32
+ tags: pdf, dokument, extraktion
33
+ ---
34
+ # PDF lesen
35
+ ...
36
+
37
+ - `tags` accepts `a, b`, `[a, b]` or a `- item` list. Tags are lowercased.
38
+ - `keywords:` is accepted as an alias for `tags:`.
39
+ - Without frontmatter: name = file name, description = first text line.
40
+ - The frontmatter is NOT part of the chunk text.
41
+
42
+ ## Schema (schema version "3")
43
+
44
+ files (path TEXT PRIMARY KEY, hash TEXT, mtime REAL) -- hash = sha1 of file bytes
45
+ chunks(id INTEGER PRIMARY KEY, path TEXT, heading TEXT, text TEXT, vec BLOB)
46
+ fts (heading, text) -- FTS5 virtual table, rowid == chunks.id
47
+ -- tokenizer: unicode61 remove_diacritics 2
48
+ tags (path TEXT, tag TEXT) -- one row per file and tag
49
+ meta (path TEXT PRIMARY KEY, name TEXT, description TEXT)
50
+ info (key TEXT PRIMARY KEY, value TEXT) -- key 'version' = schema version
51
+
52
+ Important: the FTS column `heading` contains
53
+ `chunk title + tags + frontmatter description` (so tags weigh as much as
54
+ titles in ranking), while `chunks.heading` holds only the plain chunk title.
55
+
56
+ If the schema version differs, all files are re-indexed once automatically.
57
+
58
+ ## Chunking
59
+
60
+ Markdown is split at headings (levels 1-4, ignored inside ``` code fences).
61
+ Sections longer than 1200 characters are split at blank lines.
62
+ `chunks.heading` = section title (file name stem if there is none).
63
+
64
+ ## Keyword ranking (BM25)
65
+
66
+ Query text -> lowercase -> tokens `\w+` -> each token becomes a prefix term,
67
+ joined with OR: "agent"* OR "tools"*
68
+
69
+ SELECT rowid FROM fts
70
+ WHERE fts MATCH '"agent"* OR "tools"*'
71
+ AND rowid IN (SELECT c.id FROM chunks c JOIN tags t ON t.path=c.path
72
+ WHERE t.tag='python') -- only when a tag filter is given
73
+ ORDER BY bm25(fts, 5.0, 1.0) -- column weights: heading 5.0, text 1.0
74
+ LIMIT 4*k;
75
+
76
+ SQLite's bm25() returns lower (more negative) = better, hence ascending order.
77
+ With a tag but an empty query, the chunks of all tagged skills are returned in
78
+ file order (no ranking).
79
+
80
+ ## Vectors (only in hybrid mode)
81
+
82
+ - Model: `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2`
83
+ (via the `fastembed` package).
84
+ - Embedded text per chunk: `chunk title + "\n" + chunk text` (no tags).
85
+ - Storage: raw little-endian float32 bytes, **L2-normalized**.
86
+ Dimension = len(blob) / 4 (384 for this model).
87
+ - Decode:
88
+
89
+ import numpy as np
90
+ vec = np.frombuffer(blob, dtype="float32")
91
+
92
+ - Similarity: all vectors are normalized, so **cosine similarity = dot
93
+ product**: score = chunk_vec @ query_vec
94
+ The query must be embedded with the SAME model and normalized the same way.
95
+ - A tag filter is applied to the vector candidates as well.
96
+
97
+ ## Hybrid fusion (Reciprocal Rank Fusion)
98
+
99
+ Take the top 4*k candidates from BM25 and from the vector ranking (ranks start
100
+ at 0). For every chunk:
101
+
102
+ fused = 1/(60 + rank_bm25) + 1/(60 + rank_vector) # missing list -> term omitted
103
+
104
+ Sort by `fused` descending, return the top k. The `score` reported by the
105
+ tool is `round(fused * 60, 4)`. Scores are only comparable within one query.
106
+
107
+ ## Using it from Python
108
+
109
+ from secure_vectorizer import LocalSkillVectorEngine
110
+ engine = LocalSkillVectorEngine(target_dir=r"C:\path\to\skills")
111
+ engine.build() # incremental; only changed files are re-read
112
+
113
+ engine.catalog() # [{path, name, description, tags, chunks}, ...]
114
+ engine.catalog(tag="python") # only skills with this tag
115
+
116
+ hits = engine.search("your question", top_k=3, tag=None)
117
+ # each hit: file, content, path, heading, text, tags, score
118
+
119
+ ## Using it from the command line
120
+
121
+ python secure_vectorizer.py # index current folder
122
+ python secure_vectorizer.py index --skills <dir>
123
+ python secure_vectorizer.py search "query" -k 5 [--tag python]
124
+ python secure_vectorizer.py catalog [--tag python]
125
+ python secure_vectorizer.py tags # all tags with skill counts
126
+ python secure_vectorizer.py list | stats
127
+
128
+ ## Reading with plain SQL (read-only is fine)
129
+
130
+ import sqlite3
131
+ db = sqlite3.connect("skills.db")
132
+ for name, desc in db.execute("SELECT name, description FROM meta"):
133
+ print(name, desc)
134
+ for path, heading, text in db.execute("SELECT path, heading, text FROM chunks LIMIT 5"):
135
+ print(path, heading, text[:100])
136
+
137
+ ## Notes for agents
138
+
139
+ - The DB is rebuildable from the .md files; never edit it by hand.
140
+ - Run `build()` / `index` before searching if skill files may have changed.
141
+ - Prefer a tag filter when the topic is clear; it narrows results precisely.
app/db_sync.py CHANGED
@@ -23,12 +23,18 @@
23
  # users, sessions → Guardian (fundaments/user_handler.py) — DO NOT TOUCH!
24
  # hub_state → db_sync (app/* internal state)
25
  # tool_cache → db_sync (app/* tool response cache)
 
 
26
  # =============================================================================
27
 
 
 
 
28
  import aiosqlite
 
29
  import logging
30
  import json
31
- from datetime import datetime, timezone
32
  from typing import Any, Dict, List, Optional
33
 
34
  from . import config
@@ -108,6 +114,43 @@ async def _init_tables() -> None:
108
  ON tool_cache(tool_name)
109
  """)
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  await db.commit()
112
 
113
  logger.info("db_sync tables ready.")
@@ -223,26 +266,31 @@ async def cache_write(
223
  await db.commit()
224
 
225
 
226
- async def cache_read(tool_name: str, prompt: str) -> Optional[str]:
227
  """
228
  Read a cached tool response.
229
- Returns None if no cache entry exists.
230
 
231
  Args:
232
- tool_name: Tool name to look up.
233
- prompt: The exact prompt/query to match.
 
234
 
235
  Returns:
236
  Cached response string, or None if not found.
237
  """
238
  _check_init()
239
 
 
 
 
 
240
  async with aiosqlite.connect(_db_path) as db:
241
  cursor = await db.execute("""
242
  SELECT response FROM tool_cache
243
- WHERE tool_name = ? AND prompt = ?
244
  ORDER BY created_at DESC LIMIT 1
245
- """, (tool_name, prompt))
246
  row = await cursor.fetchone()
247
 
248
  return row[0] if row else None
@@ -252,12 +300,26 @@ async def cache_read(tool_name: str, prompt: str) -> Optional[str]:
252
  # SECTION 4 — Read-Only Query (for mcp.py db_query tool)
253
  # =============================================================================
254
 
 
 
 
 
 
 
 
 
 
255
  async def query(sql: str) -> List[Dict]:
256
  """
257
  Execute a read-only SELECT query on the internal hub state database.
258
- Only SELECT statements are permitted — write operations are blocked.
259
  Called by mcp.py db_query tool when db_sync.py is active.
260
 
 
 
 
 
 
 
261
  Args:
262
  sql: SQL SELECT statement to execute.
263
 
@@ -265,20 +327,96 @@ async def query(sql: str) -> List[Dict]:
265
  List of result rows as dicts.
266
 
267
  Raises:
268
- ValueError: If the query is not a SELECT statement.
269
  """
270
  _check_init()
271
 
272
- if not sql.strip().upper().startswith("SELECT"):
 
 
 
273
  raise ValueError("Only SELECT queries are permitted in db_query tool.")
 
 
 
 
274
 
275
- async with aiosqlite.connect(_db_path) as db:
 
276
  db.row_factory = aiosqlite.Row
277
- cursor = await db.execute(sql)
278
- rows = await cursor.fetchall()
279
  return [dict(r) for r in rows]
280
 
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  # =============================================================================
283
  # SECTION 5 — Helpers
284
  # =============================================================================
@@ -295,34 +433,368 @@ def is_ready() -> bool:
295
 
296
 
297
  # =============================================================================
298
- # SECTION 6 — PostgreSQL Bridge (Guardian-injected, optional)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  # =============================================================================
300
  _psql_writer = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
 
303
  def set_psql_writer(writer_fn) -> None:
304
  """
305
- Receives execute_secured_query callable from Guardian via app.py.
306
  Called once in start_application() if db_service is available.
307
  app/* never imports postgresql.py directly — this is the only bridge.
308
  """
309
  global _psql_writer
310
  _psql_writer = writer_fn
 
311
  logger.info("PostgreSQL writer registered.")
312
 
313
 
314
- async def persist(table: str, data: dict) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  if not _psql_writer:
316
- raise RuntimeError("No PostgreSQL writer — DATABASE_URL not configured.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
- from datetime import datetime, timezone
319
- now = datetime.now(timezone.utc).isoformat()
320
 
321
- # asyncpg Pool.execute() Signatur: execute(query, *args)
322
- # kein fetch_method Parameter — direkt aufrufen
323
- sql = f"INSERT INTO {table} (payload, created_at) VALUES ($1::jsonb, $2)"
324
- await _psql_writer(sql, json.dumps(data), now)
325
- logger.info(f"Persisted to PostgreSQL table '{table}'.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  # =============================================================================
328
  # Direct execution guard
 
23
  # users, sessions → Guardian (fundaments/user_handler.py) — DO NOT TOUCH!
24
  # hub_state → db_sync (app/* internal state)
25
  # tool_cache → db_sync (app/* tool response cache)
26
+ # persist_queue → db_sync (restart-safe queue → PostgreSQL long-term memory)
27
+ # usage_log → db_sync (tokens + estimated cost per LLM call)
28
  # =============================================================================
29
 
30
+ import asyncio
31
+ import re
32
+ import time
33
  import aiosqlite
34
+ from pathlib import Path
35
  import logging
36
  import json
37
+ from datetime import datetime, timedelta, timezone
38
  from typing import Any, Dict, List, Optional
39
 
40
  from . import config
 
114
  ON tool_cache(tool_name)
115
  """)
116
 
117
+ # persist_queue — Dauer-Gedächtnis-Queue: überlebt Restarts,
118
+ # bis der Eintrag erfolgreich nach PostgreSQL geflusht wurde.
119
+ # score-Spalte = reservierter Slot für den Bullshit-Filter (Nutzer-eigenes
120
+ # Scoring, später) — wird von db_sync NICHT interpretiert.
121
+ await db.execute("""
122
+ CREATE TABLE IF NOT EXISTS persist_queue (
123
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
124
+ target_table TEXT NOT NULL,
125
+ payload TEXT NOT NULL,
126
+ source TEXT,
127
+ model TEXT,
128
+ score REAL,
129
+ queued_at TEXT NOT NULL,
130
+ attempts INTEGER NOT NULL DEFAULT 0,
131
+ last_error TEXT
132
+ )
133
+ """)
134
+
135
+ # usage_log — one row per successful LLM call (tokens + estimated cost).
136
+ # cost_usd is NULL when no price is known. Read-only for everything else.
137
+ await db.execute("""
138
+ CREATE TABLE IF NOT EXISTS usage_log (
139
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
140
+ ts TEXT NOT NULL,
141
+ tool TEXT,
142
+ provider TEXT,
143
+ model TEXT,
144
+ tokens_in INTEGER,
145
+ tokens_out INTEGER,
146
+ cost_usd REAL,
147
+ latency_ms INTEGER
148
+ )
149
+ """)
150
+ await db.execute("""
151
+ CREATE INDEX IF NOT EXISTS idx_usage_log_ts ON usage_log(ts)
152
+ """)
153
+
154
  await db.commit()
155
 
156
  logger.info("db_sync tables ready.")
 
266
  await db.commit()
267
 
268
 
269
+ async def cache_read(tool_name: str, prompt: str, max_age_sec: int = 0) -> Optional[str]:
270
  """
271
  Read a cached tool response.
272
+ Returns None if no cache entry exists (or it is older than max_age_sec).
273
 
274
  Args:
275
+ tool_name: Tool name to look up.
276
+ prompt: The exact prompt/query to match.
277
+ max_age_sec: Ignore entries older than this (0 = no age limit).
278
 
279
  Returns:
280
  Cached response string, or None if not found.
281
  """
282
  _check_init()
283
 
284
+ cutoff = ""
285
+ if max_age_sec and max_age_sec > 0:
286
+ cutoff = (datetime.now(timezone.utc) - timedelta(seconds=max_age_sec)).isoformat()
287
+
288
  async with aiosqlite.connect(_db_path) as db:
289
  cursor = await db.execute("""
290
  SELECT response FROM tool_cache
291
+ WHERE tool_name = ? AND prompt = ? AND created_at >= ?
292
  ORDER BY created_at DESC LIMIT 1
293
+ """, (tool_name, prompt, cutoff))
294
  row = await cursor.fetchone()
295
 
296
  return row[0] if row else None
 
300
  # SECTION 4 — Read-Only Query (for mcp.py db_query tool)
301
  # =============================================================================
302
 
303
+ # Tables db_query must never touch (Guardian-owned) + SQLite internals.
304
+ # Identifiers must appear literally in SQL, so a word-boundary check is reliable.
305
+ _QUERY_BLOCKED = re.compile(
306
+ r"\b(users|sessions|sqlite_master|sqlite_schema|sqlite_temp_master|pragma|attach|detach)\b",
307
+ re.IGNORECASE,
308
+ )
309
+ _QUERY_MAX_ROWS = 1000
310
+
311
+
312
  async def query(sql: str) -> List[Dict]:
313
  """
314
  Execute a read-only SELECT query on the internal hub state database.
 
315
  Called by mcp.py db_query tool when db_sync.py is active.
316
 
317
+ Hardening (the docstring used to promise more than the code did):
318
+ - exactly ONE statement, must start with SELECT
319
+ - Guardian tables (users, sessions) + SQLite internals are rejected
320
+ - connection is opened read-only at engine level (mode=ro) — writes are impossible
321
+ - result is capped at 1000 rows
322
+
323
  Args:
324
  sql: SQL SELECT statement to execute.
325
 
 
327
  List of result rows as dicts.
328
 
329
  Raises:
330
+ ValueError: If the query is not an allowed SELECT statement.
331
  """
332
  _check_init()
333
 
334
+ stmt = sql.strip()
335
+ if stmt.endswith(";"):
336
+ stmt = stmt[:-1].rstrip()
337
+ if not stmt.upper().startswith("SELECT"):
338
  raise ValueError("Only SELECT queries are permitted in db_query tool.")
339
+ if ";" in stmt:
340
+ raise ValueError("Only a single statement is permitted in db_query tool.")
341
+ if _QUERY_BLOCKED.search(stmt):
342
+ raise ValueError("Query touches a protected table or SQLite internals.")
343
 
344
+ ro_uri = Path(_db_path).resolve().as_uri() + "?mode=ro"
345
+ async with aiosqlite.connect(ro_uri, uri=True) as db:
346
  db.row_factory = aiosqlite.Row
347
+ cursor = await db.execute(stmt)
348
+ rows = await cursor.fetchmany(_QUERY_MAX_ROWS)
349
  return [dict(r) for r in rows]
350
 
351
 
352
+ # =============================================================================
353
+ # SECTION 4b — Usage / Cost Log (usage_log table)
354
+ # =============================================================================
355
+
356
+ async def log_usage(
357
+ tool: str,
358
+ provider: str,
359
+ model: str,
360
+ tokens_in: Optional[int],
361
+ tokens_out: Optional[int],
362
+ cost_usd: Optional[float],
363
+ latency_ms: Optional[int] = None,
364
+ ) -> None:
365
+ """Append one usage row. Called by providers.py after every successful LLM call."""
366
+ _check_init()
367
+ now = datetime.now(timezone.utc).isoformat()
368
+ async with aiosqlite.connect(_db_path) as db:
369
+ await db.execute("""
370
+ INSERT INTO usage_log (ts, tool, provider, model, tokens_in, tokens_out, cost_usd, latency_ms)
371
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
372
+ """, (now, tool, provider, model, tokens_in, tokens_out, cost_usd, latency_ms))
373
+ await db.commit()
374
+
375
+
376
+ _USAGE_GROUPS = {"model": "model", "provider": "provider", "tool": "tool", "day": "substr(ts, 1, 10)"}
377
+
378
+
379
+ async def usage_stats(group_by: str = "model", days: Optional[int] = None) -> Dict[str, Any]:
380
+ """
381
+ Aggregate usage_log.
382
+
383
+ Args:
384
+ group_by: "model" | "provider" | "tool" | "day"
385
+ days: Only count the last N days (None = all time).
386
+
387
+ Returns:
388
+ {"group_by", "days", "rows": [...], "total": {...}}
389
+ cost_usd is the sum of KNOWN costs; calls_unpriced counts calls without a price.
390
+ """
391
+ _check_init()
392
+ col = _USAGE_GROUPS.get(str(group_by).lower())
393
+ if not col:
394
+ raise ValueError(f"group_by must be one of {sorted(_USAGE_GROUPS)}")
395
+
396
+ where, args = "", []
397
+ if days:
398
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
399
+ where, args = "WHERE ts >= ?", [cutoff]
400
+
401
+ select = """
402
+ COUNT(*) AS calls,
403
+ COALESCE(SUM(tokens_in), 0) AS tokens_in,
404
+ COALESCE(SUM(tokens_out), 0) AS tokens_out,
405
+ ROUND(COALESCE(SUM(cost_usd), 0), 6) AS cost_usd,
406
+ SUM(CASE WHEN cost_usd IS NULL THEN 1 ELSE 0 END) AS calls_unpriced
407
+ """
408
+ async with aiosqlite.connect(_db_path) as db:
409
+ db.row_factory = aiosqlite.Row
410
+ cur = await db.execute(
411
+ f"SELECT {col} AS grp, {select} FROM usage_log {where} GROUP BY grp ORDER BY cost_usd DESC, calls DESC",
412
+ args)
413
+ rows = [dict(r) for r in await cur.fetchall()]
414
+ cur = await db.execute(f"SELECT {select} FROM usage_log {where}", args)
415
+ total = dict(await cur.fetchone())
416
+
417
+ return {"group_by": group_by, "days": days, "rows": rows, "total": total}
418
+
419
+
420
  # =============================================================================
421
  # SECTION 5 — Helpers
422
  # =============================================================================
 
433
 
434
 
435
  # =============================================================================
436
+ # SECTION 6 — Memory Orchestration: SQLite (Kurzzeit) → PostgreSQL (Dauer)
437
+ # =============================================================================
438
+ # ARCHITECTURE (Gedächtnis-Design):
439
+ # SQLite (hub_state / tool_cache / persist_queue) = Kurzzeit-Gedächtnis.
440
+ # Alle app/* Tools lesen/schreiben hier — immer verfügbar, kein Key nötig.
441
+ # PostgreSQL = Dauer-Gedächtnis — WICHTIGE Dinge wandern dorthin.
442
+ #
443
+ # persist_result Tool → enqueue_persist() (sofort, immer, kein Crash)
444
+ # ↓
445
+ # persist_queue (SQLite, überlebt Restarts)
446
+ # ↓
447
+ # flush_persist_queue() → PostgreSQL (Guardian-Writer)
448
+ # (Background-Retry bis die DB da ist — siehe start_persist_flusher)
449
+ #
450
+ # Delivery-Semantik: at-least-once (PG-Insert + Queue-Delete sind nicht
451
+ # atomar über zwei Systeme). Für ein Gedächtnis ist ein seltener Duplikat-
452
+ # Eintrag unkritisch — Datenverlust wäre es.
453
+ #
454
+ # HF-Spaces-Hinweis: SPACE_ID verschiebt SQLite nach /tmp — Queue überbrückt
455
+ # dort DB-Ausfälle innerhalb der Container-Lebenszeit; dauerhaft ist nur PG.
456
  # =============================================================================
457
  _psql_writer = None
458
+ _flush_task: Optional[asyncio.Task] = None
459
+ _pg_tables_ensured: set = set()
460
+
461
+ # Defense: Tabellennamen kommen aus .pyfun, aber wir validieren trotzdem.
462
+ _SAFE_TABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
463
+
464
+ # Ziel-Schema in PostgreSQL — source/model/score als eigene Spalten,
465
+ # damit dein Bullshit-Filter später direkt auf score filtern kann.
466
+ _PG_DDL = """
467
+ CREATE TABLE IF NOT EXISTS {table} (
468
+ id BIGSERIAL PRIMARY KEY,
469
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
470
+ source TEXT,
471
+ model TEXT,
472
+ score REAL,
473
+ payload JSONB NOT NULL
474
+ )
475
+ """
476
 
477
 
478
  def set_psql_writer(writer_fn) -> None:
479
  """
480
+ Receives the asyncpg execute callable from Guardian via app.py.
481
  Called once in start_application() if db_service is available.
482
  app/* never imports postgresql.py directly — this is the only bridge.
483
  """
484
  global _psql_writer
485
  _psql_writer = writer_fn
486
+ _pg_tables_ensured.clear() # neue Connection → CREATE TABLE erneut sicherstellen
487
  logger.info("PostgreSQL writer registered.")
488
 
489
 
490
+ async def enqueue_persist(
491
+ table: str,
492
+ payload: Any,
493
+ source: str = "",
494
+ model: str = "",
495
+ score: Optional[float] = None,
496
+ ) -> int:
497
+ """
498
+ Queue an entry for the PostgreSQL long-term memory.
499
+ ALWAYS works (no writer needed) — this is the 'no crash' guarantee:
500
+ persist_result ohne DATABASE_URL = Eintrag wartet restart-safe in der Queue.
501
+
502
+ Args:
503
+ table: Target table in PostgreSQL (from .pyfun target_table).
504
+ payload: JSON-serializable data to persist.
505
+ source: Which tool/component produced this entry.
506
+ model: Which model produced this entry.
507
+ score: Optional quality score — reserved slot for the bullshit filter.
508
+
509
+ Returns:
510
+ Queue depth after insert (int).
511
+ """
512
+ _check_init()
513
+ max_q = int(config.get_db_sync().get("PERSIST_QUEUE_MAX", "500"))
514
+ now = datetime.now(timezone.utc).isoformat()
515
+
516
+ async with aiosqlite.connect(_db_path) as db:
517
+ await db.execute(
518
+ """INSERT INTO persist_queue
519
+ (target_table, payload, source, model, score, queued_at)
520
+ VALUES (?, ?, ?, ?, ?, ?)""",
521
+ (table, json.dumps(payload, ensure_ascii=False, default=str),
522
+ source, model, score, now),
523
+ )
524
+ # Queue begrenzen — älteste zuerst raus (gleiche Regel wie tool_cache)
525
+ await db.execute(
526
+ "DELETE FROM persist_queue WHERE id NOT IN "
527
+ "(SELECT id FROM persist_queue ORDER BY id DESC LIMIT ?)",
528
+ (max_q,),
529
+ )
530
+ await db.commit()
531
+ cursor = await db.execute("SELECT COUNT(*) FROM persist_queue")
532
+ return int((await cursor.fetchone())[0])
533
+
534
+
535
+ async def _flush_locked(limit: int = 50) -> Dict[str, Any]:
536
+ """
537
+ Try to move queued entries to PostgreSQL. NEVER raises.
538
+ (Single-flight: always called through flush_persist_queue(), which holds the lock.)
539
+
540
+ No writer → no-op with reason (entries keep waiting — no crash).
541
+ Writer set → CREATE TABLE IF NOT EXISTS + INSERT per entry + queue delete.
542
+ Failure → attempts+1 + sanitized last_error, entry stays queued.
543
+
544
+ Args:
545
+ limit: Max entries per flush run (bounded work per interval).
546
+
547
+ Returns:
548
+ Dict: {flushed, errors, remaining, reason}
549
+ """
550
+ _check_init()
551
+
552
  if not _psql_writer:
553
+ return {
554
+ "flushed": 0, "errors": 0,
555
+ "remaining": await _queue_count(),
556
+ "reason": "no_postgres_writer",
557
+ }
558
+
559
+ # Neon asleep / unreachable a moment ago → don't even try (entries stay queued, nothing blocks)
560
+ if time.monotonic() < _pg_down_until:
561
+ return {
562
+ "flushed": 0, "errors": 0,
563
+ "remaining": await _queue_count(),
564
+ "reason": "pg_backoff",
565
+ }
566
+
567
+ flushed = errors = 0
568
+ async with aiosqlite.connect(_db_path) as db:
569
+ db.row_factory = aiosqlite.Row
570
+ cursor = await db.execute(
571
+ "SELECT id, target_table, payload, source, model, score "
572
+ "FROM persist_queue ORDER BY id ASC LIMIT ?",
573
+ (limit,),
574
+ )
575
+ rows = await cursor.fetchall()
576
+
577
+ for row in rows:
578
+ table = row["target_table"]
579
+ try:
580
+ if not _SAFE_TABLE_NAME.match(table or ""):
581
+ raise ValueError("invalid table name")
582
+ if table not in _pg_tables_ensured:
583
+ await _pg_exec(_PG_DDL.format(table=table))
584
+ _pg_tables_ensured.add(table)
585
+ await _pg_exec(
586
+ f"INSERT INTO {table} (source, model, score, payload) "
587
+ "VALUES ($1, $2, $3, $4::jsonb)",
588
+ row["source"], row["model"], row["score"], row["payload"],
589
+ )
590
+ await db.execute("DELETE FROM persist_queue WHERE id = ?", (row["id"],))
591
+ await db.commit()
592
+ flushed += 1
593
+ except Exception as e:
594
+ errors += 1
595
+ # Sanitized: nur Exception-TYP — niemals DSN/Payload in Logs oder DB
596
+ await db.execute(
597
+ "UPDATE persist_queue SET attempts = attempts + 1, last_error = ? "
598
+ "WHERE id = ?",
599
+ (type(e).__name__, row["id"]),
600
+ )
601
+ await db.commit()
602
+ if _is_conn_failure(e):
603
+ break # DB unreachable: stop here — don't burn one timeout per remaining row
604
+
605
+ remaining = await _queue_count()
606
+ if flushed:
607
+ logger.info(f"Persist flush: {flushed} → PostgreSQL, {remaining} queued.")
608
+ if errors:
609
+ logger.warning(f"Persist flush: {errors} failed — stay queued (retry next interval).")
610
+ return {"flushed": flushed, "errors": errors, "remaining": remaining}
611
+
612
+
613
+ # =============================================================================
614
+ # SECTION 6b — Neon / PostgreSQL resilience (scale-to-zero friendly)
615
+ # Neon suspends idle compute; the first connection afterwards can be slow or time out.
616
+ # Principle: the hub NEVER waits for PostgreSQL on a request path and never crashes.
617
+ # - every PG write has a hard time limit (PG_WRITE_TIMEOUT_SEC)
618
+ # - after a connection failure PG is skipped for PG_BACKOFF_SEC (entries stay queued)
619
+ # - request path waits at most PG_REQUEST_WAIT_SEC; the flush continues in background
620
+ # - pg_status() = cheap "is the DB awake?" ping for health checks (it also wakes Neon)
621
+ # - optional keepalive (PG_KEEPALIVE_SEC, default 0 = off): keeps Neon awake, costs compute time
622
+ # All knobs in .pyfun [DB_SYNC].
623
+ # =============================================================================
624
+
625
+ _pg_down_until: float = 0.0
626
+ _pg_last: Dict[str, Any] = {"at": -1e9}
627
+ _flush_lock: Optional[asyncio.Lock] = None
628
+ _bounded_task: Optional[asyncio.Task] = None
629
+ _keepalive_task: Optional[asyncio.Task] = None
630
+
631
+
632
+ def _pg_cfg(key: str, default: float) -> float:
633
+ try:
634
+ return float(config.get_db_sync().get(key, default))
635
+ except (TypeError, ValueError):
636
+ return float(default)
637
+
638
+
639
+ def _is_conn_failure(e: BaseException) -> bool:
640
+ """Timeout / socket / asyncpg connection-class errors (matched by name — no asyncpg import here)."""
641
+ name = type(e).__name__
642
+ return isinstance(e, (asyncio.TimeoutError, OSError)) or "Connection" in name or "Interface" in name
643
+
644
+
645
+ def _pg_mark(ok: bool) -> None:
646
+ global _pg_down_until
647
+ _pg_down_until = 0.0 if ok else time.monotonic() + _pg_cfg("PG_BACKOFF_SEC", 30)
648
+
649
+
650
+ async def _pg_exec(query: str, *args):
651
+ """One PG write with a hard time limit. Connection-class failures arm the backoff."""
652
+ try:
653
+ result = await asyncio.wait_for(_psql_writer(query, *args), timeout=_pg_cfg("PG_WRITE_TIMEOUT_SEC", 10))
654
+ _pg_mark(True)
655
+ return result
656
+ except Exception as e:
657
+ if _is_conn_failure(e):
658
+ _pg_mark(False)
659
+ raise
660
+
661
+
662
+ async def flush_persist_queue(limit: int = 50) -> Dict[str, Any]:
663
+ """Single-flight flush (lock): timer + request path can never insert the same row twice."""
664
+ global _flush_lock
665
+ _check_init()
666
+ if _flush_lock is None:
667
+ _flush_lock = asyncio.Lock()
668
+ async with _flush_lock:
669
+ return await _flush_locked(limit)
670
 
 
 
671
 
672
+ async def flush_persist_queue_bounded(limit: int = 50, wait_sec: Optional[float] = None) -> Dict[str, Any]:
673
+ """
674
+ Request-path flush: waits at most PG_REQUEST_WAIT_SEC. If PostgreSQL is slow/asleep the
675
+ flush keeps running in the background (shielded) and the caller gets reason "pg_slow".
676
+ """
677
+ global _bounded_task
678
+ wait = _pg_cfg("PG_REQUEST_WAIT_SEC", 3) if wait_sec is None else wait_sec
679
+ if _bounded_task is None or _bounded_task.done():
680
+ _bounded_task = asyncio.create_task(flush_persist_queue(limit))
681
+ try:
682
+ return await asyncio.wait_for(asyncio.shield(_bounded_task), timeout=wait)
683
+ except asyncio.TimeoutError:
684
+ return {"flushed": 0, "errors": 0, "remaining": await _queue_count(), "reason": "pg_slow"}
685
+
686
+
687
+ async def pg_status(force: bool = False) -> Dict[str, Any]:
688
+ """
689
+ Is PostgreSQL awake? Cheap ping (SELECT 1) with a short limit, cached PG_STATUS_TTL_SEC.
690
+ state: "up" | "no_response" (asleep/waking/unreachable) | "error"; bridge: active|inactive.
691
+ The ping itself wakes a suspended Neon compute.
692
+ """
693
+ if not _psql_writer:
694
+ return {"bridge": "inactive", "state": "n/a"}
695
+ now = time.monotonic()
696
+ if not force and now - _pg_last["at"] < _pg_cfg("PG_STATUS_TTL_SEC", 20):
697
+ return {k: v for k, v in _pg_last.items() if k != "at"}
698
+
699
+ t0 = time.monotonic()
700
+ state, err = "up", None
701
+ try:
702
+ await asyncio.wait_for(_psql_writer("SELECT 1"), timeout=_pg_cfg("PG_PING_TIMEOUT_SEC", 5))
703
+ _pg_mark(True)
704
+ except asyncio.TimeoutError:
705
+ state = "no_response"
706
+ _pg_mark(False)
707
+ except Exception as e:
708
+ state, err = "error", type(e).__name__
709
+ if _is_conn_failure(e):
710
+ _pg_mark(False)
711
+ _pg_last.clear()
712
+ _pg_last.update({
713
+ "at": time.monotonic(), "bridge": "active", "state": state,
714
+ "latency_ms": int((time.monotonic() - t0) * 1000), "error": err,
715
+ "backoff_active": time.monotonic() < _pg_down_until,
716
+ })
717
+ return {k: v for k, v in _pg_last.items() if k != "at"}
718
+
719
+
720
+ async def _keepalive_loop(interval: float) -> None:
721
+ while True:
722
+ await asyncio.sleep(interval)
723
+ try:
724
+ await pg_status(force=True)
725
+ except Exception as e:
726
+ logger.debug(f"Keepalive ping skipped: {type(e).__name__}")
727
+
728
+
729
+ async def _queue_count() -> int:
730
+ async with aiosqlite.connect(_db_path) as db:
731
+ cursor = await db.execute("SELECT COUNT(*) FROM persist_queue")
732
+ return int((await cursor.fetchone())[0])
733
+
734
+
735
+ async def persist_queue_stats() -> Dict[str, Any]:
736
+ """
737
+ Zustand des Gedächtnisses — Observability für die Orchestrierung.
738
+ Wird von persist_status (MCP + /api) gelesen.
739
+ """
740
+ _check_init()
741
+ async with aiosqlite.connect(_db_path) as db:
742
+ cursor = await db.execute(
743
+ "SELECT COUNT(*), MIN(queued_at), "
744
+ "SUM(CASE WHEN attempts > 0 THEN 1 ELSE 0 END) FROM persist_queue"
745
+ )
746
+ row = await cursor.fetchone()
747
+ count, oldest, failed = row[0] or 0, row[1], row[2] or 0
748
+ return {
749
+ "queue_depth": count,
750
+ "oldest_queued_at": oldest,
751
+ "entries_with_failed_attempts": failed,
752
+ "postgres_bridge": "active" if _psql_writer else "inactive (DATABASE_URL not set)",
753
+ "note": "queue is restart-safe — flushes to PostgreSQL as soon as the bridge is active",
754
+ }
755
+
756
+
757
+ def start_persist_flusher() -> None:
758
+ """
759
+ Background retry loop — started by app.py AFTER db_sync.initialize().
760
+ Works with AND without writer: ohne PG bleibt alles einfach liegen.
761
+ """
762
+ global _flush_task
763
+ if _flush_task is not None:
764
+ return
765
+ interval = float(config.get_db_sync().get("PERSIST_FLUSH_INTERVAL_SEC", "60"))
766
+ _flush_task = asyncio.create_task(_persist_flush_loop(interval))
767
+ logger.info(f"Persist flusher started (interval: {interval}s).")
768
+
769
+ global _keepalive_task
770
+ ka = _pg_cfg("PG_KEEPALIVE_SEC", 0)
771
+ if ka > 0 and _keepalive_task is None:
772
+ _keepalive_task = asyncio.create_task(_keepalive_loop(ka))
773
+ logger.info(f"PostgreSQL keepalive ping every {int(ka)}s (keeps Neon awake — uses compute time).")
774
+
775
+
776
+ async def _persist_flush_loop(interval: float) -> None:
777
+ # Erster Versuch kurz nach dem Boot — fängt Einträge der letzten Session ab
778
+ # (genau dann, wenn PG wieder da ist, während er vorher down war).
779
+ await asyncio.sleep(5)
780
+ while True:
781
+ try:
782
+ result = await flush_persist_queue()
783
+ if result.get("flushed") or result.get("errors"):
784
+ logger.info(f"Persist flusher: {result}")
785
+ except Exception as e:
786
+ logger.warning(f"Persist flusher error (non-fatal): {type(e).__name__}")
787
+ await asyncio.sleep(interval)
788
+
789
+
790
+ async def persist(table: str, data: dict) -> None:
791
+ """
792
+ Legacy direct-write — jetzt queue-safe: enqueue + sofortiger Flush-Versuch.
793
+ Beißt nicht mehr ohne DATABASE_URL (früher: RuntimeError). Backward-kompatibel
794
+ für externe Aufrufer; tools.py nutzt jetzt enqueue_persist() direkt.
795
+ """
796
+ await enqueue_persist(table, payload=data)
797
+ await flush_persist_queue(limit=1)
798
 
799
  # =============================================================================
800
  # Direct execution guard
app/discord_api.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # extractet from our discord community bank :D
2
+ # =============================================================================
3
+ # app/discord_api.py
4
+ # Discord Integration — Modular API layer (REST + Interactions Endpoint)
5
+ # Universal My AI HUB (Sandboxed) - based on PyFundaments Architecture
6
+ # Copyright 2026 - Volkan Kücükbudak
7
+ # This file is under GPL License V. 3 + ESOL 2.1
8
+ # Repo: https://github.com/VolkanSah/Multi-LLM-API-Gateway
9
+ # =============================================================================
10
+ # ARCHITECTURE NOTE:
11
+ # Modular integration living in app/* only.
12
+ # NO low-level access, NO fundaments access. Config comes from
13
+ # app/.pyfun [DISCORD] via app/config.py — secrets stay in .env
14
+ # (env_key = NAME only, same rule as every provider).
15
+ #
16
+ # ZERO new dependencies — deliberately:
17
+ # httpx → REST calls (already required by the hub)
18
+ # cryptography → Ed25519 signature verify (PyFundaments core!)
19
+ # The discord.py library + PyNaCl are NOT needed. Pure HTTP.
20
+ # (Only if you later want gateway events or voice: uncomment in
21
+ # requirements.txt — this module never imports them.)
22
+ #
23
+ # WHY "discord_api.py" AND NOT "discord.py":
24
+ # A file named discord.py that imports the discord library is the
25
+ # classic self-import footgun (works in the package, explodes the
26
+ # moment anything runs inside app/). app.py already references this
27
+ # module as discord_api (line 43 + /discord route stub).
28
+ #
29
+ # THREE CAPABILITIES — use what you need, ignore the rest:
30
+ #
31
+ # 1. OUTBOUND (hub → Discord):
32
+ # send_message() — bot posts to a channel (REST, 'Bot' token)
33
+ # send_webhook() — write-only channel webhook (no bot needed)
34
+ # read_channel() — last N messages (needs Message Content Intent)
35
+ # send_followup() — late answer after defer (LLM latency pattern)
36
+ #
37
+ # 2. INBOUND (Discord → hub) — Interactions Endpoint:
38
+ # verify_signature() — Ed25519 check, rejects forgeries (401)
39
+ # handle_interaction() — PING/PONG + slash command dispatch
40
+ # register_command_handler() — later: map commands to hub tools
41
+ #
42
+ # 3. SETUP HELPERS:
43
+ # register_commands() — register slash commands via REST
44
+ # get_me() — token sanity check (GET /users/@me)
45
+ #
46
+ # FLOW (interactions, the >3s LLM pattern):
47
+ # Discord POSTs a command → verify_signature (raw bytes, BEFORE parse!)
48
+ # → respond {"type": 5} (DEFERRED) → run the LLM/tool in background
49
+ # → send_followup(interaction_token, answer)
50
+ # Discord kills responses after 3 seconds — defer is not optional
51
+ # for LLM latency. 3s is a hard Discord limit, not ours.
52
+ #
53
+ # STATUS: shipped INACTIVE (DISCORD_ACTIVE = "false" in .pyfun).
54
+ # Wiring comes later — prepared hooks:
55
+ # app/app.py: from . import discord_api + /discord route (stub exists)
56
+ # app/tools.py: elif provider_type == "discord": ...
57
+ # See docs/discord_api.md for the exact snippets.
58
+ # =============================================================================
59
+
60
+ import asyncio
61
+ import json
62
+ import logging
63
+ import os
64
+ import time
65
+ from typing import Any, Callable, Dict, Optional
66
+
67
+ import httpx
68
+ from cryptography.exceptions import InvalidSignature
69
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
70
+
71
+ from . import config
72
+
73
+ logger = logging.getLogger("discord_api")
74
+
75
+ # Discord interaction response types (only the ones we use)
76
+ RESPONSE_PONG = 1 # answer to PING (validation)
77
+ RESPONSE_CHANNEL_MESSAGE = 4 # direct answer
78
+ RESPONSE_DEFERRED_CHANNEL_MESSAGE = 5 # "thinking..." — followup later
79
+
80
+ # Interaction request types (from Discord)
81
+ TYPE_PING = 1
82
+ TYPE_APPLICATION_COMMAND = 2
83
+
84
+ # Signature freshness — reject stale timestamps (replay protection).
85
+ # Discord signs timestamp + body; anything older than this is garbage.
86
+ _TIMESTAMP_MAX_AGE = 300 # seconds
87
+
88
+ # Content limit enforced by Discord — we reject instead of silently
89
+ # truncating. The caller (later: tools.py wrapper) decides how to split.
90
+ _MAX_CONTENT_LEN = 2000
91
+
92
+
93
+ # =============================================================================
94
+ # Module State — populated by initialize()
95
+ # =============================================================================
96
+ _cfg = {}
97
+ _active = False
98
+ _token = "" # from .env via env_key — NEVER in .pyfun
99
+ _webhook_url = "" # from .env via webhook_env_key — NEVER in .pyfun
100
+ _default_channel = ""
101
+ _app_id = "" # Dev Portal → Application ID (public)
102
+ _public_key = "" # Dev Portal → Public Key (public — verify only!)
103
+ _api_base = "https://discord.com/api/v10"
104
+ _timeout = 15
105
+
106
+ # Slash command handlers — name → async fn(interaction) → response dict.
107
+ # Populated later by tools.py wiring; ships with the built-in "ping".
108
+ _command_handlers: Dict[str, Callable] = {}
109
+
110
+
111
+ # =============================================================================
112
+ # Initialization — called by app/app.py (later, when wired)
113
+ # =============================================================================
114
+
115
+ async def initialize() -> None:
116
+ """
117
+ Initialize Discord integration from app/.pyfun [DISCORD].
118
+ Called by app/app.py — never reads os.environ beyond the named env_key
119
+ (same sandbox boundary as shellmaster / every provider).
120
+
121
+ No token + no webhook + no public key → module stays inactive.
122
+ No crash, no error — just a log line. Hub philosophy.
123
+ """
124
+ global _cfg, _active, _token, _webhook_url, _default_channel
125
+ global _app_id, _public_key, _api_base, _timeout
126
+
127
+ _cfg = config.get_discord()
128
+
129
+ if not _cfg or _cfg.get("DISCORD_ACTIVE", "false").lower() != "true":
130
+ logger.info("Discord not active in .pyfun [DISCORD] — skipped")
131
+ return
132
+
133
+ # Token + Webhook: ENV zuerst (.pyfun nennt nur den ENV-Namen).
134
+ # Secrets gehören NIE in .pyfun — gleiche Regel wie bei allen Providern.
135
+ env_key = _cfg.get("DISCORD_ENV_KEY", "DISCORD_BOT_TOKEN")
136
+ webhook_env = _cfg.get("DISCORD_WEBHOOK_ENV_KEY", "DISCORD_WEBHOOK_URL")
137
+ _token = os.getenv(env_key, "") if env_key else ""
138
+ _webhook_url = os.getenv(webhook_env, "") if webhook_env else ""
139
+
140
+ # Public values from .pyfun (safe — they are not secrets):
141
+ _default_channel = _cfg.get("DISCORD_DEFAULT_CHANNEL", "")
142
+ _app_id = _cfg.get("DISCORD_APP_ID", "")
143
+ _public_key = _cfg.get("DISCORD_APP_PUBLIC_KEY", "")
144
+ _api_base = _cfg.get("DISCORD_API_BASE", "https://discord.com/api/v10")
145
+ _timeout = int(_cfg.get("DISCORD_TIMEOUT_SEC", "15"))
146
+
147
+ if not _token and not _webhook_url:
148
+ logger.warning(
149
+ "Discord: neither %s nor %s set in .env — outbound disabled "
150
+ "(set one of them, module stays importable)", env_key, webhook_env
151
+ )
152
+ if _app_id and not _token:
153
+ logger.warning("Discord: DISCORD_APP_ID set but no bot token — followup/commands disabled")
154
+
155
+ _active = True
156
+ modes = []
157
+ if _token: modes.append("bot-rest")
158
+ if _webhook_url: modes.append("webhook")
159
+ if _public_key: modes.append("interactions-verify")
160
+ logger.info("Discord initialized | modes: %s", ", ".join(modes) or "none (config only)")
161
+
162
+
163
+ def is_ready() -> bool:
164
+ """True if initialize() ran with DISCORD_ACTIVE = "true"."""
165
+ return _active
166
+
167
+
168
+ def get_config() -> dict:
169
+ """Raw [DISCORD] config from .pyfun."""
170
+ return _cfg
171
+
172
+
173
+ def get_status() -> dict:
174
+ """
175
+ Safe status snapshot — key NAMES and booleans only, never values.
176
+ (Same principle as list_active_tools: nothing secret leaves the module.)
177
+ """
178
+ return {
179
+ "active": _active,
180
+ "bot_token_set": bool(_token),
181
+ "webhook_set": bool(_webhook_url),
182
+ "public_key_set": bool(_public_key),
183
+ "app_id_set": bool(_app_id),
184
+ "default_channel": _default_channel,
185
+ "api_base": _api_base,
186
+ "commands": sorted(_command_handlers.keys()),
187
+ }
188
+
189
+
190
+ # =============================================================================
191
+ # Internal REST helper — Bot auth, timeout, one rate-limit retry
192
+ # =============================================================================
193
+
194
+ async def _api_request(
195
+ method: str,
196
+ path: str,
197
+ payload: Optional[dict] = None,
198
+ auth: bool = True,
199
+ ) -> dict:
200
+ """
201
+ One Discord REST call with the house rules:
202
+ - 'Bot <token>' auth only when auth=True (webhook/followup URLs carry their own).
203
+ NOTE: Discord bot tokens use the 'Bot' scheme — 'Bearer' is for OAuth2 user tokens.
204
+ - 429 → sleep Retry-After (capped at 10s), retry ONCE, then give up
205
+ - never raises for flow errors — returns {"ok": False, "error": ...}
206
+ """
207
+ if auth and not _token:
208
+ return {"ok": False, "error": "no bot token — set DISCORD_BOT_TOKEN in .env"}
209
+
210
+ headers = {"Content-Type": "application/json"}
211
+ if auth:
212
+ headers["Authorization"] = f"Bot {_token}"
213
+
214
+ url = path if path.startswith("http") else f"{_api_base}{path}"
215
+
216
+ for attempt in (1, 2):
217
+ try:
218
+ async with httpx.AsyncClient() as client:
219
+ r = await client.request(
220
+ method, url, json=payload, headers=headers, timeout=_timeout,
221
+ )
222
+
223
+ if r.status_code == 429 and attempt == 1:
224
+ retry_after = float(r.headers.get("Retry-After", "1"))
225
+ retry_after = min(retry_after, 10.0)
226
+ logger.warning("Discord rate limit — retrying in %.1fs", retry_after)
227
+ await asyncio.sleep(retry_after)
228
+ continue
229
+
230
+ if 200 <= r.status_code < 300:
231
+ data = r.json() if r.content else {}
232
+ return {"ok": True, "status": r.status_code, "data": data}
233
+
234
+ # Discord error body: {"message": ..., "code": ...} — keep it, drop secrets
235
+ try:
236
+ err = r.json().get("message", "")
237
+ except Exception:
238
+ err = ""
239
+ logger.warning("Discord API error HTTP %s: %s", r.status_code, err[:120])
240
+ return {"ok": False, "status": r.status_code, "error": err or f"HTTP {r.status_code}"}
241
+
242
+ except httpx.ConnectError:
243
+ return {"ok": False, "error": "connection failed — Discord unreachable?"}
244
+ except httpx.TimeoutException:
245
+ return {"ok": False, "error": f"timeout after {_timeout}s"}
246
+ except Exception as e:
247
+ # Sanitized — only the exception type, no payload details
248
+ logger.warning("Discord request failed: %s", type(e).__name__)
249
+ return {"ok": False, "error": type(e).__name__}
250
+
251
+ return {"ok": False, "error": "rate limited twice — try later"}
252
+
253
+
254
+ # =============================================================================
255
+ # OUTBOUND — hub → Discord
256
+ # =============================================================================
257
+
258
+ async def get_me() -> dict:
259
+ """
260
+ Token sanity check — GET /users/@me.
261
+ Returns the bot's own user object (id, username, ...) on success.
262
+ Best first live test after wiring the token.
263
+ """
264
+ return await _api_request("GET", "/users/@me")
265
+
266
+
267
+ async def send_message(
268
+ channel_id: str,
269
+ content: str,
270
+ embed: Optional[dict] = None,
271
+ ) -> dict:
272
+ """
273
+ Post a message to a channel via Bot REST.
274
+
275
+ Args:
276
+ channel_id: target channel (falls back to DISCORD_DEFAULT_CHANNEL).
277
+ content: message text. Discord hard limit: 2000 chars — we REJECT
278
+ longer input instead of silently truncating.
279
+ embed: raw Discord embed JSON dict (pass-through, no builder).
280
+
281
+ Returns:
282
+ {"ok": True, "status": 200, "data": <message object>} on success,
283
+ {"ok": False, "error": ...} on failure. Never raises.
284
+ """
285
+ if not _active:
286
+ return {"ok": False, "error": "Discord not active — set DISCORD_ACTIVE = \"true\" in .pyfun"}
287
+ if not _token:
288
+ return {"ok": False, "error": "no bot token — set DISCORD_BOT_TOKEN in .env"}
289
+
290
+ channel_id = channel_id or _default_channel
291
+ if not channel_id:
292
+ return {"ok": False, "error": "no channel_id given and DISCORD_DEFAULT_CHANNEL empty"}
293
+ if content and len(content) > _MAX_CONTENT_LEN:
294
+ return {"ok": False, "error": f"content exceeds {_MAX_CONTENT_LEN} chars (Discord limit)"}
295
+ if not content and not embed:
296
+ return {"ok": False, "error": "content and embed are both empty"}
297
+
298
+ payload = {"content": content}
299
+ if embed:
300
+ payload["embeds"] = [embed]
301
+
302
+ result = await _api_request("POST", f"/channels/{channel_id}/messages", payload)
303
+ if result.get("ok"):
304
+ logger.info("Discord: message sent to channel %s", channel_id)
305
+ return result
306
+
307
+
308
+ async def send_webhook(content: str = "", embed: Optional[dict] = None) -> dict:
309
+ """
310
+ Post via channel webhook — write-only, needs NO bot, NO invite,
311
+ NO intents. Dead simple notifications. The URL is the secret
312
+ (anyone holding it can post) → lives in .env only.
313
+
314
+ Args:
315
+ content: message text (same 2000 char limit, rejected not truncated).
316
+ embed: raw Discord embed JSON dict (pass-through).
317
+ """
318
+ if not _active:
319
+ return {"ok": False, "error": "Discord not active — set DISCORD_ACTIVE = \"true\" in .pyfun"}
320
+ if not _webhook_url:
321
+ return {"ok": False, "error": "no webhook — set DISCORD_WEBHOOK_URL in .env"}
322
+ if content and len(content) > _MAX_CONTENT_LEN:
323
+ return {"ok": False, "error": f"content exceeds {_MAX_CONTENT_LEN} chars (Discord limit)"}
324
+ if not content and not embed:
325
+ return {"ok": False, "error": "content and embed are both empty"}
326
+
327
+ payload: Dict[str, Any] = {}
328
+ if content:
329
+ payload["content"] = content
330
+ if embed:
331
+ payload["embeds"] = [embed]
332
+
333
+ # Webhook URL carries its own auth — no Bearer header
334
+ result = await _api_request("POST", _webhook_url, payload, auth=False)
335
+ if result.get("ok"):
336
+ logger.info("Discord: webhook message sent")
337
+ return result
338
+
339
+
340
+ async def read_channel(channel_id: str, limit: int = 10) -> dict:
341
+ """
342
+ Read the last N messages from a channel (newest first).
343
+
344
+ NOTE: message TEXT only comes back if the bot has the
345
+ "Message Content Intent" enabled in the Discord Developer Portal
346
+ (Bot tab). Without it you get metadata, content comes back empty.
347
+ Send-only setups don't need the intent.
348
+ """
349
+ if not _active:
350
+ return {"ok": False, "error": "Discord not active — set DISCORD_ACTIVE = \"true\" in .pyfun"}
351
+ if not _token:
352
+ return {"ok": False, "error": "no bot token — set DISCORD_BOT_TOKEN in .env"}
353
+
354
+ channel_id = channel_id or _default_channel
355
+ if not channel_id:
356
+ return {"ok": False, "error": "no channel_id given and DISCORD_DEFAULT_CHANNEL empty"}
357
+
358
+ limit = max(1, min(int(limit), 50)) # Discord caps at 100, we cap tighter
359
+ return await _api_request("GET", f"/channels/{channel_id}/messages?limit={limit}")
360
+
361
+
362
+ async def send_followup(interaction_token: str, content: str, embed: Optional[dict] = None) -> dict:
363
+ """
364
+ Late answer after a DEFERRED response (type 5).
365
+
366
+ The interaction token authorizes the call itself (webhook-style) —
367
+ no bot token needed. Valid for 15 minutes after the interaction.
368
+
369
+ Args:
370
+ interaction_token: token from the interaction payload.
371
+ content: the (possibly slow) LLM answer.
372
+ embed: optional raw embed dict.
373
+ """
374
+ if not _app_id:
375
+ return {"ok": False, "error": "no app id — set DISCORD_APP_ID in .pyfun"}
376
+ if not interaction_token:
377
+ return {"ok": False, "error": "no interaction_token given"}
378
+ if content and len(content) > _MAX_CONTENT_LEN:
379
+ return {"ok": False, "error": f"content exceeds {_MAX_CONTENT_LEN} chars (Discord limit)"}
380
+
381
+ payload: Dict[str, Any] = {"content": content}
382
+ if embed:
383
+ payload["embeds"] = [embed]
384
+
385
+ path = f"/webhooks/{_app_id}/{interaction_token}"
386
+ return await _api_request("POST", path, payload, auth=False)
387
+
388
+
389
+ # =============================================================================
390
+ # SETUP HELPER — slash command registration via REST
391
+ # =============================================================================
392
+
393
+ async def register_commands(commands: list, guild_id: str = "") -> dict:
394
+ """
395
+ Bulk-register slash commands (PUT — overwrites existing set).
396
+
397
+ Args:
398
+ commands: list of Discord command defs, e.g.
399
+ [{"name": "ask", "description": "Ask the hub",
400
+ "type": 1,
401
+ "options": [{"name": "prompt", "type": 3,
402
+ "description": "your question",
403
+ "required": True}]}]
404
+ guild_id: optional — guild-scoped = instant propagation (testing).
405
+ Global commands take up to 1 hour to propagate (Discord rule).
406
+
407
+ Returns:
408
+ {"ok": True, "data": <registered commands>} on success.
409
+ """
410
+ if not _active or not _token:
411
+ return {"ok": False, "error": "Discord not active or no bot token"}
412
+ if not _app_id:
413
+ return {"ok": False, "error": "no app id — set DISCORD_APP_ID in .pyfun"}
414
+ if not commands or not isinstance(commands, list):
415
+ return {"ok": False, "error": "commands must be a non-empty list"}
416
+
417
+ if guild_id:
418
+ path = f"/applications/{_app_id}/guilds/{guild_id}/commands"
419
+ else:
420
+ path = f"/applications/{_app_id}/commands"
421
+
422
+ return await _api_request("PUT", path, commands)
423
+
424
+
425
+ # =============================================================================
426
+ # INBOUND — Interactions Endpoint (Discord → hub)
427
+ # =============================================================================
428
+
429
+ def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
430
+ """
431
+ Ed25519 verification of an incoming Discord interaction.
432
+
433
+ Discord signs (timestamp + raw body) with the app's private key;
434
+ we verify against the PUBLIC key from the Dev Portal. Forged or
435
+ replayed requests fail here → route answers 401. This is the ONLY
436
+ thing that makes a public /discord endpoint safe.
437
+
438
+ IMPORTANT: the route must pass the RAW request bytes (request.get_data()),
439
+ NOT re-serialized JSON — re-serialization changes one byte and the
440
+ signature dies. Verify BEFORE json.loads, always.
441
+
442
+ Args:
443
+ raw_body: raw request body bytes (exactly as received).
444
+ signature: hex string from X-Signature-Ed25519 header.
445
+ timestamp: string from X-Signature-Timestamp header.
446
+
447
+ Returns:
448
+ True = authentic and fresh. False = reject (401), never crash.
449
+ """
450
+ if not _public_key or not signature or not timestamp:
451
+ return False
452
+
453
+ # Replay protection — stale timestamp = someone replaying an old request
454
+ try:
455
+ if abs(time.time() - int(timestamp)) > _TIMESTAMP_MAX_AGE:
456
+ logger.warning("Discord: interaction timestamp stale — rejected")
457
+ return False
458
+ except ValueError:
459
+ return False
460
+
461
+ try:
462
+ pub_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(_public_key))
463
+ pub_key.verify(
464
+ bytes.fromhex(signature),
465
+ timestamp.encode("utf-8") + raw_body,
466
+ )
467
+ return True
468
+ except (InvalidSignature, ValueError, TypeError) as e:
469
+ logger.warning("Discord: signature invalid (%s) — rejected", type(e).__name__)
470
+ return False
471
+
472
+
473
+ def register_command_handler(name: str, handler: Callable) -> None:
474
+ """
475
+ Map a slash command name to a handler — LATER wiring point for tools.py.
476
+
477
+ Handler contract:
478
+ async def handler(interaction: dict) -> dict
479
+ Returns a full Discord response payload, e.g.
480
+ {"type": 4, "data": {"content": "answer"}} — direct
481
+ {"type": 5} — defer, then
482
+ await discord_api.send_followup(token, answer) — followup
483
+
484
+ The built-in "ping" answers without any tools — proves the endpoint.
485
+ """
486
+ if not name or not callable(handler):
487
+ logger.warning("Discord: register_command_handler ignored (bad name/handler)")
488
+ return
489
+ _command_handlers[name] = handler
490
+ logger.info("Discord: command handler registered: %s", name)
491
+
492
+
493
+ async def _builtin_ping(interaction: dict) -> dict:
494
+ """Built-in health command — no tools needed, proves endpoint + config."""
495
+ hub = config.get_hub()
496
+ name = hub.get("HUB_NAME", "Hub")
497
+ return {"type": RESPONSE_CHANNEL_MESSAGE, "data": {"content": f"🏓 {name} is alive"}}
498
+
499
+
500
+ # Ship the builtin — removable via unregister if you hate it
501
+ register_command_handler("ping", _builtin_ping)
502
+
503
+
504
+ async def handle_interaction(payload: dict) -> Optional[dict]:
505
+ """
506
+ Process a verified interaction payload → response dict for Discord.
507
+
508
+ Handles:
509
+ type 1 (PING) → {"type": 1} (PONG — Dev Portal URL check)
510
+ type 2 (APPLICATION_COMMAND) → dispatch to registered handler;
511
+ unknown command → honest error message.
512
+ anything else → None (route answers 400 + logs — extend here
513
+ when you need components/modals).
514
+
515
+ Args:
516
+ payload: parsed JSON body — only AFTER verify_signature passed.
517
+
518
+ Returns:
519
+ Response dict (route: jsonify + 200) or None (route: 400).
520
+ """
521
+ itype = payload.get("type")
522
+
523
+ if itype == TYPE_PING:
524
+ logger.info("Discord: PING → PONG (endpoint validation)")
525
+ return {"type": RESPONSE_PONG}
526
+
527
+ if itype == TYPE_APPLICATION_COMMAND:
528
+ data = payload.get("data", {})
529
+ name = data.get("name", "")
530
+ handler = _command_handlers.get(name)
531
+
532
+ if handler is None:
533
+ logger.warning("Discord: no handler for command '%s'", name)
534
+ return {
535
+ "type": RESPONSE_CHANNEL_MESSAGE,
536
+ "data": {"content": f"Unknown command: {name} — no handler registered."},
537
+ }
538
+
539
+ try:
540
+ return await handler(payload)
541
+ except Exception as e:
542
+ # Sanitized — exception type only, never payload details
543
+ logger.warning("Discord: handler '%s' failed: %s", name, type(e).__name__)
544
+ return {
545
+ "type": RESPONSE_CHANNEL_MESSAGE,
546
+ "data": {"content": "Handler error — see hub logs."},
547
+ }
548
+
549
+ logger.warning("Discord: unhandled interaction type %s", itype)
550
+ return None
app/gsc.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # app/gsc.py
3
+ # Google Search Console tool — top queries / pages for a property
4
+ # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
5
+ # Copyright 2026 - Volkan Kücükbudak
6
+ # Apache License V. 2 + ESOL 1.1
7
+ # (idea: google-search-console.py)
8
+ # =============================================================================
9
+ # ARCHITECTURE NOTE:
10
+ # Lives in app/* only. Config from app/.pyfun [GSC]; the credentials come from the
11
+ # ENV var NAMED there (GSC_ENV_KEY) — never from .pyfun, never hard-coded.
12
+ #
13
+ # OPTIONAL dependencies (imported lazily — the hub starts fine without them):
14
+ # pip install google-api-python-client google-auth
15
+ #
16
+ # Credentials ENV value = the service-account JSON text itself (ideal for HF Space
17
+ # secrets) OR a path to the JSON file. The service account's e-mail must be added as
18
+ # a user in Search Console for the property. Scope is READ-ONLY.
19
+ #
20
+ # The Google client is blocking → it runs in a worker thread (asyncio.to_thread),
21
+ # so it never freezes the event loop.
22
+ #
23
+ # STATUS: shipped INACTIVE (GSC_ACTIVE = "false" in .pyfun).
24
+ # =============================================================================
25
+ import asyncio
26
+ import json
27
+ import logging
28
+ import os
29
+ from datetime import datetime, timedelta, timezone
30
+ from typing import Any, Dict, List
31
+
32
+ from . import config
33
+
34
+ logger = logging.getLogger("gsc")
35
+
36
+ _SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
37
+ _DIMENSIONS = {"query", "page", "country", "device", "date"}
38
+ _creds_value = "" # ENV value (JSON text or file path) — NEVER logged
39
+ _active = False
40
+
41
+
42
+ def initialize() -> None:
43
+ """Read [GSC]. Inactive / no credentials → stays inactive. No crash."""
44
+ global _creds_value, _active
45
+ cfg = config.get_gsc()
46
+ if not cfg or str(cfg.get("GSC_ACTIVE", "false")).lower() != "true":
47
+ logger.info("GSC not active in .pyfun [GSC] — skipped")
48
+ return
49
+ env_key = cfg.get("GSC_ENV_KEY", "GSC_CREDENTIALS")
50
+ _creds_value = (os.getenv(env_key, "") if env_key else "").strip()
51
+ if not _creds_value:
52
+ logger.warning("GSC: %s not set in .env — tool disabled", env_key)
53
+ return
54
+ _active = True
55
+ logger.info("GSC initialized (read-only, default property: %s)", cfg.get("GSC_DEFAULT_PROPERTY") or "none")
56
+
57
+
58
+ def is_ready() -> bool:
59
+ return _active
60
+
61
+
62
+ def _build_service():
63
+ """Blocking: credentials + API client. Lazy imports → clear error if libs are missing."""
64
+ try:
65
+ from google.oauth2 import service_account
66
+ from googleapiclient.discovery import build
67
+ except ImportError:
68
+ raise RuntimeError("Search Console libs missing — pip install google-api-python-client google-auth")
69
+
70
+ if _creds_value.startswith("{"):
71
+ creds = service_account.Credentials.from_service_account_info(json.loads(_creds_value), scopes=[_SCOPE])
72
+ else:
73
+ creds = service_account.Credentials.from_service_account_file(_creds_value, scopes=[_SCOPE])
74
+
75
+ cfg = config.get_gsc()
76
+ return build(cfg.get("GSC_API_NAME", "searchconsole"), cfg.get("GSC_API_VERSION", "v1"),
77
+ credentials=creds, cache_discovery=False)
78
+
79
+
80
+ def _query_sync(property_url: str, days_back: int, row_limit: int, dimension: str) -> List[Dict[str, Any]]:
81
+ service = _build_service()
82
+ # GSC data lags ~2-3 days
83
+ end = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d")
84
+ start = (datetime.now(timezone.utc) - timedelta(days=days_back + 2)).strftime("%Y-%m-%d")
85
+ body = {"startDate": start, "endDate": end, "dimensions": [dimension], "rowLimit": row_limit}
86
+
87
+ resp = service.searchanalytics().query(siteUrl=property_url, body=body).execute()
88
+
89
+ label = "keyword" if dimension == "query" else dimension
90
+ return [
91
+ {
92
+ label: row["keys"][0],
93
+ "clicks": row.get("clicks", 0),
94
+ "impressions": row.get("impressions", 0),
95
+ "ctr": round(row.get("ctr", 0) * 100, 2), # in %
96
+ "position": round(row.get("position", 0), 1),
97
+ }
98
+ for row in resp.get("rows", [])
99
+ ]
100
+
101
+
102
+ async def query(
103
+ property_url: str = "",
104
+ days_back: int = 30,
105
+ row_limit: int = 10,
106
+ dimension: str = "query",
107
+ ) -> List[Dict[str, Any]]:
108
+ """
109
+ Top queries (or pages/countries/devices/dates) of a Search Console property.
110
+
111
+ Args:
112
+ property_url: 'sc-domain:example.com' or 'https://example.com/'. Empty → GSC_DEFAULT_PROPERTY.
113
+ days_back: 1-365 (default 30).
114
+ row_limit: 1-100 (default 10).
115
+ dimension: query | page | country | device | date.
116
+
117
+ Raises:
118
+ ValueError — bad input.
119
+ RuntimeError — inactive / libs missing / API error (sanitized, no secrets).
120
+ """
121
+ if not _active:
122
+ raise RuntimeError("GSC not active — set GSC_ACTIVE = \"true\" in .pyfun and the credentials in .env")
123
+
124
+ prop = (property_url or config.get_gsc().get("GSC_DEFAULT_PROPERTY", "")).strip()
125
+ if not (prop.startswith("sc-domain:") or prop.startswith(("http://", "https://"))):
126
+ raise ValueError("property_url must be 'sc-domain:example.com' or a full https:// URL")
127
+ dimension = str(dimension or "query").lower()
128
+ if dimension not in _DIMENSIONS:
129
+ raise ValueError(f"dimension must be one of {sorted(_DIMENSIONS)}")
130
+ days_back = max(1, min(int(days_back or 30), 365))
131
+ row_limit = max(1, min(int(row_limit or 10), 100))
132
+
133
+ try:
134
+ return await asyncio.to_thread(_query_sync, prop, days_back, row_limit, dimension)
135
+ except RuntimeError:
136
+ raise
137
+ except Exception as e:
138
+ status = getattr(getattr(e, "resp", None), "status", None)
139
+ logger.warning("GSC request failed: %s%s", type(e).__name__, f" (HTTP {status})" if status else "")
140
+ raise RuntimeError(f"Search Console request failed: {type(e).__name__}{f' (HTTP {status})' if status else ''}") from None
app/mcp.py CHANGED
@@ -37,11 +37,13 @@
37
  # db_sync.py → internal SQLite IPC (app/* state) — NOT postgresql.py!
38
  # mcp.py → registers tools only, delegates all logic to tools.py
39
  # =============================================================================
 
40
  import logging
41
  from typing import Dict, Any
42
  from . import config as app_config
43
  from . import providers
44
  from . import models
 
45
  from . import tools
46
 
47
  logger = logging.getLogger('mcp')
@@ -72,7 +74,8 @@ async def initialize() -> None:
72
  1. LLM tools → via tools.py + providers.py (key-gated)
73
  2. Search tools → via tools.py + providers.py (key-gated)
74
  3. System tools → always registered, no key required
75
- 4. DB tools → uncomment when db_sync.py is ready
 
76
  """
77
  global _mcp, _transport, _stateless
78
 
@@ -106,7 +109,11 @@ async def initialize() -> None:
106
  _register_llm_tools(_mcp)
107
  _register_search_tools(_mcp)
108
  _register_system_tools(_mcp)
109
- # _register_db_tools(_mcp) # uncomment when db_sync.py is ready
 
 
 
 
110
 
111
  logger.info(f"MCP Hub initialized. Transport: {_transport}")
112
 
@@ -139,31 +146,35 @@ def get_asgi_app():
139
 
140
 
141
  # =============================================================================
142
- # Request Handler — Quart /mcp route entry point (SSE legacy transport only)
143
  # =============================================================================
144
- async def handle_request(request) -> None:
145
- """
146
- Handles incoming MCP SSE requests via Quart /mcp route.
147
- Only active when HUB_TRANSPORT = "sse" in .pyfun [HUB].
 
148
 
149
- For Streamable HTTP transport this function is NOT called —
150
- app/app.py mounts the ASGI app from get_asgi_app() directly.
151
 
152
- Interceptor point for SSE traffic:
153
- Add auth, rate limiting, logging here before reaching MCP.
 
 
 
154
  """
155
- if _mcp is None:
156
- logger.error("MCP not initialized — call initialize() first.")
157
- from quart import jsonify
158
- return jsonify({"error": "MCP not initialized"}), 503
159
-
160
- # --- Interceptor hooks (uncomment as needed) ---
161
- # logger.debug(f"MCP SSE request: {request.method} {request.path}")
162
- # await _check_auth(request)
163
- # await _rate_limit(request)
164
- # await _log_payload(request)
165
-
166
- return await _mcp.handle_sse(request)
 
 
167
 
168
 
169
  # =============================================================================
@@ -276,92 +287,344 @@ def _register_system_tools(mcp) -> None:
276
  "active_search_providers": providers.list_active_search(),
277
  "active_tools": tools.list_all(),
278
  "available_models": models.list_all(),
 
279
  }
280
 
281
  logger.info("Tool registered: list_active_tools")
282
 
283
  @mcp.tool()
284
- def health_check() -> Dict[str, str]:
285
  """
286
- Health check endpoint for HuggingFace Spaces and monitoring systems.
 
287
 
288
  Returns:
289
- Dict with service status and active transport.
290
  """
291
- return {
292
- "status": "ok",
293
- "service": "Universal MCP Hub",
294
- "transport": _transport,
295
- }
296
 
297
  logger.info("Tool registered: health_check")
298
 
299
  @mcp.tool()
300
- def get_model_info(model_name: str) -> Dict[str, Any]:
301
  """
302
  Get limits, costs, and capabilities for a specific model.
 
 
303
 
304
  Args:
305
  model_name: Model name as defined in .pyfun [MODELS] (e.g. 'claude-sonnet-4-6').
306
 
307
  Returns:
308
  Dict with context size, max output tokens, rate limits, costs, and capabilities.
309
- Returns empty dict if model is not configured in .pyfun.
310
  """
311
- return models.get(model_name)
 
 
 
 
312
 
313
  logger.info("Tool registered: get_model_info")
314
 
315
 
316
  # =============================================================================
317
- # DB Tools — uncomment when db_sync.py is ready
318
  # =============================================================================
319
- # def _register_db_tools(mcp) -> None:
320
- # """
321
- # Register internal SQLite query tool.
322
- # Uses db_sync.py (app/* internal SQLite) — NOT postgresql.py (Guardian-only)!
323
- #
324
- # SECURITY: Only SELECT queries are permitted.
325
- # Enforced at application level in db_sync.query() — not just in docs.
326
- # Tables accessible: hub_state, tool_cache (app/* only)
327
- # Tables blocked: users, sessions (Guardian-only, different owner)
328
- #
329
- # To enable:
330
- # 1. Uncomment this function
331
- # 2. Uncomment _register_db_tools(_mcp) in initialize()
332
- # 3. Make sure db_sync.initialize() is called in app/app.py before mcp.initialize()
333
- # """
334
- # from . import db_sync
335
- #
336
- # @mcp.tool()
337
- # async def db_query(sql: str) -> list:
338
- # """
339
- # Execute a read-only SELECT query on the internal hub state database.
340
- #
341
- # Only SELECT statements are permitted — all write operations are blocked
342
- # at the db_sync layer (not just by convention).
343
- #
344
- # Accessible tables:
345
- # hub_state — current hub runtime state (tool status, uptime, etc.)
346
- # tool_cache — cached tool results for repeated queries
347
- #
348
- # NOT accessible (Guardian-only):
349
- # users — managed by fundaments/user_handler.py
350
- # sessions — managed by fundaments/user_handler.py
351
- #
352
- # Args:
353
- # sql: SQL SELECT statement. Example: "SELECT * FROM hub_state LIMIT 10"
354
- #
355
- # Returns:
356
- # List of result rows as dicts. Empty list if no results.
357
- #
358
- # Raises:
359
- # ValueError: If statement is not a SELECT query.
360
- # RuntimeError: If db_sync is not initialized.
361
- # """
362
- # return await db_sync.query(sql)
363
- #
364
- # logger.info("Tool registered: db_query (SQLite SELECT-only, app/* tables)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
 
367
  # =============================================================================
 
37
  # db_sync.py → internal SQLite IPC (app/* state) — NOT postgresql.py!
38
  # mcp.py → registers tools only, delegates all logic to tools.py
39
  # =============================================================================
40
+ import contextlib
41
  import logging
42
  from typing import Dict, Any
43
  from . import config as app_config
44
  from . import providers
45
  from . import models
46
+ from . import pricing
47
  from . import tools
48
 
49
  logger = logging.getLogger('mcp')
 
74
  1. LLM tools → via tools.py + providers.py (key-gated)
75
  2. Search tools → via tools.py + providers.py (key-gated)
76
  3. System tools → always registered, no key required
77
+ 4. DB tools → db_sync SELECT-only (app/* tables)
78
+ 5. Persist tools → memory orchestration (works WITHOUT PostgreSQL — queue)
79
  """
80
  global _mcp, _transport, _stateless
81
 
 
109
  _register_llm_tools(_mcp)
110
  _register_search_tools(_mcp)
111
  _register_system_tools(_mcp)
112
+ _register_pricing_tools(_mcp)
113
+ _register_db_tools(_mcp) # db_sync ready — SELECT-only, app/* tables
114
+ _register_persist_tools(_mcp) # memory orchestration — queue-safe, no PG required
115
+ _register_integration_tools(_mcp) # discord_post / gsc_query — only if active in .pyfun
116
+ _register_skills_tools(_mcp) # show_skills / search_skills — read-only skill DBs
117
 
118
  logger.info(f"MCP Hub initialized. Transport: {_transport}")
119
 
 
146
 
147
 
148
  # =============================================================================
149
+ # Mount Prefixes — welche URL-Prefixe gehören der MCP-ASGI-App
150
  # =============================================================================
151
+ def mount_prefixes():
152
+ """URL prefixes the mounted ASGI app owns (used by app/app.py)."""
153
+ if _transport == "streamable-http":
154
+ return ("/mcp",)
155
+ return ("/sse", "/messages")
156
 
 
 
157
 
158
+ # =============================================================================
159
+ # Lifespan — Session-Manager für die Lebensdauer des Servers
160
+ # =============================================================================
161
+ @contextlib.asynccontextmanager
162
+ async def lifespan():
163
  """
164
+ Runs the FastMCP session manager for the lifetime of the server.
165
+
166
+ WHY: FastMCP's streamable_http_app() starts its session manager in the
167
+ Starlette lifespan. Because app/app.py mounts it behind a plain ASGI
168
+ middleware (lifespan scope goes to Quart, not to the inner app), we have
169
+ to run it ourselves — otherwise every request fails with
170
+ 'Task group is not initialized'.
171
+ Call AFTER get_asgi_app() (it creates the session manager).
172
+ """
173
+ if _mcp is not None and _transport == "streamable-http":
174
+ async with _mcp.session_manager.run():
175
+ yield
176
+ else:
177
+ yield
178
 
179
 
180
  # =============================================================================
 
287
  "active_search_providers": providers.list_active_search(),
288
  "active_tools": tools.list_all(),
289
  "available_models": models.list_all(),
290
+ "pricing_stats": pricing.stats(),
291
  }
292
 
293
  logger.info("Tool registered: list_active_tools")
294
 
295
  @mcp.tool()
296
+ async def health_check() -> Dict[str, Any]:
297
  """
298
+ Health check: hub status + PostgreSQL awake-check (short ping, also wakes a
299
+ sleeping Neon), persist queue depth, active providers, integrations.
300
 
301
  Returns:
302
+ Dict — status "ok" | "degraded" (PostgreSQL configured but not answering).
303
  """
304
+ result = await tools.health()
305
+ result["transport"] = _transport
306
+ return result
 
 
307
 
308
  logger.info("Tool registered: health_check")
309
 
310
  @mcp.tool()
311
+ async def get_model_info(model_name: str) -> Dict[str, Any]:
312
  """
313
  Get limits, costs, and capabilities for a specific model.
314
+ Costs are MERGED: .pyfun manual values win, auto-fetched values
315
+ (OpenRouter / LiteLLM) fill the gaps.
316
 
317
  Args:
318
  model_name: Model name as defined in .pyfun [MODELS] (e.g. 'claude-sonnet-4-6').
319
 
320
  Returns:
321
  Dict with context size, max output tokens, rate limits, costs, and capabilities.
322
+ Returns pricing source info. Empty dict if model unknown.
323
  """
324
+ # .pyfun [MODELS] als Basis + Auto-Pricing füllt Lücken
325
+ merged = await pricing.get_pricing(model_name)
326
+ if not merged.get("provider") and not merged.get("cost_input_per_1k"):
327
+ return merged # komplett unbekanntes Model
328
+ return merged
329
 
330
  logger.info("Tool registered: get_model_info")
331
 
332
 
333
  # =============================================================================
334
+ # Pricing Tools — auto-fetched model costs (OpenRouter + LiteLLM)
335
  # =============================================================================
336
+ def _register_pricing_tools(mcp) -> None:
337
+ """
338
+ Register pricing tools.
339
+ Answers the question nobody wants to maintain by hand:
340
+ 'Was kostet dieses Model?' — ohne [.MODELS] Kosten von Hand zu pflegen.
341
+ """
342
+
343
+ @mcp.tool()
344
+ async def get_model_pricing(model_name: str) -> Dict[str, Any]:
345
+ """
346
+ Get current pricing for a model — auto-fetched, no manual maintenance.
347
+
348
+ Merge order (highest priority first):
349
+ 1. .pyfun [MODELS] manual values — your override always wins
350
+ 2. OpenRouter public models API (all OR models, per-token USD)
351
+ 3. LiteLLM community price list (Anthropic, Gemini, OpenAI, ...)
352
+
353
+ Args:
354
+ model_name: Model name (e.g. 'claude-opus-4-6', 'gemini-2.5-flash',
355
+ 'meta-llama/llama-3.3-70b-instruct').
356
+
357
+ Returns:
358
+ Dict with cost_input_per_1k, cost_output_per_1k (USD),
359
+ context_tokens, max_output_tokens, source, pricing_note.
360
+ """
361
+ return await pricing.get_pricing(model_name)
362
+
363
+ logger.info("Tool registered: get_model_pricing")
364
+
365
+ @mcp.tool()
366
+ async def refresh_pricing() -> Dict[str, Any]:
367
+ """
368
+ Force a pricing refresh from OpenRouter + LiteLLM.
369
+ Updates the db_sync cache (TTL-gated, default 24h).
370
+
371
+ Returns:
372
+ Dict with fetched_at, per-source model counts, and total cached models.
373
+ """
374
+ return await pricing.refresh()
375
+
376
+ logger.info("Tool registered: refresh_pricing")
377
+
378
+ @mcp.tool()
379
+ async def usage_stats(group_by: str = "model", days: int = 0) -> Dict[str, Any]:
380
+ """
381
+ What did my LLM calls cost? Aggregated from the usage_log table.
382
+
383
+ Args:
384
+ group_by: "model" | "provider" | "tool" | "day". Default: "model".
385
+ days: Only the last N days (0 = all time).
386
+
387
+ Returns:
388
+ Dict with rows (calls, tokens_in, tokens_out, cost_usd, calls_unpriced per group)
389
+ and a total. cost_usd is an ESTIMATE from merged pricing; calls_unpriced counts
390
+ calls for which no price was known.
391
+ """
392
+ from . import db_sync
393
+ return await db_sync.usage_stats(group_by=group_by, days=days or None)
394
+
395
+ logger.info("Tool registered: usage_stats")
396
+
397
+ @mcp.tool()
398
+ async def openrouter_catalog(query: str = "", free_only: bool = True, limit: int = 40) -> Dict[str, Any]:
399
+ """
400
+ Real model IDs from the live OpenRouter catalog — to pick valid entries for .pyfun [MODELS]
401
+ (free models come and go; `stale_models` in health_check lists the ones that vanished).
402
+
403
+ Args:
404
+ query: Substring filter, e.g. "llama", "qwen", "gemma".
405
+ free_only: Only ":free" models (default True).
406
+ limit: Max rows (1-200).
407
+
408
+ Returns:
409
+ {in_catalog, matches, shown, models:[{id, context_tokens, cost_input_per_1k, cost_output_per_1k}]}
410
+ """
411
+ from . import pricing
412
+ return await pricing.catalog(query=query, free_only=free_only, limit=limit)
413
+
414
+ logger.info("Tool registered: openrouter_catalog")
415
+
416
+
417
+ # =============================================================================
418
+ # DB Tools — db_sync.py is ready, enabled
419
+ # =============================================================================
420
+ def _register_db_tools(mcp) -> None:
421
+ """
422
+ Register internal SQLite query tool.
423
+ Uses db_sync.py (app/* internal SQLite) — NOT postgresql.py (Guardian-only)!
424
+
425
+ SECURITY: Only a single SELECT is permitted.
426
+ Enforced in db_sync.query(): read-only connection, users/sessions/sqlite internals
427
+ rejected, max 1000 rows.
428
+ Tables accessible: hub_state, tool_cache, persist_queue, usage_log (app/* only)
429
+ Tables blocked: users, sessions (Guardian-only, different owner)
430
+
431
+ Requirements (all met):
432
+ 1. This function enabled
433
+ 2. _register_db_tools(_mcp) called in initialize()
434
+ 3. db_sync.initialize() runs in app/app.py BEFORE mcp.initialize()
435
+ """
436
+ from . import db_sync
437
+
438
+ @mcp.tool()
439
+ async def db_query(sql: str) -> list:
440
+ """
441
+ Execute a read-only SELECT query on the internal hub state database.
442
+
443
+ Only SELECT statements are permitted — all write operations are blocked
444
+ at the db_sync layer (not just by convention).
445
+
446
+ Accessible tables:
447
+ hub_state — current hub runtime state (tool status, uptime, etc.)
448
+ tool_cache — cached tool results for repeated queries
449
+ persist_queue — memory queue waiting for PostgreSQL
450
+ usage_log — tokens + estimated cost per LLM call
451
+
452
+ NOT accessible (Guardian-only):
453
+ users — managed by fundaments/user_handler.py
454
+ sessions — managed by fundaments/user_handler.py
455
+
456
+ Args:
457
+ sql: SQL SELECT statement. Example: "SELECT * FROM hub_state LIMIT 10"
458
+
459
+ Returns:
460
+ List of result rows as dicts. Empty list if no results.
461
+
462
+ Raises:
463
+ ValueError: If statement is not a SELECT query.
464
+ RuntimeError: If db_sync is not initialized.
465
+ """
466
+ return await db_sync.query(sql)
467
+
468
+ logger.info("Tool registered: db_query (SQLite SELECT-only, app/* tables)")
469
+
470
+
471
+ # =============================================================================
472
+ # Persist Tools — Gedächtnis-Orchestrierung (SQLite-Queue → PostgreSQL)
473
+ # =============================================================================
474
+ def _register_skills_tools(mcp) -> None:
475
+ """show_skills / search_skills — only if active in .pyfun AND skill DBs were found."""
476
+ from . import skills_db
477
+ if not skills_db.is_ready():
478
+ return
479
+
480
+ if "show_skills" in tools.list_all():
481
+ @mcp.tool()
482
+ async def show_skills(scope: str = "all", tag: str = "") -> Any:
483
+ """
484
+ List available skills (name, description, tags, chunk count).
485
+
486
+ Args:
487
+ scope: "all" (default) | "user" | "agent". The core skill is never listed.
488
+ tag: Optional tag filter.
489
+ """
490
+ return await tools.run("show_skills", scope=scope, tag=tag)
491
+
492
+ logger.info("Tool registered: show_skills")
493
+
494
+ if "search_skills" in tools.list_all():
495
+ @mcp.tool()
496
+ async def search_skills(query: str, top_k: int = 3, tag: str = "", scope: str = "user") -> Any:
497
+ """
498
+ Keyword search (BM25) over skills — returns the best matching chunks.
499
+
500
+ Args:
501
+ query: Search words.
502
+ top_k: 1-10 chunks. Default 3.
503
+ tag: Optional tag filter.
504
+ scope: "user" (default) | "agent" | "all". The core skill is never searchable.
505
+ """
506
+ return await tools.run("search_skills", prompt=query, top_k=top_k, tag=tag, scope=scope)
507
+
508
+ logger.info("Tool registered: search_skills")
509
+
510
+
511
+ def _register_integration_tools(mcp) -> None:
512
+ """
513
+ Optional integrations — registered ONLY if the tool is active in .pyfun AND the module is ready.
514
+ discord_post → hub posts into Discord (app/discord_api.py, [DISCORD])
515
+ gsc_query → Search Console top queries/pages (app/gsc.py, [GSC])
516
+ """
517
+ if "discord_post" in tools.list_all():
518
+ from . import discord_api
519
+ if discord_api.is_ready():
520
+ @mcp.tool()
521
+ async def discord_post(message: str, channel_id: str = "") -> Dict[str, Any]:
522
+ """
523
+ Post a message to Discord (max 2000 chars).
524
+
525
+ Args:
526
+ message: The text to post.
527
+ channel_id: Optional channel — only accepted if listed in DISCORD_ALLOWED_CHANNELS.
528
+ Empty = default channel / webhook.
529
+
530
+ Returns:
531
+ {"ok": bool, "error": str|None, "message_id": str|None}
532
+ """
533
+ return await tools.run("discord_post", prompt=message, channel_id=channel_id)
534
+
535
+ logger.info("Tool registered: discord_post")
536
+ else:
537
+ logger.info("discord_post active in .pyfun but Discord not ready — skipped.")
538
+
539
+ if "gsc_query" in tools.list_all():
540
+ from . import gsc
541
+ if gsc.is_ready():
542
+ @mcp.tool()
543
+ async def gsc_query(
544
+ property_url: str = "",
545
+ days_back: int = 30,
546
+ row_limit: int = 10,
547
+ dimension: str = "query",
548
+ ) -> Any:
549
+ """
550
+ Google Search Console: top queries (or pages) of a property, read-only.
551
+
552
+ Args:
553
+ property_url: 'sc-domain:example.com' or 'https://example.com/'. Empty = default property.
554
+ days_back: 1-365 days (data lags 2-3 days). Default 30.
555
+ row_limit: 1-100 rows. Default 10.
556
+ dimension: query | page | country | device | date. Default: query.
557
+
558
+ Returns:
559
+ List of {keyword|page|..., clicks, impressions, ctr (%), position}.
560
+ """
561
+ return await tools.run(
562
+ "gsc_query", property_url=property_url, days_back=days_back,
563
+ row_limit=row_limit, dimension=dimension,
564
+ )
565
+
566
+ logger.info("Tool registered: gsc_query")
567
+ else:
568
+ logger.info("gsc_query active in .pyfun but GSC not ready — skipped.")
569
+
570
+
571
+ def _register_persist_tools(mcp) -> None:
572
+ """
573
+ Register memory-orchestration tools.
574
+
575
+ persist_result works WITHOUT PostgreSQL: entries queue restart-safe in
576
+ the SQLite persist_queue (db_sync) and flush automatically once the
577
+ Guardian-injected PostgreSQL bridge is active. No key = queued, not crash.
578
+ """
579
+ if "persist_result" not in tools.list_all():
580
+ logger.info("persist_result not active in .pyfun — skipped.")
581
+ return
582
+
583
+ @mcp.tool()
584
+ async def persist_result(
585
+ prompt: str = None,
586
+ source: str = None,
587
+ model: str = None,
588
+ score: float = None,
589
+ ) -> Dict[str, Any]:
590
+ """
591
+ Persist a result to long-term memory (PostgreSQL).
592
+
593
+ Restart-safe: without DATABASE_URL the entry queues in SQLite and
594
+ flushes automatically once PostgreSQL is available. Never crashes.
595
+
596
+ Args:
597
+ prompt: The text/result to persist (or hub_state key via .pyfun state_read_key).
598
+ source: Which tool/component produced this entry (optional).
599
+ model: Which model produced this entry (optional).
600
+ score: Optional quality score — reserved slot, set by your own filter.
601
+
602
+ Returns:
603
+ Dict with status ('persisted' | 'queued'), target_table, queue_depth.
604
+ """
605
+ return await tools.run(
606
+ tool_name="persist_result",
607
+ prompt=prompt,
608
+ source=source,
609
+ model=model,
610
+ score=score,
611
+ )
612
+
613
+ logger.info("Tool registered: persist_result (queue-safe memory orchestration)")
614
+
615
+ @mcp.tool()
616
+ async def persist_status() -> Dict[str, Any]:
617
+ """
618
+ Show memory orchestration status: queue depth, oldest entry,
619
+ failed attempts, and whether the PostgreSQL bridge is active.
620
+
621
+ Returns:
622
+ Dict with queue stats — observability for the memory pipeline.
623
+ """
624
+ from . import db_sync
625
+ return await db_sync.persist_queue_stats()
626
+
627
+ logger.info("Tool registered: persist_status")
628
 
629
 
630
  # =============================================================================
app/models.py CHANGED
@@ -39,8 +39,10 @@ def initialize() -> None:
39
 
40
 
41
  def get(model_name: str) -> dict:
42
- """Get model config by name."""
43
- return _registry.get(model_name, {})
 
 
44
 
45
 
46
  def get_limit(model_name: str, key: str, default=None):
 
39
 
40
 
41
  def get(model_name: str) -> dict:
42
+ """Get model config by name. Falls back to raw .pyfun if not initialized yet."""
43
+ if _registry:
44
+ return _registry.get(model_name, {})
45
+ return config.get_models().get(model_name, {})
46
 
47
 
48
  def get_limit(model_name: str, key: str, default=None):
app/pricing.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # app/pricing.py
3
+ # Auto Model Pricing — weil niemand Lust hat, Kosten von Hand zu pflegen.
4
+ # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
5
+ # Copyright 2026 - Volkan Kücükbudak
6
+ # Apache License V. 2 + ESOL 1.1
7
+ # =============================================================================
8
+ # ARCHITECTURE NOTE:
9
+ # This file lives exclusively in app/ and is ONLY started by app/app.py.
10
+ # NO direct access to fundaments/*, .env, or Guardian (main.py).
11
+ # All config comes from app/.pyfun [PRICING] via app/config.py.
12
+ #
13
+ # WHY THIS EXISTS:
14
+ # Not every provider has a pricing API (Anthropic & Google: none).
15
+ # Manually maintaining cost_input_per_1k in [MODELS] sucks. Solution:
16
+ # two free public sources cover almost everything, .pyfun stays the override.
17
+ #
18
+ # SOURCES (priority: .pyfun manual > OpenRouter > LiteLLM):
19
+ # 1. .pyfun [MODELS] → your values ALWAYS win (manual override)
20
+ # 2. OpenRouter /api/v1/models → public, no key. Pricing per token for all
21
+ # OR models + context_length + max_completion_tokens
22
+ # 3. LiteLLM community JSON → github.com/BerriAI/litellm main branch.
23
+ # input_cost_per_token / output_cost_per_token
24
+ # for Anthropic, Gemini, OpenAI, Mistral, ...
25
+ #
26
+ # PRINCIPLE (same as everything here):
27
+ # No network = no crash. No match = costs stay as configured.
28
+ # Server always starts — pricing refresh runs in the BACKGROUND.
29
+ #
30
+ # CACHING:
31
+ # Fetched data lands in db_sync hub_state ("pricing.cache") with TTL
32
+ # from .pyfun (default 24h). Restart within TTL = no external call.
33
+ #
34
+ # MCP TOOLS (registered in mcp.py):
35
+ # get_model_pricing(model_name) → merged cost info for one model
36
+ # refresh_pricing() → force refresh, returns source counts
37
+ # =============================================================================
38
+ import asyncio
39
+ import logging
40
+ from typing import Any, Dict, Optional
41
+
42
+ import httpx
43
+
44
+ from . import config
45
+ from . import db_sync
46
+
47
+ logger = logging.getLogger("pricing")
48
+
49
+ # =============================================================================
50
+ # Internal State
51
+ # =============================================================================
52
+ _cache: Dict[str, Dict[str, Any]] = {} # normalized_name → {cost_in_1k, cost_out_1k, context, max_out, source}
53
+ _meta: Dict[str, Any] = {} # {"fetched_at": iso, "sources": {...}, "model_count": int}
54
+ _task: Optional[asyncio.Task] = None # background refresh task
55
+ _initialized: bool = False
56
+
57
+ # Loose index: alnum-only keys for dot/dash-insensitive matching.
58
+ # Why: OpenRouter writes 'claude-opus-4.6', LiteLLM 'claude-opus-4-6',
59
+ # your .pyfun 'claude-opus-4-6'. All should find each other.
60
+ _loose_index: Dict[str, str] = {} # loose_key → normalized cache key
61
+ _loose_dirty: bool = True
62
+
63
+ _HUB_STATE_KEY = "pricing.cache"
64
+
65
+ # Known vendor prefixes used by OpenRouter / LiteLLM IDs
66
+ # ("anthropic/claude-...", "gemini/gemini-...", "meta-llama/llama-...", ...)
67
+ _VENDOR_PREFIXES = (
68
+ "anthropic/", "google/", "openai/", "meta-llama/", "mistralai/", "mistral/",
69
+ "qwen/", "nvidia/", "microsoft/", "deepseek/", "x-ai/", "xai/", "perplexity/",
70
+ "cohere/", "gryphe/", "thedrummer/", "undi95/", "cognitivecomputations/",
71
+ "gemini/", "groq/", "together_ai/", "together/", "openrouter/", "fireworks_ai/",
72
+ "azure/", "vertex_ai/", "bedrock/", "cloudflare/", "voyage/", "sambanova/",
73
+ "ai21/", "huggingface/", "ollama/", "ollama_chat/",
74
+ )
75
+
76
+
77
+ # =============================================================================
78
+ # Normalization — model IDs differ wildly between sources
79
+ # =============================================================================
80
+ def _normalize(name: str) -> str:
81
+ """
82
+ Normalize a model name for matching across sources.
83
+ 'anthropic/claude-haiku-4.5' → 'claude-haiku-4.5'
84
+ 'Claude-Haiku-4.5' → 'claude-haiku-4.5'
85
+ Dots vs dashes are kept — providers are inconsistent there by design.
86
+ """
87
+ n = (name or "").strip().lower()
88
+ for prefix in _VENDOR_PREFIXES:
89
+ if n.startswith(prefix):
90
+ n = n[len(prefix):]
91
+ break
92
+ return n
93
+
94
+
95
+ def _safe_int(value: Any) -> Optional[int]:
96
+ """int() that returns None instead of raising (LiteLLM ships doc samples
97
+ like sample_spec.max_input_tokens = 'max input tokens, if the provider specifies it')."""
98
+ try:
99
+ return int(value)
100
+ except (TypeError, ValueError):
101
+ return None
102
+
103
+
104
+ def _per_token_to_per_1k(cost: Any) -> Optional[float]:
105
+ """'0.0000015' per token → 0.0015 per 1k tokens. Returns None on garbage."""
106
+ try:
107
+ val = float(cost)
108
+ except (TypeError, ValueError):
109
+ return None
110
+ if val < 0: # negative = OpenRouter's "we pay you" marker → treat as 0
111
+ val = 0.0
112
+ return round(val * 1000, 6)
113
+
114
+
115
+ def _loose(name: str) -> str:
116
+ """Strip everything except a-z0-9 — kills dot/dash/underscore differences."""
117
+ return "".join(ch for ch in name if ch.isalnum())
118
+
119
+
120
+ def _rebuild_loose_index() -> None:
121
+ """Rebuild the alnum-only lookup index after a cache change."""
122
+ global _loose_index, _loose_dirty
123
+ _loose_index = {_loose(k): k for k in _cache}
124
+ _loose_dirty = False
125
+
126
+
127
+ def _lookup(model_name: str) -> Optional[Dict[str, Any]]:
128
+ """
129
+ Cache lookup with graceful fallback:
130
+ 1. exact normalized match ('claude-opus-4-6')
131
+ 2. loose alnum match ('claude-opus-4.6' ≈ 'claude-opus-4-6')
132
+ """
133
+ norm = _normalize(model_name)
134
+ hit = _cache.get(norm)
135
+ if hit:
136
+ return hit
137
+ if _loose_dirty:
138
+ _rebuild_loose_index()
139
+ loose_key = _loose_index.get(_loose(norm))
140
+ if loose_key:
141
+ return _cache.get(loose_key)
142
+ return None
143
+
144
+
145
+ # =============================================================================
146
+ # Source Fetchers — each returns {normalized_name: entry}
147
+ # =============================================================================
148
+ async def _fetch_openrouter(url: str):
149
+ """
150
+ OpenRouter public models endpoint — no key required.
151
+ https://openrouter.ai/api/v1/models → data[].{id, pricing{prompt,completion}, context_length}
152
+
153
+ Returns (entries, raw_ids): raw_ids = every real OpenRouter model ID (lowercase) —
154
+ used by check_models() to spot stale/removed models in .pyfun [MODELS].
155
+ """
156
+ out: Dict[str, Dict[str, Any]] = {}
157
+ raw_ids = []
158
+ async with httpx.AsyncClient() as client:
159
+ r = await client.get(url, timeout=30)
160
+ r.raise_for_status()
161
+ payload = r.json()
162
+
163
+ for m in payload.get("data", []):
164
+ model_id = m.get("id", "")
165
+ if not model_id:
166
+ continue
167
+ raw_ids.append(model_id.lower())
168
+ pricing = m.get("pricing", {}) or {}
169
+ entry: Dict[str, Any] = {"source": "openrouter"}
170
+
171
+ cost_in = _per_token_to_per_1k(pricing.get("prompt"))
172
+ cost_out = _per_token_to_per_1k(pricing.get("completion"))
173
+ if cost_in is not None:
174
+ entry["cost_input_per_1k"] = cost_in
175
+ if cost_out is not None:
176
+ entry["cost_output_per_1k"] = cost_out
177
+ if m.get("context_length"):
178
+ ctx = _safe_int(m["context_length"])
179
+ if ctx:
180
+ entry["context_tokens"] = ctx
181
+
182
+ # max_completion_tokens is nested under top_provider in OR payloads
183
+ max_out = _safe_int((m.get("top_provider") or {}).get("max_completion_tokens"))
184
+ if max_out:
185
+ entry["max_output_tokens"] = max_out
186
+
187
+ out[_normalize(model_id)] = entry
188
+ return out, raw_ids
189
+
190
+
191
+ async def _fetch_litellm(url: str) -> Dict[str, Dict[str, Any]]:
192
+ """
193
+ LiteLLM community price list — no key required.
194
+ github.com/BerriAI/litellm → model_prices_and_context_window.json
195
+ Keys like 'claude-sonnet-4-5', 'gemini/gemini-2.5-flash', 'gpt-4o'.
196
+ """
197
+ out: Dict[str, Dict[str, Any]] = {}
198
+ async with httpx.AsyncClient() as client:
199
+ r = await client.get(url, timeout=30)
200
+ r.raise_for_status()
201
+ payload = r.json()
202
+
203
+ for model_id, info in payload.items():
204
+ if not isinstance(info, dict):
205
+ continue # skip metadata keys like "additional_vendor_names"
206
+ entry: Dict[str, Any] = {"source": "litellm"}
207
+
208
+ cost_in = _per_token_to_per_1k(info.get("input_cost_per_token"))
209
+ cost_out = _per_token_to_per_1k(info.get("output_cost_per_token"))
210
+ if cost_in is not None:
211
+ entry["cost_input_per_1k"] = cost_in
212
+ if cost_out is not None:
213
+ entry["cost_output_per_1k"] = cost_out
214
+ if info.get("max_input_tokens"):
215
+ ctx = _safe_int(info["max_input_tokens"])
216
+ if ctx:
217
+ entry["context_tokens"] = ctx
218
+ if info.get("max_output_tokens"):
219
+ max_out = _safe_int(info["max_output_tokens"])
220
+ if max_out:
221
+ entry["max_output_tokens"] = max_out
222
+
223
+ out[_normalize(model_id)] = entry
224
+ return out
225
+
226
+
227
+ # =============================================================================
228
+ # Cache — db_sync hub_state, TTL-gated
229
+ # =============================================================================
230
+ def _cache_is_fresh() -> bool:
231
+ if not _cache or not _meta.get("fetched_at"):
232
+ return False
233
+ from datetime import datetime, timezone
234
+ try:
235
+ fetched = datetime.fromisoformat(_meta["fetched_at"])
236
+ except (ValueError, TypeError):
237
+ return False
238
+ ttl_hours = float(config.get_pricing().get("PRICING_TTL_HOURS", "24"))
239
+ age = datetime.now(timezone.utc) - fetched
240
+ return age.total_seconds() < ttl_hours * 3600
241
+
242
+
243
+ async def _load_cache_from_db() -> None:
244
+ """Read pricing cache from db_sync — never raises."""
245
+ global _cache, _meta, _loose_dirty
246
+ try:
247
+ stored = await db_sync.read(_HUB_STATE_KEY, default=None)
248
+ if isinstance(stored, dict) and stored.get("models"):
249
+ _cache = stored["models"]
250
+ _meta = stored.get("meta", {})
251
+ _loose_dirty = True
252
+ logger.info(
253
+ f"Pricing cache loaded from db_sync: {len(_cache)} models "
254
+ f"(fetched_at: {_meta.get('fetched_at', 'unknown')})"
255
+ )
256
+ except Exception as e:
257
+ logger.warning(f"Pricing cache load failed (continuing without): {type(e).__name__}")
258
+
259
+
260
+ async def _save_cache_to_db() -> None:
261
+ """Write pricing cache to db_sync — never raises."""
262
+ try:
263
+ await db_sync.write(_HUB_STATE_KEY, {"models": _cache, "meta": _meta})
264
+ except Exception as e:
265
+ logger.warning(f"Pricing cache save failed: {type(e).__name__}")
266
+
267
+
268
+ # =============================================================================
269
+ # Refresh — fetch sources, merge, cache
270
+ # =============================================================================
271
+ async def refresh() -> Dict[str, Any]:
272
+ """
273
+ Fetch all pricing sources, rebuild the auto-pricing cache.
274
+ Never raises — a failing source is just skipped (no fetch = no crash).
275
+ """
276
+ global _cache, _meta, _loose_dirty
277
+ from datetime import datetime, timezone
278
+
279
+ cfg = config.get_pricing()
280
+ or_url = cfg.get("OPENROUTER_MODELS_URL", "https://openrouter.ai/api/v1/models")
281
+ llm_url = cfg.get(
282
+ "LITELLM_PRICES_URL",
283
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
284
+ )
285
+
286
+ merged: Dict[str, Dict[str, Any]] = {}
287
+ sources: Dict[str, int] = {}
288
+ or_ids: list = []
289
+
290
+ # --- Source 1: OpenRouter ---
291
+ try:
292
+ or_models, or_ids = await _fetch_openrouter(or_url)
293
+ merged.update(or_models)
294
+ sources["openrouter"] = len(or_models)
295
+ logger.info(f"Pricing source OpenRouter: {len(or_models)} models.")
296
+ except Exception as e:
297
+ sources["openrouter"] = 0
298
+ logger.warning(f"Pricing source OpenRouter failed: {type(e).__name__} — skipped.")
299
+
300
+ # --- Source 2: LiteLLM (fills gaps — never overwrites OpenRouter) ---
301
+ try:
302
+ llm_models = await _fetch_litellm(llm_url)
303
+ for name, entry in llm_models.items():
304
+ if name not in merged:
305
+ merged[name] = entry
306
+ sources["litellm"] = len(llm_models)
307
+ logger.info(f"Pricing source LiteLLM: {len(llm_models)} models.")
308
+ except Exception as e:
309
+ sources["litellm"] = 0
310
+ logger.warning(f"Pricing source LiteLLM failed: {type(e).__name__} — skipped.")
311
+
312
+ if merged:
313
+ _cache = merged
314
+ _meta = {
315
+ "fetched_at": datetime.now(timezone.utc).isoformat(),
316
+ "sources": sources,
317
+ "model_count": len(merged),
318
+ # keep the last known OpenRouter ID list if this fetch failed
319
+ "openrouter_ids": or_ids or _meta.get("openrouter_ids", []),
320
+ }
321
+ _loose_dirty = True
322
+ await _save_cache_to_db()
323
+
324
+ stale = check_models()
325
+ if stale:
326
+ logger.warning(f"Models in .pyfun not found in OpenRouter catalog (stale/removed?): {stale}")
327
+
328
+ return {
329
+ "fetched_at": _meta.get("fetched_at"),
330
+ "sources": sources,
331
+ "models_cached": len(_cache),
332
+ "stale_models": stale,
333
+ }
334
+
335
+
336
+ # =============================================================================
337
+ # Initialization — called by app/app.py, NON-BLOCKING
338
+ # =============================================================================
339
+ async def initialize() -> None:
340
+ """
341
+ Prepare auto-pricing. Loads the db_sync cache if fresh; otherwise kicks
342
+ off a BACKGROUND refresh (asyncio.create_task) — the server never waits
343
+ for external pricing APIs. No network = no crash.
344
+ """
345
+ global _initialized, _task
346
+ if _initialized:
347
+ return
348
+
349
+ cfg = config.get_pricing()
350
+ enabled = cfg.get("PRICING_ENABLED", "true").lower() == "true"
351
+
352
+ if not enabled:
353
+ logger.info("Auto-pricing disabled in .pyfun [PRICING] — .pyfun [MODELS] values only.")
354
+ _initialized = True
355
+ return
356
+
357
+ await _load_cache_from_db()
358
+
359
+ if _cache_is_fresh():
360
+ logger.info("Auto-pricing cache fresh — no external fetch needed.")
361
+ else:
362
+ logger.info("Auto-pricing cache stale/empty — background refresh started.")
363
+ _task = asyncio.create_task(_background_refresh())
364
+
365
+ _initialized = True
366
+
367
+
368
+ async def _background_refresh() -> None:
369
+ """Runs refresh() and swallows everything — background task must never crash the app."""
370
+ try:
371
+ await refresh()
372
+ except Exception as e:
373
+ logger.warning(f"Pricing background refresh failed (non-fatal): {type(e).__name__}")
374
+
375
+
376
+ # =============================================================================
377
+ # Public API — merged view: .pyfun manual values WIN over auto-fetched
378
+ # =============================================================================
379
+ async def get_pricing(model_name: str) -> Dict[str, Any]:
380
+ """
381
+ Returns merged pricing info for one model.
382
+
383
+ Merge order (highest priority first):
384
+ 1. .pyfun [MODELS.<model>] → your manual values always win
385
+ 2. auto-fetched cache → OpenRouter / LiteLLM
386
+ 3. nothing → costs omitted, source: "unknown"
387
+
388
+ Returns dict with: model, provider (if known from .pyfun),
389
+ cost_input_per_1k, cost_output_per_1k (USD), context_tokens,
390
+ max_output_tokens, source ("pyfun" | "openrouter" | "litellm" | "unknown"),
391
+ pricing_note.
392
+ """
393
+ from . import models as models_mod
394
+
395
+ result: Dict[str, Any] = {"model": model_name}
396
+ notes = []
397
+
398
+ # --- Layer 1: .pyfun manual config (models.py reads [MODELS]) ---
399
+ manual = models_mod.get(model_name)
400
+ if manual:
401
+ result["provider"] = manual.get("provider", "")
402
+ for key in ("cost_input_per_1k", "cost_output_per_1k",
403
+ "context_tokens", "max_output_tokens",
404
+ "requests_per_min", "requests_per_day", "capabilities"):
405
+ if manual.get(key):
406
+ value = manual[key]
407
+ if key.startswith("cost_"):
408
+ # .pyfun-Werte sind Strings ("0.015") — Auto-Werte sind float.
409
+ # Normalisieren, damit die API immer Zahlen liefert.
410
+ try:
411
+ value = round(float(value), 6)
412
+ except (TypeError, ValueError):
413
+ pass
414
+ result[key] = value
415
+ if manual.get("cost_input_per_1k") or manual.get("cost_output_per_1k"):
416
+ result["source"] = "pyfun"
417
+ notes.append(".pyfun manual values (override)")
418
+ else:
419
+ notes.append(".pyfun entry has no costs — auto values used")
420
+
421
+ # --- Layer 2: auto-fetched cache fills the gaps ---
422
+ auto = _lookup(model_name)
423
+ if auto:
424
+ for key in ("cost_input_per_1k", "cost_output_per_1k",
425
+ "context_tokens", "max_output_tokens"):
426
+ if key not in result and auto.get(key) is not None:
427
+ result[key] = auto[key]
428
+ if "source" not in result:
429
+ result["source"] = auto.get("source", "auto")
430
+ notes.append(f"auto-fetched from {auto.get('source', 'unknown')}")
431
+ elif "source" not in result:
432
+ result["source"] = "unknown"
433
+ notes.append("no auto source matched this model name")
434
+
435
+ # --- Stale hint ---
436
+ if _cache and not _cache_is_fresh():
437
+ notes.append("cache stale — refresh_pricing() to update")
438
+
439
+ result["pricing_note"] = "; ".join(notes) if notes else "ok"
440
+ return result
441
+
442
+
443
+ async def catalog(query: str = "", free_only: bool = False, limit: int = 50) -> Dict[str, Any]:
444
+ """
445
+ Real model IDs of the live OpenRouter catalog (for picking valid [MODELS] entries).
446
+ Fetches the catalog once if it is not loaded yet. Read-only, no key needed.
447
+ """
448
+ if not _meta.get("openrouter_ids"):
449
+ await refresh()
450
+ ids = _meta.get("openrouter_ids") or []
451
+ q = (query or "").strip().lower()
452
+ hits = sorted(i for i in ids if (not free_only or i.endswith(":free")) and (not q or q in i))
453
+ limit = max(1, min(int(limit or 50), 200))
454
+ rows = []
455
+ for i in hits[:limit]:
456
+ info = _lookup(i) or {}
457
+ rows.append({"id": i, "context_tokens": info.get("context_tokens"),
458
+ "cost_input_per_1k": info.get("cost_input_per_1k"),
459
+ "cost_output_per_1k": info.get("cost_output_per_1k")})
460
+ return {"in_catalog": len(ids), "matches": len(hits), "shown": len(rows), "models": rows}
461
+
462
+
463
+ def check_models() -> list:
464
+ """
465
+ .pyfun [MODELS] entries with provider "openrouter" whose ID is NOT in the live
466
+ OpenRouter catalog (free models come and go). Empty list if the catalog is unknown.
467
+ """
468
+ ids = set(_meta.get("openrouter_ids") or [])
469
+ if not ids:
470
+ return []
471
+ return sorted(
472
+ name for name, cfg in config.get_models().items()
473
+ if cfg.get("provider") == "openrouter" and name.lower() not in ids
474
+ )
475
+
476
+
477
+ def max_output_for(model_name: str) -> Optional[int]:
478
+ """
479
+ Max output tokens for a model (sync, for clamping): manual .pyfun value wins,
480
+ then auto-fetched. None if unknown → no clamp.
481
+ """
482
+ from . import models as models_mod
483
+ manual = _safe_int((models_mod.get(model_name) or {}).get("max_output_tokens"))
484
+ if manual:
485
+ return manual
486
+ auto = _lookup(model_name) if _cache else None
487
+ return _safe_int(auto.get("max_output_tokens")) if auto else None
488
+
489
+
490
+ async def cost_for(model_name: str, tokens_in: Optional[int], tokens_out: Optional[int]) -> Optional[float]:
491
+ """
492
+ Estimated USD cost of one call from merged pricing (manual > auto).
493
+ None if no price is known. Anthropic cache read/write surcharges are not modeled.
494
+ """
495
+ info = await get_pricing(model_name)
496
+ c_in, c_out = info.get("cost_input_per_1k"), info.get("cost_output_per_1k")
497
+ if c_in is None and c_out is None:
498
+ return None
499
+ try:
500
+ return round(((tokens_in or 0) / 1000) * float(c_in or 0)
501
+ + ((tokens_out or 0) / 1000) * float(c_out or 0), 8)
502
+ except (TypeError, ValueError):
503
+ return None
504
+
505
+
506
+ def is_ready() -> bool:
507
+ """True if pricing cache holds at least one model."""
508
+ return bool(_cache)
509
+
510
+
511
+ def stats() -> Dict[str, Any]:
512
+ """Cache stats for list_active_tools / debugging."""
513
+ return {
514
+ "models_cached": len(_cache),
515
+ "fetched_at": _meta.get("fetched_at"),
516
+ "sources": _meta.get("sources", {}),
517
+ "fresh": _cache_is_fresh(),
518
+ "stale_models": check_models(),
519
+ }
520
+
521
+
522
+ # =============================================================================
523
+ # Direct execution guard
524
+ # =============================================================================
525
+ if __name__ == "__main__":
526
+ print("WARNING: Run via main.py → app.py, not directly.")
app/providers.py CHANGED
@@ -1,6 +1,6 @@
1
  # =============================================================================
2
  # app/providers.py
3
- # 09.03.2026 | updated 23.03.2026
4
  # LLM + Search Provider Registry + Fallback Chain
5
  # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
6
  # Copyright 2026 - Volkan Kücükbudak
@@ -15,12 +15,33 @@
15
  # PROVIDER PRINCIPLE:
16
  # No key = no provider = no tool = no crash.
17
  # Server always starts, just with fewer providers.
18
- # Adding a new provider = update .pyfun + add class here. Never touch mcp.py!
 
 
 
 
 
 
 
 
19
  #
20
  # FALLBACK CHAIN:
21
  # Defined in .pyfun per provider via fallback_to field.
22
  # anthropic → fails → gemini → fails → openrouter → fails → RuntimeError
23
  # Visited set prevents infinite loops.
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  #
25
  # SECURITY NOTE:
26
  # API keys are NEVER logged or included in exception messages.
@@ -28,25 +49,9 @@
28
  # and safe_url (query params stripped) are ever exposed in logs.
29
  #
30
  # CACHING NOTE:
31
- # Anthropic → prompt_caching (cache_control: ephemeral)
32
- # Requires anthropic-beta: prompt-caching-2024-07-31 header.
33
- # Caches system prompt + long user prompts (>1024 tokens estimated).
34
- # Saves up to 90% input token costs on repeated context.
35
  # Enable per provider in .pyfun: supports_cache = "true"
36
- #
37
- # Gemini → Implicit caching (automatic, no extra API call needed)
38
- # Google automatically caches repeated prompt prefixes server-side.
39
- # No code change needed — Gemini handles it transparently.
40
- # Explicit Context Caching API exists but requires separate cache management
41
- # and is only worth it for very large static contexts (32k+ tokens).
42
- # Enable per provider in .pyfun: supports_cache = "true"
43
- # (currently used as log hint only for Gemini — implicit cache is always on)
44
- #
45
- # HOW TO ADD A NEW LLM PROVIDER — 3 steps, nothing else to touch:
46
- # 1. Add class below (copy a dummy, implement complete())
47
- # 2. Register name → class in _PROVIDER_CLASSES dict
48
- # 3. Add [LLM_PROVIDER.yourprovider] block in app/.pyfun
49
- # → env_key, base_url, default_model, fallback_to
50
  #
51
  # DEPENDENCY CHAIN (app/* only, no fundaments!):
52
  # config.py → parses app/.pyfun — single source of truth
@@ -56,14 +61,92 @@
56
  # =============================================================================
57
 
58
  import os
 
 
 
59
  import logging
60
  import httpx
 
 
 
61
 
62
  from . import config
 
 
 
 
63
  logging.getLogger("httpx").setLevel(logging.WARNING)
64
  logger = logging.getLogger("providers")
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  # =============================================================================
68
  # SECTION 1 — Base Provider
69
  # Shared HTTP logic — implemented ONCE, reused by all providers.
@@ -73,9 +156,11 @@ class BaseProvider:
73
  """
74
  Base class for all LLM providers.
75
  Subclasses only implement complete() — HTTP logic lives here.
 
76
  """
77
  def __init__(self, name: str, cfg: dict):
78
  self.name = name
 
79
  self.key = os.getenv(cfg.get("env_key", ""))
80
  self.base_url = cfg.get("base_url", "")
81
  self.fallback = cfg.get("fallback_to", "")
@@ -89,14 +174,15 @@ class BaseProvider:
89
  else "***"
90
  )
91
 
92
- async def complete(self, prompt: str, model: str, max_tokens: int) -> str:
 
93
  """Override in each provider subclass."""
94
  raise NotImplementedError
95
 
96
  async def _post(self, url: str, headers: dict, payload: dict) -> dict:
97
  """
98
  Shared HTTP POST — used by all providers.
99
- Raises RuntimeError with sanitized message on non-2xx responses.
100
  API keys are never included in raised exceptions or log output.
101
  """
102
  safe_url = url.split("?")[0] # strip query params (may contain API keys)
@@ -112,8 +198,9 @@ class BaseProvider:
112
  r.raise_for_status()
113
  except httpx.HTTPStatusError as e:
114
  # Sanitize: only status code + safe_url, never headers or body
115
- raise RuntimeError(
116
- f"HTTP {e.response.status_code} from {safe_url}"
 
117
  ) from None
118
  return r.json()
119
 
@@ -123,40 +210,74 @@ class BaseProvider:
123
  # Only the API-specific parsing logic differs per provider.
124
  # =============================================================================
125
 
126
- # --- SmolLM2 (Custom Assistant Space) ----------------------------------------
127
- class SmolLMProvider(BaseProvider):
128
  """
129
- SmolLM2 Custom Assistant Space — OpenAI-compatible, ADI routing included.
130
- Free tier on HF Spaces (CPU). Falls back to next provider on 503.
131
- Response includes extra 'adi' field with score + decision (ignored by hub).
132
- Deploy: https://github.com/VolkanSah/Multi-LLM-API-Gateway (smollm-space/)
133
 
134
- .pyfun block:
135
- [LLM_PROVIDER.smollm]
136
  active = "true"
137
- base_url = "https://codey-lab-SmolLM2-customs.hf.space/v1"
138
- env_key = "SMOLLM_API_KEY"
139
- default_model = "smollm2-360m"
140
- models = "smollm2-360m, codey-lab/model.universal-mcp-hub"
141
- fallback_to = "anthropic"
142
- [LLM_PROVIDER.smollm_END]
 
 
 
 
 
 
143
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- async def complete(self, prompt: str, model: str = None, max_tokens: int = 150) -> str:
146
  data = await self._post(
147
- f"{self.base_url}/chat/completions",
148
- headers={
149
- "Authorization": f"Bearer {self.key}",
150
- "X-IP-Token": self.key,
151
- "content-type": "application/json",
152
- },
153
  payload={
154
  "model": model or self.model,
155
  "max_tokens": max_tokens,
156
- "messages": [{"role": "user", "content": prompt}],
157
  },
158
  )
159
- return data["choices"][0]["message"]["content"]
 
 
 
 
160
 
161
 
162
  # --- Anthropic ----------------------------------------------------------------
@@ -166,20 +287,22 @@ class AnthropicProvider(BaseProvider):
166
 
167
  Prompt Caching (supports_cache = "true" in .pyfun):
168
  Uses cache_control: ephemeral on system prompt and long user prompts.
169
- Requires anthropic-beta: prompt-caching-2024-07-31 header.
170
  Cache TTL: 5 minutes, extended on each cache hit.
171
  Min tokens to cache: ~1024 (Anthropic requirement).
172
  Cost: cache write ~25% more, cache read ~90% less than normal input.
173
 
 
 
174
  .pyfun block:
175
  [LLM_PROVIDER.anthropic]
176
- active = "true"
177
- base_url = "https://api.anthropic.com/v1"
178
- env_key = "ANTHROPIC_API_KEY"
 
179
  api_version_header = "2023-06-01"
180
- default_model = "claude-haiku-4-5"
181
- supports_cache = "true"
182
- fallback_to = "gemini"
183
  [LLM_PROVIDER.anthropic_END]
184
  """
185
 
@@ -187,35 +310,26 @@ class AnthropicProvider(BaseProvider):
187
  _CHARS_PER_TOKEN = 4
188
  _CACHE_MIN_TOKENS = 1024
189
 
 
 
 
 
190
  def _is_cacheable(self, text: str) -> bool:
191
  """Estimate if text is long enough to benefit from caching."""
192
  return len(text) >= self._CACHE_MIN_TOKENS * self._CHARS_PER_TOKEN
193
 
194
- async def complete(
195
- self,
196
- prompt: str,
197
- model: str = None,
198
- max_tokens: int = 1024,
199
- system: str = None,
200
- ) -> str:
201
- cfg = config.get_active_llm_providers().get("anthropic", {})
202
-
203
  headers = {
204
  "x-api-key": self.key,
205
- "anthropic-version": cfg.get("api_version_header", "2023-06-01"),
206
  "content-type": "application/json",
207
  }
208
 
209
- # --- Build user content ---
210
- # Add cache_control if caching enabled + prompt long enough
211
  if self.supports_cache and self._is_cacheable(prompt):
212
- user_content = [
213
- {
214
- "type": "text",
215
- "text": prompt,
216
- "cache_control": {"type": "ephemeral"},
217
- }
218
- ]
219
  headers["anthropic-beta"] = "prompt-caching-2024-07-31"
220
  logger.debug("Anthropic: prompt cache_control applied to user message.")
221
  else:
@@ -230,20 +344,21 @@ class AnthropicProvider(BaseProvider):
230
  # --- Optional system prompt with cache_control ---
231
  if system:
232
  if self.supports_cache and self._is_cacheable(system):
233
- payload["system"] = [
234
- {
235
- "type": "text",
236
- "text": system,
237
- "cache_control": {"type": "ephemeral"},
238
- }
239
- ]
240
  headers["anthropic-beta"] = "prompt-caching-2024-07-31"
241
  logger.debug("Anthropic: prompt cache_control applied to system prompt.")
242
  else:
243
  payload["system"] = system
244
 
245
  data = await self._post(f"{self.base_url}/messages", headers, payload)
246
- return data["content"][0]["text"]
 
 
 
 
 
 
247
 
248
 
249
  # --- Gemini ------------------------------------------------------------------
@@ -251,222 +366,71 @@ class GeminiProvider(BaseProvider):
251
  """
252
  Google Gemini API — generateContent endpoint.
253
 
254
- Implicit Caching (always active on Gemini side, no code needed):
255
- Google automatically caches repeated prompt prefixes server-side.
256
- No extra API call, no cache key, no TTL management needed.
257
- Just send the same prompt structure and Gemini handles the rest.
258
- supports_cache = "true" in .pyfun logs cache hint only.
259
-
260
- Explicit Context Caching (NOT implemented here — when to use it):
261
- Only worth the extra API complexity for very large static contexts
262
- (32k+ tokens, e.g. large documents sent on every request).
263
- Requires separate POST to /cachedContents, returns a cache_name,
264
- which is then referenced in generateContent as cachedContent.name.
265
- Implement as a separate tool (cache_create / cache_use) when needed.
266
 
267
  .pyfun block:
268
  [LLM_PROVIDER.gemini]
269
  active = "true"
 
270
  base_url = "https://generativelanguage.googleapis.com/v1beta"
271
  env_key = "GEMINI_API_KEY"
272
- default_model = "gemini-2.0-flash"
273
  supports_cache = "true"
274
  fallback_to = "openrouter"
275
  [LLM_PROVIDER.gemini_END]
276
  """
277
 
278
- async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
 
279
  m = model or self.model
280
  safe_url = f"{self.base_url}/models/{m}:generateContent"
281
 
282
  if self.supports_cache:
283
  logger.debug(f"Gemini: implicit caching active for model {m} (server-side, automatic).")
284
 
 
 
 
 
 
 
 
285
  async with httpx.AsyncClient() as client:
286
  r = await client.post(
287
  safe_url,
288
  params={"key": self.key}, # key in query param, never in logs
289
- json={
290
- "contents": [{"parts": [{"text": prompt}]}],
291
- "generationConfig": {"maxOutputTokens": max_tokens},
292
- },
293
  timeout=self.timeout,
294
  )
295
  try:
296
  r.raise_for_status()
297
  except httpx.HTTPStatusError as e:
298
- raise RuntimeError(
299
- f"HTTP {e.response.status_code} from {safe_url}"
 
300
  ) from None
301
- return r.json()["candidates"][0]["content"]["parts"][0]["text"]
302
-
303
-
304
- # --- OpenRouter ---------------------------------------------------------------
305
- class OpenRouterProvider(BaseProvider):
306
- """OpenRouter API — OpenAI-compatible chat completions endpoint.
307
-
308
- Required headers: HTTP-Referer + X-Title (required by OpenRouter for
309
- free models and rate limit attribution).
310
- """
311
 
312
- async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
313
- data = await self._post(
314
- f"{self.base_url}/chat/completions",
315
- headers={
316
- "Authorization": f"Bearer {self.key}",
317
- "HTTP-Referer": os.getenv("APP_URL", "https://huggingface.co"),
318
- "X-Title": os.getenv("HUB_NAME", "Universal AI Hub"), # required!
319
- "content-type": "application/json",
320
- },
321
- payload={
322
- "model": model or self.model,
323
- "max_tokens": max_tokens,
324
- "messages": [{"role": "user", "content": prompt}],
325
- },
326
- )
327
- return data["choices"][0]["message"]["content"]
328
-
329
-
330
- # --- HuggingFace --------------------------------------------------------------
331
- class HuggingFaceProvider(BaseProvider):
332
- """HuggingFace Inference API — OpenAI-compatible serverless endpoint.
333
-
334
- base_url in .pyfun: https://api-inference.huggingface.co/v1
335
- Model goes in payload, not in URL.
336
- Free tier: max ~8B models. PRO required for 70B+.
337
- """
338
-
339
- async def complete(self, prompt: str, model: str = None, max_tokens: int = 512) -> str:
340
- m = model or self.model
341
- data = await self._post(
342
- f"{self.base_url}/chat/completions",
343
- headers={
344
- "Authorization": f"Bearer {self.key}",
345
- "content-type": "application/json",
346
- },
347
- payload={
348
- "model": m,
349
- "max_tokens": max_tokens,
350
- "messages": [{"role": "user", "content": prompt}],
351
- },
352
- )
353
- return data["choices"][0]["message"]["content"]
354
-
355
-
356
- # =============================================================================
357
- # DUMMY PROVIDERS — copy, uncomment, adapt
358
- # Steps: (1) uncomment class (2) add to _PROVIDER_CLASSES (3) add to .pyfun
359
- # =============================================================================
360
-
361
- # --- OpenAI -------------------------------------------------------------------
362
- # .pyfun block to add:
363
- #
364
- # [LLM_PROVIDER.openai]
365
- # active = "true"
366
- # base_url = "https://api.openai.com/v1"
367
- # env_key = "OPENAI_API_KEY"
368
- # default_model = "gpt-4o-mini"
369
- # models = "gpt-4o, gpt-4o-mini, gpt-3.5-turbo"
370
- # fallback_to = ""
371
- # [LLM_PROVIDER.openai_END]
372
- #
373
- # class OpenAIProvider(BaseProvider):
374
- # """OpenAI API — OpenAI-compatible chat completions endpoint."""
375
- #
376
- # async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
377
- # data = await self._post(
378
- # f"{self.base_url}/chat/completions",
379
- # headers={
380
- # "Authorization": f"Bearer {self.key}",
381
- # "content-type": "application/json",
382
- # },
383
- # payload={
384
- # "model": model or self.model,
385
- # "max_tokens": max_tokens,
386
- # "messages": [{"role": "user", "content": prompt}],
387
- # },
388
- # )
389
- # return data["choices"][0]["message"]["content"]
390
-
391
-
392
- # --- Mistral ------------------------------------------------------------------
393
- # .pyfun block to add:
394
- #
395
- # [LLM_PROVIDER.mistral]
396
- # active = "true"
397
- # base_url = "https://api.mistral.ai/v1"
398
- # env_key = "MISTRAL_API_KEY"
399
- # default_model = "mistral-large-latest"
400
- # models = "mistral-large-latest, mistral-small-latest, codestral-latest"
401
- # fallback_to = ""
402
- # [LLM_PROVIDER.mistral_END]
403
- #
404
- # class MistralProvider(BaseProvider):
405
- # """Mistral AI API — OpenAI-compatible chat completions endpoint."""
406
- #
407
- # async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
408
- # data = await self._post(
409
- # f"{self.base_url}/chat/completions",
410
- # headers={
411
- # "Authorization": f"Bearer {self.key}",
412
- # "content-type": "application/json",
413
- # },
414
- # payload={
415
- # "model": model or self.model,
416
- # "max_tokens": max_tokens,
417
- # "messages": [{"role": "user", "content": prompt}],
418
- # },
419
- # )
420
- # return data["choices"][0]["message"]["content"]
421
-
422
-
423
- # --- xAI (Grok) ---------------------------------------------------------------
424
- # .pyfun block to add:
425
- #
426
- # [LLM_PROVIDER.xai]
427
- # active = "true"
428
- # base_url = "https://api.x.ai/v1"
429
- # env_key = "XAI_API_KEY"
430
- # default_model = "grok-3-mini"
431
- # models = "grok-3, grok-3-mini, grok-3-fast"
432
- # fallback_to = ""
433
- # [LLM_PROVIDER.xai_END]
434
- #
435
- # class XAIProvider(BaseProvider):
436
- # """xAI Grok API — OpenAI-compatible chat completions endpoint."""
437
- #
438
- # async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024) -> str:
439
- # data = await self._post(
440
- # f"{self.base_url}/chat/completions",
441
- # headers={
442
- # "Authorization": f"Bearer {self.key}",
443
- # "content-type": "application/json",
444
- # },
445
- # payload={
446
- # "model": model or self.model,
447
- # "max_tokens": max_tokens,
448
- # "messages": [{"role": "user", "content": prompt}],
449
- # },
450
- # )
451
- # return data["choices"][0]["message"]["content"]
452
 
453
 
454
  # =============================================================================
455
  # SECTION 3 — Provider Registry
456
  # Built from .pyfun [LLM_PROVIDERS] at initialize().
457
- # Maps provider names → classes.
458
- # To activate a dummy: uncomment class above + add entry here.
 
 
 
459
  # =============================================================================
460
 
461
- _PROVIDER_CLASSES = {
462
- "smollm": SmolLMProvider,
463
- "anthropic": AnthropicProvider,
464
- "gemini": GeminiProvider,
465
- "openrouter": OpenRouterProvider,
466
- "huggingface": HuggingFaceProvider,
467
- # "openai": OpenAIProvider, # ← uncomment to activate
468
- # "mistral": MistralProvider, # ← uncomment to activate
469
- # "xai": XAIProvider, # ← uncomment to activate
470
  }
471
 
472
  _registry: dict = {}
@@ -474,7 +438,7 @@ _registry: dict = {}
474
 
475
  def initialize() -> None:
476
  """
477
- Build provider registry from .pyfun [LLM_PROVIDERS].
478
  Called once by mcp.py during startup sequence.
479
  Skips providers with missing ENV keys — no crash, just fewer tools.
480
  """
@@ -486,25 +450,70 @@ def initialize() -> None:
486
  if not env_key or not os.getenv(env_key):
487
  logger.info(f"Provider '{name}' skipped — ENV key not set.")
488
  continue
489
- cls = _PROVIDER_CLASSES.get(name)
 
490
  if not cls:
491
- logger.info(f"Provider '{name}' has no handler yet — skipped.")
492
  continue
493
  _registry[name] = cls(name, cfg)
494
  cache_hint = " [cache: ON]" if cfg.get("supports_cache", "false") == "true" else ""
495
- logger.info(f"Provider registered: {name}{cache_hint}")
 
 
 
496
 
497
 
498
  # =============================================================================
499
  # SECTION 4 — LLM Execution + Fallback Chain
500
  # =============================================================================
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  async def llm_complete(
503
  prompt: str,
504
  provider_name: str = None,
505
  model: str = None,
506
  max_tokens: int = 1024,
507
  system: str = None,
 
508
  ) -> str:
509
  """
510
  Send prompt to LLM provider with automatic fallback chain.
@@ -513,49 +522,53 @@ async def llm_complete(
513
 
514
  Args:
515
  prompt: Input text to send to the model.
516
- provider_name: Provider name override. Defaults to default_provider
517
- from .pyfun [TOOL.llm_complete].
518
- model: Model name override. Defaults to provider's default_model.
519
- max_tokens: Max tokens in response. Default: 1024.
520
- system: Optional system prompt. Passed to providers that support it.
521
- AnthropicProvider caches it automatically if supports_cache = true
522
- and the system prompt is long enough (>= ~1024 tokens).
523
 
524
  Returns:
525
- Model response as plain text string.
526
  """
 
 
 
 
 
 
527
  if not provider_name:
528
  tools_cfg = config.get_active_tools()
529
  provider_name = tools_cfg.get("llm_complete", {}).get("default_provider", "anthropic")
530
 
531
  visited = set()
532
  current = provider_name
 
533
 
534
  while current and current not in visited:
535
  visited.add(current)
536
- provider = _registry.get(current)
 
 
537
 
538
  if not provider:
539
  logger.warning(f"Provider '{current}' not in registry — trying fallback.")
540
  else:
 
 
541
  try:
542
- # Pass system prompt if provider supports it (Anthropic)
543
- # Other providers accept **kwargs and ignore unknown params safely
544
- if system is not None and hasattr(provider, 'complete'):
545
- import inspect
546
- sig = inspect.signature(provider.complete)
547
- if 'system' in sig.parameters:
548
- result = await provider.complete(prompt, model, max_tokens, system=system)
549
- else:
550
- result = await provider.complete(prompt, model, max_tokens)
551
- else:
552
- result = await provider.complete(prompt, model, max_tokens)
553
-
554
  logger.info(f"Response from provider: '{current}'")
555
- return f"[{current}] {result}"
 
556
  except Exception as e:
557
- # Log only exception type + sanitized message — never raw {e}
558
- # which may contain headers, keys, or response bodies
559
  logger.warning(
560
  f"Provider '{current}' failed: {type(e).__name__}: {e} — trying fallback."
561
  )
@@ -572,9 +585,289 @@ complete = llm_complete
572
 
573
  # =============================================================================
574
  # SECTION 5 — Search Execution
575
- # Search providers not yet implemented — returns placeholder.
576
- # Add BraveProvider, TavilyProvider here when ready.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
  async def search(
580
  query: str,
@@ -582,21 +875,49 @@ async def search(
582
  max_results: int = 5,
583
  ) -> str:
584
  """
585
- Search the web via configured search provider.
586
- Search providers not yet implemented — placeholder until BraveProvider ready.
 
587
 
588
  Args:
589
  query: Search query string.
590
  provider_name: Provider name override (e.g. 'brave', 'tavily').
 
591
  max_results: Maximum number of results. Default: 5.
592
 
593
  Returns:
594
  Formatted search results as plain text string.
595
  """
596
- # TODO: implement BraveProvider, TavilyProvider
597
- # Same pattern as LLM providers — add class + register in _SEARCH_REGISTRY
598
- logger.info(f"web_search called — query: '{query}' — search providers not yet active.")
599
- return f"Search not yet implemented. Query was: {query}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
 
601
 
602
  # =============================================================================
@@ -610,12 +931,8 @@ def list_active_llm() -> list:
610
 
611
 
612
  def list_active_search() -> list:
613
- """
614
- Returns list of active search provider names.
615
- Empty until search providers are implemented.
616
- """
617
- # TODO: return list(_search_registry.keys()) when search providers are ready
618
- return []
619
 
620
 
621
  def get(name: str) -> BaseProvider:
 
1
  # =============================================================================
2
  # app/providers.py
3
+ # 09.03.2026 | updated 23.03.2026 | reworked 10.11.2026
4
  # LLM + Search Provider Registry + Fallback Chain
5
  # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
6
  # Copyright 2026 - Volkan Kücükbudak
 
15
  # PROVIDER PRINCIPLE:
16
  # No key = no provider = no tool = no crash.
17
  # Server always starts, just with fewer providers.
18
+ #
19
+ # HOW TO ADD A NEW LLM PROVIDER:
20
+ # OpenAI-compatible API (Mistral, OpenAI, xAI, Groq, Together, DeepSeek, ...):
21
+ # → ONLY a [LLM_PROVIDER.xyz] block in app/.pyfun with type = "openai_compat"
22
+ # + the key in .env. NO code needed. See examples in .pyfun.
23
+ # Different API format (like Anthropic / Gemini):
24
+ # 1. Add a class below (subclass BaseProvider, implement complete())
25
+ # 2. Register it in _TYPE_CLASSES
26
+ # 3. Add the [LLM_PROVIDER.xyz] block in .pyfun with type = "<your type>"
27
  #
28
  # FALLBACK CHAIN:
29
  # Defined in .pyfun per provider via fallback_to field.
30
  # anthropic → fails → gemini → fails → openrouter → fails → RuntimeError
31
  # Visited set prevents infinite loops.
32
+ # A model chosen explicitly applies to the FIRST provider only — every
33
+ # fallback provider uses its own default_model (model IDs are provider-specific).
34
+ #
35
+ # LIMITS (from .pyfun [HUB_LIMITS]):
36
+ # MAX_PARALLEL_REQUESTS → semaphore around every provider call
37
+ # RETRY_COUNT / RETRY_DELAY_SEC → attempts per provider on 429/5xx/connect errors
38
+ # (timeouts are NOT retried → straight to fallback)
39
+ # MAX_TOKENS_CAP → hard ceiling for max_tokens per call (0 = off)
40
+ # Per-model max_output_tokens (manual or auto from pricing) clamps further.
41
+ #
42
+ # USAGE / COST:
43
+ # complete() returns (text, usage). llm_complete() logs usage + estimated cost
44
+ # into db_sync (usage_log) after every successful call. Never breaks a call.
45
  #
46
  # SECURITY NOTE:
47
  # API keys are NEVER logged or included in exception messages.
 
49
  # and safe_url (query params stripped) are ever exposed in logs.
50
  #
51
  # CACHING NOTE:
52
+ # Anthropic → prompt_caching (cache_control: ephemeral) for long system/user prompts.
 
 
 
53
  # Enable per provider in .pyfun: supports_cache = "true"
54
+ # Gemini → implicit caching (automatic, server-side). supports_cache = log hint only.
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  #
56
  # DEPENDENCY CHAIN (app/* only, no fundaments!):
57
  # config.py → parses app/.pyfun — single source of truth
 
61
  # =============================================================================
62
 
63
  import os
64
+ import re
65
+ import time
66
+ import asyncio
67
  import logging
68
  import httpx
69
+ from html.parser import HTMLParser
70
+ from urllib.parse import urlparse, parse_qs
71
+ from typing import Optional, Tuple, Dict, Any
72
 
73
  from . import config
74
+ from . import models as models_mod
75
+ from . import pricing
76
+ from . import db_sync
77
+
78
  logging.getLogger("httpx").setLevel(logging.WARNING)
79
  logger = logging.getLogger("providers")
80
 
81
 
82
+ # =============================================================================
83
+ # SECTION 0 — Errors, Limits, Retry
84
+ # =============================================================================
85
+
86
+ class ProviderError(RuntimeError):
87
+ """Sanitized provider error. RuntimeError subclass → app.py maps it to 502."""
88
+ def __init__(self, message: str, status: Optional[int] = None):
89
+ super().__init__(message)
90
+ self.status = status
91
+
92
+
93
+ _TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504, 529}
94
+
95
+
96
+ def _limit(key: str, default, cast=int):
97
+ """Read one value from .pyfun [HUB_LIMITS] with safe fallback."""
98
+ try:
99
+ return cast(config.get_limits().get(key, default))
100
+ except (TypeError, ValueError):
101
+ return cast(default)
102
+
103
+
104
+ _sem: Optional[asyncio.Semaphore] = None
105
+
106
+
107
+ def _get_sem() -> asyncio.Semaphore:
108
+ """One global semaphore (MAX_PARALLEL_REQUESTS) — created lazily inside the loop."""
109
+ global _sem
110
+ if _sem is None:
111
+ _sem = asyncio.Semaphore(max(1, _limit("MAX_PARALLEL_REQUESTS", 5)))
112
+ return _sem
113
+
114
+
115
+ def _is_transient(e: Exception) -> bool:
116
+ """429/5xx and connect errors are worth a retry. Timeouts are NOT (60s x N = forever)."""
117
+ if isinstance(e, ProviderError):
118
+ return e.status in _TRANSIENT_STATUS
119
+ return isinstance(e, httpx.ConnectError)
120
+
121
+
122
+ async def _with_retry(name: str, call):
123
+ """
124
+ Run `call()` (async callable) under the global semaphore.
125
+ Attempts = RETRY_COUNT (incl. the first), flat RETRY_DELAY_SEC between them.
126
+ """
127
+ attempts = max(1, _limit("RETRY_COUNT", 3))
128
+ delay = max(0.0, _limit("RETRY_DELAY_SEC", 2, float))
129
+ for i in range(1, attempts + 1):
130
+ try:
131
+ async with _get_sem():
132
+ return await call()
133
+ except Exception as e:
134
+ if i >= attempts or not _is_transient(e):
135
+ raise
136
+ logger.info(f"'{name}' transient error ({type(e).__name__}) — retry {i}/{attempts - 1} in {delay}s")
137
+ await asyncio.sleep(delay)
138
+
139
+
140
+ def _usage(tokens_in: Any, tokens_out: Any) -> Dict[str, Optional[int]]:
141
+ """Normalize token counts to int|None."""
142
+ def _i(v):
143
+ try:
144
+ return int(v)
145
+ except (TypeError, ValueError):
146
+ return None
147
+ return {"input": _i(tokens_in), "output": _i(tokens_out)}
148
+
149
+
150
  # =============================================================================
151
  # SECTION 1 — Base Provider
152
  # Shared HTTP logic — implemented ONCE, reused by all providers.
 
156
  """
157
  Base class for all LLM providers.
158
  Subclasses only implement complete() — HTTP logic lives here.
159
+ complete() returns (text, usage) with usage = {"input": int|None, "output": int|None}.
160
  """
161
  def __init__(self, name: str, cfg: dict):
162
  self.name = name
163
+ self.cfg = cfg
164
  self.key = os.getenv(cfg.get("env_key", ""))
165
  self.base_url = cfg.get("base_url", "")
166
  self.fallback = cfg.get("fallback_to", "")
 
174
  else "***"
175
  )
176
 
177
+ async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024,
178
+ system: str = None) -> Tuple[str, Dict[str, Optional[int]]]:
179
  """Override in each provider subclass."""
180
  raise NotImplementedError
181
 
182
  async def _post(self, url: str, headers: dict, payload: dict) -> dict:
183
  """
184
  Shared HTTP POST — used by all providers.
185
+ Raises ProviderError with sanitized message on non-2xx responses.
186
  API keys are never included in raised exceptions or log output.
187
  """
188
  safe_url = url.split("?")[0] # strip query params (may contain API keys)
 
198
  r.raise_for_status()
199
  except httpx.HTTPStatusError as e:
200
  # Sanitize: only status code + safe_url, never headers or body
201
+ raise ProviderError(
202
+ f"HTTP {e.response.status_code} from {safe_url}",
203
+ e.response.status_code,
204
  ) from None
205
  return r.json()
206
 
 
210
  # Only the API-specific parsing logic differs per provider.
211
  # =============================================================================
212
 
213
+ # --- OpenAI-compatible (GENERIC — no code needed for new providers) -----------
214
+ class OpenAICompatProvider(BaseProvider):
215
  """
216
+ Generic OpenAI-compatible /chat/completions provider.
217
+ Covers smollm, openrouter, huggingface, mistral, openai, xai, groq, ...
218
+ Everything provider-specific lives in the .pyfun block:
 
219
 
220
+ [LLM_PROVIDER.mistral]
 
221
  active = "true"
222
+ type = "openai_compat"
223
+ base_url = "https://api.mistral.ai/v1"
224
+ env_key = "MISTRAL_API_KEY"
225
+ default_model = "mistral-small-latest"
226
+ fallback_to = ""
227
+ # optional:
228
+ # auth_header = "Authorization" (default)
229
+ # auth_scheme = "Bearer" (default; "" = send the raw key)
230
+ # chat_path = "/chat/completions" (default)
231
+ # header.X-Foo = "{key}" extra header; {key} = API key,
232
+ # {env:NAME|fallback} = ENV value
233
+ [LLM_PROVIDER.mistral_END]
234
  """
235
+ _PLACEHOLDER = re.compile(r"\{(key|env:([A-Za-z0-9_]+)(?:\|([^}]*))?)\}")
236
+
237
+ def __init__(self, name: str, cfg: dict):
238
+ super().__init__(name, cfg)
239
+ self.auth_header = cfg.get("auth_header", "Authorization")
240
+ self.auth_scheme = cfg.get("auth_scheme", "Bearer")
241
+ self.chat_path = cfg.get("chat_path", "/chat/completions")
242
+
243
+ def _fill(self, value: str) -> str:
244
+ def sub(m):
245
+ if m.group(1) == "key":
246
+ return self.key or ""
247
+ return os.getenv(m.group(2)) or (m.group(3) or "")
248
+ return self._PLACEHOLDER.sub(sub, value)
249
+
250
+ def _headers(self) -> dict:
251
+ headers = {
252
+ self.auth_header: f"{self.auth_scheme} {self.key}".strip(),
253
+ "content-type": "application/json",
254
+ }
255
+ for k, v in self.cfg.items():
256
+ if k.startswith("header."):
257
+ headers[k[len("header."):]] = self._fill(v)
258
+ return headers
259
+
260
+ async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024,
261
+ system: str = None):
262
+ messages = []
263
+ if system:
264
+ messages.append({"role": "system", "content": system})
265
+ messages.append({"role": "user", "content": prompt})
266
 
 
267
  data = await self._post(
268
+ f"{self.base_url}{self.chat_path}",
269
+ headers=self._headers(),
 
 
 
 
270
  payload={
271
  "model": model or self.model,
272
  "max_tokens": max_tokens,
273
+ "messages": messages,
274
  },
275
  )
276
+ text = data["choices"][0]["message"]["content"]
277
+ if text is None:
278
+ raise ProviderError("empty content in response")
279
+ u = data.get("usage") or {}
280
+ return text, _usage(u.get("prompt_tokens"), u.get("completion_tokens"))
281
 
282
 
283
  # --- Anthropic ----------------------------------------------------------------
 
287
 
288
  Prompt Caching (supports_cache = "true" in .pyfun):
289
  Uses cache_control: ephemeral on system prompt and long user prompts.
 
290
  Cache TTL: 5 minutes, extended on each cache hit.
291
  Min tokens to cache: ~1024 (Anthropic requirement).
292
  Cost: cache write ~25% more, cache read ~90% less than normal input.
293
 
294
+ Usage: input = input + cache_creation + cache_read tokens (upper bound for cost).
295
+
296
  .pyfun block:
297
  [LLM_PROVIDER.anthropic]
298
+ active = "true"
299
+ type = "anthropic"
300
+ base_url = "https://api.anthropic.com/v1"
301
+ env_key = "ANTHROPIC_API_KEY"
302
  api_version_header = "2023-06-01"
303
+ default_model = "claude-haiku-5-5"
304
+ supports_cache = "true"
305
+ fallback_to = "gemini"
306
  [LLM_PROVIDER.anthropic_END]
307
  """
308
 
 
310
  _CHARS_PER_TOKEN = 4
311
  _CACHE_MIN_TOKENS = 1024
312
 
313
+ def __init__(self, name: str, cfg: dict):
314
+ super().__init__(name, cfg)
315
+ self.api_version = cfg.get("api_version_header", "2023-06-01")
316
+
317
  def _is_cacheable(self, text: str) -> bool:
318
  """Estimate if text is long enough to benefit from caching."""
319
  return len(text) >= self._CACHE_MIN_TOKENS * self._CHARS_PER_TOKEN
320
 
321
+ async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024,
322
+ system: str = None):
 
 
 
 
 
 
 
323
  headers = {
324
  "x-api-key": self.key,
325
+ "anthropic-version": self.api_version,
326
  "content-type": "application/json",
327
  }
328
 
329
+ # --- Build user content (cache_control if enabled + long enough) ---
 
330
  if self.supports_cache and self._is_cacheable(prompt):
331
+ user_content = [{"type": "text", "text": prompt,
332
+ "cache_control": {"type": "ephemeral"}}]
 
 
 
 
 
333
  headers["anthropic-beta"] = "prompt-caching-2024-07-31"
334
  logger.debug("Anthropic: prompt cache_control applied to user message.")
335
  else:
 
344
  # --- Optional system prompt with cache_control ---
345
  if system:
346
  if self.supports_cache and self._is_cacheable(system):
347
+ payload["system"] = [{"type": "text", "text": system,
348
+ "cache_control": {"type": "ephemeral"}}]
 
 
 
 
 
349
  headers["anthropic-beta"] = "prompt-caching-2024-07-31"
350
  logger.debug("Anthropic: prompt cache_control applied to system prompt.")
351
  else:
352
  payload["system"] = system
353
 
354
  data = await self._post(f"{self.base_url}/messages", headers, payload)
355
+ text = data["content"][0]["text"]
356
+ u = data.get("usage") or {}
357
+ tin = None
358
+ if u:
359
+ tin = sum(int(u.get(k) or 0) for k in
360
+ ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
361
+ return text, _usage(tin, u.get("output_tokens"))
362
 
363
 
364
  # --- Gemini ------------------------------------------------------------------
 
366
  """
367
  Google Gemini API — generateContent endpoint.
368
 
369
+ Implicit Caching (always active on Gemini side, no code needed).
370
+ Explicit Context Caching is NOT implemented (only worth it for 32k+ static contexts).
 
 
 
 
 
 
 
 
 
 
371
 
372
  .pyfun block:
373
  [LLM_PROVIDER.gemini]
374
  active = "true"
375
+ type = "gemini"
376
  base_url = "https://generativelanguage.googleapis.com/v1beta"
377
  env_key = "GEMINI_API_KEY"
378
+ default_model = "gemini-2.5-flash"
379
  supports_cache = "true"
380
  fallback_to = "openrouter"
381
  [LLM_PROVIDER.gemini_END]
382
  """
383
 
384
+ async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024,
385
+ system: str = None):
386
  m = model or self.model
387
  safe_url = f"{self.base_url}/models/{m}:generateContent"
388
 
389
  if self.supports_cache:
390
  logger.debug(f"Gemini: implicit caching active for model {m} (server-side, automatic).")
391
 
392
+ body = {
393
+ "contents": [{"parts": [{"text": prompt}]}],
394
+ "generationConfig": {"maxOutputTokens": max_tokens},
395
+ }
396
+ if system:
397
+ body["systemInstruction"] = {"parts": [{"text": system}]}
398
+
399
  async with httpx.AsyncClient() as client:
400
  r = await client.post(
401
  safe_url,
402
  params={"key": self.key}, # key in query param, never in logs
403
+ json=body,
 
 
 
404
  timeout=self.timeout,
405
  )
406
  try:
407
  r.raise_for_status()
408
  except httpx.HTTPStatusError as e:
409
+ raise ProviderError(
410
+ f"HTTP {e.response.status_code} from {safe_url}",
411
+ e.response.status_code,
412
  ) from None
413
+ data = r.json()
 
 
 
 
 
 
 
 
 
414
 
415
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
416
+ meta = data.get("usageMetadata") or {}
417
+ return text, _usage(meta.get("promptTokenCount"), meta.get("candidatesTokenCount"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
 
419
 
420
  # =============================================================================
421
  # SECTION 3 — Provider Registry
422
  # Built from .pyfun [LLM_PROVIDERS] at initialize().
423
+ # Handler is chosen by the `type` field of the block (default: the block name).
424
+ # type = "openai_compat" → OpenAICompatProvider (generic, no code per provider)
425
+ # type = "anthropic" → AnthropicProvider
426
+ # type = "gemini" → GeminiProvider
427
+ # New API format = new class above + one line here.
428
  # =============================================================================
429
 
430
+ _TYPE_CLASSES = {
431
+ "openai_compat": OpenAICompatProvider,
432
+ "anthropic": AnthropicProvider,
433
+ "gemini": GeminiProvider,
 
 
 
 
 
434
  }
435
 
436
  _registry: dict = {}
 
438
 
439
  def initialize() -> None:
440
  """
441
+ Build provider registries from .pyfun [LLM_PROVIDERS] + [SEARCH_PROVIDERS].
442
  Called once by mcp.py during startup sequence.
443
  Skips providers with missing ENV keys — no crash, just fewer tools.
444
  """
 
450
  if not env_key or not os.getenv(env_key):
451
  logger.info(f"Provider '{name}' skipped — ENV key not set.")
452
  continue
453
+ ptype = (cfg.get("type") or name).strip().lower()
454
+ cls = _TYPE_CLASSES.get(ptype)
455
  if not cls:
456
+ logger.info(f"Provider '{name}' has no handler (type '{ptype}') — skipped.")
457
  continue
458
  _registry[name] = cls(name, cfg)
459
  cache_hint = " [cache: ON]" if cfg.get("supports_cache", "false") == "true" else ""
460
+ logger.info(f"Provider registered: {name} (type: {ptype}){cache_hint}")
461
+
462
+ # Search providers — same key-gating principle
463
+ _initialize_search()
464
 
465
 
466
  # =============================================================================
467
  # SECTION 4 — LLM Execution + Fallback Chain
468
  # =============================================================================
469
 
470
+ def provider_for_model(model: str) -> Optional[str]:
471
+ """
472
+ Provider that owns `model` according to .pyfun [MODELS] (provider field).
473
+ None if the model is unknown → caller keeps its default provider.
474
+ """
475
+ if not model:
476
+ return None
477
+ entry = models_mod.get(model) or {}
478
+ return entry.get("provider") or None
479
+
480
+
481
+ def _clamp_tokens(model: str, max_tokens: int) -> int:
482
+ """Hard cap (MAX_TOKENS_CAP) + per-model max_output_tokens (manual or auto-pricing)."""
483
+ try:
484
+ n = max(1, int(max_tokens))
485
+ except (TypeError, ValueError):
486
+ n = 1024
487
+ cap = _limit("MAX_TOKENS_CAP", 8192)
488
+ if cap > 0:
489
+ n = min(n, cap)
490
+ lim = pricing.max_output_for(model)
491
+ if lim:
492
+ n = min(n, lim)
493
+ return n
494
+
495
+
496
+ async def _record_usage(tool: str, provider: str, model: str, usage: dict, latency_ms: int) -> None:
497
+ """Log usage + estimated cost. NEVER raises — accounting must not break a call."""
498
+ try:
499
+ tin, tout = usage.get("input"), usage.get("output")
500
+ cost = None
501
+ if tin is not None or tout is not None:
502
+ cost = await pricing.cost_for(model, tin, tout)
503
+ await db_sync.log_usage(tool, provider, model, tin, tout, cost, latency_ms)
504
+ cost_txt = f"${cost:.6f}" if cost is not None else "n/a"
505
+ logger.info(f"Usage: {provider}/{model} in={tin} out={tout} cost≈{cost_txt} ({latency_ms} ms)")
506
+ except Exception as e:
507
+ logger.debug(f"Usage logging skipped: {type(e).__name__}")
508
+
509
+
510
  async def llm_complete(
511
  prompt: str,
512
  provider_name: str = None,
513
  model: str = None,
514
  max_tokens: int = 1024,
515
  system: str = None,
516
+ tool_name: str = "llm_complete",
517
  ) -> str:
518
  """
519
  Send prompt to LLM provider with automatic fallback chain.
 
522
 
523
  Args:
524
  prompt: Input text to send to the model.
525
+ provider_name: Provider override. Unknown / non-LLM names are ignored.
526
+ If only `model` is given, the provider is derived from [MODELS].
527
+ model: Model override — applies to the FIRST provider only.
528
+ max_tokens: Max tokens in response (clamped, see header).
529
+ system: Optional system prompt — sent as real system prompt to every provider.
530
+ tool_name: Only used for the usage log.
 
531
 
532
  Returns:
533
+ "[provider] text"
534
  """
535
+ model = (model or "").strip() or None
536
+ if provider_name and provider_name not in config.get_llm_providers():
537
+ logger.debug(f"'{provider_name}' is not an LLM provider — using default.")
538
+ provider_name = None
539
+ if not provider_name:
540
+ provider_name = provider_for_model(model)
541
  if not provider_name:
542
  tools_cfg = config.get_active_tools()
543
  provider_name = tools_cfg.get("llm_complete", {}).get("default_provider", "anthropic")
544
 
545
  visited = set()
546
  current = provider_name
547
+ first = True
548
 
549
  while current and current not in visited:
550
  visited.add(current)
551
+ provider = _registry.get(current)
552
+ use_model = model if first else None # explicit model belongs to the first provider only
553
+ first = False
554
 
555
  if not provider:
556
  logger.warning(f"Provider '{current}' not in registry — trying fallback.")
557
  else:
558
+ m = use_model or provider.model
559
+ mt = _clamp_tokens(m, max_tokens)
560
  try:
561
+ t0 = time.monotonic()
562
+ text, usage = await _with_retry(
563
+ current,
564
+ lambda p=provider, um=use_model, n=mt: p.complete(prompt, um, n, system=system),
565
+ )
566
+ latency_ms = int((time.monotonic() - t0) * 1000)
 
 
 
 
 
 
567
  logger.info(f"Response from provider: '{current}'")
568
+ await _record_usage(tool_name, current, m, usage, latency_ms)
569
+ return f"[{current}] {text}"
570
  except Exception as e:
571
+ # Log only exception type + sanitized message — never raw headers/keys/bodies
 
572
  logger.warning(
573
  f"Provider '{current}' failed: {type(e).__name__}: {e} — trying fallback."
574
  )
 
585
 
586
  # =============================================================================
587
  # SECTION 5 — Search Execution
588
+ # Same pattern as LLM providers: class + _SEARCH_PROVIDER_CLASSES + fallback.
589
+ # Adding a search provider = add class + register + .pyfun block. Nothing else.
590
+ # =============================================================================
591
+
592
+ class BaseSearchProvider:
593
+ """
594
+ Base class for all search providers.
595
+ Subclasses only implement search() — HTTP logic lives here.
596
+ """
597
+ def __init__(self, name: str, cfg: dict):
598
+ self.name = name
599
+ self.key = os.getenv(cfg.get("env_key", ""))
600
+ self.base_url = cfg.get("base_url", "")
601
+ self.fallback = cfg.get("fallback_to", "")
602
+ self.timeout = int(config.get_limits().get("SEARCH_TIMEOUT_SEC", "30"))
603
+ self.default_results = int(cfg.get("default_results", "5"))
604
+ self.max_results = int(cfg.get("max_results", "20"))
605
+ self._key_hint = (
606
+ f"{self.key[:4]}...{self.key[-4:]}"
607
+ if self.key and len(self.key) > 8
608
+ else "***"
609
+ )
610
+
611
+ async def search(self, query: str, max_results: int) -> str:
612
+ """Override in each search provider subclass."""
613
+ raise NotImplementedError
614
+
615
+ async def _get(self, url: str, headers: dict, params: dict) -> dict:
616
+ """Shared HTTP GET with sanitized errors (never leak keys/bodies)."""
617
+ safe_url = url.split("?")[0]
618
+ async with httpx.AsyncClient() as client:
619
+ r = await client.get(url, headers=headers, params=params, timeout=self.timeout)
620
+ try:
621
+ r.raise_for_status()
622
+ except httpx.HTTPStatusError as e:
623
+ raise ProviderError(f"HTTP {e.response.status_code} from {safe_url}", e.response.status_code) from None
624
+ return r.json()
625
+
626
+ async def _get_text(self, url: str, headers: dict, params: dict) -> str:
627
+ """Shared HTTP GET returning the raw body (HTML) — sanitized errors."""
628
+ safe_url = url.split("?")[0]
629
+ async with httpx.AsyncClient() as client:
630
+ r = await client.get(url, headers=headers, params=params, timeout=self.timeout)
631
+ try:
632
+ r.raise_for_status()
633
+ except httpx.HTTPStatusError as e:
634
+ raise ProviderError(f"HTTP {e.response.status_code} from {safe_url}", e.response.status_code) from None
635
+ return r.text
636
+
637
+ async def _post(self, url: str, headers: dict, payload: dict) -> dict:
638
+ """Shared HTTP POST with sanitized errors."""
639
+ safe_url = url.split("?")[0]
640
+ async with httpx.AsyncClient() as client:
641
+ r = await client.post(url, headers=headers, json=payload, timeout=self.timeout)
642
+ try:
643
+ r.raise_for_status()
644
+ except httpx.HTTPStatusError as e:
645
+ raise ProviderError(f"HTTP {e.response.status_code} from {safe_url}", e.response.status_code) from None
646
+ return r.json()
647
+
648
+ def _clip(self, max_results: int) -> int:
649
+ """Clamp requested results into [1, max_results]."""
650
+ try:
651
+ n = int(max_results)
652
+ except (TypeError, ValueError):
653
+ n = self.default_results
654
+ return max(1, min(n, self.max_results))
655
+
656
+
657
+ # --- Brave Search ---------------------------------------------------------------
658
+ class BraveSearchProvider(BaseSearchProvider):
659
+ """
660
+ Brave Search API — web search endpoint.
661
+ Auth via X-Subscription-Token header (NOT Bearer!).
662
+
663
+ .pyfun block:
664
+ [SEARCH_PROVIDER.brave]
665
+ active = "true"
666
+ base_url = "https://api.search.brave.com/res/v1/web/search"
667
+ env_key = "BRAVE_API_KEY"
668
+ default_results = "5"
669
+ max_results = "20"
670
+ fallback_to = "tavily"
671
+ [SEARCH_PROVIDER.brave_END]
672
+ """
673
+
674
+ async def search(self, query: str, max_results: int = 5) -> str:
675
+ data = await self._get(
676
+ self.base_url,
677
+ headers={
678
+ "X-Subscription-Token": self.key,
679
+ "Accept": "application/json",
680
+ "Accept-Encoding": "gzip",
681
+ },
682
+ params={
683
+ "q": query,
684
+ "count": self._clip(max_results),
685
+ },
686
+ )
687
+ results = data.get("web", {}).get("results", [])
688
+ if not results:
689
+ raise RuntimeError("Brave returned no results")
690
+ lines = [f"[{self.name}] Search results for: '{query}'"]
691
+ for i, r in enumerate(results, 1):
692
+ lines.append(f"{i}. {r.get('title', '')}\n {r.get('url', '')}\n {r.get('description', '')}")
693
+ return "\n\n".join(lines)
694
+
695
+
696
+ # --- Tavily ---------------------------------------------------------------------
697
+ class TavilySearchProvider(BaseSearchProvider):
698
+ """
699
+ Tavily Search API — AI-optimized web search.
700
+ include_answer = "true" in .pyfun → adds AI-synthesized answer on top.
701
+
702
+ .pyfun block:
703
+ [SEARCH_PROVIDER.tavily]
704
+ active = "true"
705
+ base_url = "https://api.tavily.com/search"
706
+ env_key = "TAVILY_API_KEY"
707
+ default_results = "5"
708
+ max_results = "10"
709
+ include_answer = "true"
710
+ fallback_to = ""
711
+ [SEARCH_PROVIDER.tavily_END]
712
+ """
713
+
714
+ def __init__(self, name: str, cfg: dict):
715
+ super().__init__(name, cfg)
716
+ self.include_answer = cfg.get("include_answer", "false").lower() == "true"
717
+
718
+ async def search(self, query: str, max_results: int = 5) -> str:
719
+ data = await self._post(
720
+ self.base_url,
721
+ headers={"Content-Type": "application/json"},
722
+ payload={
723
+ "api_key": self.key, # Tavily takes the key in the payload
724
+ "query": query,
725
+ "max_results": self._clip(max_results),
726
+ "include_answer": self.include_answer,
727
+ },
728
+ )
729
+ results = data.get("results", [])
730
+ if not results and not data.get("answer"):
731
+ raise RuntimeError("Tavily returned no results")
732
+ lines = [f"[{self.name}] Search results for: '{query}'"]
733
+ if self.include_answer and data.get("answer"):
734
+ lines.append(f"AI answer: {data['answer']}")
735
+ for i, r in enumerate(results, 1):
736
+ lines.append(f"{i}. {r.get('title', '')}\n {r.get('url', '')}\n {r.get('content', '')}")
737
+ return "\n\n".join(lines)
738
+
739
+
740
+ # --- DuckDuckGo (keyless, gentle last-resort fallback) --------------------------
741
+ class _DDGParser(HTMLParser):
742
+ """Minimal parser for html.duckduckgo.com result pages (stdlib only, no BeautifulSoup)."""
743
+ def __init__(self):
744
+ super().__init__()
745
+ self.results: list = []
746
+ self._mode = None
747
+ self._cur = None
748
+ self._buf: list = []
749
+
750
+ @staticmethod
751
+ def _unwrap(href: str) -> str:
752
+ """DDG wraps targets as //duckduckgo.com/l/?uddg=<urlencoded>&rut=… → real URL."""
753
+ target = (parse_qs(urlparse(href).query).get("uddg") or [href])[0]
754
+ return "https:" + target if target.startswith("//") else target
755
+
756
+ def handle_starttag(self, tag, attrs):
757
+ if tag != "a":
758
+ return
759
+ a = dict(attrs)
760
+ cls = (a.get("class") or "").split()
761
+ if "result__a" in cls:
762
+ self._cur = {"title": "", "url": self._unwrap(a.get("href") or ""), "snippet": ""}
763
+ self.results.append(self._cur)
764
+ self._mode, self._buf = "title", []
765
+ elif "result__snippet" in cls and self.results:
766
+ self._cur = self.results[-1]
767
+ self._mode, self._buf = "snippet", []
768
+
769
+ def handle_data(self, data):
770
+ if self._mode:
771
+ self._buf.append(data)
772
+
773
+ def handle_endtag(self, tag):
774
+ if tag == "a" and self._mode:
775
+ self._cur[self._mode] = " ".join("".join(self._buf).split())
776
+ self._mode = None
777
+
778
+
779
+ class DuckDuckGoSearchProvider(BaseSearchProvider):
780
+ """
781
+ Keyless search fallback (idea: "GentleSearchProvider").
782
+
783
+ DuckDuckGo has NO official search API — this reads the public HTML endpoint, so it is
784
+ fragile by nature and gentle by design:
785
+ - global cooldown (min_interval_sec, default 300): called too early it RAISES, so the
786
+ fallback chain reports an honest failure instead of a fake "result" string
787
+ - the attempt counts for the cooldown even if it fails (no hammering a blocked endpoint)
788
+ - honest User-Agent, no evasion tricks, no retries
789
+ - captcha / layout change = no parsable results = clear error
790
+
791
+ .pyfun block (no key needed → requires_key = "false"):
792
+ [SEARCH_PROVIDER.duckduckgo]
793
+ active = "false" requires_key = "false"
794
+ base_url = "https://html.duckduckgo.com/html/"
795
+ min_interval_sec = "300" # optional user_agent = "..." # optional
796
+ """
797
+ def __init__(self, name: str, cfg: dict):
798
+ super().__init__(name, cfg)
799
+ self.base_url = cfg.get("base_url") or "https://html.duckduckgo.com/html/"
800
+ self.min_interval = float(cfg.get("min_interval_sec", "300"))
801
+ self.user_agent = cfg.get("user_agent") or "UniversalMCPHub/1.0 (+https://github.com/VolkanSah)"
802
+ self._last_call: Optional[float] = None
803
+
804
+ async def search(self, query: str, max_results: int) -> str:
805
+ now = time.monotonic()
806
+ if self._last_call is not None and now - self._last_call < self.min_interval:
807
+ wait = int(self.min_interval - (now - self._last_call))
808
+ raise ProviderError(f"DuckDuckGo cooldown — wait {wait}s (min_interval_sec={int(self.min_interval)})")
809
+ self._last_call = now # counts even if the request fails
810
+
811
+ try:
812
+ html = await self._get_text(
813
+ self.base_url,
814
+ headers={
815
+ "User-Agent": self.user_agent,
816
+ "Accept": "text/html,application/xhtml+xml",
817
+ "Accept-Language": "de,en-US;q=0.7,en;q=0.3",
818
+ "DNT": "1",
819
+ },
820
+ params={"q": query},
821
+ )
822
+ except ProviderError as e:
823
+ raise ProviderError(str(e)) from None # drop status → never retried (gentle)
824
+
825
+ parser = _DDGParser()
826
+ parser.feed(html)
827
+ hits = [r for r in parser.results if r["url"]][: self._clip(max_results)]
828
+ if not hits:
829
+ raise ProviderError("DuckDuckGo: no parsable results (blocked/captcha or layout changed)")
830
+
831
+ lines = [f"[{self.name}] Search results for: '{query}'"]
832
+ for i, r in enumerate(hits, 1):
833
+ lines.append(f"{i}. {r['title']}\n {r['url']}\n {r['snippet']}")
834
+ return "\n\n".join(lines)
835
+
836
+
837
  # =============================================================================
838
+ # Search Registry — built from .pyfun [SEARCH_PROVIDERS] at initialize()
839
+ # =============================================================================
840
+
841
+ _SEARCH_PROVIDER_CLASSES = {
842
+ "brave": BraveSearchProvider,
843
+ "tavily": TavilySearchProvider,
844
+ "duckduckgo": DuckDuckGoSearchProvider, # keyless (requires_key = "false" in .pyfun)
845
+ # "serper": SerperSearchProvider, # ← uncomment to activate
846
+ }
847
+
848
+ _search_registry: dict = {}
849
+
850
+
851
+ def _initialize_search() -> None:
852
+ """
853
+ Build search provider registry from .pyfun [SEARCH_PROVIDERS].
854
+ Called inside initialize() — same key-gating as LLM providers:
855
+ no ENV key = provider skipped, no crash.
856
+ """
857
+ global _search_registry
858
+ for name, cfg in config.get_active_search_providers().items():
859
+ env_key = cfg.get("env_key", "")
860
+ needs_key = str(cfg.get("requires_key", "true")).lower() != "false"
861
+ if needs_key and (not env_key or not os.getenv(env_key)):
862
+ logger.info(f"Search provider '{name}' skipped — ENV key not set.")
863
+ continue
864
+ cls = _SEARCH_PROVIDER_CLASSES.get(name)
865
+ if not cls:
866
+ logger.info(f"Search provider '{name}' has no handler yet — skipped.")
867
+ continue
868
+ _search_registry[name] = cls(name, cfg)
869
+ logger.info(f"Search provider registered: {name}")
870
+
871
 
872
  async def search(
873
  query: str,
 
875
  max_results: int = 5,
876
  ) -> str:
877
  """
878
+ Search the web via configured search provider with automatic fallback chain.
879
+ Fallback order is defined in .pyfun via fallback_to field.
880
+ Raises RuntimeError if all providers in the chain fail.
881
 
882
  Args:
883
  query: Search query string.
884
  provider_name: Provider name override (e.g. 'brave', 'tavily').
885
+ Defaults to default_provider from .pyfun [TOOL.web_search].
886
  max_results: Maximum number of results. Default: 5.
887
 
888
  Returns:
889
  Formatted search results as plain text string.
890
  """
891
+ if provider_name and provider_name not in config.get_search_providers():
892
+ logger.debug(f"'{provider_name}' is not a search provider — using default.")
893
+ provider_name = None
894
+ if not provider_name:
895
+ tools_cfg = config.get_active_tools()
896
+ provider_name = tools_cfg.get("web_search", {}).get("default_provider", "brave")
897
+
898
+ visited = set()
899
+ current = provider_name
900
+
901
+ while current and current not in visited:
902
+ visited.add(current)
903
+ provider = _search_registry.get(current)
904
+
905
+ if not provider:
906
+ logger.warning(f"Search provider '{current}' not in registry — trying fallback.")
907
+ else:
908
+ try:
909
+ result = await _with_retry(current, lambda p=provider: p.search(query, max_results))
910
+ logger.info(f"Search response from provider: '{current}'")
911
+ return result
912
+ except Exception as e:
913
+ logger.warning(
914
+ f"Search provider '{current}' failed: {type(e).__name__}: {e} — trying fallback."
915
+ )
916
+
917
+ cfg = config.get_active_search_providers().get(current, {})
918
+ current = cfg.get("fallback_to", "")
919
+
920
+ raise RuntimeError("All search providers failed — no fallback available.")
921
 
922
 
923
  # =============================================================================
 
931
 
932
 
933
  def list_active_search() -> list:
934
+ """Returns list of active search provider names."""
935
+ return list(_search_registry.keys())
 
 
 
 
936
 
937
 
938
  def get(name: str) -> BaseProvider:
app/shellmaster.py CHANGED
@@ -33,6 +33,8 @@
33
 
34
  import json
35
  import logging
 
 
36
  import httpx
37
  from pathlib import Path
38
  from typing import Optional
@@ -89,9 +91,23 @@ _HARDCODED_BLOCKED = {
89
  }
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def _is_hardblocked(cmd_str: str) -> bool:
93
  """Check if command contains any hardcoded blocked pattern."""
94
- cmd_lower = cmd_str.lower().strip()
 
 
95
  return any(blocked in cmd_lower for blocked in _HARDCODED_BLOCKED)
96
 
97
 
@@ -125,7 +141,13 @@ async def initialize() -> None:
125
  return
126
 
127
  _agent_url = _cfg.get("shellmaster_agent_url", "http://localhost:5004")
128
- _token = _cfg.get("shellmaster_token", "")
 
 
 
 
 
 
129
  _timeout = int(_cfg.get("timeout_sec", "30"))
130
 
131
  # Command registry — local file first
@@ -184,15 +206,23 @@ def _validate_command(cmd_str: str) -> tuple[bool, str]:
184
  if _is_hardblocked(cmd_str):
185
  return False, "blocked by system policy"
186
 
187
- # Match against registry
 
 
 
 
188
  registry = _load_registry()
189
- cmd_lower = cmd_str.lower().strip()
 
 
190
 
191
  for cmd_id, cmd in registry.items():
192
  # Check both unix and win templates
193
  for os_key in ("unix", "win"):
194
- template_base = cmd.get(os_key, "").split()[0].lower()
195
- if template_base and cmd_lower.startswith(template_base):
 
 
196
  if not cmd.get("allowed", False):
197
  return False, f"command '{cmd_id}' is blocked in registry"
198
  return True, cmd_id
 
33
 
34
  import json
35
  import logging
36
+ import os
37
+ import re
38
  import httpx
39
  from pathlib import Path
40
  from typing import Optional
 
91
  }
92
 
93
 
94
+ # rm with a recursive flag in ANY order/position (-rf, -fr, -r -f, --recursive) aimed at
95
+ # root, home, wildcard or the current/parent dir. Plain substring matching on "rm -rf /"
96
+ # misses "rm -fr /" and "rm -r -f /".
97
+ _RM_DANGEROUS = re.compile(
98
+ r"\brm\s+(?:-\S+\s+)*(?:-\S*r\S*|--recursive)\s+(?:-\S+\s+)*(?:/(?:\s|$|\*)|~|\*|\$home|\.\.?(?:\s|$))"
99
+ )
100
+
101
+ # Shell metacharacters that chain / redirect / substitute commands. A registry command is
102
+ # matched by its FIRST token only, so "ls; rm x" would otherwise pass as "ls".
103
+ _SHELL_META = re.compile(r"[;&|`<>\n\r]|\$\(|\$\{")
104
+
105
+
106
  def _is_hardblocked(cmd_str: str) -> bool:
107
  """Check if command contains any hardcoded blocked pattern."""
108
+ cmd_lower = " ".join(cmd_str.lower().split()) # normalize whitespace
109
+ if _RM_DANGEROUS.search(cmd_lower):
110
+ return True
111
  return any(blocked in cmd_lower for blocked in _HARDCODED_BLOCKED)
112
 
113
 
 
141
  return
142
 
143
  _agent_url = _cfg.get("shellmaster_agent_url", "http://localhost:5004")
144
+ # Token: ENV zuerst (shellmaster_env_key → .env, Guardian lädt ENV) —
145
+ # das .pyfun-Feld ist nur noch Fallback. Secrets gehören NICHT in .pyfun
146
+ # (gleiche Regel wie bei allen Providern: env_key = NAME, Wert in .env).
147
+ env_key = _cfg.get("shellmaster_env_key", "")
148
+ _token = os.getenv(env_key, "") if env_key else ""
149
+ if not _token:
150
+ _token = _cfg.get("shellmaster_token", "")
151
  _timeout = int(_cfg.get("timeout_sec", "30"))
152
 
153
  # Command registry — local file first
 
206
  if _is_hardblocked(cmd_str):
207
  return False, "blocked by system policy"
208
 
209
+ # No chaining / redirection / substitution — one command, nothing else
210
+ if _SHELL_META.search(cmd_str):
211
+ return False, "shell metacharacters (; & | ` < > $( ) are not allowed"
212
+
213
+ # Match against registry — FIRST TOKEN must equal the template's first token
214
  registry = _load_registry()
215
+ tokens = cmd_str.lower().split()
216
+ if not tokens:
217
+ return False, "empty command"
218
 
219
  for cmd_id, cmd in registry.items():
220
  # Check both unix and win templates
221
  for os_key in ("unix", "win"):
222
+ parts = (cmd.get(os_key) or "").split() # empty template (e.g. unix-only command) → skip
223
+ if not parts:
224
+ continue
225
+ if tokens[0] == parts[0].lower():
226
  if not cmd.get("allowed", False):
227
  return False, f"command '{cmd_id}' is blocked in registry"
228
  return True, cmd_id
app/skills/README.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/skills/ — System-Prompts als Dateien
2
+
3
+ Eine Markdown-Datei hier = ein "Skill" für ein LLM-Tool. In `app/.pyfun`:
4
+
5
+ [TOOL.mein_tool]
6
+ provider_type = "llm"
7
+ system_prompt_file = "mein_skill.md" # wird NACH system_prompt angehängt (falls beides gesetzt)
8
+
9
+ - Nur `.md` / `.txt`, nur direkt in diesem Ordner (kein `../`), max. 20 000 Zeichen.
10
+ - Es wird **nur Text gelesen** und als `system`-Prompt gesendet. Aus diesem Ordner wird nie Code
11
+ importiert oder ausgeführt — eine Datei hier kann nichts ausführen.
12
+ - Änderungen werden nach einem Neustart wirksam (Cache). Fehlende Datei = Warnung beim Start.
13
+ - Skills von Dritten (z. B. Neons Agent-Skills) sind Anleitungen für Coding-Agents und gehören
14
+ nicht ungeprüft hierher — vorher lesen.
app/skills_db.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # app/skills_db.py
3
+ # Read-only access to the skill databases built by local_tools/vector_hub/secure_vectorizer.py
4
+ # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
5
+ # Copyright 2026 - Volkan Kücükbudak
6
+ # Apache License V. 2 + ESOL 1.1
7
+ # =============================================================================
8
+ # LAYOUT (app/db/skills/):
9
+ # core/core.db → CORE: the main skill with all restrictions. Never exposed through tools.
10
+ # Optionally injected as system-prompt prefix (SKILLS_INJECT_CORE / use_core).
11
+ # core/<other>.db → AGENT: reference for agents (e.g. core_agent.db). Searchable, not injected.
12
+ # user/*.db → USER skills (autoload). Searchable; best chunks can be injected per
13
+ # request (SKILLS_INJECT_RELEVANT / use_skills).
14
+ #
15
+ # WHAT IS USED: FTS5 / BM25 keyword search (same query + weights as the vectorizer).
16
+ # The optional semantic vectors (vec BLOB) are ignored — no embedding dependency in the hub.
17
+ #
18
+ # SAFETY:
19
+ # - DBs are opened READ-ONLY (mode=ro); nothing here writes, imports or executes skill content.
20
+ # - Skill text is plain data that ends up in a system prompt — only ship DBs you built yourself.
21
+ # - A broken / foreign DB (wrong schema) is skipped with a warning — never a crash.
22
+ # - SHIP DBs CHECKPOINTED: PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;
23
+ # and do not commit the *.db-wal / *.db-shm files.
24
+ # =============================================================================
25
+ import asyncio
26
+ import logging
27
+ import re
28
+ import shutil
29
+ import sqlite3
30
+ import tempfile
31
+ from pathlib import Path
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ from . import config
35
+
36
+ logger = logging.getLogger("skills_db")
37
+
38
+ _ROOT = Path(__file__).resolve().parent / "db" / "skills"
39
+ _TOKEN = re.compile(r"\w+", re.UNICODE)
40
+ _NEEDED = {"chunks", "tags", "meta", "fts"}
41
+
42
+ _dbs: Dict[str, List[Path]] = {"core": [], "agent": [], "user": []}
43
+ _open_as: Dict[Path, tuple] = {} # db path → (target, use_uri)
44
+ _core_text = ""
45
+ _active = False
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Open / validate
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def _connect(path: Path) -> sqlite3.Connection:
53
+ target, uri = _open_as[path]
54
+ return sqlite3.connect(target, uri=uri)
55
+
56
+
57
+ def _validate(path: Path) -> bool:
58
+ """Open read-only, check schema. WAL on a read-only dir → fall back to a private copy."""
59
+ attempts = [(f"{path.resolve().as_uri()}?mode=ro", True), None]
60
+ for attempt in attempts:
61
+ if attempt is None: # fallback: private temp copy (db + wal/shm), opened normally
62
+ tmp = Path(tempfile.mkdtemp(prefix="skills_"))
63
+ for suffix in ("", "-wal", "-shm"):
64
+ src = Path(str(path) + suffix)
65
+ if src.exists():
66
+ shutil.copy2(src, tmp / src.name)
67
+ attempt = (str(tmp / path.name), False)
68
+ try:
69
+ c = sqlite3.connect(attempt[0], uri=attempt[1])
70
+ names = {r[0] for r in c.execute("SELECT name FROM sqlite_master")}
71
+ if not _NEEDED <= names:
72
+ c.close()
73
+ logger.warning(f"Skills DB '{path.name}' skipped — not a vectorizer DB (missing {sorted(_NEEDED - names)}).")
74
+ return False
75
+ c.execute("SELECT 1 FROM chunks LIMIT 1").fetchall()
76
+ c.execute("SELECT rowid FROM fts WHERE fts MATCH 'x' LIMIT 1").fetchall() # FTS5 available?
77
+ c.close()
78
+ _open_as[path] = attempt
79
+ return True
80
+ except sqlite3.Error as e:
81
+ last = type(e).__name__
82
+ logger.warning(f"Skills DB '{path.name}' skipped — cannot open read-only ({last}).")
83
+ return False
84
+
85
+
86
+ def initialize() -> None:
87
+ """Scan app/db/skills/. Inactive or empty → stays inactive. Never raises."""
88
+ global _active, _core_text
89
+ cfg = config.get_skills()
90
+ if str(cfg.get("SKILLS_ACTIVE", "false")).lower() != "true":
91
+ logger.info("Skills DB not active in .pyfun [SKILLS] — skipped")
92
+ return
93
+ for k in _dbs:
94
+ _dbs[k].clear()
95
+ for path in sorted((_ROOT / "core").glob("*.db")) if (_ROOT / "core").is_dir() else []:
96
+ if _validate(path):
97
+ _dbs["core" if path.name == "core.db" else "agent"].append(path)
98
+ for path in sorted((_ROOT / "user").glob("*.db")) if (_ROOT / "user").is_dir() else []:
99
+ if _validate(path):
100
+ _dbs["user"].append(path)
101
+
102
+ # core text: all chunks of core.db, in order, capped
103
+ try:
104
+ cap = int(cfg.get("SKILLS_CORE_MAX_CHARS", "8000"))
105
+ except ValueError:
106
+ cap = 8000
107
+ parts = []
108
+ for p in _dbs["core"]:
109
+ c = _connect(p)
110
+ parts += [f"## {h}\n{t}" if h else t for h, t in c.execute("SELECT heading, text FROM chunks ORDER BY id")]
111
+ c.close()
112
+ _core_text = "\n\n".join(parts)[:cap]
113
+
114
+ _active = any(_dbs.values())
115
+ logger.info(f"Skills DB: core={len(_dbs['core'])} agent={len(_dbs['agent'])} user={len(_dbs['user'])} "
116
+ f"(core text: {len(_core_text)} chars)")
117
+
118
+
119
+ def is_ready() -> bool:
120
+ return _active
121
+
122
+
123
+ def core_text() -> str:
124
+ """The always-on core skill (restrictions). For system-prompt injection only — never via tools."""
125
+ return _core_text
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Search / catalog (sync, run in a thread by the async wrappers)
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def _fts_query(q: str) -> str:
133
+ toks = _TOKEN.findall((q or "").lower())
134
+ return " OR ".join(f'"{t}"*' for t in toks)
135
+
136
+
137
+ def _scope_paths(scope: str) -> List[Path]:
138
+ scope = (scope or "user").lower()
139
+ if scope == "all": # core is deliberately NOT included
140
+ return _dbs["agent"] + _dbs["user"]
141
+ if scope not in ("user", "agent"):
142
+ raise ValueError('scope must be "user", "agent" or "all"')
143
+ return _dbs[scope]
144
+
145
+
146
+ def _search_sync(query: str, top_k: int, tag: str, scope: str) -> List[Dict[str, Any]]:
147
+ tag = (tag or "").strip().lower() or None
148
+ fq = _fts_query(query)
149
+ if not fq and not tag:
150
+ return []
151
+ out: List[Dict[str, Any]] = []
152
+ for path in _scope_paths(scope):
153
+ c = _connect(path)
154
+ try:
155
+ if fq:
156
+ sql, params = "SELECT rowid FROM fts WHERE fts MATCH ?", [fq]
157
+ if tag:
158
+ sql += " AND rowid IN (SELECT c.id FROM chunks c JOIN tags t ON t.path=c.path WHERE t.tag=?)"
159
+ params.append(tag)
160
+ sql += " ORDER BY bm25(fts, 5.0, 1.0) LIMIT ?"
161
+ else:
162
+ sql = ("SELECT id FROM chunks WHERE path IN (SELECT path FROM tags WHERE tag=?) "
163
+ "ORDER BY id LIMIT ?")
164
+ params = [tag]
165
+ params.append(top_k)
166
+ for rank, (rid,) in enumerate(c.execute(sql, params).fetchall()):
167
+ row = c.execute(
168
+ "SELECT c.path, c.heading, c.text, m.name FROM chunks c "
169
+ "LEFT JOIN meta m ON m.path = c.path WHERE c.id = ?", (rid,)).fetchone()
170
+ if row:
171
+ out.append({"skill": row[0], "name": row[3] or row[0], "heading": row[1],
172
+ "text": row[2], "db": path.name, "rank": rank})
173
+ finally:
174
+ c.close()
175
+ out.sort(key=lambda r: r["rank"]) # best of each DB first (BM25 rank per DB)
176
+ return out[:top_k]
177
+
178
+
179
+ def _catalog_sync(scope: str, tag: str) -> List[Dict[str, Any]]:
180
+ tag = (tag or "").strip().lower() or None
181
+ out: List[Dict[str, Any]] = []
182
+ for path in _scope_paths(scope):
183
+ c = _connect(path)
184
+ try:
185
+ for p, name, desc in c.execute("SELECT path, name, description FROM meta ORDER BY path"):
186
+ tags = [r[0] for r in c.execute("SELECT tag FROM tags WHERE path=? ORDER BY tag", (p,))]
187
+ if tag and tag not in tags:
188
+ continue
189
+ n = c.execute("SELECT COUNT(*) FROM chunks WHERE path=?", (p,)).fetchone()[0]
190
+ out.append({"skill": p, "name": name, "description": desc, "tags": tags, "chunks": n, "db": path.name})
191
+ finally:
192
+ c.close()
193
+ return out
194
+
195
+
196
+ async def search(query: str = "", top_k: int = 3, tag: str = "", scope: str = "user") -> List[Dict[str, Any]]:
197
+ """BM25 search over skill chunks. scope: user (default) | agent | all. core is never searchable."""
198
+ if not _active:
199
+ raise RuntimeError("Skills DB not active — set SKILLS_ACTIVE = \"true\" in .pyfun [SKILLS]")
200
+ top_k = max(1, min(int(top_k or 3), 10))
201
+ return await asyncio.to_thread(_search_sync, query, top_k, tag, scope)
202
+
203
+
204
+ async def catalog(scope: str = "all", tag: str = "") -> List[Dict[str, Any]]:
205
+ """List skills (name, description, tags, chunk count). core is never listed."""
206
+ if not _active:
207
+ raise RuntimeError("Skills DB not active — set SKILLS_ACTIVE = \"true\" in .pyfun [SKILLS]")
208
+ return await asyncio.to_thread(_catalog_sync, scope, tag)
209
+
210
+
211
+ async def context_for(prompt: str, top_k: int = 3, max_chars: int = 3000) -> str:
212
+ """Best matching USER skill chunks for a prompt, formatted for a system prompt ('' if none)."""
213
+ if not _active or not _dbs["user"] or not (prompt or "").strip():
214
+ return ""
215
+ try:
216
+ hits = await asyncio.to_thread(_search_sync, prompt, max(1, min(int(top_k), 10)), "", "user")
217
+ except Exception as e:
218
+ logger.warning(f"Skills lookup failed ({type(e).__name__}) — continuing without skills.")
219
+ return ""
220
+ parts, used = [], 0
221
+ for h in hits:
222
+ block = f"### {h['name']} — {h['heading']}\n{h['text']}" if h["heading"] else f"### {h['name']}\n{h['text']}"
223
+ if used + len(block) > max_chars:
224
+ break
225
+ parts.append(block)
226
+ used += len(block)
227
+ return ("Relevant skills for this request:\n\n" + "\n\n".join(parts)) if parts else ""
app/tools.py CHANGED
@@ -23,6 +23,7 @@
23
 
24
  import logging
25
  import os
 
26
  from typing import Any, Dict, Optional
27
 
28
  from . import config # reads app/.pyfun — single source of truth
@@ -54,6 +55,125 @@ def initialize() -> None:
54
  global _registry
55
  _registry = config.get_active_tools()
56
  logger.info(f"Tools loaded: {list(_registry.keys())}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
 
59
  # =============================================================================
@@ -62,29 +182,32 @@ def initialize() -> None:
62
 
63
  async def run(
64
  tool_name: str,
65
- prompt: str,
66
  provider_name: Optional[str] = None,
67
  model: Optional[str] = None,
68
  max_tokens: int = 1024,
69
  max_results: int = 5,
 
70
  ) -> str:
71
  """
72
  Execute a tool by name.
73
- Reads tool config from registry, delegates to providers.py.
74
  Applies system_prompt from .pyfun if defined.
75
 
76
  Args:
77
  tool_name: Tool name as defined in .pyfun [TOOLS] (e.g. 'llm_complete').
78
- prompt: User input / query string.
79
  provider_name: Override provider. Defaults to tool's default_provider in .pyfun.
80
  model: Override model. Defaults to provider's default_model in .pyfun.
81
  max_tokens: Max tokens for LLM response. Default: 1024.
 
 
82
 
83
  Returns:
84
  Tool response as plain text string.
85
 
86
  Raises:
87
- ValueError: If tool_name is not found in registry.
88
  RuntimeError: If all providers fail (propagated from providers.py).
89
  """
90
  tool_cfg = _registry.get(tool_name)
@@ -94,25 +217,96 @@ async def run(
94
  provider_type = tool_cfg.get("provider_type", "llm")
95
  default_provider = provider_name or tool_cfg.get("default_provider", "")
96
  system_prompt = tool_cfg.get("system_prompt", "")
 
 
97
 
98
- # Build full prompt — prepend system_prompt if defined in .pyfun
99
- full_prompt = f"{system_prompt}\n\n{prompt}".strip() if system_prompt else prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  # --- LLM tools ---
 
 
102
  if provider_type == "llm":
103
- return await providers.llm_complete(
104
- prompt=full_prompt,
105
- provider_name=default_provider,
106
- model=model,
107
- max_tokens=max_tokens,
 
 
 
 
 
 
108
  )
109
 
110
  # --- Search tools ---
 
 
111
  if provider_type == "search":
112
- return await providers.search(
113
- query=prompt,
114
- provider_name=default_provider,
115
- max_results=int(tool_cfg.get("default_results", "5")),
 
 
 
 
116
  )
117
 
118
  # --- DB tools (read-only, delegated to db_sync when ready) ---
@@ -120,14 +314,41 @@ async def run(
120
  sql = prompt # prompt ist hier die SQL-Query
121
  return await db_sync.query(sql)
122
 
123
- # in run() — neuer persist-Block:
 
 
 
124
  if provider_type == "persist":
125
- # hub_state lesen + an PSQL schicken via db_sync bridge
126
- key = tool_cfg.get("state_read_key", "")
127
- table = tool_cfg.get("target_table", "hub_results")
 
 
 
 
 
 
128
  data = await db_sync.read(key) if key else {"prompt": prompt}
129
- await db_sync.persist(table, data)
130
- return f"Persisted to PostgreSQL table '{table}'."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  # --- Unknown provider type ---
133
  logger.warning(f"Tool '{tool_name}' has unknown provider_type '{provider_type}' — skipped.")
 
23
 
24
  import logging
25
  import os
26
+ from pathlib import Path
27
  from typing import Any, Dict, Optional
28
 
29
  from . import config # reads app/.pyfun — single source of truth
 
55
  global _registry
56
  _registry = config.get_active_tools()
57
  logger.info(f"Tools loaded: {list(_registry.keys())}")
58
+ for tname, tcfg in _registry.items():
59
+ skill = tcfg.get("system_prompt_file", "")
60
+ if skill:
61
+ try:
62
+ _load_skill(skill)
63
+ logger.info(f"Tool '{tname}': skill '{skill}' loaded.")
64
+ except ValueError as e:
65
+ logger.warning(f"Tool '{tname}': {e}")
66
+
67
+
68
+ # =============================================================================
69
+ # Health — shared by the MCP tool and the REST gateway. Cheap, never raises, never blocks long.
70
+ # =============================================================================
71
+
72
+ async def health() -> Dict[str, Any]:
73
+ """Hub status incl. PostgreSQL awake-check (short ping), queue depth, providers, integrations."""
74
+ from . import pricing, discord_api, gsc
75
+ db = await db_sync.pg_status()
76
+ try:
77
+ queue = (await db_sync.persist_queue_stats()).get("queue_depth")
78
+ except Exception:
79
+ queue = None
80
+ degraded = db.get("bridge") == "active" and db.get("state") != "up"
81
+ return {
82
+ "status": "degraded" if degraded else "ok",
83
+ "service": "Universal MCP Hub",
84
+ "database": db,
85
+ "persist_queue": queue,
86
+ "llm_providers": providers.list_active_llm(),
87
+ "search_providers": providers.list_active_search(),
88
+ "pricing_ready": pricing.is_ready(),
89
+ "stale_models": pricing.check_models(),
90
+ "integrations": {"discord": discord_api.is_ready(), "gsc": gsc.is_ready()},
91
+ }
92
+
93
+
94
+ # =============================================================================
95
+ # Skills — system prompts as plain Markdown files in app/skills/
96
+ # .pyfun [TOOL.xyz]: system_prompt_file = "my_skill.md" (combined with system_prompt if both set)
97
+ # DATA only: the file is read as text and sent as system prompt. No code is ever executed
98
+ # or imported from app/skills/ — dropping a file there cannot run anything.
99
+ # =============================================================================
100
+ _SKILLS_DIR = Path(__file__).resolve().parent / "skills"
101
+ _SKILL_MAX_CHARS = 20000
102
+ _skill_cache: Dict[str, str] = {}
103
+
104
+
105
+ def _load_skill(name: str) -> str:
106
+ """Read app/skills/<name> (.md/.txt only, no path tricks). Cached until restart."""
107
+ name = (name or "").strip()
108
+ if not name:
109
+ return ""
110
+ if name in _skill_cache:
111
+ return _skill_cache[name]
112
+ path = (_SKILLS_DIR / name).resolve()
113
+ if _SKILLS_DIR not in path.parents or path.suffix.lower() not in (".md", ".txt"):
114
+ raise ValueError(f"system_prompt_file '{name}' must be a .md/.txt file inside app/skills/")
115
+ try:
116
+ text = path.read_text(encoding="utf-8").strip()[:_SKILL_MAX_CHARS]
117
+ except OSError:
118
+ raise ValueError(f"system_prompt_file '{name}' not found in app/skills/") from None
119
+ _skill_cache[name] = text
120
+ return text
121
+
122
+
123
+ # =============================================================================
124
+ # Discord post (hub → Discord). Inbound (/discord route, /ask) lives in app.py.
125
+ # =============================================================================
126
+
127
+ async def _run_discord(content: str, params: Dict[str, Any]) -> Dict[str, Any]:
128
+ """
129
+ Post `content` to Discord. mode: "auto" (default) | "webhook" | "bot".
130
+ A channel_id override is only accepted if listed in [DISCORD] DISCORD_ALLOWED_CHANNELS
131
+ (or equals DISCORD_DEFAULT_CHANNEL) — a hub client must not be able to make the bot
132
+ write into arbitrary channels.
133
+ """
134
+ from . import discord_api
135
+ cfg = config.get_discord()
136
+ allowed = {c.strip() for c in str(cfg.get("DISCORD_ALLOWED_CHANNELS", "")).split(",") if c.strip()}
137
+ if cfg.get("DISCORD_DEFAULT_CHANNEL"):
138
+ allowed.add(str(cfg["DISCORD_DEFAULT_CHANNEL"]).strip())
139
+
140
+ channel = str(params.get("channel_id") or "").strip()
141
+ if channel and channel not in allowed:
142
+ return {"ok": False, "error": "channel_id not allowed — add it to DISCORD_ALLOWED_CHANNELS in .pyfun"}
143
+
144
+ mode = str(params.get("mode") or "auto").lower()
145
+ if mode == "webhook" or (mode == "auto" and not channel and discord_api.get_status()["webhook_set"]):
146
+ r = await discord_api.send_webhook(str(content))
147
+ else:
148
+ r = await discord_api.send_message(channel, str(content))
149
+ return {"ok": bool(r.get("ok")), "error": r.get("error"), "message_id": (r.get("data") or {}).get("id")}
150
+
151
+
152
+ # =============================================================================
153
+ # Response cache helper (opt-in per tool, backed by db_sync.tool_cache)
154
+ # =============================================================================
155
+
156
+ async def _cached(tool_name: str, key: str, tool_cfg: Dict, enabled: bool, call) -> str:
157
+ """Run `call()` (async callable); with cache = "true" serve/store via db_sync. Never breaks the call."""
158
+ if not enabled:
159
+ return await call()
160
+ try:
161
+ ttl = int(tool_cfg.get("cache_ttl_sec", "3600"))
162
+ except (TypeError, ValueError):
163
+ ttl = 3600
164
+ try:
165
+ hit = await db_sync.cache_read(tool_name, key, max_age_sec=ttl)
166
+ if hit is not None:
167
+ logger.info(f"Tool '{tool_name}': cache hit.")
168
+ return hit
169
+ except Exception as e:
170
+ logger.debug(f"Cache read skipped: {type(e).__name__}")
171
+ result = await call()
172
+ try:
173
+ await db_sync.cache_write(tool_name, key, result)
174
+ except Exception as e:
175
+ logger.debug(f"Cache write skipped: {type(e).__name__}")
176
+ return result
177
 
178
 
179
  # =============================================================================
 
182
 
183
  async def run(
184
  tool_name: str,
185
+ prompt: Optional[str] = None,
186
  provider_name: Optional[str] = None,
187
  model: Optional[str] = None,
188
  max_tokens: int = 1024,
189
  max_results: int = 5,
190
+ **params: Any,
191
  ) -> str:
192
  """
193
  Execute a tool by name.
194
+ Reads tool config from registry, delegates to providers.py / db_sync.py.
195
  Applies system_prompt from .pyfun if defined.
196
 
197
  Args:
198
  tool_name: Tool name as defined in .pyfun [TOOLS] (e.g. 'llm_complete').
199
+ prompt: User input / query string. For db tools this is the SQL query.
200
  provider_name: Override provider. Defaults to tool's default_provider in .pyfun.
201
  model: Override model. Defaults to provider's default_model in .pyfun.
202
  max_tokens: Max tokens for LLM response. Default: 1024.
203
+ max_results: Max results for search tools. Default: 5.
204
+ **params: Ignored extras — keeps /api calls with unknown keys from crashing.
205
 
206
  Returns:
207
  Tool response as plain text string.
208
 
209
  Raises:
210
+ ValueError: If tool_name is not found in registry or prompt is missing.
211
  RuntimeError: If all providers fail (propagated from providers.py).
212
  """
213
  tool_cfg = _registry.get(tool_name)
 
217
  provider_type = tool_cfg.get("provider_type", "llm")
218
  default_provider = provider_name or tool_cfg.get("default_provider", "")
219
  system_prompt = tool_cfg.get("system_prompt", "")
220
+ if tool_cfg.get("system_prompt_file"):
221
+ system_prompt = f"{system_prompt}\n\n{_load_skill(tool_cfg['system_prompt_file'])}".strip()
222
 
223
+ # Skill DBs (app/skills_db.py): core rules first, relevant user skills last. Opt-in (costs tokens).
224
+ if provider_type == "llm":
225
+ from . import skills_db
226
+ scfg = config.get_skills()
227
+
228
+ def _on(tool_key: str, global_key: str) -> bool:
229
+ return str(tool_cfg.get(tool_key, scfg.get(global_key, "false"))).lower() == "true"
230
+
231
+ if skills_db.is_ready():
232
+ if _on("use_core", "SKILLS_INJECT_CORE") and skills_db.core_text():
233
+ system_prompt = f"{skills_db.core_text()}\n\n{system_prompt}".strip()
234
+ if _on("use_skills", "SKILLS_INJECT_RELEVANT"):
235
+ ctx = await skills_db.context_for(
236
+ str(prompt or ""), int(scfg.get("SKILLS_TOP_K", "3")), int(scfg.get("SKILLS_MAX_CHARS", "3000")))
237
+ if ctx:
238
+ system_prompt = f"{system_prompt}\n\n{ctx}".strip()
239
+
240
+ # prompt ist für llm/search/db Pflicht — sauberer 400er statt TypeError/500er
241
+ # persist ist die Ausnahme: ohne state_read_key braucht es einen prompt,
242
+ # mit state_read_key liest es stattdessen aus hub_state (Gedächtnis-Pattern).
243
+ needs_prompt = provider_type in ("llm", "search", "db", "discord") or \
244
+ (provider_type == "skills" and tool_cfg.get("skills_action") == "search")
245
+ if needs_prompt and (prompt is None or str(prompt).strip() == ""):
246
+ raise ValueError(
247
+ f"Tool '{tool_name}' requires a 'prompt' parameter (db: the SQL query, discord: the message)."
248
+ )
249
+ if provider_type == "persist" and not tool_cfg.get("state_read_key", "") \
250
+ and (prompt is None or str(prompt).strip() == ""):
251
+ raise ValueError(
252
+ "persist_result requires 'prompt' (or set state_read_key in .pyfun [TOOL.persist_result])."
253
+ )
254
+
255
+ # --- Skill DBs (read-only): catalog / search ---
256
+ if provider_type == "skills":
257
+ from . import skills_db
258
+ if tool_cfg.get("skills_action") == "catalog":
259
+ return await skills_db.catalog(scope=str(params.get("scope") or "all"), tag=str(params.get("tag") or ""))
260
+ return await skills_db.search(
261
+ query=str(prompt or ""), top_k=params.get("top_k", 3),
262
+ tag=str(params.get("tag") or ""), scope=str(params.get("scope") or "user"))
263
+
264
+ # --- Discord post ---
265
+ if provider_type == "discord":
266
+ return await _run_discord(prompt, params)
267
+
268
+ # --- Google Search Console (optional libs, see app/gsc.py) ---
269
+ if provider_type == "gsc":
270
+ from . import gsc
271
+ return await gsc.query(
272
+ property_url=str(params.get("property_url") or prompt or ""),
273
+ days_back=params.get("days_back", 30),
274
+ row_limit=params.get("row_limit", 10),
275
+ dimension=params.get("dimension", "query"),
276
+ )
277
+
278
+ # Opt-in response cache per tool: cache = "true" (+ cache_ttl_sec) in .pyfun [TOOL.xyz]
279
+ use_cache = str(tool_cfg.get("cache", "false")).lower() == "true"
280
 
281
  # --- LLM tools ---
282
+ # system_prompt goes out as a REAL system prompt (no longer glued into the user prompt).
283
+ # Only a model given (no provider) → provider is derived from .pyfun [MODELS].
284
  if provider_type == "llm":
285
+ prov = provider_name or providers.provider_for_model(model) or tool_cfg.get("default_provider", "")
286
+ return await _cached(
287
+ tool_name, f"{prov}|{model or ''}|{max_tokens}|{system_prompt}|{prompt}", tool_cfg, use_cache,
288
+ lambda: providers.llm_complete(
289
+ prompt=prompt,
290
+ provider_name=prov,
291
+ model=model,
292
+ max_tokens=max_tokens,
293
+ system=system_prompt or None,
294
+ tool_name=tool_name,
295
+ ),
296
  )
297
 
298
  # --- Search tools ---
299
+ # providers.search() ignores provider names that are not search providers
300
+ # (e.g. a GUI sending its LLM provider along).
301
  if provider_type == "search":
302
+ n_results = max_results or int(tool_cfg.get("default_results", "5"))
303
+ return await _cached(
304
+ tool_name, f"{default_provider}|{n_results}|{prompt}", tool_cfg, use_cache,
305
+ lambda: providers.search(
306
+ query=prompt,
307
+ provider_name=default_provider,
308
+ max_results=n_results,
309
+ ),
310
  )
311
 
312
  # --- DB tools (read-only, delegated to db_sync when ready) ---
 
314
  sql = prompt # prompt ist hier die SQL-Query
315
  return await db_sync.query(sql)
316
 
317
+ # --- Persist tools (Gedächtnis-Orchestrierung: SQLite-Queue → PostgreSQL) ---
318
+ # NIEMALS direkt an PG schreiben — enqueue in die restart-safe Queue,
319
+ # dann EINEN best-effort Flush-Versuch. Ohne DATABASE_URL = status "queued",
320
+ # kein Crash. Der Background-Flusher (db_sync) retries später automatisch.
321
  if provider_type == "persist":
322
+ key = tool_cfg.get("state_read_key", "")
323
+ table = tool_cfg.get("target_table", "hub_results")
324
+ source = str(params.get("source") or tool_name)
325
+ # score: optionaler Slot für den Bullshit-Filter (Nutzer-eigenes Scoring).
326
+ # Wird 1:1 durchgereicht — KEIN Tool hier interpretiert oder berechnet ihn.
327
+ try:
328
+ score = float(params["score"]) if params.get("score") is not None else None
329
+ except (TypeError, ValueError):
330
+ score = None
331
  data = await db_sync.read(key) if key else {"prompt": prompt}
332
+ depth = await db_sync.enqueue_persist(
333
+ table, payload=data, source=source, model=model or "", score=score,
334
+ )
335
+ # best effort — waits at most PG_REQUEST_WAIT_SEC; a sleeping Neon never blocks the request
336
+ flush = await db_sync.flush_persist_queue_bounded()
337
+ if flush.get("flushed", 0) > 0:
338
+ status, note = "persisted", "written to PostgreSQL"
339
+ else:
340
+ status = "queued"
341
+ note = {
342
+ "pg_slow": "PostgreSQL is slow/waking up — stays queued (SQLite) and flushes in the background",
343
+ "pg_backoff": "PostgreSQL unreachable a moment ago — stays queued (SQLite), retried automatically",
344
+ }.get(flush.get("reason"), "restart-safe in SQLite — flushes to PostgreSQL when the bridge becomes active")
345
+ return {
346
+ "status": status,
347
+ "target_table": table,
348
+ "queue_depth": depth,
349
+ "flushed": flush.get("flushed", 0),
350
+ "note": note,
351
+ }
352
 
353
  # --- Unknown provider type ---
354
  logger.warning(f"Tool '{tool_name}' has unknown provider_type '{provider_type}' — skipped.")
desktop_ui.py ADDED
@@ -0,0 +1,978 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # mcp_desktop.py
3
+ # Universal MCP Desktop Client
4
+ # Copyright 2026 - Volkan Kücükbudak
5
+ # Apache License V. 2 + ESOL 1.1
6
+ # Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed
7
+ # =============================================================================
8
+ # USAGE:
9
+ # pip install PySide6 httpx Pillow PyPDF2 pandas openpyxl
10
+ # python mcp_desktop.py
11
+ #
12
+ # CONNECT:
13
+ # 1. Enter HF Token + Hub URL in Settings tab → Save
14
+ # 2. Go to Connect tab → Connect
15
+ # 3. Use Chat tab — select Tool, Provider, Model, upload files
16
+ # =============================================================================
17
+ # 7 Bugs from Claude AI fixed by Human 14.03.2026 but amazing work from ClaudeAi
18
+
19
+ import sys
20
+ import json
21
+ import asyncio
22
+ import httpx
23
+ import io
24
+ import base64
25
+ import zipfile
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+
29
+ from PySide6.QtWidgets import (
30
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
31
+ QPushButton, QLineEdit, QTextEdit, QLabel, QTabWidget,
32
+ QStatusBar, QComboBox, QSpinBox, QFileDialog
33
+ )
34
+ from PySide6.QtCore import QThread, Signal, QObject
35
+
36
+ # Optional file handling imports — graceful degradation if missing
37
+ try:
38
+ from PIL import Image
39
+ HAS_PIL = True
40
+ except ImportError:
41
+ HAS_PIL = False
42
+
43
+ try:
44
+ import PyPDF2
45
+ HAS_PDF = True
46
+ except ImportError:
47
+ HAS_PDF = False
48
+
49
+ try:
50
+ import pandas as pd
51
+ HAS_PANDAS = True
52
+ except ImportError:
53
+ HAS_PANDAS = False
54
+
55
+
56
+ # =============================================================================
57
+ # Local config — saved to ~/.mcp_desktop.json
58
+ # =============================================================================
59
+
60
+ CONFIG_PATH = Path(__file__).parent / "mcp_desktop.json"
61
+ # old CONFIG_PATH = Path.home() / ".mcp_desktop.json"
62
+
63
+ def load_config() -> dict:
64
+ """Load local config. Returns defaults if not found."""
65
+ if CONFIG_PATH.exists():
66
+ try:
67
+ return json.loads(CONFIG_PATH.read_text())
68
+ except Exception:
69
+ pass
70
+ return {
71
+ "hf_token": "",
72
+ "hub_url": "",
73
+ "default_provider": "",
74
+ "default_model": "",
75
+ "default_tool": "llm_complete",
76
+ "font_size": 14,
77
+ "chats": {}, # chat_id → {"title": str, "messages": [...]}
78
+ }
79
+
80
+ def save_config(cfg: dict) -> None:
81
+ """Save config to mcp_desktop.json."""
82
+ try:
83
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
84
+ except Exception as e:
85
+ print(f"[save_config] WARNING: Could not save config: {e}")
86
+
87
+
88
+ # =============================================================================
89
+ # File Handler — extracted from Streamlit app
90
+ # Supports: images, text/code, PDF, CSV/XLSX, ZIP
91
+ # =============================================================================
92
+
93
+ def encode_image(image) -> str:
94
+ """Encode PIL image to base64 JPEG string."""
95
+ buffered = io.BytesIO()
96
+ image.save(buffered, format="JPEG")
97
+ return base64.b64encode(buffered.getvalue()).decode("utf-8")
98
+
99
+
100
+ def process_file(file_path: str) -> dict:
101
+ """
102
+ Process uploaded file into text or image content.
103
+ Returns dict: {"type": "text"|"image"|"error", "content": ...}
104
+ Supports: jpg/png, txt/code, pdf, csv/xlsx, zip
105
+ """
106
+ path = Path(file_path)
107
+ file_type = path.suffix.lower().lstrip(".")
108
+
109
+ # Images — requires Pillow
110
+ if file_type in ["jpg", "jpeg", "png"]:
111
+ if not HAS_PIL:
112
+ return {"type": "error", "content": "Pillow not installed — pip install Pillow"}
113
+ img = Image.open(file_path).convert("RGB")
114
+ return {"type": "image", "content": img, "b64": encode_image(img)}
115
+
116
+ # Text / code files
117
+ code_extensions = ["html", "css", "php", "js", "py", "java", "c", "cpp",
118
+ "ts", "go", "rb", "rs", "sh", "sql", "json", "xml", "md"]
119
+ if file_type in ["txt"] + code_extensions:
120
+ try:
121
+ return {"type": "text", "content": path.read_text(encoding="utf-8")}
122
+ except Exception as e:
123
+ return {"type": "error", "content": str(e)}
124
+
125
+ # CSV / Excel — requires pandas
126
+ if file_type in ["csv", "xlsx"]:
127
+ if not HAS_PANDAS:
128
+ return {"type": "error", "content": "pandas not installed — pip install pandas openpyxl"}
129
+ try:
130
+ df = pd.read_csv(file_path) if file_type == "csv" else pd.read_excel(file_path)
131
+ return {"type": "text", "content": df.to_string()}
132
+ except Exception as e:
133
+ return {"type": "error", "content": str(e)}
134
+
135
+ # PDF — requires PyPDF2
136
+ if file_type == "pdf":
137
+ if not HAS_PDF:
138
+ return {"type": "error", "content": "PyPDF2 not installed — pip install PyPDF2"}
139
+ try:
140
+ reader = PyPDF2.PdfReader(file_path)
141
+ content = "".join(
142
+ page.extract_text() for page in reader.pages if page.extract_text()
143
+ )
144
+ return {"type": "text", "content": content}
145
+ except Exception as e:
146
+ return {"type": "error", "content": str(e)}
147
+
148
+ # ZIP — recursive text extraction
149
+ if file_type == "zip":
150
+ try:
151
+ text_extensions = (
152
+ ".txt", ".csv", ".py", ".html", ".js", ".css", ".php",
153
+ ".json", ".xml", ".c", ".cpp", ".java", ".cs", ".rb",
154
+ ".go", ".ts", ".swift", ".kt", ".rs", ".sh", ".sql", ".md"
155
+ )
156
+ content = "ZIP Contents:\n"
157
+ with zipfile.ZipFile(file_path) as z:
158
+ for info in z.infolist():
159
+ if info.is_dir():
160
+ continue
161
+ try:
162
+ with z.open(info.filename) as f:
163
+ if info.filename.lower().endswith(text_extensions):
164
+ content += f"\n📄 {info.filename}:\n{f.read().decode('utf-8')}\n"
165
+ else:
166
+ try:
167
+ content += f"\n📄 {info.filename}:\n{f.read().decode('utf-8')}\n"
168
+ except UnicodeDecodeError:
169
+ content += f"\n⚠️ Binary ignored: {info.filename}\n"
170
+ except Exception as e:
171
+ content += f"\n❌ Error in {info.filename}: {e}\n"
172
+ return {"type": "text", "content": content}
173
+ except Exception as e:
174
+ return {"type": "error", "content": str(e)}
175
+
176
+ return {"type": "error", "content": f"Unsupported format: .{file_type}"}
177
+
178
+
179
+ # =============================================================================
180
+ # Async Worker — all Hub HTTP communication
181
+ # =============================================================================
182
+ class AsyncWorker(QObject):
183
+ result = Signal(str)
184
+ error = Signal(str)
185
+ log = Signal(str)
186
+ tools = Signal(dict)
187
+ status = Signal(str)
188
+
189
+ def __init__(self, hub_url: str, hf_token: str):
190
+ super().__init__()
191
+ self.hub_url = hub_url.rstrip("/")
192
+ self.headers = {"Authorization": f"Bearer {hf_token}"}
193
+
194
+ def _run(self, coro):
195
+ """Run coroutine in fresh event loop — thread safe."""
196
+ loop = asyncio.new_event_loop()
197
+ try:
198
+ return loop.run_until_complete(coro)
199
+ finally:
200
+ loop.close()
201
+
202
+ def health_check(self):
203
+ """GET / — returns uptime + status."""
204
+ async def _do():
205
+ try:
206
+ async with httpx.AsyncClient() as client:
207
+ r = await client.get(f"{self.hub_url}/", headers=self.headers, timeout=10)
208
+ data = r.json()
209
+ self.status.emit(f"● connected — uptime: {data.get('uptime_seconds', '?')}s")
210
+ self.log.emit(f"[health] {json.dumps(data)}")
211
+ except Exception as e:
212
+ self.status.emit("✗ disconnected")
213
+ self.error.emit(f"Health check failed: {e}")
214
+ self._run(_do())
215
+
216
+ def fetch_tools(self):
217
+ """POST /api → list_active_tools — returns tools + providers + models."""
218
+ async def _do():
219
+ try:
220
+ async with httpx.AsyncClient() as client:
221
+ r = await client.post(
222
+ f"{self.hub_url}/api",
223
+ headers={**self.headers, "Content-Type": "application/json"},
224
+ json={"tool": "list_active_tools", "params": {}},
225
+ timeout=15,
226
+ )
227
+ data = r.json()
228
+ self.tools.emit(data)
229
+ self.log.emit(f"[tools] {json.dumps(data)}")
230
+ except Exception as e:
231
+ self.error.emit(f"Fetch tools failed: {e}")
232
+ self._run(_do())
233
+
234
+ def call_tool(self, tool_name: str, prompt: str, provider: str = None, model: str = None):
235
+ """
236
+ Generic tool call — POST /api with tool_name + params.
237
+ Handles all tools: llm_complete, summarize, translate, db_query, etc.
238
+ db_query uses 'sql' param instead of 'prompt'.
239
+ """
240
+ async def _do():
241
+ try:
242
+ # db_query needs 'sql' not 'prompt'
243
+ if tool_name == "db_query":
244
+ params = {"sql": prompt}
245
+ else:
246
+ params = {
247
+ "prompt": prompt,
248
+ "provider_name": provider or "",
249
+ "model": model or "",
250
+ "max_tokens": 1024,
251
+ }
252
+
253
+ async with httpx.AsyncClient() as client:
254
+ r = await client.post(
255
+ f"{self.hub_url}/api",
256
+ headers={**self.headers, "Content-Type": "application/json"},
257
+ json={"tool": tool_name, "params": params},
258
+ timeout=60,
259
+ )
260
+ data = r.json()
261
+ response = data.get("result", data.get("error", str(data)))
262
+ if isinstance(response, (dict, list)):
263
+ response = json.dumps(response, indent=2)
264
+ self.result.emit(str(response))
265
+ self.log.emit(f"[{tool_name}] prompt: {prompt[:60]}...")
266
+ except Exception as e:
267
+ self.error.emit(f"Tool call failed: {e}")
268
+ self._run(_do())
269
+
270
+
271
+ # =============================================================================
272
+ # Worker Thread
273
+ # =============================================================================
274
+ class WorkerThread(QThread):
275
+ def __init__(self, fn):
276
+ super().__init__()
277
+ self.fn = fn
278
+
279
+ def run(self):
280
+ self.fn()
281
+
282
+
283
+ # =============================================================================
284
+ # Main Window
285
+ # =============================================================================
286
+ class MCPDesktop(QMainWindow):
287
+
288
+ # GitHub dark theme
289
+ STYLE = """
290
+ QMainWindow, QWidget {{
291
+ background-color: #0d1117;
292
+ color: #e6edf3;
293
+ font-family: 'Consolas', monospace;
294
+ font-size: {font_size}px;
295
+ }}
296
+ QTabWidget::pane {{
297
+ border: 1px solid #21262d;
298
+ background: #0d1117;
299
+ }}
300
+ QTabBar::tab {{
301
+ background: #161b22;
302
+ color: #8b949e;
303
+ padding: 8px 16px;
304
+ border: 1px solid #21262d;
305
+ border-bottom: none;
306
+ }}
307
+ QTabBar::tab:selected {{
308
+ background: #0d1117;
309
+ color: #58a6ff;
310
+ border-bottom: 2px solid #58a6ff;
311
+ }}
312
+ QTabBar::tab:hover {{ color: #e6edf3; }}
313
+ QLineEdit {{
314
+ background: #161b22;
315
+ border: 1px solid #21262d;
316
+ border-radius: 4px;
317
+ padding: 6px 10px;
318
+ color: #e6edf3;
319
+ }}
320
+ QLineEdit:focus {{ border-color: #58a6ff; }}
321
+ QTextEdit {{
322
+ background: #161b22;
323
+ border: 1px solid #21262d;
324
+ border-radius: 4px;
325
+ padding: 8px;
326
+ color: #e6edf3;
327
+ font-family: 'Consolas', monospace;
328
+ }}
329
+ QPushButton {{
330
+ background: #21262d;
331
+ color: #e6edf3;
332
+ border: 1px solid #30363d;
333
+ border-radius: 4px;
334
+ padding: 6px 14px;
335
+ font-weight: bold;
336
+ }}
337
+ QPushButton:hover {{
338
+ background: #30363d;
339
+ border-color: #58a6ff;
340
+ color: #58a6ff;
341
+ }}
342
+ QPushButton:pressed {{ background: #161b22; }}
343
+ QPushButton#btn_connect {{
344
+ background: #238636; border-color: #2ea043; color: #fff;
345
+ }}
346
+ QPushButton#btn_connect:hover {{ background: #2ea043; }}
347
+ QPushButton#btn_send {{
348
+ background: #1f6feb; border-color: #388bfd; color: #fff; min-width: 80px;
349
+ }}
350
+ QPushButton#btn_send:hover {{ background: #388bfd; }}
351
+ QPushButton#btn_save {{
352
+ background: #6e40c9; border-color: #8957e5; color: #fff;
353
+ }}
354
+ QPushButton#btn_save:hover {{ background: #8957e5; }}
355
+ QPushButton#btn_new_chat {{
356
+ background: #0d1117; border-color: #238636; color: #3fb950; padding: 4px 10px;
357
+ }}
358
+ QPushButton#btn_new_chat:hover {{ background: #238636; color: #fff; }}
359
+ QComboBox {{
360
+ background: #161b22;
361
+ border: 1px solid #21262d;
362
+ border-radius: 4px;
363
+ padding: 5px 8px;
364
+ color: #e6edf3;
365
+ }}
366
+ QComboBox QAbstractItemView {{
367
+ background: #161b22;
368
+ border: 1px solid #30363d;
369
+ color: #e6edf3;
370
+ selection-background-color: #21262d;
371
+ }}
372
+ QSpinBox {{
373
+ background: #161b22;
374
+ border: 1px solid #21262d;
375
+ border-radius: 4px;
376
+ padding: 4px 8px;
377
+ color: #e6edf3;
378
+ }}
379
+ QStatusBar {{
380
+ background: #161b22;
381
+ color: #8b949e;
382
+ border-top: 1px solid #21262d;
383
+ font-size: 12px;
384
+ }}
385
+ """
386
+
387
+ def __init__(self):
388
+ super().__init__()
389
+ self.cfg = load_config()
390
+ self._threads = [] # list — prevents GC while running
391
+ self._file_cache = None # currently loaded file
392
+ self._current_chat_id = None
393
+ self._tools_loading = False # prevent duplicate tool fetches
394
+ self._last_models = [] # Bug 3 fix: cached models for tool filter
395
+ self._last_llm_providers = [] # cached providers
396
+
397
+ self.setWindowTitle("Universal MCP Desktop")
398
+ self.setMinimumSize(1000, 720)
399
+ self._apply_style()
400
+ self._build_ui()
401
+ self._set_status("✗ not connected")
402
+ self._load_last_chat()
403
+
404
+ def _apply_style(self):
405
+ self.setStyleSheet(self.STYLE.format(font_size=self.cfg.get("font_size", 14)))
406
+
407
+ # =========================================================================
408
+ # UI Build
409
+ # =========================================================================
410
+ def _build_ui(self):
411
+ central = QWidget()
412
+ layout = QVBoxLayout(central)
413
+ layout.setContentsMargins(0, 0, 0, 0)
414
+ layout.setSpacing(0)
415
+
416
+ layout.addWidget(self._build_header())
417
+
418
+ self.tabs = QTabWidget()
419
+ self.tabs.addTab(self._tab_chat(), "💬 Chat")
420
+ self.tabs.addTab(self._tab_tools(), "🛠 Tools")
421
+ self.tabs.addTab(self._tab_connect(), "🔌 Connect")
422
+ self.tabs.addTab(self._tab_settings(), "⚙ Settings")
423
+ self.tabs.addTab(self._tab_logs(), "📋 Logs")
424
+ layout.addWidget(self.tabs)
425
+
426
+ self.setCentralWidget(central)
427
+ self.status_bar = QStatusBar()
428
+ self.setStatusBar(self.status_bar)
429
+
430
+ def _build_header(self) -> QWidget:
431
+ """Header bar: title | Tool | Provider | Model | status"""
432
+ header = QWidget()
433
+ header.setFixedHeight(48)
434
+ header.setStyleSheet("background: #161b22; border-bottom: 1px solid #21262d;")
435
+ row = QHBoxLayout(header)
436
+ row.setContentsMargins(16, 0, 16, 0)
437
+ row.setSpacing(8)
438
+
439
+ title = QLabel("⬡ Universal MCP Desktop")
440
+ title.setStyleSheet("color: #58a6ff; font-size: 15px; font-weight: bold;")
441
+ row.addWidget(title)
442
+ row.addSpacing(16)
443
+
444
+ # Tool selector — in header, always visible
445
+ row.addWidget(self._small_label("Tool:"))
446
+ self.tool_select = QComboBox()
447
+ self.tool_select.addItem("llm_complete")
448
+ self.tool_select.setMinimumWidth(130)
449
+ row.addWidget(self.tool_select)
450
+
451
+ # Provider selector
452
+ row.addWidget(self._small_label("Provider:"))
453
+ self.provider_select = QComboBox()
454
+ self.provider_select.addItem("default")
455
+ self.provider_select.setMinimumWidth(120)
456
+ row.addWidget(self.provider_select)
457
+
458
+ # Model selector
459
+ row.addWidget(self._small_label("Model:"))
460
+ self.model_select = QComboBox()
461
+ self.model_select.addItem("default")
462
+ self.model_select.setMinimumWidth(200)
463
+ row.addWidget(self.model_select)
464
+
465
+ row.addStretch()
466
+
467
+ self.status_label = QLabel("✗ not connected")
468
+ self.status_label.setStyleSheet("color: #f85149; font-size: 12px;")
469
+ row.addWidget(self.status_label)
470
+
471
+ return header
472
+
473
+ # =========================================================================
474
+ # Tab: Chat — multi-chat + file upload
475
+ # =========================================================================
476
+ def _tab_chat(self) -> QWidget:
477
+ tab = QWidget()
478
+ layout = QVBoxLayout(tab)
479
+ layout.setContentsMargins(12, 12, 12, 12)
480
+ layout.setSpacing(6)
481
+
482
+ # Chat selector row
483
+ chat_row = QHBoxLayout()
484
+ chat_row.addWidget(QLabel("Chat:"))
485
+ self.chat_select = QComboBox()
486
+ self.chat_select.setMinimumWidth(280)
487
+ self.chat_select.currentIndexChanged.connect(self._on_chat_selected)
488
+ chat_row.addWidget(self.chat_select)
489
+
490
+ new_btn = QPushButton("+ New")
491
+ new_btn.setObjectName("btn_new_chat")
492
+ new_btn.clicked.connect(self._new_chat)
493
+ chat_row.addWidget(new_btn)
494
+
495
+ del_btn = QPushButton("🗑")
496
+ del_btn.setToolTip("Delete current chat")
497
+ del_btn.clicked.connect(self._delete_chat)
498
+ chat_row.addWidget(del_btn)
499
+
500
+ chat_row.addStretch()
501
+ layout.addLayout(chat_row)
502
+
503
+ # Chat output
504
+ self.chat_output = QTextEdit()
505
+ self.chat_output.setReadOnly(True)
506
+ self.chat_output.setPlaceholderText(
507
+ "Connect in Connect tab first — Tool/Provider/Model selectable in header bar above"
508
+ )
509
+ layout.addWidget(self.chat_output)
510
+
511
+ # File label — shown when file attached, click to remove
512
+ self.file_label = QLabel("")
513
+ self.file_label.setStyleSheet(
514
+ "color: #3fb950; font-size: 11px; padding: 2px 6px;"
515
+ "background: #0d1117; border: 1px solid #238636; border-radius: 3px;"
516
+ )
517
+ self.file_label.hide()
518
+ self.file_label.mousePressEvent = lambda e: self._clear_file()
519
+ layout.addWidget(self.file_label)
520
+
521
+ # Input row
522
+ input_row = QHBoxLayout()
523
+
524
+ file_btn = QPushButton("📎")
525
+ file_btn.setToolTip("Attach file (image, PDF, text, ZIP, CSV...)")
526
+ file_btn.clicked.connect(self._attach_file)
527
+ input_row.addWidget(file_btn)
528
+
529
+ self.chat_input = QLineEdit()
530
+ self.chat_input.setPlaceholderText(
531
+ "Enter prompt — Tool/Provider/Model in header bar..."
532
+ )
533
+ self.chat_input.returnPressed.connect(self._send_chat)
534
+ input_row.addWidget(self.chat_input)
535
+
536
+ send_btn = QPushButton("Send ▶")
537
+ send_btn.setObjectName("btn_send")
538
+ send_btn.clicked.connect(self._send_chat)
539
+ input_row.addWidget(send_btn)
540
+ layout.addLayout(input_row)
541
+
542
+ return tab
543
+
544
+ # =========================================================================
545
+ # Tab: Tools
546
+ # =========================================================================
547
+ def _tab_tools(self) -> QWidget:
548
+ tab = QWidget()
549
+ layout = QVBoxLayout(tab)
550
+ layout.setContentsMargins(16, 16, 16, 16)
551
+ layout.setSpacing(8)
552
+
553
+ btn_row = QHBoxLayout()
554
+ refresh_btn = QPushButton("↻ Refresh Tools")
555
+ refresh_btn.clicked.connect(self._fetch_tools)
556
+ btn_row.addWidget(refresh_btn)
557
+ btn_row.addStretch()
558
+ layout.addLayout(btn_row)
559
+
560
+ self.tools_output = QTextEdit()
561
+ self.tools_output.setReadOnly(True)
562
+ self.tools_output.setPlaceholderText("Connect first, then refresh...")
563
+ layout.addWidget(self.tools_output)
564
+
565
+ return tab
566
+
567
+ # =========================================================================
568
+ # Tab: Connect — separate from Settings!
569
+ # =========================================================================
570
+ def _tab_connect(self) -> QWidget:
571
+ tab = QWidget()
572
+ layout = QVBoxLayout(tab)
573
+ layout.setContentsMargins(24, 24, 24, 24)
574
+ layout.setSpacing(12)
575
+
576
+ layout.addWidget(self._section("Hub URL (edit in Settings tab)"))
577
+ self.connect_url_label = QLabel(self.cfg.get("hub_url", "— not configured —"))
578
+ self.connect_url_label.setStyleSheet("color: #58a6ff; padding: 6px 0;")
579
+ layout.addWidget(self.connect_url_label)
580
+
581
+ layout.addWidget(self._section("HF Token"))
582
+ token_ok = bool(self.cfg.get("hf_token"))
583
+ token_status = QLabel("✓ configured" if token_ok else "✗ not set — go to Settings")
584
+ token_status.setStyleSheet("color: #3fb950;" if token_ok else "color: #f85149;")
585
+ layout.addWidget(token_status)
586
+
587
+ btn_row = QHBoxLayout()
588
+ conn_btn = QPushButton("🔌 Connect")
589
+ conn_btn.setObjectName("btn_connect")
590
+ conn_btn.clicked.connect(self._connect)
591
+ btn_row.addWidget(conn_btn)
592
+
593
+ ping_btn = QPushButton("❤ Ping")
594
+ ping_btn.clicked.connect(self._health_check)
595
+ btn_row.addWidget(ping_btn)
596
+
597
+ refresh_btn = QPushButton("↻ Refresh Tools")
598
+ refresh_btn.clicked.connect(self._fetch_tools)
599
+ btn_row.addWidget(refresh_btn)
600
+
601
+ btn_row.addStretch()
602
+ layout.addLayout(btn_row)
603
+
604
+ layout.addStretch()
605
+
606
+ info = QTextEdit()
607
+ info.setReadOnly(True)
608
+ info.setMaximumHeight(80)
609
+ info.setPlainText(
610
+ "Connect = Health Check + auto-load Tools/Providers/Models into header dropdowns.\n"
611
+ "Token is only sent to your own Hub — never stored elsewhere."
612
+ )
613
+ info.setStyleSheet("color: #8b949e; background: #0d1117; border: none; font-size: 12px;")
614
+ layout.addWidget(info)
615
+
616
+ return tab
617
+
618
+ # =========================================================================
619
+ # Tab: Settings — ONLY config, no connect!
620
+ # =========================================================================
621
+ def _tab_settings(self) -> QWidget:
622
+ tab = QWidget()
623
+ layout = QVBoxLayout(tab)
624
+ layout.setContentsMargins(24, 24, 24, 24)
625
+ layout.setSpacing(12)
626
+
627
+ layout.addWidget(self._section("HuggingFace Token"))
628
+ self.token_input = QLineEdit(self.cfg.get("hf_token", ""))
629
+ self.token_input.setPlaceholderText("hf_...")
630
+ self.token_input.setEchoMode(QLineEdit.Password)
631
+ layout.addWidget(self.token_input)
632
+
633
+ layout.addWidget(self._section("Hub URL"))
634
+ self.url_input = QLineEdit(self.cfg.get("hub_url", ""))
635
+ self.url_input.setPlaceholderText("https://your-space-name.hf.space")
636
+ layout.addWidget(self.url_input)
637
+
638
+ layout.addWidget(self._section("Default Provider (optional)"))
639
+ self.default_provider_input = QLineEdit(self.cfg.get("default_provider", ""))
640
+ self.default_provider_input.setPlaceholderText("e.g. gemini — leave empty for Hub default")
641
+ layout.addWidget(self.default_provider_input)
642
+
643
+ layout.addWidget(self._section("Default Model (optional)"))
644
+ self.default_model_input = QLineEdit(self.cfg.get("default_model", ""))
645
+ self.default_model_input.setPlaceholderText("e.g. gemini-2.5-flash — leave empty for Hub default")
646
+ layout.addWidget(self.default_model_input)
647
+
648
+ layout.addWidget(self._section("Font Size"))
649
+ font_row = QHBoxLayout()
650
+ self.font_size_input = QSpinBox()
651
+ self.font_size_input.setRange(10, 24)
652
+ self.font_size_input.setValue(self.cfg.get("font_size", 14))
653
+ font_row.addWidget(self.font_size_input)
654
+ font_row.addStretch()
655
+ layout.addLayout(font_row)
656
+
657
+ layout.addStretch()
658
+
659
+ btn_row = QHBoxLayout()
660
+ save_btn = QPushButton("��� Save Settings")
661
+ save_btn.setObjectName("btn_save")
662
+ save_btn.clicked.connect(self._save_settings)
663
+ btn_row.addWidget(save_btn)
664
+ btn_row.addStretch()
665
+ layout.addLayout(btn_row)
666
+
667
+ info = QTextEdit()
668
+ info.setReadOnly(True)
669
+ info.setMaximumHeight(60)
670
+ info.setPlainText("Saved to ~/.mcp_desktop.json — go to Connect tab to connect.")
671
+ info.setStyleSheet("color: #8b949e; background: #0d1117; border: none; font-size: 12px;")
672
+ layout.addWidget(info)
673
+
674
+ return tab
675
+
676
+ # =========================================================================
677
+ # Tab: Logs
678
+ # =========================================================================
679
+ def _tab_logs(self) -> QWidget:
680
+ tab = QWidget()
681
+ layout = QVBoxLayout(tab)
682
+ layout.setContentsMargins(16, 16, 16, 16)
683
+ layout.setSpacing(8)
684
+
685
+ btn_row = QHBoxLayout()
686
+ clear_btn = QPushButton("🗑 Clear")
687
+ clear_btn.clicked.connect(lambda: self.log_output.clear())
688
+ btn_row.addWidget(clear_btn)
689
+ btn_row.addStretch()
690
+ layout.addLayout(btn_row)
691
+
692
+ self.log_output = QTextEdit()
693
+ self.log_output.setReadOnly(True)
694
+ self.log_output.setPlaceholderText("All requests and responses appear here...")
695
+ layout.addWidget(self.log_output)
696
+
697
+ return tab
698
+
699
+ # =========================================================================
700
+ # Helper Widgets
701
+ # =========================================================================
702
+ def _section(self, text: str) -> QLabel:
703
+ label = QLabel(text)
704
+ label.setStyleSheet("color: #8b949e; font-size: 11px; margin-top: 8px;")
705
+ return label
706
+
707
+ def _small_label(self, text: str) -> QLabel:
708
+ label = QLabel(text)
709
+ label.setStyleSheet("color: #8b949e; font-size: 11px;")
710
+ return label
711
+
712
+ # =========================================================================
713
+ # Status + Log
714
+ # =========================================================================
715
+ def _set_status(self, text: str):
716
+ self.status_label.setText(text)
717
+ self.status_bar.showMessage(text)
718
+ connected = "connected" in text and "not" not in text
719
+ color = "#3fb950" if connected else "#f85149"
720
+ self.status_label.setStyleSheet(f"color: {color}; font-size: 12px;")
721
+
722
+ def _log(self, msg: str):
723
+ ts = datetime.now().strftime("%H:%M:%S")
724
+ self.log_output.append(f"[{ts}] {msg}")
725
+
726
+ def _make_worker(self) -> AsyncWorker:
727
+ return AsyncWorker(
728
+ hub_url=self.cfg.get("hub_url", ""),
729
+ hf_token=self.cfg.get("hf_token", ""),
730
+ )
731
+
732
+ def _run_in_thread(self, fn):
733
+ """Run in background QThread — list keeps all references alive until done."""
734
+ t = WorkerThread(fn)
735
+ def _cleanup():
736
+ try:
737
+ self._threads.remove(t)
738
+ except ValueError:
739
+ pass
740
+ t.deleteLater()
741
+ t.finished.connect(_cleanup)
742
+ t.start()
743
+ self._threads.append(t)
744
+
745
+ # =========================================================================
746
+ # Chat Management — multi-chat with timestamp IDs
747
+ # =========================================================================
748
+ def _new_chat(self):
749
+ """Create new chat session — ID = timestamp."""
750
+ chat_id = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
751
+ self.cfg.setdefault("chats", {})[chat_id] = {"title": f"Chat {chat_id}", "messages": []}
752
+ save_config(self.cfg)
753
+ self._refresh_chat_dropdown()
754
+ idx = self.chat_select.findText(chat_id)
755
+ if idx >= 0:
756
+ self.chat_select.setCurrentIndex(idx)
757
+ self._current_chat_id = chat_id
758
+ self.chat_output.clear()
759
+
760
+ def _delete_chat(self):
761
+ """Delete currently selected chat."""
762
+ chat_id = self.chat_select.currentText()
763
+ if chat_id and chat_id in self.cfg.get("chats", {}):
764
+ del self.cfg["chats"][chat_id]
765
+ save_config(self.cfg)
766
+ self._refresh_chat_dropdown()
767
+ self.chat_output.clear()
768
+
769
+ def _refresh_chat_dropdown(self):
770
+ """Rebuild dropdown — sorted newest first."""
771
+ self.chat_select.blockSignals(True)
772
+ self.chat_select.clear()
773
+ for chat_id in sorted(self.cfg.get("chats", {}).keys(), reverse=True):
774
+ self.chat_select.addItem(chat_id)
775
+ self.chat_select.blockSignals(False)
776
+ if self.chat_select.count() > 0:
777
+ self._on_chat_selected(0)
778
+
779
+ def _on_chat_selected(self, idx: int):
780
+ """Load selected chat into output."""
781
+ chat_id = self.chat_select.currentText()
782
+ if not chat_id:
783
+ return
784
+ self._current_chat_id = chat_id
785
+ self.chat_output.clear()
786
+ for msg in self.cfg.get("chats", {}).get(chat_id, {}).get("messages", []):
787
+ self.chat_output.append(msg)
788
+
789
+ def _load_last_chat(self):
790
+ """Load most recent chat on startup — create one if none exist."""
791
+ self._refresh_chat_dropdown()
792
+ if self.chat_select.count() > 0:
793
+ self.chat_select.setCurrentIndex(0)
794
+ else:
795
+ self._new_chat()
796
+
797
+ def _save_chat_message(self, msg: str):
798
+ """Persist message to current chat history."""
799
+ if not self._current_chat_id:
800
+ return
801
+ self.cfg.setdefault("chats", {}).setdefault(
802
+ self._current_chat_id, {"title": "", "messages": []}
803
+ )["messages"].append(msg)
804
+ save_config(self.cfg)
805
+
806
+ # =========================================================================
807
+ # File Attachment
808
+ # =========================================================================
809
+ def _attach_file(self):
810
+ """Open file dialog — process and cache file for next send."""
811
+ path, _ = QFileDialog.getOpenFileName(
812
+ self, "Attach File", "",
813
+ "Supported (*.jpg *.jpeg *.png *.txt *.py *.php *.js *.html *.css "
814
+ "*.pdf *.csv *.xlsx *.zip *.json *.xml *.md *.sql *.sh *.c *.cpp *.java);;"
815
+ "All files (*.*)"
816
+ )
817
+ if not path:
818
+ return
819
+
820
+ result = process_file(path)
821
+ if result["type"] == "error":
822
+ self._log(f"File error: {result['content']}")
823
+ return
824
+
825
+ self._file_cache = result
826
+ self._file_cache["path"] = path
827
+ filename = Path(path).name
828
+ self.file_label.setText(f"📎 {filename} (click to remove)")
829
+ self.file_label.show()
830
+ self._log(f"[file] loaded: {filename} ({result['type']})")
831
+
832
+ def _clear_file(self):
833
+ """Remove attached file."""
834
+ self._file_cache = None
835
+ self.file_label.hide()
836
+
837
+ # =========================================================================
838
+ # Actions
839
+ # =========================================================================
840
+ def _save_settings(self):
841
+ """Save settings only — no connect action."""
842
+ self.cfg["hf_token"] = self.token_input.text().strip()
843
+ self.cfg["hub_url"] = self.url_input.text().strip()
844
+ self.cfg["default_provider"] = self.default_provider_input.text().strip()
845
+ self.cfg["default_model"] = self.default_model_input.text().strip()
846
+ self.cfg["font_size"] = self.font_size_input.value()
847
+ save_config(self.cfg)
848
+ self._apply_style()
849
+ self.connect_url_label.setText(self.cfg.get("hub_url", "— not configured —"))
850
+ self._log("Settings saved — go to Connect tab to connect.")
851
+
852
+ def _connect(self):
853
+ """Health check + auto-fetch tools on success."""
854
+ if not self.cfg.get("hf_token") or not self.cfg.get("hub_url"):
855
+ self._set_status("✗ configure in Settings first!")
856
+ return
857
+ self._set_status("… connecting")
858
+ self._log(f"Connecting to {self.cfg['hub_url']}...")
859
+ w = self._make_worker()
860
+ w.status.connect(self._set_status)
861
+ w.status.connect(lambda s: self._fetch_tools() if "● connected" in s else None)
862
+ w.error.connect(lambda e: self._log(f"ERROR: {e}"))
863
+ w.log.connect(self._log)
864
+ self._run_in_thread(w.health_check)
865
+
866
+ def _health_check(self):
867
+ """Ping Hub — update status."""
868
+ w = self._make_worker()
869
+ w.status.connect(self._set_status)
870
+ w.status.connect(lambda s: self._fetch_tools() if "● connected" in s else None)
871
+ w.error.connect(lambda e: self._log(f"ERROR: {e}"))
872
+ w.log.connect(self._log)
873
+ self._run_in_thread(w.health_check)
874
+
875
+ def _fetch_tools(self):
876
+ """Fetch tools + providers + models — skip if already loading."""
877
+ if self._tools_loading:
878
+ return
879
+ self._tools_loading = True
880
+ w = self._make_worker()
881
+ w.tools.connect(self._on_tools)
882
+ w.error.connect(lambda e: setattr(self, '_tools_loading', False) or self._log(f"ERROR: {e}"))
883
+ w.log.connect(self._log)
884
+ self._run_in_thread(w.fetch_tools)
885
+
886
+ def _on_tools(self, data: dict):
887
+ """Populate tool/provider/model dropdowns from Hub response."""
888
+ self._tools_loading = False
889
+ result = data.get("result", data)
890
+ self.tools_output.setPlainText(
891
+ json.dumps({"active_tools": result}, indent=2)
892
+ if isinstance(result, list)
893
+ else json.dumps(result, indent=2)
894
+ )
895
+
896
+ tools = result.get("active_tools", []) if isinstance(result, dict) else []
897
+ try:
898
+ self.tool_select.currentTextChanged.disconnect(self._populate_models_for_tool)
899
+ except RuntimeError:
900
+ pass
901
+ self.tool_select.clear()
902
+ for t in tools:
903
+ self.tool_select.addItem(t)
904
+ idx = self.tool_select.findText(self.cfg.get("default_tool", "llm_complete"))
905
+ if idx >= 0:
906
+ self.tool_select.setCurrentIndex(idx)
907
+
908
+ self.provider_select.clear()
909
+ self.provider_select.addItem("default")
910
+ for p in result.get("active_llm_providers", []) if isinstance(result, dict) else []:
911
+ self.provider_select.addItem(p)
912
+
913
+ self._last_models = result.get("available_models", []) if isinstance(result, dict) else []
914
+ self._last_llm_providers = result.get("active_llm_providers", []) if isinstance(result, dict) else []
915
+
916
+ self._populate_models_for_tool(self.tool_select.currentText())
917
+ self.tool_select.currentTextChanged.connect(self._populate_models_for_tool)
918
+
919
+ self._log(f"Tools loaded: {tools}")
920
+
921
+ _LLM_TOOLS = {"llm_complete", "code_review", "summarize", "translate"}
922
+
923
+ def _populate_models_for_tool(self, tool_name: str):
924
+ """Filter model dropdown based on selected tool — only LLM tools need models."""
925
+ self.model_select.clear()
926
+ self.model_select.addItem("default")
927
+ if tool_name in self._LLM_TOOLS:
928
+ for m in getattr(self, "_last_models", []):
929
+ self.model_select.addItem(m)
930
+
931
+ def _send_chat(self):
932
+ """Send prompt to Hub — uses Tool/Provider/Model from header dropdowns."""
933
+ prompt = self.chat_input.text().strip()
934
+ if not prompt:
935
+ return
936
+
937
+ tool_name = self.tool_select.currentText() or "llm_complete"
938
+ provider = self.provider_select.currentText()
939
+ if provider == "default":
940
+ provider = self.cfg.get("default_provider") or None
941
+ model = self.model_select.currentText()
942
+ if model == "default":
943
+ model = self.cfg.get("default_model") or None
944
+
945
+ full_prompt = prompt
946
+ if self._file_cache:
947
+ if self._file_cache["type"] == "text":
948
+ full_prompt = f"{prompt}\n\n[File Content]\n{self._file_cache['content']}"
949
+ self._clear_file()
950
+
951
+ user_msg = f"\n▶ You [{tool_name}]: {prompt}"
952
+ self.chat_output.append(user_msg)
953
+ self._save_chat_message(user_msg)
954
+ self.chat_input.clear()
955
+ self._log(f"→ tool: {tool_name}, provider: {provider or 'default'}, model: {model or 'default'}")
956
+
957
+ w = self._make_worker()
958
+ def on_result(r):
959
+ msg = f"⬡ Hub: {r}\n"
960
+ self.chat_output.append(msg)
961
+ self._save_chat_message(msg)
962
+ w.result.connect(on_result)
963
+ w.error.connect(lambda e: self.chat_output.append(f"✗ Error: {e}\n"))
964
+ w.log.connect(self._log)
965
+ self._run_in_thread(lambda: w.call_tool(tool_name, full_prompt, provider, model))
966
+
967
+
968
+ # =============================================================================
969
+ # Entry Point
970
+ # =============================================================================
971
+ if __name__ == "__main__":
972
+ if sys.platform == "win32":
973
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
974
+
975
+ app = QApplication(sys.argv)
976
+ window = MCPDesktop()
977
+ window.show()
978
+ sys.exit(app.exec())
example-mcp___.env CHANGED
@@ -1,30 +1,59 @@
1
  # ===========================================
2
- # MCP.env HUB CONFIGURATION
 
 
 
 
 
 
 
 
 
3
  # ===========================================
4
 
5
- # App-Modus: 'mcp' oder 'app' (app ,modus for local)
6
- APP_MODE="mcp"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
- # Transport: 'stdio' (lokal/Claude Desktop) oder 'sse' (HuggingFace/Remote)
9
- MCP_TRANSPORT="sse"
10
 
11
- # Für SSE/HuggingFace Spaces (added by app/app.py)
12
- # PORT="7860"
13
- # HOST="0.0.0.0"
14
 
15
- # Optional: Die App-URL für OpenRouter HTTP-Referer
 
16
  APP_URL="https://huggingface.co/spaces/codey-lab/Universal-MCP-Hub"
17
 
18
- # .env.example
19
- # This file serves as a template for your .env file.
20
- # Copy this file to a new file named .env and fill in the values.
21
- # IMPORTANT: Never commit the .env file to version control.
 
22
 
23
  # ===========================================
24
  # PyFundaments CORE CONFIGURATION
25
  # ===========================================
26
 
27
- # --- Database Connection ---
28
  # The complete Data Source Name (DSN) for your PostgreSQL database.
29
  # This works for both local instances and cloud providers like Neon.tech.
30
  # Example for Neon: postgresql://user:password@host.eu-central-1.aws.neon.tech/neondb?sslmode=require
@@ -34,34 +63,30 @@ DATABASE_URL="your_database_dsn_here"
34
 
35
  # --- Application-Level Encryption Keys ---
36
  # These keys are used by the application itself to encrypt sensitive data
37
- # before it is written to the database. This provides an additional layer
38
- # of security for your data.
39
- # Comment out both lines if your app doesn't need encryption
40
  # The master key must be a 256-bit key (32 bytes).
41
- #
42
  MASTER_ENCRYPTION_KEY="your_256_bit_key_here"
43
- #
44
  # The salt is a unique, persistent value used with the master key.
45
  # It should also be a secure, random string.
46
- #
47
  PERSISTENT_ENCRYPTION_SALT="your_unique_salt_here"
48
- #
49
 
50
  # ===========================================
51
  # LOGGING CONFIGURATION (Optional)
52
  # ===========================================
53
 
54
- # PyFundaments Debug Mode (true/false)
55
- PYFUNDAMENTS_DEBUG="true"
 
56
 
57
  # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
58
- LOG_LEVEL="DEBUG"
 
59
 
60
- # Store logs in /tmp directory (cleared on restart)
61
  LOG_TO_TMP="false"
62
 
63
- # Enable public logging (disable for production security)
64
- ENABLE_PUBLIC_LOGS="true"
65
-
66
-
67
-
 
1
  # ===========================================
2
+ # .env.example — Multi-LLM-API-Gateway
3
+ # Copy this file to .env and fill in the values.
4
+ # IMPORTANT: Never commit the .env file to version control.
5
+ # ===========================================
6
+ # ARCHITECTURE NOTE:
7
+ # The Guardian (main.py) is the ONLY process that reads this file.
8
+ # app/* modules NEVER touch .env — they get ENV-VAR NAMES via app/.pyfun
9
+ # and the values are read with os.getenv() inside the sandbox boundary.
10
+ #
11
+ # No key set = provider skipped = tool not registered = NO CRASH.
12
  # ===========================================
13
 
14
+ # --- LLM Provider Keys ---
15
+ # Which providers activate is defined in app/.pyfun [LLM_PROVIDERS].
16
+ # A provider only registers when its ENV key here is set.
17
+
18
+ # Anthropic (api.anthropic.com)
19
+ ANTHROPIC_API_KEY=""
20
+
21
+ # Google Gemini (generativelanguage.googleapis.com)
22
+ GEMINI_API_KEY=""
23
+
24
+ # OpenRouter (openrouter.ai) — free models available
25
+ OPENROUTER_API_KEY=""
26
+
27
+ # HuggingFace Inference API (api-inference.huggingface.co)
28
+ HF_TOKEN=""
29
+
30
+ # SmolLM2 custom Space (your own deployment)
31
+ SMOLLM_API_KEY=""
32
+
33
+ # --- Search Provider Keys ---
34
+ # Activate search by setting at least one of these.
35
 
36
+ # Brave Search (api.search.brave.com)
37
+ BRAVE_API_KEY=""
38
 
39
+ # Tavily (api.tavily.com) — AI-optimized search
40
+ TAVILY_API_KEY=""
 
41
 
42
+ # --- App Identity (optional) ---
43
+ # Used by OpenRouter HTTP-Referer header (rate limit attribution)
44
  APP_URL="https://huggingface.co/spaces/codey-lab/Universal-MCP-Hub"
45
 
46
+ # --- Optional hub gate for /api, /crypto and /mcp (see app/auth.py) ---
47
+ # Leave empty = hub is open (as before). Set a long random string to require it.
48
+ # Clients send: X-Hub-Token: <token> or Authorization: Bearer <token>
49
+ # (the ENV var NAME is configured in app/.pyfun [HUB] HUB_AUTH_ENV)
50
+ HUB_API_TOKEN=""
51
 
52
  # ===========================================
53
  # PyFundaments CORE CONFIGURATION
54
  # ===========================================
55
 
56
+ # --- Database Connection (PostgreSQL) ---
57
  # The complete Data Source Name (DSN) for your PostgreSQL database.
58
  # This works for both local instances and cloud providers like Neon.tech.
59
  # Example for Neon: postgresql://user:password@host.eu-central-1.aws.neon.tech/neondb?sslmode=require
 
63
 
64
  # --- Application-Level Encryption Keys ---
65
  # These keys are used by the application itself to encrypt sensitive data
66
+ # before it is written to the database.
 
 
67
  # The master key must be a 256-bit key (32 bytes).
 
68
  MASTER_ENCRYPTION_KEY="your_256_bit_key_here"
69
+
70
  # The salt is a unique, persistent value used with the master key.
71
  # It should also be a secure, random string.
 
72
  PERSISTENT_ENCRYPTION_SALT="your_unique_salt_here"
 
73
 
74
  # ===========================================
75
  # LOGGING CONFIGURATION (Optional)
76
  # ===========================================
77
 
78
+ # PyFundaments Debug Mode (true = startup diagnostics: Python version,
79
+ # sys.path, file checks). Only needed while developing — false for normal use.
80
+ PYFUNDAMENTS_DEBUG="false"
81
 
82
  # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
83
+ # INFO is enough for daily operation; DEBUG only while hunting a bug.
84
+ LOG_LEVEL="INFO"
85
 
86
+ # Store logs in /tmp directory (cleared on restart, host-local only)
87
  LOG_TO_TMP="false"
88
 
89
+ # Public logging — CAREFUL: stdout logs are PUBLICLY VISIBLE on HF Spaces
90
+ # (Space -> Logs tab). Set to "false" for production. Only keep "true"
91
+ # while actively debugging a deployment.
92
+ ENABLE_PUBLIC_LOGS="false"
 
fundaments/access_control.py CHANGED
@@ -43,12 +43,19 @@ class AccessControl:
43
  def __init__(self, user_id: Optional[int] = None):
44
  self.user_id = user_id
45
 
46
- async def has_permission(self, permission_name: str) -> bool:
47
  """
48
  Checks if the user has a specific permission.
49
  Uses the secure execute_secured_query function from the foundation.
 
 
 
 
 
 
50
  """
51
- if self.user_id is None:
 
52
  return False
53
 
54
  # SQL Query Explanation:
@@ -66,7 +73,7 @@ class AccessControl:
66
  try:
67
  result = await db.execute_secured_query(
68
  sql,
69
- self.user_id,
70
  permission_name,
71
  fetch_method='fetchrow'
72
  )
 
43
  def __init__(self, user_id: Optional[int] = None):
44
  self.user_id = user_id
45
 
46
+ async def has_permission(self, permission_name: str, user_id: Optional[int] = None) -> bool:
47
  """
48
  Checks if the user has a specific permission.
49
  Uses the secure execute_secured_query function from the foundation.
50
+
51
+ Args:
52
+ permission_name: Name of the permission to check.
53
+ user_id: Optional override — lets Security.check_permission()
54
+ check ANY user without constructing a new instance.
55
+ Falls back to the constructor user_id.
56
  """
57
+ effective_user_id = user_id if user_id is not None else self.user_id
58
+ if effective_user_id is None:
59
  return False
60
 
61
  # SQL Query Explanation:
 
73
  try:
74
  result = await db.execute_secured_query(
75
  sql,
76
+ effective_user_id,
77
  permission_name,
78
  fetch_method='fetchrow'
79
  )
fundaments/postgresql.py CHANGED
@@ -4,6 +4,7 @@
4
  # Repo: https://github.com/VolkanSah/PyFundaments
5
  # fundaments/postgresql.py
6
  import os
 
7
  import logging
8
  import asyncpg
9
  import ssl
@@ -122,8 +123,12 @@ async def init_db_pool(dsn_url: Optional[str] = None) -> Optional[asyncpg.Pool]:
122
  dsn=secured_dsn,
123
  min_size=1,
124
  max_size=10,
125
- timeout=5,
 
 
126
  command_timeout=30,
 
 
127
  ssl=ssl_context
128
  )
129
  # Post-init checks
@@ -144,36 +149,69 @@ async def close_db_pool():
144
  _db_pool = None
145
  logger.info("Database pool closed successfully.")
146
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  async def execute_secured_query(query: str, *params, fetch_method='fetch'):
148
  """
149
  Executes a parameterized query with integrated security checks.
 
 
 
 
 
 
150
  """
151
  global _db_pool
152
  if not _db_pool:
153
  raise RuntimeError("Database pool not initialized")
154
 
155
- try:
156
- async with _db_pool.acquire() as conn:
157
- if fetch_method == 'fetch':
158
- return await conn.fetch(query, *params)
159
- elif fetch_method == 'fetchrow':
160
- return await conn.fetchrow(query, *params)
161
- elif fetch_method == 'execute':
162
- return await conn.execute(query, *params)
 
 
 
 
 
 
 
 
 
 
 
163
  else:
164
- raise ValueError("Invalid fetch_method")
165
- except asyncpg.PostgresError as e:
166
- error_type = "Security violation" if getattr(e, 'sqlstate', None) == '42501' else "Database error"
167
-
168
- if os.getenv('APP_ENV') == 'production':
169
- logger.error(f"{error_type} [Code: {getattr(e, 'sqlstate', '?')}]")
170
- else:
171
- logger.error(f"{error_type}: {e}")
172
-
173
- # Neon: Reconnect if connection terminated (optional)
174
- if getattr(e, 'sqlstate', None) == '08006' and 'neon.tech' in (os.getenv("DATABASE_URL") or ''):
175
- logger.warning("Neon.tech connection terminated. Restarting pool...")
176
- await close_db_pool()
177
- await init_db_pool(os.getenv("DATABASE_URL"))
178
-
179
- raise
 
 
 
 
 
4
  # Repo: https://github.com/VolkanSah/PyFundaments
5
  # fundaments/postgresql.py
6
  import os
7
+ import asyncio
8
  import logging
9
  import asyncpg
10
  import ssl
 
123
  dsn=secured_dsn,
124
  min_size=1,
125
  max_size=10,
126
+ # Neon scale-to-zero: suspended compute can need >5 s to wake up. This timeout also
127
+ # applies to every pool RECONNECT (was 5 → "Failed to restore the pool connection floor").
128
+ timeout=20,
129
  command_timeout=30,
130
+ # Neon suspends after ~5 min idle → drop idle connections ourselves before it kills them.
131
+ max_inactive_connection_lifetime=240,
132
  ssl=ssl_context
133
  )
134
  # Post-init checks
 
149
  _db_pool = None
150
  logger.info("Database pool closed successfully.")
151
 
152
+ # Connection-level failures (dead/suspended Neon connection, wake-up timeout). Built defensively
153
+ # so a missing attribute in some asyncpg version cannot break the import.
154
+ _CONN_ERRORS = tuple(
155
+ e for e in (
156
+ getattr(asyncpg, "PostgresConnectionError", None), # SQLSTATE class 08 (e.g. 08003, 08006)
157
+ getattr(asyncpg, "InterfaceError", None), # "connection is closed" etc.
158
+ asyncio.TimeoutError, # connect/command timeout
159
+ OSError, # socket-level (reset, refused, DNS)
160
+ ) if e
161
+ )
162
+
163
+
164
  async def execute_secured_query(query: str, *params, fetch_method='fetch'):
165
  """
166
  Executes a parameterized query with integrated security checks.
167
+
168
+ Neon resilience: on a connection-level failure the query is retried ONCE with a fresh
169
+ connection (the pool discards dead ones). Safe by construction:
170
+ - failure while OBTAINING a connection (nothing was sent yet) → retried for every fetch_method
171
+ - failure DURING the statement → retried only for reads (fetch/fetchrow); an INSERT/UPDATE
172
+ ('execute') is NOT repeated — it may already have been committed.
173
  """
174
  global _db_pool
175
  if not _db_pool:
176
  raise RuntimeError("Database pool not initialized")
177
 
178
+ for attempt in (1, 2):
179
+ in_statement = False
180
+ try:
181
+ async with _db_pool.acquire() as conn:
182
+ in_statement = True
183
+ if fetch_method == 'fetch':
184
+ return await conn.fetch(query, *params)
185
+ elif fetch_method == 'fetchrow':
186
+ return await conn.fetchrow(query, *params)
187
+ elif fetch_method == 'execute':
188
+ return await conn.execute(query, *params)
189
+ else:
190
+ raise ValueError("Invalid fetch_method")
191
+ except asyncpg.PostgresError as e:
192
+ sqlstate = getattr(e, 'sqlstate', None)
193
+ error_type = "Security violation" if sqlstate == '42501' else "Database error"
194
+
195
+ if os.getenv('APP_ENV') == 'production':
196
+ logger.error(f"{error_type} [Code: {sqlstate or '?'}]")
197
  else:
198
+ logger.error(f"{error_type}: {e}")
199
+
200
+ # Neon: Reconnect if connection terminated (optional)
201
+ if sqlstate == '08006' and 'neon.tech' in (os.getenv("DATABASE_URL") or ''):
202
+ logger.warning("Neon.tech connection terminated. Restarting pool...")
203
+ await close_db_pool()
204
+ await init_db_pool(os.getenv("DATABASE_URL"))
205
+
206
+ is_conn_error = isinstance(e, _CONN_ERRORS) or str(sqlstate or '').startswith('08')
207
+ if attempt == 1 and is_conn_error and (not in_statement or fetch_method in ('fetch', 'fetchrow')):
208
+ logger.warning("Connection error — retrying query once.")
209
+ continue
210
+ raise
211
+ except _CONN_ERRORS as e:
212
+ # Not a PostgresError: timeout / socket / interface error
213
+ if attempt == 1 and (not in_statement or fetch_method in ('fetch', 'fetchrow')):
214
+ logger.warning(f"DB connection problem ({type(e).__name__}) — retrying query once.")
215
+ continue
216
+ logger.error(f"DB connection problem ({type(e).__name__}) — giving up.")
217
+ raise
fundaments/security.py CHANGED
@@ -50,14 +50,17 @@ class Security:
50
  # Wenn der Dienst nicht initialisiert wurde (wie oben), würde dies einen AttributeError auslösen.
51
  async def user_login(self, username: str, password: str, request_data: dict) -> bool:
52
  logger.info(f"Attempting login for user: {username}")
53
- if await self.user_handler.login(username, password, request_data):
54
- return await self.user_handler.validate_session(request_data)
 
 
55
  return False
56
 
57
  # VORHER: Hier fehlt die Überprüfung, ob 'self.access_control' None ist.
58
  async def check_permission(self, user_id: int, permission_name: str) -> bool:
 
59
  logger.debug(f"Checking permission '{permission_name}' for user ID {user_id}")
60
- return await self.access_control.has_permission(user_id, permission_name)
61
 
62
  # VORHER: Hier wird geprüft, ob 'self.encryption' None ist, was korrekt ist.
63
  def encrypt_data(self, data: str) -> Dict[str, str]:
 
50
  # Wenn der Dienst nicht initialisiert wurde (wie oben), würde dies einen AttributeError auslösen.
51
  async def user_login(self, username: str, password: str, request_data: dict) -> bool:
52
  logger.info(f"Attempting login for user: {username}")
53
+ # UserHandler.login() is synchronous (SQLite) — no await.
54
+ # The old 'await' raised TypeError: bool can't be awaited.
55
+ if self.user_handler.login(username, password, request_data):
56
+ return self.user_handler.validate_session(request_data)
57
  return False
58
 
59
  # VORHER: Hier fehlt die Überprüfung, ob 'self.access_control' None ist.
60
  async def check_permission(self, user_id: int, permission_name: str) -> bool:
61
+ """Checks permission for ANY user_id (not just the constructor one)."""
62
  logger.debug(f"Checking permission '{permission_name}' for user ID {user_id}")
63
+ return await self.access_control.has_permission(permission_name, user_id=user_id)
64
 
65
  # VORHER: Hier wird geprüft, ob 'self.encryption' None ist, was korrekt ist.
66
  def encrypt_data(self, data: str) -> Dict[str, str]:
main.py CHANGED
@@ -78,8 +78,13 @@ async def initialize_fundaments() -> Dict[str, Any]:
78
  if database_url and database_url != "your_database_dsn_here":
79
  try:
80
  db_service = await init_db_pool(database_url)
81
- fundaments["db"] = db_service
82
- logger.info("Database service initialized.")
 
 
 
 
 
83
  except Exception as e:
84
  logger.warning(f"Database initialization failed, continuing without DB: {e}")
85
  fundaments["db"] = None
@@ -119,17 +124,20 @@ async def initialize_fundaments() -> Dict[str, Any]:
119
  fundaments["access_control"] = None
120
 
121
  # --- User Handler Initialization ---
122
- # Only initialize if we have a database connection
123
- if fundaments["db"] is not None:
124
- try:
125
- user_handler_service = UserHandler(fundaments["db"])
126
- fundaments["user_handler"] = user_handler_service
127
- logger.info("User Handler service initialized.")
128
- except Exception as e:
129
- logger.warning(f"User Handler initialization failed: {e}")
130
- fundaments["user_handler"] = None
131
- else:
132
- logger.info("No database available, skipping User Handler initialization.")
 
 
 
133
  fundaments["user_handler"] = None
134
 
135
  # --- Security Manager Initialization ---
 
78
  if database_url and database_url != "your_database_dsn_here":
79
  try:
80
  db_service = await init_db_pool(database_url)
81
+ if db_service is not None:
82
+ fundaments["db"] = db_service
83
+ logger.info("Database service initialized.")
84
+ else:
85
+ # init_db_pool() returns None instead of raising on failure
86
+ logger.warning("Database pool could not be built, continuing without DB.")
87
+ fundaments["db"] = None
88
  except Exception as e:
89
  logger.warning(f"Database initialization failed, continuing without DB: {e}")
90
  fundaments["db"] = None
 
124
  fundaments["access_control"] = None
125
 
126
  # --- User Handler Initialization ---
127
+ # UserHandler is SQLite-based (own Database class, see user_handler.py)
128
+ # and does NOT use the asyncpg pool — wiring it with the Postgres pool
129
+ # was a bug (pool has no fetchone/execute-sync interface).
130
+ # SQLite path: SQLITE_PATH env or fallback next to app/ (HF Spaces: read-only FS
131
+ # may fail — caught below, app continues without user_handler).
132
+ try:
133
+ from fundaments.user_handler import Database as UserDB
134
+ user_db = UserDB()
135
+ user_db.setup_tables()
136
+ user_handler_service = UserHandler(user_db)
137
+ fundaments["user_handler"] = user_handler_service
138
+ logger.info("User Handler service initialized (SQLite store).")
139
+ except Exception as e:
140
+ logger.warning(f"User Handler initialization failed: {e}")
141
  fundaments["user_handler"] = None
142
 
143
  # --- Security Manager Initialization ---
requirements.txt CHANGED
@@ -5,7 +5,8 @@ passlib
5
  cryptography
6
 
7
  # MCP Hub Dependencies (needed for app/mcp.py)
8
- fastmcp
 
9
  httpx
10
  quart
11
  hypercorn
@@ -30,4 +31,7 @@ requests
30
  #PyNaCl
31
 
32
  # Alternative PostgreSQL driver (if not using asyncpg)
33
- #psycopg2-binary
 
 
 
 
5
  cryptography
6
 
7
  # MCP Hub Dependencies (needed for app/mcp.py)
8
+ # Code imports mcp.server.fastmcp — the mcp SDK, NOT the fastmcp package.
9
+ mcp>=1.10,<2
10
  httpx
11
  quart
12
  hypercorn
 
31
  #PyNaCl
32
 
33
  # Alternative PostgreSQL driver (if not using asyncpg)
34
+ #psycopg2-binary
35
+ # Optional — Google Search Console tool (app/gsc.py), nur nötig wenn GSC_ACTIVE = "true":
36
+ # google-api-python-client
37
+ # google-auth