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:
......
......@@ -2,18 +2,20 @@
## Overview
AISBF is a modular proxy server for managing multiple AI provider integrations. It provides a unified API interface for interacting with various AI services (Google, OpenAI, Anthropic, Claude Code, Ollama, Kiro, Kilocode, Codex, Qwen) with support for provider rotation, AI-assisted model selection, unified wallet system, and error tracking.
AISBF is a modular proxy server for managing multiple AI provider integrations. It provides a unified API interface for interacting with various AI services (Google, OpenAI, Anthropic, Claude Code, Ollama, Kiro, Kilocode, Codex, Qwen, CoderAI, RunPod) with support for provider rotation, AI-assisted model selection, marketplace exports, multimodal Studio workflows, unified wallet operations, and operational analytics.
### Key Features
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro (Amazon Q Developer), Kilocode (OAuth2), Codex (OAuth2), and Qwen (API Key/OAuth2)
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro (Amazon Q Developer), Kilocode (OAuth2), Codex (OAuth2), Qwen (API Key/OAuth2), CoderAI, and RunPod
- **Unified Wallet System**: Fiat wallet with crypto/PayPal/Stripe top-ups and auto top-up for subscription renewals
- **Claude OAuth2 Authentication**: Full OAuth2 PKCE flow for Claude Code with automatic token refresh, Chrome extension for remote servers, and curl_cffi TLS fingerprinting support
- **Claude CLI Mode**: When the `claude` binary is present in PATH at startup, AISBF automatically enables CLI proxy mode — requests are piped through the official Anthropic CLI (`claude -p`) instead of the HTTP API, using per-user isolated config directories with a 10-minute idle cleanup
- **Claude CLI Mode**: When the `claude` binary is present in PATH at startup, AISBF automatically enables CLI proxy mode and uses per-user isolated config directories with automatic idle cleanup
- **Kiro-cli Support**: Full support for Amazon Q Developer CLI authentication with Device Authorization Grant
- **Kilocode OAuth2 Authentication**: OAuth2 Device Authorization Grant for Kilo Code with automatic token refresh
- **Codex OAuth2 Authentication**: OAuth2 Device Authorization Grant for OpenAI Codex with automatic token refresh and API key exchange
- **Qwen Authentication**: API key authentication (recommended) or OAuth2 (discontinued as of April 2026)
- **CoderAI Broker & Bridge**: Broker-backed and direct CoderAI integrations with WebSocket transport, NAT-friendly outbound registration, session persistence, runtime telemetry, and Studio-native proxy forwarding
- **RunPod Runtime Integration**: Pod-backed, serverless-template, and public-catalog RunPod providers with lifecycle management, persistent runtime state, startup polling, idle shutdown, and protocol-aware delegation to OpenAI, Ollama, or CoderAI handlers
- **Rotation Models**: Intelligent load balancing across multiple providers with weighted model selection and automatic failover
- **Autoselect Models**: AI-powered model selection that analyzes request content to route to the most appropriate specialized model
- **Semantic Classification**: Fast hybrid BM25 + semantic model selection using sentence transformers (optional)
......@@ -24,16 +26,62 @@ AISBF is a modular proxy server for managing multiple AI provider integrations.
- **Provider-Native Caching**: 50-70% cost reduction using Anthropic `cache_control`, Google Context Caching, and OpenAI-compatible APIs
- **Response Caching**: 20-30% cache hit rate with intelligent request deduplication using SHA256-based cache keys
- **Smart Request Batching**: 15-25% latency reduction by batching similar requests within 100ms window
- **Token Usage Analytics**: Comprehensive analytics dashboard with token usage tracking, cost estimation, performance metrics, and export functionality
- **Token Usage Analytics**: Comprehensive analytics dashboard with token usage tracking, cost estimation, performance metrics, broker telemetry, and export functionality
- **Marketplace & References**: Publish providers, models, rotations, and autoselects to a shared market, import them as locked references, settle usage revenue, and administer listings from dedicated dashboard pages
- **AISBF Studio**: Multimodal dashboard workspace with function bindings for chat, image, video, audio, embeddings, and 3D workflows, plus reusable characters, environments, voices, archive assets, and custom pipelines
- **Context Management**: Automatic context condensation with 8+ methods when approaching model limits
- **SSL/TLS Support**: Built-in HTTPS support with Let's Encrypt integration and automatic certificate renewal
- **TOR Hidden Service**: Full support for exposing AISBF over TOR network as a hidden service
- **MCP Server**: Model Context Protocol server for remote agent configuration and model access
- **Persistent Database**: SQLite/MySQL-based tracking of token usage, context dimensions, model embeddings, and user configurations
- **Persistent Database**: SQLite/MySQL-based tracking of token usage, context dimensions, market data, RunPod runtime state, broker sessions, and user configurations
- **Multi-User Support**: User management with isolated configurations, role-based access control, and API token management
- **User-Specific API Endpoints**: Dedicated API endpoints for authenticated users to access their own configurations with Bearer token authentication
- **Proxy-Awareness**: Full support for reverse proxy deployments with automatic URL generation and subpath support
## New Since 0.99.65
### CoderAI broker and telemetry
- Added broker-side WebSocket endpoints for global and user-scoped CoderAI sessions
- Added provider-scoped registration tokens with inline rotation in the provider editor
- Persisted broker session metadata to `~/.aisbf/coderai_broker_sessions.json` so dashboards keep the last known status after restart
- Added dashboard visibility into session owner, client ID, connection state, transport, advertised Studio endpoints, and estimated performance metrics such as latency and tokens-per-second
- Expanded bridge proxy support so Studio-native endpoints, long-running jobs, multipart payloads, progress polling, and non-chat streaming envelopes can transit the broker connection
### RunPod provider runtime management
- Added `runpod` provider type with validation and dashboard configuration support
- Added `pod`, `serverless_template`, and `public` RunPod operating modes
- Added wrapper delegation so non-public RunPod resources can expose OpenAI-compatible, Ollama, or CoderAI-backed runtimes through one AISBF provider
- Added persistent RunPod runtime state in the database, including endpoint URL, resource kind, public catalog metadata, and last-used timestamps
- Added startup polling, timeout controls, idle shutdown, and runtime refresh actions in the provider dashboard
- Added public catalog support with per-model protocol override for `runpod_public`, `openai`, `ollama`, or `coderai`
### Marketplace and reference imports
- Added a dedicated market administration page for filtering, reviewing, and paging through listings
- Added marketplace publishing and settlement logic so providers, models, rotations, and autoselects can be shared and monetized
- Added listing analytics snapshots, revenue tracking, usage counters, and market fee support
- Added market import references so users can import shared resources as locked read-only references inside their provider, rotation, and autoselect selectors
- Added runtime resolution of market references plus improved dashboard rendering for locked resources and import variants
- Added user filtering and improved relevance ordering for market discovery and admin search
### AISBF Studio expansion
- Added Studio dashboard shell with multimodal tabs for chat, image, video, audio, embeddings, profiles, pipelines, archive, and 3D workflows
- Added Studio function-binding APIs for admin and user scopes so each workflow can be bound to the right provider/model role
- Added file-backed and database-backed persistence for Studio characters, environments, voices, archive items, custom pipelines, and binding definitions
- Added custom pipeline creation, update, delete, and run endpoints in both admin and user scopes
- Added capability-aware Studio adapter inference and profile shaping so different providers receive the payload format they expect for multimodal endpoints
- Added support for progress endpoints, reusable thumbnails, and user-owned Studio assets
### Operational hardening and dashboard fixes
- Added automatic cleanup of stale self-registered accounts that never log in within 14 days
- Reused cached provider model data before forced auto-detect refreshes to reduce unnecessary upstream calls
- Normalized remaining dashboard proxy-aware paths and bootstrap URLs to improve reverse-proxy compatibility
- Fixed analytics, dashboard state restoration, helper regressions, and market rendering issues introduced during the 0.99.65 to 0.99.66 cycle
## Project Structure
```
......@@ -42,45 +90,36 @@ aisbf/
│ ├── __init__.py # Module initialization with exports
│ ├── config.py # Configuration management
│ ├── models.py # Pydantic models
│ ├── providers.py # Provider handlers
│ ├── handlers.py # Request handlers
│ ├── payments/ # Payment system
│ ├── analytics.py # Analytics and telemetry
│ ├── coderai_broker.py # CoderAI broker session registry and transport
│ ├── studio.py # Studio catalog helpers
│ ├── studio_adapters.py # Studio adapter inference and payload shaping
│ ├── studio_services.py # Studio persistence and runtime services
│ ├── providers/ # Provider handlers
│ │ ├── __init__.py
│ │ ├── models.py # Payment models
│ │ ├── service.py # Payment service
│ │ ├── fiat/ # Fiat payment handlers
│ │ │ ├── __init__.py
│ │ │ ├── stripe_handler.py # Stripe integration
│ │ │ └── paypal_handler.py # PayPal integration
│ │ ├── crypto/ # Crypto payment handlers
│ │ │ ├── __init__.py
│ │ │ ├── wallet.py # HD wallet manager
│ │ │ └── monitor.py # Crypto payment monitor
│ │ ├── subscription/ # Subscription management
│ │ │ ├── __init__.py
│ │ │ ├── manager.py # Subscription lifecycle
│ │ │ ├── renewal.py # Renewal processing
│ │ │ └── quota.py # Quota management
│ │ └── wallet/ # Wallet system (NEW)
│ │ ├── __init__.py
│ │ └── manager.py # Wallet operations
│ ├── notifications/ # Notification system
│ ├── __init__.py
│ │ ├── base.py # Shared provider utilities
│ │ ├── claude.py
│ │ ├── coderai.py # CoderAI direct and bridge integration
│ │ ├── codex.py
│ │ ├── google.py
│ │ ├── ollama.py
│ │ ├── openai.py
│ │ ├── qwen.py
│ │ ├── runpod.py # RunPod runtime lifecycle and delegation
│ │ └── kiro/
│ ├── auth/ # Authentication modules
│ ├── routes/ # FastAPI routes
│ │ ├── coderai_broker.py # Broker transport endpoints
│ │ ├── dashboard/ # Dashboard views and APIs
│ │ └── user_api.py # User-scoped API routes
│ ├── payments/ # Payment system
│ └── [other modules...]
├── config/ # Configuration files
│ ├── providers.json # Default provider configs
│ ├── rotations.json # Default rotation configs
│ ├── autoselect.json # Default autoselect configs
│ └── aisbf.json # Main configuration
├── templates/ # HTML templates
│ └── dashboard/ # Dashboard templates
├── docs/ # Documentation and integration references
├── static/ # Static assets (CSS, JS, images)
├── docs/ # Documentation
├── templates/ # HTML templates
├── main.py # FastAPI application entry point
├── cli.py # Command-line interface
├── setup.py # Installation script
├── pyproject.toml # Modern packaging configuration
├── requirements.txt # Python dependencies
├── README.md # Project overview
└── DOCUMENTATION.md # Comprehensive documentation (this file)
```
......@@ -129,6 +168,10 @@ Creates local virtual environment and installs in development mode with auto-rel
- `~/.aisbf/rotations.json` - Rotation configurations
- `~/.aisbf/autoselect.json` - Autoselect configurations
- `~/.aisbf/aisbf.json` - Main server configuration
- `~/.aisbf/coderai_broker_sessions.json` - Persisted broker session snapshots
- `~/.aisbf/studio/` - File-backed admin Studio assets
- `~/.aisbf/pipelines.json` - Admin custom pipeline definitions
- `~/.aisbf/studio_bindings.json` - Admin Studio function bindings
**Development Mode:**
- `config/providers.json` and `config/rotations.json` in source tree
......@@ -139,7 +182,7 @@ Creates local virtual environment and installs in development mode with auto-rel
3. Copies default configs from installed location to `~/.aisbf/`
4. Loads configuration from `~/.aisbf/` on subsequent runs
### Main Configuration (aisbf.json)
### Main Configuration (`aisbf.json`)
```json
{
"host": "127.0.0.1",
......@@ -175,615 +218,185 @@ Creates local virtual environment and installs in development mode with auto-rel
}
```
## Wallet System
AISBF includes a comprehensive unified wallet system that manages user fiat balances for subscription payments and provides multiple top-up methods.
### Features
- **Unified Fiat Wallet**: Single wallet per user in system-configured currency
- **Multiple Top-Up Methods**: Crypto (BTC, ETH, USDT, USDC), PayPal, Stripe (credit cards)
- **Auto Top-Up**: Automatic balance replenishment when subscription renewal fails
- **Transaction History**: Complete audit trail of all wallet operations
- **Subscription Integration**: Automatic deduction for subscription renewals
- **Configurable Amounts**: Fixed preset amounts (10, 15, 20, 50, 100) plus custom amounts
### Wallet Balance Management
Each user has a single fiat wallet with the following properties:
- **Balance**: Current fiat balance in system currency
- **Currency**: Configurable system-wide currency (default: USD)
- **Auto Top-Up**: Optional automatic top-up when balance falls below threshold
- **Transaction History**: Complete log of all credits and debits
### Top-Up Methods
#### 1. Cryptocurrency Top-Up
- **Supported Currencies**: BTC, ETH, USDT (ERC20), USDC (ERC20)
- **Process**: Generate unique address → User sends crypto → Automatic balance credit
- **Confirmation**: Requires blockchain confirmations (6 for BTC, 12 for ETH/ERC20)
#### 2. PayPal Top-Up
- **Integration**: PayPal Checkout Orders API
- **Amounts**: Fixed presets or custom amounts
- **Process**: Create payment link → User completes PayPal payment → Automatic balance credit
#### 3. Stripe Top-Up (Credit Cards)
- **Integration**: Stripe PaymentIntents API
- **Amounts**: Fixed presets or custom amounts
- **Process**: Secure card payment → User completes payment → Automatic balance credit
### Auto Top-Up System
Auto top-up automatically replenishes wallet balance when needed for subscription renewals:
#### Configuration
```json
{
"wallet": {
"auto_topup_enabled": true,
"auto_topup_amount": 20.00,
"auto_topup_threshold": 5.00,
"auto_topup_payment_method_id": "stripe_card_123"
}
}
```
#### How It Works
1. **Threshold Monitoring**: System checks wallet balance before subscription renewal
2. **Auto Top-Up Trigger**: If balance < threshold, auto top-up is activated
3. **Payment Processing**: Uses stored Stripe payment method for automatic charge
4. **Balance Credit**: Wallet is topped up with configured amount
5. **Renewal Retry**: Subscription renewal proceeds with sufficient balance
### Subscription Renewal Flow
```
Subscription Renewal Process:
↳ Check Wallet Balance
↳ Sufficient? → Deduct Amount → Success ✅
↳ Insufficient?
↳ Auto Top-Up Enabled?
↳ Yes → Charge Stripe → Top Up Wallet → Retry Deduction → Success ✅
↳ No → Renewal Failed → Grace Period → Future Retry ❌
```
### Wallet API Endpoints
#### Get Wallet Balance
```http
GET /api/wallet/balance
Authorization: Bearer <token>
```
Response:
```json
{
"balance": 25.50,
"currency": "USD",
"auto_topup_enabled": true,
"auto_topup_threshold": 10.00
}
```
#### Top-Up Wallet
```http
POST /api/wallet/topup
Content-Type: application/json
Authorization: Bearer <token>
{
"amount": 20.00,
"payment_method": "stripe",
"custom_amount": false
}
```
#### Configure Auto Top-Up
```http
POST /api/wallet/auto-topup
Content-Type: application/json
Authorization: Bearer <token>
{
"enabled": true,
"amount": 15.00,
"threshold": 5.00,
"payment_method_id": "stripe_pm_123"
}
```
#### Transaction History
```http
GET /api/wallet/transactions?page=1&limit=20
Authorization: Bearer <token>
```
Response:
```json
{
"transactions": [
{
"id": 123,
"type": "credit",
"amount": 10.00,
"description": "Stripe top-up",
"created_at": "2026-04-21T10:30:00Z"
}
],
"total": 45,
"page": 1,
"pages": 3
}
```
### Dashboard Wallet Management
The dashboard provides complete wallet management:
- **Balance Display**: Current balance and recent transactions
- **Top-Up Options**: Quick top-up buttons for preset amounts
- **Custom Top-Up**: Enter custom amounts with payment method selection
- **Auto Top-Up Settings**: Configure automatic top-up preferences
- **Transaction History**: Paginated view of all wallet activity
- **Payment Method Management**: Add/remove payment methods for auto top-up
### Supported Top-Up Amounts
### Provider Highlights
- **Fixed Amounts**: $10, $15, $20, $50, $100
- **Custom Amounts**: $5 - $500 (configurable per payment method)
- **Currency**: System-configured currency (default: USD)
#### CoderAI provider
### Security Features
Use `type: "coderai"` for local bridges, direct HTTP/WebSocket transport, or broker-first deployments.
- **Atomic Transactions**: All wallet operations use database transactions
- **Audit Trail**: Complete transaction history with tamper-evident logging
- **Payment Method Security**: Secure storage of payment method tokens
- **Rate Limiting**: Protection against excessive top-up attempts
- **Fraud Prevention**: Monitoring for suspicious transaction patterns
Important `coderai_config` fields:
- `transport` - `http` or `websocket` for direct mode
- `broker_enabled` - allow broker registration and queue-based routing
- `broker_mode` - prefer inbound broker connectivity instead of direct calls
- `broker_preferred` - prefer the broker even when direct transport is configured
- `client_id` - stable client identity used for broker routing
- `registration_token` - required provider-scoped secret for broker admission
- `bridge_path` and `registration_path` - direct transport paths advertised to AISBF
### Integration with Subscriptions
Broker endpoints:
- `GET /api/coderai/broker/sessions`
- `GET /api/coderai/broker/providers/{provider_id}/status`
- `GET /api/coderai/wss`
- `GET /api/u/{username}/coderai/wss`
Wallet system integrates seamlessly with subscription management:
#### RunPod provider
- **Automatic Deduction**: Subscription renewals deduct from wallet first
- **Graceful Handling**: Failed renewals enter grace period with retry logic
- **Cost Tracking**: All subscription charges logged in transaction history
- **Multi-Currency**: Supports system-wide currency configuration
Use `type: "runpod"` for managed remote runtimes.
## API Endpoints
### Three Proxy Paths
AISBF provides three ways to proxy AI models:
#### PATH 1: Direct Provider Models
Format: `{provider_id}/{model_name}`
- Access specific models from configured providers
- Example: `openai/gpt-4`, `gemini/gemini-2.0-flash`, `anthropic/claude-3-5-sonnet-20241022`
Important `runpod_config` fields:
- `mode` - `pod`, `serverless_template`, or `public`
- `wrapper_mode` - `openai`, `ollama`, or `coderai` for non-public resources
- `pod_id`, `endpoint_id`, `template_id`, `serverless_template_id` - mode-specific resource identifiers
- `startup_poll_interval_ms` and `startup_timeout_ms` - runtime readiness controls
- `idle_shutdown_ms` - automatic stop window for inactive pods
- `public_endpoint_protocol_default` - protocol used when public catalog entries do not override it
- `public_models` - per-model protocol and capability overrides for public catalog entries
#### PATH 2: Rotations
Format: `rotation/{rotation_name}`
- Weighted load balancing across multiple providers
- Example: `rotation/coding`, `rotation/general`
Dashboard support includes runtime refresh, status inspection, public catalog import, and persisted state tracking.
#### PATH 3: Autoselect
Format: `autoselect/{autoselect_name}`
- AI-powered model selection based on content analysis
- Example: `autoselect/autoselect`
## AISBF Studio
### General Endpoints
- `GET /` - Server status and provider list
- `GET /api/models` - List all available models from all three proxy paths
- `GET /api/v1/models` - OpenAI-compatible model listing
AISBF Studio is the dashboard-native multimodal workspace exposed at `/dashboard/studio`.
### Chat Completions
### Studio capabilities
#### Unified Endpoints (Recommended)
These endpoints accept all three proxy path formats in the model field:
**OpenAI-Compatible Format:**
```bash
POST /api/v1/chat/completions
{
"model": "openai/gpt-4", # PATH 1: Direct provider
"messages": [...]
}
- Chat with bound text-generation models
- Image generation, editing, inpainting, upscaling, segmentation, deblurring, outfit change, and 2D/3D conversions
- Video generation, interpolation, subtitling, dubbing, upscaling, face swap, outfit change, and 3D-related transforms
- Audio generation, TTS, transcription, voice cloning, voice conversion, stem extraction, and cleanup
- Embeddings and model-binding aware workflow routing
- Reusable profile assets: characters, environments, voices
- Archive storage and custom pipelines for repeatable workflows
POST /api/v1/chat/completions
{
"model": "rotation/coding", # PATH 2: Rotation
"messages": [...]
}
### Studio persistence model
POST /api/v1/chat/completions
{
"model": "autoselect/autoselect", # PATH 3: Autoselect
"messages": [...]
}
```
- Admin/global Studio assets are stored under `~/.aisbf/studio/`
- Admin custom pipelines are stored in `~/.aisbf/pipelines.json`
- Admin function bindings are stored in `~/.aisbf/studio_bindings.json`
- User-owned assets and pipelines are stored in the database
- User-owned function bindings are stored in user prompt override state
**Alternative Format:**
```bash
POST /api/{provider_id}/chat/completions
# Where provider_id can be:
# - A provider name (e.g., "openai", "gemini")
# - A rotation name (e.g., "coding", "general")
# - An autoselect name (e.g., "autoselect")
```
### Studio APIs
### User-Specific API Endpoints
Examples:
- `GET /dashboard/api/studio/cached-models`
- `GET /dashboard/api/studio/function-bindings`
- `PUT /dashboard/api/studio/function-bindings/{binding_id}`
- `GET /dashboard/api/studio/pipelines/custom`
- `POST /dashboard/api/studio/pipelines/custom/{pipeline_id}/run`
- `GET /dashboard/api/studio/u/{username}/characters`
- `GET /dashboard/api/studio/u/{username}/audio/voices`
Authenticated users can access their own configurations via user-specific API endpoints. These endpoints require either a valid API token (generated in the user dashboard) or session authentication.
## Marketplace and References
#### Authentication
AISBF includes a built-in marketplace for sharing configured resources between users.
**Option 1: Bearer Token (Recommended for API access)**
```bash
Authorization: Bearer YOUR_API_TOKEN
```
### What can be published
**Option 2: Query Parameter**
```bash
?token=YOUR_API_TOKEN
```
- Providers
- Individual provider models
- Rotations
- Autoselect configurations
#### User API Endpoints
### Marketplace features
| Endpoint | Description |
|----------|-------------|
| `GET /api/u/{username}/models` | List available models from user's own configurations |
| `GET /api/u/{username}/providers` | List user's provider configurations |
| `GET /api/u/{username}/rotations` | List user's rotation configurations |
| `GET /api/u/{username}/autoselects` | List user's autoselect configurations |
| `POST /api/u/{username}/chat/completions` | Chat completions using user's own models |
| `GET /api/u/{username}/{config_type}/models` | List models for specific config type |
- Dedicated admin market page at `/dashboard/admin/market`
- Listing filters by search term, owner, source type, active status, and availability
- Pricing per million tokens and per 1,000 requests
- Revenue, request-count, vote, and analytics snapshots for each listing
- Settlement support for usage-based sharing
- User export controls and market visibility filtering
**Access Control:**
- **Global Tokens** (from `aisbf.json`): Access to global endpoints only (`/api/...`), no access to user-specific endpoints
- **User Tokens** (from dashboard): Access to their user-specific endpoints (`/api/u/username/...`) and global endpoints
### Imported references
#### Example: Using User API with cURL
Users can import market listings as references instead of duplicating the underlying configuration.
```bash
# List user models
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:17765/api/u/yourusername/models
# Chat using user's own models
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model": "your-rotation/model", "messages": [{"role": "user", "content": "Hello"}]}' \
http://localhost:17765/api/u/yourusername/chat/completions
```
Reference behavior:
- Imported entries appear as read-only locked resources in provider, rotation, and autoselect selectors
- AISBF resolves references at runtime before handling requests
- Availability is tied to the source listing state
- Dashboard UI clearly distinguishes imported resources from locally owned ones
### MCP (Model Context Protocol)
AISBF provides an MCP server for remote agent configuration and model access:
**Global MCP Endpoints (Admin-configured tokens):**
- `GET /mcp` - SSE endpoint for MCP communication
- `POST /mcp` - HTTP POST endpoint for MCP
- `GET /mcp/tools` - List available global MCP tools
- `POST /mcp/tools/call` - Call global MCP tools
**User-Specific MCP Endpoints (User API tokens):**
- `GET /mcp/u/{username}/tools` - List user's MCP tools
- `POST /mcp/u/{username}/tools/call` - Call user's MCP tool
MCP tools include:
- `list_models` - List available models
- `chat_completions` - Send chat completion requests
- `get_wallet_balance` - Check wallet balance
- `get_wallet_transactions` - Get wallet transaction history
- `get_providers` - Get provider configurations
- `get_rotations` - Get rotation configurations
- `get_autoselects` - Get autoselect configurations
**User-specific MCP tools (when authenticated with user token):**
- `list_user_models` - List user's available models
- `get_user_wallet_balance` - Check user's wallet balance
- `get_user_wallet_transactions` - Get user's wallet transaction history
- `user_chat_completion` - Send chat completion using user's configurations
## Provider Support
### Model Metadata Extraction
AISBF automatically extracts and tracks model metadata from provider responses:
**Automatic Extraction:**
- **Pricing Information**: `rate_multiplier`, `rate_unit` (e.g., "per million tokens")
- **Token Usage**: `prompt_tokens`, `completion_tokens` from API responses
- **Rate Limits**: Auto-configures rate limits from 429 responses with retry-after headers
- **Model Details**: `description`, `context_length`, `architecture`, `supported_parameters`
**Dashboard Features:**
- **"Get Models" Button**: Fetches and displays comprehensive model metadata
- **Real-time Display**: Shows pricing, rate limits, and capabilities for each model
- **Extended Fields**: OpenRouter-style metadata including top_provider, pricing details, and architecture
### Google
- Uses google-genai SDK
- Requires API key
- Supports streaming and non-streaming responses
- Context Caching API support for cost reduction
### OpenAI
- Uses openai SDK
- Requires API key
- Supports streaming and non-streaming responses
- Automatic prefix caching (no configuration needed)
### Anthropic
- Uses anthropic SDK
- Requires API key
- Static model list (no dynamic model discovery)
- cache_control support for cost reduction
### Claude Code (OAuth2 / CLI)
AISBF supports two modes for the Claude provider, selected automatically at runtime:
#### HTTP API / OAuth2 mode (default)
- Full OAuth2 PKCE authentication flow
- Automatic token refresh with refresh token rotation
- Chrome extension for remote server OAuth2 callback interception
- Proxy-aware extension serving: automatically detects reverse proxy deployments
- Supports all Claude models with streaming, tool calling, vision, and extended thinking
- **Note**: this mode uses an unofficial client interface; use at your own risk as per Claude's terms of service
#### CLI proxy mode (requires `claude` in PATH)
- Activated automatically at startup when the `claude` binary is found in PATH
- Requests are proxied through the official Anthropic CLI using `claude -p --input-format stream-json --output-format stream-json`
- Uses the official CLI as intended by Anthropic — permitted by Claude's terms of service
- Per-user isolated temporary config directories (`CLAUDE_CONFIG_DIR`) prevent credential cross-contamination
- Idle session cleanup: temp dirs are removed after 10 minutes of inactivity; active requests always get a fresh subprocess
- Multiple parallel requests are handled concurrently via `asyncio.create_subprocess_exec`
- Credentials can be supplied in two ways:
- **"Use Claude CLI mode" checkbox**: derives credentials automatically from existing OAuth2 tokens already stored in AISBF, converting them to the `claudeAiOauth` schema expected by the CLI
- **Explicit upload**: users can upload their own `~/.claude/.credentials.json` file to use a specific account (takes priority over OAuth2-derived credentials)
- Admin (config-file user): CLI credentials file path stored in `providers.json` under `claude_config.cli_credentials_file`
- Database users (all roles): CLI credentials stored in `user_oauth2_credentials` with `auth_type='claude_cli_credentials'`
##### Credentials file schema
The CLI expects a `.credentials.json` with this structure:
```json
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": 1776940481301,
"scopes": ["user:inference", "user:file_upload", "user:profile", "user:mcp_servers", "user:sessions:claude_code"],
"subscriptionType": "pro",
"rateLimitTier": "default_claude_ai"
}
}
```
When "Use Claude CLI mode" is checked without an explicit file upload, AISBF converts the stored OAuth2 tokens to this format automatically (`expires_at` seconds → `expiresAt` milliseconds, `scope` string → `scopes` list).
### Ollama
- Uses direct HTTP API
- No API key required
- Local model hosting support
### Kiro (Amazon Q Developer)
- Native integration with Kiro authentication
- Supports IDE credentials and CLI authentication
- No separate API key required
### Kilocode
- OAuth2 Device Authorization Grant flow
- Supports both API key and OAuth2 authentication
- Dashboard OAuth2 authentication UI
### Codex
- OAuth2 Device Authorization Grant for OpenAI protocol
- API key exchange from ID token
- Dashboard authentication UI
### Qwen
- API key authentication (recommended)
- OpenAI-compatible DashScope API endpoint
- Supports all Qwen models
## Advanced Features
### Provider-Native Caching
AISBF supports provider-native caching APIs for cost reduction:
#### Supported Providers
- **Anthropic**: `cache_control` with ephemeral caching
- **Google**: Context Caching API with TTL support
- **OpenAI**: Automatic prefix caching
#### Configuration
```json
{
"providers": {
"anthropic": {
"enable_native_caching": true,
"min_cacheable_tokens": 1024
}
}
}
```
### Response Caching
Intelligent response caching with semantic deduplication:
#### Features
- SHA256-based cache key generation
- Multiple backends (Redis, SQLite, MySQL, memory)
- TTL-based expiration
- Cache statistics and dashboard management
#### Configuration
```json
{
"response_cache": {
"enabled": true,
"backend": "redis",
"ttl": 600,
"max_size": 1000
}
}
```
### Adaptive Rate Limiting
Intelligent rate limit management that learns from provider responses:
#### Features
- Learns optimal request rates from 429 responses
- Exponential backoff with jitter
- Per-provider tracking
- Dashboard monitoring
#### Configuration
```json
{
"adaptive_rate_limiting": {
"enabled": true,
"learning_rate": 0.1,
"headroom_percent": 10
}
}
```
## Wallet System
### Context Management
AISBF includes a comprehensive unified wallet system that manages user fiat balances for subscription payments and provides multiple top-up methods.
Automatic context condensation with multiple methods:
### Features
#### Condensation Methods
1. **Hierarchical**: Separates persistent and transient layers
2. **Conversational**: Summarizes old messages
3. **Semantic**: Prunes irrelevant context
4. **Algorithmic**: Mathematical compression
- **Unified Fiat Wallet**: Single wallet per user in system-configured currency
- **Multiple Top-Up Methods**: Crypto (BTC, ETH, USDT, USDC), PayPal, Stripe (credit cards)
- **Auto Top-Up**: Automatic balance replenishment when subscription renewal fails
- **Transaction History**: Complete audit trail of all wallet operations
- **Subscription Integration**: Automatic deduction for subscription renewals
- **Configurable Amounts**: Fixed preset amounts (10, 15, 20, 50, 100) plus custom amounts
#### Configuration
```json
{
"models": [
{
"name": "gpt-4",
"context_size": 128000,
"condense_context": 80,
"condense_method": ["hierarchical", "semantic"]
}
]
}
```
### Wallet Balance Management
### SSL/TLS Support
Each user has a single fiat wallet with the following properties:
- **Balance**: Current fiat balance in system currency
- **Currency**: Configurable system-wide currency (default: USD)
- **Auto Top-Up**: Optional automatic top-up when balance falls below threshold
- **Transaction History**: Complete log of all credits and debits
Built-in HTTPS with Let's Encrypt integration:
### Top-Up Methods
#### Self-Signed Certificates
- Automatic generation for development
- Stored in `~/.aisbf/cert.pem` and `key.pem`
#### 1. Cryptocurrency Top-Up
- **Supported Currencies**: BTC, ETH, USDT (ERC20), USDC (ERC20)
- **Process**: Generate unique address -> User sends crypto -> Automatic balance credit
- **Confirmation**: Requires blockchain confirmations (6 for BTC, 12 for ETH/ERC20)
#### Let's Encrypt
- Automatic certificate generation and renewal
- Requires certbot installation
- Public domain configuration in dashboard
#### 2. PayPal Top-Up
- **Integration**: PayPal Checkout Orders API
- **Amounts**: Fixed presets or custom amounts
- **Process**: Create payment link -> User completes PayPal payment -> Automatic balance credit
### TOR Hidden Service
#### 3. Stripe Top-Up (Credit Cards)
- **Integration**: Stripe PaymentIntents API
- **Amounts**: Fixed presets or custom amounts
- **Process**: Secure card payment -> User completes payment -> Automatic balance credit
Anonymous access via TOR network:
## API Endpoints
#### Configuration
```json
{
"tor": {
"enabled": true,
"control_port": 9051,
"hidden_service_dir": "~/.aisbf/tor_hidden_service"
}
}
```
### Core chat endpoints
- `POST /api/v1/chat/completions`
- `GET /api/v1/models`
- `POST /api/{provider_id}/chat/completions`
- `GET /api/{provider_id}/models`
### User-scoped endpoints
- `POST /api/u/{username}/chat/completions`
- `GET /api/u/{username}/models`
- `GET /api/u/{username}/providers`
- `GET /api/u/{username}/rotations`
- `GET /api/u/{username}/autoselects`
### Broker and runtime endpoints
- `GET /api/coderai/broker/sessions`
- `GET /api/coderai/broker/providers/{provider_id}/status`
- `GET /dashboard/providers/{provider_id}/runpod-status`
- `POST /dashboard/providers/{provider_id}/runpod-refresh`
### Dashboard and Studio endpoints
- `GET /dashboard`
- `GET /dashboard/providers`
- `GET /dashboard/studio`
- `GET /dashboard/admin/market`
## Development
### Building the Package
```bash
./build.sh
```
Creates distribution files in `dist/`.
### PyPI Publishing
See `PYPI.md` for detailed publishing instructions.
### Running Tests
```bash
pytest tests/
```
### Code Style
- Follow PEP 8
- Use type hints
- Add docstrings to all functions
## Troubleshooting
### Common Issues
#### Import Errors
- Ensure AISBF is properly installed
- Check Python path
- Verify dependencies in requirements.txt
#### Authentication Failures
- Check API keys in provider configuration
- Verify OAuth2 credentials are valid
- Check network connectivity to provider endpoints
#### Rate Limiting Issues
- Monitor dashboard rate limit statistics
- Adjust adaptive rate limiting settings
- Check provider account limits
#### Database Issues
- Verify database file permissions
- Check SQLite/MySQL connectivity
- Run database migrations if needed
### Debug Mode
Enable debug logging:
```bash
export AISBF_DEBUG=1
aisbf
```
### Logs Location
- Application logs: Console output
- Database logs: `~/.aisbf/aisbf.log`
- Error logs: Check application output
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
## License
GNU General Public License v3.0
## Author
### Documentation notes for 0.99.66
Stefy Lanza <stefy@nexlab.net>
The 0.99.66 documentation refresh covers the major product additions since 0.99.65:
- CoderAI broker state persistence and performance telemetry
- RunPod provider lifecycle management
- Marketplace publication, settlement, and reference imports
- Studio bindings, assets, and custom pipelines
- Stale signup cleanup and dashboard proxy hardening
## Repository
### Additional references
https://git.nexlab.net/nexlab/aisbf.git
\ No newline at end of file
- `README.md` - High-level feature overview
- `docs/coderai-integration.md` - CoderAI integration contract
- `docs/coderai-broker-implementation-reference.md` - Mirror reference for broker-side implementers
- `RUNPOD_IMPLEMENTATION_PLAN.md` - RunPod rollout notes and recovery plan
- `CHANGELOG.md` - Release-by-release history
# 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
......@@ -127,4 +149,4 @@ Official repository: https://git.nexlab.net/nexlab/aisbf.git
## License
GNU General Public License v3.0
\ No newline at end of file
GNU General Public License v3.0
......@@ -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,15 +371,14 @@ 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 ==="
python3 -c "
# Test importing main module before starting uvicorn
echo "=== DEBUG: Testing main module import ==="
python3 -c "
try:
import main
print('main module imported successfully')
......@@ -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,16 +69,24 @@ def _iter_user_coderai_matches(provider_id: str) -> list[Tuple[int, Dict[str, An
def resolve_coderai_provider_owner(provider_id: str, username: Optional[str] = None) -> Tuple[Optional[int], Optional[Dict[str, Any]]]:
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:
requested_user_id = user.get('id')
for user_id, provider_config in user_matches:
if user_id == requested_user_id:
return user_id, provider_config
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:
return user_id, provider_config
return None, None
except Exception as e:
logger.debug(f"Failed to resolve user by username for provider={provider_id} username={username}: {e}")
return None, None
......@@ -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)
......
......@@ -22,7 +22,7 @@ Why did the programmer quit his job? Because he didn't get arrays!
Configuration management for AISBF.
"""
from typing import Dict, List, Optional, Union
from typing import Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field, field_validator
import json
import shutil
......@@ -46,8 +46,16 @@ class ProviderModelConfig(BaseModel):
# Content classification flags
nsfw: bool = False # Model can handle NSFW content
privacy: bool = False # Model can handle privacy-sensitive content
# Tri-state feature overrides
enable_nsfw_classification: Optional[bool] = None # None = inherit provider/global default
enable_privacy_classification: Optional[bool] = None # None = inherit provider/global default
enable_context_condensation: Optional[bool] = None # None = inherit provider/global default
# Response caching control
enable_response_cache: Optional[bool] = None # Enable/disable response caching for this model (None = use provider default)
enable_prompt_batching: Optional[bool] = None # None = inherit provider/global default
enable_prompt_security: Optional[bool] = None # None = inherit provider/global default
enable_context_lens: Optional[bool] = None # None = inherit provider/global default
block_high_risk_prompts: Optional[bool] = None # None = inherit provider/global default
# Studio capability metadata
studio_capabilities: Optional[List[str]] = None
studio_capability_source: Optional[str] = None
......@@ -64,6 +72,35 @@ class CondensationConfig(BaseModel):
max_context: Optional[int] = None # Maximum context size for condensation model
class FeatureModeConfig(BaseModel):
"""Tri-state feature mode. None/absent means inherit at parent scope."""
mode: Literal['inherit', 'enabled', 'disabled'] = 'inherit'
class PromptFeatureConfig(BaseModel):
"""Global prompt security and context analytics defaults."""
security_scan: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
context_lens: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
block_high_risk_prompts: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
persist_prompt_text: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
redact_before_persist: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('enabled'))
risk_threshold: str = 'high'
class FeatureControlsConfig(BaseModel):
"""Global tri-state defaults shared across request features."""
nsfw_classification: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
privacy_classification: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
context_condensation: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
response_cache: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
prompt_batching: FeatureModeConfig = Field(default_factory=lambda: _feature_mode_default('disabled'))
prompt_security: PromptFeatureConfig = Field(default_factory=PromptFeatureConfig)
def _feature_mode_default(mode: str) -> FeatureModeConfig:
return FeatureModeConfig(mode=mode)
class ProviderConfig(BaseModel):
id: str
name: str
......@@ -101,8 +138,22 @@ class ProviderConfig(BaseModel):
free_tier_limit_type: Optional[str] = None # requests or tokens
premium_reference_monthly_cost: Optional[float] = None # Equivalent premium value for one extra upstream free tier
free_tier_description: Optional[str] = None # Human description for upstream free tier
pro_tier_requests_daily: Optional[int] = None # Explicit request quota for one pro tier per day
pro_tier_requests_weekly: Optional[int] = None # Explicit request quota for one pro tier per week
pro_tier_requests_monthly: Optional[int] = None # Explicit request quota for one pro tier per month
pro_tier_tokens_daily: Optional[int] = None # Explicit token quota for one pro tier per day
pro_tier_tokens_weekly: Optional[int] = None # Explicit token quota for one pro tier per week
pro_tier_tokens_monthly: Optional[int] = None # Explicit token quota for one pro tier per month
# Tri-state feature overrides
enable_nsfw_classification: Optional[bool] = None # None = use global default
enable_privacy_classification: Optional[bool] = None # None = use global default
enable_context_condensation: Optional[bool] = None # None = use global default
# Response caching control
enable_response_cache: Optional[bool] = None # Enable/disable response caching for this provider (None = use global default)
enable_prompt_batching: Optional[bool] = None # None = use global default
enable_prompt_security: Optional[bool] = None # None = use global default
enable_context_lens: Optional[bool] = None # None = use global default
block_high_risk_prompts: Optional[bool] = None # None = use global default
class RotationConfig(BaseModel):
model_name: str
......@@ -129,8 +180,16 @@ class RotationConfig(BaseModel):
default_condense_context: Optional[int] = None
default_condense_method: Optional[Union[str, List[str]]] = None
default_error_cooldown: Optional[int] = None # Default cooldown period in seconds after 3 consecutive failures (default: 300)
# Tri-state feature overrides
enable_nsfw_classification: Optional[bool] = None # None = use global default
enable_privacy_classification: Optional[bool] = None # None = use global default
enable_context_condensation: Optional[bool] = None # None = use global default
# Response caching control
enable_response_cache: Optional[bool] = None # Enable/disable response caching for this rotation (None = use global default)
enable_prompt_batching: Optional[bool] = None # None = use global default
enable_prompt_security: Optional[bool] = None # None = use global default
enable_context_lens: Optional[bool] = None # None = use global default
block_high_risk_prompts: Optional[bool] = None # None = use global default
class AutoselectModelInfo(BaseModel):
model_id: str
......@@ -175,8 +234,16 @@ class AutoselectConfig(BaseModel):
default_condense_context: Optional[int] = None
default_condense_method: Optional[Union[str, List[str]]] = None
default_error_cooldown: Optional[int] = None # Default cooldown period in seconds after 3 consecutive failures (default: 300)
# Tri-state feature overrides
enable_nsfw_classification: Optional[bool] = None # None = use global default
enable_privacy_classification: Optional[bool] = None # None = use global default
enable_context_condensation: Optional[bool] = None # None = use global default
# Response caching control
enable_response_cache: Optional[bool] = None # Enable/disable response caching for this autoselect (None = use global default)
enable_prompt_batching: Optional[bool] = None # None = use global default
enable_prompt_security: Optional[bool] = None # None = use global default
enable_context_lens: Optional[bool] = None # None = use global default
block_high_risk_prompts: Optional[bool] = None # None = use global default
class ResponseCacheConfig(BaseModel):
"""Configuration for response caching with semantic deduplication"""
......@@ -307,6 +374,7 @@ class AISBFConfig(BaseModel):
cache: Optional[Dict] = None
response_cache: Optional[ResponseCacheConfig] = None
batching: Optional[BatchingConfig] = None
feature_controls: Optional[FeatureControlsConfig] = None
adaptive_rate_limiting: Optional[AdaptiveRateLimitingConfig] = None
client_rate_limiting: Optional[ClientRateLimitingConfig] = None
signup: Optional[SignupConfig] = None
......@@ -887,6 +955,9 @@ class Config:
batching_data = data.get('batching')
if batching_data:
data['batching'] = BatchingConfig(**batching_data)
feature_controls_data = data.get('feature_controls')
if feature_controls_data:
data['feature_controls'] = FeatureControlsConfig(**feature_controls_data)
# Parse adaptive_rate_limiting separately if present
adaptive_data = data.get('adaptive_rate_limiting')
if adaptive_data:
......@@ -994,6 +1065,75 @@ class Config:
def get_aisbf_config(self) -> AISBFConfig:
return self.aisbf
@staticmethod
def _feature_mode_to_bool(value: Any) -> Optional[bool]:
if value is None:
return None
if isinstance(value, dict):
value = value.get('mode')
else:
value = getattr(value, 'mode', value)
if value in ('enabled', True):
return True
if value in ('disabled', False):
return False
return None
def resolve_feature_enabled(
self,
feature_name: str,
*,
model_config: Any = None,
provider_config: Any = None,
rotation_config: Any = None,
autoselect_config: Any = None,
global_default: Optional[bool] = None,
) -> bool:
field_name = f'enable_{feature_name}'
if feature_name == 'block_high_risk_prompts':
field_name = 'block_high_risk_prompts'
def _value_from(scope: Any) -> Optional[bool]:
if scope is None:
return None
if isinstance(scope, dict):
return scope.get(field_name)
return getattr(scope, field_name, None)
for scope in (model_config, provider_config, rotation_config, autoselect_config):
resolved = _value_from(scope)
if resolved is not None:
return bool(resolved)
aisbf_config = self.get_aisbf_config()
feature_controls = getattr(aisbf_config, 'feature_controls', None) if aisbf_config else None
if feature_controls is not None:
control = getattr(feature_controls, feature_name, None)
resolved = self._feature_mode_to_bool(control)
if resolved is not None:
return resolved
prompt_security = getattr(feature_controls, 'prompt_security', None)
if feature_name == 'prompt_security' and prompt_security is not None:
resolved = self._feature_mode_to_bool(getattr(prompt_security, 'security_scan', None))
if resolved is not None:
return resolved
if prompt_security is not None and hasattr(prompt_security, feature_name):
resolved = self._feature_mode_to_bool(getattr(prompt_security, feature_name, None))
if resolved is not None:
return resolved
if feature_name == 'response_cache' and aisbf_config and aisbf_config.response_cache:
return bool(aisbf_config.response_cache.enabled)
if feature_name == 'prompt_batching' and aisbf_config and aisbf_config.batching:
return bool(aisbf_config.batching.enabled)
if feature_name == 'context_condensation':
condensation = self.get_condensation()
if condensation is not None:
return bool(condensation.enabled)
return bool(global_default) if global_default is not None else False
config = Config()
def get_config():
......
......@@ -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
......@@ -1215,4 +1228,4 @@ def _infer_context_size_from_model(model_name: str) -> int:
return 8192
# Generic default
return 8192
\ No newline at end of file
return 8192
......@@ -60,6 +60,7 @@ from .streaming_optimization import (
optimize_sse_chunk
)
from .database import DatabaseRegistry
from .prompt_analysis import analyze_prompt_payload
_autoselect_result_cache: dict = {}
_autoselect_result_cache_ttl: int = 3600 # seconds
......@@ -136,6 +137,97 @@ class RequestHandler:
return provider_config.get('type', 'openai') or 'openai'
return getattr(provider_config, 'type', 'openai') or 'openai'
def _record_dashboard_proxy_event(self, request, provider_id: str, model_name: str, success: bool, latency_ms: float, metadata: Dict | None = None):
try:
from aisbf.database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
status_code = (metadata or {}).get('status_code')
if status_code is None:
status_code = 200 if success else 500
db.record_dashboard_event(
event_type='request_proxied',
path=request.url.path,
user_id=getattr(request.state, 'user_id', None),
username=request.session.get('username') if hasattr(request, 'session') else None,
method=request.method,
status_code=status_code,
provider_id=provider_id,
rotation_id=(metadata or {}).get('rotation_id'),
autoselect_id=(metadata or {}).get('autoselect_id'),
metadata={
'model_name': model_name,
'latency_ms': round(float(latency_ms or 0), 2),
'stream': bool((metadata or {}).get('stream')),
**(metadata or {}),
},
)
except Exception:
logger.debug("Failed to record proxied request dashboard event", exc_info=True)
def _run_prompt_analysis(self, request, provider_id: str, model_name: str, request_data: Dict, prompt_tokens: Optional[int] = None, effective_context: Optional[int] = None):
aisbf_config = self.config.get_aisbf_config()
if not aisbf_config:
return None
provider_config = self._get_provider_config(provider_id)
model_config = None
models = provider_config.get('models', []) if isinstance(provider_config, dict) else getattr(provider_config, 'models', None) or []
for candidate in models:
candidate_name = candidate.get('name') if isinstance(candidate, dict) else getattr(candidate, 'name', None)
if candidate_name == model_name:
model_config = candidate
break
scan_enabled = self.config.resolve_feature_enabled(
'prompt_security',
model_config=model_config,
provider_config=provider_config,
)
analytics_enabled = self.config.resolve_feature_enabled(
'context_lens',
model_config=model_config,
provider_config=provider_config,
)
block_high_risk = self.config.resolve_feature_enabled(
'block_high_risk_prompts',
model_config=model_config,
provider_config=provider_config,
)
if not scan_enabled and not analytics_enabled:
return None
analysis = analyze_prompt_payload(request_data, model_name=model_name)
blocked = bool(block_high_risk and analysis.get('risk_level') == 'high')
db = DatabaseRegistry.get_config_database()
run_id = db.record_prompt_analysis_run(
user_id=getattr(request.state, 'user_id', None) if request is not None else self.user_id,
token_id=getattr(request.state, 'token_id', None) if request is not None else None,
provider_id=provider_id,
model_name=model_name,
rotation_id=request_data.get('_rotation_id'),
autoselect_id=request_data.get('_autoselect_id'),
request_path=request.url.path if request is not None else None,
request_hash=analysis.get('request_hash'),
prompt_tokens=prompt_tokens,
effective_context=effective_context,
scan_enabled=scan_enabled,
analytics_enabled=analytics_enabled,
blocked=blocked,
risk_level=analysis.get('risk_level'),
risk_score=analysis.get('risk_score'),
findings_count=analysis.get('summary', {}).get('findings_count', 0),
summary_json={
'security_summary': analysis.get('summary', {}),
'composition': analysis.get('composition', {}),
},
)
if scan_enabled:
db.record_prompt_analysis_findings(run_id, analysis.get('findings', []))
analysis['blocked'] = blocked
return analysis
def _adapt_studio_workflow_payload(self, provider_id: str, endpoint_path: str, body: Dict) -> Dict:
payload = dict(body or {})
provider_type = (self._get_provider_type(provider_id) or '').lower()
......@@ -311,49 +403,15 @@ class RequestHandler:
import logging
logger = logging.getLogger(__name__)
# Check model-level setting (highest priority)
if model_config:
model_cache_setting = None
if isinstance(model_config, dict):
model_cache_setting = model_config.get('enable_response_cache')
else:
model_cache_setting = getattr(model_config, 'enable_response_cache', None)
if model_cache_setting is not None:
logger.debug(f"Using model-level cache setting: {model_cache_setting}")
return model_cache_setting
# Check provider-level setting
if provider_config:
provider_cache_setting = getattr(provider_config, 'enable_response_cache', None)
if provider_cache_setting is not None:
logger.debug(f"Using provider-level cache setting: {provider_cache_setting}")
return provider_cache_setting
# Check rotation-level setting
if rotation_config:
rotation_cache_setting = getattr(rotation_config, 'enable_response_cache', None)
if rotation_cache_setting is not None:
logger.debug(f"Using rotation-level cache setting: {rotation_cache_setting}")
return rotation_cache_setting
# Check autoselect-level setting
if autoselect_config:
autoselect_cache_setting = getattr(autoselect_config, 'enable_response_cache', None)
if autoselect_cache_setting is not None:
logger.debug(f"Using autoselect-level cache setting: {autoselect_cache_setting}")
return autoselect_cache_setting
# Fall back to global setting
aisbf_config = self.config.get_aisbf_config()
if aisbf_config and aisbf_config.response_cache:
global_setting = aisbf_config.response_cache.enabled
logger.debug(f"Using global cache setting: {global_setting}")
return global_setting
# Default to False if no configuration found
logger.debug("No cache configuration found, defaulting to False")
return False
resolved = self.config.resolve_feature_enabled(
'response_cache',
model_config=model_config,
provider_config=provider_config,
rotation_config=rotation_config,
autoselect_config=autoselect_config,
)
logger.debug(f"Resolved response cache setting: {resolved}")
return resolved
async def _handle_chunked_request(
self,
......@@ -495,6 +553,7 @@ class RequestHandler:
# Track request start time for analytics
request_start_time = time.time()
model_name = request_data.get('model', 'unknown')
autoselect_id = request_data.get('_autoselect_id')
# Check for user-specific provider config first
if self.user_id and provider_id in self.user_providers:
......@@ -611,7 +670,9 @@ class RequestHandler:
context_config = get_context_config_for_model(
model_name=model,
provider_config=provider_config,
rotation_model_config=None
rotation_model_config=None,
rotation_config=None,
autoselect_config=None,
)
logger.info(f"Context config: {context_config}")
......@@ -625,6 +686,17 @@ class RequestHandler:
# Calculate effective context (total tokens used)
effective_context = count_messages_tokens(messages, model)
logger.info(f"Effective context: {effective_context} tokens")
prompt_analysis = self._run_prompt_analysis(
request,
provider_id,
model,
request_data,
prompt_tokens=effective_context,
effective_context=effective_context,
)
if prompt_analysis and prompt_analysis.get('blocked'):
raise HTTPException(status_code=400, detail="Prompt blocked by local security policy")
# Apply context condensation if needed
if context_config.get('condense_context', 0) > 0:
......@@ -812,6 +884,23 @@ class RequestHandler:
actual_cost=actual_cost,
analytics_kind='execution'
)
self._record_dashboard_proxy_event(
request,
provider_id,
model_name,
True,
latency_ms,
metadata={
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'total_tokens': total_tokens,
'actual_cost': actual_cost,
'stream': bool(stream),
'autoselect_id': autoselect_id,
'prompt_risk_level': (prompt_analysis or {}).get('risk_level'),
'prompt_findings_count': (prompt_analysis or {}).get('summary', {}).get('findings_count', 0),
},
)
# Record context dimensions for model performance tracking
try:
......@@ -866,12 +955,29 @@ class RequestHandler:
actual_cost=None,
analytics_kind='execution'
)
self._record_dashboard_proxy_event(
request,
provider_id,
model_name,
False,
latency_ms,
metadata={
'error_type': type(e).__name__,
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'total_tokens': total_tokens,
'stream': bool(request_data.get('stream', False)),
'autoselect_id': autoselect_id,
'prompt_blocked': 'local security policy' in str(e).lower(),
},
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for failed request failed: {analytics_error}")
raise HTTPException(status_code=500, detail=str(e))
async def handle_streaming_chat_completion(self, request: Request, provider_id: str, request_data: Dict):
autoselect_id = request_data.get('_autoselect_id')
# Check for user-specific provider config first
if self.user_id and provider_id in self.user_providers:
provider_config = self.user_providers[provider_id]
......@@ -909,10 +1015,23 @@ class RequestHandler:
context_config = get_context_config_for_model(
model_name=model,
provider_config=provider_config,
rotation_model_config=None
rotation_model_config=None,
rotation_config=None,
autoselect_config=None,
)
effective_context = count_messages_tokens(messages, model)
prompt_analysis = self._run_prompt_analysis(
request,
provider_id,
model,
request_data,
prompt_tokens=effective_context,
effective_context=effective_context,
)
if prompt_analysis and prompt_analysis.get('blocked'):
raise HTTPException(status_code=400, detail="Prompt blocked by local security policy")
# Apply context condensation if needed
if context_config.get('condense_context', 0) > 0:
......@@ -1507,6 +1626,22 @@ class RequestHandler:
actual_cost=None,
analytics_kind='execution'
)
self._record_dashboard_proxy_event(
request,
provider_id,
request_data['model'],
True,
latency_ms,
metadata={
'prompt_tokens': effective_context,
'completion_tokens': completion_tokens,
'total_tokens': total_tokens,
'stream': True,
'autoselect_id': autoselect_id,
'prompt_risk_level': (prompt_analysis or {}).get('risk_level'),
'prompt_findings_count': (prompt_analysis or {}).get('summary', {}).get('findings_count', 0),
},
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for streaming request failed: {analytics_error}")
......@@ -1546,6 +1681,22 @@ class RequestHandler:
actual_cost=None,
analytics_kind='execution'
)
self._record_dashboard_proxy_event(
request,
provider_id,
request_data['model'],
False,
latency_ms,
metadata={
'error_type': type(e).__name__,
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'total_tokens': total_tokens,
'stream': True,
'autoselect_id': autoselect_id,
'prompt_blocked': 'local security policy' in str(e).lower(),
},
)
except Exception as analytics_error:
logger.warning(f"Analytics recording for failed streaming request failed: {analytics_error}")
......@@ -3018,7 +3169,9 @@ class RotationHandler:
context_config = get_context_config_for_model(
model_name=model_name,
provider_config=None,
rotation_model_config=current_model
rotation_model_config=current_model,
rotation_config=rotation_config,
autoselect_config=None,
)
logger.info(f"Context config: {context_config}")
......@@ -3237,6 +3390,22 @@ class RotationHandler:
actual_cost=actual_cost,
analytics_kind='execution'
)
self._record_dashboard_proxy_event(
request,
provider_id,
model_name,
True,
(time.time() - request_start_time) * 1000,
metadata={
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'total_tokens': total_tokens,
'actual_cost': actual_cost,
'stream': False,
'rotation_id': rotation_id,
'autoselect_id': request_data.get('_autoselect_id'),
},
)
except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}")
......@@ -3999,6 +4168,21 @@ class RotationHandler:
actual_cost=None,
analytics_kind='execution'
)
self._record_dashboard_proxy_event(
request,
provider_id,
model_name,
True,
latency_ms,
metadata={
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens_count,
'total_tokens': total_tokens,
'stream': True,
'rotation_id': rotation_id,
'autoselect_id': request_data.get('_autoselect_id'),
},
)
try:
self._settle_market_result(provider_id, {
'prompt_tokens': prompt_tokens,
......@@ -4012,10 +4196,39 @@ class RotationHandler:
except RateLimitError as e:
# Provider already disabled by handle_429 — do NOT call record_failure()
latency_ms = (time.time() - request_start_time) * 1000 if request_start_time else 0
self._record_dashboard_proxy_event(
request,
provider_id,
model_name,
False,
latency_ms,
metadata={
'error_type': type(e).__name__,
'stream': True,
'rotation_id': rotation_id,
'status_code': 503,
'autoselect_id': request_data.get('_autoselect_id'),
},
)
error_dict = {"error": str(e)}
yield f"data: {json.dumps(error_dict)}\n\n".encode('utf-8')
except Exception as e:
handler.record_failure()
latency_ms = (time.time() - request_start_time) * 1000 if request_start_time else 0
self._record_dashboard_proxy_event(
request,
provider_id,
model_name,
False,
latency_ms,
metadata={
'error_type': type(e).__name__,
'stream': True,
'rotation_id': rotation_id,
'autoselect_id': request_data.get('_autoselect_id'),
},
)
error_dict = {"error": str(e)}
yield f"data: {json.dumps(error_dict)}\n\n".encode('utf-8')
......@@ -4149,6 +4362,21 @@ class AutoselectHandler:
self.user_autoselects = {}
self.autoselects = self.config.autoselect if hasattr(self.config, 'autoselect') else {}
def _get_response_cache_backend(self):
aisbf_config = self.config.get_aisbf_config()
if not (aisbf_config and aisbf_config.response_cache and aisbf_config.response_cache.enabled):
return None
return get_response_cache(aisbf_config.response_cache.model_dump())
def _should_cache_response(self, provider_config=None, model_config=None, rotation_config=None, autoselect_config=None):
return self.config.resolve_feature_enabled(
'response_cache',
model_config=model_config,
provider_config=provider_config,
rotation_config=rotation_config,
autoselect_config=autoselect_config,
)
def _load_user_configs(self):
"""Load user-specific configurations from database"""
from .database import DatabaseRegistry
......@@ -4909,9 +5137,11 @@ class AutoselectHandler:
stream = request_data.get('stream', False)
if not stream:
try:
aisbf_config = self.config.get_aisbf_config()
if aisbf_config and aisbf_config.response_cache and aisbf_config.response_cache.enabled:
response_cache = get_response_cache(aisbf_config.response_cache.model_dump())
current_autoselect_config = self.autoselects.get(autoselect_id) if self.user_id else self.config.get_autoselect(autoselect_id)
if self._should_cache_response(autoselect_config=current_autoselect_config):
response_cache = self._get_response_cache_backend()
if response_cache is None:
raise RuntimeError("Response cache backend unavailable")
cached_response = response_cache.get(request_data, user_id=self.user_id)
if cached_response:
logger.info(f"Cache hit for autoselect request {autoselect_id}")
......@@ -5030,7 +5260,9 @@ class AutoselectHandler:
logger.info(f"Proxying request to: {selected_model_id}")
try:
response = await rotation_handler.handle_rotation_request(selected_model_id, request_data, user_id, token_id)
proxied_request_data = dict(request_data)
proxied_request_data['_autoselect_id'] = autoselect_id
response = await rotation_handler.handle_rotation_request(selected_model_id, proxied_request_data, user_id, token_id)
except Exception as e:
logger.warning(f"Model '{selected_model_id}' raised exception: {e} — escalating")
failed_models.append(selected_model_id)
......@@ -5084,9 +5316,10 @@ class AutoselectHandler:
# Cache the response for non-streaming requests
if not stream:
try:
aisbf_config = self.config.get_aisbf_config()
if aisbf_config and aisbf_config.response_cache and aisbf_config.response_cache.enabled:
response_cache = get_response_cache(aisbf_config.response_cache.model_dump())
if self._should_cache_response(autoselect_config=autoselect_config):
response_cache = self._get_response_cache_backend()
if response_cache is None:
raise RuntimeError("Response cache backend unavailable")
response_cache.set(request_data, response, user_id=self.user_id)
logger.debug(f"Cached response for autoselect request {autoselect_id}")
except Exception as cache_error:
......@@ -5241,27 +5474,29 @@ class AutoselectHandler:
logger.info(f"Proxying streaming request to: {selected_model_id}")
try:
proxied_request_data = dict(request_data)
proxied_request_data['_autoselect_id'] = autoselect_id
# Check if it's a rotation first
if (self.user_id and selected_model_id in self.rotations) or selected_model_id in self.config.rotations:
rotation_handler = RotationHandler(user_id=self.user_id)
response = await rotation_handler.handle_rotation_request(selected_model_id, request_data, self.user_id)
response = await rotation_handler.handle_rotation_request(selected_model_id, proxied_request_data, self.user_id)
elif '/' in selected_model_id:
provider_id, model_name = selected_model_id.split('/', 1)
if provider_id not in self.config.providers:
raise HTTPException(status_code=400, detail=f"Provider {provider_id} not found")
request_handler = RequestHandler()
request_data['model'] = model_name
proxied_request_data['model'] = model_name
response = await request_handler.handle_streaming_chat_completion(
request=None, provider_id=provider_id, request_data=request_data)
request=None, provider_id=provider_id, request_data=proxied_request_data)
elif selected_model_id in self.config.providers:
provider_config = self.config.get_provider(selected_model_id)
models = getattr(provider_config, "models", [])
if not models:
raise HTTPException(status_code=400, detail=f"Provider {selected_model_id} has no models configured")
request_handler = RequestHandler()
request_data['model'] = models[0].name
proxied_request_data['model'] = models[0].name
response = await request_handler.handle_streaming_chat_completion(
request=None, provider_id=selected_model_id, request_data=request_data)
request=None, provider_id=selected_model_id, request_data=proxied_request_data)
else:
raise HTTPException(status_code=400, detail=f"Model {selected_model_id} not found")
except Exception as e:
......
......@@ -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}")
......
......@@ -916,6 +916,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]:
"""
......
......@@ -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']:
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:
if not self._native_cache_user_allows(model_name):
cache_config['enabled'] = False
import logging
logging.getLogger(__name__).warning(f"Error checking user cache settings: {e}")
logging.getLogger(__name__).info(f"User {user_id} disabled cache for provider={provider_id}, model={model_name}")
return cache_config
......
......@@ -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']:
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}")
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}")
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']:
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}")
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}")
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
......@@ -46,79 +47,84 @@ async def _coderai_broker_websocket_impl(websocket: WebSocket, scope_name: str):
if not valid:
await websocket.close(code=1008, reason=error or "registration rejected")
return
await websocket.accept()
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})
await websocket.accept()
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)
message = json.loads(raw)
op = message.get("op")
if op == "register":
payload = message.get("payload") or {}
payload_token = payload.get("registration_token") or message.get("registration_token")
if payload_token and payload_token != presented_token:
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "error",
"error": "Registration token mismatch",
}))
continue
capabilities = payload.get("capabilities") or message.get("capabilities") or {}
metadata = {
"endpoint": payload.get("endpoint"),
"transport": payload.get("transport"),
"studio_endpoints": payload.get("studio_endpoints") or [],
"hardware": payload.get("hardware") or {},
"gpus": payload.get("gpus") or ((payload.get("hardware") or {}).get("gpus")) or [],
"gpu_count": payload.get("gpu_count") or ((payload.get("hardware") or {}).get("gpu_count")),
"total_vram_mb": payload.get("total_vram_mb") or ((payload.get("hardware") or {}).get("total_vram_mb")),
"available_vram_mb": payload.get("available_vram_mb") or ((payload.get("hardware") or {}).get("available_vram_mb")),
"owner_user_id": owner_user_id,
"username": username,
"scope_name": expected_scope,
"proxy_scheme": websocket.url.scheme,
}
await broker.touch(session.session_id, metadata=metadata, capabilities=capabilities)
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"payload": {
**_coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope),
"session_id": session.session_id,
},
}))
continue
if op == "heartbeat":
await broker.touch(session.session_id, metadata=message.get("payload") or {})
raw = await websocket.receive_text()
message = json.loads(raw)
op = message.get("op")
if op == "register":
payload = message.get("payload") or {}
payload_token = payload.get("registration_token") or message.get("registration_token")
if payload_token and payload_token != presented_token:
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"event": "heartbeat",
"payload": {"ts": int(time.time())},
"status": "error",
"error": "Registration token mismatch",
}))
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})
capabilities = payload.get("capabilities") or message.get("capabilities") or {}
metadata = {
"endpoint": payload.get("endpoint"),
"transport": payload.get("transport"),
"studio_endpoints": payload.get("studio_endpoints") or [],
"hardware": payload.get("hardware") or {},
"gpus": payload.get("gpus") or ((payload.get("hardware") or {}).get("gpus")) or [],
"gpu_count": payload.get("gpu_count") or ((payload.get("hardware") or {}).get("gpu_count")),
"total_vram_mb": payload.get("total_vram_mb") or ((payload.get("hardware") or {}).get("total_vram_mb")),
"available_vram_mb": payload.get("available_vram_mb") or ((payload.get("hardware") or {}).get("available_vram_mb")),
"owner_user_id": owner_user_id,
"username": username,
"scope_name": expected_scope,
"proxy_scheme": websocket.url.scheme,
}
await broker.touch(session.session_id, metadata=metadata, capabilities=capabilities)
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"payload": {
**_coderai_register_payload(session.provider_id, session.client_id, username, owner_user_id, expected_scope),
"session_id": session.session_id,
},
}))
continue
if op == "heartbeat":
await broker.touch(session.session_id, metadata=message.get("payload") or {})
await websocket.send_text(json.dumps({
"v": 1,
"request_id": message.get("request_id"),
"status": "ok",
"event": "heartbeat",
"payload": {"ts": int(time.time())},
}))
continue
await broker.touch(session.session_id)
await broker.publish_response(message)
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})
......
......@@ -264,8 +264,9 @@ def _augment_provider_broker_status(provider_id: str, provider_config: dict, bro
coderai_config = dict(stamped.get('coderai_config') or {})
status_metadata = dict((status or {}).get('metadata') or {})
status_performance = dict((status or {}).get('performance') or {})
is_connected = bool((status or {}).get('connected'))
coderai_config['broker_session'] = {
'connected': bool(status),
'connected': is_connected,
'client_id': status.get('client_id') if status else None,
'session_id': status.get('session_id') if status else None,
'connected_at': status.get('connected_at') if status else None,
......@@ -275,13 +276,36 @@ def _augment_provider_broker_status(provider_id: str, provider_config: dict, bro
'endpoint': status_metadata.get('endpoint'),
'studio_endpoints': status_metadata.get('studio_endpoints') or [],
'capabilities': (status or {}).get('capabilities') or {},
'connection_state': status_metadata.get('connection_state') or ('connected' if status else 'disconnected'),
'connection_state': status_metadata.get('connection_state') or ('connected' if is_connected else 'disconnected'),
'metadata': status_metadata,
'performance': status_performance,
}
stamped['coderai_config'] = coderai_config
return stamped
def _record_dashboard_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 event %s", event_type, exc_info=True)
def _resource_change_event(existing_config: dict | None, new_config: dict | None, base_saved: str, base_removed: str) -> str:
if existing_config is None and new_config is not None:
return f"{base_saved}_created"
if existing_config is not None and new_config is not None:
return f"{base_saved}_updated"
return base_removed
def init(config, templates, server_config=None):
global _config, _templates, _server_config
_config = config
......@@ -529,6 +553,10 @@ async def dashboard_providers(request: Request):
"session": request.session,
"__version__": __version__,
"providers_data": providers_data,
"providers_bootstrap_json": _json_parse_bootstrap([
{"provider_id": provider_id, "config": provider_config}
for provider_id, provider_config in providers_data.items()
]),
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
......@@ -811,6 +839,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
providers_data = _reorder_dict(providers_data, list(providers_data.keys()))
existing_global_providers = dict(full_config.get('providers') or {})
# Update providers section while preserving other keys
full_config['providers'] = providers_data
......@@ -820,12 +849,19 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
removed_global_provider_keys = set(existing_global_providers.keys()) - set(providers_data.keys())
for provider_key in removed_global_provider_keys:
_record_dashboard_event(request, 'provider_removed', provider_id=provider_key, metadata={'scope': 'global', 'source': 'bulk'})
for provider_key, provider_config in providers_data.items():
event_type = _resource_change_event(existing_global_providers.get(provider_key), provider_config, 'provider_saved', 'provider_removed')
_record_dashboard_event(request, event_type, provider_id=provider_key, metadata={'scope': 'global', 'provider_type': provider_config.get('type'), 'source': 'bulk'})
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
# Get existing providers to find which to delete
existing_providers = db.get_user_providers(current_user_id)
existing_provider_map = {p['provider_id']: p.get('config') for p in existing_providers}
existing_provider_keys = {p['provider_id'] for p in existing_providers}
new_provider_keys = set(providers_data.keys())
......@@ -833,10 +869,13 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
providers_to_delete = existing_provider_keys - new_provider_keys
for provider_key in providers_to_delete:
db.delete_user_provider(current_user_id, provider_key)
_record_dashboard_event(request, 'provider_removed', provider_id=provider_key, metadata={'scope': 'user'})
# Save each provider to database
for provider_key, provider_config in providers_data.items():
db.save_user_provider(current_user_id, provider_key, provider_config)
event_type = _resource_change_event(existing_provider_map.get(provider_key), provider_config, 'provider_saved', 'provider_removed')
_record_dashboard_event(request, event_type, provider_id=provider_key, metadata={'scope': 'user', 'provider_type': provider_config.get('type')})
providers_data = _ordered_resource_map(
providers_data,
......@@ -857,6 +896,10 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"session": request.session,
"__version__": __version__,
"providers_data": providers_data,
"providers_bootstrap_json": _json_parse_bootstrap([
{"provider_id": provider_id, "config": provider_config}
for provider_id, provider_config in providers_data.items()
]),
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
......@@ -891,10 +934,11 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"success": success_msg
}
)
except json.JSONDecodeError as e:
# Reload current config on error
except (json.JSONDecodeError, ValueError) as e:
# Reload current config on parse/validation error
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
error_prefix = 'Invalid JSON' if isinstance(e, json.JSONDecodeError) else 'Validation error'
if is_config_admin:
config_path = Path.home() / '.aisbf' / 'providers.json'
......@@ -917,12 +961,16 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"session": request.session,
"__version__": __version__,
"providers_data": providers_data,
"providers_bootstrap_json": _json_parse_bootstrap([
{"provider_id": provider_id, "config": provider_config}
for provider_id, provider_config in providers_data.items()
]),
"studio_capability_choices": serialize_studio_capability_choices(),
"studio_adapter_choices": serialize_studio_adapter_choices(),
"studio_adapter_profile_choices": serialize_studio_adapter_profile_choices(),
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}"
"error": f"{error_prefix}: {str(e)}"
}
)
else:
......@@ -944,7 +992,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"is_local_client": _is_local_client(request),
"error": f"Invalid JSON: {str(e)}"
"error": f"{error_prefix}: {str(e)}"
}
)
......@@ -1360,6 +1408,11 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
if is_config_admin:
# Config admin: save to JSON files
config_path = Path.home() / '.aisbf' / 'rotations.json'
existing_global_rotations = {}
if config_path.exists():
with open(config_path) as f:
existing_rotations_config = json.load(f)
existing_global_rotations = dict(existing_rotations_config.get('rotations') or {})
config_path.parent.mkdir(parents=True, exist_ok=True)
rotations_data['rotations'] = _reorder_dict(
rotations_data.get('rotations', {}),
......@@ -1368,6 +1421,12 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
with open(config_path, 'w') as f:
json.dump(rotations_data, f, indent=2)
_reload_global_config()
removed_global_rotation_keys = set(existing_global_rotations.keys()) - set(rotations_data.get('rotations', {}).keys())
for rotation_key in removed_global_rotation_keys:
_record_dashboard_event(request, 'rotation_removed', rotation_id=rotation_key, metadata={'scope': 'global', 'source': 'bulk'})
for rotation_key, rotation_config in rotations_data.get('rotations', {}).items():
event_type = _resource_change_event(existing_global_rotations.get(rotation_key), rotation_config, 'rotation_saved', 'rotation_removed')
_record_dashboard_event(request, event_type, rotation_id=rotation_key, metadata={'scope': 'global', 'source': 'bulk'})
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
......@@ -1380,10 +1439,12 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
new_rotation_keys = set(rotations.keys())
for rotation_key in existing_rotation_keys - new_rotation_keys:
db.delete_user_rotation(current_user_id, rotation_key)
_record_dashboard_event(request, 'rotation_removed', rotation_id=rotation_key, metadata={'scope': 'user'})
# Save each rotation to database
for rotation_key, rotation_config in rotations.items():
db.save_user_rotation(current_user_id, rotation_key, rotation_config)
_record_dashboard_event(request, 'rotation_saved', rotation_id=rotation_key, metadata={'scope': 'user'})
rotations_data['rotations'] = _ordered_resource_map(
rotations_data.get('rotations', {}),
......@@ -1661,11 +1722,21 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
if is_config_admin:
# Config admin: save to JSON files
config_path = Path.home() / '.aisbf' / 'autoselect.json'
existing_global_autoselects = {}
if config_path.exists():
with open(config_path) as f:
existing_global_autoselects = dict(json.load(f) or {})
config_path.parent.mkdir(parents=True, exist_ok=True)
autoselect_data = _reorder_dict(autoselect_data, list(autoselect_data.keys()))
with open(config_path, 'w') as f:
json.dump(autoselect_data, f, indent=2)
_reload_global_config()
removed_global_autoselect_keys = set(existing_global_autoselects.keys()) - set(autoselect_data.keys())
for autoselect_key in removed_global_autoselect_keys:
_record_dashboard_event(request, 'autoselect_removed', autoselect_id=autoselect_key, metadata={'scope': 'global', 'source': 'bulk'})
for autoselect_key, autoselect_config in autoselect_data.items():
event_type = _resource_change_event(existing_global_autoselects.get(autoselect_key), autoselect_config, 'autoselect_saved', 'autoselect_removed')
_record_dashboard_event(request, event_type, autoselect_id=autoselect_key, metadata={'scope': 'global', 'source': 'bulk'})
else:
# Database user: save to database
db = DatabaseRegistry.get_config_database()
......@@ -1676,10 +1747,12 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
new_autoselect_keys = set(autoselect_data.keys())
for autoselect_key in existing_autoselect_keys - new_autoselect_keys:
db.delete_user_autoselect(current_user_id, autoselect_key)
_record_dashboard_event(request, 'autoselect_removed', autoselect_id=autoselect_key, metadata={'scope': 'user'})
# Save each autoselect to database
for autoselect_key, autoselect_config in autoselect_data.items():
db.save_user_autoselect(current_user_id, autoselect_key, autoselect_config)
_record_dashboard_event(request, 'autoselect_saved', autoselect_id=autoselect_key, metadata={'scope': 'user'})
autoselect_data = _ordered_resource_map(
autoselect_data,
......@@ -1934,9 +2007,11 @@ async def api_provider_save(request: Request):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
_record_dashboard_event(request, 'provider_saved', provider_id=provider_id, metadata={'scope': 'global', 'provider_type': provider_config.get('type'), 'source': 'api'})
else:
db = DatabaseRegistry.get_config_database()
db.save_user_provider(current_user_id, provider_id, provider_config)
_record_dashboard_event(request, 'provider_saved', provider_id=provider_id, metadata={'scope': 'user', 'provider_type': provider_config.get('type'), 'source': 'api'})
return JSONResponse({"success": True})
except Exception as e:
......@@ -1971,6 +2046,7 @@ async def api_provider_coderai_token_rotate(request: Request, provider_id: str):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
_record_dashboard_event(request, 'provider_saved', provider_id=provider_id, metadata={'scope': 'global', 'provider_type': provider_config.get('type'), 'source': 'coderai-token'})
else:
db = DatabaseRegistry.get_config_database()
record = db.get_user_provider(current_user_id, provider_id)
......@@ -1980,6 +2056,7 @@ async def api_provider_coderai_token_rotate(request: Request, provider_id: str):
provider_config.setdefault('coderai_config', {})
provider_config['coderai_config']['registration_token'] = secrets.token_urlsafe(32)
db.save_user_provider(current_user_id, provider_id, provider_config)
_record_dashboard_event(request, 'provider_saved', provider_id=provider_id, metadata={'scope': 'user', 'provider_type': provider_config.get('type'), 'source': 'coderai-token'})
token = provider_config['coderai_config']['registration_token']
return JSONResponse({"success": True, "registration_token": token})
......@@ -2030,9 +2107,11 @@ async def api_provider_delete(request: Request, provider_id: str):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
_record_dashboard_event(request, 'provider_removed', provider_id=provider_id, metadata={'scope': 'global', 'source': 'api'})
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_provider(current_user_id, provider_id)
_record_dashboard_event(request, 'provider_removed', provider_id=provider_id, metadata={'scope': 'user', 'source': 'api'})
return JSONResponse({"success": True})
except Exception as e:
......@@ -2127,9 +2206,11 @@ async def api_rotation_save(request: Request):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
_record_dashboard_event(request, 'rotation_saved', rotation_id=rotation_id, metadata={'scope': 'global', 'source': 'api'})
else:
db = DatabaseRegistry.get_config_database()
db.save_user_rotation(current_user_id, rotation_id, rotation_config)
_record_dashboard_event(request, 'rotation_saved', rotation_id=rotation_id, metadata={'scope': 'user', 'source': 'api'})
return JSONResponse({"success": True})
except Exception as e:
......@@ -2158,9 +2239,11 @@ async def api_rotation_delete(request: Request, rotation_id: str):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
_record_dashboard_event(request, 'rotation_removed', rotation_id=rotation_id, metadata={'scope': 'global', 'source': 'api'})
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_rotation(current_user_id, rotation_id)
_record_dashboard_event(request, 'rotation_removed', rotation_id=rotation_id, metadata={'scope': 'user', 'source': 'api'})
return JSONResponse({"success": True})
except Exception as e:
......@@ -2212,9 +2295,11 @@ async def api_autoselect_save(request: Request):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
_record_dashboard_event(request, 'autoselect_saved', autoselect_id=autoselect_id, metadata={'scope': 'global', 'source': 'api'})
else:
db = DatabaseRegistry.get_config_database()
db.save_user_autoselect(current_user_id, autoselect_id, autoselect_config)
_record_dashboard_event(request, 'autoselect_saved', autoselect_id=autoselect_id, metadata={'scope': 'user', 'source': 'api'})
return JSONResponse({"success": True})
except Exception as e:
......@@ -2246,9 +2331,11 @@ async def api_autoselect_delete(request: Request, autoselect_id: str):
with open(save_path, 'w') as f:
json.dump(full_config, f, indent=2)
_reload_global_config()
_record_dashboard_event(request, 'autoselect_removed', autoselect_id=autoselect_id, metadata={'scope': 'global', 'source': 'api'})
else:
db = DatabaseRegistry.get_config_database()
db.delete_user_autoselect(current_user_id, autoselect_id)
_record_dashboard_event(request, 'autoselect_removed', autoselect_id=autoselect_id, metadata={'scope': 'user', 'source': 'api'})
return JSONResponse({"success": True})
except Exception as e:
......@@ -2272,18 +2359,21 @@ def _ordered_resource_map(resource_map: dict, saved_order: list | None) -> dict:
return _reorder_dict(resource_map, saved_order)
@router.get("/dashboard/analytics", response_class=HTMLResponse)
async def dashboard_analytics(
async def _render_dashboard_analytics(
request: Request,
time_range: str = Query("24h"),
from_date: Optional[str] = Query(None),
to_date: Optional[str] = Query(None),
provider_filter: Optional[str] = Query(None),
model_filter: Optional[str] = Query(None),
rotation_filter: Optional[str] = Query(None),
autoselect_filter: Optional[str] = Query(None),
user_filter: Optional[str] = Query(None),
global_only: Optional[str] = Query(None)
time_range: str = "24h",
from_date: Optional[str] = None,
to_date: Optional[str] = None,
provider_filter: Optional[str] = None,
model_filter: Optional[str] = None,
rotation_filter: Optional[str] = None,
autoselect_filter: Optional[str] = None,
user_filter: Optional[str] = None,
global_only: Optional[str] = None,
activity_event_filter: Optional[str] = None,
activity_path_filter: Optional[str] = None,
activity_country_filter: Optional[str] = None,
template_name: str = "dashboard/analytics.html",
):
"""Token usage analytics dashboard"""
from decimal import Decimal
......@@ -2441,8 +2531,88 @@ async def dashboard_analytics(
rotation_filter=rotation_filter
)
dashboard_visit_overview = None
dashboard_visit_series = []
dashboard_visit_pages = []
dashboard_visit_countries = []
dashboard_visit_users = []
dashboard_visit_events = []
prompt_analysis_overview = None
prompt_analysis_series = []
prompt_analysis_details = {
'provider_breakdown': [],
'model_breakdown': [],
'risk_breakdown': [],
'segment_breakdown': [],
'shape_breakdown': [],
'recent_runs': [],
'posture': {
'runs_with_tools': 0,
'runs_with_system_prompt': 0,
'avg_findings_count': 0.0,
'avg_risk_score': 0.0,
},
}
is_config_admin = is_admin and current_user_id is None
if db:
prompt_analysis_overview = db.get_prompt_analysis_summary(
from_datetime or (datetime.now() - timedelta(hours=24)),
to_datetime or datetime.now(),
user_id=user_filter_int,
provider_filter=effective_provider_filter,
model_filter=effective_model_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter,
)
prompt_analysis_series = db.get_prompt_analysis_series(
from_datetime or (datetime.now() - timedelta(hours=24)),
to_datetime or datetime.now(),
bucket='hour' if time_range in ('1h', '6h', '24h', 'custom') else 'day',
user_id=user_filter_int,
)
prompt_analysis_details = db.get_prompt_analysis_details(
from_datetime or (datetime.now() - timedelta(hours=24)),
to_datetime or datetime.now(),
user_id=user_filter_int,
provider_filter=effective_provider_filter,
model_filter=effective_model_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter,
)
if is_config_admin and db:
selected_event_types = [activity_event_filter] if activity_event_filter else None
dashboard_visit_overview = db.get_dashboard_event_summary(
from_datetime or (datetime.now() - timedelta(hours=24)),
to_datetime or datetime.now(),
event_types=selected_event_types,
user_id=user_filter_int,
provider_filter=effective_provider_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter,
path_filter=activity_path_filter,
country_filter=activity_country_filter,
)
dashboard_visit_series = db.get_dashboard_event_series(
from_datetime or (datetime.now() - timedelta(hours=24)),
to_datetime or datetime.now(),
bucket='hour' if time_range in ('1h', '6h', '24h', 'custom') else 'day',
event_types=selected_event_types,
user_id=user_filter_int,
)
dashboard_visit_pages = dashboard_visit_overview.get('pages', [])
dashboard_visit_countries = dashboard_visit_overview.get('countries', [])
dashboard_visit_users = dashboard_visit_overview.get('users', [])
dashboard_visit_events = db.get_dashboard_events(
from_datetime or (datetime.now() - timedelta(hours=24)),
to_datetime or datetime.now(),
event_types=selected_event_types,
user_id=user_filter_int,
limit=200,
)
def decimal_default(obj):
if isinstance(obj, Decimal):
return int(obj)
......@@ -2450,7 +2620,7 @@ async def dashboard_analytics(
return _templates.TemplateResponse(
request=request,
name="dashboard/analytics.html",
name=template_name,
context={
"request": request,
"session": request.session,
......@@ -2477,13 +2647,128 @@ async def dashboard_analytics(
"selected_autoselect": autoselect_filter,
"selected_user": user_filter,
"global_only": global_only,
"activity_event_filter": activity_event_filter,
"activity_path_filter": activity_path_filter,
"activity_country_filter": activity_country_filter,
"currency_symbol": db.get_currency_settings().get('currency_symbol', '$'),
"rotation_breakdown": rotation_breakdown,
"autoselect_breakdown": autoselect_breakdown,
"dashboard_visit_overview": dashboard_visit_overview,
"dashboard_visit_series": json.dumps(dashboard_visit_series, default=decimal_default),
"dashboard_visit_pages": dashboard_visit_pages,
"dashboard_visit_countries": dashboard_visit_countries,
"dashboard_visit_users": dashboard_visit_users,
"dashboard_visit_events": dashboard_visit_events,
"prompt_analysis_overview": prompt_analysis_overview,
"prompt_analysis_series": json.dumps(prompt_analysis_series, default=decimal_default),
"prompt_analysis_details": prompt_analysis_details,
}
)
@router.get("/dashboard/analytics", response_class=HTMLResponse)
async def dashboard_analytics(
request: Request,
time_range: str = Query("24h"),
from_date: Optional[str] = Query(None),
to_date: Optional[str] = Query(None),
provider_filter: Optional[str] = Query(None),
model_filter: Optional[str] = Query(None),
rotation_filter: Optional[str] = Query(None),
autoselect_filter: Optional[str] = Query(None),
user_filter: Optional[str] = Query(None),
global_only: Optional[str] = Query(None),
activity_event_filter: Optional[str] = Query(None),
activity_path_filter: Optional[str] = Query(None),
activity_country_filter: Optional[str] = Query(None),
):
return await _render_dashboard_analytics(
request=request,
time_range=time_range,
from_date=from_date,
to_date=to_date,
provider_filter=provider_filter,
model_filter=model_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter,
user_filter=user_filter,
global_only=global_only,
activity_event_filter=activity_event_filter,
activity_path_filter=activity_path_filter,
activity_country_filter=activity_country_filter,
template_name="dashboard/analytics.html",
)
@router.get("/dashboard/prompt-analytics", response_class=HTMLResponse)
async def dashboard_prompt_analytics(
request: Request,
time_range: str = "24h",
from_date: Optional[str] = None,
to_date: Optional[str] = None,
provider_filter: Optional[str] = None,
model_filter: Optional[str] = None,
rotation_filter: Optional[str] = None,
autoselect_filter: Optional[str] = None,
user_filter: Optional[str] = None,
global_only: Optional[str] = None,
):
return await _render_dashboard_analytics(
request=request,
time_range=time_range,
from_date=from_date,
to_date=to_date,
provider_filter=provider_filter,
model_filter=model_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter,
user_filter=user_filter,
global_only=global_only,
template_name="dashboard/prompt_analytics.html",
)
@router.get("/dashboard/traffic-visits", response_class=HTMLResponse)
async def dashboard_traffic_visits(
request: Request,
time_range: str = "24h",
from_date: Optional[str] = None,
to_date: Optional[str] = None,
provider_filter: Optional[str] = None,
model_filter: Optional[str] = None,
rotation_filter: Optional[str] = None,
autoselect_filter: Optional[str] = None,
user_filter: Optional[str] = None,
global_only: Optional[str] = None,
activity_event_filter: Optional[str] = None,
activity_path_filter: Optional[str] = None,
activity_country_filter: Optional[str] = None,
):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
if request.session.get('role') != 'admin' or request.session.get('user_id') is not None:
return RedirectResponse(url=str(request.url_for("dashboard_analytics")), status_code=302)
return await _render_dashboard_analytics(
request=request,
time_range=time_range,
from_date=from_date,
to_date=to_date,
provider_filter=provider_filter,
model_filter=model_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter,
user_filter=user_filter,
global_only=global_only,
activity_event_filter=activity_event_filter,
activity_path_filter=activity_path_filter,
activity_country_filter=activity_country_filter,
template_name="dashboard/traffic_visits.html",
)
@router.get("/dashboard/analytics/provider-quotas", response_class=HTMLResponse)
async def dashboard_analytics_provider_quotas(request: Request):
auth_check = require_dashboard_auth(request)
......
......@@ -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,7 +410,18 @@ async def dashboard_settings_save(
classify_nsfw: bool = Form(False),
classify_privacy: bool = Form(False),
classify_semantic: bool = Form(False),
batching_enabled: bool = Form(False),
feature_nsfw_classification_mode: str = Form("inherit"),
feature_privacy_classification_mode: str = Form("inherit"),
feature_context_condensation_mode: str = Form("inherit"),
feature_response_cache_mode: str = Form("inherit"),
feature_prompt_batching_mode: str = Form("inherit"),
feature_prompt_security_mode: str = Form("inherit"),
feature_context_lens_mode: str = Form("inherit"),
feature_block_high_risk_prompts_mode: str = Form("inherit"),
feature_persist_prompt_text_mode: str = Form("inherit"),
feature_redact_before_persist_mode: str = Form("inherit"),
feature_risk_threshold: str = Form("high"),
batching_enabled: bool = Form(False),
batching_window_ms: int = Form(100),
batching_max_batch_size: int = Form(8),
batching_openai_enabled: bool = Form(False),
......@@ -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)">
......@@ -444,6 +430,16 @@ function renderAutoselectDetails(autoselectKey) {
</label>
<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>
......@@ -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
......@@ -162,4 +167,4 @@ function showToast(message, type) {
}, 4000);
}
</script>
{% endblock %}
\ No newline at end of file
{% endblock %}
{% extends "base.html" %}
{% block title %}Prompt Analytics - AISBF Dashboard{% endblock %}
{% macro format_tokens(value) %}
{% if value is none or value == 0 %}0{% else %}
{% set val = value | float %}
{% if val >= 1000000000 %}
{{ "%.2f"|format(val / 1000000000) }}B
{% elif val >= 1000000 %}
{{ "%.2f"|format(val / 1000000) }}M
{% elif val >= 1000 %}
{{ "%.2f"|format(val / 1000) }}K
{% else %}
{{ "%.0f"|format(val) }}
{% endif %}
{% endif %}
{% endmacro %}
{% macro pct(part, total) %}
{% if total and total > 0 %}
{{ "%.0f"|format((part / total) * 100) }}%
{% else %}
0%
{% endif %}
{% endmacro %}
{% block content %}
<h2 style="margin-bottom: 12px;">Prompt Analytics</h2>
<p style="margin: 0 0 24px; color: var(--color-muted); max-width: 960px; line-height: 1.6;">
Native AISBF prompt analysis for prompt-security findings, request composition, blocking posture, and model/provider exposure.
{% if is_config_admin %}
This view can compare global traffic with user-scoped runs when filters are applied.
{% else %}
This view is limited to your own requests and does not expose global visitor activity.
{% endif %}
</p>
<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" style="text-decoration: none;">🧠 Prompt Analytics</a>
{% if is_config_admin %}
<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;">💾 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;">
<h3 style="margin-bottom: 15px;">Filter by Date Range</h3>
<form method="get" action="{{ url_for(request, '/dashboard/prompt-analytics') }}" style="display: flex; flex-wrap: wrap; gap: 15px; align-items: flex-end;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">Time Range</label>
<select name="time_range" id="timeRangeSelect" style="width: 100%; padding: 10px; border-radius: 4px; background:var(--bg-accent); color: white; border: 1px solid #2a4a7a;">
<option value="1h" {% if selected_time_range == '1h' %}selected{% endif %}>Last 1 Hour</option>
<option value="6h" {% if selected_time_range == '6h' %}selected{% endif %}>Last 6 Hours</option>
<option value="24h" {% if selected_time_range == '24h' %}selected{% endif %}>Last 24 Hours (Default)</option>
<option value="yesterday" {% if selected_time_range == 'yesterday' %}selected{% endif %}>Yesterday</option>
<option value="7d" {% if selected_time_range == '7d' %}selected{% endif %}>Last 7 Days</option>
<option value="30d" {% if selected_time_range == '30d' %}selected{% endif %}>Last 30 Days</option>
<option value="90d" {% if selected_time_range == '90d' %}selected{% endif %}>Last 90 Days</option>
<option value="custom" {% if selected_time_range == 'custom' %}selected{% endif %}>Custom Range</option>
</select>
</div>
<div id="customDateRange" style="display: {% if selected_time_range == 'custom' or from_date %}flex{% else %}none{% endif %}; flex-wrap: wrap; gap: 15px; flex: 2;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">From Date &amp; Time</label>
<input type="datetime-local" name="from_date" value="{{ from_date or '' }}" 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: 200px;">
<label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">To Date &amp; Time</label>
<input type="datetime-local" name="to_date" value="{{ to_date or '' }}" style="width: 100%; padding: 10px; border-radius: 4px; background:var(--bg-accent); color: white; border: 1px solid #2a4a7a;">
</div>
</div>
<div>
<button type="submit" style="padding: 10px 20px; background: var(--color-primary); color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply Filter</button>
</div>
</form>
</div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px; margin-bottom: 30px;">
<h3 style="margin-bottom: 15px;">Filter by Provider, Model, Rotation, Autoselect, or User</h3>
<form method="get" action="{{ url_for(request, '/dashboard/prompt-analytics') }}" 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 %}
<div style="flex: 1; min-width: 150px;"><label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">Provider</label><select name="provider_filter" style="width: 100%; padding: 10px; border-radius: 4px; background:var(--bg-accent); color: white; border: 1px solid #2a4a7a;"><option value="">All Providers</option>{% for provider in available_providers %}<option value="{{ provider }}" {% if selected_provider == provider %}selected{% endif %}>{{ provider }}</option>{% endfor %}</select></div>
<div style="flex: 1; min-width: 150px;"><label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">Model</label><select name="model_filter" style="width: 100%; padding: 10px; border-radius: 4px; background:var(--bg-accent); color: white; border: 1px solid #2a4a7a;"><option value="">All Models</option>{% for model in available_models %}<option value="{{ model }}" {% if selected_model == model %}selected{% endif %}>{{ model }}</option>{% endfor %}</select></div>
<div style="flex: 1; min-width: 150px;"><label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">Rotation</label><select name="rotation_filter" style="width: 100%; padding: 10px; border-radius: 4px; background:var(--bg-accent); color: white; border: 1px solid #2a4a7a;"><option value="">All Rotations</option>{% for rotation in available_rotations %}<option value="{{ rotation }}" {% if selected_rotation == rotation %}selected{% endif %}>{{ rotation }}</option>{% endfor %}</select></div>
<div style="flex: 1; min-width: 150px;"><label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">Autoselect</label><select name="autoselect_filter" style="width: 100%; padding: 10px; border-radius: 4px; background:var(--bg-accent); color: white; border: 1px solid #2a4a7a;"><option value="">All Autoselects</option>{% for autoselect in available_autoselects %}<option value="{{ autoselect }}" {% if selected_autoselect == autoselect %}selected{% endif %}>{{ autoselect }}</option>{% endfor %}</select></div>
{% if is_admin %}
<div style="flex: 1; min-width: 150px;"><label style="display: block; margin-bottom: 5px; color: var(--color-muted); font-size: 14px;">User</label><select name="user_filter" style="width: 100%; padding: 10px; border-radius: 4px; background:var(--bg-accent); color: white; border: 1px solid #2a4a7a;"><option value="">All Users</option>{% for user in available_users %}<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.username }}{% if user.role == 'admin' %} (admin){% endif %}</option>{% endfor %}</select></div>
<div style="flex: 1; min-width: 150px; display: flex; align-items: center;"><label style="display: flex; align-items: center; color: var(--color-text); cursor: pointer; user-select: none;"><input type="checkbox" name="global_only" value="1" {% if global_only == '1' %}checked{% endif %} style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;"><span style="font-size: 14px;">Global requests only</span></label></div>
{% endif %}
<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 Filters</button></div>
</form>
</div>
<script>
const analyticsData = {
promptAnalysisSeries: {{ prompt_analysis_series|default('[]')|safe }}
};
document.getElementById('timeRangeSelect').addEventListener('change', function() {
const customRange = document.getElementById('customDateRange');
customRange.style.display = this.value === 'custom' ? 'flex' : 'none';
});
</script>
{% set details = prompt_analysis_details or {} %}
{% set posture = details.posture or {} %}
{% set total_runs = prompt_analysis_overview.total_runs if prompt_analysis_overview else 0 %}
{% if prompt_analysis_overview and prompt_analysis_overview.total_runs > 0 %}
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:16px; margin-bottom:24px;">
<div style="background: linear-gradient(135deg, rgba(37,99,235,0.16), rgba(15,23,42,0.9)); padding: 18px; border-radius: 10px; border: 1px solid rgba(37,99,235,0.30);"><div style="color: #bfdbfe; font-size: 13px;">Analyzed Requests</div><div style="font-size: 30px; font-weight: 700;">{{ prompt_analysis_overview.total_runs }}</div></div>
<div style="background: linear-gradient(135deg, rgba(220,38,38,0.16), rgba(15,23,42,0.9)); padding: 18px; border-radius: 10px; border: 1px solid rgba(220,38,38,0.30);"><div style="color: #fecaca; font-size: 13px;">Blocked Requests</div><div style="font-size: 30px; font-weight: 700; color:#fca5a5;">{{ prompt_analysis_overview.blocked_runs }}</div><div style="margin-top: 6px; color: var(--color-muted); font-size: 13px;">{{ pct(prompt_analysis_overview.blocked_runs, prompt_analysis_overview.total_runs) }} of analyzed runs</div></div>
<div style="background: linear-gradient(135deg, rgba(245,158,11,0.16), rgba(15,23,42,0.9)); padding: 18px; border-radius: 10px; border: 1px solid rgba(245,158,11,0.30);"><div style="color: #fde68a; font-size: 13px;">High Risk</div><div style="font-size: 30px; font-weight: 700; color:#fcd34d;">{{ prompt_analysis_overview.high_risk_runs }}</div><div style="margin-top: 6px; color: var(--color-muted); font-size: 13px;">{{ pct(prompt_analysis_overview.high_risk_runs, prompt_analysis_overview.total_runs) }} flagged as high risk</div></div>
<div style="background: var(--bg-page); padding: 18px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.15);"><div style="color: var(--color-muted); font-size: 13px;">Avg Prompt / Context Tokens</div><div style="font-size: 22px; font-weight: 700;">{{ "%.0f"|format(prompt_analysis_overview.avg_prompt_tokens) }} / {{ "%.0f"|format(prompt_analysis_overview.avg_effective_context) }}</div><div style="margin-top: 6px; color: var(--color-muted); font-size: 13px;">Prompt body versus effective context footprint</div></div>
<div style="background: var(--bg-page); padding: 18px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.15);"><div style="color: var(--color-muted); font-size: 13px;">Runs With Tools</div><div style="font-size: 28px; font-weight: 700;">{{ posture.runs_with_tools or 0 }}</div><div style="margin-top: 6px; color: var(--color-muted); font-size: 13px;">{{ pct(posture.runs_with_tools or 0, total_runs) }} included tool definitions</div></div>
<div style="background: var(--bg-page); padding: 18px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.15);"><div style="color: var(--color-muted); font-size: 13px;">Runs With System Prompt</div><div style="font-size: 28px; font-weight: 700;">{{ posture.runs_with_system_prompt or 0 }}</div><div style="margin-top: 6px; color: var(--color-muted); font-size: 13px;">{{ pct(posture.runs_with_system_prompt or 0, total_runs) }} had an explicit system layer</div></div>
<div style="background: var(--bg-page); padding: 18px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.15);"><div style="color: var(--color-muted); font-size: 13px;">Avg Findings / Avg Risk Score</div><div style="font-size: 22px; font-weight: 700;">{{ "%.1f"|format(posture.avg_findings_count or 0) }} / {{ "%.0f"|format(posture.avg_risk_score or 0) }}</div><div style="margin-top: 6px; color: var(--color-muted); font-size: 13px;">Average detector density per analyzed request</div></div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 24px; align-items: stretch;">
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px; min-width: 0;">
<h4 style="margin-top:0; margin-bottom: 8px;">Prompt Risk Timeline</h4>
<p style="margin: 0 0 16px; color: var(--color-muted); font-size: 14px;">Run volume, high-risk detections, and blocking decisions over time.</p>
<canvas id="promptAnalysisChart" height="120"></canvas>
</div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;">
<h4 style="margin-top:0; margin-bottom: 12px;">Scope</h4>
<div style="display: grid; gap: 12px;">
<div style="padding: 14px; border-radius: 8px; background: rgba(37,99,235,0.10); border: 1px solid rgba(37,99,235,0.20);">
<div style="font-size: 12px; color: #bfdbfe; text-transform: uppercase; letter-spacing: 0.04em;">Visible Dataset</div>
<div style="font-size: 18px; font-weight: 700; margin-top: 4px;">{% if is_config_admin %}{% if global_only == '1' %}Global-only{% elif selected_user %}Selected user{% else %}All visible scopes{% endif %}{% else %}Current user only{% endif %}</div>
</div>
<div style="padding: 14px; border-radius: 8px; background: rgba(15,23,42,0.5); border: 1px solid rgba(148,163,184,0.15); color: var(--color-muted); font-size: 14px; line-height: 1.6;">
{% if is_config_admin %}
Apply the user filter or enable <strong>Global requests only</strong> to separate user-owned prompt analysis from global admin traffic.
{% else %}
Prompt analytics are already scoped to your own requests, with no traffic or visitor telemetry exposed here.
{% endif %}
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; margin-bottom: 24px;">
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;">
<h4 style="margin-top:0;">Top Prompt Findings</h4>
{% if prompt_analysis_overview.findings %}
<table>
<tr><th>Category</th><th>Severity</th><th>Count</th></tr>
{% for finding in prompt_analysis_overview.findings %}
<tr><td>{{ finding.category }}</td><td>{{ finding.severity }}</td><td>{{ finding.count }}</td></tr>
{% endfor %}
</table>
{% else %}
<p style="color: var(--color-muted); margin: 0;">No findings were recorded in this range.</p>
{% endif %}
</div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;">
<h4 style="margin-top:0;">Risk Level Mix</h4>
{% if details.risk_breakdown %}
<table>
<tr><th>Risk</th><th>Runs</th><th>Share</th></tr>
{% for row in details.risk_breakdown %}
<tr><td>{{ row.risk_level }}</td><td>{{ row.count }}</td><td>{{ pct(row.count, total_runs) }}</td></tr>
{% endfor %}
</table>
{% else %}
<p style="color: var(--color-muted); margin: 0;">No risk distribution available.</p>
{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 20px; margin-bottom: 24px;">
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;">
<h4 style="margin-top:0;">Providers Under Prompt Pressure</h4>
{% if details.provider_breakdown %}
<table>
<tr><th>Provider</th><th>Runs</th><th>Blocked</th><th>High Risk</th></tr>
{% for provider in details.provider_breakdown %}
<tr>
<td>{{ provider.provider_id }}</td>
<td>{{ provider.runs }}</td>
<td>{{ provider.blocked }}</td>
<td>{{ provider.high_risk }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="color: var(--color-muted); margin: 0;">No provider breakdown available.</p>
{% endif %}
</div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;">
<h4 style="margin-top:0;">Most Analyzed Models</h4>
{% if details.model_breakdown %}
<table>
<tr><th>Model</th><th>Runs</th><th>Blocked</th><th>High Risk</th></tr>
{% for model in details.model_breakdown %}
<tr>
<td>{{ model.model_name }}</td>
<td>{{ model.runs }}</td>
<td>{{ model.blocked }}</td>
<td>{{ model.high_risk }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="color: var(--color-muted); margin: 0;">No model breakdown available.</p>
{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; margin-bottom: 24px;">
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;">
<h4 style="margin-top:0;">Dominant Prompt Segment</h4>
{% if details.segment_breakdown %}
<table>
<tr><th>Segment</th><th>Runs</th><th>Share</th></tr>
{% for row in details.segment_breakdown %}
<tr><td>{{ row.role }}</td><td>{{ row.count }}</td><td>{{ pct(row.count, total_runs) }}</td></tr>
{% endfor %}
</table>
{% else %}
<p style="color: var(--color-muted); margin: 0;">No segment mix available.</p>
{% endif %}
</div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px;">
<h4 style="margin-top:0;">Prompt Shapes</h4>
{% if details.shape_breakdown %}
<table>
<tr><th>Shape</th><th>Runs</th><th>Share</th></tr>
{% for row in details.shape_breakdown %}
<tr><td>{{ row.prompt_shape }}</td><td>{{ row.count }}</td><td>{{ pct(row.count, total_runs) }}</td></tr>
{% endfor %}
</table>
{% else %}
<p style="color: var(--color-muted); margin: 0;">No prompt-shape data available.</p>
{% endif %}
</div>
</div>
<div style="background: var(--bg-page); padding: 20px; border-radius: 8px; margin-bottom: 24px;">
<h4 style="margin-top:0; margin-bottom: 12px;">Recent Prompt Analysis Runs</h4>
{% if details.recent_runs %}
<div style="overflow-x: auto;">
<table>
<tr>
<th>When</th>
<th>Provider</th>
<th>Model</th>
<th>Risk</th>
<th>Blocked</th>
<th>Prompt / Context</th>
<th>Shape</th>
<th>Signals</th>
<th>Findings</th>
</tr>
{% for run in details.recent_runs %}
<tr>
<td>{{ run.created_at }}</td>
<td>{{ run.provider_id }}</td>
<td>
<div>{{ run.model_name }}</div>
{% if run.rotation_id or run.autoselect_id %}
<div style="color: var(--color-muted); font-size: 12px; margin-top: 4px;">
{% if run.rotation_id %}Rotation: {{ run.rotation_id }}{% endif %}
{% if run.rotation_id and run.autoselect_id %} | {% endif %}
{% if run.autoselect_id %}Autoselect: {{ run.autoselect_id }}{% endif %}
</div>
{% endif %}
</td>
<td>
<div style="font-weight: 700; {% if run.risk_level == 'high' %}color:#fca5a5;{% elif run.risk_level == 'medium' %}color:#fcd34d;{% elif run.risk_level == 'info' %}color:#93c5fd;{% endif %}">{{ run.risk_level }}</div>
<div style="color: var(--color-muted); font-size: 12px;">score {{ run.risk_score }}</div>
</td>
<td>{% if run.blocked %}<span style="color:#fca5a5; font-weight:700;">Yes</span>{% else %}<span style="color: var(--color-muted);">No</span>{% endif %}</td>
<td>{{ format_tokens(run.prompt_tokens) }} / {{ format_tokens(run.effective_context) }}</td>
<td>
<div>{{ run.prompt_shape }}</div>
<div style="color: var(--color-muted); font-size: 12px;">largest: {{ run.largest_segment_role }}</div>
</td>
<td>
{% if run.has_system_prompt %}<span style="display:inline-block; padding:2px 8px; border-radius:999px; background: rgba(37,99,235,0.15); color:#bfdbfe; font-size:12px; margin: 0 6px 6px 0;">system</span>{% endif %}
{% if run.has_tools %}<span style="display:inline-block; padding:2px 8px; border-radius:999px; background: rgba(16,185,129,0.15); color:#a7f3d0; font-size:12px; margin: 0 6px 6px 0;">tools</span>{% endif %}
{% if not run.has_system_prompt and not run.has_tools %}<span style="color: var(--color-muted); font-size: 12px;">base</span>{% endif %}
</td>
<td>
<div>{{ run.findings_count }} total</div>
<div style="color: var(--color-muted); font-size: 12px; margin-top: 4px;">H {{ run.high_count }} / M {{ run.medium_count }} / I {{ run.info_count }}</div>
{% if run.findings %}
<div style="margin-top: 6px; color: var(--color-muted); font-size: 12px; line-height: 1.5;">
{% for finding in run.findings %}
<div>{{ finding.category }} ({{ finding.severity }}) x{{ finding.count }}</div>
{% endfor %}
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p style="color: var(--color-muted); margin: 0;">No recent prompt-analysis runs matched the current filters.</p>
{% endif %}
</div>
{% else %}
<p style="color: var(--color-muted);">No prompt analysis 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>
{% if is_admin %}
<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>
{% endif %}
</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('promptAnalysisChart') && analyticsData.promptAnalysisSeries.length) {
new Chart(document.getElementById('promptAnalysisChart'), {
type: 'line',
data: {
datasets: [
{ label: 'Runs', data: analyticsData.promptAnalysisSeries.map(d => ({x: d.bucket, y: d.runs})), borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,0.10)', fill: true, tension: 0.2 },
{ label: 'High Risk', data: analyticsData.promptAnalysisSeries.map(d => ({x: d.bucket, y: d.high_risk})), borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.08)', fill: false, tension: 0.2 },
{ label: 'Blocked', data: analyticsData.promptAnalysisSeries.map(d => ({x: d.bucket, y: d.blocked})), borderColor: '#dc2626', backgroundColor: 'rgba(220,38,38,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 %}
......@@ -19,7 +19,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block title %}Providers - AISBF Dashboard{% endblock %}
{% block content %}
<script id="providers-bootstrap" type="application/json">{{ providers_data | tojson }}</script>
<script id="providers-bootstrap" type="application/json">{{ providers_bootstrap_json | safe }}</script>
<h2 style="margin-bottom: 30px;">Providers Configuration</h2>
{% if success %}
......@@ -192,6 +192,17 @@ function formatPerfNumber(value, digits = 1, suffix = '') {
return `${num.toFixed(digits)}${suffix}`;
}
function renderCoderaiCollapsedStatus(provider) {
const coderaiConfig = provider?.coderai_config || {};
if (provider?.type !== 'coderai' || coderaiConfig.broker_mode === false) return '';
const connected = !!coderaiConfig?.broker_session?.connected;
const label = connected ? 'Online' : 'Offline';
const bg = connected ? 'rgba(34,197,94,0.16)' : 'rgba(239,68,68,0.16)';
const border = connected ? 'rgba(34,197,94,0.45)' : 'rgba(239,68,68,0.45)';
const color = connected ? '#22c55e' : '#ef4444';
return `<span style="display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:12px;font-weight:600;background:${bg};border:1px solid ${border};color:${color};"><span style="width:8px;height:8px;border-radius:50%;background:${color};display:inline-block;"></span>${label}</span>`;
}
function formatRunpodTimestamp(value) {
if (value == null || value === '') return 'Never';
const date = new Date(Number(value) * 1000);
......@@ -307,7 +318,7 @@ const IS_LOCAL_CLIENT = {{ 'true' if is_local_client else 'false' }};
const BASE_PATH = {{ (request.scope.get('root_path', '') or '') | tojson }};
// Marker used by the AISBF Chrome Extension to auto-detect this page and configure itself.
window.AISBF_PROVIDERS_PAGE = { serverUrl: window.location.origin + BASE_PATH };
let providersData = {{ providers_data | tojson }};
let providersData = {};
const STUDIO_CAPABILITY_CHOICES = {{ studio_capability_choices | tojson }};
const STUDIO_ADAPTER_CHOICES = {{ studio_adapter_choices | tojson }};
const STUDIO_ADAPTER_PROFILE_CHOICES = {{ studio_adapter_profile_choices | tojson }};
......@@ -315,9 +326,23 @@ let expandedProviders = new Set();
let currentProviderPage = 0;
const PROVIDERS_PAGE_SIZE = 10;
let providerSearchFilter = '';
let providerMasterOrder = Object.keys(providersData);
let rawProviders = JSON.parse(document.getElementById('providers-bootstrap').textContent);
if (typeof rawProviders === 'string') {
rawProviders = JSON.parse(rawProviders);
}
let providerMasterOrder = [];
let _providerDS = null;
if (Array.isArray(rawProviders)) {
rawProviders.forEach(provider => {
providersData[provider.provider_id] = provider.config;
});
providerMasterOrder = rawProviders.map(provider => provider.provider_id);
} else {
providersData = rawProviders || {};
providerMasterOrder = Object.keys(providersData);
}
// Chunk size: 512KB chunks for maximum compatibility with restrictive proxies
const CHUNK_SIZE = 512 * 1024;
......@@ -704,6 +729,7 @@ function renderProvidersList() {
const supportsUsage = _providerSupportsUsage(key);
const cachedUsage = _usageCache[key];
const compactBadge = supportsUsage ? `<div id="usage-badge-${escHtmlAttr(key)}" onclick="event.stopPropagation();" style="margin-left:8px;">${cachedUsage && cachedUsage.data ? _renderUsageCompact(cachedUsage.data) : '<span style="color:var(--color-muted);font-size:11px;">loading…</span>'}</div>` : '';
const coderaiCollapsedStatus = renderCoderaiCollapsedStatus(provider);
providerItem.innerHTML = `
<div class="provider-header" onclick="toggleProvider('${safeKey}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none; flex-wrap: wrap; gap: 8px;">
......@@ -712,6 +738,7 @@ function renderProvidersList() {
<span style="font-size: 18px;">${isExpanded ? '▼' : '▶'}</span>
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: var(--color-muted); font-size: 14px;">(${escHtmlAttr(provider.name || key)})</span>
${coderaiCollapsedStatus}
${compactBadge}
</div>
<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('providers.remove')}</button>
......@@ -1583,6 +1610,68 @@ function renderProviderDetails(key) {
Leave empty to use default pricing. Examples: OpenAI GPT-4: $30, Anthropic Claude: $75, Google Gemini: $5.00
</small>
</div>
<div class="form-group">
<label>Pro Tier Monthly Cost</label>
<input type="number" step="0.01" min="0" value="${provider.premium_reference_monthly_cost || ''}" onchange="updateProvider('${key}', 'premium_reference_monthly_cost', parseFloat(this.value) || null)" placeholder="e.g., 20.00">
<small style="color: var(--color-muted); display: block; margin-top: 5px;">
Used for free-tier equivalent savings estimates.
</small>
</div>
<div class="form-group">
<label>Free Tier Limit</label>
<input type="number" step="1" min="0" value="${provider.free_tier_limit || ''}" onchange="updateProvider('${key}', 'free_tier_limit', this.value ? parseInt(this.value, 10) : null)" placeholder="e.g., 50">
</div>
<div class="form-group">
<label>Free Tier Period</label>
<select onchange="updateProvider('${key}', 'free_tier_period', this.value || null)">
<option value="" ${!provider.free_tier_period ? 'selected' : ''}>Unspecified</option>
<option value="day" ${provider.free_tier_period === 'day' ? 'selected' : ''}>Daily</option>
<option value="week" ${provider.free_tier_period === 'week' ? 'selected' : ''}>Weekly</option>
<option value="month" ${provider.free_tier_period === 'month' ? 'selected' : ''}>Monthly</option>
</select>
</div>
<div class="form-group">
<label>Free Tier Limit Type</label>
<select onchange="updateProvider('${key}', 'free_tier_limit_type', this.value || null)">
<option value="" ${!provider.free_tier_limit_type ? 'selected' : ''}>Unspecified</option>
<option value="requests" ${provider.free_tier_limit_type === 'requests' ? 'selected' : ''}>Requests</option>
<option value="tokens" ${provider.free_tier_limit_type === 'tokens' ? 'selected' : ''}>Tokens</option>
</select>
</div>
<div class="form-group">
<label>Pro Tier Requests Daily</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_requests_daily || ''}" onchange="updateProvider('${key}', 'pro_tier_requests_daily', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Requests Weekly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_requests_weekly || ''}" onchange="updateProvider('${key}', 'pro_tier_requests_weekly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Requests Monthly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_requests_monthly || ''}" onchange="updateProvider('${key}', 'pro_tier_requests_monthly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Tokens Daily</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_tokens_daily || ''}" onchange="updateProvider('${key}', 'pro_tier_tokens_daily', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Tokens Weekly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_tokens_weekly || ''}" onchange="updateProvider('${key}', 'pro_tier_tokens_weekly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Tokens Monthly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_tokens_monthly || ''}" onchange="updateProvider('${key}', 'pro_tier_tokens_monthly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
</div>
</div>
......@@ -1630,19 +1719,16 @@ function renderProviderDetails(key) {
<input type="text" value="${Array.isArray(provider.default_condense_method) ? provider.default_condense_method.join(', ') : (provider.default_condense_method || '')}" onchange="updateProviderCondenseMethod('${key}', this.value)" placeholder="e.g., semantic, conversational">
</div>
<div class="form-group">
<label>
<input type="checkbox" ${provider.nsfw ? 'checked' : ''} onchange="updateProvider('${key}', 'nsfw', this.checked)">
NSFW
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${provider.privacy ? 'checked' : ''} onchange="updateProvider('${key}', 'privacy', this.checked)">
Privacy
</label>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
<small style="color: var(--color-muted); display: block; margin-bottom: 15px;">Per-provider tri-state overrides. Inherit follows the global default.</small>
${triStateSelect('Context Condensation', provider.enable_context_condensation, `updateProviderTriState('${key}', 'enable_context_condensation', this.value)`, 'Controls whether condensation is active for this provider.')}
${triStateSelect('Response Cache', provider.enable_response_cache, `updateProviderTriState('${key}', 'enable_response_cache', this.value)`, 'Overrides the global response-cache default for this provider.')}
${triStateSelect('Prompt Batching', provider.enable_prompt_batching, `updateProviderTriState('${key}', 'enable_prompt_batching', this.value)`, 'Overrides batching behavior for this provider.')}
${triStateSelect('Prompt Security', provider.enable_prompt_security, `updateProviderTriState('${key}', 'enable_prompt_security', this.value)`, 'Runs local prompt scanning before provider execution.')}
${triStateSelect('Context Lens Analytics', provider.enable_context_lens, `updateProviderTriState('${key}', 'enable_context_lens', this.value)`, 'Captures prompt composition analytics for this provider.')}
${triStateSelect('Block High-Risk Prompts', provider.block_high_risk_prompts, `updateProviderTriState('${key}', 'block_high_risk_prompts', this.value)`, 'Rejects high-risk prompts locally before sending upstream.')}
${triStateSelect('NSFW Classification', provider.enable_nsfw_classification, `updateProviderTriState('${key}', 'enable_nsfw_classification', this.value)`, 'Controls NSFW classification checks for this provider.')}
${triStateSelect('Privacy Classification', provider.enable_privacy_classification, `updateProviderTriState('${key}', 'enable_privacy_classification', this.value)`, 'Controls privacy classification checks for this provider.')}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('providers.native_caching_section')}</h4>
<small style="color: var(--color-muted); display: block; margin-bottom: 15px;">
......@@ -1807,18 +1893,16 @@ function renderModels(providerKey) {
<input type="text" value="${Array.isArray(model.condense_method) ? model.condense_method.join(', ') : (model.condense_method || '')}" onchange="updateModelCondenseMethod('${providerKey}', ${index}, this.value)" placeholder="e.g., semantic, conversational">
</div>
<div class="form-group">
<label>
<input type="checkbox" ${model.nsfw ? 'checked' : ''} onchange="updateModel('${providerKey}', ${index}, 'nsfw', this.checked)">
NSFW
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${model.privacy ? 'checked' : ''} onchange="updateModel('${providerKey}', ${index}, 'privacy', this.checked)">
Privacy
</label>
<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, `updateModelTriState('${providerKey}', ${index}, 'enable_context_condensation', this.value)`)}
${triStateSelect('Response Cache', model.enable_response_cache, `updateModelTriState('${providerKey}', ${index}, 'enable_response_cache', this.value)`)}
${triStateSelect('Prompt Batching', model.enable_prompt_batching, `updateModelTriState('${providerKey}', ${index}, 'enable_prompt_batching', this.value)`)}
${triStateSelect('Prompt Security', model.enable_prompt_security, `updateModelTriState('${providerKey}', ${index}, 'enable_prompt_security', this.value)`)}
${triStateSelect('Context Lens Analytics', model.enable_context_lens, `updateModelTriState('${providerKey}', ${index}, 'enable_context_lens', this.value)`)}
${triStateSelect('Block High-Risk Prompts', model.block_high_risk_prompts, `updateModelTriState('${providerKey}', ${index}, 'block_high_risk_prompts', this.value)`)}
${triStateSelect('NSFW Classification', model.enable_nsfw_classification, `updateModelTriState('${providerKey}', ${index}, 'enable_nsfw_classification', this.value)`)}
${triStateSelect('Privacy Classification', model.enable_privacy_classification, `updateModelTriState('${providerKey}', ${index}, 'enable_privacy_classification', this.value)`)}
</div>
<div class="form-group">
......@@ -1918,6 +2002,38 @@ function togglePricingFields(key) {
}
}
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); display:block; margin-top:5px;">${helpText}</small>` : ''}
</div>
`;
}
function updateTriState(target, field, value) {
target[field] = value === 'inherit' ? null : value === 'enabled';
}
function updateProviderTriState(key, field, value) {
updateTriState(providersData[key], field, value);
}
function updateModelTriState(providerKey, index, field, value) {
updateTriState(providersData[providerKey].models[index], field, value);
}
// Helper function to format expiration time
function formatExpirationTime(expiresAt) {
if (!expiresAt) return 'Unknown';
......
......@@ -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>
......@@ -212,4 +220,4 @@ loadRateLimits();
// Auto-refresh every 30 seconds
setInterval(loadRateLimits, 30000);
</script>
{% endblock %}
\ No newline at end of file
{% endblock %}
......@@ -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 %}
......@@ -467,7 +467,17 @@ function renderAutoselectDetails(autoselectKey) {
</label>
<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');
......
......@@ -188,6 +188,17 @@ function formatPerfNumber(value, digits = 1, suffix = '') {
return `${num.toFixed(digits)}${suffix}`;
}
function renderCoderaiCollapsedStatus(provider) {
const coderaiConfig = provider?.coderai_config || {};
if (provider?.type !== 'coderai' || coderaiConfig.broker_mode === false) return '';
const connected = !!coderaiConfig?.broker_session?.connected;
const label = connected ? 'Online' : 'Offline';
const bg = connected ? 'rgba(34,197,94,0.16)' : 'rgba(239,68,68,0.16)';
const border = connected ? 'rgba(34,197,94,0.45)' : 'rgba(239,68,68,0.45)';
const color = connected ? '#22c55e' : '#ef4444';
return `<span style="display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border-radius:999px;font-size:12px;font-weight:600;background:${bg};border:1px solid ${border};color:${color};"><span style="width:8px;height:8px;border-radius:50%;background:${color};display:inline-block;"></span>${label}</span>`;
}
function renderGpuSummary(metadata) {
const gpus = Array.isArray(metadata?.gpus) ? metadata.gpus : [];
if (!gpus.length) return 'None reported';
......@@ -199,63 +210,6 @@ function renderGpuSummary(metadata) {
}).join('; ');
}
async function setCacheSetting(provider_id, model_name, enabled) {
try {
const response = await fetch('{{ url_for(request, "/dashboard/api/cache-settings") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider_id: provider_id || null,
model_name: model_name || null,
cache_enabled: enabled
})
});
if (response.ok) {
showToast('Cache setting updated', 'success');
// Refresh cache settings
await loadCacheSettings();
} else {
showToast('Failed to update cache setting', 'danger');
}
} catch (error) {
console.error('Error updating cache setting:', error);
showToast('Error updating cache setting', 'danger');
}
}
async function loadCacheSettings() {
try {
const response = await fetch('{{ url_for(request, "/dashboard/api/cache-settings") }}', {
credentials: 'same-origin'
});
const data = await response.json();
cacheSettings = data.settings || [];
} catch (error) {
console.error('Error loading cache settings:', error);
}
}
// Check if cache is enabled for a provider/model
function isCacheEnabled(provider_id, model_name = null) {
// Check model-level setting first
if (model_name) {
const modelSetting = cacheSettings.find(s => s.provider_id === provider_id && s.model_name === model_name);
if (modelSetting) return modelSetting.cache_enabled;
}
// Check provider-level setting
const providerSetting = cacheSettings.find(s => s.provider_id === provider_id && s.model_name === null);
if (providerSetting) return providerSetting.cache_enabled;
// Check global setting
const globalSetting = cacheSettings.find(s => s.provider_id === null && s.model_name === null);
if (globalSetting) return globalSetting.cache_enabled;
// Default: enabled
return true;
}
function showToast(message, type) {
const alertDiv = document.createElement('div');
alertDiv.style.cssText = `
......@@ -670,6 +624,7 @@ function renderProvidersList() {
const supportsUsage = _providerSupportsUsage(key);
const cachedUsage = _usageCache[key];
const compactBadge = supportsUsage ? `<div id="usage-badge-${escHtmlAttr(key)}" onclick="event.stopPropagation();" style="margin-left:8px;">${cachedUsage && cachedUsage.data ? _renderUsageCompact(cachedUsage.data) : '<span style="color:var(--color-muted);font-size:11px;">loading…</span>'}</div>` : '';
const coderaiCollapsedStatus = renderCoderaiCollapsedStatus(provider);
providerItem.innerHTML = `
<div class="provider-header" onclick="toggleProvider('${safeKey}')" style="padding: 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none; flex-wrap: wrap; gap: 8px;">
......@@ -679,6 +634,7 @@ function renderProvidersList() {
<strong style="font-size: 16px;">${escHtmlAttr(key)}</strong>
<span style="color: var(--color-muted); font-size: 14px;">(${escHtmlAttr(provider.name || key)})</span>
${isMarketReference ? '<span class="badge" data-market-reference="true" style="background: var(--color-link); color: white; padding: 2px 8px; border-radius: 999px; font-size: 12px;">Market-linked</span><span class="badge" style="background: var(--bg-accent); color: var(--color-text); padding: 2px 8px; border-radius: 999px; font-size: 12px;">Read-only</span>' : ''}
${coderaiCollapsedStatus}
${compactBadge}
</div>
${isMarketReference ? '' : `<button type="button" class="btn btn-secondary" onclick="event.stopPropagation(); removeProvider('${safeKey}')" style="background: #dc3545; padding: 5px 15px;">${window.i18n.t('providers.remove')}</button>`}
......@@ -1363,6 +1319,68 @@ function renderProviderDetails(key) {
Leave empty to use default pricing. Examples: OpenAI GPT-4: $30, Anthropic Claude: $75, Google Gemini: $5.00
</small>
</div>
<div class="form-group">
<label>Pro Tier Monthly Cost</label>
<input type="number" step="0.01" min="0" value="${provider.premium_reference_monthly_cost || ''}" onchange="updateProvider('${key}', 'premium_reference_monthly_cost', parseFloat(this.value) || null)" placeholder="e.g., 20.00">
<small style="color: var(--color-muted); display: block; margin-top: 5px;">
Used for free-tier equivalent savings estimates.
</small>
</div>
<div class="form-group">
<label>Free Tier Limit</label>
<input type="number" step="1" min="0" value="${provider.free_tier_limit || ''}" onchange="updateProvider('${key}', 'free_tier_limit', this.value ? parseInt(this.value, 10) : null)" placeholder="e.g., 50">
</div>
<div class="form-group">
<label>Free Tier Period</label>
<select onchange="updateProvider('${key}', 'free_tier_period', this.value || null)">
<option value="" ${!provider.free_tier_period ? 'selected' : ''}>Unspecified</option>
<option value="day" ${provider.free_tier_period === 'day' ? 'selected' : ''}>Daily</option>
<option value="week" ${provider.free_tier_period === 'week' ? 'selected' : ''}>Weekly</option>
<option value="month" ${provider.free_tier_period === 'month' ? 'selected' : ''}>Monthly</option>
</select>
</div>
<div class="form-group">
<label>Free Tier Limit Type</label>
<select onchange="updateProvider('${key}', 'free_tier_limit_type', this.value || null)">
<option value="" ${!provider.free_tier_limit_type ? 'selected' : ''}>Unspecified</option>
<option value="requests" ${provider.free_tier_limit_type === 'requests' ? 'selected' : ''}>Requests</option>
<option value="tokens" ${provider.free_tier_limit_type === 'tokens' ? 'selected' : ''}>Tokens</option>
</select>
</div>
<div class="form-group">
<label>Pro Tier Requests Daily</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_requests_daily || ''}" onchange="updateProvider('${key}', 'pro_tier_requests_daily', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Requests Weekly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_requests_weekly || ''}" onchange="updateProvider('${key}', 'pro_tier_requests_weekly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Requests Monthly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_requests_monthly || ''}" onchange="updateProvider('${key}', 'pro_tier_requests_monthly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Tokens Daily</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_tokens_daily || ''}" onchange="updateProvider('${key}', 'pro_tier_tokens_daily', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Tokens Weekly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_tokens_weekly || ''}" onchange="updateProvider('${key}', 'pro_tier_tokens_weekly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
<div class="form-group">
<label>Pro Tier Tokens Monthly</label>
<input type="number" step="1" min="0" value="${provider.pro_tier_tokens_monthly || ''}" onchange="updateProvider('${key}', 'pro_tier_tokens_monthly', this.value ? parseInt(this.value, 10) : null)" placeholder="Optional">
</div>
</div>
</div>
......@@ -1417,18 +1435,6 @@ function renderProviderDetails(key) {
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${provider.cache_enabled ? 'checked' : ''} onchange="setCacheSetting('${key}', null, this.checked)">
Enable Prompt Caching
</label>
<small style="color: var(--color-subtle); font-size: 12px;">
<strong>Provider-level cache override:</strong> Disabling this prevents the provider's native prompt
caching feature from being used, even if enabled in the provider configuration.
This takes priority over the global cache setting.
</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${provider.privacy ? 'checked' : ''} onchange="updateProvider('${key}', 'privacy', this.checked)">
......@@ -1436,6 +1442,17 @@ function renderProviderDetails(key) {
</label>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Feature Overrides</h4>
<small style="color: var(--color-muted); display: block; margin-bottom: 15px;">Per-provider tri-state overrides. Inherit follows the global default.</small>
${triStateSelect('Context Condensation', provider.enable_context_condensation, `updateProviderTriState('${key}', 'enable_context_condensation', this.value)`)}
${triStateSelect('Response Cache', provider.enable_response_cache, `updateProviderTriState('${key}', 'enable_response_cache', this.value)`)}
${triStateSelect('Prompt Batching', provider.enable_prompt_batching, `updateProviderTriState('${key}', 'enable_prompt_batching', this.value)`)}
${triStateSelect('Prompt Security', provider.enable_prompt_security, `updateProviderTriState('${key}', 'enable_prompt_security', this.value)`)}
${triStateSelect('Context Lens Analytics', provider.enable_context_lens, `updateProviderTriState('${key}', 'enable_context_lens', this.value)`)}
${triStateSelect('Block High-Risk Prompts', provider.block_high_risk_prompts, `updateProviderTriState('${key}', 'block_high_risk_prompts', this.value)`)}
${triStateSelect('NSFW Classification', provider.enable_nsfw_classification, `updateProviderTriState('${key}', 'enable_nsfw_classification', this.value)`)}
${triStateSelect('Privacy Classification', provider.enable_privacy_classification, `updateProviderTriState('${key}', 'enable_privacy_classification', this.value)`)}
<h4 style="margin-top: 20px; margin-bottom: 10px;">${window.i18n.t('providers.native_caching_section')}</h4>
<small style="color: var(--color-muted); display: block; margin-bottom: 15px;">
Provider-native caching features (Anthropic cache_control, Google Context Caching, OpenAI and Kilo-compatible APIs) for cost reduction.
......@@ -1608,15 +1625,16 @@ function renderModels(providerKey) {
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${model.cache_enabled ? 'checked' : ''} onchange="setCacheSetting('${providerKey}', '${model.name || model.id}', this.checked)">
Enable Prompt Caching
</label>
<small style="color: var(--color-subtle); font-size: 12px;">
<strong>Model-level cache override:</strong> Disabling this prevents prompt caching for this specific model.
Overrides both provider-level cache setting and global cache setting.
</small>
<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, `updateModelTriState('${providerKey}', ${index}, 'enable_context_condensation', this.value)`)}
${triStateSelect('Response Cache', model.enable_response_cache, `updateModelTriState('${providerKey}', ${index}, 'enable_response_cache', this.value)`)}
${triStateSelect('Prompt Batching', model.enable_prompt_batching, `updateModelTriState('${providerKey}', ${index}, 'enable_prompt_batching', this.value)`)}
${triStateSelect('Prompt Security', model.enable_prompt_security, `updateModelTriState('${providerKey}', ${index}, 'enable_prompt_security', this.value)`)}
${triStateSelect('Context Lens Analytics', model.enable_context_lens, `updateModelTriState('${providerKey}', ${index}, 'enable_context_lens', this.value)`)}
${triStateSelect('Block High-Risk Prompts', model.block_high_risk_prompts, `updateModelTriState('${providerKey}', ${index}, 'block_high_risk_prompts', this.value)`)}
${triStateSelect('NSFW Classification', model.enable_nsfw_classification, `updateModelTriState('${providerKey}', ${index}, 'enable_nsfw_classification', this.value)`)}
${triStateSelect('Privacy Classification', model.enable_privacy_classification, `updateModelTriState('${providerKey}', ${index}, 'enable_privacy_classification', this.value)`)}
</div>
<div class="form-group">
......@@ -1717,6 +1735,38 @@ function togglePricingFields(key) {
}
}
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); display:block; margin-top:5px;">${helpText}</small>` : ''}
</div>
`;
}
function updateTriState(target, field, value) {
target[field] = value === 'inherit' ? null : value === 'enabled';
}
function updateProviderTriState(key, field, value) {
updateTriState(providersData[key], field, value);
}
function updateModelTriState(providerKey, index, field, value) {
updateTriState(providersData[providerKey].models[index], field, value);
}
// Helper function to format expiration time
function formatExpirationTime(expiresAt) {
if (!expiresAt) return 'Unknown';
......@@ -2712,7 +2762,6 @@ async function saveProviders() {
}
async function initProviders() {
await loadCacheSettings();
renderProvidersList();
refreshAllUsage().then(() => renderProvidersList());
}
......
......@@ -412,7 +412,17 @@ function renderRotationDetails(rotationKey) {
${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>
<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
from aisbf.providers.anthropic import AnthropicProviderHandler
from aisbf.providers.google import GoogleProviderHandler
from types import SimpleNamespace
def test_anthropic_native_cache_helper_respects_user_override(monkeypatch):
handler = AnthropicProviderHandler(provider_id="anthropic-test", api_key="token", user_id=3)
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("claude-model") is False
def test_google_native_cache_helper_defaults_true_without_user(monkeypatch):
handler = GoogleProviderHandler(provider_id="google-test", api_key="token", user_id=9)
class ExplodingRegistry:
@staticmethod
def get_config_database():
raise AssertionError("should not be called")
monkeypatch.setattr("aisbf.providers.base.DatabaseRegistry", ExplodingRegistry)
assert handler._native_cache_user_allows("gemini-model") is True
def test_google_native_cache_helper_respects_user_override(monkeypatch):
handler = GoogleProviderHandler(provider_id="google-test", api_key="token", user_id=9)
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("gemini-model") is False
def test_anthropic_native_caching_adds_cache_control_markers(monkeypatch):
handler = AnthropicProviderHandler(provider_id="anthropic-test", api_key="token", user_id=3)
class MessagesStub:
def create(self, **kwargs):
self.kwargs = kwargs
return SimpleNamespace(content=[SimpleNamespace(text="ok")], usage=SimpleNamespace(input_tokens=10, output_tokens=5), stop_reason="end_turn", model="claude-test")
messages_api = MessagesStub()
handler.client = SimpleNamespace(messages=messages_api)
monkeypatch.setattr("aisbf.providers.anthropic.config.providers", {"anthropic-test": SimpleNamespace(enable_native_caching=True, min_cacheable_tokens=1)})
monkeypatch.setattr("aisbf.providers.anthropic.count_messages_tokens", lambda messages, model: 10)
monkeypatch.setattr(handler, "_native_cache_user_allows", lambda model_name=None: True)
monkeypatch.setattr(handler, "record_success", lambda: None)
result = __import__("asyncio").run(handler.handle_request(
model="claude-test",
messages=[
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "second"},
{"role": "user", "content": "third"},
],
stream=False,
))
assert result["choices"][0]["message"]["content"] == "ok"
api_params = messages_api.kwargs
assert isinstance(api_params["system"], list)
assert api_params["system"][0]["cache_control"]["type"] == "ephemeral"
assert api_params["messages"][0]["content"][-1]["cache_control"]["type"] == "ephemeral"
def test_google_native_caching_sets_pending_cache_key_only_when_allowed(monkeypatch):
handler = GoogleProviderHandler(provider_id="google-test", api_key="token", user_id=9)
cache_key_calls = []
class ModelsStub:
def generate_content(self, **kwargs):
self.kwargs = kwargs
return SimpleNamespace(
candidates=[SimpleNamespace(content=SimpleNamespace(parts=[SimpleNamespace(text="ok")]), finish_reason="STOP")],
usage_metadata=SimpleNamespace(prompt_token_count=10, candidates_token_count=5, total_token_count=15),
)
models_api = ModelsStub()
handler.client = SimpleNamespace(models=models_api)
monkeypatch.setattr("aisbf.providers.google.config.providers", {"google-test": SimpleNamespace(enable_native_caching=True, cache_ttl=60, min_cacheable_tokens=1)})
monkeypatch.setattr("aisbf.providers.google.count_messages_tokens", lambda messages, model: 10)
monkeypatch.setattr(handler, "_generate_cache_key", lambda messages, model: cache_key_calls.append((messages, model)) or "cache-key")
monkeypatch.setattr(handler, "_create_cached_content", lambda messages, model, ttl: None)
monkeypatch.setattr(handler, "record_success", lambda: None)
monkeypatch.setattr(handler, "_native_cache_user_allows", lambda model_name=None: False)
__import__("asyncio").run(handler.handle_request(
model="gemini-test",
messages=[{"role": "user", "content": "hello"}],
stream=False,
))
assert handler._pending_cache_key is None
assert cache_key_calls == []
monkeypatch.setattr(handler, "_native_cache_user_allows", lambda model_name=None: True)
__import__("asyncio").run(handler.handle_request(
model="gemini-test",
messages=[{"role": "user", "content": "hello again"}],
stream=False,
))
assert len(cache_key_calls) == 1
assert models_api.kwargs["model"] == "gemini-test"
......@@ -4,6 +4,8 @@ import sys
from base64 import b64encode
from pathlib import Path
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from itsdangerous import TimestampSigner
from fastapi.testclient import TestClient
......@@ -16,6 +18,24 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from main import app
class TemplateCapture:
def __init__(self):
self.calls = []
self._env = Environment(
loader=FileSystemLoader(str(Path(__file__).resolve().parents[2] / "templates")),
autoescape=select_autoescape(["html", "xml"]),
)
self._env.globals["url_for"] = lambda request, path, **kwargs: f"{request.scope.get('root_path', '')}{path}"
def TemplateResponse(self, *args, **kwargs):
request = kwargs["request"]
name = kwargs["name"]
context = kwargs["context"]
self.calls.append({"request": request, "name": name, "context": context})
template = self._env.get_template(name)
return HTMLResponse(template.render(**context))
class DummyOpen:
def __init__(self, sink):
self.sink = sink
......@@ -101,6 +121,7 @@ async def _clear_broker_sessions():
class DbStub:
def __init__(self):
self.saved = {}
self.events = []
def save_user_provider(self, user_id, provider_id, provider_config):
self.saved[(user_id, provider_id)] = provider_config
......@@ -112,6 +133,10 @@ class DbStub:
def get_user_by_id(self, user_id):
return {"id": user_id, "username": f"user{user_id}"}
def record_dashboard_event(self, **kwargs):
self.events.append(kwargs)
return len(self.events)
class RegistryStub:
def __init__(self, db):
......@@ -121,6 +146,42 @@ class RegistryStub:
return self._db
class ProvidersMutationDbStub(DbStub):
def __init__(self):
super().__init__()
self.user_rotations = {}
self.user_autoselects = {}
def save_user_rotation(self, user_id, rotation_id, rotation_config):
self.user_rotations[(user_id, rotation_id)] = rotation_config
def delete_user_rotation(self, user_id, rotation_id):
self.user_rotations.pop((user_id, rotation_id), None)
def get_user_rotations(self, user_id):
return [
{"rotation_id": rotation_id, "config": config}
for (uid, rotation_id), config in self.user_rotations.items()
if uid == user_id
]
def save_user_autoselect(self, user_id, autoselect_id, autoselect_config):
self.user_autoselects[(user_id, autoselect_id)] = autoselect_config
def delete_user_autoselect(self, user_id, autoselect_id):
self.user_autoselects.pop((user_id, autoselect_id), None)
def get_user_autoselects(self, user_id):
return [
{"autoselect_id": autoselect_id, "config": config}
for (uid, autoselect_id), config in self.user_autoselects.items()
if uid == user_id
]
def get_sort_order(self, user_id, resource_type):
return None
def test_api_provider_save_rejects_coderai_broker_without_registration_token(monkeypatch):
client = TestClient(app)
_login_as_admin(client)
......@@ -228,6 +289,8 @@ def test_dashboard_providers_bulk_save_generates_and_persists_coderai_token(monk
def test_dashboard_providers_page_includes_broker_status_for_coderai(monkeypatch):
client = TestClient(app)
_login_as_admin(client)
capture = TemplateCapture()
asyncio.run(_clear_broker_sessions())
original_provider = config.providers.get("coderai")
config.providers["coderai"] = ProviderConfig(
id="coderai",
......@@ -238,6 +301,7 @@ def test_dashboard_providers_page_includes_broker_status_for_coderai(monkeypatch
rate_limit=0,
coderai_config={"registration_token": "global-token"},
)
monkeypatch.setattr(dashboard_providers, "_templates", capture)
async def scenario():
class StubWebSocket:
......@@ -272,12 +336,31 @@ def test_dashboard_providers_page_includes_broker_status_for_coderai(monkeypatch
asyncio.run(_clear_broker_sessions())
asyncio.run(scenario())
try:
class RequestStub:
session = {"logged_in": True, "user_id": None}
broker_response = asyncio.run(dashboard_providers.api_coderai_broker_sessions(RequestStub()))
sessions = json.loads(broker_response.body)["sessions"]
coderai_session = next(session for session in sessions if session.get("client_id") == "workstation-01")
metadata = coderai_session["metadata"]
response = client.get("/dashboard/providers")
assert response.status_code == 200
assert "Broker Session Status" in response.text
assert "workstation-01" in response.text
assert "RTX 4090" in response.text
assert "54.6 tok/s" in response.text
assert metadata["gpu_count"] == 1
assert metadata["gpus"][0]["name"] == "RTX 4090"
status_map = asyncio.run(dashboard_providers._load_coderai_broker_status_map())
assert "coderai" in status_map
assert any(session.get("client_id") == "workstation-01" for session in sessions)
augmented = dashboard_providers._augment_provider_broker_status(
"coderai",
config.providers["coderai"].model_dump(),
{"coderai": coderai_session},
)
broker_session = augmented["coderai_config"]["broker_session"]
assert broker_session["connected"] is True
assert broker_session["client_id"] == "workstation-01"
assert broker_session["metadata"]["gpu_count"] == 1
finally:
asyncio.run(_clear_broker_sessions())
if original_provider is None:
......@@ -286,6 +369,39 @@ def test_dashboard_providers_page_includes_broker_status_for_coderai(monkeypatch
config.providers["coderai"] = original_provider
def test_augment_provider_broker_status_marks_disconnected_persisted_session_offline():
provider_config = {
"id": "coderai",
"type": "coderai",
"coderai_config": {"registration_token": "global-token"},
}
disconnected_session = {
"session_id": "coderai_stale",
"provider_id": "coderai",
"client_id": "workstation-01",
"connected": False,
"connected_at": 1747079542,
"last_seen": 1747079544,
"metadata": {
"owner_user_id": None,
"transport": "websocket",
"endpoint": "ws://nat-client",
"connection_state": "disconnected",
},
"performance": {},
}
augmented = dashboard_providers._augment_provider_broker_status(
"coderai",
provider_config,
{"coderai": disconnected_session},
)
broker_session = augmented["coderai_config"]["broker_session"]
assert broker_session["connected"] is False
assert broker_session["connection_state"] == "disconnected"
def test_dashboard_api_coderai_broker_sessions_filters_by_owner(monkeypatch):
async def scenario():
class StubWebSocket:
......@@ -309,3 +425,214 @@ def test_dashboard_api_coderai_broker_sessions_filters_by_owner(monkeypatch):
assert any(session["provider_id"] == "user-coderai" for session in sessions)
finally:
asyncio.run(_clear_broker_sessions())
def test_provider_coderai_token_rotate_records_saved_event_for_user(monkeypatch):
client = TestClient(app)
_login_as_user(client)
recorded_events = []
db = ProvidersMutationDbStub()
db.saved[(17, "coderai-extra")] = {
"id": "coderai-extra",
"type": "coderai",
"coderai_config": {"registration_token": "old-token"},
}
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(
dashboard_providers,
"_record_dashboard_event",
lambda request, event_type, **kwargs: recorded_events.append({"event_type": event_type, **kwargs}),
)
response = client.post("/dashboard/api/provider/coderai-extra/coderai-token")
assert response.status_code == 200
assert recorded_events[-1]["event_type"] == "provider_saved"
assert recorded_events[-1]["provider_id"] == "coderai-extra"
assert recorded_events[-1]["metadata"]["source"] == "coderai-token"
assert db.saved[(17, "coderai-extra")]["coderai_config"]["registration_token"] != "old-token"
def test_provider_delete_records_removed_event_for_global(monkeypatch):
client = TestClient(app)
_login_as_admin(client)
recorded_events = []
saved_payload = {"providers": {"demo": {"id": "demo", "type": "openai"}}}
db = ProvidersMutationDbStub()
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_reload_global_config", lambda: None)
monkeypatch.setattr(dashboard_providers, "_providers_json_path", lambda: Path("/tmp/providers-source.json"))
monkeypatch.setattr(dashboard_providers, "open", lambda *args, **kwargs: DummyOpen(saved_payload), raising=False)
monkeypatch.setattr(
dashboard_providers,
"_record_dashboard_event",
lambda request, event_type, **kwargs: recorded_events.append({"event_type": event_type, **kwargs}),
)
response = client.delete("/dashboard/api/provider/demo")
assert response.status_code == 200
assert recorded_events[-1]["event_type"] == "provider_removed"
assert recorded_events[-1]["provider_id"] == "demo"
def test_rotation_save_and_delete_record_correct_global_events(monkeypatch):
client = TestClient(app)
_login_as_admin(client)
recorded_events = []
saved_payload = {"rotations": {}}
db = ProvidersMutationDbStub()
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_reload_global_config", lambda: None)
monkeypatch.setattr(dashboard_providers, "_rotations_json_path", lambda: Path("/tmp/rotations-source.json"))
monkeypatch.setattr(dashboard_providers, "open", lambda *args, **kwargs: DummyOpen(saved_payload), raising=False)
monkeypatch.setattr(
dashboard_providers,
"_record_dashboard_event",
lambda request, event_type, **kwargs: recorded_events.append({"event_type": event_type, **kwargs}),
)
save_response = client.post(
"/dashboard/api/rotation",
json={"rotation_id": "rot-a", "config": {"providers": []}},
)
delete_response = client.delete("/dashboard/api/rotation/rot-a")
assert save_response.status_code == 200
assert delete_response.status_code == 200
assert recorded_events[-2]["event_type"] == "rotation_saved"
assert recorded_events[-2]["rotation_id"] == "rot-a"
assert recorded_events[-1]["event_type"] == "rotation_removed"
assert recorded_events[-1]["rotation_id"] == "rot-a"
def test_autoselect_save_and_delete_record_correct_global_events(monkeypatch):
client = TestClient(app)
_login_as_admin(client)
recorded_events = []
saved_payload = {}
db = ProvidersMutationDbStub()
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_reload_global_config", lambda: None)
monkeypatch.setattr(dashboard_providers, "_autoselect_json_path", lambda: Path("/tmp/autoselect-source.json"))
monkeypatch.setattr(dashboard_providers, "open", lambda *args, **kwargs: DummyOpen(saved_payload), raising=False)
monkeypatch.setattr(
dashboard_providers,
"_record_dashboard_event",
lambda request, event_type, **kwargs: recorded_events.append({"event_type": event_type, **kwargs}),
)
save_response = client.post(
"/dashboard/api/autoselect",
json={
"autoselect_id": "auto-a",
"config": {"selection_model": "internal", "available_models": [{"model_id": "demo/model", "priority": 1, "description": "Demo"}]},
},
)
delete_response = client.delete("/dashboard/api/autoselect/auto-a")
assert save_response.status_code == 200
assert delete_response.status_code == 200
assert recorded_events[-2]["event_type"] == "autoselect_saved"
assert recorded_events[-2]["autoselect_id"] == "auto-a"
assert recorded_events[-1]["event_type"] == "autoselect_removed"
assert recorded_events[-1]["autoselect_id"] == "auto-a"
def test_user_rotation_save_persists_tri_state_feature_overrides(monkeypatch):
client = TestClient(app)
_login_as_user(client)
db = ProvidersMutationDbStub()
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
response = client.post(
"/dashboard/api/rotation",
json={
"rotation_id": "rot-user",
"config": {
"providers": [
{
"provider_id": "demo-provider",
"models": [
{
"name": "demo-model",
"enable_context_condensation": True,
"enable_response_cache": False,
"enable_prompt_security": True,
"enable_context_lens": False,
}
],
}
],
"enable_prompt_batching": True,
"block_high_risk_prompts": False,
"enable_nsfw_classification": True,
"enable_privacy_classification": False,
},
},
)
assert response.status_code == 200
saved = db.user_rotations[(17, "rot-user")]
assert saved["enable_prompt_batching"] is True
assert saved["block_high_risk_prompts"] is False
assert saved["enable_nsfw_classification"] is True
assert saved["enable_privacy_classification"] is False
model = saved["providers"][0]["models"][0]
assert model["enable_context_condensation"] is True
assert model["enable_response_cache"] is False
assert model["enable_prompt_security"] is True
assert model["enable_context_lens"] is False
def test_user_autoselect_save_persists_tri_state_feature_overrides(monkeypatch):
client = TestClient(app)
_login_as_user(client)
db = ProvidersMutationDbStub()
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
response = client.post(
"/dashboard/api/autoselect",
json={
"autoselect_id": "auto-user",
"config": {
"selection_model": "internal",
"enable_context_condensation": True,
"enable_response_cache": False,
"enable_prompt_security": True,
"enable_context_lens": False,
"block_high_risk_prompts": True,
"available_models": [
{
"model_id": "demo/model",
"description": "Demo",
"enable_prompt_batching": True,
"enable_nsfw_classification": False,
"enable_privacy_classification": True,
}
],
},
},
)
assert response.status_code == 200
saved = db.user_autoselects[(17, "auto-user")]
assert saved["enable_context_condensation"] is True
assert saved["enable_response_cache"] is False
assert saved["enable_prompt_security"] is True
assert saved["enable_context_lens"] is False
assert saved["block_high_risk_prompts"] is True
model = saved["available_models"][0]
assert model["enable_prompt_batching"] is True
assert model["enable_nsfw_classification"] is False
assert model["enable_privacy_classification"] is True
import json
import sys
from base64 import b64encode
from datetime import datetime, timedelta
from pathlib import Path
from fastapi.responses import HTMLResponse
......@@ -9,11 +10,13 @@ from itsdangerous import TimestampSigner
from jinja2 import Environment, FileSystemLoader, select_autoescape
from aisbf.routes.dashboard import market as dashboard_market
from aisbf.routes.dashboard import admin as dashboard_admin
from aisbf.routes.dashboard import providers as dashboard_providers
from aisbf.routes.dashboard import settings as dashboard_settings
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from main import app
from aisbf.database import DatabaseManager
class TemplateCapture:
......@@ -94,6 +97,36 @@ class DashboardProxyDbStub:
"is_active": True,
}
]
self.recorded_events = []
self.dashboard_event_summary = {
"total_events": 4,
"unique_ips": 2,
"unique_visitors": 2,
"unique_sessions": 3,
"by_type": [
{"event_type": "provider_removed", "count": 1},
{"event_type": "rotation_saved_updated", "count": 1},
{"event_type": "autoselect_saved_created", "count": 1},
{"event_type": "request_proxied", "count": 1},
],
"pages": [{"path": "/dashboard/providers", "count": 2}],
"countries": [{"country_code": "IT", "count": 2}],
"users": [{"username": "admin", "user_id": None, "count": 4}],
}
self.dashboard_event_series = [
{"bucket": "2026-05-12 10:00:00", "events": 4, "unique_ips": 2, "unique_visitors": 2}
]
self.dashboard_event_rows = [
{"event_type": "provider_removed", "path": "/dashboard/api/provider/demo", "metadata": {"source": "api"}},
{"event_type": "rotation_saved_updated", "path": "/dashboard/rotations", "metadata": {"source": "bulk"}},
{"event_type": "autoselect_saved_created", "path": "/dashboard/autoselect", "metadata": {"source": "bulk"}},
]
self.summary_calls = []
self.series_calls = []
self.events_calls = []
self.prompt_summary_calls = []
self.prompt_series_calls = []
def get_market_settings(self):
return dict(self.market_settings)
......@@ -113,6 +146,9 @@ class DashboardProxyDbStub:
def get_user_autoselects(self, user_id):
return [dict(row) for row in self.user_autoselects]
def get_all_user_cache_settings(self, user_id):
return []
def list_market_listings(self, active_only=False):
return [dict(row) for row in self.market_listings]
......@@ -143,6 +179,95 @@ class DashboardProxyDbStub:
def get_market_listing_stats(self, listing_id):
return {"gross_revenue": 4.0, "total_requests": 2}
def record_dashboard_event(self, **kwargs):
self.recorded_events.append(kwargs)
return len(self.recorded_events)
def get_dashboard_event_summary(self, *args, **kwargs):
self.summary_calls.append(kwargs)
return dict(self.dashboard_event_summary)
def get_dashboard_event_series(self, *args, **kwargs):
self.series_calls.append(kwargs)
return [dict(row) for row in self.dashboard_event_series]
def get_dashboard_events(self, *args, **kwargs):
self.events_calls.append(kwargs)
return [dict(row) for row in self.dashboard_event_rows]
def get_users(self):
return [
{"id": 1, "username": "alice", "display_name": "Alice", "role": "user"},
{"id": 2, "username": "bob", "display_name": "Bob", "role": "user"},
]
def get_prompt_analysis_summary(self, *args, **kwargs):
self.prompt_summary_calls.append(kwargs)
return {
"total_runs": 3,
"blocked_runs": 1,
"high_risk_runs": 1,
"avg_prompt_tokens": 120.0,
"avg_effective_context": 180.0,
"findings": [{"category": "role_hijack_ignore", "severity": "high", "count": 1}],
}
def get_prompt_analysis_series(self, *args, **kwargs):
self.prompt_series_calls.append(kwargs)
return [{"bucket": "2026-05-12 10:00:00", "runs": 3, "high_risk": 1, "blocked": 1}]
def get_prompt_analysis_details(self, *args, **kwargs):
return {
"provider_breakdown": [
{"provider_id": "provider-a", "runs": 2, "blocked": 1, "high_risk": 1, "avg_prompt_tokens": 120.0, "avg_effective_context": 180.0}
],
"model_breakdown": [
{"model_name": "model-a", "runs": 2, "blocked": 1, "high_risk": 1}
],
"risk_breakdown": [
{"risk_level": "high", "count": 1},
{"risk_level": "none", "count": 2},
],
"segment_breakdown": [
{"role": "user", "count": 2},
{"role": "system", "count": 1},
],
"shape_breakdown": [
{"prompt_shape": "assistant+system+user", "count": 2}
],
"recent_runs": [
{
"id": 1,
"created_at": "2026-05-12 10:00:00",
"user_id": 1,
"provider_id": "provider-a",
"model_name": "model-a",
"rotation_id": "rotation-a",
"autoselect_id": "auto-a",
"blocked": True,
"risk_level": "high",
"risk_score": 90,
"prompt_tokens": 120,
"effective_context": 180,
"findings_count": 1,
"prompt_shape": "assistant+system+user",
"largest_segment_role": "user",
"has_tools": True,
"has_system_prompt": True,
"high_count": 1,
"medium_count": 0,
"info_count": 0,
"findings": [{"category": "role_hijack_ignore", "severity": "high", "count": 1}],
}
],
"posture": {
"runs_with_tools": 1,
"runs_with_system_prompt": 2,
"avg_findings_count": 0.3,
"avg_risk_score": 30.0,
},
}
class RegistryStub:
def __init__(self, db):
......@@ -171,6 +296,32 @@ class DashboardSettingsRegistryStub(RegistryStub):
pass
class AnalyticsStub:
def get_all_providers_stats(self, *args, **kwargs):
return []
def get_token_usage_over_time(self, *args, **kwargs):
return []
def get_model_performance(self, *args, **kwargs):
return []
def get_cost_overview(self, *args, **kwargs):
return {"total_estimated_cost_today": 0, "date_range": None, "providers": []}
def get_savings_overview(self, *args, **kwargs):
return None
def get_token_usage_by_date_range(self, *args, **kwargs):
return None
def get_rotation_breakdown(self, *args, **kwargs):
return []
def get_autoselect_breakdown(self, *args, **kwargs):
return []
def _find_session_secret() -> str:
for middleware in app.user_middleware:
kwargs = getattr(middleware, "kwargs", {})
......@@ -381,6 +532,14 @@ def test_analytics_page_uses_proxy_aware_search_delete_and_back_urls(monkeypatch
"token_over_time": "[]",
"rotation_breakdown": [],
"autoselect_breakdown": [],
"dashboard_visit_overview": None,
"dashboard_visit_series": "[]",
"dashboard_visit_pages": [],
"dashboard_visit_countries": [],
"dashboard_visit_users": [],
"dashboard_visit_events": [],
"prompt_analysis_overview": None,
"prompt_analysis_series": "[]",
},
)
......@@ -390,6 +549,285 @@ def test_analytics_page_uses_proxy_aware_search_delete_and_back_urls(monkeypatch
assert "fetch(`${BASE_PATH}/api/users/search?q=${encodeURIComponent(query)}`)" in response_text
assert "fetch(`${BASE_PATH}/api/admin/analytics/delete-${scope}`, {method: 'POST'})" in response_text
assert '<a href="/proxy/app/dashboard" class="btn btn-secondary" data-i18n="analytics_page.back">Back to Dashboard</a>' in response_text
assert '<a href="/proxy/app/dashboard/prompt-analytics" class="btn btn-secondary" style="text-decoration: none;">' in response_text
assert '<a href="/proxy/app/dashboard/traffic-visits" class="btn btn-secondary" style="text-decoration: none;">' in response_text
assert "fetch(`/api/users/search?q=${encodeURIComponent(query)}`)" not in response_text
assert "fetch('/api/admin/analytics/delete-' + scope" not in response_text
assert '<a href="/dashboard" class="btn btn-secondary" data-i18n="analytics_page.back">Back to Dashboard</a>' not in response_text
def test_bulk_global_saves_record_expected_dashboard_events(monkeypatch):
events = []
request = type(
"Req",
(),
{"url": type("URL", (), {"path": "/dashboard/test"})(), "session": {"user_id": None, "username": "admin"}, "method": "POST"},
)()
dashboard_providers._record_dashboard_event = lambda request, event_type, **kwargs: events.append({"event_type": event_type, **kwargs})
existing_providers = {"legacy": {"type": "openai"}, "shared": {"type": "openai", "name": "Before"}}
new_providers = {"shared": {"type": "openai", "name": "After"}, "fresh": {"type": "openai", "name": "Fresh"}}
for provider_key in set(existing_providers.keys()) - set(new_providers.keys()):
dashboard_providers._record_dashboard_event(request, "provider_removed", provider_id=provider_key, metadata={"scope": "global", "source": "bulk"})
for provider_key, provider_config in new_providers.items():
event_type = dashboard_providers._resource_change_event(existing_providers.get(provider_key), provider_config, "provider_saved", "provider_removed")
dashboard_providers._record_dashboard_event(request, event_type, provider_id=provider_key, metadata={"scope": "global", "provider_type": provider_config.get("type"), "source": "bulk"})
existing_rotations = {"old-rot": {"providers": []}, "keep-rot": {"providers": []}}
new_rotations = {"keep-rot": {"providers": [{"provider_id": "shared", "models": [], "weight": 1}]}, "new-rot": {"providers": []}}
for rotation_key in set(existing_rotations.keys()) - set(new_rotations.keys()):
dashboard_providers._record_dashboard_event(request, "rotation_removed", rotation_id=rotation_key, metadata={"scope": "global", "source": "bulk"})
for rotation_key, rotation_config in new_rotations.items():
event_type = dashboard_providers._resource_change_event(existing_rotations.get(rotation_key), rotation_config, "rotation_saved", "rotation_removed")
dashboard_providers._record_dashboard_event(request, event_type, rotation_id=rotation_key, metadata={"scope": "global", "source": "bulk"})
existing_autoselects = {"old-auto": {"selection_model": "internal", "available_models": []}, "keep-auto": {"selection_model": "internal", "available_models": []}}
new_autoselects = {
"keep-auto": {"selection_model": "internal", "available_models": [{"model_id": "shared/model", "priority": 1, "description": "Updated"}]},
"new-auto": {"selection_model": "internal", "available_models": [{"model_id": "fresh/model", "priority": 1, "description": "Created"}]},
}
for autoselect_key in set(existing_autoselects.keys()) - set(new_autoselects.keys()):
dashboard_providers._record_dashboard_event(request, "autoselect_removed", autoselect_id=autoselect_key, metadata={"scope": "global", "source": "bulk"})
for autoselect_key, autoselect_config in new_autoselects.items():
event_type = dashboard_providers._resource_change_event(existing_autoselects.get(autoselect_key), autoselect_config, "autoselect_saved", "autoselect_removed")
dashboard_providers._record_dashboard_event(request, event_type, autoselect_id=autoselect_key, metadata={"scope": "global", "source": "bulk"})
event_types = [event["event_type"] for event in events]
assert "provider_removed" in event_types
assert "provider_saved_updated" in event_types
assert "provider_saved_created" in event_types
assert "rotation_removed" in event_types
assert "rotation_saved_updated" in event_types
assert "rotation_saved_created" in event_types
assert "autoselect_removed" in event_types
assert "autoselect_saved_updated" in event_types
assert "autoselect_saved_created" in event_types
def test_admin_analytics_page_renders_corrected_dashboard_event_types(monkeypatch):
db = DashboardProxyDbStub()
capture = TemplateCapture()
client = TestClient(app)
_login_as_admin(client)
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/analytics")
assert response.status_code == 200
context = capture.calls[-1]["context"]
assert context["dashboard_visit_overview"]["by_type"][0]["event_type"] == "provider_removed"
assert any(row["event_type"] == "rotation_saved_updated" for row in context["dashboard_visit_overview"]["by_type"])
assert any(row["event_type"] == "autoselect_saved_created" for row in context["dashboard_visit_overview"]["by_type"])
assert context["dashboard_visit_events"][0]["event_type"] == "provider_removed"
assert "Prompt Analytics" in response.text
assert "Traffic &amp; Visits" in response.text
assert "provider_removed" not in response.text
def test_user_analytics_page_hides_admin_only_visitor_activity_sections(monkeypatch):
db = DashboardProxyDbStub()
capture = TemplateCapture()
client = TestClient(app)
_login_as_user(client)
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/analytics")
assert response.status_code == 200
context = capture.calls[-1]["context"]
assert context["is_config_admin"] is False
assert context["dashboard_visit_overview"] is None
assert context["dashboard_visit_events"] == []
assert "Prompt Analytics" in response.text
assert "Traffic &amp; Visits" not in response.text
assert "Dashboard Visitor & Activity Tracking" not in response.text
assert "Visitor & Dashboard Activity Overview" not in response.text
def test_admin_analytics_page_forwards_activity_filters_to_dashboard_queries(monkeypatch):
db = DashboardProxyDbStub()
capture = TemplateCapture()
client = TestClient(app)
_login_as_admin(client)
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get(
"/dashboard/analytics",
params={
"activity_event_filter": "provider_removed",
"activity_path_filter": "/dashboard/api/provider/demo",
"activity_country_filter": "IT",
"provider_filter": "provider-a",
"rotation_filter": "rotation-a",
"autoselect_filter": "auto-a",
"user_filter": "11",
},
)
assert response.status_code == 200
assert db.summary_calls
assert db.series_calls
assert db.events_calls
summary_call = db.summary_calls[-1]
events_call = db.events_calls[-1]
assert summary_call["event_types"] == ["provider_removed"]
assert summary_call["provider_filter"] == "provider-a"
assert summary_call["rotation_filter"] == "rotation-a"
assert summary_call["autoselect_filter"] == "auto-a"
assert summary_call["path_filter"] == "/dashboard/api/provider/demo"
assert summary_call["country_filter"] == "IT"
assert summary_call["user_id"] == 11
assert events_call["event_types"] == ["provider_removed"]
context = capture.calls[-1]["context"]
assert context["activity_event_filter"] == "provider_removed"
assert context["activity_path_filter"] == "/dashboard/api/provider/demo"
assert context["activity_country_filter"] == "IT"
def test_analytics_page_renders_prompt_analysis_sections_for_user_scope(monkeypatch):
db = DashboardProxyDbStub()
capture = TemplateCapture()
client = TestClient(app)
_login_as_user(client)
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/prompt-analytics")
assert response.status_code == 200
assert capture.calls[-1]["name"] == "dashboard/prompt_analytics.html"
context = capture.calls[-1]["context"]
assert context["prompt_analysis_overview"]["total_runs"] == 3
assert context["prompt_analysis_details"]["provider_breakdown"][0]["provider_id"] == "provider-a"
assert db.prompt_summary_calls
assert db.prompt_series_calls
assert "Prompt Analytics" in response.text
assert "Top Prompt Findings" in response.text
assert "Recent Prompt Analysis Runs" in response.text
assert "Providers Under Prompt Pressure" in response.text
def test_admin_analytics_page_renders_tabbed_prompt_and_traffic_sections(monkeypatch):
db = DashboardProxyDbStub()
capture = TemplateCapture()
client = TestClient(app)
_login_as_admin(client)
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
response = client.get("/dashboard/traffic-visits")
assert response.status_code == 200
assert "Traffic &amp; Visits" in response.text
assert "Prompt Analytics" in response.text
assert "Visitor &amp; Dashboard Activity Overview" in response.text
def test_user_cache_settings_page_labels_native_legacy_scope(monkeypatch):
db = DashboardProxyDbStub()
capture = TemplateCapture()
client = TestClient(app)
_login_as_user(client)
monkeypatch.setattr(dashboard_admin, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_admin, "_templates", capture)
response = client.get("/dashboard/cache-settings", headers=_forwarded_prefix_headers())
assert response.status_code == 200
assert capture.calls[-1]["name"] == "dashboard/cache_settings.html"
assert "Native Prompt Cache Settings" in response.text
assert "legacy user-specific native prompt-caching overrides" in response.text
assert "The newer request-time response-cache policy now lives in the dashboard feature overrides" in response.text
assert "Enable Native Prompt Caching" in response.text
def test_admin_analytics_page_with_real_db_events_and_global_only_filter(monkeypatch, tmp_path):
db = DatabaseManager({
"type": "sqlite",
"sqlite_path": str(tmp_path / "analytics-route.db"),
})
capture = TemplateCapture()
client = TestClient(app)
_login_as_admin(client)
user_id = db.create_user("alice", "hash", role="user", email="alice@example.test", display_name="Alice")
now = datetime.now()
with db._get_connection() as conn:
cursor = conn.cursor()
rows = [
(
"request_proxied",
"/api/provider-a/chat/completions",
user_id,
"alice",
"sess-user",
"1.1.1.1",
"IT",
"POST",
200,
"provider-a",
"rotation-a",
"auto-a",
None,
None,
"{}",
db._format_dt_db(now - timedelta(minutes=30)),
),
(
"provider_removed",
"/dashboard/api/provider/demo",
None,
"admin",
"sess-admin",
"2.2.2.2",
"US",
"DELETE",
200,
"provider-b",
None,
None,
None,
None,
"{}",
db._format_dt_db(now - timedelta(minutes=10)),
),
]
for row in rows:
cursor.execute(
"""
INSERT INTO dashboard_events (
event_type, path, user_id, username, session_id, ip_address, country_code,
method, status_code, provider_id, rotation_id, autoselect_id, listing_id,
target_user_id, metadata, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
row,
)
conn.commit()
monkeypatch.setattr(dashboard_providers, "DatabaseRegistry", RegistryStub(db))
monkeypatch.setattr(dashboard_providers, "_templates", capture)
monkeypatch.setattr("aisbf.analytics.get_analytics", lambda db_instance=None: AnalyticsStub())
response = client.get("/dashboard/traffic-visits", params={"global_only": "1", "activity_event_filter": "provider_removed"})
assert response.status_code == 200
context = capture.calls[-1]["context"]
assert context["is_config_admin"] is True
assert context["global_only"] == "1"
assert context["dashboard_visit_overview"]["total_events"] == 1
assert context["dashboard_visit_overview"]["users"][0]["username"] == "admin"
assert context["dashboard_visit_events"][0]["event_type"] == "provider_removed"
assert "provider_removed" in response.text
assert "request_proxied" not in response.text
import json
from base64 import b64encode
from pathlib import Path
from fastapi.testclient import TestClient
from itsdangerous import TimestampSigner
from aisbf.routes.dashboard import settings as dashboard_settings
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from main import app
def _find_session_secret() -> str:
for middleware in app.user_middleware:
kwargs = getattr(middleware, "kwargs", {})
secret_key = kwargs.get("secret_key")
if secret_key:
return secret_key
raise AssertionError("Session middleware secret key not found")
def _set_session_cookie(client: TestClient, data: dict) -> None:
signer = TimestampSigner(_find_session_secret())
serialized = b64encode(json.dumps(data).encode("utf-8"))
signed = signer.sign(serialized).decode("utf-8")
client.cookies.set("session", signed)
def _login_as_admin(client: TestClient) -> None:
_set_session_cookie(
client,
{
"logged_in": True,
"username": "admin",
"role": "admin",
"user_id": None,
"expires_at": 4102444800,
},
)
def test_dashboard_settings_save_persists_feature_controls(tmp_path, monkeypatch):
cfg_path = tmp_path / "aisbf.json"
cfg_path.write_text(json.dumps({
"server": {"host": "127.0.0.1", "port": 17765, "protocol": "http"},
"auth": {"enabled": False, "tokens": []},
"dashboard": {"username": "admin", "password": "hash"},
"internal_model": {
"condensation_model_id": "internal-condense",
"autoselect_model_id": "internal-autoselect",
"semantic_vectorization": "sentence-transformers/all-MiniLM-L6-v2",
},
"database": {"type": "sqlite", "sqlite_path": "~/.aisbf/aisbf.db", "mysql_host": "localhost", "mysql_port": 3306, "mysql_user": "aisbf", "mysql_password": "", "mysql_database": "aisbf"},
"cache": {"type": "file", "redis_host": "localhost", "redis_port": 6379, "redis_db": 0, "redis_password": "", "redis_key_prefix": "aisbf:"},
"response_cache": {"enabled": True, "backend": "memory", "ttl": 600, "max_memory_cache": 1000, "redis_host": "localhost", "redis_port": 6379, "redis_db": 0, "redis_password": "", "redis_key_prefix": "aisbf:response:", "sqlite_path": "~/.aisbf/response_cache.db", "mysql_host": "localhost", "mysql_port": 3306, "mysql_user": "aisbf", "mysql_password": "", "mysql_database": "aisbf_response_cache"},
"mcp": {"enabled": False, "autoselect_tokens": [], "fullconfig_tokens": []},
"tor": {"enabled": False, "control_port": 9051, "control_host": "127.0.0.1", "control_password": None, "hidden_service_dir": None, "hidden_service_port": 80, "socks_port": 9050, "socks_host": "127.0.0.1"},
"signup": {"enabled": False, "require_email_verification": False, "verification_token_expiry_hours": 24},
"smtp": {"enabled": False, "host": "", "port": 587, "username": "", "password": "", "use_tls": True, "use_ssl": False, "from_email": "", "from_name": "AISBF"},
"oauth2": {"google": {"enabled": False, "client_id": "", "client_secret": ""}, "github": {"enabled": False, "client_id": "", "client_secret": ""}},
"batching": {"enabled": False, "window_ms": 100, "max_batch_size": 8, "provider_settings": {"openai": {"enabled": False, "max_batch_size": 10}, "anthropic": {"enabled": False, "max_batch_size": 5}}},
"adaptive_rate_limiting": {"enabled": False, "initial_rate_limit": 0, "learning_rate": 0.1, "headroom_percent": 10, "recovery_rate": 0.05, "max_rate_limit": 60, "min_rate_limit": 0.1, "backoff_base": 2, "jitter_factor": 0.25, "history_window": 3600, "consecutive_successes_for_recovery": 10},
"client_rate_limiting": {"enabled": False, "api": {"requests_per_minute": 60, "requests_per_hour": 1000}, "general": {"requests_per_minute": 120, "requests_per_hour": 3000}},
}))
monkeypatch.setattr(dashboard_settings, "get_aisbf_config_path", lambda: cfg_path)
monkeypatch.setattr(dashboard_settings, "_reload_global_config", lambda: None)
class TemplateStub:
def TemplateResponse(self, *args, **kwargs):
from starlette.responses import Response
return Response(status_code=200)
monkeypatch.setattr(dashboard_settings, "_templates", TemplateStub())
client = TestClient(app)
_login_as_admin(client)
response = client.post(
"/dashboard/settings",
data={
"host": "127.0.0.1",
"port": 17765,
"protocol": "http",
"auth_enabled": "",
"auth_tokens": "",
"dashboard_username": "admin",
"condensation_model_id": "internal-condense",
"autoselect_model_id": "internal-autoselect",
"autoselect_max_tokens": 8000,
"condensation_max_tokens": 1000,
"autoselect_max_new_tokens": 100,
"nsfw_classifier": "michelleli99/NSFW_text_classifier",
"privacy_classifier": "iiiorg/piiranha-v1-detect-personal-information",
"semantic_vectorization": "sentence-transformers/all-MiniLM-L6-v2",
"feature_nsfw_classification_mode": "enabled",
"feature_privacy_classification_mode": "disabled",
"feature_context_condensation_mode": "enabled",
"feature_response_cache_mode": "disabled",
"feature_prompt_batching_mode": "enabled",
"feature_prompt_security_mode": "enabled",
"feature_context_lens_mode": "enabled",
"feature_block_high_risk_prompts_mode": "disabled",
"feature_persist_prompt_text_mode": "enabled",
"feature_redact_before_persist_mode": "disabled",
"feature_risk_threshold": "medium",
"batching_window_ms": 100,
"batching_max_batch_size": 8,
"batching_openai_max_batch_size": 10,
"batching_anthropic_max_batch_size": 5,
"adaptive_initial_rate_limit": 0,
"adaptive_learning_rate": 0.1,
"adaptive_headroom_percent": 10,
"adaptive_recovery_rate": 0.05,
"adaptive_max_rate_limit": 60,
"adaptive_min_rate_limit": 0.1,
"adaptive_backoff_base": 2,
"adaptive_jitter_factor": 0.25,
"adaptive_history_window": 3600,
"adaptive_consecutive_successes": 10,
"active_tab": "classification",
"database_type": "sqlite",
"sqlite_path": "~/.aisbf/aisbf.db",
"mysql_host": "localhost",
"mysql_port": 3306,
"mysql_user": "aisbf",
"mysql_password": "",
"mysql_database": "aisbf",
"cache_type": "file",
"redis_host": "localhost",
"redis_port": 6379,
"redis_db": 0,
"redis_password": "",
"redis_key_prefix": "aisbf:",
"response_cache_backend": "memory",
"response_cache_ttl": 600,
"response_cache_max_memory": 1000,
"response_cache_redis_host": "localhost",
"response_cache_redis_port": 6379,
"response_cache_redis_db": 0,
"response_cache_redis_password": "",
"response_cache_redis_key_prefix": "aisbf:response:",
"response_cache_sqlite_path": "~/.aisbf/response_cache.db",
"response_cache_mysql_host": "localhost",
"response_cache_mysql_port": 3306,
"response_cache_mysql_user": "aisbf",
"response_cache_mysql_password": "",
"response_cache_mysql_database": "aisbf_response_cache",
"autoselect_tokens": "",
"fullconfig_tokens": "",
"tor_control_port": 9051,
"tor_control_host": "127.0.0.1",
"tor_control_password": "",
"tor_hidden_service_dir": "",
"tor_hidden_service_port": 80,
"tor_socks_port": 9050,
"tor_socks_host": "127.0.0.1",
"verification_token_expiry": 24,
"smtp_host": "",
"smtp_port": 587,
"smtp_username": "",
"smtp_password": "",
"smtp_from_email": "",
"smtp_from_name": "AISBF",
"oauth2_google_client_id": "",
"oauth2_google_client_secret": "",
"oauth2_github_client_id": "",
"oauth2_github_client_secret": "",
"dashboard_email": "",
"new_admin_password": "",
"confirm_admin_password": "",
"client_rl_api_rpm": 60,
"client_rl_api_rph": 1000,
"client_rl_general_rpm": 120,
"client_rl_general_rph": 3000,
},
follow_redirects=False,
)
assert response.status_code == 200
saved = json.loads((Path.home() / '.aisbf' / 'aisbf.json').read_text())
assert saved["feature_controls"]["nsfw_classification"]["mode"] == "enabled"
assert saved["feature_controls"]["privacy_classification"]["mode"] == "disabled"
assert saved["feature_controls"]["context_condensation"]["mode"] == "enabled"
assert saved["feature_controls"]["response_cache"]["mode"] == "disabled"
assert saved["feature_controls"]["prompt_batching"]["mode"] == "enabled"
assert saved["feature_controls"]["prompt_security"]["security_scan"]["mode"] == "enabled"
assert saved["feature_controls"]["prompt_security"]["context_lens"]["mode"] == "enabled"
assert saved["feature_controls"]["prompt_security"]["block_high_risk_prompts"]["mode"] == "disabled"
assert saved["feature_controls"]["prompt_security"]["persist_prompt_text"]["mode"] == "enabled"
assert saved["feature_controls"]["prompt_security"]["redact_before_persist"]["mode"] == "disabled"
assert saved["feature_controls"]["prompt_security"]["risk_threshold"] == "medium"
......@@ -8,6 +8,7 @@ from fastapi.testclient import TestClient
from aisbf.coderai_broker import broker
from aisbf.config import ProviderConfig, config
from aisbf.database import DatabaseRegistry
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from main import app
......@@ -188,6 +189,7 @@ def test_broker_stream_events_are_delivered_to_waiter():
def test_user_scoped_broker_websocket_path_registers_username():
original_provider = config.providers.get("coderai-user")
original_get_db = DatabaseRegistry.get_config_database
config.providers["coderai-user"] = ProviderConfig(
id="coderai-user",
name="CoderAI User",
......@@ -197,6 +199,25 @@ def test_user_scoped_broker_websocket_path_registers_username():
rate_limit=0,
coderai_config={"registration_token": "user-token"},
)
class DbStub:
def get_user_by_username(self, username):
if username == "alice":
return {"id": 17, "username": "alice"}
return None
def get_all_user_providers(self):
return [{
"user_id": 17,
"provider_id": "coderai-user",
"config": {
"id": "coderai-user",
"type": "coderai",
"coderai_config": {"registration_token": "user-token"},
},
}]
DatabaseRegistry.get_config_database = staticmethod(lambda: DbStub())
try:
with TestClient(app) as client:
with client.websocket_connect("/api/u/alice/coderai/wss?provider_id=coderai-user&client_id=user-client&registration_token=user-token&username=alice") as websocket:
......@@ -204,7 +225,108 @@ def test_user_scoped_broker_websocket_path_registers_username():
assert registered["username"] == "alice"
assert registered["scope_name"] == "alice"
finally:
DatabaseRegistry.get_config_database = original_get_db
if original_provider is None:
config.providers.pop("coderai-user", None)
else:
config.providers["coderai-user"] = original_provider
def test_coderai_register_endpoint_bypasses_bearer_auth_when_using_registration_token(monkeypatch):
original_get_db = DatabaseRegistry.get_config_database
DatabaseRegistry.get_config_database = staticmethod(lambda: None)
try:
with TestClient(app) as client:
response = client.post(
"/api/coderai/register",
json={
"provider_id": "coderai",
"client_id": "nat-client",
"registration_token": "global-token",
"username": "global",
},
)
assert response.status_code == 200
assert response.json()["accepted"] is True
assert response.json()["scope_name"] == "global"
finally:
DatabaseRegistry.get_config_database = original_get_db
def test_global_registration_rejects_user_scoped_provider_token(monkeypatch):
class DbStub:
def get_all_user_providers(self):
return [{
"user_id": 17,
"provider_id": "coderai-user-only",
"config": {
"id": "coderai-user-only",
"type": "coderai",
"coderai_config": {"registration_token": "user-token"},
},
}]
def get_user_by_username(self, username):
if username == "alice":
return {"id": 17, "username": "alice"}
return None
original_get_db = DatabaseRegistry.get_config_database
DatabaseRegistry.get_config_database = staticmethod(lambda: DbStub())
try:
with TestClient(app) as client:
response = client.post(
"/api/coderai/register",
json={
"provider_id": "coderai-user-only",
"client_id": "nat-client",
"registration_token": "user-token",
"username": "global",
},
)
assert response.status_code == 403
finally:
DatabaseRegistry.get_config_database = original_get_db
def test_user_registration_rejects_global_provider_token(monkeypatch):
original_provider = config.providers.get("coderai-global-only")
config.providers["coderai-global-only"] = ProviderConfig(
id="coderai-global-only",
name="CoderAI Global Only",
endpoint="http://127.0.0.1:11437",
type="coderai",
api_key_required=False,
rate_limit=0,
coderai_config={"registration_token": "global-only-token"},
)
class DbStub:
def get_all_user_providers(self):
return []
def get_user_by_username(self, username):
if username == "alice":
return {"id": 17, "username": "alice"}
return None
original_get_db = DatabaseRegistry.get_config_database
DatabaseRegistry.get_config_database = staticmethod(lambda: DbStub())
try:
with TestClient(app) as client:
response = client.post(
"/api/u/alice/coderai/register",
json={
"provider_id": "coderai-global-only",
"client_id": "user-client",
"registration_token": "global-only-token",
"username": "alice",
},
)
assert response.status_code == 403
finally:
DatabaseRegistry.get_config_database = original_get_db
if original_provider is None:
config.providers.pop("coderai-global-only", None)
else:
config.providers["coderai-global-only"] = original_provider
from datetime import datetime, timedelta
from aisbf.database import DatabaseManager
def _make_db(tmp_path):
db_path = tmp_path / "dashboard-events.db"
return DatabaseManager({
"type": "sqlite",
"sqlite_path": str(db_path),
})
def _seed_events(db: DatabaseManager):
now = datetime.now()
rows = [
{
"event_type": "request_proxied",
"path": "/api/provider-a/chat/completions",
"user_id": 11,
"username": "alice",
"session_id": "sess-a",
"ip_address": "1.1.1.1",
"country_code": "IT",
"method": "POST",
"status_code": 200,
"provider_id": "provider-a",
"rotation_id": "rotation-a",
"autoselect_id": "auto-a",
"metadata": {"kind": "success"},
"created_at": now - timedelta(hours=1),
},
{
"event_type": "provider_removed",
"path": "/dashboard/api/provider/provider-b",
"user_id": None,
"username": "admin",
"session_id": "sess-admin",
"ip_address": "2.2.2.2",
"country_code": "US",
"method": "DELETE",
"status_code": 200,
"provider_id": "provider-b",
"rotation_id": None,
"autoselect_id": None,
"metadata": {"source": "api"},
"created_at": now - timedelta(minutes=30),
},
{
"event_type": "rotation_saved_updated",
"path": "/dashboard/rotations",
"user_id": 12,
"username": "bob",
"session_id": "sess-b",
"ip_address": "3.3.3.3",
"country_code": "IT",
"method": "POST",
"status_code": 200,
"provider_id": "provider-c",
"rotation_id": "rotation-b",
"autoselect_id": None,
"metadata": {"source": "bulk"},
"created_at": now - timedelta(minutes=10),
},
{
"event_type": "autoselect_saved_created",
"path": "/dashboard/autoselect",
"user_id": 11,
"username": "alice",
"session_id": "sess-a",
"ip_address": "1.1.1.1",
"country_code": "DE",
"method": "POST",
"status_code": 200,
"provider_id": None,
"rotation_id": None,
"autoselect_id": "auto-b",
"metadata": {"source": "bulk"},
"created_at": now - timedelta(minutes=5),
},
]
with db._get_connection() as conn:
cursor = conn.cursor()
for row in rows:
cursor.execute(
"""
INSERT INTO dashboard_events (
event_type, path, user_id, username, session_id, ip_address, country_code,
method, status_code, provider_id, rotation_id, autoselect_id, listing_id,
target_user_id, metadata, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row["event_type"],
row["path"],
row["user_id"],
row["username"],
row["session_id"],
row["ip_address"],
row["country_code"],
row["method"],
row["status_code"],
row["provider_id"],
row["rotation_id"],
row["autoselect_id"],
None,
None,
"{}",
db._format_dt_db(row["created_at"]),
),
)
conn.commit()
return now
def test_dashboard_event_summary_filters_by_provider_rotation_autoselect_and_country(tmp_path):
db = _make_db(tmp_path)
now = _seed_events(db)
start = now - timedelta(hours=2)
end = now + timedelta(minutes=1)
provider_summary = db.get_dashboard_event_summary(start, end, provider_filter="provider-a")
assert provider_summary["total_events"] == 1
assert provider_summary["by_type"][0]["event_type"] == "request_proxied"
rotation_summary = db.get_dashboard_event_summary(start, end, rotation_filter="rotation-b")
assert rotation_summary["total_events"] == 1
assert rotation_summary["users"][0]["username"] == "bob"
autoselect_summary = db.get_dashboard_event_summary(start, end, autoselect_filter="auto-b")
assert autoselect_summary["total_events"] == 1
assert autoselect_summary["countries"][0]["country_code"] == "DE"
country_summary = db.get_dashboard_event_summary(start, end, country_filter="IT")
assert country_summary["total_events"] == 2
assert {row["event_type"] for row in country_summary["by_type"]} == {"request_proxied", "rotation_saved_updated"}
def test_dashboard_events_filters_return_only_matching_rows(tmp_path):
db = _make_db(tmp_path)
now = _seed_events(db)
start = now - timedelta(hours=2)
end = now + timedelta(minutes=1)
provider_rows = db.get_dashboard_events(start, end, event_types=["request_proxied"], user_id=11, limit=20)
assert len(provider_rows) == 1
assert provider_rows[0]["provider_id"] == "provider-a"
assert provider_rows[0]["autoselect_id"] == "auto-a"
guest_summary = db.get_dashboard_event_summary(start, end, user_id=-1)
assert guest_summary["total_events"] == 1
assert guest_summary["users"][0]["username"] == "admin"
def test_dashboard_event_series_buckets_by_hour_and_day(tmp_path):
db = _make_db(tmp_path)
now = _seed_events(db)
start = now - timedelta(days=2)
end = now + timedelta(minutes=1)
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO dashboard_events (
event_type, path, user_id, username, session_id, ip_address, country_code,
method, status_code, provider_id, rotation_id, autoselect_id, listing_id,
target_user_id, metadata, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"request_proxied",
"/api/provider-a/chat/completions",
11,
"alice",
"sess-old",
"1.1.1.1",
"IT",
"POST",
200,
"provider-a",
None,
None,
None,
None,
"{}",
db._format_dt_db(now - timedelta(days=1, hours=2)),
),
)
conn.commit()
hourly = db.get_dashboard_event_series(start, end, bucket="hour")
daily = db.get_dashboard_event_series(start, end, bucket="day")
assert len(hourly) >= 2
assert sum(bucket["events"] for bucket in hourly) == 5
assert len(daily) >= 2
assert sum(bucket["events"] for bucket in daily) == 5
assert any(bucket["events"] >= 4 for bucket in daily)
from types import SimpleNamespace
from aisbf.config import Config
def test_resolve_feature_enabled_prefers_model_provider_and_global_defaults():
config = Config.__new__(Config)
config.aisbf = SimpleNamespace(
feature_controls=SimpleNamespace(
response_cache=SimpleNamespace(mode="enabled"),
context_condensation=SimpleNamespace(mode="disabled"),
prompt_batching=SimpleNamespace(mode="disabled"),
),
response_cache=SimpleNamespace(enabled=False),
batching=SimpleNamespace(enabled=False),
)
config.condensation = SimpleNamespace(enabled=False)
provider_config = SimpleNamespace(enable_response_cache=False)
model_config = {"enable_response_cache": True}
assert config.resolve_feature_enabled(
"response_cache",
model_config=model_config,
provider_config=provider_config,
) is True
def test_resolve_feature_enabled_uses_global_feature_controls_for_condensation_and_batching():
config = Config.__new__(Config)
config.aisbf = SimpleNamespace(
feature_controls=SimpleNamespace(
nsfw_classification=SimpleNamespace(mode="disabled"),
privacy_classification=SimpleNamespace(mode="disabled"),
prompt_security=SimpleNamespace(
security_scan=SimpleNamespace(mode="enabled"),
context_lens=SimpleNamespace(mode="disabled"),
block_high_risk_prompts=SimpleNamespace(mode="enabled"),
),
response_cache=SimpleNamespace(mode="inherit"),
context_condensation=SimpleNamespace(mode="enabled"),
prompt_batching=SimpleNamespace(mode="enabled"),
),
response_cache=SimpleNamespace(enabled=False),
batching=SimpleNamespace(enabled=False),
)
config.condensation = SimpleNamespace(enabled=False)
assert config.resolve_feature_enabled("context_condensation") is True
assert config.resolve_feature_enabled("prompt_batching") is True
def test_resolve_feature_enabled_falls_back_to_legacy_top_level_when_feature_control_inherits():
config = Config.__new__(Config)
config.aisbf = SimpleNamespace(
feature_controls=SimpleNamespace(
nsfw_classification=SimpleNamespace(mode="inherit"),
privacy_classification=SimpleNamespace(mode="inherit"),
prompt_security=SimpleNamespace(
security_scan=SimpleNamespace(mode="inherit"),
context_lens=SimpleNamespace(mode="inherit"),
block_high_risk_prompts=SimpleNamespace(mode="inherit"),
),
response_cache=SimpleNamespace(mode="inherit"),
context_condensation=SimpleNamespace(mode="inherit"),
prompt_batching=SimpleNamespace(mode="inherit"),
),
response_cache=SimpleNamespace(enabled=True),
batching=SimpleNamespace(enabled=True),
)
config.condensation = SimpleNamespace(enabled=True)
assert config.resolve_feature_enabled("response_cache") is True
assert config.resolve_feature_enabled("context_condensation") is True
assert config.resolve_feature_enabled("prompt_batching") is True
def test_resolve_feature_enabled_supports_nsfw_and_privacy_classification_overrides():
config = Config.__new__(Config)
config.aisbf = SimpleNamespace(
feature_controls=SimpleNamespace(
nsfw_classification=SimpleNamespace(mode="enabled"),
privacy_classification=SimpleNamespace(mode="disabled"),
prompt_security=SimpleNamespace(
security_scan=SimpleNamespace(mode="enabled"),
context_lens=SimpleNamespace(mode="enabled"),
block_high_risk_prompts=SimpleNamespace(mode="disabled"),
),
response_cache=SimpleNamespace(mode="inherit"),
context_condensation=SimpleNamespace(mode="inherit"),
prompt_batching=SimpleNamespace(mode="inherit"),
),
response_cache=SimpleNamespace(enabled=False),
batching=SimpleNamespace(enabled=False),
)
config.condensation = SimpleNamespace(enabled=False)
provider_config = SimpleNamespace(enable_nsfw_classification=False)
model_config = {"enable_privacy_classification": True}
assert config.resolve_feature_enabled(
"nsfw_classification",
provider_config=provider_config,
) is False
assert config.resolve_feature_enabled(
"privacy_classification",
model_config=model_config,
) is True
def test_resolve_feature_enabled_reads_nested_prompt_security_defaults():
config = Config.__new__(Config)
config.aisbf = SimpleNamespace(
feature_controls=SimpleNamespace(
nsfw_classification=SimpleNamespace(mode="disabled"),
privacy_classification=SimpleNamespace(mode="disabled"),
prompt_security=SimpleNamespace(
security_scan=SimpleNamespace(mode="enabled"),
context_lens=SimpleNamespace(mode="enabled"),
block_high_risk_prompts=SimpleNamespace(mode="disabled"),
),
response_cache=SimpleNamespace(mode="inherit"),
context_condensation=SimpleNamespace(mode="inherit"),
prompt_batching=SimpleNamespace(mode="inherit"),
),
response_cache=SimpleNamespace(enabled=False),
batching=SimpleNamespace(enabled=False),
)
config.condensation = SimpleNamespace(enabled=False)
assert config.resolve_feature_enabled("prompt_security") is True
assert config.resolve_feature_enabled("context_lens") is True
assert config.resolve_feature_enabled("block_high_risk_prompts") is False
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