Many little updates

parent d1787cd5
...@@ -108,6 +108,7 @@ Configuration management with smart file location detection: ...@@ -108,6 +108,7 @@ Configuration management with smart file location detection:
- Creates `~/.aisbf/` directory on first run - Creates `~/.aisbf/` directory on first run
- Copies default configs to user directory - Copies default configs to user directory
- Reads from `~/.aisbf/` on subsequent runs - Reads from `~/.aisbf/` on subsequent runs
- Contains shared tri-state feature resolution for request-time controls such as context condensation, response cache, prompt batching, prompt security, Context Lens analytics, and NSFW/privacy classification
### aisbf/models.py ### aisbf/models.py
Pydantic models for data validation: Pydantic models for data validation:
...@@ -143,6 +144,13 @@ Request handling logic: ...@@ -143,6 +144,13 @@ Request handling logic:
- `AutoselectHandler` - Handles AI-powered model selection - `AutoselectHandler` - Handles AI-powered model selection
- Reuses shared Studio capability inference for model metadata normalization - Reuses shared Studio capability inference for model metadata normalization
- Supports both streaming and non-streaming responses - Supports both streaming and non-streaming responses
- Performs native local prompt analysis before upstream execution when enabled, including prompt-security scanning, Context Lens-style prompt composition capture, and optional local blocking for high-risk prompts
### aisbf/prompt_analysis.py
Native prompt security and prompt composition analytics:
- Performs local regex/heuristic scanning inspired by Prompt Sail-style prompt governance
- Builds Context Lens-style composition summaries from messages, tools, prompt/input payloads, and token-role distribution
- Produces redacted evidence previews and hashed request fingerprints for persistence without storing raw sensitive content by default
### aisbf/studio.py ### aisbf/studio.py
Shared Studio integration helpers: Shared Studio integration helpers:
...@@ -226,6 +234,8 @@ Installs to: ...@@ -226,6 +234,8 @@ Installs to:
**User Configuration Files (writable):** **User Configuration Files (writable):**
- `~/.aisbf/providers.json` - Provider configurations - `~/.aisbf/providers.json` - Provider configurations
- `~/.aisbf/rotations.json` - Rotation configurations - `~/.aisbf/rotations.json` - Rotation configurations
- `~/.aisbf/autoselect.json` - Autoselect configurations
- `~/.aisbf/aisbf.json` - Global AISBF settings, including `feature_controls`
**Development Mode:** **Development Mode:**
- `config/providers.json` and `config/rotations.json` in source tree - `config/providers.json` and `config/rotations.json` in source tree
...@@ -236,6 +246,27 @@ Installs to: ...@@ -236,6 +246,27 @@ Installs to:
3. Copies default configs from installed location to `~/.aisbf/` 3. Copies default configs from installed location to `~/.aisbf/`
4. Loads configuration from `~/.aisbf/` on subsequent runs 4. Loads configuration from `~/.aisbf/` on subsequent runs
### Feature Control Hierarchy
- Shared request-time features use tri-state inheritance: `inherit`, `enabled`, `disabled`
- Global defaults live in `aisbf.json` under `feature_controls`
- Provider/model/rotation/autoselect configs may override global defaults with `enable_*` or related boolean override fields
- Current tri-state-controlled feature families include:
- context condensation
- response cache
- prompt batching
- prompt security scanning
- Context Lens prompt analytics
- NSFW classification
- privacy classification
- high-risk prompt blocking
### Prompt Analysis Persistence
- Native prompt analysis results are stored in the configuration database using:
- `prompt_analysis_runs`
- `prompt_analysis_findings`
- `/dashboard/analytics` now includes Prompt Analysis sections for both admin and user scopes
- Visitor/activity analytics remain config-admin-only, while prompt-analysis summaries can be shown in filtered user scope
## AISBF Script Commands ## AISBF Script Commands
The installed `aisbf` script supports: The installed `aisbf` script supports:
......
This diff is collapsed.
# AISBF - AI Service Broker Framework || AI Should Be Free # AISBF - AI Service Broker Framework || AI Should Be Free
A modular proxy server for managing multiple AI provider integrations with unified API interface. AISBF provides intelligent routing, load balancing, and AI-assisted model selection to optimize AI service usage across multiple providers. A modular proxy server for managing multiple AI provider integrations with unified API interface. AISBF provides intelligent routing, load balancing, AI-assisted model selection, hosted model marketplaces, and multimodal Studio workflows across local and remote providers.
--- ---
...@@ -19,20 +19,32 @@ Also available via TOR for privacy-first access: ...@@ -19,20 +19,32 @@ Also available via TOR for privacy-first access:
## Key Features ## Key Features
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro, Kilocode, Codex, and Qwen - **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro, Kilocode, Codex, Qwen, CoderAI, and RunPod
- **Claude CLI Mode**: When the `claude` binary is in PATH, requests are proxied through the official Anthropic CLI (`claude -p`) — uses each user's own account, fully permitted by Claude's terms of service - **CoderAI Broker Mode**: NAT-friendly outbound WebSocket broker with provider-scoped registration tokens, persisted session metadata, direct dashboard status, and Studio endpoint forwarding
- **RunPod Runtime Management**: Pod-backed, serverless-template, and public-catalog RunPod providers with runtime state persistence, startup polling, idle shutdown, and wrapper-mode delegation to OpenAI, Ollama, or CoderAI
- **AISBF Studio**: Multimodal dashboard workspace for chat, image, video, audio, embedding, and 3D workflows with reusable characters, environments, voices, archives, and custom pipelines
- **Marketplace & References**: Publish providers, models, rotations, and autoselects to a shared market, import them as locked references, and track listing analytics and revenue
- **Claude CLI Mode**: When the `claude` binary is in PATH, requests are proxied through the official Anthropic CLI (`claude -p`) using each user's own account
- **Unified Wallet System**: Fiat wallet with crypto/PayPal/Stripe top-ups and auto top-up for subscription renewals - **Unified Wallet System**: Fiat wallet with crypto/PayPal/Stripe top-ups and auto top-up for subscription renewals
- **Intelligent Routing**: Weighted load balancing and AI-assisted model selection - **Intelligent Routing**: Weighted load balancing and AI-assisted model selection
- **Streaming Support**: Full support for streaming responses from all providers - **Streaming Support**: Full support for streaming responses from all providers
- **Web Dashboard**: Complete configuration and management interface - **Web Dashboard**: Complete configuration and management interface
- **Multi-User Support**: Isolated configurations with role-based access control - **Multi-User Support**: Isolated configurations with role-based access control
- **Token Usage Analytics**: Comprehensive analytics with cost estimation and export - **Token Usage Analytics**: Comprehensive analytics with cost estimation, broker telemetry, and export
- **Adaptive Rate Limiting**: Learns from 429 responses for optimal request rates - **Adaptive Rate Limiting**: Learns from 429 responses for optimal request rates
- **Provider-Native Caching**: 50-70% cost reduction with Anthropic, Google, and OpenAI caching - **Provider-Native Caching**: 50-70% cost reduction with Anthropic, Google, and OpenAI caching
- **Context Management**: Automatic condensation with 8+ methods when approaching limits - **Context Management**: Automatic condensation with 8+ methods when approaching limits
- **SSL/TLS & TOR**: Built-in HTTPS with Let's Encrypt and TOR hidden service support - **SSL/TLS & TOR**: Built-in HTTPS with Let's Encrypt and TOR hidden service support
- **MCP Server**: Model Context Protocol for remote agent integration - **MCP Server**: Model Context Protocol for remote agent integration
## What's New Since 0.99.65
- **CoderAI broker telemetry and NAT traversal**: Broker sessions now persist state across restarts, expose connection and performance telemetry in the dashboard, and support Studio-native proxying over WebSocket for remote or firewalled workers
- **RunPod provider support**: AISBF can now manage RunPod pods, serverless templates, and public endpoints from the provider editor, including runtime refresh and protocol-aware delegation
- **Marketplace administration**: Added dedicated market admin pages, publishing and settlement flows, user export filters, locked imported references, and improved search and ordering
- **Studio expansion**: Added dashboard Studio bindings, multimodal function routing, reusable profile assets, custom pipelines, and admin/user scoped Studio persistence APIs
- **Operational hardening**: Signup cleanup removes stale self-registered accounts after 14 days of inactivity, dashboard proxy path handling was normalized, and provider model caches are reused before refresh
## Quick Start ## Quick Start
### Installation ### Installation
...@@ -64,10 +76,12 @@ Access the dashboard at `http://localhost:17765/dashboard` (default credentials: ...@@ -64,10 +76,12 @@ Access the dashboard at `http://localhost:17765/dashboard` (default credentials:
The dashboard provides: The dashboard provides:
- Provider configuration and API key management - Provider configuration and API key management
- RunPod runtime controls and CoderAI broker session monitoring
- Rotation and autoselect model setup - Rotation and autoselect model setup
- AISBF Studio multimodal workflows and pipeline bindings
- User wallet management and top-up options - User wallet management and top-up options
- Token usage analytics and cost tracking - Token usage analytics, broker telemetry, and cost tracking
- Real-time monitoring and rate limit management - Marketplace publishing, importing, and administration
- SSL/TLS and TOR configuration - SSL/TLS and TOR configuration
- Multi-user administration - Multi-user administration
...@@ -94,12 +108,20 @@ curl -X POST http://localhost:17765/api/wallet/topup \ ...@@ -94,12 +108,20 @@ curl -X POST http://localhost:17765/api/wallet/topup \
}' }'
``` ```
### CoderAI Broker Session Status
```bash
curl http://localhost:17765/api/coderai/broker/sessions
```
## Documentation ## Documentation
For complete documentation, configuration guides, and API reference: For complete documentation, configuration guides, and API reference:
- **[📚 Full Documentation](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md)** - Comprehensive user and developer guide - **[📚 Full Documentation](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md)** - Comprehensive user and developer guide
- **[🔧 Installation Guide](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#installation)** - Detailed setup instructions - **[🔧 Installation Guide](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#installation)** - Detailed setup instructions
- **[⚙️ Configuration](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#configuration)** - All configuration options - **[⚙️ Configuration](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#configuration)** - All configuration options
- **[🎛️ Studio Guide](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#aisbf-studio)** - Multimodal Studio, bindings, and pipelines
- **[🛒 Marketplace](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#marketplace-and-references)** - Publishing, imports, and settlements
- **[🤖 CoderAI Broker](https://git.nexlab.net/nexlab/aisbf/blob/master/docs/coderai-integration.md)** - Broker protocol and integration reference
- **[💰 Wallet System](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#wallet-system)** - Complete wallet documentation - **[💰 Wallet System](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#wallet-system)** - Complete wallet documentation
- **[🔌 API Reference](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#api-endpoints)** - Complete API documentation - **[🔌 API Reference](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#api-endpoints)** - Complete API documentation
- **[🛠️ Development](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#development)** - Development and deployment guides - **[🛠️ Development](https://git.nexlab.net/nexlab/aisbf/blob/master/DOCUMENTATION.md#development)** - Development and deployment guides
...@@ -127,4 +149,4 @@ Official repository: https://git.nexlab.net/nexlab/aisbf.git ...@@ -127,4 +149,4 @@ Official repository: https://git.nexlab.net/nexlab/aisbf.git
## License ## License
GNU General Public License v3.0 GNU General Public License v3.0
\ No newline at end of file
...@@ -2,7 +2,5 @@ ...@@ -2,7 +2,5 @@
## Planned Features ## Planned Features
- [ ] Add support for mempalace
- [ ] Add support for caveman mode
- [ ] Integrate github larsderidder context-lens project - [ ] Integrate github larsderidder context-lens project
- [ ] Integrate https://github.com/PromptSail/prompt_sail - [ ] Integrate https://github.com/PromptSail/prompt_sail
...@@ -371,7 +371,7 @@ start_server() { ...@@ -371,7 +371,7 @@ start_server() {
echo "Starting AISBF on $HOST:$PORT..." echo "Starting AISBF on $HOST:$PORT..."
# Check if debug mode is enabled # Keep packaged launches out of debug mode unless explicitly requested.
if [ "$DEBUG" = "true" ]; then if [ "$DEBUG" = "true" ]; then
echo "Debug mode enabled - showing all debug messages" echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true export AISBF_DEBUG=true
...@@ -388,6 +388,8 @@ except Exception as e: ...@@ -388,6 +388,8 @@ except Exception as e:
traceback.print_exc() traceback.print_exc()
exit(1) exit(1)
" 2>&1 " 2>&1
else
unset AISBF_DEBUG
fi fi
# Signal to the aisbf package that it is running as a server # Signal to the aisbf package that it is running as a server
...@@ -426,10 +428,12 @@ start_daemon() { ...@@ -426,10 +428,12 @@ start_daemon() {
echo "Starting AISBF on $HOST:$PORT in background..." echo "Starting AISBF on $HOST:$PORT in background..."
# Check if debug mode is enabled # Keep packaged launches out of debug mode unless explicitly requested.
if [ "$DEBUG" = "true" ]; then if [ "$DEBUG" = "true" ]; then
echo "Debug mode enabled - showing all debug messages" echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true export AISBF_DEBUG=true
else
unset AISBF_DEBUG
fi fi
# Start in background with nohup and logging # Start in background with nohup and logging
......
...@@ -371,15 +371,14 @@ start_server() { ...@@ -371,15 +371,14 @@ start_server() {
echo "Starting AISBF on $HOST:$PORT..." echo "Starting AISBF on $HOST:$PORT..."
# Check if debug mode is enabled # Keep packaged launches out of debug mode unless explicitly requested.
if [ "$DEBUG" = "true" ]; then if [ "$DEBUG" = "true" ]; then
echo "Debug mode enabled - showing all debug messages" echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true export AISBF_DEBUG=true
fi
# Test importing main module before starting uvicorn # Test importing main module before starting uvicorn
echo "=== DEBUG: Testing main module import ===" echo "=== DEBUG: Testing main module import ==="
python3 -c " python3 -c "
try: try:
import main import main
print('main module imported successfully') print('main module imported successfully')
...@@ -389,6 +388,9 @@ except Exception as e: ...@@ -389,6 +388,9 @@ except Exception as e:
traceback.print_exc() traceback.print_exc()
exit(1) exit(1)
" 2>&1 " 2>&1
else
unset AISBF_DEBUG
fi
# Start the proxy server - runs in foreground # Start the proxy server - runs in foreground
# Use exec to replace the shell process so signals are properly handled # Use exec to replace the shell process so signals are properly handled
...@@ -423,10 +425,12 @@ start_daemon() { ...@@ -423,10 +425,12 @@ start_daemon() {
echo "Starting AISBF on $HOST:$PORT in background..." echo "Starting AISBF on $HOST:$PORT in background..."
# Check if debug mode is enabled # Keep packaged launches out of debug mode unless explicitly requested.
if [ "$DEBUG" = "true" ]; then if [ "$DEBUG" = "true" ]; then
echo "Debug mode enabled - showing all debug messages" echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true export AISBF_DEBUG=true
else
unset AISBF_DEBUG
fi fi
# Start in background with nohup and logging # Start in background with nohup and logging
......
...@@ -13,6 +13,50 @@ from starlette.middleware.base import BaseHTTPMiddleware ...@@ -13,6 +13,50 @@ from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _is_coderai_registration_route(path: str) -> bool:
return (
path == "/api/coderai/register"
or path == "/api/coderai/wss"
or path.startswith("/api/u/") and (path.endswith("/coderai/register") or path.endswith("/coderai/wss"))
)
async def _record_dashboard_visit_event(request: Request, response) -> None:
try:
if not request.url.path.startswith("/dashboard"):
return
from aisbf.database import DatabaseRegistry
from aisbf import geolocation
db = DatabaseRegistry.get_config_database()
ip_address = _get_real_client_ip(request)
country_code = None
if ip_address and ip_address != 'unknown' and not _is_private_or_local_ip(ip_address):
try:
country_code = await geolocation.get_ip_country(ip_address)
except Exception:
country_code = None
session_token = None
if hasattr(request, 'session'):
session_token = request.session.get('username') or request.session.get('user_id') or request.session.get('role')
db.record_dashboard_event(
event_type='dashboard_visit',
path=request.url.path,
user_id=request.session.get('user_id') if hasattr(request, 'session') else None,
username=request.session.get('username') if hasattr(request, 'session') else None,
session_id=str(session_token) if session_token is not None else None,
ip_address=ip_address if ip_address != 'unknown' else None,
country_code=country_code,
method=request.method,
status_code=getattr(response, 'status_code', None),
metadata={
'query': dict(request.query_params),
'is_admin': request.session.get('role') == 'admin' if hasattr(request, 'session') else False,
},
)
except Exception:
logger.debug("Failed to record dashboard visit event", exc_info=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Client rate limiter state # Client rate limiter state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
...@@ -153,6 +197,9 @@ def make_api_token_authorization_middleware(get_server_config, get_db): ...@@ -153,6 +197,9 @@ def make_api_token_authorization_middleware(get_server_config, get_db):
if request.method == "GET" and path in ["/api/models", "/api/v1/models"]: if request.method == "GET" and path in ["/api/models", "/api/v1/models"]:
return await call_next(request) return await call_next(request)
if _is_coderai_registration_route(path):
return await call_next(request)
if not (server_config and server_config.get('auth_enabled', False)): if not (server_config and server_config.get('auth_enabled', False)):
return await call_next(request) return await call_next(request)
...@@ -210,6 +257,9 @@ def make_auth_middleware(get_server_config, get_config, get_db, url_for_fn): ...@@ -210,6 +257,9 @@ def make_auth_middleware(get_server_config, get_config, get_db, url_for_fn):
if request.method == "GET" and request.url.path in ["/api/models", "/api/v1/models"]: if request.method == "GET" and request.url.path in ["/api/models", "/api/v1/models"]:
return await call_next(request) return await call_next(request)
if _is_coderai_registration_route(request.url.path):
return await call_next(request)
auth_header = request.headers.get('Authorization', '') auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '): if not auth_header.startswith('Bearer '):
return JSONResponse(status_code=401, content={"error": "Missing or invalid Authorization header."}) return JSONResponse(status_code=401, content={"error": "Missing or invalid Authorization header."})
...@@ -355,7 +405,9 @@ def make_dashboard_context_middleware(): ...@@ -355,7 +405,9 @@ def make_dashboard_context_middleware():
request.state.welcome_shown = request.session.get('welcome_shown', False) request.state.welcome_shown = request.session.get('welcome_shown', False)
else: else:
request.state.welcome_shown = True request.state.welcome_shown = True
return await call_next(request) response = await call_next(request)
await _record_dashboard_visit_event(request, response)
return response
return dashboard_context_middleware return dashboard_context_middleware
......
...@@ -130,6 +130,14 @@ def setup_logging(): ...@@ -130,6 +130,14 @@ def setup_logging():
root_logger.addHandler(error_handler) root_logger.addHandler(error_handler)
root_logger.addHandler(console_handler) root_logger.addHandler(console_handler)
for noisy_logger in (
'websockets.client',
'websockets.server',
'websockets.protocol',
'uvicorn.protocols.websockets.websockets_impl',
):
logging.getLogger(noisy_logger).setLevel(logging.INFO if AISBF_DEBUG else logging.WARNING)
bpf = BrokenPipeFilter() bpf = BrokenPipeFilter()
for h in (file_handler, error_handler, console_handler): for h in (file_handler, error_handler, console_handler):
h.addFilter(bpf) h.addFilter(bpf)
......
...@@ -1295,6 +1295,9 @@ class ResponseCache: ...@@ -1295,6 +1295,9 @@ class ResponseCache:
self.sqlite_backend = None self.sqlite_backend = None
self.mysql_backend = None self.mysql_backend = None
self.memory_cache = {} self.memory_cache = {}
self._memory_cache = {}
self._memory_timestamps = {}
self._memory_access_order = []
if not self.enabled: if not self.enabled:
logger.info("Response caching is disabled") logger.info("Response caching is disabled")
...@@ -1342,10 +1345,6 @@ class ResponseCache: ...@@ -1342,10 +1345,6 @@ class ResponseCache:
self.backend = 'memory' self.backend = 'memory'
if self.backend == 'memory': if self.backend == 'memory':
# Initialize LRU cache
self._memory_cache = {}
self._memory_timestamps = {}
self._memory_access_order = []
logger.info(f"Response cache initialized with memory backend (max: {self.max_memory_cache} items)") logger.info(f"Response cache initialized with memory backend (max: {self.max_memory_cache} items)")
def _generate_cache_key(self, request_data: Dict) -> str: def _generate_cache_key(self, request_data: Dict) -> str:
......
...@@ -69,16 +69,24 @@ def _iter_user_coderai_matches(provider_id: str) -> list[Tuple[int, Dict[str, An ...@@ -69,16 +69,24 @@ def _iter_user_coderai_matches(provider_id: str) -> list[Tuple[int, Dict[str, An
def resolve_coderai_provider_owner(provider_id: str, username: Optional[str] = None) -> Tuple[Optional[int], Optional[Dict[str, Any]]]: def resolve_coderai_provider_owner(provider_id: str, username: Optional[str] = None) -> Tuple[Optional[int], Optional[Dict[str, Any]]]:
user_matches = _iter_user_coderai_matches(provider_id) user_matches = _iter_user_coderai_matches(provider_id)
if username and username != 'global':
if username == 'global':
global_config = _load_global_provider_config(provider_id)
if global_config and global_config.get('type') == 'coderai':
return None, global_config
return None, None
if username:
try: try:
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
user = db.get_user_by_username(username) if db else None user = db.get_user_by_username(username) if db else None
if user: if not user:
requested_user_id = user.get('id')
for user_id, provider_config in user_matches:
if user_id == requested_user_id:
return user_id, provider_config
return None, None return None, None
requested_user_id = user.get('id')
for user_id, provider_config in user_matches:
if user_id == requested_user_id:
return user_id, provider_config
return None, None
except Exception as e: except Exception as e:
logger.debug(f"Failed to resolve user by username for provider={provider_id} username={username}: {e}") logger.debug(f"Failed to resolve user by username for provider={provider_id} username={username}: {e}")
return None, None return None, None
...@@ -86,7 +94,7 @@ def resolve_coderai_provider_owner(provider_id: str, username: Optional[str] = N ...@@ -86,7 +94,7 @@ def resolve_coderai_provider_owner(provider_id: str, username: Optional[str] = N
if user_matches: if user_matches:
if len(user_matches) == 1: if len(user_matches) == 1:
return user_matches[0] return user_matches[0]
logger.warning(f"Ambiguous user-scoped coderai provider owner for provider_id={provider_id}; refusing global fallback") logger.warning(f"Ambiguous user-scoped coderai provider owner for provider_id={provider_id}; refusing fallback without explicit username")
return None, None return None, None
global_config = _load_global_provider_config(provider_id) global_config = _load_global_provider_config(provider_id)
......
This diff is collapsed.
...@@ -1064,7 +1064,9 @@ class ContextManager: ...@@ -1064,7 +1064,9 @@ class ContextManager:
def get_context_config_for_model( def get_context_config_for_model(
model_name: str, model_name: str,
provider_config: Any = None, provider_config: Any = None,
rotation_model_config: Optional[Dict] = None rotation_model_config: Optional[Dict] = None,
rotation_config: Any = None,
autoselect_config: Any = None,
) -> Dict: ) -> Dict:
""" """
Get context configuration for a specific model with cascading fallback. Get context configuration for a specific model with cascading fallback.
...@@ -1087,7 +1089,14 @@ def get_context_config_for_model( ...@@ -1087,7 +1089,14 @@ def get_context_config_for_model(
context_config = { context_config = {
'context_size': None, 'context_size': None,
'condense_context': 0, 'condense_context': 0,
'condense_method': None 'condense_method': None,
'condensation_enabled': config.resolve_feature_enabled(
'context_condensation',
model_config=rotation_model_config,
provider_config=provider_config,
rotation_config=rotation_config,
autoselect_config=autoselect_config,
),
} }
# Step 1: Get provider-level defaults and model-specific config # Step 1: Get provider-level defaults and model-specific config
...@@ -1171,6 +1180,10 @@ def get_context_config_for_model( ...@@ -1171,6 +1180,10 @@ def get_context_config_for_model(
if context_config.get('context_size') is None: if context_config.get('context_size') is None:
context_config['context_size'] = _infer_context_size_from_model(model_name) context_config['context_size'] = _infer_context_size_from_model(model_name)
if not context_config.get('condensation_enabled'):
context_config['condense_context'] = 0
context_config['condense_method'] = None
return context_config return context_config
...@@ -1215,4 +1228,4 @@ def _infer_context_size_from_model(model_name: str) -> int: ...@@ -1215,4 +1228,4 @@ def _infer_context_size_from_model(model_name: str) -> int:
return 8192 return 8192
# Generic default # Generic default
return 8192 return 8192
\ No newline at end of file
This diff is collapsed.
...@@ -94,6 +94,12 @@ class Provider(BaseModel): ...@@ -94,6 +94,12 @@ class Provider(BaseModel):
free_tier_limit_type: Optional[str] = None free_tier_limit_type: Optional[str] = None
premium_reference_monthly_cost: Optional[float] = None premium_reference_monthly_cost: Optional[float] = None
free_tier_description: Optional[str] = None free_tier_description: Optional[str] = None
pro_tier_requests_daily: Optional[int] = None
pro_tier_requests_weekly: Optional[int] = None
pro_tier_requests_monthly: Optional[int] = None
pro_tier_tokens_daily: Optional[int] = None
pro_tier_tokens_weekly: Optional[int] = None
pro_tier_tokens_monthly: Optional[int] = None
class ErrorTracking(BaseModel): class ErrorTracking(BaseModel):
failures: int failures: int
......
...@@ -427,6 +427,14 @@ class PayPalPaymentHandler: ...@@ -427,6 +427,14 @@ class PayPalPaymentHandler:
return return
await self._credit_wallet_for_paypal(user_id, amount, order_id, await self._credit_wallet_for_paypal(user_id, amount, order_id,
'Wallet top up via PayPal') 'Wallet top up via PayPal')
self.db.record_dashboard_event(
event_type='payment_completed',
path='/api/webhooks/paypal',
user_id=user_id,
username=None,
method='POST',
metadata={'gateway': 'paypal', 'amount': str(amount), 'order_id': order_id},
)
async def _handle_order_approved(self, resource: dict): async def _handle_order_approved(self, resource: dict):
"""Handle approved order — record pending capture state.""" """Handle approved order — record pending capture state."""
...@@ -465,6 +473,14 @@ class PayPalPaymentHandler: ...@@ -465,6 +473,14 @@ class PayPalPaymentHandler:
if user_id > 0 and amount > 0: if user_id > 0 and amount > 0:
await self._credit_wallet_for_paypal(user_id, amount, capture_id, await self._credit_wallet_for_paypal(user_id, amount, capture_id,
'Payment capture via PayPal') 'Payment capture via PayPal')
self.db.record_dashboard_event(
event_type='payment_completed',
path='/api/webhooks/paypal',
user_id=user_id,
username=None,
method='POST',
metadata={'gateway': 'paypal', 'amount': str(amount), 'capture_id': capture_id},
)
else: else:
logger.warning(f"PayPal capture completed but missing user_id/amount: {capture_id}") logger.warning(f"PayPal capture completed but missing user_id/amount: {capture_id}")
......
...@@ -266,6 +266,14 @@ class StripePaymentHandler: ...@@ -266,6 +266,14 @@ class StripePaymentHandler:
'metadata': {'payment_intent': payment_intent['id']} 'metadata': {'payment_intent': payment_intent['id']}
} }
) )
self.db.record_dashboard_event(
event_type='payment_completed',
path='/api/webhooks/stripe',
user_id=user_id,
username=None,
method='POST',
metadata={'gateway': 'stripe', 'amount': str(amount), 'payment_intent': payment_intent['id']},
)
logger.info(f"Wallet credited: user={user_id}, amount={amount}, intent={payment_intent['id']}") logger.info(f"Wallet credited: user={user_id}, amount={amount}, intent={payment_intent['id']}")
async def auto_charge(self, user_id: int, amount: Decimal, payment_method_id: str, async def auto_charge(self, user_id: int, amount: Decimal, payment_method_id: str,
......
"""
Native prompt security scanning and context composition analytics.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any, Dict, List, Optional
SECURITY_PATTERNS = [
("role_hijack_ignore", "high", re.compile(r"ignore\s+all\s+previous\s+instructions", re.I)),
("role_confusion", "medium", re.compile(r"\b(always\s+respond|never\s+mention|you\s+are\s+a\s+helpful\s+ai)\b", re.I)),
("credential_openai", "high", re.compile(r"sk-(?:proj-)?[A-Za-z0-9_-]{20,}", re.I)),
("credential_github", "high", re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}", re.I)),
("credential_aws_key", "high", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("credential_private_key", "high", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("suspicious_unicode", "info", re.compile(r"[\u200B-\u200F\u202A-\u202E\u2066-\u2069]")),
]
SEVERITY_SCORES = {"info": 20, "medium": 60, "high": 90, "critical": 100}
def _normalize_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
parts = []
for item in value:
if isinstance(item, dict):
if isinstance(item.get("text"), str):
parts.append(item.get("text"))
elif isinstance(item.get("content"), str):
parts.append(item.get("content"))
else:
parts.append(str(item))
return " ".join(part for part in parts if part)
if isinstance(value, dict):
if isinstance(value.get("text"), str):
return value.get("text")
if isinstance(value.get("content"), str):
return value.get("content")
return str(value)
def _redact_preview(text: str) -> str:
preview = (text or "").strip().replace("\n", " ")[:120]
preview = re.sub(r"sk-(?:proj-)?[A-Za-z0-9_-]{8,}", "[redacted-openai-key]", preview, flags=re.I)
preview = re.sub(r"gh[pousr]_[A-Za-z0-9]{8,}", "[redacted-github-token]", preview)
preview = re.sub(r"\bAKIA[0-9A-Z]{16}\b", "[redacted-aws-key]", preview)
return preview
def analyze_prompt_payload(request_data: Dict[str, Any], model_name: Optional[str] = None) -> Dict[str, Any]:
messages = request_data.get("messages") or []
findings: List[Dict[str, Any]] = []
role_counts: Dict[str, int] = {}
token_distribution = {
"system": 0,
"user": 0,
"assistant": 0,
"tool": 0,
"other": 0,
}
segments: List[Dict[str, Any]] = []
for index, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
role = str(msg.get("role") or "other")
text = _normalize_text(msg.get("content"))
token_estimate = max(0, len(text) // 4)
role_counts[role] = role_counts.get(role, 0) + 1
bucket = role if role in token_distribution else "other"
token_distribution[bucket] += token_estimate
segments.append({"role": role, "text": text, "message_index": index})
input_text = _normalize_text(request_data.get("input"))
prompt_text = _normalize_text(request_data.get("prompt"))
if input_text:
segments.append({"role": "input", "text": input_text, "message_index": None})
token_distribution["other"] += max(0, len(input_text) // 4)
if prompt_text:
segments.append({"role": "prompt", "text": prompt_text, "message_index": None})
token_distribution["other"] += max(0, len(prompt_text) // 4)
for segment in segments:
role = segment["role"]
if role in {"system", "developer"}:
continue
text = segment["text"]
for pattern_id, severity, pattern in SECURITY_PATTERNS:
match = pattern.search(text or "")
if not match:
continue
findings.append({
"category": pattern_id,
"severity": severity,
"detector": "regex_v1",
"span_label": role,
"message_index": segment["message_index"],
"evidence_preview": _redact_preview(match.group(0) if severity == "info" else text),
"metadata": {"pattern_id": pattern_id},
})
max_score = max((SEVERITY_SCORES.get(item["severity"], 0) for item in findings), default=0)
risk_level = "none"
if max_score >= 90:
risk_level = "high"
elif max_score >= 60:
risk_level = "medium"
elif max_score > 0:
risk_level = "info"
total_tokens = sum(token_distribution.values())
composition = {
"message_count": len(messages),
"role_counts": role_counts,
"token_distribution": token_distribution,
"tool_count": len(request_data.get("tools") or []),
"has_system_prompt": bool(role_counts.get("system")),
"has_tools": bool(request_data.get("tools")),
"context_utilization_pct": 0.0,
"largest_segment_role": max(token_distribution, key=token_distribution.get) if total_tokens else None,
"prompt_shape": "+".join(sorted(role_counts.keys())) if role_counts else "empty",
}
payload_fingerprint = hashlib.sha256(
repr({
"model": model_name or request_data.get("model"),
"messages": messages,
"input": request_data.get("input"),
"prompt": request_data.get("prompt"),
}).encode("utf-8")
).hexdigest()
return {
"request_hash": payload_fingerprint,
"risk_level": risk_level,
"risk_score": max_score,
"findings": findings,
"summary": {
"findings_count": len(findings),
"high_count": sum(1 for item in findings if item["severity"] == "high"),
"medium_count": sum(1 for item in findings if item["severity"] == "medium"),
"info_count": sum(1 for item in findings if item["severity"] == "info"),
},
"composition": composition,
}
...@@ -30,8 +30,9 @@ from .base import BaseProviderHandler, AnthropicFormatConverter, AISBF_DEBUG ...@@ -30,8 +30,9 @@ from .base import BaseProviderHandler, AnthropicFormatConverter, AISBF_DEBUG
class AnthropicProviderHandler(BaseProviderHandler): class AnthropicProviderHandler(BaseProviderHandler):
def __init__(self, provider_id: str, api_key: str): def __init__(self, provider_id: str, api_key: str, user_id=None, provider_config=None):
super().__init__(provider_id, api_key) super().__init__(provider_id, api_key, user_id=user_id)
self.provider_config = provider_config
self.client = Anthropic(api_key=api_key) self.client = Anthropic(api_key=api_key)
def validate_credentials(self) -> bool: def validate_credentials(self) -> bool:
...@@ -70,6 +71,10 @@ class AnthropicProviderHandler(BaseProviderHandler): ...@@ -70,6 +71,10 @@ class AnthropicProviderHandler(BaseProviderHandler):
enable_native_caching = getattr(provider_config, 'enable_native_caching', False) enable_native_caching = getattr(provider_config, 'enable_native_caching', False)
min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1000) min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1000)
if enable_native_caching and not self._native_cache_user_allows(model):
enable_native_caching = False
logging.info(f"User {self.user_id} disabled cache for provider={self.provider_id}, model={model}")
logging.info(f"AnthropicProviderHandler: Native caching enabled: {enable_native_caching}") logging.info(f"AnthropicProviderHandler: Native caching enabled: {enable_native_caching}")
if enable_native_caching: if enable_native_caching:
logging.info(f"AnthropicProviderHandler: Min cacheable tokens: {min_cacheable_tokens}") logging.info(f"AnthropicProviderHandler: Min cacheable tokens: {min_cacheable_tokens}")
......
...@@ -916,6 +916,17 @@ class BaseProviderHandler: ...@@ -916,6 +916,17 @@ class BaseProviderHandler:
logger.info(f"[{self.provider_id}] API key present, validation passed") logger.info(f"[{self.provider_id}] API key present, validation passed")
return True return True
def _native_cache_user_allows(self, model_name: Optional[str] = None) -> bool:
"""Return whether user-scoped native prompt caching is allowed for this provider/model."""
if self.user_id is None:
return True
try:
db = DatabaseRegistry.get_config_database()
user_setting = db.get_user_cache_settings(self.user_id, self.provider_id, model_name)
return bool(user_setting.get('cache_enabled', True))
except Exception:
return True
def parse_429_response(self, response_data: Union[Dict, str], headers: Dict = None) -> Optional[int]: def parse_429_response(self, response_data: Union[Dict, str], headers: Dict = None) -> Optional[int]:
""" """
......
...@@ -1270,17 +1270,10 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -1270,17 +1270,10 @@ class ClaudeProviderHandler(BaseProviderHandler):
# Check user's cache settings (overrides provider config) # Check user's cache settings (overrides provider config)
if user_id and cache_config['enabled']: if user_id and cache_config['enabled']:
try: if not self._native_cache_user_allows(model_name):
from aisbf.database import DatabaseRegistry cache_config['enabled'] = False
db = DatabaseRegistry.get_config_database()
user_setting = db.get_user_cache_settings(user_id, provider_id, model_name)
if not user_setting['cache_enabled']:
cache_config['enabled'] = False
import logging
logging.getLogger(__name__).info(f"User {user_id} disabled cache for provider={provider_id}, model={model_name}")
except Exception as e:
import logging import logging
logging.getLogger(__name__).warning(f"Error checking user cache settings: {e}") logging.getLogger(__name__).info(f"User {user_id} disabled cache for provider={provider_id}, model={model_name}")
return cache_config return cache_config
......
...@@ -30,8 +30,9 @@ from .base import BaseProviderHandler, AISBF_DEBUG ...@@ -30,8 +30,9 @@ from .base import BaseProviderHandler, AISBF_DEBUG
class GoogleProviderHandler(BaseProviderHandler): class GoogleProviderHandler(BaseProviderHandler):
def __init__(self, provider_id: str, api_key: str): def __init__(self, provider_id: str, api_key: str, user_id=None, provider_config=None):
super().__init__(provider_id, api_key) super().__init__(provider_id, api_key, user_id=user_id)
self.provider_config = provider_config
# Initialize google-genai library # Initialize google-genai library
from google import genai from google import genai
self.client = genai.Client(api_key=api_key) self.client = genai.Client(api_key=api_key)
...@@ -83,6 +84,10 @@ class GoogleProviderHandler(BaseProviderHandler): ...@@ -83,6 +84,10 @@ class GoogleProviderHandler(BaseProviderHandler):
cache_ttl = getattr(provider_config, 'cache_ttl', None) cache_ttl = getattr(provider_config, 'cache_ttl', None)
min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1000) min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1000)
if enable_native_caching and not self._native_cache_user_allows(model):
enable_native_caching = False
logging.info(f"User {self.user_id} disabled cache for provider={self.provider_id}, model={model}")
logging.info(f"GoogleProviderHandler: Native caching enabled: {enable_native_caching}") logging.info(f"GoogleProviderHandler: Native caching enabled: {enable_native_caching}")
# Initialize cached_content_name for this request (will be set if we use caching) # Initialize cached_content_name for this request (will be set if we use caching)
......
...@@ -310,15 +310,9 @@ class KiloProviderHandler(BaseProviderHandler): ...@@ -310,15 +310,9 @@ class KiloProviderHandler(BaseProviderHandler):
# Check user's cache settings (overrides provider config) # Check user's cache settings (overrides provider config)
if enable_native_caching and hasattr(self, 'user_id'): if enable_native_caching and hasattr(self, 'user_id'):
try: if not self._native_cache_user_allows(model):
from aisbf.database import DatabaseRegistry enable_native_caching = False
db = DatabaseRegistry.get_config_database() logging.info(f"User {self.user_id} disabled cache for provider={self.provider_id}, model={model}")
user_setting = db.get_user_cache_settings(self.user_id, self.provider_id, model)
if not user_setting['cache_enabled']:
enable_native_caching = False
logging.info(f"User {self.user_id} disabled cache for provider={self.provider_id}, model={model}")
except Exception as e:
logging.warning(f"Error checking user cache settings: {e}")
logging.info(f"KiloProviderHandler: Native caching enabled: {enable_native_caching}") logging.info(f"KiloProviderHandler: Native caching enabled: {enable_native_caching}")
if enable_native_caching: if enable_native_caching:
......
...@@ -78,15 +78,9 @@ class OpenAIProviderHandler(BaseProviderHandler): ...@@ -78,15 +78,9 @@ class OpenAIProviderHandler(BaseProviderHandler):
# Check user's cache settings (overrides provider config) # Check user's cache settings (overrides provider config)
if enable_native_caching and hasattr(self, 'user_id'): if enable_native_caching and hasattr(self, 'user_id'):
try: if not self._native_cache_user_allows(model):
from aisbf.database import DatabaseRegistry enable_native_caching = False
db = DatabaseRegistry.get_config_database() logging.info(f"User {self.user_id} disabled cache for provider={self.provider_id}, model={model}")
user_setting = db.get_user_cache_settings(self.user_id, self.provider_id, model)
if not user_setting['cache_enabled']:
enable_native_caching = False
logging.info(f"User {self.user_id} disabled cache for provider={self.provider_id}, model={model}")
except Exception as e:
logging.warning(f"Error checking user cache settings: {e}")
logging.info(f"OpenAIProviderHandler: Native caching enabled: {enable_native_caching}") logging.info(f"OpenAIProviderHandler: Native caching enabled: {enable_native_caching}")
if enable_native_caching: if enable_native_caching:
......
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib
import json import json
import logging import logging
import time import time
import asyncio
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, Body from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, Body
...@@ -46,79 +47,84 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str): ...@@ -46,79 +47,84 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
if not valid: if not valid:
await websocket.close(code=1008, reason=error or "registration rejected") await websocket.close(code=1008, reason=error or "registration rejected")
return return
await websocket.accept() await websocket.accept()
expected_scope = scope_name expected_scope = scope_name
session = await broker.register(websocket, provider_id, client_id, metadata={"source": "websocket", "owner_user_id": owner_user_id, "username": username, "scope_name": expected_scope, "proxy_scheme": websocket.url.scheme}) session = await broker.register(websocket, provider_id, client_id, metadata={"source": "websocket", "owner_user_id": owner_user_id, "username": username, "scope_name": expected_scope, "proxy_scheme": websocket.url.scheme})
async def _drain_broker_queue() -> None:
while True:
queued = await broker.consume_request(session.session_id, timeout=1)
if queued is not None:
await websocket.send_text(json.dumps(queued))
await broker.touch(session.session_id, metadata={"proxy_scheme": websocket.url.scheme, "username": username, "scope_name": expected_scope})
try: try:
payload = _coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope) payload = _coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope)
payload["session_id"] = session.session_id payload["session_id"] = session.session_id
await websocket.send_text(json.dumps(payload)) await websocket.send_text(json.dumps(payload))
queue_task = asyncio.create_task(_drain_broker_queue())
while True: while True:
try: raw = await websocket.receive_text()
raw = await asyncio.wait_for(websocket.receive_text(), timeout=1.0) message = json.loads(raw)
message = json.loads(raw) op = message.get("op")
op = message.get("op") if op == "register":
if op == "register": payload = message.get("payload") or {}
payload = message.get("payload") or {} payload_token = payload.get("registration_token") or message.get("registration_token")
payload_token = payload.get("registration_token") or message.get("registration_token") if payload_token and payload_token != presented_token:
if payload_token and payload_token != presented_token:
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "error",
"error": "Registration token mismatch",
}))
continue
capabilities = payload.get("capabilities") or message.get("capabilities") or {}
metadata = {
"endpoint": payload.get("endpoint"),
"transport": payload.get("transport"),
"studio_endpoints": payload.get("studio_endpoints") or [],
"hardware": payload.get("hardware") or {},
"gpus": payload.get("gpus") or ((payload.get("hardware") or {}).get("gpus")) or [],
"gpu_count": payload.get("gpu_count") or ((payload.get("hardware") or {}).get("gpu_count")),
"total_vram_mb": payload.get("total_vram_mb") or ((payload.get("hardware") or {}).get("total_vram_mb")),
"available_vram_mb": payload.get("available_vram_mb") or ((payload.get("hardware") or {}).get("available_vram_mb")),
"owner_user_id": owner_user_id,
"username": username,
"scope_name": expected_scope,
"proxy_scheme": websocket.url.scheme,
}
await broker.touch(session.session_id, metadata=metadata, capabilities=capabilities)
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"payload": {
**_coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope),
"session_id": session.session_id,
},
}))
continue
if op == "heartbeat":
await broker.touch(session.session_id, metadata=message.get("payload") or {})
await websocket.send_text(json.dumps({ await websocket.send_text(json.dumps({
"v": 1, "v": 1,
"request_id": message.get("request_id"), "request_id": message.get("request_id"),
"status": "ok", "status": "error",
"event": "heartbeat", "error": "Registration token mismatch",
"payload": {"ts": int(time.time())},
})) }))
continue continue
await broker.touch(session.session_id) capabilities = payload.get("capabilities") or message.get("capabilities") or {}
await broker.publish_response(message) metadata = {
except asyncio.TimeoutError: "endpoint": payload.get("endpoint"),
queued = await broker.consume_request(session.session_id, timeout=1) "transport": payload.get("transport"),
if queued is not None: "studio_endpoints": payload.get("studio_endpoints") or [],
await websocket.send_text(json.dumps(queued)) "hardware": payload.get("hardware") or {},
await broker.touch(session.session_id, metadata={"proxy_scheme": websocket.url.scheme, "username": username, "scope_name": expected_scope}) "gpus": payload.get("gpus") or ((payload.get("hardware") or {}).get("gpus")) or [],
"gpu_count": payload.get("gpu_count") or ((payload.get("hardware") or {}).get("gpu_count")),
"total_vram_mb": payload.get("total_vram_mb") or ((payload.get("hardware") or {}).get("total_vram_mb")),
"available_vram_mb": payload.get("available_vram_mb") or ((payload.get("hardware") or {}).get("available_vram_mb")),
"owner_user_id": owner_user_id,
"username": username,
"scope_name": expected_scope,
"proxy_scheme": websocket.url.scheme,
}
await broker.touch(session.session_id, metadata=metadata, capabilities=capabilities)
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"payload": {
**_coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope),
"session_id": session.session_id,
},
}))
continue
if op == "heartbeat":
await broker.touch(session.session_id, metadata=message.get("payload") or {})
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"event": "heartbeat",
"payload": {"ts": int(time.time())},
}))
continue continue
await broker.touch(session.session_id)
await broker.publish_response(message)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"CoderAI broker disconnected provider={provider_id} client={client_id}") logger.info(f"CoderAI broker disconnected provider={provider_id} client={client_id}")
except Exception as e: except Exception as e:
logger.error(f"CoderAI broker websocket error: {e}", exc_info=True) logger.error(f"CoderAI broker websocket error: {e}", exc_info=True)
finally: finally:
if 'queue_task' in locals():
queue_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await queue_task
await broker.unregister(session.session_id) await broker.unregister(session.session_id)
await broker.fail_session_requests(session.session_id, f"CoderAI session '{session.session_id}' disconnected") await broker.fail_session_requests(session.session_id, f"CoderAI session '{session.session_id}' disconnected")
......
...@@ -2368,6 +2368,14 @@ async def dashboard_wallet_topup(request: Request): ...@@ -2368,6 +2368,14 @@ async def dashboard_wallet_topup(request: Request):
import traceback as _tb import traceback as _tb
logger.error(f"Crypto address generation error: {e!r}\n{_tb.format_exc()}") logger.error(f"Crypto address generation error: {e!r}\n{_tb.format_exc()}")
return JSONResponse({"error": "Could not generate deposit address"}, status_code=503) return JSONResponse({"error": "Could not generate deposit address"}, status_code=503)
db.record_dashboard_event(
event_type='wallet_topup_started',
path=request.url.path,
user_id=user_id,
username=request.session.get('username'),
method=request.method,
metadata={'payment_method': method, 'amount': amount, 'crypto_type': crypto_type},
)
return JSONResponse({ return JSONResponse({
"type": "crypto", "type": "crypto",
"method": method, "method": method,
...@@ -2380,6 +2388,14 @@ async def dashboard_wallet_topup(request: Request): ...@@ -2380,6 +2388,14 @@ async def dashboard_wallet_topup(request: Request):
# Stripe: create checkout session (hosted redirect flow) # Stripe: create checkout session (hosted redirect flow)
if method == 'stripe': if method == 'stripe':
try: try:
db.record_dashboard_event(
event_type='wallet_topup_started',
path=request.url.path,
user_id=user_id,
username=request.session.get('username'),
method=request.method,
metadata={'payment_method': method, 'amount': amount},
)
payment_service = getattr(request.app.state, 'payment_service', None) payment_service = getattr(request.app.state, 'payment_service', None)
if not payment_service or not hasattr(payment_service, 'stripe_handler'): if not payment_service or not hasattr(payment_service, 'stripe_handler'):
return JSONResponse({"error": "Stripe payment service unavailable"}, status_code=503) return JSONResponse({"error": "Stripe payment service unavailable"}, status_code=503)
...@@ -2401,6 +2417,14 @@ async def dashboard_wallet_topup(request: Request): ...@@ -2401,6 +2417,14 @@ async def dashboard_wallet_topup(request: Request):
# PayPal: create order # PayPal: create order
if method == 'paypal': if method == 'paypal':
try: try:
db.record_dashboard_event(
event_type='wallet_topup_started',
path=request.url.path,
user_id=user_id,
username=request.session.get('username'),
method=request.method,
metadata={'payment_method': method, 'amount': amount},
)
payment_service = getattr(request.app.state, 'payment_service', None) payment_service = getattr(request.app.state, 'payment_service', None)
if not payment_service or not hasattr(payment_service, 'paypal_handler'): if not payment_service or not hasattr(payment_service, 'paypal_handler'):
return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503) return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503)
......
...@@ -582,6 +582,18 @@ async def api_publish_market_listing(request: Request): ...@@ -582,6 +582,18 @@ async def api_publish_market_listing(request: Request):
return JSONResponse({'error': 'Unsupported source_type'}, status_code=400) return JSONResponse({'error': 'Unsupported source_type'}, status_code=400)
listing_id = db.upsert_market_listing(owner_user_id, owner_username, payload) listing_id = db.upsert_market_listing(owner_user_id, owner_username, payload)
db.record_dashboard_event(
event_type='market_export_created',
path=request.url.path,
user_id=request.session.get('user_id'),
username=request.session.get('username'),
method=request.method,
provider_id=payload.get('provider_id'),
rotation_id=payload.get('source_id') if source_type == 'rotation' else None,
autoselect_id=payload.get('source_id') if source_type == 'autoselect' else None,
listing_id=listing_id,
metadata={'source_type': source_type, 'source_scope': source_scope, 'owner_username': owner_username},
)
return JSONResponse({'success': True, 'listing_id': listing_id}) return JSONResponse({'success': True, 'listing_id': listing_id})
...@@ -627,6 +639,15 @@ async def api_import_market_listing(request: Request, listing_id: int): ...@@ -627,6 +639,15 @@ async def api_import_market_listing(request: Request, listing_id: int):
source_id=source_id, source_id=source_id,
) )
db.record_market_import(user_id, listing_id, 'market_reference', str(reference_id)) db.record_market_import(user_id, listing_id, 'market_reference', str(reference_id))
db.record_dashboard_event(
event_type='market_import_created',
path=request.url.path,
user_id=user_id,
username=request.session.get('username'),
method=request.method,
listing_id=listing_id,
metadata={'reference_id': reference_id, 'source_type': source_type, 'source_id': source_id},
)
return JSONResponse({'success': True, 'imported_config_type': 'market_reference', 'imported_config_id': reference_id}) return JSONResponse({'success': True, 'imported_config_type': 'market_reference', 'imported_config_id': reference_id})
......
This diff is collapsed.
...@@ -20,6 +20,42 @@ _templates = None ...@@ -20,6 +20,42 @@ _templates = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _feature_mode_payload(mode: str) -> dict:
return {'mode': mode}
def _set_feature_control(feature_controls: dict, key: str, mode: str) -> None:
feature_controls[key] = _feature_mode_payload(mode)
def _set_prompt_security_control(feature_controls: dict, key: str, mode: str) -> None:
prompt_security = feature_controls.setdefault('prompt_security', {})
prompt_security[key] = _feature_mode_payload(mode)
def _set_prompt_security_threshold(feature_controls: dict, threshold: str) -> None:
prompt_security = feature_controls.setdefault('prompt_security', {})
normalized = str(threshold or 'high').strip().lower()
if normalized not in {'low', 'medium', 'high'}:
normalized = 'high'
prompt_security['risk_threshold'] = normalized
def _record_dashboard_admin_event(request: Request, event_type: str, **kwargs) -> None:
try:
db = DatabaseRegistry.get_config_database()
db.record_dashboard_event(
event_type=event_type,
path=request.url.path,
user_id=request.session.get('user_id'),
username=request.session.get('username'),
method=request.method,
**kwargs,
)
except Exception:
logger.debug("Failed to record dashboard admin event %s", event_type, exc_info=True)
def _reorder_dict(d: dict, order: list) -> dict: def _reorder_dict(d: dict, order: list) -> dict:
"""Return a new dict with keys in the given order (unknown keys appended at end).""" """Return a new dict with keys in the given order (unknown keys appended at end)."""
result = {k: d[k] for k in order if k in d} result = {k: d[k] for k in order if k in d}
...@@ -374,7 +410,18 @@ async def dashboard_settings_save( ...@@ -374,7 +410,18 @@ async def dashboard_settings_save(
classify_nsfw: bool = Form(False), classify_nsfw: bool = Form(False),
classify_privacy: bool = Form(False), classify_privacy: bool = Form(False),
classify_semantic: bool = Form(False), classify_semantic: bool = Form(False),
batching_enabled: bool = Form(False), feature_nsfw_classification_mode: str = Form("inherit"),
feature_privacy_classification_mode: str = Form("inherit"),
feature_context_condensation_mode: str = Form("inherit"),
feature_response_cache_mode: str = Form("inherit"),
feature_prompt_batching_mode: str = Form("inherit"),
feature_prompt_security_mode: str = Form("inherit"),
feature_context_lens_mode: str = Form("inherit"),
feature_block_high_risk_prompts_mode: str = Form("inherit"),
feature_persist_prompt_text_mode: str = Form("inherit"),
feature_redact_before_persist_mode: str = Form("inherit"),
feature_risk_threshold: str = Form("high"),
batching_enabled: bool = Form(False),
batching_window_ms: int = Form(100), batching_window_ms: int = Form(100),
batching_max_batch_size: int = Form(8), batching_max_batch_size: int = Form(8),
batching_openai_enabled: bool = Form(False), batching_openai_enabled: bool = Form(False),
...@@ -647,6 +694,21 @@ async def dashboard_settings_save( ...@@ -647,6 +694,21 @@ async def dashboard_settings_save(
aisbf_config['classify_privacy'] = classify_privacy aisbf_config['classify_privacy'] = classify_privacy
aisbf_config['classify_semantic'] = classify_semantic aisbf_config['classify_semantic'] = classify_semantic
if 'feature_controls' not in aisbf_config or not isinstance(aisbf_config['feature_controls'], dict):
aisbf_config['feature_controls'] = {}
feature_controls = aisbf_config['feature_controls']
_set_feature_control(feature_controls, 'nsfw_classification', feature_nsfw_classification_mode)
_set_feature_control(feature_controls, 'privacy_classification', feature_privacy_classification_mode)
_set_feature_control(feature_controls, 'context_condensation', feature_context_condensation_mode)
_set_feature_control(feature_controls, 'response_cache', feature_response_cache_mode)
_set_feature_control(feature_controls, 'prompt_batching', feature_prompt_batching_mode)
_set_prompt_security_control(feature_controls, 'security_scan', feature_prompt_security_mode)
_set_prompt_security_control(feature_controls, 'context_lens', feature_context_lens_mode)
_set_prompt_security_control(feature_controls, 'block_high_risk_prompts', feature_block_high_risk_prompts_mode)
_set_prompt_security_control(feature_controls, 'persist_prompt_text', feature_persist_prompt_text_mode)
_set_prompt_security_control(feature_controls, 'redact_before_persist', feature_redact_before_persist_mode)
_set_prompt_security_threshold(feature_controls, feature_risk_threshold)
# Update internal model classifiers # Update internal model classifiers
if 'internal_model' not in aisbf_config: if 'internal_model' not in aisbf_config:
aisbf_config['internal_model'] = {} aisbf_config['internal_model'] = {}
...@@ -706,8 +768,11 @@ async def dashboard_settings_save( ...@@ -706,8 +768,11 @@ async def dashboard_settings_save(
json.dump(aisbf_config, f, indent=2) json.dump(aisbf_config, f, indent=2)
# Reload dashboard credentials in memory so the new username/password takes effect immediately # Reload dashboard credentials in memory so the new username/password takes effect immediately
if server_config is not None: if _config is not None and hasattr(_config, 'aisbf'):
server_config['dashboard_config'] = aisbf_config.get('dashboard', {}) try:
_config.reload()
except Exception:
logger.debug("Config reload after settings save failed", exc_info=True)
# Hot-reload global config so changes take effect without restart # Hot-reload global config so changes take effect without restart
_reload_global_config() _reload_global_config()
...@@ -853,6 +918,7 @@ async def dashboard_users_add(request: Request, username: str = Form(...), passw ...@@ -853,6 +918,7 @@ async def dashboard_users_add(request: Request, username: str = Form(...), passw
admin_username = request.session.get('username', 'admin') admin_username = request.session.get('username', 'admin')
# Create user with display_name defaulting to username # Create user with display_name defaulting to username
user_id = db.create_user(username, password_hash, role, admin_username, None, False, username) user_id = db.create_user(username, password_hash, role, admin_username, None, False, username)
_record_dashboard_admin_event(request, 'user_registered', target_user_id=user_id, metadata={'role': role, 'created_by': admin_username})
return RedirectResponse(url=url_for(request, "/dashboard/users"), status_code=303) return RedirectResponse(url=url_for(request, "/dashboard/users"), status_code=303)
except Exception as e: except Exception as e:
users = db.get_users() users = db.get_users()
...@@ -998,6 +1064,7 @@ async def dashboard_users_update_tier(request: Request, user_id: int): ...@@ -998,6 +1064,7 @@ async def dashboard_users_update_tier(request: Request, user_id: int):
success = db.set_user_tier(user_id, tier_id) success = db.set_user_tier(user_id, tier_id)
if success: if success:
_record_dashboard_admin_event(request, 'tier_upgraded', target_user_id=user_id, metadata={'tier_id': tier_id, 'source': 'admin-users'})
return JSONResponse({"success": True}) return JSONResponse({"success": True})
else: else:
return JSONResponse({"success": False, "error": "Failed to update user tier"}, status_code=500) return JSONResponse({"success": False, "error": "Failed to update user tier"}, status_code=500)
......
...@@ -79,6 +79,41 @@ ...@@ -79,6 +79,41 @@
"mysql_password": "", "mysql_password": "",
"mysql_database": "aisbf_response_cache" "mysql_database": "aisbf_response_cache"
}, },
"feature_controls": {
"nsfw_classification": {
"mode": "disabled"
},
"privacy_classification": {
"mode": "disabled"
},
"context_condensation": {
"mode": "disabled"
},
"response_cache": {
"mode": "enabled"
},
"prompt_batching": {
"mode": "disabled"
},
"prompt_security": {
"security_scan": {
"mode": "disabled"
},
"context_lens": {
"mode": "disabled"
},
"block_high_risk_prompts": {
"mode": "disabled"
},
"persist_prompt_text": {
"mode": "disabled"
},
"redact_before_persist": {
"mode": "enabled"
},
"risk_threshold": "high"
}
},
"tor": { "tor": {
"enabled": false, "enabled": false,
"control_port": 9051, "control_port": 9051,
......
...@@ -89,6 +89,22 @@ The outbound WebSocket connection must include: ...@@ -89,6 +89,22 @@ The outbound WebSocket connection must include:
- `username`: either `global` or the AISBF username for user-owned providers - `username`: either `global` or the AISBF username for user-owned providers
- `registration_token`: provider-scoped secret from AISBF provider configuration - `registration_token`: provider-scoped secret from AISBF provider configuration
### Current server-side resolution order
AISBF resolves broker identity in this exact order when the WebSocket handshake arrives:
- `provider_id`: query param `provider_id`, then header `x-coderai-provider-id`, then default `coderai`
- `client_id`: query param `client_id`, then header `x-coderai-client-id`, then generated fallback `anon-<unix_timestamp>`
- `username`: query param `username`, then header `x-coderai-username`, then the path scope name (`global` or the `/api/u/{username}` path segment)
- `registration_token`: query param `registration_token`, then header `x-coderai-registration-token`
Important constraints:
- the `registration_token` is required for admission
- `Authorization: Bearer ...` is currently not used by the broker WebSocket admission check
- if you omit `client_id`, AISBF generates an `anon-*` client id and future broker routing will only work if AISBF also targets that exact generated value
- the `client_id` used by the CoderAI client must match the `coderai_config.client_id` used by the AISBF provider, or the broker can show the session as connected while requests still fail to route
## Optional Headers ## Optional Headers
AISBF also accepts or may expect these headers: AISBF also accepts or may expect these headers:
...@@ -109,6 +125,35 @@ Recommended behavior: ...@@ -109,6 +125,35 @@ Recommended behavior:
Open the outbound WebSocket to the correct scoped AISBF endpoint. Open the outbound WebSocket to the correct scoped AISBF endpoint.
The handshake is a normal WebSocket upgrade request, which starts as an HTTP `GET` carrying query parameters. This is expected.
Recommended connect template:
```text
wss://<aisbf-host>/<optional-prefix>/api/coderai/wss?provider_id=<provider_id>&client_id=<stable_client_id>&username=global&registration_token=<provider_registration_token>
```
User-scoped template:
```text
wss://<aisbf-host>/<optional-prefix>/api/u/<username>/coderai/wss?provider_id=<provider_id>&client_id=<stable_client_id>&username=<username>&registration_token=<provider_registration_token>
```
Recommended handshake headers:
```text
x-coderai-provider-id: <provider_id>
x-coderai-client-id: <stable_client_id>
x-coderai-username: <username>
x-coderai-registration-token: <provider_registration_token>
```
Best practice:
- send the same identity in both query parameters and headers
- keep `client_id` stable across reconnects
- always reconnect with the same provider scope and owner scope
### 2. Wait for `registered` event ### 2. Wait for `registered` event
AISBF immediately sends a registration acknowledgment event on successful admission. AISBF immediately sends a registration acknowledgment event on successful admission.
...@@ -135,11 +180,21 @@ Store: ...@@ -135,11 +180,21 @@ Store:
- `client_id` - `client_id`
- `username` - `username`
- `scope_name` - `scope_name`
- `owner_user_id`
- `expires_at`
Notes:
- this event means the socket is admitted and the session row exists
- it does not yet mean hardware/capabilities metadata has been uploaded
- the client should send the explicit `register` operation immediately after this event
### 3. Send explicit `register` operation ### 3. Send explicit `register` operation
After the `registered` event, CoderAI must send a `register` message describing its capabilities, hardware inventory, and advertised endpoints. After the `registered` event, CoderAI must send a `register` message describing its capabilities, hardware inventory, and advertised endpoints.
AISBF currently processes `register` as a normal inbound WebSocket message and responds with `status=ok` using the same `request_id`.
### 4. Enter long-lived receive loop ### 4. Enter long-lived receive loop
Then keep listening for incoming broker requests from AISBF. Then keep listening for incoming broker requests from AISBF.
...@@ -233,6 +288,60 @@ CoderAI should send this after receiving the initial AISBF `registered` event. ...@@ -233,6 +288,60 @@ CoderAI should send this after receiving the initial AISBF `registered` event.
AISBF replies with a success envelope. AISBF replies with a success envelope.
### Fields AISBF currently reads from the `register` message
Top-level:
- `v`
- `op` with value `register`
- `request_id`
- optional top-level `registration_token`
- optional top-level `capabilities`
From `payload`:
- `endpoint`
- `transport`
- `registration_token`
- `studio_endpoints`
- `hardware`
- `gpus`
- `gpu_count`
- `total_vram_mb`
- `available_vram_mb`
- `capabilities`
AISBF behavior:
- if `payload.registration_token` or top-level `registration_token` is present and does not match the handshake token, AISBF replies with an error envelope
- if token matches, AISBF persists the metadata onto the broker session
- `payload.capabilities` takes precedence over missing top-level capability data
- if `gpus`, `gpu_count`, `total_vram_mb`, or `available_vram_mb` are omitted at the top level, AISBF falls back to the values inside `payload.hardware`
Minimal acceptable `register` message:
```json
{
"v": 1,
"op": "register",
"request_id": "reg-1",
"payload": {
"transport": "websocket",
"registration_token": "<same_registration_token>",
"capabilities": {}
}
}
```
Recommended full `register` message:
- include `endpoint`
- include `transport`
- include `registration_token`
- include `hardware.gpus`, `hardware.gpu_count`, `hardware.total_vram_mb`, `hardware.available_vram_mb`
- include `studio_endpoints`
- include `capabilities`
### Hardware Reporting Requirements ### Hardware Reporting Requirements
The `register` payload should include the best hardware view available to the running CoderAI process. The `register` payload should include the best hardware view available to the running CoderAI process.
...@@ -326,6 +435,37 @@ Heartbeat payloads may also refresh dynamic hardware state such as changing free ...@@ -326,6 +435,37 @@ Heartbeat payloads may also refresh dynamic hardware state such as changing free
} }
``` ```
Current AISBF note:
- AISBF acknowledges heartbeat messages and merges the heartbeat `payload` into session metadata
- keep heartbeat payloads small and non-blocking
- use heartbeats for lightweight dynamic updates only; do not block the main receive loop on expensive hardware rescans
## Async Client Requirements
The broker WebSocket integration must be fully asynchronous.
CoderAI client requirements:
- the main receive loop must never block on model loading, inference, GPU inspection, or disk/network I/O
- expensive work should run in background tasks or worker executors while the socket remains responsive to incoming frames and ping/pong traffic
- the client should be able to receive broker requests while also sending progress or result frames for earlier requests
- the client must not serialize all work behind registration or heartbeat handling
AISBF broker behavior:
- AISBF now drains queued outbound broker requests in a background async task while independently reading inbound websocket messages
- this means the CoderAI client should expect inbound requests to arrive even while it is still sending heartbeat or response messages for unrelated work
- operations are correlated strictly by `request_id`; client implementations must not rely on message ordering alone
Recommended client architecture:
1. one async reader task for inbound WebSocket frames
2. one async writer path or send queue for outbound replies/events
3. per-request async tasks for local execution
4. a lightweight periodic heartbeat task
5. explicit request correlation by `request_id`
AISBF merges those updates into the broker session metadata. AISBF merges those updates into the broker session metadata.
## Local HTTP Endpoints CoderAI Should Expose ## Local HTTP Endpoints CoderAI Should Expose
......
...@@ -200,6 +200,20 @@ When CoderAI dials AISBF broker directly, it should connect using: ...@@ -200,6 +200,20 @@ When CoderAI dials AISBF broker directly, it should connect using:
- `provider_id=<provider_id>` - `provider_id=<provider_id>`
- `client_id=<client_id>` - `client_id=<client_id>`
- `username=<username-or-global>` - `username=<username-or-global>`
- `registration_token=<owner-configured-token>`
AISBF broker admission currently resolves connection data in this order:
- `provider_id`: query param, then `x-coderai-provider-id`, then default `coderai`
- `client_id`: query param, then `x-coderai-client-id`, then generated fallback `anon-<timestamp>`
- `username`: query param, then `x-coderai-username`, then the scoped path value
- `registration_token`: query param, then `x-coderai-registration-token`
Important:
- the WebSocket broker flow starts as an HTTP `GET` upgrade request; this is expected
- the broker currently validates `registration_token`, not `Authorization: Bearer ...`, during WebSocket admission
- `client_id` must stay stable and match the `coderai_config.client_id` AISBF uses for that provider, or requests like `models.list` may fail even if the dashboard shows the session as connected
Example: Example:
...@@ -213,6 +227,70 @@ User-scoped example: ...@@ -213,6 +227,70 @@ User-scoped example:
wss://your-aisbf.example/api/u/alice/coderai/wss?provider_id=my-coderai&client_id=workstation-01&username=alice&registration_token=<owner-configured-token> wss://your-aisbf.example/api/u/alice/coderai/wss?provider_id=my-coderai&client_id=workstation-01&username=alice&registration_token=<owner-configured-token>
``` ```
Recommended duplicate headers for robustness:
```text
x-coderai-provider-id: <provider_id>
x-coderai-client-id: <client_id>
x-coderai-username: <username>
x-coderai-registration-token: <registration_token>
```
### Broker registration flow
1. Open the WebSocket to the correct scoped AISBF broker URL.
2. Wait for AISBF to send an initial `registered` event.
3. Immediately send `op="register"` with hardware, capability, and endpoint metadata.
4. Wait for the `status="ok"` registration ack with the same `request_id`.
5. Enter the long-lived async broker loop for heartbeats, inbound requests, and outbound responses.
Initial server event example:
```json
{
"v": 1,
"event": "registered",
"session_id": "coderai_abc123",
"provider_id": "coderai",
"client_id": "workstation-01",
"username": "global",
"scope_name": "global",
"accepted": true,
"owner_user_id": null,
"expires_at": 1747179540
}
```
Required client follow-up:
```json
{
"v": 1,
"op": "register",
"request_id": "reg-1",
"payload": {
"endpoint": "wss://client-or-descriptive-local-endpoint",
"transport": "websocket",
"registration_token": "<same_registration_token>",
"hardware": {
"gpus": [],
"gpu_count": 0,
"total_vram_mb": 0,
"available_vram_mb": 0
},
"studio_endpoints": [],
"capabilities": {}
}
}
```
AISBF reads these `register` fields today:
- top-level: `v`, `op`, `request_id`, optional `registration_token`, optional `capabilities`
- from `payload`: `endpoint`, `transport`, `registration_token`, `studio_endpoints`, `hardware`, `gpus`, `gpu_count`, `total_vram_mb`, `available_vram_mb`, `capabilities`
If `payload.registration_token` or top-level `registration_token` is present and does not match the handshake token, AISBF replies with an error envelope.
### Envelope format ### Envelope format
AISBF sends one JSON request envelope per operation: AISBF sends one JSON request envelope per operation:
...@@ -314,6 +392,17 @@ Important: ...@@ -314,6 +392,17 @@ Important:
- This keeps AISBF transport-simple and lets CoderAI own protocol correctness. - This keeps AISBF transport-simple and lets CoderAI own protocol correctness.
- Include `data: [DONE]\n\n` as one of the streamed chunks when the upstream semantics require it. - Include `data: [DONE]\n\n` as one of the streamed chunks when the upstream semantics require it.
## Async broker behavior requirements
The broker client must be fully asynchronous.
- do not block the WebSocket receive loop on hardware probing, model loading, inference, file I/O, or reconnect bookkeeping
- handle inbound requests concurrently and correlate replies by `request_id`
- keep heartbeat handling lightweight
- preserve responsiveness to ping/pong traffic while local work is running
AISBF now independently drains queued outbound broker requests while also reading inbound WebSocket messages, so client implementations should not assume a strict request/response lockstep over the socket.
## Broker session visibility, persistence, and multi-node routing ## Broker session visibility, persistence, and multi-node routing
AISBF now tracks two broker states: AISBF now tracks two broker states:
......
...@@ -233,6 +233,7 @@ setup( ...@@ -233,6 +233,7 @@ setup(
'aisbf/routes/dashboard/providers.py', 'aisbf/routes/dashboard/providers.py',
'aisbf/routes/dashboard/settings.py', 'aisbf/routes/dashboard/settings.py',
'aisbf/routes/dashboard/admin.py', 'aisbf/routes/dashboard/admin.py',
'aisbf/routes/dashboard/market.py',
'aisbf/routes/dashboard/payments.py', 'aisbf/routes/dashboard/payments.py',
'aisbf/routes/dashboard/provider_auth.py', 'aisbf/routes/dashboard/provider_auth.py',
]), ]),
...@@ -299,6 +300,7 @@ setup( ...@@ -299,6 +300,7 @@ setup(
'templates/dashboard/admin_tier_form.html', 'templates/dashboard/admin_tier_form.html',
'templates/dashboard/admin_tiers.html', 'templates/dashboard/admin_tiers.html',
'templates/dashboard/analytics.html', 'templates/dashboard/analytics.html',
'templates/dashboard/admin_market.html',
'templates/dashboard/autoselect.html', 'templates/dashboard/autoselect.html',
'templates/dashboard/billing.html', 'templates/dashboard/billing.html',
'templates/dashboard/cache_settings.html', 'templates/dashboard/cache_settings.html',
...@@ -312,6 +314,7 @@ setup( ...@@ -312,6 +314,7 @@ setup(
'templates/dashboard/profile.html', 'templates/dashboard/profile.html',
'templates/dashboard/prompts.html', 'templates/dashboard/prompts.html',
'templates/dashboard/providers.html', 'templates/dashboard/providers.html',
'templates/dashboard/prompt_analytics.html',
'templates/dashboard/pricing.html', 'templates/dashboard/pricing.html',
'templates/dashboard/paypal_connect.html', 'templates/dashboard/paypal_connect.html',
'templates/dashboard/provider_quotas.html', 'templates/dashboard/provider_quotas.html',
...@@ -325,6 +328,7 @@ setup( ...@@ -325,6 +328,7 @@ setup(
'templates/dashboard/signup.html', 'templates/dashboard/signup.html',
'templates/dashboard/studio.html', 'templates/dashboard/studio.html',
'templates/dashboard/subscription.html', 'templates/dashboard/subscription.html',
'templates/dashboard/traffic_visits.html',
'templates/dashboard/usage.html', 'templates/dashboard/usage.html',
'templates/dashboard/user_autoselects.html', 'templates/dashboard/user_autoselects.html',
'templates/dashboard/user_index.html', 'templates/dashboard/user_index.html',
......
...@@ -1093,7 +1093,7 @@ ...@@ -1093,7 +1093,7 @@
"tab_models": "Models", "tab_models": "Models",
"tab_database": "Database", "tab_database": "Database",
"tab_cache": "Cache", "tab_cache": "Cache",
"tab_classification": "Classification", "tab_classification": "Features",
"tab_tor": "TOR", "tab_tor": "TOR",
"tab_signup": "Signup", "tab_signup": "Signup",
"tab_oauth2": "OAuth2", "tab_oauth2": "OAuth2",
...@@ -1107,7 +1107,7 @@ ...@@ -1107,7 +1107,7 @@
"section_models": "Internal Models", "section_models": "Internal Models",
"section_database": "Database Configuration", "section_database": "Database Configuration",
"section_cache": "Cache Configuration", "section_cache": "Cache Configuration",
"section_classification": "Classification", "section_classification": "Features & Prompt Analysis",
"section_tor": "TOR Hidden Service", "section_tor": "TOR Hidden Service",
"section_signup": "Signup Settings", "section_signup": "Signup Settings",
"section_oauth2": "OAuth2 Authentication", "section_oauth2": "OAuth2 Authentication",
......
...@@ -407,20 +407,6 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -407,20 +407,6 @@ function renderAutoselectDetails(autoselectKey) {
${partialCapsHtml} ${partialCapsHtml}
</div> </div>
<div class="form-group">
<label>
<input type="checkbox" ${autoselect.nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'nsfw', this.checked)">
${window.i18n.t('autoselect.nsfw')}
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${autoselect.privacy ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'privacy', this.checked)">
${window.i18n.t('autoselect.privacy')}
</label>
</div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" ${autoselect.classify_nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_nsfw', this.checked)"> <input type="checkbox" ${autoselect.classify_nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_nsfw', this.checked)">
...@@ -444,6 +430,16 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -444,6 +430,16 @@ function renderAutoselectDetails(autoselectKey) {
</label> </label>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small>
</div> </div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
${triStateSelect('Context Condensation', autoselect.enable_context_condensation, `updateAutoselectTriState('${autoselectKey}', 'enable_context_condensation', this.value)`)}
${triStateSelect('Response Cache', autoselect.enable_response_cache, `updateAutoselectTriState('${autoselectKey}', 'enable_response_cache', this.value)`)}
${triStateSelect('Prompt Batching', autoselect.enable_prompt_batching, `updateAutoselectTriState('${autoselectKey}', 'enable_prompt_batching', this.value)`)}
${triStateSelect('Prompt Security', autoselect.enable_prompt_security, `updateAutoselectTriState('${autoselectKey}', 'enable_prompt_security', this.value)`)}
${triStateSelect('Context Lens Analytics', autoselect.enable_context_lens, `updateAutoselectTriState('${autoselectKey}', 'enable_context_lens', this.value)`)}
${triStateSelect('Block High-Risk Prompts', autoselect.block_high_risk_prompts, `updateAutoselectTriState('${autoselectKey}', 'block_high_risk_prompts', this.value)`)}
${triStateSelect('NSFW Classification', autoselect.enable_nsfw_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_nsfw_classification', this.value)`)}
${triStateSelect('Privacy Classification', autoselect.enable_privacy_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_privacy_classification', this.value)`)}
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.description')}</label> <label>${window.i18n.t('autoselect.description')}</label>
...@@ -570,19 +566,6 @@ function renderAutoselectModels(autoselectKey) { ...@@ -570,19 +566,6 @@ function renderAutoselectModels(autoselectKey) {
<small style="color: var(--color-subtle); font-size: 12px;">${window.i18n.t('autoselect.model_description_hint')}</small> <small style="color: var(--color-subtle); font-size: 12px;">${window.i18n.t('autoselect.model_description_hint')}</small>
</div> </div>
<div class="form-group">
<label>
<input type="checkbox" ${model.nsfw ? 'checked' : ''} onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'nsfw', this.checked)">
${window.i18n.t('autoselect.nsfw')}
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${model.privacy ? 'checked' : ''} onchange="updateAutoselectModel('${autoselectKey}', ${index}, 'privacy', this.checked)">
${window.i18n.t('autoselect.privacy')}
</label>
</div>
`; `;
container.appendChild(modelDiv); container.appendChild(modelDiv);
...@@ -596,6 +579,30 @@ async function apiCall(method, url, body) { ...@@ -596,6 +579,30 @@ async function apiCall(method, url, body) {
return r.json(); return r.json();
} }
function triStateValue(value) {
if (value === true || value === 'enabled') return 'enabled';
if (value === false || value === 'disabled') return 'disabled';
return 'inherit';
}
function triStateSelect(label, value, onChange, helpText = '') {
return `
<div class="form-group">
<label>${label}</label>
<select onchange="${onChange}">
<option value="inherit" ${triStateValue(value) === 'inherit' ? 'selected' : ''}>Inherit</option>
<option value="enabled" ${triStateValue(value) === 'enabled' ? 'selected' : ''}>Enabled</option>
<option value="disabled" ${triStateValue(value) === 'disabled' ? 'selected' : ''}>Disabled</option>
</select>
${helpText ? `<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${helpText}</small>` : ''}
</div>
`;
}
function updateAutoselectTriState(autoselectKey, field, value) {
autoselectConfig[autoselectKey][field] = value === 'inherit' ? null : value === 'enabled';
}
async function saveAutoselectItem(key) { async function saveAutoselectItem(key) {
const statusEl = document.getElementById(`asel-save-status-${key}`); const statusEl = document.getElementById(`asel-save-status-${key}`);
// Validate: no available_model may have an empty model_id // Validate: no available_model may have an empty model_id
......
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Prompt Cache Settings{% endblock %} {% block title %}Native Prompt Cache Settings{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid" style="padding: 20px;"> <div class="container-fluid" style="padding: 20px;">
<div style="max-width: 1400px; margin: 0 auto;"> <div style="max-width: 1400px; margin: 0 auto;">
<h2 style="color: var(--color-text); margin-bottom: 30px;"> <h2 style="color: var(--color-text); margin-bottom: 30px;">
<i class="fas fa-memory me-2"></i>Prompt Cache Settings <i class="fas fa-memory me-2"></i>Native Prompt Cache Settings
</h2> </h2>
<div style="background: rgba(59,130,246,0.12); border: 1px solid rgba(59,130,246,0.35); border-radius: 8px; padding: 16px 18px; margin-bottom: 20px; color: var(--color-text);">
<strong>Scope note:</strong> this page controls the legacy user-specific native prompt-caching overrides used by provider adapters such as OpenAI-compatible, Claude, and Kilo flows.
The newer request-time response-cache policy now lives in the dashboard feature overrides under <strong>Response Cache</strong>.
</div>
<!-- User Default Cache Setting --> <!-- User Default Cache Setting -->
<div style="background: var(--bg-panel); border: 2px solid #f39c12; border-radius: 8px; padding: 20px; margin-bottom: 20px;"> <div style="background: var(--bg-panel); border: 2px solid #f39c12; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: #f39c12;"> <h3 style="margin: 0 0 20px 0; color: #f39c12;">
<i class="fas fa-user-circle me-2"></i>User Default Cache Setting <i class="fas fa-user-circle me-2"></i>User Default Native Cache Setting
</h3> </h3>
<div style="background: var(--bg-page); padding: 15px; border-radius: 8px; margin-bottom: 15px;"> <div style="background: var(--bg-page); padding: 15px; border-radius: 8px; margin-bottom: 15px;">
<div style="display: flex; align-items: center; justify-content: space-between;"> <div style="display: flex; align-items: center; justify-content: space-between;">
<div> <div>
<h5 style="margin: 0 0 5px 0; color: var(--color-text);">Enable Prompt Caching</h5> <h5 style="margin: 0 0 5px 0; color: var(--color-text);">Enable Native Prompt Caching</h5>
<p style="color: var(--color-muted); margin: 0; font-size: 14px;">Enable/disable prompt caching for all your providers and models</p> <p style="color: var(--color-muted); margin: 0; font-size: 14px;">Enable or disable provider-native prompt caching overrides for all your providers and models.</p>
</div> </div>
</div> </div>
</div> </div>
...@@ -29,7 +34,7 @@ ...@@ -29,7 +34,7 @@
<i class="fas fa-info-circle me-2"></i>Cache Override Hierarchy <i class="fas fa-info-circle me-2"></i>Cache Override Hierarchy
</h5> </h5>
<p style="color: var(--color-muted); margin: 0; font-size: 13px; line-height: 1.6;"> <p style="color: var(--color-muted); margin: 0; font-size: 13px; line-height: 1.6;">
Settings are applied in this priority order (most specific takes precedence): Legacy native prompt-caching settings are applied in this priority order (most specific takes precedence):
<br><strong>1. Model-level setting</strong> → Overrides everything <br><strong>1. Model-level setting</strong> → Overrides everything
<br><strong>2. Provider-level setting</strong> → Overrides user default <br><strong>2. Provider-level setting</strong> → Overrides user default
<br><strong>3. User default</strong> → Applied if no specific setting exists <br><strong>3. User default</strong> → Applied if no specific setting exists
...@@ -162,4 +167,4 @@ function showToast(message, type) { ...@@ -162,4 +167,4 @@ function showToast(message, type) {
}, 4000); }, 4000);
} }
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
...@@ -29,6 +29,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -29,6 +29,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary" style="text-decoration: none;"> <a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary" style="text-decoration: none;">
📊 <span data-i18n="rate_limits_page.analytics">Analytics</span> 📊 <span data-i18n="rate_limits_page.analytics">Analytics</span>
</a> </a>
<a href="{{ url_for(request, '/dashboard/prompt-analytics') }}" class="btn btn-secondary" style="text-decoration: none;">
🧠 Prompt Analytics
</a>
{% if session.get('role') == 'admin' and session.get('user_id') is none %}
<a href="{{ url_for(request, '/dashboard/traffic-visits') }}" class="btn btn-secondary" style="text-decoration: none;">
👣 Traffic &amp; Visits
</a>
{% endif %}
<a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn btn-secondary" style="text-decoration: none;"> <a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn btn-secondary" style="text-decoration: none;">
💾 <span data-i18n="rate_limits_page.response_cache">Response Cache</span> 💾 <span data-i18n="rate_limits_page.response_cache">Response Cache</span>
</a> </a>
...@@ -212,4 +220,4 @@ loadRateLimits(); ...@@ -212,4 +220,4 @@ loadRateLimits();
// Auto-refresh every 30 seconds // Auto-refresh every 30 seconds
setInterval(loadRateLimits, 30000); setInterval(loadRateLimits, 30000);
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -11,6 +11,14 @@ ...@@ -11,6 +11,14 @@
<a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary" style="text-decoration: none;"> <a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary" style="text-decoration: none;">
📊 Analytics 📊 Analytics
</a> </a>
<a href="{{ url_for(request, '/dashboard/prompt-analytics') }}" class="btn btn-secondary" style="text-decoration: none;">
🧠 Prompt Analytics
</a>
{% if is_admin and session.get('user_id') is none %}
<a href="{{ url_for(request, '/dashboard/traffic-visits') }}" class="btn btn-secondary" style="text-decoration: none;">
👣 Traffic &amp; Visits
</a>
{% endif %}
<a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn" style="text-decoration: none;"> <a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn" style="text-decoration: none;">
💾 Response Cache 💾 Response Cache
</a> </a>
......
...@@ -390,19 +390,15 @@ function renderRotationDetails(rotationKey) { ...@@ -390,19 +390,15 @@ function renderRotationDetails(rotationKey) {
<input type="number" value="${rotation.default_context_size || ''}" onchange="updateRotation('${rotationKey}', 'default_context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}"> <input type="number" value="${rotation.default_context_size || ''}" onchange="updateRotation('${rotationKey}', 'default_context_size', this.value ? parseInt(this.value) : null)" placeholder="${window.i18n.t('rotations.optional')}">
</div> </div>
<div class="form-group"> <h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
<label> ${triStateCompact('Context Condensation', rotation.enable_context_condensation, `updateRotationTriState('${rotationKey}', 'enable_context_condensation', this.value)`)}
<input type="checkbox" ${rotation.nsfw ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'nsfw', this.checked)"> ${triStateCompact('Response Cache', rotation.enable_response_cache, `updateRotationTriState('${rotationKey}', 'enable_response_cache', this.value)`)}
${window.i18n.t('rotations.nsfw')} ${triStateCompact('Prompt Batching', rotation.enable_prompt_batching, `updateRotationTriState('${rotationKey}', 'enable_prompt_batching', this.value)`)}
</label> ${triStateCompact('Prompt Security', rotation.enable_prompt_security, `updateRotationTriState('${rotationKey}', 'enable_prompt_security', this.value)`)}
</div> ${triStateCompact('Context Lens Analytics', rotation.enable_context_lens, `updateRotationTriState('${rotationKey}', 'enable_context_lens', this.value)`)}
${triStateCompact('Block High-Risk Prompts', rotation.block_high_risk_prompts, `updateRotationTriState('${rotationKey}', 'block_high_risk_prompts', this.value)`)}
<div class="form-group"> ${triStateCompact('NSFW Classification', rotation.enable_nsfw_classification, `updateRotationTriState('${rotationKey}', 'enable_nsfw_classification', this.value)`)}
<label> ${triStateCompact('Privacy Classification', rotation.enable_privacy_classification, `updateRotationTriState('${rotationKey}', 'enable_privacy_classification', this.value)`)}
<input type="checkbox" ${rotation.privacy ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'privacy', this.checked)">
${window.i18n.t('rotations.privacy')}
</label>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('rotations.providers_section')}</h4> <h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('rotations.providers_section')}</h4>
<div id="providers-${rotationKey}"></div> <div id="providers-${rotationKey}"></div>
...@@ -537,6 +533,17 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -537,6 +533,17 @@ function renderRotationModels(rotationKey, providerIndex) {
<label style="font-size: 12px;">${window.i18n.t('rotations.condense_method')}</label> <label style="font-size: 12px;">${window.i18n.t('rotations.condense_method')}</label>
<input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateRotationModelCondenseMethod('${rotationKey}', ${providerIndex}, ${modelIndex}, this.value)" placeholder="${window.i18n.t('rotations.condense_method_placeholder')}" style="font-size: 12px; padding: 5px;"> <input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateRotationModelCondenseMethod('${rotationKey}', ${providerIndex}, ${modelIndex}, this.value)" placeholder="${window.i18n.t('rotations.condense_method_placeholder')}" style="font-size: 12px; padding: 5px;">
</div> </div>
<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--color-border);">
${triStateCompact('Context Condensation', model.enable_context_condensation, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_context_condensation', this.value)`)}
${triStateCompact('Response Cache', model.enable_response_cache, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_response_cache', this.value)`)}
${triStateCompact('Prompt Batching', model.enable_prompt_batching, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_prompt_batching', this.value)`)}
${triStateCompact('Prompt Security', model.enable_prompt_security, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_prompt_security', this.value)`)}
${triStateCompact('Context Lens Analytics', model.enable_context_lens, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_context_lens', this.value)`)}
${triStateCompact('Block High-Risk Prompts', model.block_high_risk_prompts, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'block_high_risk_prompts', this.value)`)}
${triStateCompact('NSFW Classification', model.enable_nsfw_classification, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_nsfw_classification', this.value)`)}
${triStateCompact('Privacy Classification', model.enable_privacy_classification, `updateRotationModelTriState('${rotationKey}', ${providerIndex}, ${modelIndex}, 'enable_privacy_classification', this.value)`)}
</div>
`; `;
container.appendChild(modelDiv); container.appendChild(modelDiv);
...@@ -554,6 +561,33 @@ async function apiCall(method, url, body) { ...@@ -554,6 +561,33 @@ async function apiCall(method, url, body) {
return r.json(); return r.json();
} }
function triStateValue(value) {
if (value === true || value === 'enabled') return 'enabled';
if (value === false || value === 'disabled') return 'disabled';
return 'inherit';
}
function triStateCompact(label, value, onChange) {
return `
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${label}</label>
<select onchange="${onChange}" style="font-size: 12px; padding: 5px;">
<option value="inherit" ${triStateValue(value) === 'inherit' ? 'selected' : ''}>Inherit</option>
<option value="enabled" ${triStateValue(value) === 'enabled' ? 'selected' : ''}>Enabled</option>
<option value="disabled" ${triStateValue(value) === 'disabled' ? 'selected' : ''}>Disabled</option>
</select>
</div>
`;
}
function updateRotationTriState(rotationKey, field, value) {
rotationsConfig.rotations[rotationKey][field] = value === 'inherit' ? null : value === 'enabled';
}
function updateRotationModelTriState(rotationKey, providerIndex, modelIndex, field, value) {
rotationsConfig.rotations[rotationKey].providers[providerIndex].models[modelIndex][field] = value === 'inherit' ? null : value === 'enabled';
}
async function saveRotation(key) { async function saveRotation(key) {
const statusEl = document.getElementById(`rot-save-status-${key}`); const statusEl = document.getElementById(`rot-save-status-${key}`);
if (statusEl) statusEl.textContent = window.i18n.t('rotations.saving'); if (statusEl) statusEl.textContent = window.i18n.t('rotations.saving');
......
...@@ -27,8 +27,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -27,8 +27,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
.settings-section { display: none; } .settings-section { display: none; }
.settings-section.active { display: block; } .settings-section.active { display: block; }
.section-title { font-size: 1.1em; font-weight: 600; color: var(--color-primary); margin: 0 0 20px; padding-bottom: 8px; border-bottom: 1px solid var(--color-border); } .section-title { font-size: 1.1em; font-weight: 600; color: var(--color-primary); margin: 0 0 20px; padding-bottom: 8px; border-bottom: 1px solid var(--color-border); }
.tri-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; }
</style> </style>
{% macro tri_state_select(field_name, selected_value, label, help_text='') %}
<div class="form-group">
<label for="{{ field_name }}">{{ label }}</label>
<select id="{{ field_name }}" name="{{ field_name }}">
<option value="inherit" {% if selected_value == 'inherit' %}selected{% endif %}>Inherit</option>
<option value="enabled" {% if selected_value == 'enabled' %}selected{% endif %}>Enabled</option>
<option value="disabled" {% if selected_value == 'disabled' %}selected{% endif %}>Disabled</option>
</select>
{% if help_text %}<small style="color: var(--color-subtle); display: block; margin-top: 5px;">{{ help_text }}</small>{% endif %}
</div>
{% endmacro %}
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
<h2 style="margin:0;">Server Settings</h2> <h2 style="margin:0;">Server Settings</h2>
</div> </div>
...@@ -58,7 +71,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -58,7 +71,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="settings-tab" onclick="switchTab('models')"><i class="fas fa-brain"></i><span data-i18n="settings_page.tab_models">Models</span></div> <div class="settings-tab" onclick="switchTab('models')"><i class="fas fa-brain"></i><span data-i18n="settings_page.tab_models">Models</span></div>
<div class="settings-tab" onclick="switchTab('database')"><i class="fas fa-database"></i><span data-i18n="settings_page.tab_database">Database</span></div> <div class="settings-tab" onclick="switchTab('database')"><i class="fas fa-database"></i><span data-i18n="settings_page.tab_database">Database</span></div>
<div class="settings-tab" onclick="switchTab('cache')"><i class="fas fa-bolt"></i><span data-i18n="settings_page.tab_cache">Cache</span></div> <div class="settings-tab" onclick="switchTab('cache')"><i class="fas fa-bolt"></i><span data-i18n="settings_page.tab_cache">Cache</span></div>
<div class="settings-tab" onclick="switchTab('classification')"><i class="fas fa-filter"></i><span data-i18n="settings_page.tab_classification">Classification</span></div> <div class="settings-tab" onclick="switchTab('classification')"><i class="fas fa-filter"></i><span data-i18n="settings_page.tab_classification">Features</span></div>
<div class="settings-tab" onclick="switchTab('tor')"><i class="fas fa-user-secret"></i><span data-i18n="settings_page.tab_tor">TOR</span></div> <div class="settings-tab" onclick="switchTab('tor')"><i class="fas fa-user-secret"></i><span data-i18n="settings_page.tab_tor">TOR</span></div>
<div class="settings-tab" onclick="switchTab('signup')"><i class="fas fa-user-plus"></i><span data-i18n="settings_page.tab_signup">Signup</span></div> <div class="settings-tab" onclick="switchTab('signup')"><i class="fas fa-user-plus"></i><span data-i18n="settings_page.tab_signup">Signup</span></div>
<div class="settings-tab" onclick="switchTab('oauth2')"><i class="fab fa-openid"></i><span data-i18n="settings_page.tab_oauth2">OAuth2</span></div> <div class="settings-tab" onclick="switchTab('oauth2')"><i class="fab fa-openid"></i><span data-i18n="settings_page.tab_oauth2">OAuth2</span></div>
...@@ -496,7 +509,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -496,7 +509,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div><!-- /tab-cache --> </div><!-- /tab-cache -->
<div class="settings-section" id="tab-classification"> <div class="settings-section" id="tab-classification">
<div class="section-title"><i class="fas fa-filter"></i> Content Classification</div> <div class="section-title"><i class="fas fa-filter"></i> <span data-i18n="settings_page.section_classification">Features &amp; Prompt Analysis</span></div>
<p style="color: var(--color-muted); margin-bottom: 18px;">Global defaults for legacy classifiers, execution features, and prompt analysis / security controls.</p>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -521,6 +535,39 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -521,6 +535,39 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</label> </label>
<small style="color: var(--color-subtle); display: block; margin-top: 5px;">Enable semantic content classification using the configured model</small> <small style="color: var(--color-subtle); display: block; margin-top: 5px;">Enable semantic content classification using the configured model</small>
</div> </div>
<div class="section-title" style="margin-top:28px;"><i class="fas fa-sliders-h"></i> Global Feature Defaults</div>
<p style="color:var(--color-muted);margin-bottom:16px;font-size:.9em;">These tri-state defaults drive inheritance for provider, model, rotation, and autoselect overrides.</p>
{% set feature_controls = config.feature_controls if config.feature_controls else none %}
{% set prompt_security = feature_controls.prompt_security if feature_controls and feature_controls.prompt_security else none %}
<div class="tri-grid">
{{ tri_state_select('feature_nsfw_classification_mode', feature_controls.nsfw_classification.mode if feature_controls and feature_controls.nsfw_classification else 'inherit', 'NSFW Classification', 'Default NSFW classification behavior.') }}
{{ tri_state_select('feature_privacy_classification_mode', feature_controls.privacy_classification.mode if feature_controls and feature_controls.privacy_classification else 'inherit', 'Privacy Classification', 'Default privacy classification behavior.') }}
{{ tri_state_select('feature_context_condensation_mode', feature_controls.context_condensation.mode if feature_controls and feature_controls.context_condensation else 'inherit', 'Context Condensation', 'Default context condensation behavior.') }}
{{ tri_state_select('feature_response_cache_mode', feature_controls.response_cache.mode if feature_controls and feature_controls.response_cache else 'inherit', 'Response Cache', 'Default response-cache behavior.') }}
{{ tri_state_select('feature_prompt_batching_mode', feature_controls.prompt_batching.mode if feature_controls and feature_controls.prompt_batching else 'inherit', 'Prompt Batching', 'Default batching behavior.') }}
{{ tri_state_select('feature_prompt_security_mode', prompt_security.security_scan.mode if prompt_security and prompt_security.security_scan else 'inherit', 'Prompt Security', 'Default local prompt scanning behavior.') }}
{{ tri_state_select('feature_context_lens_mode', prompt_security.context_lens.mode if prompt_security and prompt_security.context_lens else 'inherit', 'Context Lens Analytics', 'Default prompt composition analytics behavior.') }}
{{ tri_state_select('feature_block_high_risk_prompts_mode', prompt_security.block_high_risk_prompts.mode if prompt_security and prompt_security.block_high_risk_prompts else 'inherit', 'Block High-Risk Prompts', 'Default local high-risk blocking behavior.') }}
</div>
<div class="section-title" style="margin-top:28px;"><i class="fas fa-shield-alt"></i> Prompt Security Persistence</div>
<p style="color:var(--color-muted);margin-bottom:16px;font-size:.9em;">Controls how prompt analysis findings are stored when prompt-security features are active.</p>
<div class="tri-grid">
{{ tri_state_select('feature_persist_prompt_text_mode', prompt_security.persist_prompt_text.mode if prompt_security and prompt_security.persist_prompt_text else 'inherit', 'Persist Prompt Text', 'Store prompt text with prompt-analysis records when allowed.') }}
{{ tri_state_select('feature_redact_before_persist_mode', prompt_security.redact_before_persist.mode if prompt_security and prompt_security.redact_before_persist else 'inherit', 'Redact Before Persist', 'Redact sensitive fragments before storing prompt-analysis records.') }}
</div>
<div class="form-group" style="margin-top: 18px; max-width: 340px;">
<label for="feature_risk_threshold">Prompt Security Risk Threshold</label>
<select id="feature_risk_threshold" name="feature_risk_threshold">
{% set risk_threshold = prompt_security.risk_threshold if prompt_security and prompt_security.risk_threshold else 'high' %}
<option value="low" {% if risk_threshold == 'low' %}selected{% endif %}>Low</option>
<option value="medium" {% if risk_threshold == 'medium' %}selected{% endif %}>Medium</option>
<option value="high" {% if risk_threshold == 'high' %}selected{% endif %}>High</option>
</select>
<small style="color: var(--color-subtle); display: block; margin-top: 5px;">Minimum risk level that should be treated as high-risk for blocking and alerting.</small>
</div>
</div><!-- /tab-classification --> </div><!-- /tab-classification -->
<div class="settings-section" id="tab-tor"> <div class="settings-section" id="tab-tor">
......
{% extends "base.html" %}
{% block title %}Traffic & Visits - AISBF Dashboard{% endblock %}
{% block content %}
<h2 style="margin-bottom: 30px;">Traffic &amp; Visits</h2>
<div style="background: var(--bg-page); padding: 15px; border-radius: 8px; margin-bottom: 30px;">
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary" style="text-decoration: none;">📊 Analytics</a>
<a href="{{ url_for(request, '/dashboard/prompt-analytics') }}" class="btn btn-secondary" style="text-decoration: none;">🧠 Prompt Analytics</a>
<a href="{{ url_for(request, '/dashboard/traffic-visits') }}" class="btn" style="text-decoration: none;">👣 Traffic &amp; Visits</a>
<a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn btn-secondary" style="text-decoration: none;">💾 Response Cache</a>
<a href="{{ url_for(request, '/dashboard/rate-limits') }}" class="btn btn-secondary" style="text-decoration: none;">⏱️ Rate Limits</a>
</div>
</div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px; margin-bottom: 30px; border: 1px solid var(--color-border);">
<h3 style="margin-bottom: 15px;">Dashboard Visitor &amp; Activity Tracking</h3>
<form method="get" action="{{ url_for(request, '/dashboard/traffic-visits') }}" style="display:flex; flex-wrap:wrap; gap:15px; align-items:flex-end;">
<input type="hidden" name="time_range" value="{{ selected_time_range }}">
{% if from_date %}<input type="hidden" name="from_date" value="{{ from_date }}">{% endif %}
{% if to_date %}<input type="hidden" name="to_date" value="{{ to_date }}">{% endif %}
{% if selected_provider %}<input type="hidden" name="provider_filter" value="{{ selected_provider }}">{% endif %}
{% if selected_model %}<input type="hidden" name="model_filter" value="{{ selected_model }}">{% endif %}
{% if selected_rotation %}<input type="hidden" name="rotation_filter" value="{{ selected_rotation }}">{% endif %}
{% if selected_autoselect %}<input type="hidden" name="autoselect_filter" value="{{ selected_autoselect }}">{% endif %}
{% if selected_user %}<input type="hidden" name="user_filter" value="{{ selected_user }}">{% endif %}
{% if global_only %}<input type="hidden" name="global_only" value="{{ global_only }}">{% endif %}
<div style="flex:1; min-width:180px;"><label style="display:block; margin-bottom:5px; color:var(--color-muted); font-size:14px;">Activity Type</label><input type="text" name="activity_event_filter" value="{{ activity_event_filter or '' }}" placeholder="dashboard_visit, provider_saved..." style="width:100%; padding:10px; border-radius:4px; background:var(--bg-accent); color:white; border:1px solid #2a4a7a;"></div>
<div style="flex:1; min-width:180px;"><label style="display:block; margin-bottom:5px; color:var(--color-muted); font-size:14px;">Visited Page</label><input type="text" name="activity_path_filter" value="{{ activity_path_filter or '' }}" placeholder="/dashboard/providers" style="width:100%; padding:10px; border-radius:4px; background:var(--bg-accent); color:white; border:1px solid #2a4a7a;"></div>
<div style="flex:1; min-width:140px;"><label style="display:block; margin-bottom:5px; color:var(--color-muted); font-size:14px;">Country</label><input type="text" name="activity_country_filter" value="{{ activity_country_filter or '' }}" placeholder="ZA" style="width:100%; padding:10px; border-radius:4px; background:var(--bg-accent); color:white; border:1px solid #2a4a7a; text-transform:uppercase;"></div>
<div><button type="submit" style="padding: 10px 20px; background: var(--color-link); color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply Activity Filters</button></div>
</form>
</div>
<script>
const analyticsData = {
dashboardVisitSeries: {{ dashboard_visit_series|default('[]')|safe }}
};
</script>
{% if dashboard_visit_overview %}
<h3 style="margin-top: 30px; margin-bottom: 15px;">Visitor &amp; Dashboard Activity Overview</h3>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(170px, 1fr)); gap:16px; margin-bottom:24px;">
<div style="background:#1f6feb; color:white; padding:18px; border-radius:8px;"><div style="font-size:13px; opacity:0.9;">Total Events</div><div style="font-size:28px; font-weight:700;">{{ dashboard_visit_overview.total_events }}</div></div>
<div style="background:#0f766e; color:white; padding:18px; border-radius:8px;"><div style="font-size:13px; opacity:0.9;">Unique IPs</div><div style="font-size:28px; font-weight:700;">{{ dashboard_visit_overview.unique_ips }}</div></div>
<div style="background:#7c3aed; color:white; padding:18px; border-radius:8px;"><div style="font-size:13px; opacity:0.9;">Unique Visitors</div><div style="font-size:28px; font-weight:700;">{{ dashboard_visit_overview.unique_visitors }}</div></div>
<div style="background:#c2410c; color:white; padding:18px; border-radius:8px;"><div style="font-size:13px; opacity:0.9;">Unique Sessions</div><div style="font-size:28px; font-weight:700;">{{ dashboard_visit_overview.unique_sessions }}</div></div>
</div>
<div style="display:grid; grid-template-columns:2fr 1fr; gap:20px; margin-bottom:24px;">
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;"><h4 style="margin-top:0; margin-bottom:12px;">Visit &amp; Activity Timeline</h4><canvas id="dashboardVisitChart" style="width: 100%; height: 280px;"></canvas></div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;"><h4 style="margin-top:0; margin-bottom:12px;">Activity Types</h4><table><tr><th>Type</th><th>Count</th></tr>{% for row in dashboard_visit_overview.by_type %}<tr><td>{{ row.event_type }}</td><td>{{ row.count }}</td></tr>{% endfor %}</table></div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:24px;">
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;"><h4 style="margin-top:0; margin-bottom:12px;">Visited Pages</h4><table><tr><th>Page</th><th>Visits</th></tr>{% for row in dashboard_visit_pages %}<tr><td>{{ row.path }}</td><td>{{ row.count }}</td></tr>{% endfor %}</table></div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;"><h4 style="margin-top:0; margin-bottom:12px;">Countries</h4><table><tr><th>Country</th><th>Visits</th></tr>{% for row in dashboard_visit_countries %}<tr><td>{{ row.country_code }}</td><td>{{ row.count }}</td></tr>{% endfor %}</table></div>
</div>
<div style="display:grid; grid-template-columns:1fr 2fr; gap:20px; margin-bottom:24px;">
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;"><h4 style="margin-top:0; margin-bottom:12px;">Tracked Users</h4><table><tr><th>User</th><th>Events</th></tr>{% for row in dashboard_visit_users %}<tr><td>{{ row.username }}{% if row.user_id %} (#{{ row.user_id }}){% endif %}</td><td>{{ row.count }}</td></tr>{% endfor %}</table></div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px; overflow:auto;"><h4 style="margin-top:0; margin-bottom:12px;">Recent Dashboard Events</h4><table><tr><th>Time</th><th>Type</th><th>User</th><th>IP</th><th>Country</th><th>Page</th><th>Status</th><th>Details</th></tr>{% for row in dashboard_visit_events %}<tr><td>{{ row.created_at }}</td><td>{{ row.event_type }}</td><td>{{ row.username or 'guest' }}</td><td>{{ row.ip_address or '-' }}</td><td>{{ row.country_code or '-' }}</td><td>{{ row.path or '-' }}</td><td>{{ row.status_code or '-' }}</td><td>{% if row.provider_id %}provider={{ row.provider_id }} {% endif %}{% if row.rotation_id %}rotation={{ row.rotation_id }} {% endif %}{% if row.autoselect_id %}autoselect={{ row.autoselect_id }} {% endif %}{% if row.listing_id %}listing={{ row.listing_id }} {% endif %}{% if row.metadata %}<code>{{ row.metadata|tojson }}</code>{% endif %}</td></tr>{% endfor %}</table></div>
</div>
{% else %}
<p style="color: var(--color-muted);">No visitor or activity data available yet.</p>
{% endif %}
<div style="margin-top: 30px; display: flex; gap: 10px; flex-wrap: wrap;">
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
<a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary">Analytics</a>
<a href="{{ url_for(request, '/dashboard/rate-limits') }}" class="btn">Rate Limits</a>
<a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn">Response Cache</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<script>
if (document.getElementById('dashboardVisitChart') && analyticsData.dashboardVisitSeries.length) {
new Chart(document.getElementById('dashboardVisitChart'), {
type: 'line',
data: {
datasets: [
{ label: 'Events', data: analyticsData.dashboardVisitSeries.map(d => ({x: d.bucket, y: d.events})), borderColor: '#1f6feb', backgroundColor: 'rgba(31,111,235,0.12)', fill: true, tension: 0.2 },
{ label: 'Unique IPs', data: analyticsData.dashboardVisitSeries.map(d => ({x: d.bucket, y: d.unique_ips})), borderColor: '#0f766e', backgroundColor: 'rgba(15,118,110,0.08)', fill: false, tension: 0.2 },
{ label: 'Unique Visitors', data: analyticsData.dashboardVisitSeries.map(d => ({x: d.bucket, y: d.unique_visitors})), borderColor: '#7c3aed', backgroundColor: 'rgba(124,58,237,0.08)', fill: false, tension: 0.2 }
]
},
options: { responsive: true, scales: { x: { type: 'time', time: { unit: 'hour' }, title: { display: true, text: 'Time' } }, y: { beginAtZero: true, title: { display: true, text: 'Count' } } } }
});
}
</script>
{% endblock %}
...@@ -467,7 +467,17 @@ function renderAutoselectDetails(autoselectKey) { ...@@ -467,7 +467,17 @@ function renderAutoselectDetails(autoselectKey) {
</label> </label>
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small> <small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small>
</div> </div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
${triStateSelect('Context Condensation', autoselect.enable_context_condensation, `updateAutoselectTriState('${autoselectKey}', 'enable_context_condensation', this.value)`)}
${triStateSelect('Response Cache', autoselect.enable_response_cache, `updateAutoselectTriState('${autoselectKey}', 'enable_response_cache', this.value)`)}
${triStateSelect('Prompt Batching', autoselect.enable_prompt_batching, `updateAutoselectTriState('${autoselectKey}', 'enable_prompt_batching', this.value)`)}
${triStateSelect('Prompt Security', autoselect.enable_prompt_security, `updateAutoselectTriState('${autoselectKey}', 'enable_prompt_security', this.value)`)}
${triStateSelect('Context Lens Analytics', autoselect.enable_context_lens, `updateAutoselectTriState('${autoselectKey}', 'enable_context_lens', this.value)`)}
${triStateSelect('Block High-Risk Prompts', autoselect.block_high_risk_prompts, `updateAutoselectTriState('${autoselectKey}', 'block_high_risk_prompts', this.value)`)}
${triStateSelect('NSFW Classification', autoselect.enable_nsfw_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_nsfw_classification', this.value)`)}
${triStateSelect('Privacy Classification', autoselect.enable_privacy_classification, `updateAutoselectTriState('${autoselectKey}', 'enable_privacy_classification', this.value)`)}
<div class="form-group"> <div class="form-group">
<label>${window.i18n.t('autoselect.description')}</label> <label>${window.i18n.t('autoselect.description')}</label>
<textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea> <textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea>
...@@ -605,12 +615,52 @@ function renderAutoselectModels(autoselectKey) { ...@@ -605,12 +615,52 @@ function renderAutoselectModels(autoselectKey) {
${window.i18n.t('autoselect.privacy')} ${window.i18n.t('autoselect.privacy')}
</label> </label>
</div> </div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border);">
<h5 style="margin: 0 0 10px 0;">Feature Overrides</h5>
${triStateSelect('Context Condensation', model.enable_context_condensation, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'enable_context_condensation', this.value)`)}
${triStateSelect('Response Cache', model.enable_response_cache, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'enable_response_cache', this.value)`)}
${triStateSelect('Prompt Batching', model.enable_prompt_batching, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'enable_prompt_batching', this.value)`)}
${triStateSelect('Prompt Security', model.enable_prompt_security, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'enable_prompt_security', this.value)`)}
${triStateSelect('Context Lens Analytics', model.enable_context_lens, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'enable_context_lens', this.value)`)}
${triStateSelect('Block High-Risk Prompts', model.block_high_risk_prompts, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'block_high_risk_prompts', this.value)`)}
${triStateSelect('NSFW Classification', model.enable_nsfw_classification, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'enable_nsfw_classification', this.value)`)}
${triStateSelect('Privacy Classification', model.enable_privacy_classification, `updateAutoselectModelTriState('${autoselectKey}', ${index}, 'enable_privacy_classification', this.value)`)}
</div>
`; `;
container.appendChild(modelDiv); container.appendChild(modelDiv);
}); });
} }
function triStateValue(value) {
if (value === true || value === 'enabled') return 'enabled';
if (value === false || value === 'disabled') return 'disabled';
return 'inherit';
}
function triStateSelect(label, value, onChange, helpText = '') {
return `
<div class="form-group">
<label>${label}</label>
<select onchange="${onChange}">
<option value="inherit" ${triStateValue(value) === 'inherit' ? 'selected' : ''}>Inherit</option>
<option value="enabled" ${triStateValue(value) === 'enabled' ? 'selected' : ''}>Enabled</option>
<option value="disabled" ${triStateValue(value) === 'disabled' ? 'selected' : ''}>Disabled</option>
</select>
${helpText ? `<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${helpText}</small>` : ''}
</div>
`;
}
function updateAutoselectTriState(autoselectKey, field, value) {
autoselectConfig[autoselectKey][field] = value === 'inherit' ? null : value === 'enabled';
}
function updateAutoselectModelTriState(autoselectKey, index, field, value) {
autoselectConfig[autoselectKey].available_models[index][field] = value === 'inherit' ? null : value === 'enabled';
}
async function saveAutoselectItem(key) { async function saveAutoselectItem(key) {
const statusEl = document.getElementById(`asel-save-status-${key}`); const statusEl = document.getElementById(`asel-save-status-${key}`);
if (statusEl) statusEl.textContent = window.i18n.t('autoselect.saving'); if (statusEl) statusEl.textContent = window.i18n.t('autoselect.saving');
......
This diff is collapsed.
This diff is collapsed.
...@@ -65,3 +65,19 @@ async def test_handle_request_raises_error_when_token_refresh_fails(mock_auth_ex ...@@ -65,3 +65,19 @@ async def test_handle_request_raises_error_when_token_refresh_fails(mock_auth_ex
assert "authentication required" in str(exc_info.value).lower() or "token" in str(exc_info.value).lower() assert "authentication required" in str(exc_info.value).lower() or "token" in str(exc_info.value).lower()
mock_auth_expired.get_valid_token_with_refresh.assert_called() mock_auth_expired.get_valid_token_with_refresh.assert_called()
def test_get_cache_config_disables_prompt_caching_when_user_override_blocks(monkeypatch):
handler = ClaudeProviderHandler(provider_id="test_claude", api_key=None, user_id=7)
handler.provider_config = {
"claude_config": {
"enable_prompt_caching": True,
"cache_min_messages": 6,
}
}
monkeypatch.setattr(handler, "_native_cache_user_allows", lambda model_name=None: False)
cache_config = handler._get_cache_config(user_id=7, provider_id="test_claude", model_name="claude-3-5-sonnet")
assert cache_config["enabled"] is False
assert cache_config["min_messages"] == 6
...@@ -54,3 +54,33 @@ async def test_ensure_authenticated_initiates_device_flow_when_expired(mock_oaut ...@@ -54,3 +54,33 @@ async def test_ensure_authenticated_initiates_device_flow_when_expired(mock_oaut
assert result["verification_url"] == "https://kilo.ai/device" assert result["verification_url"] == "https://kilo.ai/device"
mock_oauth2_expired.get_valid_token_with_refresh.assert_called_once() mock_oauth2_expired.get_valid_token_with_refresh.assert_called_once()
mock_oauth2_expired.initiate_device_flow.assert_called_once() mock_oauth2_expired.initiate_device_flow.assert_called_once()
def test_native_cache_user_allows_defaults_true_on_db_error(monkeypatch):
handler = KiloProviderHandler(provider_id="test_kilo", api_key=None, user_id=5)
class BrokenRegistry:
@staticmethod
def get_config_database():
raise RuntimeError("db unavailable")
monkeypatch.setattr("aisbf.providers.base.DatabaseRegistry", BrokenRegistry)
assert handler._native_cache_user_allows("kilo-model") is True
def test_native_cache_user_allows_respects_disabled_setting(monkeypatch):
handler = KiloProviderHandler(provider_id="test_kilo", api_key=None, user_id=5)
class DbStub:
def get_user_cache_settings(self, user_id, provider_id, model_name):
return {"cache_enabled": False}
class RegistryStub:
@staticmethod
def get_config_database():
return DbStub()
monkeypatch.setattr("aisbf.providers.base.DatabaseRegistry", RegistryStub)
assert handler._native_cache_user_allows("kilo-model") is False
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment