fix: refine analytics cost and provider quota insights

parent 18da1eed
This diff is collapsed.
...@@ -272,6 +272,7 @@ async def _refresh_provider_usage_if_stale(provider_id: str, user_id): ...@@ -272,6 +272,7 @@ async def _refresh_provider_usage_if_stale(provider_id: str, user_id):
return return
usage_data = await handler.get_usage() usage_data = await handler.get_usage()
if usage_data: if usage_data:
usage_data = handler.normalize_usage_data(usage_data)
db.save_provider_usage(user_id, provider_id, usage_data) db.save_provider_usage(user_id, provider_id, usage_data)
_apply_usage_disable(db, user_id, provider_id, usage_data) _apply_usage_disable(db, user_id, provider_id, usage_data)
except Exception as e: except Exception as e:
......
...@@ -94,6 +94,11 @@ class ProviderConfig(BaseModel): ...@@ -94,6 +94,11 @@ class ProviderConfig(BaseModel):
cache_ttl: Optional[int] = None # Cache TTL in seconds for Google Context Caching API cache_ttl: Optional[int] = None # Cache TTL in seconds for Google Context Caching API
min_cacheable_tokens: Optional[int] = 1024 # Minimum token count for content to be cacheable (default matches OpenAI) min_cacheable_tokens: Optional[int] = 1024 # Minimum token count for content to be cacheable (default matches OpenAI)
prompt_cache_key: Optional[str] = None # Optional cache key for OpenAI's load balancer routing optimization prompt_cache_key: Optional[str] = None # Optional cache key for OpenAI's load balancer routing optimization
free_tier_limit: Optional[int] = None # Upstream provider free-tier quota amount
free_tier_period: Optional[str] = None # Upstream free-tier period: day, week, month
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
# Response caching control # Response caching control
enable_response_cache: Optional[bool] = None # Enable/disable response caching for this provider (None = use global default) enable_response_cache: Optional[bool] = None # Enable/disable response caching for this provider (None = use global default)
...@@ -421,7 +426,7 @@ class Config: ...@@ -421,7 +426,7 @@ class Config:
print(f"Created default config file: {dst}") print(f"Created default config file: {dst}")
# Copy markdown prompt files if they don't exist # Copy markdown prompt files if they don't exist
for prompt_file in ['condensation_conversational.md', 'condensation_semantic.md', 'autoselect.md']: for prompt_file in ['condensation_conversational.md', 'condensation_semantic.md', 'autoselect.md', 'STUDIO_SYSTEM.md']:
src = source_dir / prompt_file src = source_dir / prompt_file
dst = config_dir / prompt_file dst = config_dir / prompt_file
......
...@@ -25,6 +25,50 @@ from typing import Dict, Optional, Any ...@@ -25,6 +25,50 @@ from typing import Dict, Optional, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _coerce_cost_value(value: Any) -> Optional[float]:
if value is None or value == '':
return None
try:
if isinstance(value, dict):
for key in ('usd', 'amount', 'value', 'total'):
if key in value:
coerced = _coerce_cost_value(value[key])
if coerced is not None:
return coerced
return None
if isinstance(value, str):
cleaned = value.strip().replace('$', '')
if cleaned.lower().startswith('usd '):
cleaned = cleaned[4:]
return float(cleaned)
return float(value)
except (TypeError, ValueError):
return None
def _find_nested_cost(obj: Any) -> Optional[float]:
if isinstance(obj, dict):
prioritized_keys = (
'cost', 'total_cost', 'total_price', 'price', 'amount', 'usd_cost',
'billed_cost', 'request_cost', 'charge', 'credits_cost'
)
for key in prioritized_keys:
if key in obj:
coerced = _coerce_cost_value(obj.get(key))
if coerced is not None:
return coerced
for value in obj.values():
nested = _find_nested_cost(value)
if nested is not None:
return nested
elif isinstance(obj, list):
for item in obj:
nested = _find_nested_cost(item)
if nested is not None:
return nested
return None
def extract_cost_from_response(response: Dict[str, Any], provider_id: str) -> Optional[float]: def extract_cost_from_response(response: Dict[str, Any], provider_id: str) -> Optional[float]:
""" """
Extract actual cost from provider response if available. Extract actual cost from provider response if available.
...@@ -40,49 +84,18 @@ def extract_cost_from_response(response: Dict[str, Any], provider_id: str) -> Op ...@@ -40,49 +84,18 @@ def extract_cost_from_response(response: Dict[str, Any], provider_id: str) -> Op
return None return None
try: try:
# AWS Bedrock - may include cost in usage provider_id_lower = (provider_id or '').lower()
if provider_id in ['amazon', 'bedrock', 'aws']:
usage = response.get('usage', {}) if 'x_openai_metadata' in response:
if isinstance(usage, dict): cost = _find_nested_cost(response['x_openai_metadata'])
cost = usage.get('cost') if cost is not None:
if cost is not None: return cost
return float(cost)
if provider_id_lower in ['amazon', 'bedrock', 'aws', 'openrouter', 'openai', 'codex', 'claude', 'anthropic', 'google', 'qwen', 'kiro', 'kilo', 'replicate', 'cohere']:
# Cohere - has billed_units but not direct cost cost = _find_nested_cost(response)
# Would need pricing config to convert if cost is not None:
if provider_id == 'cohere': return cost
meta = response.get('meta', {})
if isinstance(meta, dict):
billed_units = meta.get('billed_units', {})
if billed_units:
# Return None - we'll calculate from tokens
# Could enhance this to calculate from billed_units
pass
# Replicate - has prediction time
if provider_id == 'replicate':
metrics = response.get('metrics', {})
if isinstance(metrics, dict):
predict_time = metrics.get('predict_time')
if predict_time:
# Would need pricing per second to calculate
# Return None for now - calculate from tokens
pass
# Check for generic cost fields that some providers might use
for cost_field in ['cost', 'price', 'amount', 'total_cost']:
if cost_field in response:
cost = response[cost_field]
if cost is not None:
return float(cost)
# Check in usage object
usage = response.get('usage', {})
if isinstance(usage, dict) and cost_field in usage:
cost = usage[cost_field]
if cost is not None:
return float(cost)
return None return None
except Exception as e: except Exception as e:
...@@ -108,20 +121,7 @@ def extract_cost_from_streaming_chunk(chunk: Dict[str, Any], provider_id: str) - ...@@ -108,20 +121,7 @@ def extract_cost_from_streaming_chunk(chunk: Dict[str, Any], provider_id: str) -
return None return None
try: try:
# Check if this is a final chunk with usage/cost info return _find_nested_cost(chunk)
usage = chunk.get('usage', {})
if isinstance(usage, dict):
# Try to extract cost from usage
cost = usage.get('cost')
if cost is not None:
return float(cost)
# Some providers might include cost at top level in final chunk
cost = chunk.get('cost')
if cost is not None:
return float(cost)
return None
except Exception as e: except Exception as e:
logger.debug(f"Error extracting cost from {provider_id} streaming chunk: {e}") logger.debug(f"Error extracting cost from {provider_id} streaming chunk: {e}")
......
This diff is collapsed.
...@@ -14,6 +14,8 @@ _IPNet = Union[ipaddress.IPv4Network, ipaddress.IPv6Network] ...@@ -14,6 +14,8 @@ _IPNet = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
# failures are not cached so they are retried on the next request. # failures are not cached so they are retried on the next request.
_CACHE_TTL = 30 * 24 * 3600 # 30 days _CACHE_TTL = 30 * 24 * 3600 # 30 days
_subnet_cache: Dict[str, Tuple[str, float]] = {} _subnet_cache: Dict[str, Tuple[str, float]] = {}
_failure_cache: Dict[str, float] = {}
_FAILURE_TTL = 15 * 60
def _fallback_prefix(addr: _IPAddr) -> _IPNet: def _fallback_prefix(addr: _IPAddr) -> _IPNet:
...@@ -69,12 +71,25 @@ async def get_ip_country(ip: str) -> Optional[str]: ...@@ -69,12 +71,25 @@ async def get_ip_country(ip: str) -> Optional[str]:
if cached is not None: if cached is not None:
return cached return cached
failure_expires = _failure_cache.get(ip)
if failure_expires and time.time() < failure_expires:
logger.debug("geo lookup: skipping %s due to recent failure backoff", ip)
return None
logger.debug("geo lookup: querying ipapi.co for %s", ip) logger.debug("geo lookup: querying ipapi.co for %s", ip)
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"https://ipapi.co/{ip}/json/") response = await client.get(f"https://ipapi.co/{ip}/json/")
if response.status_code != 200: if response.status_code != 200:
logger.debug("geo lookup: %s returned HTTP %d", ip, response.status_code) logger.debug("geo lookup: %s returned HTTP %d", ip, response.status_code)
retry_after = response.headers.get('Retry-After') or response.headers.get('retry-after')
wait_seconds = _FAILURE_TTL
if retry_after:
try:
wait_seconds = max(int(retry_after), _FAILURE_TTL)
except (TypeError, ValueError):
pass
_failure_cache[ip] = time.time() + wait_seconds
return None return None
data = response.json() data = response.json()
...@@ -99,6 +114,7 @@ async def get_ip_country(ip: str) -> Optional[str]: ...@@ -99,6 +114,7 @@ async def get_ip_country(ip: str) -> Optional[str]:
except Exception as exc: except Exception as exc:
logger.debug("geo lookup: %s failed with %s: %s", ip, type(exc).__name__, exc) logger.debug("geo lookup: %s failed with %s: %s", ip, type(exc).__name__, exc)
_failure_cache[ip] = time.time() + _FAILURE_TTL
return None return None
......
...@@ -38,6 +38,7 @@ from .models import ChatCompletionRequest, ChatCompletionResponse ...@@ -38,6 +38,7 @@ from .models import ChatCompletionRequest, ChatCompletionResponse
from .providers import get_provider_handler, RateLimitError from .providers import get_provider_handler, RateLimitError
from .config import config from .config import config
from .studio import infer_model_capabilities from .studio import infer_model_capabilities
from .studio_adapters import effective_studio_adapter, infer_studio_adapter_profile, adapt_studio_payload_with_profile
from .utils import ( from .utils import (
count_messages_tokens, count_messages_tokens,
split_messages_into_chunks, split_messages_into_chunks,
...@@ -121,6 +122,66 @@ class RequestHandler: ...@@ -121,6 +122,66 @@ class RequestHandler:
self.user_rotations = {} self.user_rotations = {}
self.user_autoselects = {} self.user_autoselects = {}
def _get_provider_config(self, provider_id: str):
if self.user_id and provider_id in getattr(self, 'user_providers', {}):
return self.user_providers[provider_id]
return self.config.get_provider(provider_id)
def _get_provider_type(self, provider_id: str) -> str:
provider_config = self._get_provider_config(provider_id)
if isinstance(provider_config, dict):
return provider_config.get('type', 'openai') or 'openai'
return getattr(provider_config, 'type', 'openai') or 'openai'
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()
model_metadata = payload.get('_studio_model_metadata') if isinstance(payload.get('_studio_model_metadata'), dict) else {}
if provider_id and '_studio_provider_id' not in payload:
payload['_studio_provider_id'] = provider_id
if model_metadata.get('provider_endpoint') and '_studio_provider_endpoint' not in payload:
payload['_studio_provider_endpoint'] = model_metadata.get('provider_endpoint')
adapter = effective_studio_adapter(provider_type, model_metadata)
profile = infer_studio_adapter_profile(provider_id, provider_type, model_metadata)
def first_value(*keys):
for key in keys:
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
if endpoint_path == 'v1/video/dub':
selected = first_value('video_model', 'stt_model', 'tts_model', 'model')
if selected:
payload['model'] = selected
elif endpoint_path == 'v1/audio/clone':
selected = first_value('model', 'tts_model')
if selected:
payload['model'] = selected
elif endpoint_path == 'v1/audio/convert':
selected = first_value('model', 'audio_model', 'tts_model', 'stt_model')
if selected:
payload['model'] = selected
elif endpoint_path in {'v1/audio/split', 'v1/audio/denoise'}:
selected = first_value('model', 'audio_model')
if selected:
payload['model'] = selected
elif endpoint_path in {'v1/images/faceswap', 'v1/images/outfit'}:
selected = first_value('model', 'image_model', 'video_model')
if selected:
payload['model'] = selected
elif endpoint_path in {'v1/images/to3d', 'v1/images/from3d', 'v1/video/to3d', 'v1/video/from3d', 'v1/3d/generate'}:
selected = first_value('model', 'render_model', 'image_model', 'video_model')
if selected:
payload['model'] = selected
payload = adapt_studio_payload_with_profile(adapter, profile, endpoint_path, payload)
payload.pop('_studio_model_metadata', None)
payload.pop('_studio_provider_id', None)
payload.pop('_studio_provider_endpoint', None)
return payload
def _load_user_configs(self): def _load_user_configs(self):
"""Load user-specific configurations from database""" """Load user-specific configurations from database"""
self.reload_user_config() self.reload_user_config()
...@@ -662,7 +723,8 @@ class RequestHandler: ...@@ -662,7 +723,8 @@ class RequestHandler:
token_id=getattr(request.state, 'token_id', None), token_id=getattr(request.state, 'token_id', None),
prompt_tokens=prompt_tokens if prompt_tokens > 0 else None, prompt_tokens=prompt_tokens if prompt_tokens > 0 else None,
completion_tokens=completion_tokens if completion_tokens > 0 else None, completion_tokens=completion_tokens if completion_tokens > 0 else None,
actual_cost=actual_cost actual_cost=actual_cost,
analytics_kind='execution'
) )
# Record context dimensions for model performance tracking # Record context dimensions for model performance tracking
...@@ -715,7 +777,8 @@ class RequestHandler: ...@@ -715,7 +777,8 @@ class RequestHandler:
token_id=getattr(request.state, 'token_id', None), token_id=getattr(request.state, 'token_id', None),
prompt_tokens=prompt_tokens, prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens, completion_tokens=completion_tokens,
actual_cost=None actual_cost=None,
analytics_kind='execution'
) )
except Exception as analytics_error: except Exception as analytics_error:
logger.warning(f"Analytics recording for failed request failed: {analytics_error}") logger.warning(f"Analytics recording for failed request failed: {analytics_error}")
...@@ -1355,7 +1418,8 @@ class RequestHandler: ...@@ -1355,7 +1418,8 @@ class RequestHandler:
token_id=getattr(request.state, 'token_id', None), token_id=getattr(request.state, 'token_id', None),
prompt_tokens=effective_context, prompt_tokens=effective_context,
completion_tokens=completion_tokens, completion_tokens=completion_tokens,
actual_cost=None # Streaming responses typically don't include cost actual_cost=None,
analytics_kind='execution'
) )
except Exception as analytics_error: except Exception as analytics_error:
logger.warning(f"Analytics recording for streaming request failed: {analytics_error}") logger.warning(f"Analytics recording for streaming request failed: {analytics_error}")
...@@ -1393,7 +1457,8 @@ class RequestHandler: ...@@ -1393,7 +1457,8 @@ class RequestHandler:
token_id=getattr(request.state, 'token_id', None), token_id=getattr(request.state, 'token_id', None),
prompt_tokens=prompt_tokens, prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens, completion_tokens=completion_tokens,
actual_cost=None actual_cost=None,
analytics_kind='execution'
) )
except Exception as analytics_error: except Exception as analytics_error:
logger.warning(f"Analytics recording for failed streaming request failed: {analytics_error}") logger.warning(f"Analytics recording for failed streaming request failed: {analytics_error}")
...@@ -1678,6 +1743,7 @@ class RequestHandler: ...@@ -1678,6 +1743,7 @@ class RequestHandler:
import httpx import httpx
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
body = self._adapt_studio_workflow_payload(provider_id, endpoint_path, body)
# Support user-defined providers (dict format) and global providers (object format) # Support user-defined providers (dict format) and global providers (object format)
if self.user_id and provider_id in self.user_providers: if self.user_id and provider_id in self.user_providers:
...@@ -2990,7 +3056,8 @@ class RotationHandler: ...@@ -2990,7 +3056,8 @@ class RotationHandler:
token_id=token_id, token_id=token_id,
prompt_tokens=prompt_tokens if prompt_tokens > 0 else None, prompt_tokens=prompt_tokens if prompt_tokens > 0 else None,
completion_tokens=completion_tokens if completion_tokens > 0 else None, completion_tokens=completion_tokens if completion_tokens > 0 else None,
actual_cost=actual_cost actual_cost=actual_cost,
analytics_kind='execution'
) )
except Exception as analytics_error: except Exception as analytics_error:
logger.warning(f"Analytics recording failed: {analytics_error}") logger.warning(f"Analytics recording failed: {analytics_error}")
...@@ -3065,7 +3132,8 @@ class RotationHandler: ...@@ -3065,7 +3132,8 @@ class RotationHandler:
token_id=token_id, token_id=token_id,
prompt_tokens=prompt_tokens, prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens, completion_tokens=completion_tokens,
actual_cost=None actual_cost=None,
analytics_kind='selector'
) )
except Exception as analytics_error: except Exception as analytics_error:
logger.warning(f"Analytics recording for failed rotation failed: {analytics_error}") logger.warning(f"Analytics recording for failed rotation failed: {analytics_error}")
...@@ -3735,7 +3803,8 @@ class RotationHandler: ...@@ -3735,7 +3803,8 @@ class RotationHandler:
token_id=token_id, token_id=token_id,
prompt_tokens=prompt_tokens if prompt_tokens > 0 else None, prompt_tokens=prompt_tokens if prompt_tokens > 0 else None,
completion_tokens=completion_tokens_count if completion_tokens_count > 0 else None, completion_tokens=completion_tokens_count if completion_tokens_count > 0 else None,
actual_cost=None actual_cost=None,
analytics_kind='execution'
) )
except Exception as analytics_error: except Exception as analytics_error:
logger.warning(f"Analytics recording for streaming rotation failed: {analytics_error}") logger.warning(f"Analytics recording for streaming rotation failed: {analytics_error}")
...@@ -4852,7 +4921,8 @@ class AutoselectHandler: ...@@ -4852,7 +4921,8 @@ class AutoselectHandler:
token_id=token_id, token_id=token_id,
prompt_tokens=prompt_tokens if prompt_tokens > 0 else None, prompt_tokens=prompt_tokens if prompt_tokens > 0 else None,
completion_tokens=completion_tokens if completion_tokens > 0 else None, completion_tokens=completion_tokens if completion_tokens > 0 else None,
actual_cost=None actual_cost=None,
analytics_kind='selector'
) )
except Exception as analytics_error: except Exception as analytics_error:
logger.warning(f"Analytics recording for autoselect failed: {analytics_error}") logger.warning(f"Analytics recording for autoselect failed: {analytics_error}")
......
...@@ -89,6 +89,11 @@ class Provider(BaseModel): ...@@ -89,6 +89,11 @@ class Provider(BaseModel):
is_subscription: bool = False # If True, pricing is 0 (subscription-based provider) is_subscription: bool = False # If True, pricing is 0 (subscription-based provider)
price_per_million_prompt: Optional[float] = None # Price per million prompt tokens (USD) price_per_million_prompt: Optional[float] = None # Price per million prompt tokens (USD)
price_per_million_completion: Optional[float] = None # Price per million completion tokens (USD) price_per_million_completion: Optional[float] = None # Price per million completion tokens (USD)
free_tier_limit: Optional[int] = None
free_tier_period: Optional[str] = None
free_tier_limit_type: Optional[str] = None
premium_reference_monthly_cost: Optional[float] = None
free_tier_description: Optional[str] = None
class ErrorTracking(BaseModel): class ErrorTracking(BaseModel):
failures: int failures: int
......
...@@ -1541,6 +1541,24 @@ class BaseProviderHandler: ...@@ -1541,6 +1541,24 @@ class BaseProviderHandler:
"""Fetch current usage/quota data from the provider. Returns None if unsupported.""" """Fetch current usage/quota data from the provider. Returns None if unsupported."""
return None return None
def normalize_usage_data(self, usage_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Normalize provider usage/quota data into a shared schema when possible."""
if not usage_data or not isinstance(usage_data, dict):
return usage_data
normalized = dict(usage_data)
free_tier = normalized.get('free_tier')
if isinstance(free_tier, dict):
normalized['free_tier'] = {
'limit': free_tier.get('limit'),
'used': free_tier.get('used'),
'remaining': free_tier.get('remaining'),
'period': free_tier.get('period'),
'limit_type': free_tier.get('limit_type', 'requests'),
'source': free_tier.get('source', 'provider')
}
return normalized
async def handle_request_with_batching(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None, async def handle_request_with_batching(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False, temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
tools: Optional[List[Dict]] = None, tool_choice: Optional[Union[str, Dict]] = None) -> Union[Dict, object]: tools: Optional[List[Dict]] = None, tool_choice: Optional[Union[str, Dict]] = None) -> Union[Dict, object]:
......
...@@ -957,6 +957,28 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -957,6 +957,28 @@ class ClaudeProviderHandler(BaseProviderHandler):
return True return True
return False return False
def normalize_usage_data(self, usage_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
normalized = super().normalize_usage_data(usage_data)
if not normalized or not isinstance(normalized, dict):
return normalized
utilization = normalized.get('quota_7d_utilization')
if utilization is not None:
try:
util_float = float(str(utilization).rstrip('%'))
normalized['free_tier'] = {
'limit': 100,
'used': max(util_float, 0.0),
'remaining': max(100.0 - util_float, 0.0),
'period': 'week',
'limit_type': 'percent',
'source': 'provider'
}
except (TypeError, ValueError):
pass
return normalized
async def _ensure_session(self): async def _ensure_session(self):
"""Ensure session is initialized and valid before making requests.""" """Ensure session is initialized and valid before making requests."""
......
...@@ -996,7 +996,24 @@ class CodexProviderHandler(BaseProviderHandler): ...@@ -996,7 +996,24 @@ class CodexProviderHandler(BaseProviderHandler):
data = response.json() data = response.json()
import json as _json import json as _json
logger.debug(f"CodexProviderHandler: Usage raw body for {self.provider_id}:\n{_json.dumps(data, indent=2)}") logger.debug(f"CodexProviderHandler: Usage raw body for {self.provider_id}:\n{_json.dumps(data, indent=2)}")
return data normalized = dict(data)
weekly_limit = data.get('weekly_requests_limit') or data.get('weekly_limit') or data.get('free_requests_per_week')
weekly_used = data.get('weekly_requests_used') or data.get('used_requests') or data.get('requests_used')
if weekly_limit is not None:
try:
weekly_limit_int = int(weekly_limit)
weekly_used_int = int(weekly_used or 0)
normalized['free_tier'] = {
'limit': weekly_limit_int,
'used': weekly_used_int,
'remaining': max(weekly_limit_int - weekly_used_int, 0),
'period': 'week',
'limit_type': 'requests',
'source': 'provider'
}
except (TypeError, ValueError):
pass
return self.normalize_usage_data(normalized)
except Exception as e: except Exception as e:
logger.warning(f"CodexProviderHandler: Failed to fetch usage: {e}") logger.warning(f"CodexProviderHandler: Failed to fetch usage: {e}")
return None return None
...@@ -29,7 +29,7 @@ import json ...@@ -29,7 +29,7 @@ import json
import uuid import uuid
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from ...config import config from ...config import config
from ...models import Model from ...models import Model
...@@ -366,6 +366,30 @@ class KiroProviderHandler(BaseProviderHandler): ...@@ -366,6 +366,30 @@ class KiroProviderHandler(BaseProviderHandler):
return openai_response return openai_response
def normalize_usage_data(self, usage_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
normalized = super().normalize_usage_data(usage_data)
if not normalized or not isinstance(normalized, dict):
return normalized
monthly_limit = normalized.get('monthly_requests_limit') or normalized.get('free_requests_per_month')
monthly_used = normalized.get('monthly_requests_used') or normalized.get('used_requests')
if monthly_limit is not None:
try:
monthly_limit_int = int(monthly_limit)
monthly_used_int = int(monthly_used or 0)
normalized['free_tier'] = {
'limit': monthly_limit_int,
'used': monthly_used_int,
'remaining': max(monthly_limit_int - monthly_used_int, 0),
'period': 'month',
'limit_type': 'requests',
'source': 'provider'
}
except (TypeError, ValueError):
pass
return normalized
async def _handle_streaming_request(self, kiro_api_url: str, payload: dict, headers: dict, model: str): async def _handle_streaming_request(self, kiro_api_url: str, payload: dict, headers: dict, model: str):
"""Handle streaming request to Kiro API.""" """Handle streaming request to Kiro API."""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
......
This diff is collapsed.
{ {
"providers": { "providers": {
"codex": {
"id": "codex",
"name": "Codex / ChatGPT OAuth2",
"endpoint": "https://chatgpt.com/backend-api",
"type": "codex",
"api_key_required": false,
"is_subscription": true,
"free_tier_limit": 1,
"free_tier_period": "week",
"free_tier_limit_type": "requests",
"premium_reference_monthly_cost": 20,
"free_tier_description": "Codex upstream weekly free-tier equivalent",
"rate_limit": 0,
"models": []
},
"kiro": {
"id": "kiro",
"name": "Kiro CLI",
"endpoint": "https://api.kiro.dev",
"type": "kiro",
"api_key_required": false,
"is_subscription": true,
"free_tier_limit": 50,
"free_tier_period": "month",
"free_tier_limit_type": "requests",
"premium_reference_monthly_cost": 19,
"free_tier_description": "Kiro upstream 50 requests/month free tier",
"rate_limit": 0,
"models": []
},
"claude": {
"id": "claude",
"name": "Claude OAuth2",
"endpoint": "https://api.anthropic.com",
"type": "claude",
"api_key_required": false,
"is_subscription": true,
"free_tier_limit": 1,
"free_tier_period": "month",
"free_tier_limit_type": "requests",
"premium_reference_monthly_cost": 20,
"free_tier_description": "Claude upstream subscription/free-tier equivalent",
"rate_limit": 0,
"models": []
},
"kilo": { "kilo": {
"id": "kilo", "id": "kilo",
"name": "Kilocode (OAuth2)", "name": "Kilocode (OAuth2)",
...@@ -7,6 +52,11 @@ ...@@ -7,6 +52,11 @@
"type": "kilo", "type": "kilo",
"api_key_required": false, "api_key_required": false,
"is_subscription": true, "is_subscription": true,
"free_tier_limit": 1,
"free_tier_period": "month",
"free_tier_limit_type": "requests",
"premium_reference_monthly_cost": 0,
"free_tier_description": "Kilo upstream free-tier/subscription equivalent",
"rate_limit": 0, "rate_limit": 0,
"kilo_config": { "kilo_config": {
"credentials_file": "~/.kilo_credentials.json" "credentials_file": "~/.kilo_credentials.json"
......
...@@ -155,6 +155,7 @@ setup( ...@@ -155,6 +155,7 @@ setup(
'config/autoselect.md', 'config/autoselect.md',
'config/condensation_conversational.md', 'config/condensation_conversational.md',
'config/condensation_semantic.md', 'config/condensation_semantic.md',
'config/STUDIO_SYSTEM.md',
'config/aisbf.json', 'config/aisbf.json',
]), ]),
# Install aisbf package to share directory for venv installation # Install aisbf package to share directory for venv installation
...@@ -307,6 +308,7 @@ setup( ...@@ -307,6 +308,7 @@ setup(
'templates/dashboard/providers.html', 'templates/dashboard/providers.html',
'templates/dashboard/pricing.html', 'templates/dashboard/pricing.html',
'templates/dashboard/paypal_connect.html', 'templates/dashboard/paypal_connect.html',
'templates/dashboard/provider_quotas.html',
'templates/dashboard/error.html', 'templates/dashboard/error.html',
'templates/dashboard/forgot_password.html', 'templates/dashboard/forgot_password.html',
'templates/dashboard/rate_limits.html', 'templates/dashboard/rate_limits.html',
......
...@@ -603,8 +603,14 @@ window.onclick = function(event) { ...@@ -603,8 +603,14 @@ window.onclick = function(event) {
<div style="background: #3498db; color: white; padding: 20px; border-radius: 8px;"> <div style="background: #3498db; color: white; padding: 20px; border-radius: 8px;">
<h4 style="font-size: 14px; margin-bottom: 10px;">💰 Estimated Savings</h4> <h4 style="font-size: 14px; margin-bottom: 10px;">💰 Estimated Savings</h4>
<p style="font-size: 28px; font-weight: bold;" id="savings-amount">{{ currency_symbol }}0.00</p> <p style="font-size: 28px; font-weight: bold;" id="savings-amount">{{ currency_symbol }}{{ "%.2f"|format((optimization_savings.total_cost_saved if optimization_savings else 0) or 0) }}</p>
<small style="color: rgba(255,255,255,0.8);">From cache hits & optimization</small> <small style="color: rgba(255,255,255,0.8);">
{% if optimization_savings and optimization_savings.provider_equivalents %}
Provider free-tier equivalents + optimization
{% else %}
Cache hits & optimization
{% endif %}
</small>
</div> </div>
{% for pc in cost_overview.providers %} {% for pc in cost_overview.providers %}
...@@ -635,6 +641,48 @@ window.onclick = function(event) { ...@@ -635,6 +641,48 @@ window.onclick = function(event) {
</div> </div>
</div> </div>
{% if optimization_savings.provider_equivalents %}
<div style="background: var(--bg-page); padding: 16px; border-radius: 8px; margin-bottom: 20px;">
<h4 style="margin: 0 0 10px 0;">Free-Tier Equivalent Savings</h4>
<p style="margin: 0 0 10px 0; color: var(--color-muted);">
The table below estimates provider-side free-tier equivalents using known upstream free limits such as weekly or monthly request allowances.
When the filtered usage exceeds a provider's base free allowance, the overflow is counted as additional free-tier-equivalent capacity and translated into token and premium-value equivalents.
</p>
<table>
<tr>
<th>Provider</th>
<th>Used Tokens</th>
<th>Requests</th>
<th>Measured Usage</th>
<th>Free Limit</th>
<th>Extra Free Tiers</th>
<th>Equivalent Saved Tokens</th>
<th>Premium Reference</th>
<th>Equivalent Saved Cost</th>
</tr>
{% for item in optimization_savings.provider_equivalents %}
<tr>
<td><strong>{{ item.provider_id }}</strong></td>
<td>{{ format_tokens(item.tokens_used) }}</td>
<td>{{ item.request_count }}</td>
<td>{{ item.usage_amount }} {{ item.free_tier_limit_type }}</td>
<td>{{ item.free_tier_limit }}/{{ item.free_tier_period }} {{ item.free_tier_limit_type }} <small style="color: var(--color-muted);">({{ item.quota_source }})</small></td>
<td>{{ item.extra_free_tiers }}</td>
<td>{{ format_tokens(item.equivalent_saved_tokens) }}</td>
<td>
{% if item.premium_reference_name %}
{{ item.premium_reference_name }} ({{ currency_symbol }}{{ "%.2f"|format(item.premium_reference_monthly_cost) }}/mo)
{% else %}
N/A
{% endif %}
</td>
<td>{{ currency_symbol }}{{ "%.2f"|format(item.equivalent_saved_cost) }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% if optimization_savings.savings_by_type %} {% if optimization_savings.savings_by_type %}
<table> <table>
<tr> <tr>
...@@ -666,26 +714,9 @@ window.onclick = function(event) { ...@@ -666,26 +714,9 @@ window.onclick = function(event) {
</table> </table>
{% endif %} {% endif %}
{% else %} {% else %}
<p style="color: var(--color-muted);">No optimization savings recorded yet. Enable deduplication, condensation, batching, or caching to see savings.</p> <p style="color: var(--color-muted);">No optimization savings recorded yet. Increase usage on providers with known upstream free tiers, or enable deduplication, condensation, batching, or caching to see savings.</p>
{% endif %} {% endif %}
<script>
// Fetch and display savings estimation
fetch('{{ url_for(request, "/dashboard/response-cache/stats") }}')
.then(response => response.json())
.then(data => {
if (data.enabled && data.hits > 0) {
// Estimate savings: assume average cost of $0.002 per cached request
const avgCostPerRequest = 0.002;
const estimatedSavings = data.hits * avgCostPerRequest;
document.getElementById('savings-amount').textContent = '{{ currency_symbol }}' + estimatedSavings.toFixed(2);
}
})
.catch(error => {
console.error('Error fetching cache stats:', error);
});
</script>
<h3 style="margin-top: 30px; margin-bottom: 15px;">Model Performance</h3> <h3 style="margin-top: 30px; margin-bottom: 15px;">Model Performance</h3>
{% if model_performance %} {% if model_performance %}
<table> <table>
...@@ -1015,9 +1046,10 @@ function confirmDeleteAnalytics(scope) { ...@@ -1015,9 +1046,10 @@ function confirmDeleteAnalytics(scope) {
<div style="margin-top: 30px; display: flex; gap: 10px; flex-wrap: wrap;"> <div style="margin-top: 30px; display: flex; gap: 10px; flex-wrap: wrap;">
<a href="/dashboard" class="btn btn-secondary" data-i18n="analytics_page.back">Back to Dashboard</a> <a href="/dashboard" class="btn btn-secondary" data-i18n="analytics_page.back">Back to Dashboard</a>
<a href="{{ url_for(request, '/dashboard/analytics/provider-quotas') }}" class="btn btn-secondary">Provider Quota Debug</a>
{% if is_admin %} {% if is_admin %}
<a href="{{ url_for(request, '/dashboard/rate-limits') }}" class="btn">Rate Limits</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> <a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn">Response Cache</a>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block title %}Provider Quotas - AISBF Dashboard{% endblock %}
{% block content %}
<h2 style="margin-bottom: 20px;">Provider Quota Debug</h2>
<p style="color: var(--color-muted); margin-bottom: 20px;">
Debug view for normalized provider quota/free-tier data persisted from upstream usage endpoints. This page is intentionally not linked from the main navigation.
</p>
{% if provider_rows %}
<table>
<tr>
<th>Provider</th>
<th>Configured Free Tier</th>
<th>Normalized Runtime Quota</th>
<th>User Snapshot</th>
{% if is_admin %}<th>Global Snapshot</th>{% endif %}
</tr>
{% for row in provider_rows %}
<tr>
<td><strong>{{ row.provider_id }}</strong></td>
<td>
{% if row.free_tier_info %}
<div><strong>{{ row.free_tier_info.description or row.provider_id }}</strong></div>
<div style="color: var(--color-muted); font-size: 13px;">
{{ row.free_tier_info.limit }}/{{ row.free_tier_info.period }} {{ row.free_tier_info.limit_type }}
· {{ row.free_tier_info.source }}
</div>
{% else %}
<span style="color: var(--color-muted);">N/A</span>
{% endif %}
</td>
<td>
{% if row.normalized_quota %}
<div class="code">{{ row.normalized_quota | tojson(indent=2) }}</div>
{% else %}
<span style="color: var(--color-muted);">N/A</span>
{% endif %}
</td>
<td>
{% if row.user_snapshot %}
<div style="margin-bottom: 8px; color: var(--color-muted); font-size: 13px;">{{ row.user_snapshot.last_updated or 'unknown time' }}</div>
<div class="code">{{ row.user_snapshot.usage_data | tojson(indent=2) }}</div>
{% else %}
<span style="color: var(--color-muted);">N/A</span>
{% endif %}
</td>
{% if is_admin %}
<td>
{% if row.global_snapshot %}
<div style="margin-bottom: 8px; color: var(--color-muted); font-size: 13px;">{{ row.global_snapshot.last_updated or 'unknown time' }}</div>
<div class="code">{{ row.global_snapshot.usage_data | tojson(indent=2) }}</div>
{% else %}
<span style="color: var(--color-muted);">N/A</span>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% else %}
<p style="color: var(--color-muted);">No provider quota data available.</p>
{% endif %}
<div style="margin-top: 24px; display: flex; gap: 10px; flex-wrap: wrap;">
<a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary">Back to Analytics</a>
</div>
{% endblock %}
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