Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions src/.env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ OPENAI_API_KEY= # OpenAI API key
# Github auth for extracting readme files from GitHub repositories. Optional.
# GITHUB_AUTH_TOKEN= # Authorization token for Github API

# Amazon Web Sevices - for transcript summaries. Optional.
# AWS_ACCESS_KEY= # AWS Access Key
# AWS_SECRET_KEY= # AWS Secret Key

# Usage tracking logging settings
USAGE_LOG_TO_S3=false # Enable S3 logging for usage data (default: false)
USAGE_LOG_TO_FILE=true # Enable local file logging for usage data (default: true)
Expand Down
3 changes: 0 additions & 3 deletions src/sherpa_ai/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,6 @@
# Github auth for extracting readme files from GitHub repositories. Optional.
GITHUB_AUTH_TOKEN = environ.get("GITHUB_AUTH_TOKEN")

# Amazon Web Sevices - for transcript summaries. Optional.
AWS_ACCESS_KEY = environ.get("AWS_ACCESS_KEY")
AWS_SECRET_KEY = environ.get("AWS_SECRET_KEY")

# Configure logger. To get JSON serialization, set serialize=True.
# See https://loguru.readthedocs.io/en/stable/ for info on Loguru features.
Expand Down
11 changes: 6 additions & 5 deletions src/sherpa_ai/cost_tracking/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ def log_usage(
usage_metadata: Optional[Dict[str, Any]] = None
):
"""Log usage data and check cost thresholds."""
# Update user cost tracking
self.user_costs[user_id] = self.user_costs.get(user_id, 0.0) + cost

# Check cost thresholds and send alerts
self._check_cost_thresholds(user_id, self.user_costs[user_id])
if cfg.ENABLE_COST_TRACKING:
# Update user cost tracking
self.user_costs[user_id] = self.user_costs.get(user_id, 0.0) + cost

# Check cost thresholds and send alerts
self._check_cost_thresholds(user_id, self.user_costs[user_id])

# Log usage data if enabled
if not self.log_to_file:
Expand Down
2 changes: 1 addition & 1 deletion src/sherpa_ai/cost_tracking/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ class PricingManager:
def __init__(self, config_path: Optional[str] = None):
"""Initialize the pricing manager."""
self.pricing_data = {}
self._load_pricing_config(config_path)
self._load_pricing_config(config_path or cfg.MODEL_PRICING_CONFIG_PATH)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we using this env var? MODEL_PRICING_CONFIG_PATH

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — that's exactly what this PR wires up. MODEL_PRICING_CONFIG_PATH was previously defined in config/__init__.py but never read anywhere (issue #517). This line (config_path or cfg.MODEL_PRICING_CONFIG_PATH) makes PricingManager fall back to it when no explicit config_path is passed in, so setting the env var now actually takes effect. Covered by test_config_path_falls_back_to_env_setting (verified it fails if this fallback is removed).


def _load_pricing_config(self, config_path: Optional[str] = None):
"""Load pricing configuration from JSON file."""
Expand Down
36 changes: 9 additions & 27 deletions src/sherpa_ai/database/user_usage_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,32 +277,33 @@ def check_usage(self, user_id: str, input_tokens: int, output_tokens: int, usage

# Get current usage
current_usage = self._get_sum_of_tokens_since_last_reset(user_id)

remaining_tokens = self.max_daily_token - current_usage

# Use total_tokens from usage_metadata if available, otherwise calculate
if usage_metadata and "total_tokens" in usage_metadata:
total_tokens = usage_metadata["total_tokens"]
else:
total_tokens = input_tokens + output_tokens

# Check limits
if total_tokens > self.max_daily_token:
return {
"token-left": 0,
"token-left": remaining_tokens,
"can_execute": False,
"message": "Your request exceeds token limit. Try using smaller context.",
"time_left": ""
}

if current_usage + total_tokens > self.max_daily_token:
return {
"token-left": self.max_daily_token - current_usage,
"token-left": remaining_tokens,
"can_execute": False,
"message": "Daily token limit exceeded.",
"message": cfg.DAILY_LIMIT_REACHED_MESSAGE,
"time_left": ""
}

return {
"token-left": self.max_daily_token - current_usage - total_tokens,
"token-left": remaining_tokens,
"can_execute": True,
"message": "",
"time_left": ""
Expand Down Expand Up @@ -514,25 +515,6 @@ def is_in_whitelist(self, user_id: str) -> bool:
"""Check if user is in whitelist."""
return self.session.query(Whitelist).filter_by(user_id=user_id).first() is not None

def check_usage(self, user_id: str, input_tokens: int, output_tokens: int,
usage_metadata: Dict[str, Any] = None) -> dict:
"""Check usage limits."""
# Use total_tokens from usage_metadata if available
if usage_metadata and "total_tokens" in usage_metadata:
total_tokens = usage_metadata["total_tokens"]
else:
total_tokens = input_tokens + output_tokens

current_usage = self._get_sum_of_tokens_since_last_reset(user_id)
remaining_tokens = self.max_daily_token - current_usage

return {
"can_execute": total_tokens <= remaining_tokens,
"token-left": remaining_tokens,
"current_usage": current_usage,
"requested_tokens": total_tokens
}

def get_usage_metadata_statistics(self, user_id: Optional[str] = None) -> Dict[str, Any]:
"""Get detailed usage statistics from usage metadata."""
from sherpa_ai.cost_tracking.reporting import CostReporter
Expand Down
6 changes: 6 additions & 0 deletions src/sherpa_ai/scrape/file_scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ def download_file(self, file):

# Check if the request was successful (HTTP status code 200)
if response.status_code == 200:
if len(response.content) > cfg.FILE_SIZE_LIMIT:
return {
"status": "error",
"message": f"File size exceeds the limit of {cfg.FILE_SIZE_LIMIT} bytes.",
}

content_data = ""
if file["filetype"] == "pdf":
# Open the local file and write the content of the downloaded file
Expand Down
3 changes: 3 additions & 0 deletions src/tests/integration_tests/test_usage_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ def test_check_usage_limits_remaining_tokens(tracker, mock_s3_client):
# Try to use more than remaining - should fail
check_usage = tracker.check_usage(user_id="jack", input_tokens=500, output_tokens=600)
assert check_usage["can_execute"] is False
# The user-facing message should come from cfg.DAILY_LIMIT_REACHED_MESSAGE,
# not a hardcoded string that ignores the configured setting.
assert check_usage["message"] == cfg.DAILY_LIMIT_REACHED_MESSAGE


# # TODO mock time or remove this entirely
Expand Down
40 changes: 40 additions & 0 deletions src/tests/unit_tests/cost_tracking/test_cost_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ def test_custom_pricing_file(self):
finally:
os.unlink(temp_file)

def test_config_path_falls_back_to_env_setting(self):
"""PricingManager() with no explicit config_path should still pick up
cfg.MODEL_PRICING_CONFIG_PATH (set via the MODEL_PRICING_CONFIG_PATH
env var) instead of silently ignoring it."""
custom_pricing = {
"env-configured-model": {
"input_price_per_1k": 0.001,
"output_price_per_1k": 0.002,
}
}

with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(custom_pricing, f)
temp_file = f.name

try:
with patch("sherpa_ai.cost_tracking.pricing.cfg.MODEL_PRICING_CONFIG_PATH", temp_file):
pricing = PricingManager()

assert "env-configured-model" in pricing.pricing_data
finally:
os.unlink(temp_file)

def test_get_pricing(self):
"""Test getting pricing information."""
pricing = PricingManager()
Expand Down Expand Up @@ -199,6 +222,23 @@ def test_80_percent_alert(self):
assert call_args[1]["alert_level"] == "WARNING"
assert call_args[1]["threshold"] == 80

def test_disabled_cost_tracking_suppresses_alerts(self):
"""cfg.ENABLE_COST_TRACKING=False should turn off cost tracking and
alerting entirely, not just be an ignored setting."""
alert_callback = Mock()
logger = UsageLogger(
daily_cost_limit=10.0,
alert_threshold=0.8,
alert_callback=alert_callback
)

with patch("sherpa_ai.cost_tracking.logger.cfg.ENABLE_COST_TRACKING", False):
# Usage that would normally trigger both the 80% and 100% alerts
logger.log_usage("user1", 10.0, "gpt-4o")

alert_callback.assert_not_called()
assert logger.get_user_cost("user1") == 0.0

def test_100_percent_alert(self):
"""Test that 100% threshold alert is triggered."""
alert_callback = Mock()
Expand Down
50 changes: 50 additions & 0 deletions src/tests/unit_tests/scrape/test_file_scraper_size_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from unittest.mock import MagicMock, patch

from sherpa_ai.scrape.file_scraper import QuestionWithFileHandler


def _make_handler():
return QuestionWithFileHandler(
question="what does this say?",
files=[{
"id": "f1",
"filetype": "txt",
"mimetype": "text/plain",
"url_private_download": "https://example.com/f.txt",
}],
token="token",
user_id="user1",
team_id="team1",
llm=None,
)


def _mock_response(content: bytes):
response = MagicMock()
response.status_code = 200
response.content = content
return response


def test_download_file_rejects_oversized_file():
handler = _make_handler()
oversized_content = b"x" * 10

with patch("sherpa_ai.scrape.file_scraper.safe_get", return_value=_mock_response(oversized_content)), \
patch("sherpa_ai.scrape.file_scraper.cfg.FILE_SIZE_LIMIT", 5):
result = handler.download_file(handler.files[0])

assert result["status"] == "error"
assert "size" in result["message"].lower()


def test_download_file_accepts_file_within_limit():
handler = _make_handler()
small_content = b"hello world"

with patch("sherpa_ai.scrape.file_scraper.safe_get", return_value=_mock_response(small_content)), \
patch("sherpa_ai.scrape.file_scraper.cfg.FILE_SIZE_LIMIT", 1000):
result = handler.download_file(handler.files[0])

assert result["status"] == "success"
assert result["data"] == "hello world"