Many little updates

parent d1787cd5
......@@ -108,6 +108,7 @@ Configuration management with smart file location detection:
- Creates `~/.aisbf/` directory on first run
- Copies default configs to user directory
- 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
Pydantic models for data validation:
......@@ -143,6 +144,13 @@ Request handling logic:
- `AutoselectHandler` - Handles AI-powered model selection
- Reuses shared Studio capability inference for model metadata normalization
- 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
Shared Studio integration helpers:
......@@ -226,6 +234,8 @@ Installs to:
**User Configuration Files (writable):**
- `~/.aisbf/providers.json` - Provider configurations
- `~/.aisbf/rotations.json` - Rotation configurations
- `~/.aisbf/autoselect.json` - Autoselect configurations
- `~/.aisbf/aisbf.json` - Global AISBF settings, including `feature_controls`
**Development Mode:**
- `config/providers.json` and `config/rotations.json` in source tree
......@@ -236,6 +246,27 @@ Installs to:
3. Copies default configs from installed location to `~/.aisbf/`
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
The installed `aisbf` script supports:
......
This diff is collapsed.
# 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:
## Key Features
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro, Kilocode, Codex, and Qwen
- **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
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro, Kilocode, Codex, Qwen, CoderAI, and RunPod
- **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
- **Intelligent Routing**: Weighted load balancing and AI-assisted model selection
- **Streaming Support**: Full support for streaming responses from all providers
- **Web Dashboard**: Complete configuration and management interface
- **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
- **Provider-Native Caching**: 50-70% cost reduction with Anthropic, Google, and OpenAI caching
- **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
- **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
### Installation
......@@ -64,10 +76,12 @@ Access the dashboard at `http://localhost:17765/dashboard` (default credentials:
The dashboard provides:
- Provider configuration and API key management
- RunPod runtime controls and CoderAI broker session monitoring
- Rotation and autoselect model setup
- AISBF Studio multimodal workflows and pipeline bindings
- User wallet management and top-up options
- Token usage analytics and cost tracking
- Real-time monitoring and rate limit management
- Token usage analytics, broker telemetry, and cost tracking
- Marketplace publishing, importing, and administration
- SSL/TLS and TOR configuration
- Multi-user administration
......@@ -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
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
- **[🔧 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
- **[🎛️ 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
- **[🔌 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
......
......@@ -2,7 +2,5 @@
## Planned Features
- [ ] Add support for mempalace
- [ ] Add support for caveman mode
- [ ] Integrate github larsderidder context-lens project
- [ ] Integrate https://github.com/PromptSail/prompt_sail
......@@ -371,7 +371,7 @@ start_server() {
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
echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true
......@@ -388,6 +388,8 @@ except Exception as e:
traceback.print_exc()
exit(1)
" 2>&1
else
unset AISBF_DEBUG
fi
# Signal to the aisbf package that it is running as a server
......@@ -426,10 +428,12 @@ start_daemon() {
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
echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true
else
unset AISBF_DEBUG
fi
# Start in background with nohup and logging
......
......@@ -371,11 +371,10 @@ start_server() {
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
echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true
fi
# Test importing main module before starting uvicorn
echo "=== DEBUG: Testing main module import ==="
......@@ -389,6 +388,9 @@ except Exception as e:
traceback.print_exc()
exit(1)
" 2>&1
else
unset AISBF_DEBUG
fi
# Start the proxy server - runs in foreground
# Use exec to replace the shell process so signals are properly handled
......@@ -423,10 +425,12 @@ start_daemon() {
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
echo "Debug mode enabled - showing all debug messages"
export AISBF_DEBUG=true
else
unset AISBF_DEBUG
fi
# Start in background with nohup and logging
......
......@@ -13,6 +13,50 @@ from starlette.middleware.base import BaseHTTPMiddleware
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
# ---------------------------------------------------------------------------
......@@ -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"]:
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)):
return await call_next(request)
......@@ -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"]:
return await call_next(request)
if _is_coderai_registration_route(request.url.path):
return await call_next(request)
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return JSONResponse(status_code=401, content={"error": "Missing or invalid Authorization header."})
......@@ -355,7 +405,9 @@ def make_dashboard_context_middleware():
request.state.welcome_shown = request.session.get('welcome_shown', False)
else:
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
......
......@@ -130,6 +130,14 @@ def setup_logging():
root_logger.addHandler(error_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()
for h in (file_handler, error_handler, console_handler):
h.addFilter(bpf)
......
......@@ -1295,6 +1295,9 @@ class ResponseCache:
self.sqlite_backend = None
self.mysql_backend = None
self.memory_cache = {}
self._memory_cache = {}
self._memory_timestamps = {}
self._memory_access_order = []
if not self.enabled:
logger.info("Response caching is disabled")
......@@ -1342,10 +1345,6 @@ class ResponseCache:
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)")
def _generate_cache_key(self, request_data: Dict) -> str:
......
......@@ -69,11 +69,19 @@ 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]]]:
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:
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_username(username) if db else None
if user:
if not user:
return None, None
requested_user_id = user.get('id')
for user_id, provider_config in user_matches:
if user_id == requested_user_id:
......@@ -86,7 +94,7 @@ def resolve_coderai_provider_owner(provider_id: str, username: Optional[str] = N
if user_matches:
if len(user_matches) == 1:
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
global_config = _load_global_provider_config(provider_id)
......
This diff is collapsed.
......@@ -1064,7 +1064,9 @@ class ContextManager:
def get_context_config_for_model(
model_name: str,
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:
"""
Get context configuration for a specific model with cascading fallback.
......@@ -1087,7 +1089,14 @@ def get_context_config_for_model(
context_config = {
'context_size': None,
'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
......@@ -1171,6 +1180,10 @@ def get_context_config_for_model(
if context_config.get('context_size') is None:
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
......
This diff is collapsed.
......@@ -94,6 +94,12 @@ class Provider(BaseModel):
free_tier_limit_type: Optional[str] = None
premium_reference_monthly_cost: Optional[float] = 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):
failures: int
......
......@@ -427,6 +427,14 @@ class PayPalPaymentHandler:
return
await self._credit_wallet_for_paypal(user_id, amount, order_id,
'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):
"""Handle approved order — record pending capture state."""
......@@ -465,6 +473,14 @@ class PayPalPaymentHandler:
if user_id > 0 and amount > 0:
await self._credit_wallet_for_paypal(user_id, amount, capture_id,
'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:
logger.warning(f"PayPal capture completed but missing user_id/amount: {capture_id}")
......
......@@ -266,6 +266,14 @@ class StripePaymentHandler:
'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']}")
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
class AnthropicProviderHandler(BaseProviderHandler):
def __init__(self, provider_id: str, api_key: str):
super().__init__(provider_id, api_key)
def __init__(self, provider_id: str, api_key: str, user_id=None, provider_config=None):
super().__init__(provider_id, api_key, user_id=user_id)
self.provider_config = provider_config
self.client = Anthropic(api_key=api_key)
def validate_credentials(self) -> bool:
......@@ -70,6 +71,10 @@ class AnthropicProviderHandler(BaseProviderHandler):
enable_native_caching = getattr(provider_config, 'enable_native_caching', False)
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}")
if enable_native_caching:
logging.info(f"AnthropicProviderHandler: Min cacheable tokens: {min_cacheable_tokens}")
......
......@@ -917,6 +917,17 @@ class BaseProviderHandler:
logger.info(f"[{self.provider_id}] API key present, validation passed")
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]:
"""
Parse 429 rate limit response to extract wait time in seconds.
......
......@@ -1270,17 +1270,10 @@ class ClaudeProviderHandler(BaseProviderHandler):
# Check user's cache settings (overrides provider config)
if user_id and cache_config['enabled']:
try:
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
user_setting = db.get_user_cache_settings(user_id, provider_id, model_name)
if not user_setting['cache_enabled']:
if not self._native_cache_user_allows(model_name):
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
logging.getLogger(__name__).warning(f"Error checking user cache settings: {e}")
return cache_config
......
......@@ -30,8 +30,9 @@ from .base import BaseProviderHandler, AISBF_DEBUG
class GoogleProviderHandler(BaseProviderHandler):
def __init__(self, provider_id: str, api_key: str):
super().__init__(provider_id, api_key)
def __init__(self, provider_id: str, api_key: str, user_id=None, provider_config=None):
super().__init__(provider_id, api_key, user_id=user_id)
self.provider_config = provider_config
# Initialize google-genai library
from google import genai
self.client = genai.Client(api_key=api_key)
......@@ -83,6 +84,10 @@ class GoogleProviderHandler(BaseProviderHandler):
cache_ttl = getattr(provider_config, 'cache_ttl', None)
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}")
# Initialize cached_content_name for this request (will be set if we use caching)
......
......@@ -310,15 +310,9 @@ class KiloProviderHandler(BaseProviderHandler):
# Check user's cache settings (overrides provider config)
if enable_native_caching and hasattr(self, 'user_id'):
try:
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
user_setting = db.get_user_cache_settings(self.user_id, self.provider_id, model)
if not user_setting['cache_enabled']:
if 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}")
except Exception as e:
logging.warning(f"Error checking user cache settings: {e}")
logging.info(f"KiloProviderHandler: Native caching enabled: {enable_native_caching}")
if enable_native_caching:
......
......@@ -78,15 +78,9 @@ class OpenAIProviderHandler(BaseProviderHandler):
# Check user's cache settings (overrides provider config)
if enable_native_caching and hasattr(self, 'user_id'):
try:
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
user_setting = db.get_user_cache_settings(self.user_id, self.provider_id, model)
if not user_setting['cache_enabled']:
if 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}")
except Exception as e:
logging.warning(f"Error checking user cache settings: {e}")
logging.info(f"OpenAIProviderHandler: Native caching enabled: {enable_native_caching}")
if enable_native_caching:
......
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import time
import asyncio
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, Body
......@@ -50,13 +51,20 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
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})
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:
payload = _coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope)
payload["session_id"] = session.session_id
await websocket.send_text(json.dumps(payload))
queue_task = asyncio.create_task(_drain_broker_queue())
while True:
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
raw = await websocket.receive_text()
message = json.loads(raw)
op = message.get("op")
if op == "register":
......@@ -108,17 +116,15 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
continue
await broker.touch(session.session_id)
await broker.publish_response(message)
except asyncio.TimeoutError:
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})
continue
except WebSocketDisconnect:
logger.info(f"CoderAI broker disconnected provider={provider_id} client={client_id}")
except Exception as e:
logger.error(f"CoderAI broker websocket error: {e}", exc_info=True)
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.fail_session_requests(session.session_id, f"CoderAI session '{session.session_id}' disconnected")
......
......@@ -2368,6 +2368,14 @@ async def dashboard_wallet_topup(request: Request):
import traceback as _tb
logger.error(f"Crypto address generation error: {e!r}\n{_tb.format_exc()}")
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({
"type": "crypto",
"method": method,
......@@ -2380,6 +2388,14 @@ async def dashboard_wallet_topup(request: Request):
# Stripe: create checkout session (hosted redirect flow)
if method == 'stripe':
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)
if not payment_service or not hasattr(payment_service, 'stripe_handler'):
return JSONResponse({"error": "Stripe payment service unavailable"}, status_code=503)
......@@ -2401,6 +2417,14 @@ async def dashboard_wallet_topup(request: Request):
# PayPal: create order
if method == 'paypal':
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)
if not payment_service or not hasattr(payment_service, 'paypal_handler'):
return JSONResponse({"error": "PayPal payment service unavailable"}, status_code=503)
......
......@@ -582,6 +582,18 @@ async def api_publish_market_listing(request: Request):
return JSONResponse({'error': 'Unsupported source_type'}, status_code=400)
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})
......@@ -627,6 +639,15 @@ async def api_import_market_listing(request: Request, listing_id: int):
source_id=source_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})
......
This diff is collapsed.
......@@ -20,6 +20,42 @@ _templates = None
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:
"""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}
......@@ -374,6 +410,17 @@ async def dashboard_settings_save(
classify_nsfw: bool = Form(False),
classify_privacy: bool = Form(False),
classify_semantic: 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_max_batch_size: int = Form(8),
......@@ -647,6 +694,21 @@ async def dashboard_settings_save(
aisbf_config['classify_privacy'] = classify_privacy
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
if 'internal_model' not in aisbf_config:
aisbf_config['internal_model'] = {}
......@@ -706,8 +768,11 @@ async def dashboard_settings_save(
json.dump(aisbf_config, f, indent=2)
# Reload dashboard credentials in memory so the new username/password takes effect immediately
if server_config is not None:
server_config['dashboard_config'] = aisbf_config.get('dashboard', {})
if _config is not None and hasattr(_config, 'aisbf'):
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
_reload_global_config()
......@@ -853,6 +918,7 @@ async def dashboard_users_add(request: Request, username: str = Form(...), passw
admin_username = request.session.get('username', 'admin')
# Create user with display_name defaulting to 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)
except Exception as e:
users = db.get_users()
......@@ -998,6 +1064,7 @@ async def dashboard_users_update_tier(request: Request, user_id: int):
success = db.set_user_tier(user_id, tier_id)
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})
else:
return JSONResponse({"success": False, "error": "Failed to update user tier"}, status_code=500)
......
......@@ -79,6 +79,41 @@
"mysql_password": "",
"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": {
"enabled": false,
"control_port": 9051,
......
......@@ -89,6 +89,22 @@ The outbound WebSocket connection must include:
- `username`: either `global` or the AISBF username for user-owned providers
- `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
AISBF also accepts or may expect these headers:
......@@ -109,6 +125,35 @@ Recommended behavior:
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
AISBF immediately sends a registration acknowledgment event on successful admission.
......@@ -135,11 +180,21 @@ Store:
- `client_id`
- `username`
- `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
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
Then keep listening for incoming broker requests from AISBF.
......@@ -233,6 +288,60 @@ CoderAI should send this after receiving the initial AISBF `registered` event.
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
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
}
```
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.
## Local HTTP Endpoints CoderAI Should Expose
......
......@@ -200,6 +200,20 @@ When CoderAI dials AISBF broker directly, it should connect using:
- `provider_id=<provider_id>`
- `client_id=<client_id>`
- `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:
......@@ -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>
```
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
AISBF sends one JSON request envelope per operation:
......@@ -314,6 +392,17 @@ Important:
- 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.
## 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
AISBF now tracks two broker states:
......
......@@ -233,6 +233,7 @@ setup(
'aisbf/routes/dashboard/providers.py',
'aisbf/routes/dashboard/settings.py',
'aisbf/routes/dashboard/admin.py',
'aisbf/routes/dashboard/market.py',
'aisbf/routes/dashboard/payments.py',
'aisbf/routes/dashboard/provider_auth.py',
]),
......@@ -299,6 +300,7 @@ setup(
'templates/dashboard/admin_tier_form.html',
'templates/dashboard/admin_tiers.html',
'templates/dashboard/analytics.html',
'templates/dashboard/admin_market.html',
'templates/dashboard/autoselect.html',
'templates/dashboard/billing.html',
'templates/dashboard/cache_settings.html',
......@@ -312,6 +314,7 @@ setup(
'templates/dashboard/profile.html',
'templates/dashboard/prompts.html',
'templates/dashboard/providers.html',
'templates/dashboard/prompt_analytics.html',
'templates/dashboard/pricing.html',
'templates/dashboard/paypal_connect.html',
'templates/dashboard/provider_quotas.html',
......@@ -325,6 +328,7 @@ setup(
'templates/dashboard/signup.html',
'templates/dashboard/studio.html',
'templates/dashboard/subscription.html',
'templates/dashboard/traffic_visits.html',
'templates/dashboard/usage.html',
'templates/dashboard/user_autoselects.html',
'templates/dashboard/user_index.html',
......
......@@ -1093,7 +1093,7 @@
"tab_models": "Models",
"tab_database": "Database",
"tab_cache": "Cache",
"tab_classification": "Classification",
"tab_classification": "Features",
"tab_tor": "TOR",
"tab_signup": "Signup",
"tab_oauth2": "OAuth2",
......@@ -1107,7 +1107,7 @@
"section_models": "Internal Models",
"section_database": "Database Configuration",
"section_cache": "Cache Configuration",
"section_classification": "Classification",
"section_classification": "Features & Prompt Analysis",
"section_tor": "TOR Hidden Service",
"section_signup": "Signup Settings",
"section_oauth2": "OAuth2 Authentication",
......
......@@ -407,20 +407,6 @@ function renderAutoselectDetails(autoselectKey) {
${partialCapsHtml}
</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">
<label>
<input type="checkbox" ${autoselect.classify_nsfw ? 'checked' : ''} onchange="updateAutoselect('${autoselectKey}', 'classify_nsfw', this.checked)">
......@@ -445,6 +431,16 @@ function renderAutoselectDetails(autoselectKey) {
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small>
</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">
<label>${window.i18n.t('autoselect.description')}</label>
<textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea>
......@@ -570,19 +566,6 @@ function renderAutoselectModels(autoselectKey) {
<small style="color: var(--color-subtle); font-size: 12px;">${window.i18n.t('autoselect.model_description_hint')}</small>
</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);
......@@ -596,6 +579,30 @@ async function apiCall(method, url, body) {
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) {
const statusEl = document.getElementById(`asel-save-status-${key}`);
// Validate: no available_model may have an empty model_id
......
{% extends "base.html" %}
{% block title %}Prompt Cache Settings{% endblock %}
{% block title %}Native Prompt Cache Settings{% endblock %}
{% block content %}
<div class="container-fluid" style="padding: 20px;">
<div style="max-width: 1400px; margin: 0 auto;">
<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>
<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 -->
<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;">
<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>
<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>
<h5 style="margin: 0 0 5px 0; color: var(--color-text);">Enable 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>
<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 or disable provider-native prompt caching overrides for all your providers and models.</p>
</div>
</div>
</div>
......@@ -29,7 +34,7 @@
<i class="fas fa-info-circle me-2"></i>Cache Override Hierarchy
</h5>
<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>2. Provider-level setting</strong> → Overrides user default
<br><strong>3. User default</strong> → Applied if no specific setting exists
......
This diff is collapsed.
This diff is collapsed.
......@@ -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;">
📊 <span data-i18n="rate_limits_page.analytics">Analytics</span>
</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;">
💾 <span data-i18n="rate_limits_page.response_cache">Response Cache</span>
</a>
......
......@@ -11,6 +11,14 @@
<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>
{% 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;">
💾 Response Cache
</a>
......
......@@ -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')}">
</div>
<div class="form-group">
<label>
<input type="checkbox" ${rotation.nsfw ? 'checked' : ''} onchange="updateRotation('${rotationKey}', 'nsfw', this.checked)">
${window.i18n.t('rotations.nsfw')}
</label>
</div>
<div class="form-group">
<label>
<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;">Feature Overrides</h4>
${triStateCompact('Context Condensation', rotation.enable_context_condensation, `updateRotationTriState('${rotationKey}', 'enable_context_condensation', this.value)`)}
${triStateCompact('Response Cache', rotation.enable_response_cache, `updateRotationTriState('${rotationKey}', 'enable_response_cache', this.value)`)}
${triStateCompact('Prompt Batching', rotation.enable_prompt_batching, `updateRotationTriState('${rotationKey}', 'enable_prompt_batching', this.value)`)}
${triStateCompact('Prompt Security', rotation.enable_prompt_security, `updateRotationTriState('${rotationKey}', 'enable_prompt_security', this.value)`)}
${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)`)}
${triStateCompact('NSFW Classification', rotation.enable_nsfw_classification, `updateRotationTriState('${rotationKey}', 'enable_nsfw_classification', this.value)`)}
${triStateCompact('Privacy Classification', rotation.enable_privacy_classification, `updateRotationTriState('${rotationKey}', 'enable_privacy_classification', this.value)`)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('rotations.providers_section')}</h4>
<div id="providers-${rotationKey}"></div>
......@@ -537,6 +533,17 @@ function renderRotationModels(rotationKey, providerIndex) {
<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;">
</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);
......@@ -554,6 +561,33 @@ async function apiCall(method, url, body) {
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) {
const statusEl = document.getElementById(`rot-save-status-${key}`);
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/>.
.settings-section { display: none; }
.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); }
.tri-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; }
</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;">
<h2 style="margin:0;">Server Settings</h2>
</div>
......@@ -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('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('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('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>
......@@ -496,7 +509,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div><!-- /tab-cache -->
<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">
<label>
......@@ -521,6 +535,39 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</label>
<small style="color: var(--color-subtle); display: block; margin-top: 5px;">Enable semantic content classification using the configured model</small>
</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 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 %}
......@@ -468,6 +468,16 @@ function renderAutoselectDetails(autoselectKey) {
<small style="color: var(--color-muted); font-size: 12px; display: block; margin-top: 5px;">${window.i18n.t('autoselect.classify_semantic_desc')}</small>
</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">
<label>${window.i18n.t('autoselect.description')}</label>
<textarea onchange="updateAutoselect('${autoselectKey}', 'description', this.value)" style="min-height: 60px;">${autoselect.description || ''}</textarea>
......@@ -605,12 +615,52 @@ function renderAutoselectModels(autoselectKey) {
${window.i18n.t('autoselect.privacy')}
</label>
</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);
});
}
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) {
const statusEl = document.getElementById(`asel-save-status-${key}`);
if (statusEl) statusEl.textContent = window.i18n.t('autoselect.saving');
......
This diff is collapsed.
......@@ -413,6 +413,16 @@ function renderRotationDetails(rotationKey) {
</label>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
${triStateCompact('Context Condensation', rotation.enable_context_condensation, `updateRotationTriState('${rotationKey}', 'enable_context_condensation', this.value)`)}
${triStateCompact('Response Cache', rotation.enable_response_cache, `updateRotationTriState('${rotationKey}', 'enable_response_cache', this.value)`)}
${triStateCompact('Prompt Batching', rotation.enable_prompt_batching, `updateRotationTriState('${rotationKey}', 'enable_prompt_batching', this.value)`)}
${triStateCompact('Prompt Security', rotation.enable_prompt_security, `updateRotationTriState('${rotationKey}', 'enable_prompt_security', this.value)`)}
${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)`)}
${triStateCompact('NSFW Classification', rotation.enable_nsfw_classification, `updateRotationTriState('${rotationKey}', 'enable_nsfw_classification', this.value)`)}
${triStateCompact('Privacy Classification', rotation.enable_privacy_classification, `updateRotationTriState('${rotationKey}', 'enable_privacy_classification', this.value)`)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('rotations.providers_section')}</h4>
<div id="providers-${rotationKey}"></div>
<button type="button" class="btn btn-secondary" onclick="addRotationProvider('${safeRKey}')" style="margin-top: 10px;">${window.i18n.t('rotations.add_provider')}</button>
......@@ -546,6 +556,17 @@ function renderRotationModels(rotationKey, providerIndex) {
<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;">
</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);
......@@ -559,6 +580,33 @@ async function apiCall(method, url, body) {
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) {
const statusEl = document.getElementById(`rot-save-status-${key}`);
if (statusEl) statusEl.textContent = window.i18n.t('rotations.saving');
......
......@@ -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()
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
assert result["verification_url"] == "https://kilo.ai/device"
mock_oauth2_expired.get_valid_token_with_refresh.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