Spaces:
Running
Running
Upload 58 files
Browse files- .gitattributes +1 -0
- .github/FUNDING.yml +15 -0
- .github/scripts/generate_structure.py +92 -0
- .github/workflows/clean_repo.example +61 -0
- .github/workflows/generate_structure.example +56 -0
- .gitignore +12 -10
- CITATION.cff +9 -0
- CODE_OF_CONDUCT.md +68 -0
- CONTRIBUTING.md +154 -0
- Dockerfile +2 -3
- LICENSE +661 -201
- PyFundaments #U2013 Function Overview.md +145 -0
- README.md +233 -102
- SECURITY.md +146 -0
- app/.pyfun +283 -138
- app/app.py +188 -60
- app/auth.py +88 -0
- app/config.py +20 -0
- app/db/skills/README.md +20 -0
- app/db/skills/core/core.db +3 -0
- app/db/skills/core/core.md +147 -0
- app/db/skills/core/core_agent.db +0 -0
- app/db/skills/core/core_agent.md +141 -0
- app/db_sync.py +496 -24
- app/discord_api.py +550 -0
- app/gsc.py +140 -0
- app/mcp.py +344 -81
- app/models.py +4 -2
- app/pricing.py +526 -0
- app/providers.py +621 -304
- app/shellmaster.py +36 -6
- app/skills/README.md +14 -0
- app/skills_db.py +227 -0
- app/tools.py +242 -21
- desktop_ui.py +978 -0
- example-mcp___.env +55 -30
- fundaments/access_control.py +10 -3
- fundaments/postgresql.py +63 -25
- fundaments/security.py +6 -3
- main.py +21 -13
- requirements.txt +6 -2
.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
|
| 22 |
-
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 3 |
emoji: 🛡️
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: true
|
| 8 |
license: apache-2.0
|
| 9 |
-
short_description: Secure Multi-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
Pick the description that fits your use case. They're all correct.
|
| 19 |
|
| 20 |
-
> A
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 54 |
|
| 55 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 91 |
|
| 92 |
---
|
| 93 |
|
| 94 |
-
|
|
|
|
| 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 ←
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 146 |
|
|
|
|
| 147 |
|
| 148 |
```
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 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 |
-
**
|
| 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 |
-
| `
|
| 182 |
-
|
|
|
|
| 183 |
|
| 184 |
---
|
| 185 |
|
| 186 |
-
|
|
|
|
| 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` | `
|
| 199 |
-
| `
|
| 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`,
|
|
|
|
|
|
|
| 221 |
|
| 222 |
---
|
| 223 |
|
| 224 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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="
|
| 275 |
HF_TOKEN=""
|
| 276 |
HUB_SPACE_URL=""
|
| 277 |
```
|
| 278 |
|
|
|
|
|
|
|
| 279 |
Transport is configured in `app/.pyfun [HUB]` — not via ENV.
|
| 280 |
|
|
|
|
|
|
|
| 281 |
---
|
| 282 |
|
| 283 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
---
|
| 488 |
|
| 489 |
-
## License
|
| 490 |
|
| 491 |
-
|
|
|
|
| 492 |
|
| 493 |
-
-
|
| 494 |
-
- [
|
| 495 |
|
| 496 |
-
By using this software you agree to all ethical constraints defined in ESOL
|
|
|
|
|
|
|
| 497 |
|
| 498 |
---
|
| 499 |
|
| 500 |
-
*Architecture, security decisions
|
| 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.
|
| 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-
|
| 84 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
# ──
|
|
|
|
| 119 |
# [LLM_PROVIDER.mistral]
|
| 120 |
-
# active = "
|
|
|
|
| 121 |
# base_url = "https://api.mistral.ai/v1"
|
| 122 |
# env_key = "MISTRAL_API_KEY"
|
| 123 |
-
# default_model = "mistral-
|
| 124 |
-
# models = "mistral-large-latest, mistral-small-latest"
|
| 125 |
# fallback_to = ""
|
| 126 |
# [LLM_PROVIDER.mistral_END]
|
| 127 |
|
| 128 |
# [LLM_PROVIDER.openai]
|
| 129 |
-
# active = "
|
|
|
|
| 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.
|
| 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-
|
| 318 |
|
| 319 |
-
[MODEL.nvidia
|
| 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
|
| 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-
|
| 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-
|
| 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-
|
| 362 |
|
| 363 |
-
[MODEL.Mistral-7B-Instruct-v0.3]
|
| 364 |
-
provider = "
|
| 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 = "
|
| 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
|
| 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 |
-
|
|
|
|
| 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 = "
|
| 540 |
-
DEBUG_LEVEL = "
|
| 541 |
-
LOG_FILE = "hub_debug.log"
|
| 542 |
-
LOG_REQUESTS = "
|
| 543 |
-
LOG_RESPONSES = "
|
| 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 |
-
|
| 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
|
| 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
|
| 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,
|
| 64 |
"""
|
| 65 |
-
|
| 66 |
-
(FastMCP Streamable HTTP) weiter, alles andere geht an outer_app (Quart).
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"""
|
| 69 |
async def middleware(scope, receive, send):
|
| 70 |
path = scope.get("path", "")
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
return middleware
|
| 80 |
|
| 81 |
# =============================================================================
|
| 82 |
# Quart app instance
|
| 83 |
# =============================================================================
|
| 84 |
app = Quart(__name__)
|
| 85 |
-
START_TIME = datetime.
|
| 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.
|
| 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":
|
| 121 |
|
| 122 |
# db_query — handled by db_sync directly, not tools.run()
|
| 123 |
if tool_name == "db_query":
|
| 124 |
-
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":
|
| 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 |
-
#
|
| 152 |
-
#
|
| 153 |
-
#
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 234 |
hub_cfg = app_config.get_hub()
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 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 |
-
|
|
|
|
| 252 |
|
| 253 |
# --- Configure hypercorn ---
|
| 254 |
config = Config()
|
| 255 |
-
config.bind = [f"
|
| 256 |
|
| 257 |
-
logger.info(f"Starting hypercorn on
|
| 258 |
-
logger.info("All services running.")
|
| 259 |
|
| 260 |
# --- Run hypercorn — blocks until shutdown ---
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 233 |
-
prompt:
|
|
|
|
| 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
|
| 269 |
"""
|
| 270 |
_check_init()
|
| 271 |
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
| 273 |
raise ValueError("Only SELECT queries are permitted in db_query tool.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
-
|
|
|
|
| 276 |
db.row_factory = aiosqlite.Row
|
| 277 |
-
cursor = await db.execute(
|
| 278 |
-
rows = await cursor.
|
| 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 —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
# =============================================================================
|
| 300 |
_psql_writer = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
|
| 303 |
def set_psql_writer(writer_fn) -> None:
|
| 304 |
"""
|
| 305 |
-
Receives
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
if not _psql_writer:
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
-
from datetime import datetime, timezone
|
| 319 |
-
now = datetime.now(timezone.utc).isoformat()
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 →
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
logger.info(f"MCP Hub initialized. Transport: {_transport}")
|
| 112 |
|
|
@@ -139,31 +146,35 @@ def get_asgi_app():
|
|
| 139 |
|
| 140 |
|
| 141 |
# =============================================================================
|
| 142 |
-
#
|
| 143 |
# =============================================================================
|
| 144 |
-
|
| 145 |
-
"""
|
| 146 |
-
|
| 147 |
-
|
|
|
|
| 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 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
"""
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
| 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,
|
| 285 |
"""
|
| 286 |
-
Health check
|
|
|
|
| 287 |
|
| 288 |
Returns:
|
| 289 |
-
Dict
|
| 290 |
"""
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 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
|
| 310 |
"""
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
logger.info("Tool registered: get_model_info")
|
| 314 |
|
| 315 |
|
| 316 |
# =============================================================================
|
| 317 |
-
#
|
| 318 |
# =============================================================================
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
| 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
|
| 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 |
-
# ---
|
| 127 |
-
class
|
| 128 |
"""
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
Deploy: https://github.com/VolkanSah/Multi-LLM-API-Gateway (smollm-space/)
|
| 133 |
|
| 134 |
-
|
| 135 |
-
[LLM_PROVIDER.smollm]
|
| 136 |
active = "true"
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
fallback_to = "
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 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":
|
| 157 |
},
|
| 158 |
)
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 177 |
-
|
| 178 |
-
|
|
|
|
| 179 |
api_version_header = "2023-06-01"
|
| 180 |
-
default_model
|
| 181 |
-
supports_cache
|
| 182 |
-
fallback_to
|
| 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 |
-
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 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
|
|
|
|
| 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
|
| 299 |
-
f"HTTP {e.response.status_code} from {safe_url}"
|
|
|
|
| 300 |
) from None
|
| 301 |
-
|
| 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 |
-
|
| 313 |
-
|
| 314 |
-
|
| 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 |
-
#
|
| 458 |
-
#
|
|
|
|
|
|
|
|
|
|
| 459 |
# =============================================================================
|
| 460 |
|
| 461 |
-
|
| 462 |
-
"
|
| 463 |
-
"anthropic":
|
| 464 |
-
"gemini":
|
| 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
|
| 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 |
-
|
|
|
|
| 490 |
if not cls:
|
| 491 |
-
logger.info(f"Provider '{name}' has no handler
|
| 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
|
| 517 |
-
|
| 518 |
-
model: Model
|
| 519 |
-
max_tokens: Max tokens in response
|
| 520 |
-
system: Optional system prompt
|
| 521 |
-
|
| 522 |
-
and the system prompt is long enough (>= ~1024 tokens).
|
| 523 |
|
| 524 |
Returns:
|
| 525 |
-
|
| 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
|
|
|
|
|
|
|
| 537 |
|
| 538 |
if not provider:
|
| 539 |
logger.warning(f"Provider '{current}' not in registry — trying fallback.")
|
| 540 |
else:
|
|
|
|
|
|
|
| 541 |
try:
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 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 |
-
|
|
|
|
| 556 |
except Exception as e:
|
| 557 |
-
# Log only exception type + sanitized message — never raw
|
| 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 |
-
#
|
| 576 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
|
| 601 |
|
| 602 |
# =============================================================================
|
|
@@ -610,12 +931,8 @@ def list_active_llm() -> list:
|
|
| 610 |
|
| 611 |
|
| 612 |
def list_active_search() -> list:
|
| 613 |
-
"""
|
| 614 |
-
|
| 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().
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
registry = _load_registry()
|
| 189 |
-
|
|
|
|
|
|
|
| 190 |
|
| 191 |
for cmd_id, cmd in registry.items():
|
| 192 |
# Check both unix and win templates
|
| 193 |
for os_key in ("unix", "win"):
|
| 194 |
-
|
| 195 |
-
if
|
|
|
|
|
|
|
| 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 |
-
#
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
# --- LLM tools ---
|
|
|
|
|
|
|
| 102 |
if provider_type == "llm":
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
)
|
| 109 |
|
| 110 |
# --- Search tools ---
|
|
|
|
|
|
|
| 111 |
if provider_type == "search":
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 124 |
if provider_type == "persist":
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
data = await db_sync.read(key) if key else {"prompt": prompt}
|
| 129 |
-
await db_sync.
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
# ===========================================
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
#
|
| 9 |
-
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
# HOST="0.0.0.0"
|
| 14 |
|
| 15 |
-
#
|
|
|
|
| 16 |
APP_URL="https://huggingface.co/spaces/codey-lab/Universal-MCP-Hub"
|
| 17 |
|
| 18 |
-
# .
|
| 19 |
-
#
|
| 20 |
-
#
|
| 21 |
-
#
|
|
|
|
| 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.
|
| 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
|
| 55 |
-
|
|
|
|
| 56 |
|
| 57 |
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 58 |
-
|
|
|
|
| 59 |
|
| 60 |
-
# Store logs in /tmp directory (cleared on restart)
|
| 61 |
LOG_TO_TMP="false"
|
| 62 |
|
| 63 |
-
#
|
| 64 |
-
|
| 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
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
else:
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|