Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local secrets / machine-specific config
|
||||
config.json
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
## AI Model Tester
|
||||
|
||||
Single-page web app to test AI models across multiple providers with a unified UI.
|
||||
|
||||
### Requirements
|
||||
- Python 3.10+
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python server.py
|
||||
```
|
||||
|
||||
Then open `http://localhost:8000`.
|
||||
|
||||
### Notes
|
||||
- API keys are stored in `config.json` (base64 encoding for demo purposes). This file is gitignored; copy from `config.example.json`.
|
||||
- Providers:
|
||||
- Ollama: local `http://localhost:11434`
|
||||
- Ollama-web: default `https://ollama.com` (official `ollama.com/api` via Bearer key)
|
||||
- OpenRouter / NVIDIA / HuggingFace: hosted APIs
|
||||
@@ -0,0 +1,125 @@
|
||||
1. Добавь еще сохранение последней выбранной модели
|
||||
2. Ollama-web это работа по API сайта ollama.com (через ключ)
|
||||
3. openrouter модели отвечаю кракозябрами
|
||||
4. nvidia не подтягивает модели с сайта
|
||||
5. HuggingFace 404 Client Error: Not Found for url: https://api-inference.huggingface.co/models/meta-llama/Llama-3.2-1B
|
||||
6. добавь автоматическую подгрузки моделей а так же выстрый поиск модели в списке
|
||||
|
||||
# AI Model Tester - Specification
|
||||
|
||||
## Project Overview
|
||||
- **Project name**: AI Model Tester
|
||||
- **Type**: Web application (single-page)
|
||||
- **Core functionality**: Test AI models across multiple providers (Ollama, Ollama-web, OpenRouter, NVIDIA, HuggingFace) with unified interface
|
||||
- **Target users**: Developers testing AI model integrations
|
||||
|
||||
## UI/UX Specification
|
||||
|
||||
### Layout Structure
|
||||
- **Single page application** with vertical flow
|
||||
- **Sections**:
|
||||
1. Header - Logo + title
|
||||
2. Provider selector - Horizontal tabs/cards
|
||||
3. Configuration panel - API key input, model selector
|
||||
4. Query section - Textarea for prompt, thinking toggle
|
||||
5. Response area - Streaming response display
|
||||
|
||||
### Visual Design
|
||||
- **Theme**: Dark mode with glassmorphism elements
|
||||
- **Typography**:
|
||||
- Font: 'JetBrains Mono' for code, 'Outfit' for UI
|
||||
- Headings: Bold, large
|
||||
- **Effects**:
|
||||
- Glass cards with backdrop-filter blur
|
||||
- Subtle glow on active elements
|
||||
- Smooth transitions (0.3s ease)
|
||||
- Animated gradient borders on focus
|
||||
|
||||
### Components
|
||||
1. **Provider Tabs**
|
||||
- 5 providers as clickable cards
|
||||
- Active state: glowing border + background tint
|
||||
- Icons for each provider
|
||||
|
||||
2. **API Key Input**
|
||||
- Password input with show/hide toggle
|
||||
- Save indicator
|
||||
- Validation feedback
|
||||
|
||||
3. **Model Selector**
|
||||
- Dropdown loaded dynamically per provider
|
||||
- Refresh button for Ollama
|
||||
- Loading state with skeleton
|
||||
|
||||
4. **Thinking Toggle**
|
||||
- Custom styled checkbox
|
||||
- Label "Enable reasoning" / "Режим размышления"
|
||||
- Glow effect when enabled
|
||||
|
||||
5. **Query Input**
|
||||
- Large textarea with syntax highlighting feel
|
||||
- Character count
|
||||
- Submit button with loading state
|
||||
|
||||
6. **Response Display**
|
||||
- Markdown rendering
|
||||
- Copy button
|
||||
- Streaming animation (typing effect)
|
||||
- Thinking process reveal if enabled
|
||||
|
||||
## Functionality Specification
|
||||
|
||||
### Core Features
|
||||
1. **Provider Management**
|
||||
- Select from: Ollama (local), Ollama-web, OpenRouter, NVIDIA, HuggingFace
|
||||
- Store API keys per provider in config.json
|
||||
- Display saved key status (masked)
|
||||
|
||||
2. **Model Discovery**
|
||||
- Ollama: GET /api/tags from local instance
|
||||
- Ollama-web (ollama.com): GET https://ollama.com/api/tags (Bearer key)
|
||||
- OpenRouter: https://openrouter.ai/api/v1/models
|
||||
- NVIDIA: https://integrate.api.nvidia.com/v1/models
|
||||
- HuggingFace: https://api-inference.huggingface.co/models (list via inference)
|
||||
|
||||
3. **API Key Storage**
|
||||
- Save to config.json via backend
|
||||
- Keys stored encrypted (simple base64 for demo)
|
||||
- Load on page init
|
||||
|
||||
4. **Query Execution**
|
||||
- Send request to appropriate API
|
||||
- Handle streaming responses
|
||||
- Display thinking/reasoning if enabled
|
||||
- Error handling with user-friendly messages
|
||||
|
||||
5. **Thinking Mode**
|
||||
- Enable: send to provider with reasoning enabled
|
||||
- Display reasoning in collapsible section
|
||||
- Default: disabled
|
||||
|
||||
### Data Flow
|
||||
- Frontend → Flask backend → Provider APIs
|
||||
- Config stored in JSON file on server
|
||||
|
||||
### Edge Cases
|
||||
- No API key entered - prompt to enter
|
||||
- Invalid API key - show error
|
||||
- Model list empty - show "No models found"
|
||||
- Network error - show retry option
|
||||
- Ollama not running - detect and show message
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All 5 providers selectable and functional
|
||||
- [ ] API keys save to config.json
|
||||
- [ ] Models load dynamically per provider
|
||||
- [ ] Queries send and receive responses
|
||||
- [ ] Thinking mode toggles correctly
|
||||
- [ ] Responsive on desktop (mobile optional)
|
||||
- [ ] Dark theme with glassmorphism working
|
||||
- [ ] Error states handled gracefully
|
||||
|
||||
## Files
|
||||
- `server.py` - Flask backend
|
||||
- `index.html` - Frontend
|
||||
- `config.json` - Data storage (auto-created)
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"endpoint": "https://example.com",
|
||||
"last_model": "openai/gpt-oss-20b",
|
||||
"api_key_b64": ""
|
||||
},
|
||||
"huggingface": {
|
||||
"last_model": "google/gemma-3-1b-it",
|
||||
"api_key_b64": ""
|
||||
},
|
||||
"nvidia": {
|
||||
"api_key_b64": "",
|
||||
"last_model": "openai/gpt-oss-20b"
|
||||
},
|
||||
"ollama-web": {
|
||||
"api_key_b64": "",
|
||||
"endpoint": "https://ollama.com",
|
||||
"last_model": "gemma4:31b"
|
||||
},
|
||||
"ollama": {
|
||||
"last_model": "tinyllama:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1393
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
flask>=3.0.0
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,827 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, Generator, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from flask import Flask, Response, jsonify, request, send_from_directory
|
||||
|
||||
|
||||
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_PATH = os.path.join(APP_DIR, "config.json")
|
||||
DB_PATH = os.path.join(APP_DIR, "chat.sqlite3")
|
||||
_CONFIG_LOCK = threading.Lock()
|
||||
|
||||
DEFAULT_ENDPOINTS = {
|
||||
"ollama": "http://localhost:11434",
|
||||
# "ollama-web" in this app means "Ollama Cloud via ollama.com API"
|
||||
"ollama-web": "https://ollama.com",
|
||||
}
|
||||
|
||||
|
||||
def _b64e(s: str) -> str:
|
||||
return base64.b64encode(s.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def _b64d(s: str) -> str:
|
||||
return base64.b64decode(s.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def _mask_key(k: str) -> str:
|
||||
if not k:
|
||||
return ""
|
||||
if len(k) <= 6:
|
||||
return "*" * len(k)
|
||||
return f"{k[:3]}***{k[-3:]}"
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
return {"providers": {}}
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {"providers": {}}
|
||||
data.setdefault("providers", {})
|
||||
if not isinstance(data["providers"], dict):
|
||||
data["providers"] = {}
|
||||
return data
|
||||
except Exception:
|
||||
return {"providers": {}}
|
||||
|
||||
|
||||
def save_config(cfg: Dict[str, Any]) -> None:
|
||||
# Use a unique temp file to avoid races between concurrent requests.
|
||||
# Keep temp file in the same directory to make os.replace atomic.
|
||||
with _CONFIG_LOCK:
|
||||
fd, tmp = tempfile.mkstemp(prefix="config.", suffix=".json.tmp", dir=APP_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, CONFIG_PATH)
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_provider_cfg(provider: str) -> Dict[str, Any]:
|
||||
cfg = load_config()
|
||||
prov = cfg.get("providers", {}).get(provider, {})
|
||||
return prov if isinstance(prov, dict) else {}
|
||||
|
||||
|
||||
def set_provider_cfg(provider: str, provider_cfg: Dict[str, Any]) -> None:
|
||||
cfg = load_config()
|
||||
cfg.setdefault("providers", {})
|
||||
cfg["providers"][provider] = provider_cfg
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def get_api_key(provider: str) -> str:
|
||||
prov = get_provider_cfg(provider)
|
||||
enc = prov.get("api_key_b64", "")
|
||||
if not enc:
|
||||
return ""
|
||||
try:
|
||||
return _b64d(enc)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_endpoint(provider: str) -> str:
|
||||
prov = get_provider_cfg(provider)
|
||||
ep = prov.get("endpoint", "")
|
||||
if isinstance(ep, str) and ep.strip():
|
||||
return ep.strip().rstrip("/")
|
||||
return DEFAULT_ENDPOINTS.get(provider, "")
|
||||
|
||||
|
||||
def json_line(obj: Dict[str, Any]) -> bytes:
|
||||
return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _decode_line(raw: Any) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
if isinstance(raw, str):
|
||||
return raw
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _extract_ollama_library_models_from_text(txt: str) -> list:
|
||||
# Web page fetch (no stable public JSON API). Parse headings like "## llama3.2".
|
||||
models = []
|
||||
for line in (txt or "").splitlines():
|
||||
s = line.strip()
|
||||
if not s.startswith("## "):
|
||||
continue
|
||||
name = s[3:].strip()
|
||||
if name:
|
||||
models.append(name)
|
||||
uniq = sorted(set(models))
|
||||
return uniq
|
||||
|
||||
|
||||
def _extract_ollama_library_tags_from_model_page(txt: str, base_model: str) -> list:
|
||||
"""
|
||||
Parse a model library page (converted to text/markdown by WebFetch-like rendering)
|
||||
and extract available tags like "<base_model>:<tag>".
|
||||
"""
|
||||
tags: set[str] = set()
|
||||
needle = f"/library/{base_model}:"
|
||||
for line in (txt or "").splitlines():
|
||||
if needle not in line:
|
||||
continue
|
||||
# Example lines in fetched text:
|
||||
# [llama3.2:latest2.0GB · ...](https://ollama.com/library/llama3.2:latest)
|
||||
# [llama3.2:1b1.3GB · ...](https://ollama.com/library/llama3.2:1b)
|
||||
i = line.find(needle)
|
||||
if i < 0:
|
||||
continue
|
||||
frag = line[i + len("/library/") :]
|
||||
# frag now starts with "<base_model>:<tag>..."
|
||||
if not frag.startswith(base_model + ":"):
|
||||
continue
|
||||
frag2 = frag[len(base_model) + 1 :]
|
||||
tag = ""
|
||||
for ch in frag2:
|
||||
if ch.isalnum() or ch in ("-", "_", "."):
|
||||
tag += ch
|
||||
else:
|
||||
break
|
||||
if tag:
|
||||
tags.add(f"{base_model}:{tag}")
|
||||
if not tags:
|
||||
# Fallback: at least a usable default
|
||||
return [f"{base_model}:latest"]
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def _ollama_cloud_headers() -> Dict[str, str]:
|
||||
key = get_api_key("ollama-web")
|
||||
if not key:
|
||||
raise RuntimeError("Ollama.com API key is missing (create one at https://ollama.com/settings/keys)")
|
||||
return {"Authorization": f"Bearer {key}"}
|
||||
|
||||
|
||||
def _stream_ollama_ndjson(r: requests.Response) -> Generator[Tuple[str, str], None, None]:
|
||||
"""
|
||||
Ollama streaming endpoints can return application/x-ndjson where each line is a JSON object.
|
||||
We emit "content" deltas based on response/message fields.
|
||||
"""
|
||||
for raw in r.iter_lines(decode_unicode=False):
|
||||
line = _decode_line(raw).strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
|
||||
# /api/generate shape: {"response": "...", "done": false, ...}
|
||||
resp = obj.get("response")
|
||||
if isinstance(resp, str) and resp:
|
||||
yield "content", resp
|
||||
continue
|
||||
|
||||
# /api/chat shape: {"message": {"role": "...", "content": "..."}, "done": false, ...}
|
||||
msg = obj.get("message")
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
yield "content", content
|
||||
continue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderResult:
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def _required_str(payload: Dict[str, Any], key: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
v = payload.get(key)
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
return None, f"Missing or invalid field: {key}"
|
||||
return v, None
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder=".", static_url_path="")
|
||||
|
||||
|
||||
def _db_connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
except Exception:
|
||||
# Some environments can reject WAL; keep default mode.
|
||||
pass
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
conn = _db_connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
response TEXT NOT NULL DEFAULT '',
|
||||
reasoning TEXT NOT NULL DEFAULT '',
|
||||
reasoning_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'streaming',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_provider_id ON chat_messages(provider, id);")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _chat_create(provider: str, model: str, prompt: str, reasoning_enabled: bool) -> int:
|
||||
ts = _now_ts()
|
||||
conn = _db_connect()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_messages(provider, model, prompt, reasoning_enabled, status, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, 'streaming', ?, ?)
|
||||
""",
|
||||
(provider, model, prompt, 1 if reasoning_enabled else 0, ts, ts),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _chat_update(
|
||||
chat_id: int,
|
||||
*,
|
||||
response: Optional[str] = None,
|
||||
reasoning: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
ts = _now_ts()
|
||||
sets = ["updated_at = ?"]
|
||||
args: list[Any] = [ts]
|
||||
if response is not None:
|
||||
sets.append("response = ?")
|
||||
args.append(response)
|
||||
if reasoning is not None:
|
||||
sets.append("reasoning = ?")
|
||||
args.append(reasoning)
|
||||
if status is not None:
|
||||
sets.append("status = ?")
|
||||
args.append(status)
|
||||
if error is not None:
|
||||
sets.append("error = ?")
|
||||
args.append(error)
|
||||
args.append(chat_id)
|
||||
|
||||
conn = _db_connect()
|
||||
try:
|
||||
conn.execute(f"UPDATE chat_messages SET {', '.join(sets)} WHERE id = ?", args)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Ensure DB exists even when run via `flask run` / WSGI.
|
||||
init_db()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> Any:
|
||||
return send_from_directory(APP_DIR, "index.html")
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def api_get_config() -> Any:
|
||||
cfg = load_config()
|
||||
out: Dict[str, Any] = {"providers": {}}
|
||||
for provider, p in cfg.get("providers", {}).items():
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
key = ""
|
||||
try:
|
||||
key = _b64d(p.get("api_key_b64", "") or "")
|
||||
except Exception:
|
||||
key = ""
|
||||
out["providers"][provider] = {
|
||||
"hasKey": bool(key),
|
||||
"keyMasked": _mask_key(key) if key else "",
|
||||
"endpoint": p.get("endpoint", DEFAULT_ENDPOINTS.get(provider, "")),
|
||||
"lastModel": p.get("last_model", ""),
|
||||
}
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@app.post("/api/config")
|
||||
def api_set_config() -> Any:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
provider, err = _required_str(payload, "provider")
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
|
||||
api_key = payload.get("apiKey", "")
|
||||
if api_key is not None and not isinstance(api_key, str):
|
||||
return jsonify({"ok": False, "error": "apiKey must be a string"}), 400
|
||||
|
||||
endpoint = payload.get("endpoint", None)
|
||||
if endpoint is not None and not isinstance(endpoint, str):
|
||||
return jsonify({"ok": False, "error": "endpoint must be a string"}), 400
|
||||
|
||||
last_model = payload.get("lastModel", None)
|
||||
if last_model is not None and not isinstance(last_model, str):
|
||||
return jsonify({"ok": False, "error": "lastModel must be a string"}), 400
|
||||
|
||||
current = get_provider_cfg(provider)
|
||||
new_cfg = dict(current)
|
||||
|
||||
if isinstance(api_key, str) and api_key.strip():
|
||||
new_cfg["api_key_b64"] = _b64e(api_key.strip())
|
||||
elif payload.get("clearKey") is True:
|
||||
new_cfg.pop("api_key_b64", None)
|
||||
|
||||
if isinstance(endpoint, str):
|
||||
new_cfg["endpoint"] = endpoint.strip().rstrip("/")
|
||||
|
||||
if isinstance(last_model, str) and last_model.strip():
|
||||
new_cfg["last_model"] = last_model.strip()
|
||||
|
||||
set_provider_cfg(provider, new_cfg)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/models")
|
||||
def api_models() -> Any:
|
||||
provider = request.args.get("provider", "").strip()
|
||||
if not provider:
|
||||
return jsonify({"ok": False, "error": "Missing provider"}), 400
|
||||
|
||||
try:
|
||||
models = discover_models(provider)
|
||||
return jsonify({"ok": True, "models": models})
|
||||
except requests.exceptions.ConnectionError:
|
||||
if provider == "ollama":
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Ollama не запущена. Проверьте, что локальный Ollama работает на http://localhost:11434.",
|
||||
}
|
||||
),
|
||||
502,
|
||||
)
|
||||
return jsonify({"ok": False, "error": "Network error while loading models"}), 502
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e) or "Failed to load models"}), 500
|
||||
|
||||
|
||||
def discover_models(provider: str) -> list:
|
||||
if provider == "ollama":
|
||||
base = get_endpoint("ollama") or DEFAULT_ENDPOINTS["ollama"]
|
||||
r = requests.get(f"{base}/api/tags", timeout=10)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = []
|
||||
for m in data.get("models", []) or []:
|
||||
name = m.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
models.append(name)
|
||||
return sorted(set(models))
|
||||
|
||||
if provider == "ollama-web":
|
||||
# Ollama Cloud: https://ollama.com/api/tags (requires API key)
|
||||
base = get_endpoint("ollama-web") or DEFAULT_ENDPOINTS["ollama-web"]
|
||||
r = requests.get(f"{base}/api/tags", headers=_ollama_cloud_headers(), timeout=20)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = []
|
||||
for m in data.get("models", []) or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
name = m.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
models.append(name)
|
||||
return sorted(set(models))
|
||||
|
||||
if provider == "openrouter":
|
||||
key = get_api_key("openrouter")
|
||||
if not key:
|
||||
raise RuntimeError("OpenRouter API key is missing")
|
||||
r = requests.get(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
timeout=20,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = []
|
||||
for m in data.get("data", []) or []:
|
||||
mid = m.get("id")
|
||||
if isinstance(mid, str) and mid:
|
||||
models.append(mid)
|
||||
return sorted(set(models))
|
||||
|
||||
if provider == "nvidia":
|
||||
key = get_api_key("nvidia")
|
||||
if not key:
|
||||
raise RuntimeError("NVIDIA API key is missing")
|
||||
r = requests.get(
|
||||
"https://integrate.api.nvidia.com/v1/models",
|
||||
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
|
||||
timeout=20,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = []
|
||||
items = []
|
||||
if isinstance(data, dict):
|
||||
if isinstance(data.get("data"), list):
|
||||
items = data["data"]
|
||||
elif isinstance(data.get("models"), list):
|
||||
items = data["models"]
|
||||
for m in items or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
mid = m.get("id") or m.get("name")
|
||||
if isinstance(mid, str) and mid:
|
||||
models.append(mid)
|
||||
return sorted(set(models))
|
||||
|
||||
if provider == "huggingface":
|
||||
# List only models that are actually served by at least one Inference Provider.
|
||||
# This avoids many 404s on https://api-inference.huggingface.co/models/{id}.
|
||||
key = get_api_key("huggingface")
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
r = requests.get(
|
||||
"https://huggingface.co/api/models"
|
||||
"?inference_provider=all"
|
||||
"&pipeline_tag=text-generation"
|
||||
"&sort=downloads"
|
||||
"&direction=-1"
|
||||
"&limit=80",
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = []
|
||||
if isinstance(data, list):
|
||||
for m in data:
|
||||
if isinstance(m, dict) and isinstance(m.get("modelId"), str):
|
||||
models.append(m["modelId"])
|
||||
return sorted(set(models))
|
||||
|
||||
raise RuntimeError(f"Unknown provider: {provider}")
|
||||
|
||||
|
||||
@app.get("/api/chat")
|
||||
def api_chat_list() -> Any:
|
||||
provider = request.args.get("provider", "").strip()
|
||||
if not provider:
|
||||
return jsonify({"ok": False, "error": "Missing provider"}), 400
|
||||
|
||||
try:
|
||||
limit = int(request.args.get("limit", "20"))
|
||||
except Exception:
|
||||
limit = 20
|
||||
limit = max(1, min(limit, 100))
|
||||
|
||||
before_id_raw = request.args.get("before_id", "").strip()
|
||||
before_id: Optional[int] = None
|
||||
if before_id_raw:
|
||||
try:
|
||||
before_id = int(before_id_raw)
|
||||
except Exception:
|
||||
return jsonify({"ok": False, "error": "before_id must be an integer"}), 400
|
||||
|
||||
conn = _db_connect()
|
||||
try:
|
||||
if before_id is None:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, provider, model, prompt, response, reasoning, reasoning_enabled, status, error, created_at, updated_at
|
||||
FROM chat_messages
|
||||
WHERE provider = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(provider, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, provider, model, prompt, response, reasoning, reasoning_enabled, status, error, created_at, updated_at
|
||||
FROM chat_messages
|
||||
WHERE provider = ? AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(provider, before_id, limit),
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": int(r["id"]),
|
||||
"provider": r["provider"],
|
||||
"model": r["model"],
|
||||
"prompt": r["prompt"],
|
||||
"response": r["response"],
|
||||
"reasoning": r["reasoning"],
|
||||
"reasoningEnabled": bool(r["reasoning_enabled"]),
|
||||
"status": r["status"],
|
||||
"error": r["error"],
|
||||
"createdAt": int(r["created_at"]),
|
||||
"updatedAt": int(r["updated_at"]),
|
||||
}
|
||||
)
|
||||
|
||||
next_before_id = items[-1]["id"] if items else None
|
||||
has_more = len(items) == limit
|
||||
return jsonify({"ok": True, "items": items, "nextBeforeId": next_before_id, "hasMore": has_more})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.post("/api/chat/clear")
|
||||
def api_chat_clear() -> Any:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
provider, err = _required_str(payload, "provider")
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
conn = _db_connect()
|
||||
try:
|
||||
conn.execute("DELETE FROM chat_messages WHERE provider = ?", (provider,))
|
||||
conn.commit()
|
||||
return jsonify({"ok": True})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.post("/api/query")
|
||||
def api_query() -> Any:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
provider, err = _required_str(payload, "provider")
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
model, err = _required_str(payload, "model")
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
prompt, err = _required_str(payload, "prompt")
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
reasoning = bool(payload.get("reasoning", False))
|
||||
|
||||
# persist last model
|
||||
try:
|
||||
set_provider_cfg(provider, {**get_provider_cfg(provider), "last_model": model})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
chat_id: Optional[int] = None
|
||||
try:
|
||||
chat_id = _chat_create(provider, model, prompt, reasoning_enabled=reasoning)
|
||||
except Exception:
|
||||
chat_id = None
|
||||
|
||||
def gen() -> Generator[bytes, None, None]:
|
||||
yield json_line(
|
||||
{"type": "meta", "provider": provider, "model": model, "ts": int(time.time()), "chatId": chat_id}
|
||||
)
|
||||
full_text = ""
|
||||
full_reasoning = ""
|
||||
try:
|
||||
for channel, chunk in stream_completion(provider, model, prompt, reasoning=reasoning):
|
||||
if channel == "reasoning":
|
||||
full_reasoning += chunk
|
||||
else:
|
||||
full_text += chunk
|
||||
yield json_line({"type": "delta", "channel": channel, "text": chunk})
|
||||
if chat_id is not None:
|
||||
_chat_update(chat_id, response=full_text, reasoning=full_reasoning, status="done", error="")
|
||||
yield json_line({"type": "done"})
|
||||
except requests.exceptions.ConnectionError:
|
||||
if chat_id is not None:
|
||||
_chat_update(
|
||||
chat_id,
|
||||
response=full_text,
|
||||
reasoning=full_reasoning,
|
||||
status="error",
|
||||
error="Network error",
|
||||
)
|
||||
yield json_line({"type": "error", "error": "Network error. Проверьте соединение и повторите."})
|
||||
except Exception as e:
|
||||
msg = str(e) or "Request failed"
|
||||
if chat_id is not None:
|
||||
_chat_update(chat_id, response=full_text, reasoning=full_reasoning, status="error", error=msg)
|
||||
yield json_line({"type": "error", "error": msg})
|
||||
except GeneratorExit:
|
||||
if chat_id is not None:
|
||||
try:
|
||||
_chat_update(chat_id, response=full_text, reasoning=full_reasoning, status="aborted")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
return Response(gen(), mimetype="application/x-ndjson")
|
||||
|
||||
|
||||
def stream_completion(
|
||||
provider: str, model: str, prompt: str, reasoning: bool
|
||||
) -> Generator[Tuple[str, str], None, None]:
|
||||
if provider == "ollama":
|
||||
base = get_endpoint("ollama") or DEFAULT_ENDPOINTS["ollama"]
|
||||
r = requests.post(
|
||||
f"{base}/api/generate",
|
||||
json={"model": model, "prompt": prompt, "stream": True},
|
||||
timeout=60,
|
||||
stream=True,
|
||||
)
|
||||
r.raise_for_status()
|
||||
for raw in r.iter_lines(decode_unicode=False):
|
||||
line = _decode_line(raw).strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(obj, dict) and isinstance(obj.get("response"), str):
|
||||
yield "content", obj["response"]
|
||||
return
|
||||
|
||||
if provider == "ollama-web":
|
||||
# Ollama Cloud via ollama.com API: https://ollama.com/api/chat
|
||||
base = get_endpoint("ollama-web") or DEFAULT_ENDPOINTS["ollama-web"]
|
||||
headers = {"Content-Type": "application/json", **_ollama_cloud_headers()}
|
||||
body: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
if reasoning:
|
||||
body["reasoning"] = True
|
||||
r = requests.post(f"{base}/api/chat", headers=headers, json=body, timeout=60, stream=True)
|
||||
r.raise_for_status()
|
||||
yield from _stream_ollama_ndjson(r)
|
||||
return
|
||||
|
||||
if provider == "openrouter":
|
||||
key = get_api_key("openrouter")
|
||||
if not key:
|
||||
raise RuntimeError("OpenRouter API key is missing")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
if reasoning:
|
||||
body["reasoning"] = True
|
||||
r = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers=headers,
|
||||
json=body,
|
||||
timeout=60,
|
||||
stream=True,
|
||||
)
|
||||
r.raise_for_status()
|
||||
yield from _stream_openai_sse(r)
|
||||
return
|
||||
|
||||
if provider == "nvidia":
|
||||
key = get_api_key("nvidia")
|
||||
if not key:
|
||||
raise RuntimeError("NVIDIA API key is missing")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
if reasoning:
|
||||
body["reasoning"] = True
|
||||
r = requests.post(
|
||||
"https://integrate.api.nvidia.com/v1/chat/completions",
|
||||
headers=headers,
|
||||
json=body,
|
||||
timeout=60,
|
||||
stream=True,
|
||||
)
|
||||
r.raise_for_status()
|
||||
yield from _stream_openai_sse(r)
|
||||
return
|
||||
|
||||
if provider == "huggingface":
|
||||
key = get_api_key("huggingface")
|
||||
if not key:
|
||||
raise RuntimeError("HuggingFace API key is missing")
|
||||
headers = {"Authorization": f"Bearer {key}"}
|
||||
# Basic inference API. Not truly streaming; emulate.
|
||||
r = requests.post(
|
||||
f"https://api-inference.huggingface.co/models/{model}",
|
||||
headers=headers,
|
||||
json={"inputs": prompt, "parameters": {"max_new_tokens": 512}},
|
||||
timeout=90,
|
||||
)
|
||||
if r.status_code == 404:
|
||||
raise RuntimeError(
|
||||
"HuggingFace: модель не найдена или недоступна для Inference API (404). "
|
||||
"Попробуйте другую модель из списка (text-generation) или проверьте права доступа/гейтинг."
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
text = ""
|
||||
if isinstance(data, list) and data and isinstance(data[0], dict) and isinstance(data[0].get("generated_text"), str):
|
||||
text = data[0]["generated_text"]
|
||||
elif isinstance(data, dict) and isinstance(data.get("generated_text"), str):
|
||||
text = data["generated_text"]
|
||||
elif isinstance(data, dict) and isinstance(data.get("error"), str):
|
||||
raise RuntimeError(data["error"])
|
||||
else:
|
||||
text = json.dumps(data, ensure_ascii=False)
|
||||
for ch in text:
|
||||
yield "content", ch
|
||||
return
|
||||
|
||||
raise RuntimeError(f"Unknown provider: {provider}")
|
||||
|
||||
|
||||
def _stream_openai_sse(r: requests.Response) -> Generator[Tuple[str, str], None, None]:
|
||||
for raw in r.iter_lines(decode_unicode=False):
|
||||
line = _decode_line(raw).strip()
|
||||
if not line:
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[len("data:") :].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
delta = obj["choices"][0].get("delta", {})
|
||||
content = delta.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
yield "content", content
|
||||
|
||||
reasoning = delta.get("reasoning") or delta.get("thinking")
|
||||
if isinstance(reasoning, str) and reasoning:
|
||||
yield "reasoning", reasoning
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# In some sandboxed environments, Werkzeug's debugger can fail due to /dev/shm restrictions.
|
||||
debug = os.environ.get("FLASK_DEBUG", "").strip() in ("1", "true", "True")
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=int(os.environ.get("PORT", "8000")),
|
||||
debug=debug,
|
||||
use_reloader=debug,
|
||||
)
|
||||
Reference in New Issue
Block a user