diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/rollback.yml b/.github/workflows/rollback.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 2e107c5..d3fae0a --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ wp-content/uploads/ node_modules/ vendor/ +# Environment & credentials +.env +.env.local +.env.credentials + # Logs *.log debug.log diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist old mode 100644 new mode 100755 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100755 index 0000000..13a82dc --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,156 @@ +# Peptide News Aggregator - Architecture + +**Version:** 2.4.0 +**Last updated:** April 2026 + +## What it does + +Peptide News Aggregator is a WordPress plugin that fetches peptide research news from RSS feeds and NewsAPI, optionally enriches articles with LLM-generated keywords and summaries via OpenRouter, filters out promotional/press-release content, and presents the feed through a React-powered shortcode with click analytics and trend reporting. + +## File tree + +``` +peptide-news-plugin/ +├── peptide-news-plugin.php # Plugin bootstrap: constants, activation hooks, DB upgrade check +├── uninstall.php # Clean uninstall: drops all 4 custom tables, deletes all options +├── composer.json # Dev dependencies (PHPUnit, WP stubs) +├── phpunit.xml.dist # PHPUnit config with coverage for includes/, admin/, public/ +├── package.json # JS lint config (ESLint) +├── .phpcs.xml.dist # PHPCS config (WordPress-Core ruleset) +├── ARCHITECTURE.md # This file +├── CONVENTIONS.md # Coding patterns and extension guides +├── README.md # Plugin overview and setup instructions +│ +├── .github/workflows/ +│ ├── ci.yml # CI: PHP lint x3, PHPCS, PHPUnit x3, security audit, JS lint +│ ├── deploy.yml # Deploy: gates on CI, rsync+SSH to Hostinger, cache purge +│ └── rollback.yml # Manual rollback workflow +│ +├── includes/ +│ ├── class-peptide-news.php # Main orchestrator: loads deps, registers all hooks +│ ├── class-peptide-news-loader.php # Hook registration utility (actions + filters) +│ ├── class-peptide-news-activator.php # Activation: creates tables, sets defaults, schedules cron +│ ├── class-peptide-news-deactivator.php # Deactivation: clears cron (preserves data) +│ ├── class-peptide-news-encryption.php # AES-256-CBC encryption for API keys at rest +│ ├── class-peptide-news-fetcher.php # RSS + NewsAPI fetching, dedup, storage, cron lock +│ ├── class-peptide-news-llm.php # OpenRouter integration (keywords + summaries) +│ ├── class-peptide-news-cost-tracker.php # LLM API cost tracking, budget enforcement, reporting +│ ├── class-peptide-news-content-filter.php # Two-tier ad/promo filter (rules + LLM) +│ ├── class-peptide-news-analytics.php # Click recording, daily aggregation, CSV export +│ ├── class-peptide-news-rest-api.php # REST API: /articles, /analytics/*, article deletion +│ └── class-peptide-news-logger.php # Structured DB logging with auto-prune +│ +├── admin/ +│ ├── class-peptide-news-admin.php # Settings page, analytics dashboard, admin assets +│ ├── css/admin-style.css # Admin dashboard styles +│ ├── js/admin-script.js # Analytics charts (Chart.js), AJAX handlers +│ ├── partials/ +│ │ ├── dashboard.php # Analytics dashboard view +│ │ ├── articles-list.php # Articles management view +│ │ └── cost-dashboard.php # LLM cost tracking dashboard view +│ └── vendor/chartjs/ # Bundled Chart.js 4.4.0 +│ +├── public/ +│ ├── class-peptide-news-public.php # Shortcode, widget, click tracking AJAX handler +│ ├── css/public-style.css # Frontend feed styles (.pn-* namespace) +│ ├── js/peptide-news-feed.js # React feed component +│ └── images/default-thumb.png # Fallback thumbnail +│ +├── tests/ +│ ├── bootstrap.php # Test bootstrap (WP function stubs) +│ └── unit/ +│ ├── FetcherTest.php +│ ├── AdminTest.php +│ ├── RestApiTest.php +│ ├── ContentFilterTest.php +│ ├── CostTrackerTest.php +│ ├── LlmUsageTest.php +│ └── PluginBootstrapTest.php +│ +└── languages/ + └── peptide-news.pot # i18n template +``` + +## Data flow + +``` +User configures settings (admin/class-peptide-news-admin.php) + │ + ▼ +WP-Cron fires `peptide_news_cron_fetch` + │ + ▼ +Fetcher::fetch_all_sources() ─── acquires transient lock (5 min) ───┐ + │ │ + ├── fetch_rss_feeds() → SimplePie via fetch_feed() │ + ├── fetch_newsapi() → NewsAPI REST (decrypted key) │ + │ │ + ▼ │ +Content_Filter::filter_articles() │ + ├── Rule-based: domain blocklist, title keywords, body keywords │ + └── LLM tier: borderline articles → OpenRouter classification │ + │ │ + ▼ │ +Fetcher::store_article() → SHA-256 dedup → INSERT into articles table│ + │ │ + ▼ │ +LLM::process_unanalyzed() (if enabled) │ + ├── Budget check: Cost_Tracker::is_budget_exceeded() │ + ├── Keywords: OpenRouter (Gemini Flash default) │ + ├── Summary: OpenRouter (Gemini Flash default) │ + └── Cost logging: Cost_Tracker::log_api_call() after each call │ + │ │ + ▼ │ +Results saved to articles table (tags, ai_summary columns) │ +Cost data saved to llm_costs table (tokens, cost, model) │ + │ lock released + ▼ +REST API serves /articles → React feed component renders on frontend + │ + ▼ +User clicks article → AJAX click tracking → Analytics::record_click() + │ + ▼ +Daily aggregation → Admin dashboard (Chart.js) + CSV export +``` + +## Custom database tables + +| Table | Schema | Written by | Purpose | +|-------|--------|-----------|---------| +| `wp_peptide_news_articles` | id, source, source_url, title, excerpt, content, author, thumbnail_url, thumbnail_local, published_at, fetched_at, categories, tags, language, sentiment_score, ai_summary, hash (UNIQUE), is_active | Fetcher, LLM | Stores fetched articles with SHA-256 dedup hash | +| `wp_peptide_news_clicks` | id, article_id (FK), clicked_at, user_ip, user_agent, referrer_url, page_url, session_id, user_id, country, device_type | Analytics | Granular click events per article | +| `wp_peptide_news_daily_stats` | id, article_id (FK), stat_date, click_count, unique_visitors | Analytics | Aggregated daily metrics for fast trend queries | +| `wp_peptide_news_llm_costs` | id, request_id, model, provider, operation, prompt_tokens, completion_tokens, total_tokens, cost_usd, article_id, created_at | Cost_Tracker | Per-API-call cost log with token counts and USD cost | +| `wp_peptide_news_log` | id, level, context, message, created_at | Logger | Structured application log (auto-pruned at 2000 rows) | + +Schema version tracked via `peptide_news_db_version` option. Migrations run via `dbDelta()` on activation and on `plugins_loaded` when version mismatch detected. + +## External API integrations + +| Service | Purpose | Integration code | Auth | +|---------|---------|-----------------|------| +| NewsAPI.org | Fetch news articles by keyword | `Fetcher::fetch_newsapi()` | API key (encrypted in wp_options) | +| OpenRouter | LLM inference (keywords, summaries, content classification) | `LLM::call_openrouter()` | API key (encrypted in wp_options) | + +Both keys are encrypted at rest using AES-256-CBC via `Peptide_News_Encryption` (since v2.3.0). Legacy plaintext keys are decrypted transparently during the migration period. + +## Key architectural decisions + +**Transient-based fetch lock instead of database mutex.** The fetcher uses a 5-minute transient lock to prevent overlapping cron runs. This is simpler than a DB-level `INSERT IGNORE` mutex and sufficient for the plugin's write volume. The lock is released on completion or expires after 5 minutes as a safety net. + +**Two-tier content filter.** Rule-based keyword/domain matching handles obvious spam cheaply. Only borderline cases (1 body keyword match, below threshold) are sent to the LLM for classification. This keeps API costs near zero for most filter operations. + +**React frontend on WP-bundled React.** The feed component uses WordPress's bundled React to avoid shipping a duplicate. This ties us to WP's React version but avoids bloating the plugin. + +**CSV export capped at 50,000 rows.** The `export_clicks_csv()` method applies a SQL LIMIT to prevent memory exhaustion on high-traffic sites. The cap is defined as a class constant (`CSV_EXPORT_MAX_ROWS`) for easy adjustment. + +**API keys encrypted at rest.** All API keys stored in wp_options are encrypted using AES-256-CBC with `wp_salt('auth')` as key material. The encryption class handles legacy plaintext values transparently, so existing installations upgrade without manual re-entry. + +**Cascading foreign keys.** The clicks and daily_stats tables have ON DELETE CASCADE foreign keys referencing the articles table, so deleting an article automatically cleans up its analytics data. + +**LLM cost tracking in a dedicated table.** API call costs are logged to `wp_peptide_news_llm_costs` rather than post_meta because cost data is high-volume write data (2 rows per article processed) that needs date-range aggregation queries. The table is indexed on `created_at` and `model` for fast dashboard reporting. + +**Budget enforcement with hard-stop mode.** The cost tracker checks `is_budget_exceeded()` before every LLM API call. In `hard_stop` mode, calls are blocked when monthly spend meets the configured limit — this prevents silent overspending. Budget alerts fire at 50%, 80%, and 100% thresholds (once per month per threshold). The monthly spend is cached in a 5-minute transient to avoid excessive DB queries during batch processing. + +**OpenRouter usage data extraction.** Token counts and costs are extracted from the OpenRouter API response body (`data.usage` field) rather than response headers, because the response body is more reliable and includes model-specific token counts. If the API reports an explicit cost, that takes precedence over our calculated estimate. diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100755 index 0000000..0a93f48 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,143 @@ +# Peptide News Aggregator - Conventions + +Project-specific patterns and step-by-step extension guides. + +## Naming conventions + +**Classes:** `Peptide_News_{Name}` (WordPress standard, one class per file). +**Files:** `class-peptide-news-{name}.php` in `includes/`, `admin/`, or `public/`. +**Options:** All prefixed `peptide_news_` (e.g., `peptide_news_fetch_interval`). +**Transients:** Prefixed `peptide_news_` (e.g., `peptide_news_fetch_lock`). +**Database tables:** `{$wpdb->prefix}peptide_news_{name}` (e.g., `wp_peptide_news_articles`). +**Hooks:** Custom actions/filters prefixed `peptide_news_` (e.g., `peptide_news_cron_fetch`). +**CSS classes:** `.pn-*` namespace to avoid collisions with theme and other plugins. +**Constants:** `PEPTIDE_NEWS_*` (e.g., `PEPTIDE_NEWS_VERSION`, `PEPTIDE_NEWS_PLUGIN_DIR`). + +## Hook registration pattern + +All hooks are registered centrally in `class-peptide-news.php` via the Loader class. Never call `add_action()` or `add_filter()` directly in a module class. Instead, register in the appropriate `define_*_hooks()` method: + +```php +// In class-peptide-news.php +private function define_cron_hooks() { + $fetcher = new Peptide_News_Fetcher(); + $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); +} +``` + +## Error handling patterns + +**External API calls:** Always check `is_wp_error()` on `wp_remote_post()`/`wp_remote_get()` responses. Return `WP_Error` from methods that make API calls. Log errors via `Peptide_News_Logger`. + +**AJAX handlers:** Always verify nonce with `check_ajax_referer( 'peptide_news_admin', 'nonce' )` and check `current_user_can( 'manage_options' )` before any action. Return via `wp_send_json_success()` / `wp_send_json_error()`. + +**Fail-open for optional features:** If encryption is unavailable (no OpenSSL), store keys as plaintext. If LLM classification fails, let the article through (`'editorial'`). Never block core functionality due to optional feature failures. + +**Logging:** Use `Peptide_News_Logger` with appropriate levels and context tags. Available contexts: `fetch`, `llm`, `admin`, `cron`, `general`. The logger auto-prunes at 2000 rows. + +## How to add a new news source + +1. Add a new private method to `class-peptide-news-fetcher.php`: + ```php + private function fetch_new_source() { + // Return array of article arrays matching this structure: + // [ 'source' => 'SourceName', 'source_url' => '...', 'title' => '...', + // 'excerpt' => '...', 'content' => '...', 'author' => '...', + // 'thumbnail_url' => '', 'published_at' => 'Y-m-d H:i:s', 'categories' => '' ] + } + ``` + +2. Call the method from `fetch_all_sources()`, gated by an option: + ```php + if ( get_option( 'peptide_news_new_source_enabled', 0 ) ) { + $articles = array_merge( $articles, $this->fetch_new_source() ); + } + ``` + +3. Add the setting to `class-peptide-news-admin.php` in `get_sanitize_callback()`. +4. Add a field renderer method and register it in `register_settings()`. +5. Update `ARCHITECTURE.md` with the new source in the data flow diagram. + +## How to add a new admin setting + +1. In `class-peptide-news-admin.php`: + - Add the sanitize callback in `get_sanitize_callback()`. + - Add a `render_{setting_name}_field()` method. + - Register the field in `register_settings()` using `$this->register_field()`. + +2. In `class-peptide-news-activator.php`: + - Add a default value in the `$defaults` array in `set_default_options()`. + +3. The option is automatically registered with the Settings API and will appear in the admin UI. + +## How to add a new LLM provider + +The plugin currently talks to OpenRouter, which proxies to multiple LLM providers. To add a direct provider: + +1. Create `includes/class-peptide-news-{provider}.php` implementing the same interface as `LLM::call_openrouter()`: takes `(string $api_key, string $model, string $prompt)` and returns `string|WP_Error`. + +2. Add a provider selection setting to the admin page. + +3. Update `LLM::process_article()` to dispatch to the selected provider. + +4. Encrypt the new provider's API key using `Peptide_News_Encryption::encrypt()` on save. + +## How to add a new REST API endpoint + +1. In `class-peptide-news-rest-api.php`: + - Add the route in `register_routes()`. + - Add the callback method. + - Use `check_admin_permissions` for admin-only endpoints. + - Use `'permission_callback' => '__return_true'` for public endpoints. + +2. For analytics endpoints, use the shared `get_date_range_args()` for consistent parameter handling. + +## How to add cost tracking for a new LLM operation + +1. In the method that calls the LLM API, use `call_openrouter_with_usage()` instead of `call_openrouter()`: + ```php + $response = self::call_openrouter_with_usage( $api_key, $model, $prompt ); + ``` + +2. After the API call, log the cost via `Peptide_News_Cost_Tracker::log_api_call()`: + ```php + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $response['usage'] ) ) { + Peptide_News_Cost_Tracker::log_api_call( + $model, + 'your_operation_name', // e.g., 'keywords', 'summary', 'filter' + $response['usage'], + $article_id, + $response['request_id'] ?? '', + $response['cost'] ?? 0.0 + ); + } + ``` + +3. Before the API call, check the budget gate: + ```php + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) { + // Skip the call, log a warning, return early + } + ``` + +4. If the model isn't in `DEFAULT_MODEL_PRICING`, add it to the constant array in `class-peptide-news-cost-tracker.php`. + +## Security checklist for every change + +- Sanitize all input: `sanitize_text_field()`, `absint()`, `esc_url_raw()`. +- Escape all output: `esc_html()`, `esc_attr()`, `esc_url()`, `wp_kses_post()`. +- Nonce on every form/AJAX: `wp_nonce_field()` / `check_ajax_referer()`. +- Capability check: `current_user_can( 'manage_options' )` on every admin action. +- API keys: always encrypt via `Peptide_News_Encryption::encrypt()`, decrypt via `::decrypt()`. +- Never expose API keys to the browser (all API calls are server-side only). + +## Database migration pattern + +- Schema changes go in `Peptide_News_Activator::create_tables()` using `dbDelta()`. +- The `plugins_loaded` hook in the bootstrap file checks `peptide_news_db_version` against `PEPTIDE_NEWS_VERSION` and re-runs activation if outdated. +- Always use `$wpdb->prepare()` for queries with dynamic values. +- Use `// phpcs:ignore` comments for unavoidable PHPCS warnings on `$wpdb->query()` calls. + +## Type safety + +All PHP files use `declare(strict_types=1)`. All public and private methods have typed parameters and return types. This catches type errors at call time rather than at runtime and gives AI agents structural information for reasoning about code. diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php old mode 100644 new mode 100755 index eb7465b..02c14a6 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -1,4 +1,5 @@ plugin_name = $plugin_name; $this->version = $version; } @@ -22,7 +23,7 @@ class Peptide_News_Admin { /** * Enqueue admin CSS. */ - public function enqueue_styles( $hook ) { + public function enqueue_styles( string $hook ): void { if ( strpos( $hook, 'peptide-news' ) === false ) { return; } @@ -38,7 +39,7 @@ class Peptide_News_Admin { /** * Enqueue admin JS. */ - public function enqueue_scripts( $hook ) { + public function enqueue_scripts( string $hook ): void { if ( strpos( $hook, 'peptide-news' ) === false ) { return; } @@ -71,7 +72,7 @@ class Peptide_News_Admin { /** * Add admin menu pages. */ - public function add_admin_menu() { + public function add_admin_menu(): void { // Main menu — Analytics Dashboard. add_menu_page( __( 'Peptide News', 'peptide-news' ), @@ -102,12 +103,22 @@ class Peptide_News_Admin { 'peptide-news-articles', array( $this, 'render_articles_page' ) ); + + // Sub-menu — Cost Dashboard. + add_submenu_page( + 'peptide-news-dashboard', + __( 'LLM Costs', 'peptide-news' ), + __( 'LLM Costs', 'peptide-news' ), + 'manage_options', + 'peptide-news-costs', + array( $this, 'render_cost_dashboard_page' ) + ); } /** * Register all settings. */ - public function register_settings() { + public function register_settings(): void { // --- General section --- add_settings_section( @@ -213,12 +224,43 @@ class Peptide_News_Admin { $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); + + // --- Cost Tracking / Budget section --- + add_settings_section( + 'peptide_news_cost_section', + __( 'Cost Tracking & Budget', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Monitor and control LLM API spending. Set a monthly budget to prevent unexpected charges.', 'peptide-news' ) . '

'; + if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) { + $month_spend = Peptide_News_Cost_Tracker::get_current_month_spend(); + $budget = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + printf( + '

%s: $%.4f', + esc_html__( 'This month\'s spend', 'peptide-news' ), + $month_spend + ); + if ( $budget > 0 ) { + printf( + ' / $%.2f (%.1f%%)', + $budget, + ( $month_spend / $budget ) * 100 + ); + } + echo '

'; + } + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'monthly_budget', __( 'Monthly Budget (USD)', 'peptide-news' ), 'render_monthly_budget_field', 'peptide_news_cost_section' ); + $this->add_setting( 'budget_mode', __( 'Budget Enforcement', 'peptide-news' ), 'render_budget_mode_field', 'peptide_news_cost_section' ); + $this->add_setting( 'cost_retention', __( 'Cost Log Retention (days)', 'peptide-news' ), 'render_cost_retention_field', 'peptide_news_cost_section' ); } /** * Helper: register a single setting with a field callback. */ - private function add_setting( $key, $label, $callback, $section ) { + private function add_setting( string $key, string $label, string $callback, string $section ): void { $option_name = "peptide_news_{$key}"; $sanitize_cb = $this->get_sanitize_callback( $key ); @@ -242,7 +284,7 @@ class Peptide_News_Admin { * @param string $key Setting key (without prefix). * @return callable */ - private function get_sanitize_callback( $key ) { + private function get_sanitize_callback( string $key ) { $callbacks = array( 'fetch_interval' => 'sanitize_text_field', 'articles_count' => 'absint', @@ -270,6 +312,16 @@ class Peptide_News_Admin { 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), 'llm_summary_model' => array( $this, 'sanitize_model_id' ), 'llm_max_articles' => 'absint', + 'monthly_budget' => function ( $value ) { + return max( 0.0, (float) $value ); + }, + 'budget_mode' => function ( $value ) { + $valid = array( 'disabled', 'hard_stop', 'warn_only' ); + return in_array( $value, $valid, true ) ? $value : 'disabled'; + }, + 'cost_retention' => function ( $value ) { + return max( 30, absint( $value ) ); + }, ); return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; @@ -281,7 +333,7 @@ class Peptide_News_Admin { * @param string $value Raw input. * @return string Sanitized model ID or default. */ - public function sanitize_model_id( $value ) { + public function sanitize_model_id( string $value ): string { $value = sanitize_text_field( $value ); if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) { add_settings_error( @@ -300,13 +352,17 @@ class Peptide_News_Admin { } /** - * Sanitize API keys. If a masked key is submitted (••••••••XXXX format), - * keep the existing key. Otherwise, store the new full key. + * Sanitize and encrypt API keys before storage. * - * @param string $value Raw input. - * @return string Stored API key (full value or existing). + * If a masked key is submitted (••••••••XXXX format), the existing + * encrypted value is preserved. New plaintext keys are encrypted + * via Peptide_News_Encryption before being written to wp_options. + * + * @param string $value Raw input from the settings form. + * @param string $option_name The wp_options key for this API key. + * @return string Encrypted API key or existing stored value. */ - public function sanitize_api_key( $value, $option_name ) { + public function sanitize_api_key( string $value, string $option_name ): string { $value = sanitize_text_field( $value ); if ( empty( $value ) ) { return ''; @@ -317,19 +373,33 @@ class Peptide_News_Admin { return get_option( $option_name, '' ); } + // Encrypt the new plaintext key before storing. + if ( class_exists( 'Peptide_News_Encryption' ) ) { + return Peptide_News_Encryption::encrypt( $value ); + } + return $value; } /** * Get a masked version of an API key showing only the last 4 characters. * - * @param string $key Full API key. + * Decrypts the stored value first (handles both encrypted and legacy + * plaintext keys transparently). + * + * @param string $key Stored API key (encrypted or plaintext). * @return string Masked key (e.g., ••••••••XXXX). */ - private function mask_api_key( $key ) { + private function mask_api_key( string $key ): string { if ( empty( $key ) ) { return ''; } + + // Decrypt before masking so we show the real key's last 4 chars. + if ( class_exists( 'Peptide_News_Encryption' ) ) { + $key = Peptide_News_Encryption::decrypt( $key ); + } + $len = strlen( $key ); if ( $len <= 4 ) { return str_repeat( '•', $len ); @@ -591,9 +661,57 @@ class Peptide_News_Admin { echo '

' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '

'; } + // --- Cost tracking field renderers --- + + /** + * Render the monthly budget input field. + */ + public function render_monthly_budget_field(): void { + $value = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + printf( + '', + $value + ); + echo '

' . esc_html__( 'Set to 0 to disable budget limits. The plugin will stop making API calls when this amount is reached (if enforcement is enabled).', 'peptide-news' ) . '

'; + } + + /** + * Render the budget enforcement mode selector. + */ + public function render_budget_mode_field(): void { + $value = get_option( 'peptide_news_budget_mode', 'disabled' ); + $modes = array( + 'disabled' => __( 'Disabled — track costs only, no limits', 'peptide-news' ), + 'warn_only' => __( 'Warn only — log alerts at 50%, 80%, 100%', 'peptide-news' ), + 'hard_stop' => __( 'Hard stop — block API calls when budget is reached', 'peptide-news' ), + ); + echo ''; + } + + /** + * Render the cost log retention field. + */ + public function render_cost_retention_field(): void { + $value = absint( get_option( 'peptide_news_cost_retention', 365 ) ); + printf( + '', + $value + ); + echo '

' . esc_html__( 'How long to keep detailed cost records. Minimum 30 days.', 'peptide-news' ) . '

'; + } + // --- Page renderers --- - public function render_settings_page() { + public function render_settings_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } @@ -895,7 +1013,7 @@ class Peptide_News_Admin { echo ''; } - public function render_dashboard_page() { + public function render_dashboard_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } @@ -913,7 +1031,7 @@ class Peptide_News_Admin { /** * Stream a CSV export of click analytics data. */ - private function export_csv() { + private function export_csv(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) ); } @@ -947,10 +1065,25 @@ class Peptide_News_Admin { exit; } - public function render_articles_page() { + public function render_articles_page(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php'; } + + /** + * Render the LLM cost tracking dashboard page. + * + * Shows monthly budget status, daily cost chart, per-model breakdown, + * and recent API call log. Data is loaded via AJAX for responsiveness. + * + * @since 2.4.0 + */ + public function render_cost_dashboard_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/cost-dashboard.php'; + } } diff --git a/admin/css/admin-style.css b/admin/css/admin-style.css old mode 100644 new mode 100755 diff --git a/admin/js/admin-script.js b/admin/js/admin-script.js old mode 100644 new mode 100755 diff --git a/admin/partials/articles-list.php b/admin/partials/articles-list.php old mode 100644 new mode 100755 index d0c8d6c..c4021fd --- a/admin/partials/articles-list.php +++ b/admin/partials/articles-list.php @@ -1,4 +1,5 @@ +
+

+ + +
+ + + +
+ + +
+

+
+

+
+
+ + +
+
+

+

$0.0000

+
+
+

+

0

+
+
+

+

0

+
+
+ + +
+

+ +
+ + +
+ +
+

+ + + + + + + + + + + + +
+
+ + +
+

+ + + + + + + + + + + +
+
+
+ + +
+

+ + + + + + + + + + + + + + + +
+
+
+ + diff --git a/admin/partials/dashboard.php b/admin/partials/dashboard.php old mode 100644 new mode 100755 index b64f797..adb47fd --- a/admin/partials/dashboard.php +++ b/admin/partials/dashboard.php @@ -1,4 +1,5 @@ get_charset_collate(); @@ -107,7 +113,7 @@ class Peptide_News_Activator { /** * Set sensible default option values. */ - private static function set_default_options() { + private static function set_default_options(): void { $defaults = array( 'fetch_interval' => 'twicedaily', 'articles_count' => 10, @@ -128,6 +134,9 @@ class Peptide_News_Activator { 'llm_keywords_model' => 'google/gemini-2.0-flash-001', 'llm_summary_model' => 'google/gemini-2.0-flash-001', 'llm_max_articles' => 10, + 'monthly_budget' => 0, + 'budget_mode' => 'disabled', + 'cost_retention' => 365, ); foreach ( $defaults as $key => $value ) { @@ -140,7 +149,7 @@ class Peptide_News_Activator { /** * Schedule the cron event based on the configured interval. */ - private static function schedule_cron() { + private static function schedule_cron(): void { $interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) { @@ -151,7 +160,7 @@ class Peptide_News_Activator { /** * Add foreign key constraints to tables. */ - private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) { + private static function add_foreign_key_constraints( string $articles_table, string $clicks_table, string $daily_table ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching diff --git a/includes/class-peptide-news-analytics.php b/includes/class-peptide-news-analytics.php old mode 100644 new mode 100755 index 068edd4..a01e7fe --- a/includes/class-peptide-news-analytics.php +++ b/includes/class-peptide-news-analytics.php @@ -1,4 +1,5 @@ prefix . 'peptide_news_clicks'; @@ -61,7 +62,7 @@ class Peptide_News_Analytics { * @param int $article_id * @param string $ip Anonymized IP for unique visitor counting. */ - private static function update_daily_stats( $article_id, $ip ) { + private static function update_daily_stats( int $article_id, string $ip ): void { global $wpdb; $table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -114,7 +115,7 @@ class Peptide_News_Analytics { * @param int $limit * @return array */ - public static function get_top_articles( $start_date, $end_date, $limit = 20 ) { + public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array { global $wpdb; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -143,7 +144,7 @@ class Peptide_News_Analytics { * @param string $end_date * @return array */ - public static function get_click_trends( $start_date, $end_date ) { + public static function get_click_trends( string $start_date, string $end_date ): array { global $wpdb; $table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -167,7 +168,7 @@ class Peptide_News_Analytics { * @param int $limit * @return array */ - public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) { + public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array { global $wpdb; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -221,7 +222,7 @@ class Peptide_News_Analytics { * @param string $end_date * @return array */ - public static function get_device_breakdown( $start_date, $end_date ) { + public static function get_device_breakdown( string $start_date, string $end_date ): array { global $wpdb; $table = $wpdb->prefix . 'peptide_news_clicks'; @@ -244,7 +245,7 @@ class Peptide_News_Analytics { * @param string $end_date * @return array */ - public static function get_source_performance( $start_date, $end_date ) { + public static function get_source_performance( string $start_date, string $end_date ): array { global $wpdb; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; @@ -265,27 +266,41 @@ class Peptide_News_Analytics { ) ); } + /** + * Hard cap on CSV export rows to prevent memory exhaustion on large datasets. + * + * @var int + */ + const CSV_EXPORT_MAX_ROWS = 50000; + /** * Export click data as CSV-ready array. * - * @param string $start_date - * @param string $end_date - * @return array + * Capped at CSV_EXPORT_MAX_ROWS to prevent memory exhaustion on sites + * with large click volumes. The cap is applied at the SQL level so + * only the limited result set is ever loaded into PHP memory. + * + * @param string $start_date Y-m-d format. + * @param string $end_date Y-m-d format. + * @return array Array of associative arrays (one per click row). */ - public static function export_clicks_csv( $start_date, $end_date ) { + public static function export_clicks_csv( string $start_date, string $end_date ): array { global $wpdb; $clicks_table = $wpdb->prefix . 'peptide_news_clicks'; $articles_table = $wpdb->prefix . 'peptide_news_articles'; + return $wpdb->get_results( $wpdb->prepare( "SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories, c.referrer_url, c.page_url, c.device_type, c.session_id FROM {$clicks_table} c INNER JOIN {$articles_table} a ON a.id = c.article_id WHERE DATE(c.clicked_at) BETWEEN %s AND %s - ORDER BY c.clicked_at DESC", + ORDER BY c.clicked_at DESC + LIMIT %d", $start_date, - $end_date + $end_date, + self::CSV_EXPORT_MAX_ROWS ), ARRAY_A ); } @@ -294,7 +309,7 @@ class Peptide_News_Analytics { * * @return string|null */ - private static function get_client_ip() { + private static function get_client_ip(): ?string { $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); foreach ( $ip_keys as $key ) { @@ -316,7 +331,7 @@ class Peptide_News_Analytics { * @param string $ip * @return string */ - private static function anonymize_ip( $ip ) { + private static function anonymize_ip( string $ip ): string { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return preg_replace( '/\.\d+$/', '.0', $ip ); } @@ -332,7 +347,7 @@ class Peptide_News_Analytics { * @param string $ua * @return string */ - private static function detect_device( $ua ) { + private static function detect_device( string $ua ): string { $ua = strtolower( $ua ); if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { diff --git a/includes/class-peptide-news-content-filter.php b/includes/class-peptide-news-content-filter.php old mode 100644 new mode 100755 index 5cb2910..3ac6431 --- a/includes/class-peptide-news-content-filter.php +++ b/includes/class-peptide-news-content-filter.php @@ -1,4 +1,5 @@ 1, 'moderate' => 2, @@ -435,7 +439,7 @@ class Peptide_News_Content_Filter { * * @return array */ - public static function get_title_keywords() { + public static function get_title_keywords(): array { $custom = get_option( 'peptide_news_filter_title_keywords', '' ); if ( ! empty( $custom ) ) { @@ -450,7 +454,7 @@ class Peptide_News_Content_Filter { * * @return array */ - public static function get_body_keywords() { + public static function get_body_keywords(): array { $custom = get_option( 'peptide_news_filter_body_keywords', '' ); if ( ! empty( $custom ) ) { @@ -465,7 +469,7 @@ class Peptide_News_Content_Filter { * * @return array */ - public static function get_blocked_domains() { + public static function get_blocked_domains(): array { $custom = get_option( 'peptide_news_filter_blocked_domains', '' ); if ( ! empty( $custom ) ) { @@ -480,7 +484,7 @@ class Peptide_News_Content_Filter { * * @return string */ - public static function get_default_title_keywords_text() { + public static function get_default_title_keywords_text(): string { return implode( "\n", self::$default_title_keywords ); } @@ -489,7 +493,7 @@ class Peptide_News_Content_Filter { * * @return string */ - public static function get_default_body_keywords_text() { + public static function get_default_body_keywords_text(): string { return implode( "\n", self::$default_body_keywords ); } @@ -498,7 +502,7 @@ class Peptide_News_Content_Filter { * * @return string */ - public static function get_default_blocked_domains_text() { + public static function get_default_blocked_domains_text(): string { return implode( "\n", self::$default_blocked_domains ); } @@ -509,7 +513,7 @@ class Peptide_News_Content_Filter { * @param int $removed Articles removed by the filter. * @param int $llm_checked Articles sent to LLM for classification. */ - private static function update_filter_stats( $total, $removed, $llm_checked ) { + private static function update_filter_stats( int $total, int $removed, int $llm_checked ): void { update_option( 'peptide_news_filter_last_run', array( 'time' => current_time( 'mysql' ), 'total' => $total, @@ -524,7 +528,7 @@ class Peptide_News_Content_Filter { * * @param string $message Log message. */ - private static function log( $message ) { + private static function log( string $message ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( self::LOG_PREFIX . ' ' . $message ); } diff --git a/includes/class-peptide-news-cost-tracker.php b/includes/class-peptide-news-cost-tracker.php new file mode 100755 index 0000000..7a765f0 --- /dev/null +++ b/includes/class-peptide-news-cost-tracker.php @@ -0,0 +1,546 @@ + + */ + const DEFAULT_MODEL_PRICING = array( + 'google/gemini-2.0-flash-001' => array( 'input' => 0.10, 'output' => 0.40 ), + 'google/gemma-3-27b-it:free' => array( 'input' => 0.00, 'output' => 0.00 ), + 'anthropic/claude-3.5-sonnet' => array( 'input' => 3.00, 'output' => 15.00 ), + 'anthropic/claude-3-haiku' => array( 'input' => 0.25, 'output' => 1.25 ), + 'openai/gpt-4o-mini' => array( 'input' => 0.15, 'output' => 0.60 ), + 'openai/gpt-4o' => array( 'input' => 2.50, 'output' => 10.00 ), + ); + + /** @var string Budget enforcement mode: 'hard_stop', 'warn_only', or 'disabled'. */ + const BUDGET_MODE_HARD_STOP = 'hard_stop'; + const BUDGET_MODE_WARN_ONLY = 'warn_only'; + const BUDGET_MODE_DISABLED = 'disabled'; + + /** + * Create the llm_costs table. Called from Activator::activate(). + * + * Uses dbDelta() for idempotent CREATE/ALTER so it's safe to call on every + * plugin upgrade without manual migration logic. + */ + public static function create_table(): void { + global $wpdb; + + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + request_id VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(100) NOT NULL DEFAULT '', + provider VARCHAR(50) NOT NULL DEFAULT 'openrouter', + operation VARCHAR(50) NOT NULL DEFAULT '', + prompt_tokens INT UNSIGNED NOT NULL DEFAULT 0, + completion_tokens INT UNSIGNED NOT NULL DEFAULT 0, + total_tokens INT UNSIGNED NOT NULL DEFAULT 0, + cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0.000000, + article_id BIGINT(20) UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_created_at (created_at), + KEY idx_model (model), + KEY idx_operation (operation), + KEY idx_article (article_id) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } + + /** + * Log a single API call's token usage and cost. + * + * Called by LLM::call_openrouter() after a successful (or failed) API response. + * Extracts token counts from the OpenRouter response's `usage` field and + * calculates cost using model-specific pricing. + * + * @param string $model OpenRouter model ID (e.g., 'google/gemini-2.0-flash-001'). + * @param string $operation What this call was for: 'keywords', 'summary', or 'filter'. + * @param array $usage Token usage from API response: {prompt_tokens, completion_tokens, total_tokens}. + * @param int|null $article_id Associated article ID, if applicable. + * @param string $request_id OpenRouter request ID from response headers (for debugging). + * @param float $explicit_cost Cost reported directly by the API (OpenRouter generation.total_cost), if available. + * @return bool True on success. + */ + public static function log_api_call( + string $model, + string $operation, + array $usage, + ?int $article_id = null, + string $request_id = '', + float $explicit_cost = 0.0 + ): bool { + global $wpdb; + + $prompt_tokens = absint( $usage['prompt_tokens'] ?? 0 ); + $completion_tokens = absint( $usage['completion_tokens'] ?? 0 ); + $total_tokens = absint( $usage['total_tokens'] ?? ( $prompt_tokens + $completion_tokens ) ); + + // Use explicit cost from API if available, otherwise calculate from pricing table. + $cost_usd = $explicit_cost > 0.0 + ? $explicit_cost + : self::calculate_cost( $model, $prompt_tokens, $completion_tokens ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( + $wpdb->prefix . 'peptide_news_llm_costs', + array( + 'request_id' => sanitize_text_field( $request_id ), + 'model' => sanitize_text_field( $model ), + 'provider' => 'openrouter', + 'operation' => sanitize_text_field( $operation ), + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'total_tokens' => $total_tokens, + 'cost_usd' => $cost_usd, + 'article_id' => $article_id, + 'created_at' => current_time( 'mysql' ), + ), + array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%f', '%d', '%s' ) + ); + + if ( false === $result ) { + Peptide_News_Logger::error( 'Failed to log API cost for model ' . $model, 'cost' ); + return false; + } + + // Check budget thresholds and fire alerts if needed. + self::check_budget_alerts(); + + return true; + } + + /** + * Calculate estimated cost from token counts using model pricing. + * + * Pricing is per 1M tokens. Checks user-configured overrides first, + * then falls back to DEFAULT_MODEL_PRICING, then to zero (free model assumed). + * + * @param string $model OpenRouter model ID. + * @param int $prompt_tokens Number of input tokens. + * @param int $completion_tokens Number of output tokens. + * @return float Estimated cost in USD. + */ + public static function calculate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float { + $pricing = self::get_model_pricing( $model ); + + $input_cost = ( $prompt_tokens / 1000000.0 ) * $pricing['input']; + $output_cost = ( $completion_tokens / 1000000.0 ) * $pricing['output']; + + return round( $input_cost + $output_cost, 6 ); + } + + /** + * Get pricing for a specific model (per 1M tokens). + * + * Checks user-configured custom pricing first, then falls back to defaults. + * + * @param string $model OpenRouter model ID. + * @return array{input: float, output: float} + */ + public static function get_model_pricing( string $model ): array { + // Check user-configured custom pricing overrides. + $custom_pricing = get_option( 'peptide_news_custom_model_pricing', array() ); + if ( is_array( $custom_pricing ) && isset( $custom_pricing[ $model ] ) ) { + return array( + 'input' => (float) ( $custom_pricing[ $model ]['input'] ?? 0.0 ), + 'output' => (float) ( $custom_pricing[ $model ]['output'] ?? 0.0 ), + ); + } + + // Fall back to built-in defaults. + if ( isset( self::DEFAULT_MODEL_PRICING[ $model ] ) ) { + return self::DEFAULT_MODEL_PRICING[ $model ]; + } + + // Unknown model — assume free to avoid overcharging estimates. + return array( 'input' => 0.0, 'output' => 0.0 ); + } + + /** + * Check if the monthly budget has been exceeded. + * + * Should be called before every LLM API call to enforce the hard stop. + * Returns true if budget mode is 'hard_stop' and current month's spend + * meets or exceeds the configured limit. + * + * @return bool True if budget is exceeded and calls should be blocked. + */ + public static function is_budget_exceeded(): bool { + $mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); + $limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + + if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) { + return false; + } + + if ( self::BUDGET_MODE_HARD_STOP !== $mode ) { + return false; + } + + $current_spend = self::get_current_month_spend(); + + return $current_spend >= $limit; + } + + /** + * Get total spend for the current calendar month. + * + * Uses a short-lived transient cache (5 min) to avoid hammering the DB + * on every budget check during batch processing. + * + * @return float Total USD spent this month. + */ + public static function get_current_month_spend(): float { + $cache_key = 'peptide_news_month_spend_' . gmdate( 'Y_m' ); + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + return (float) $cached; + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $spend = (float) $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE(SUM(cost_usd), 0) FROM {$table} WHERE created_at >= %s", + gmdate( 'Y-m-01 00:00:00' ) + ) + ); + + set_transient( $cache_key, $spend, 5 * MINUTE_IN_SECONDS ); + + return $spend; + } + + /** + * Invalidate the monthly spend cache. + * + * Called after logging a new API call so the next budget check + * reflects the latest spend. + */ + public static function invalidate_spend_cache(): void { + delete_transient( 'peptide_news_month_spend_' . gmdate( 'Y_m' ) ); + } + + /** + * Check budget threshold alerts and log warnings. + * + * Fires at 50%, 80%, and 100% of the monthly budget. Each threshold + * is only logged once per month (tracked via a transient). + */ + private static function check_budget_alerts(): void { + $mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); + $limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + + if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) { + return; + } + + // Invalidate cache so we get fresh numbers. + self::invalidate_spend_cache(); + + $spend = self::get_current_month_spend(); + $percent = ( $spend / $limit ) * 100.0; + + $alert_key = 'peptide_news_budget_alerts_' . gmdate( 'Y_m' ); + $fired = get_transient( $alert_key ); + if ( ! is_array( $fired ) ) { + $fired = array(); + } + + $thresholds = array( 50, 80, 100 ); + + foreach ( $thresholds as $threshold ) { + if ( $percent >= $threshold && ! in_array( $threshold, $fired, true ) ) { + $message = sprintf( + 'LLM budget alert: %.0f%% of $%.2f monthly budget used ($%.4f spent).', + $percent, + $limit, + $spend + ); + Peptide_News_Logger::warning( $message, 'cost' ); + + if ( 100 === $threshold && self::BUDGET_MODE_HARD_STOP === $mode ) { + Peptide_News_Logger::error( 'Monthly LLM budget exceeded — API calls will be blocked until next month.', 'cost' ); + } + + $fired[] = $threshold; + } + } + + set_transient( $alert_key, $fired, 35 * DAY_IN_SECONDS ); + } + + /** + * Get an aggregated cost summary for a date range. + * + * Returns total spend, total tokens, request count, and per-model breakdown. + * Used by the admin cost dashboard. + * + * @param string $start_date Start date (Y-m-d format). + * @param string $end_date End date (Y-m-d format). + * @return array{total_cost: float, total_tokens: int, total_requests: int, by_model: array, by_operation: array} + */ + public static function get_cost_summary( string $start_date, string $end_date ): array { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // Overall totals. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $totals = $wpdb->get_row( + $wpdb->prepare( + "SELECT + COALESCE(SUM(cost_usd), 0) AS total_cost, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COUNT(*) AS total_requests + FROM {$table} + WHERE created_at >= %s AND created_at < %s", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ); + + // Per-model breakdown. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $by_model = $wpdb->get_results( + $wpdb->prepare( + "SELECT + model, + COUNT(*) AS requests, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(SUM(cost_usd), 0) AS total_cost + FROM {$table} + WHERE created_at >= %s AND created_at < %s + GROUP BY model + ORDER BY total_cost DESC", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ); + + // Per-operation breakdown. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $by_operation = $wpdb->get_results( + $wpdb->prepare( + "SELECT + operation, + COUNT(*) AS requests, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(SUM(cost_usd), 0) AS total_cost + FROM {$table} + WHERE created_at >= %s AND created_at < %s + GROUP BY operation + ORDER BY total_cost DESC", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ); + + return array( + 'total_cost' => (float) ( $totals->total_cost ?? 0 ), + 'total_tokens' => (int) ( $totals->total_tokens ?? 0 ), + 'total_requests' => (int) ( $totals->total_requests ?? 0 ), + 'by_model' => $by_model ?: array(), + 'by_operation' => $by_operation ?: array(), + ); + } + + /** + * Get daily cost data for charting. + * + * Returns one row per day within the date range with aggregated cost and token counts. + * + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * @return array Array of objects with {date, cost, tokens, requests}. + */ + public static function get_daily_costs( string $start_date, string $end_date ): array { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_results( + $wpdb->prepare( + "SELECT + DATE(created_at) AS date, + COALESCE(SUM(cost_usd), 0) AS cost, + COALESCE(SUM(total_tokens), 0) AS tokens, + COUNT(*) AS requests + FROM {$table} + WHERE created_at >= %s AND created_at < %s + GROUP BY DATE(created_at) + ORDER BY date ASC", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ) + ) ?: array(); + } + + /** + * Get the most recent API calls for the cost log table. + * + * @param int $limit Number of recent entries to return. + * @return array Array of cost log rows. + */ + public static function get_recent_calls( int $limit = 50 ): array { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", + $limit + ) + ) ?: array(); + } + + /** + * AJAX handler: return cost dashboard data. + * + * Accepts 'period' param: 'day', 'week', 'month', or 'custom' with start/end dates. + * Returns summary stats and daily chart data. + * + * @since 2.4.0 + */ + public static function ajax_get_cost_data(): void { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + $period = sanitize_text_field( wp_unslash( $_GET['period'] ?? 'month' ) ); + + switch ( $period ) { + case 'day': + $start_date = gmdate( 'Y-m-d' ); + $end_date = gmdate( 'Y-m-d' ); + break; + + case 'week': + $start_date = gmdate( 'Y-m-d', strtotime( '-6 days' ) ); + $end_date = gmdate( 'Y-m-d' ); + break; + + case 'custom': + $raw_start = sanitize_text_field( wp_unslash( $_GET['start_date'] ?? '' ) ); + $raw_end = sanitize_text_field( wp_unslash( $_GET['end_date'] ?? '' ) ); + $start_date = self::validate_date( $raw_start ) ? $raw_start : gmdate( 'Y-m-01' ); + $end_date = self::validate_date( $raw_end ) ? $raw_end : gmdate( 'Y-m-d' ); + break; + + case 'month': + default: + $start_date = gmdate( 'Y-m-01' ); + $end_date = gmdate( 'Y-m-d' ); + break; + } + + $summary = self::get_cost_summary( $start_date, $end_date ); + $daily_costs = self::get_daily_costs( $start_date, $end_date ); + $recent = self::get_recent_calls( 25 ); + + // Budget context. + $budget_limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 ); + $budget_mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED ); + $month_spend = self::get_current_month_spend(); + + wp_send_json_success( array( + 'summary' => $summary, + 'daily_costs' => $daily_costs, + 'recent_calls' => $recent, + 'budget' => array( + 'limit' => $budget_limit, + 'mode' => $budget_mode, + 'month_spend' => $month_spend, + 'percent_used' => $budget_limit > 0 ? round( ( $month_spend / $budget_limit ) * 100, 1 ) : 0, + ), + 'period' => array( + 'start' => $start_date, + 'end' => $end_date, + ), + ) ); + } + + /** + * Validate a date string is in Y-m-d format and represents a real date. + * + * @param string $date Date string to validate. + * @return bool True if valid Y-m-d date. + */ + private static function validate_date( string $date ): bool { + $d = \DateTime::createFromFormat( 'Y-m-d', $date ); + return $d && $d->format( 'Y-m-d' ) === $date; + } + + /** + * Prune old cost records beyond the configured retention period. + * + * Called on a monthly cron schedule. Default retention: 365 days. + * + * @return int Number of rows deleted. + */ + public static function prune_old_records(): int { + $retention_days = absint( get_option( 'peptide_news_cost_retention', 365 ) ); + if ( $retention_days < 30 ) { + $retention_days = 30; // Minimum 30 days to prevent accidental data loss. + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_llm_costs'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $deleted = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$table} WHERE created_at < %s", + gmdate( 'Y-m-d 00:00:00', strtotime( "-{$retention_days} days" ) ) + ) + ); + + if ( $deleted > 0 ) { + Peptide_News_Logger::info( sprintf( 'Pruned %d cost records older than %d days.', $deleted, $retention_days ), 'cost' ); + } + + return (int) $deleted; + } +} diff --git a/includes/class-peptide-news-deactivator.php b/includes/class-peptide-news-deactivator.php old mode 100644 new mode 100755 index 9d58e31..c294dee --- a/includes/class-peptide-news-deactivator.php +++ b/includes/class-peptide-news-deactivator.php @@ -1,4 +1,5 @@ 900, 'display' => __( 'Every 15 Minutes', 'peptide-news' ), @@ -40,7 +41,7 @@ class Peptide_News_Fetcher { * Master fetch method — called by WP-Cron. * Uses a transient lock to prevent concurrent executions. */ - public function fetch_all_sources() { + public function fetch_all_sources(): void { // Prevent overlapping fetch jobs. $lock_key = 'peptide_news_fetch_lock'; if ( get_transient( $lock_key ) ) { @@ -61,7 +62,10 @@ class Peptide_News_Fetcher { // Fetch from NewsAPI. if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) { - $api_key = get_option( 'peptide_news_newsapi_key', '' ); + $api_key_raw = get_option( 'peptide_news_newsapi_key', '' ); + $api_key = class_exists( 'Peptide_News_Encryption' ) + ? Peptide_News_Encryption::decrypt( $api_key_raw ) + : $api_key_raw; if ( ! empty( $api_key ) ) { $newsapi_articles = $this->fetch_newsapi( $api_key ); $articles = array_merge( $articles, $newsapi_articles ); @@ -128,7 +132,7 @@ class Peptide_News_Fetcher { * * @return array */ - private function fetch_rss_feeds() { + private function fetch_rss_feeds(): array { $feeds_raw = get_option( 'peptide_news_rss_feeds', '' ); $feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) ); $articles = array(); @@ -175,7 +179,7 @@ class Peptide_News_Fetcher { * @param string $feed_url * @return array Article data or empty array if processing failed. */ - private function process_rss_item( $item, $feed_url ) { + private function process_rss_item( $item, string $feed_url ): array { $pub_date = $item->get_date( 'Y-m-d H:i:s' ); if ( empty( $pub_date ) ) { $pub_date = current_time( 'mysql' ); @@ -209,7 +213,7 @@ class Peptide_News_Fetcher { * @param string $api_key * @return array */ - private function fetch_newsapi( $api_key ) { + private function fetch_newsapi( string $api_key ): array { $keywords = get_option( 'peptide_news_search_keywords', 'peptide research' ); $articles = array(); @@ -267,7 +271,7 @@ class Peptide_News_Fetcher { * @param array $article * @return bool True if new article was inserted. */ - private function store_article( $article ) { + private function store_article( array $article ): bool { global $wpdb; $table = $wpdb->prefix . 'peptide_news_articles'; @@ -310,7 +314,7 @@ class Peptide_News_Fetcher { /** * Remove articles older than the retention period. */ - private function prune_old_articles() { + private function prune_old_articles(): void { global $wpdb; $retention_days = (int) get_option( 'peptide_news_article_retention', 90 ); @@ -326,7 +330,7 @@ class Peptide_News_Fetcher { /** * Clear all article transient caches. */ - private function clear_article_cache() { + private function clear_article_cache(): void { global $wpdb; $wpdb->query( $wpdb->prepare( @@ -342,7 +346,7 @@ class Peptide_News_Fetcher { /** * Invalidate REST API response caches matching the articles pattern. */ - private function invalidate_rest_api_cache() { + private function invalidate_rest_api_cache(): void { global $wpdb; $wpdb->query( $wpdb->prepare( @@ -361,7 +365,7 @@ class Peptide_News_Fetcher { * @param SimplePie_Item $item * @return string Comma-separated categories. */ - private function extract_categories( $item ) { + private function extract_categories( $item ): string { $categories = $item->get_categories(); if ( empty( $categories ) ) { return ''; @@ -393,7 +397,7 @@ class Peptide_News_Fetcher { * @param string $article_url The article permalink. * @return string Source name or domain. */ - private function resolve_article_source( $item, $feed_url, $article_url ) { + private function resolve_article_source( $item, string $feed_url, string $article_url ): string { $feed_domain = $this->extract_domain( $feed_url ); $is_aggregator = in_array( $feed_domain, @@ -455,7 +459,7 @@ class Peptide_News_Fetcher { * @param string $url The URL to resolve. * @return string|false The final URL or false on failure. */ - private function resolve_redirect_url( $url ) { + private function resolve_redirect_url( string $url ) { if ( empty( $url ) ) { return false; } @@ -505,7 +509,7 @@ class Peptide_News_Fetcher { * * @return int Number of articles updated. */ - public function backfill_article_sources() { + public function backfill_article_sources(): int { global $wpdb; $table = $wpdb->prefix . 'peptide_news_articles'; @@ -572,7 +576,7 @@ class Peptide_News_Fetcher { /** * AJAX handler for the backfill action. */ - public function ajax_backfill_sources() { + public function ajax_backfill_sources(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { @@ -589,7 +593,7 @@ class Peptide_News_Fetcher { * @param string $url * @return string */ - private function extract_domain( $url ) { + private function extract_domain( string $url ): string { $host = wp_parse_url( $url, PHP_URL_HOST ); return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown'; } @@ -599,7 +603,7 @@ class Peptide_News_Fetcher { * * @param string $message */ - private function log_error( $message ) { + private function log_error( string $message ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[Peptide News] ' . $message ); } diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php old mode 100644 new mode 100755 index 25058e6..6e78213 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -1,4 +1,5 @@ '', 'summary' => '', 'success' => false ); } @@ -67,25 +88,44 @@ class Peptide_News_LLM { 'errors' => array(), ); - $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); + $api_key = self::get_api_key(); $any_success = false; + $article_id = (int) $article->id; // --- Keyword extraction --- $keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); $need_keywords = $force || empty( $article->tags ); if ( $need_keywords && self::is_valid_model( $keywords_model ) ) { - $keywords_prompt = self::build_keywords_prompt( $article ); - $keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt ); - - if ( ! is_wp_error( $keywords_result ) ) { - $results['keywords'] = self::sanitize_keywords( $keywords_result ); - $any_success = true; - Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' ); + // Budget gate: block if monthly limit reached. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) { + $results['errors'][] = 'Monthly LLM budget exceeded — keywords skipped.'; + Peptide_News_Logger::warning( 'Budget exceeded, skipping keywords for article #' . $article_id, 'cost' ); } else { - $err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message(); - $results['errors'][] = $err_msg; - Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' ); + $keywords_prompt = self::build_keywords_prompt( $article ); + $keywords_response = self::call_openrouter_with_usage( $api_key, $keywords_model, $keywords_prompt ); + + if ( ! is_wp_error( $keywords_response['content'] ) ) { + $results['keywords'] = self::sanitize_keywords( $keywords_response['content'] ); + $any_success = true; + Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article_id, 'llm' ); + } else { + $err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_response['content']->get_error_message(); + $results['errors'][] = $err_msg; + Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article_id . ': ' . $keywords_response['content']->get_error_message(), 'llm' ); + } + + // Log cost regardless of success — failed calls still consume tokens. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $keywords_response['usage'] ) ) { + Peptide_News_Cost_Tracker::log_api_call( + $keywords_model, + 'keywords', + $keywords_response['usage'], + $article_id, + $keywords_response['request_id'] ?? '', + $keywords_response['cost'] ?? 0.0 + ); + } } } elseif ( $need_keywords ) { $results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model; @@ -97,18 +137,36 @@ class Peptide_News_LLM { $need_summary = $force || empty( $article->ai_summary ); if ( $need_summary && self::is_valid_model( $summary_model ) ) { - $summary_prompt = self::build_summary_prompt( $article ); - $summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt ); - - if ( ! is_wp_error( $summary_result ) ) { - // Strip all HTML tags from LLM output to prevent XSS. - $results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) ); - $any_success = true; - Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' ); + // Budget gate: re-check in case keyword extraction pushed us over. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) { + $results['errors'][] = 'Monthly LLM budget exceeded — summary skipped.'; + Peptide_News_Logger::warning( 'Budget exceeded, skipping summary for article #' . $article_id, 'cost' ); } else { - $err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message(); - $results['errors'][] = $err_msg; - Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' ); + $summary_prompt = self::build_summary_prompt( $article ); + $summary_response = self::call_openrouter_with_usage( $api_key, $summary_model, $summary_prompt ); + + if ( ! is_wp_error( $summary_response['content'] ) ) { + // Strip all HTML tags from LLM output to prevent XSS. + $results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_response['content'] ) ); + $any_success = true; + Peptide_News_Logger::info( 'AI summary generated for article #' . $article_id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' ); + } else { + $err_msg = 'Summary (' . $summary_model . '): ' . $summary_response['content']->get_error_message(); + $results['errors'][] = $err_msg; + Peptide_News_Logger::error( 'Summarization failed for article #' . $article_id . ': ' . $summary_response['content']->get_error_message(), 'llm' ); + } + + // Log cost regardless of success. + if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $summary_response['usage'] ) ) { + Peptide_News_Cost_Tracker::log_api_call( + $summary_model, + 'summary', + $summary_response['usage'], + $article_id, + $summary_response['request_id'] ?? '', + $summary_response['cost'] ?? 0.0 + ); + } } } elseif ( $need_summary ) { $results['errors'][] = 'Invalid summary model ID: ' . $summary_model; @@ -119,7 +177,7 @@ class Peptide_News_LLM { // --- Persist results --- if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) { - self::save_results( $article->id, $results, $force ); + self::save_results( $article_id, $results, $force ); } return $results; @@ -136,7 +194,7 @@ class Peptide_News_LLM { * @param bool $override_limit When true, ignore the admin "max per cycle" cap. * @return int Number of articles successfully processed. */ - public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) { + public static function process_unanalyzed( int $batch_size = 10, bool $override_limit = false ): int { if ( ! self::is_enabled() ) { return 0; } @@ -221,7 +279,7 @@ class Peptide_News_LLM { * @param object $article * @return string */ - private static function build_keywords_prompt( $article ) { + private static function build_keywords_prompt( object $article ): string { $text = $article->title; if ( ! empty( $article->excerpt ) ) { $text .= "\n\n" . $article->excerpt; @@ -245,7 +303,7 @@ class Peptide_News_LLM { * @param object $article * @return string */ - private static function build_summary_prompt( $article ) { + private static function build_summary_prompt( object $article ): string { $title = trim( $article->title ?? '' ); $excerpt = trim( $article->excerpt ?? '' ); $content = trim( $article->content ?? '' ); @@ -287,7 +345,7 @@ class Peptide_News_LLM { * @param string $url The article URL. * @return string Cleaned plain text or empty string on failure. */ - private static function fetch_article_text( $url ) { + private static function fetch_article_text( string $url ): string { $response = wp_remote_get( $url, array( 'timeout' => 10, 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0; +https://peptiderepo.com)', @@ -337,12 +395,38 @@ class Peptide_News_LLM { /** * Call the OpenRouter API with retry logic for 429 rate limits. * + * Backward-compatible wrapper that returns only the content string. + * Internally delegates to call_openrouter_with_usage(). + * * @param string $api_key * @param string $model * @param string $prompt * @return string|WP_Error The response text or an error. */ - public static function call_openrouter( $api_key, $model, $prompt ) { + public static function call_openrouter( string $api_key, string $model, string $prompt ) { + $result = self::call_openrouter_with_usage( $api_key, $model, $prompt ); + return $result['content']; + } + + /** + * Call the OpenRouter API and return content + token usage data. + * + * Returns an array with 'content' (string|WP_Error), 'usage' (token counts), + * 'request_id' (for debugging), and 'cost' (if API reports it). + * + * @param string $api_key + * @param string $model + * @param string $prompt + * @return array{content: string|WP_Error, usage: array, request_id: string, cost: float} + */ + public static function call_openrouter_with_usage( string $api_key, string $model, string $prompt ): array { + $result = array( + 'content' => '', + 'usage' => array(), + 'request_id' => '', + 'cost' => 0.0, + ); + $body = array( 'model' => $model, 'messages' => array( @@ -371,7 +455,8 @@ class Peptide_News_LLM { ) ); if ( is_wp_error( $response ) ) { - return $response; + $result['content'] = $response; + return $result; } $status = wp_remote_retrieve_response_code( $response ); @@ -391,29 +476,55 @@ class Peptide_News_LLM { $data = json_decode( wp_remote_retrieve_body( $response ), true ); + // Extract usage data from response regardless of success/failure. + // OpenRouter includes usage in the response body: data.usage.{prompt_tokens, completion_tokens, total_tokens} + if ( is_array( $data ) && isset( $data['usage'] ) && is_array( $data['usage'] ) ) { + $result['usage'] = array( + 'prompt_tokens' => (int) ( $data['usage']['prompt_tokens'] ?? 0 ), + 'completion_tokens' => (int) ( $data['usage']['completion_tokens'] ?? 0 ), + 'total_tokens' => (int) ( $data['usage']['total_tokens'] ?? 0 ), + ); + } + + // Extract the request ID from response for debugging/reconciliation. + if ( is_array( $data ) && isset( $data['id'] ) ) { + $result['request_id'] = (string) $data['id']; + } + + // OpenRouter may include generation cost in data.usage.total_cost or similar. + if ( is_array( $data ) && isset( $data['usage']['total_cost'] ) ) { + $result['cost'] = (float) $data['usage']['total_cost']; + } + if ( $status < 200 || $status >= 300 ) { $error_msg = 'HTTP ' . $status; if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) { $error_msg = $data['error']['message']; } - return new WP_Error( 'openrouter_error', $error_msg ); + $result['content'] = new WP_Error( 'openrouter_error', $error_msg ); + return $result; } // Validate the response structure thoroughly. if ( ! is_array( $data ) ) { - return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); + $result['content'] = new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); + return $result; } if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) { - return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); + $result['content'] = new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); + return $result; } if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) { - return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); + $result['content'] = new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); + return $result; } if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) { - return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); + $result['content'] = new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); + return $result; } - return trim( $data['choices'][0]['message']['content'] ); + $result['content'] = trim( $data['choices'][0]['message']['content'] ); + return $result; } /** @@ -422,7 +533,7 @@ class Peptide_News_LLM { * @param string $raw Raw comma-separated keywords. * @return string Cleaned, deduplicated, comma-separated list. */ - private static function sanitize_keywords( $raw ) { + private static function sanitize_keywords( string $raw ): string { // Strip all HTML first to prevent XSS. $raw = wp_strip_all_tags( $raw ); @@ -451,7 +562,7 @@ class Peptide_News_LLM { * @param array $results Array with 'keywords' and 'summary'. * @param bool $force Overwrite existing values. */ - private static function save_results( $article_id, $results, $force = false ) { + private static function save_results( int $article_id, array $results, bool $force = false ): void { global $wpdb; $table = $wpdb->prefix . 'peptide_news_articles'; @@ -487,7 +598,7 @@ class Peptide_News_LLM { * * @since 2.0.1 */ - public static function ajax_generate_summaries() { + public static function ajax_generate_summaries(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); @@ -557,7 +668,7 @@ class Peptide_News_LLM { /** * Clear all article transient caches. */ - private static function clear_article_cache() { + private static function clear_article_cache(): void { global $wpdb; $wpdb->query( $wpdb->prepare( @@ -575,7 +686,7 @@ class Peptide_News_LLM { * * @param string $message */ - private static function log_error( $message ) { + private static function log_error( string $message ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[Peptide News LLM] ' . $message ); } diff --git a/includes/class-peptide-news-loader.php b/includes/class-peptide-news-loader.php old mode 100644 new mode 100755 index 40ea35c..51366d3 --- a/includes/class-peptide-news-loader.php +++ b/includes/class-peptide-news-loader.php @@ -1,4 +1,5 @@ actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args ); } @@ -64,7 +65,7 @@ class Peptide_News_Loader { * @param int $priority Optional. The priority at which the function should be fired. Default is 10. * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. */ - public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { + public function add_filter( string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1 ): void { $this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args ); } @@ -81,7 +82,7 @@ class Peptide_News_Loader { * @param int $accepted_args The number of arguments that should be passed to the $callback. * @return array The collection of actions and filters registered with WordPress. */ - private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) { + private function add( array $hooks, string $hook, object $component, string $callback, int $priority, int $accepted_args ): array { $hooks[] = array( 'hook' => $hook, 'component' => $component, @@ -98,7 +99,7 @@ class Peptide_News_Loader { * * @since 1.0.0 */ - public function run() { + public function run(): void { foreach ( $this->filters as $hook ) { add_filter( $hook['hook'], diff --git a/includes/class-peptide-news-logger.php b/includes/class-peptide-news-logger.php old mode 100644 new mode 100755 index 20cfbdf..2966a6f --- a/includes/class-peptide-news-logger.php +++ b/includes/class-peptide-news-logger.php @@ -1,4 +1,5 @@ prefix . 'peptide_news_log'; } @@ -37,7 +38,7 @@ class Peptide_News_Logger { * @param string $message Human-readable message. * @param string $context Category tag (fetch, llm, admin, cron, general). */ - public static function log( $level, $message, $context = 'general' ) { + public static function log( string $level, string $message, string $context = 'general' ): void { global $wpdb; $wpdb->insert( @@ -56,22 +57,22 @@ class Peptide_News_Logger { } /** Convenience: info level. */ - public static function info( $message, $context = 'general' ) { + public static function info( string $message, string $context = 'general' ): void { self::log( self::LEVEL_INFO, $message, $context ); } /** Convenience: warning level. */ - public static function warning( $message, $context = 'general' ) { + public static function warning( string $message, string $context = 'general' ): void { self::log( self::LEVEL_WARNING, $message, $context ); } /** Convenience: error level. */ - public static function error( $message, $context = 'general' ) { + public static function error( string $message, string $context = 'general' ): void { self::log( self::LEVEL_ERROR, $message, $context ); } /** Convenience: debug level (only writes when WP_DEBUG is on). */ - public static function debug( $message, $context = 'general' ) { + public static function debug( string $message, string $context = 'general' ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { self::log( self::LEVEL_DEBUG, $message, $context ); } @@ -94,7 +95,7 @@ class Peptide_News_Logger { * } * @return array { 'rows' => array, 'total' => int } */ - public static function get_logs( $args = array() ) { + public static function get_logs( array $args = array() ): array { global $wpdb; $defaults = array( @@ -145,7 +146,7 @@ class Peptide_News_Logger { /** * Remove rows beyond the MAX_ROWS cap. */ - private static function maybe_prune() { + private static function maybe_prune(): void { global $wpdb; $table = self::table(); @@ -168,7 +169,7 @@ class Peptide_News_Logger { /** * Clear all log entries. */ - public static function clear() { + public static function clear(): void { global $wpdb; $wpdb->query( "TRUNCATE TABLE " . self::table() ); } @@ -180,7 +181,7 @@ class Peptide_News_Logger { /** * AJAX: fetch paginated log entries. */ - public static function ajax_get_logs() { + public static function ajax_get_logs(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { @@ -210,7 +211,7 @@ class Peptide_News_Logger { /** * AJAX: clear all log entries. */ - public static function ajax_clear_logs() { + public static function ajax_clear_logs(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { @@ -230,7 +231,7 @@ class Peptide_News_Logger { /** * Create the log table (called from activator / upgrade check). */ - public static function create_table() { + public static function create_table(): void { global $wpdb; $table = self::table(); diff --git a/includes/class-peptide-news-rest-api.php b/includes/class-peptide-news-rest-api.php old mode 100644 new mode 100755 index 8dc07b9..f697f4b --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -1,4 +1,5 @@ array( 'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), @@ -216,7 +217,7 @@ class Peptide_News_Rest_API { * @param string $source_url The source URL (used to extract domain-based publisher names). * @return string Cleaned text. */ - private function strip_source_suffix( $text, $source, $source_url = '' ) { + private function strip_source_suffix( string $text, string $source, string $source_url = '' ): string { if ( empty( $text ) ) { return $text; } @@ -387,7 +388,7 @@ class Peptide_News_Rest_API { * * @since 2.1.0 */ - public static function ajax_delete_articles() { + public static function ajax_delete_articles(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php old mode 100644 new mode 100755 index e6120cf..e3b5fd1 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -1,4 +1,5 @@ plugin_name, $this->version ); $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' ); @@ -62,7 +65,7 @@ class Peptide_News { /** * Register public-facing hooks. */ - private function define_public_hooks() { + private function define_public_hooks(): void { $public = new Peptide_News_Public( $this->plugin_name, $this->version ); $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' ); @@ -76,7 +79,7 @@ class Peptide_News { /** * Register WP-Cron hooks for scheduled fetching. */ - private function define_cron_hooks() { + private function define_cron_hooks(): void { $fetcher = new Peptide_News_Fetcher(); $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); @@ -86,12 +89,13 @@ class Peptide_News { $this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' ); $this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' ); $this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' ); + $this->loader->add_action( 'wp_ajax_peptide_news_get_cost_data', 'Peptide_News_Cost_Tracker', 'ajax_get_cost_data' ); } /** * Register REST API hooks. */ - private function define_rest_hooks() { + private function define_rest_hooks(): void { $rest = new Peptide_News_Rest_API(); $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); @@ -100,19 +104,19 @@ class Peptide_News { /** * Run the loader to execute all registered hooks. */ - public function run() { + public function run(): void { $this->loader->run(); } - public function get_plugin_name() { + public function get_plugin_name(): string { return $this->plugin_name; } - public function get_version() { + public function get_version(): string { return $this->version; } - public function get_loader() { + public function get_loader(): Peptide_News_Loader { return $this->loader; } } diff --git a/languages/peptide-news.pot b/languages/peptide-news.pot old mode 100644 new mode 100755 diff --git a/package-lock.json b/package-lock.json old mode 100644 new mode 100755 diff --git a/package.json b/package.json old mode 100644 new mode 100755 diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php old mode 100644 new mode 100755 index 5d1e878..74503f9 --- a/peptide-news-plugin.php +++ b/peptide-news-plugin.php @@ -1,9 +1,10 @@ run(); } diff --git a/phpunit.xml.dist b/phpunit.xml.dist old mode 100644 new mode 100755 diff --git a/public/class-peptide-news-public.php b/public/class-peptide-news-public.php old mode 100644 new mode 100755 index 7e961a4..0ff8228 --- a/public/class-peptide-news-public.php +++ b/public/class-peptide-news-public.php @@ -1,4 +1,5 @@ plugin_name = $plugin_name; $this->version = $version; } @@ -24,7 +25,7 @@ class Peptide_News_Public { /** * Register front-end CSS (enqueued on demand by the shortcode/widget). */ - public function enqueue_styles() { + public function enqueue_styles(): void { wp_register_style( $this->plugin_name, PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css', @@ -40,7 +41,7 @@ class Peptide_News_Public { * Loads React (from WordPress core), the feed component, and * passes configuration via wp_localize_script. */ - public function enqueue_scripts() { + public function enqueue_scripts(): void { // React feed component (depends on WP-bundled React). wp_register_script( $this->plugin_name . '-feed', @@ -54,14 +55,14 @@ class Peptide_News_Public { /** * Register the shortcode. */ - public function register_shortcode() { + public function register_shortcode(): void { add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) ); } /** * Register the widget. */ - public function register_widget() { + public function register_widget(): void { register_widget( 'Peptide_News_Widget' ); } @@ -75,7 +76,7 @@ class Peptide_News_Public { * @param array $atts Shortcode attributes. * @return string HTML output. */ - public function render_shortcode( $atts ) { + public function render_shortcode( $atts ): string { $atts = shortcode_atts( array( 'count' => get_option( 'peptide_news_articles_count', 10 ), ), $atts, 'peptide_news' ); @@ -102,7 +103,7 @@ class Peptide_News_Public { /** * Handle AJAX click tracking with basic rate limiting. */ - public function handle_click_tracking() { + public function handle_click_tracking(): void { check_ajax_referer( 'peptide_news_track', 'nonce' ); $article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0; @@ -142,7 +143,7 @@ class Peptide_News_Public { * * @return string */ - private function get_or_create_session_id() { + private function get_or_create_session_id(): string { if ( isset( $_COOKIE['pn_session_id'] ) ) { return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) ); } diff --git a/public/css/public-style.css b/public/css/public-style.css old mode 100644 new mode 100755 diff --git a/public/images/default-thumb.png b/public/images/default-thumb.png old mode 100644 new mode 100755 diff --git a/public/js/peptide-news-feed.js b/public/js/peptide-news-feed.js old mode 100644 new mode 100755 diff --git a/public/js/public-script.js b/public/js/public-script.js old mode 100644 new mode 100755 diff --git a/public/react-app/PeptideNewsFeed.jsx b/public/react-app/PeptideNewsFeed.jsx old mode 100644 new mode 100755 diff --git a/tests/bootstrap.php b/tests/bootstrap.php old mode 100644 new mode 100755 index ced6b39..17bcda3 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -118,6 +118,32 @@ if ( ! function_exists( 'add_filter' ) ) { return true; } } +if ( ! function_exists( 'get_transient' ) ) { + function get_transient( $transient ) { + global $_test_transients; + return isset( $_test_transients[ $transient ] ) ? $_test_transients[ $transient ] : false; + } +} +if ( ! function_exists( 'set_transient' ) ) { + function set_transient( $transient, $value, $expiration = 0 ) { + global $_test_transients; + $_test_transients[ $transient ] = $value; + return true; + } +} +if ( ! function_exists( 'delete_transient' ) ) { + function delete_transient( $transient ) { + global $_test_transients; + unset( $_test_transients[ $transient ] ); + return true; + } +} +if ( ! defined( 'MINUTE_IN_SECONDS' ) ) { + define( 'MINUTE_IN_SECONDS', 60 ); +} +if ( ! defined( 'DAY_IN_SECONDS' ) ) { + define( 'DAY_IN_SECONDS', 86400 ); +} if ( ! function_exists( 'add_action' ) ) { function add_action( $tag, $callback, $priority = 10, $accepted_args = 1 ) { return true; @@ -212,6 +238,72 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filt // LLM class provides is_valid_model() used by Admin sanitize callbacks. require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; +// Stub AJAX-related functions used by cost tracker and other modules. +if ( ! function_exists( 'wp_send_json_success' ) ) { + function wp_send_json_success( $data = null, $status_code = null ) { + global $_test_json_response; + $_test_json_response = array( 'success' => true, 'data' => $data ); + } +} +if ( ! function_exists( 'wp_send_json_error' ) ) { + function wp_send_json_error( $data = null, $status_code = null ) { + global $_test_json_response; + $_test_json_response = array( 'success' => false, 'data' => $data ); + } +} +if ( ! function_exists( 'check_ajax_referer' ) ) { + function check_ajax_referer( $action = -1, $query_arg = false, $die = true ) { + return true; + } +} +if ( ! function_exists( 'wp_unslash' ) ) { + function wp_unslash( $value ) { + return is_string( $value ) ? stripslashes( $value ) : $value; + } +} +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, $options = 0, $depth = 512 ) { + return json_encode( $data, $options, $depth ); + } +} +if ( ! function_exists( 'home_url' ) ) { + function home_url( $path = '' ) { + return 'https://example.com' . $path; + } +} +if ( ! function_exists( 'wp_remote_post' ) ) { + function wp_remote_post( $url, $args = array() ) { + return new WP_Error( 'http_request_not_available', 'HTTP requests not available in tests' ); + } +} +if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + function wp_remote_retrieve_response_code( $response ) { + return 200; + } +} +if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { + function wp_remote_retrieve_body( $response ) { + return ''; + } +} +if ( ! function_exists( 'wp_remote_retrieve_header' ) ) { + function wp_remote_retrieve_header( $response, $header ) { + return ''; + } +} +if ( ! function_exists( 'selected' ) ) { + function selected( $selected, $current = true, $echo = true ) { + $result = (string) $selected === (string) $current ? ' selected="selected"' : ''; + if ( $echo ) { + echo $result; + } + return $result; + } +} + +// Cost tracker provides calculate_cost(), budget checks used by tests. +require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.php'; + // Stub WP_REST_Request for REST API tests. if ( ! class_exists( 'WP_REST_Request' ) ) { class WP_REST_Request { diff --git a/tests/unit/AdminTest.php b/tests/unit/AdminTest.php old mode 100644 new mode 100755 diff --git a/tests/unit/ContentFilterTest.php b/tests/unit/ContentFilterTest.php old mode 100644 new mode 100755 diff --git a/tests/unit/CostTrackerTest.php b/tests/unit/CostTrackerTest.php new file mode 100755 index 0000000..728edf8 --- /dev/null +++ b/tests/unit/CostTrackerTest.php @@ -0,0 +1,213 @@ +assertEqualsWithDelta( 0.0003, $cost, 0.000001 ); + } + + public function test_calculate_cost_with_free_model(): void { + $cost = Peptide_News_Cost_Tracker::calculate_cost( + 'google/gemma-3-27b-it:free', + 5000, + 2000 + ); + + $this->assertEqualsWithDelta( 0.0, $cost, 0.000001 ); + } + + public function test_calculate_cost_with_unknown_model_returns_zero(): void { + // Unknown models default to zero pricing to avoid overcharging estimates. + $cost = Peptide_News_Cost_Tracker::calculate_cost( + 'unknown/model-xyz', + 10000, + 5000 + ); + + $this->assertEqualsWithDelta( 0.0, $cost, 0.000001 ); + } + + public function test_calculate_cost_with_expensive_model(): void { + // anthropic/claude-3.5-sonnet: input $3.00/1M, output $15.00/1M + $cost = Peptide_News_Cost_Tracker::calculate_cost( + 'anthropic/claude-3.5-sonnet', + 2000, // 2K prompt tokens + 1000 // 1K completion tokens + ); + + // Expected: (2000/1M * 3.00) + (1000/1M * 15.00) = 0.006 + 0.015 = 0.021 + $this->assertEqualsWithDelta( 0.021, $cost, 0.000001 ); + } + + public function test_calculate_cost_with_zero_tokens(): void { + $cost = Peptide_News_Cost_Tracker::calculate_cost( + 'anthropic/claude-3.5-sonnet', + 0, + 0 + ); + + $this->assertEqualsWithDelta( 0.0, $cost, 0.000001 ); + } + + public function test_calculate_cost_precision_with_large_token_count(): void { + // Simulate a large batch: 1M prompt tokens with gpt-4o ($2.50/1M) + $cost = Peptide_News_Cost_Tracker::calculate_cost( + 'openai/gpt-4o', + 1000000, // 1M prompt tokens + 500000 // 500K completion tokens + ); + + // Expected: (1M/1M * 2.50) + (500K/1M * 10.00) = 2.50 + 5.00 = 7.50 + $this->assertEqualsWithDelta( 7.5, $cost, 0.000001 ); + } + + // ============================================= + // get_model_pricing() — pricing lookup tests + // ============================================= + + public function test_get_model_pricing_returns_known_defaults(): void { + $pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'openai/gpt-4o-mini' ); + + $this->assertArrayHasKey( 'input', $pricing ); + $this->assertArrayHasKey( 'output', $pricing ); + $this->assertEqualsWithDelta( 0.15, $pricing['input'], 0.001 ); + $this->assertEqualsWithDelta( 0.60, $pricing['output'], 0.001 ); + } + + public function test_get_model_pricing_unknown_model_returns_zero(): void { + $pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'totally/unknown-model' ); + + $this->assertEqualsWithDelta( 0.0, $pricing['input'], 0.001 ); + $this->assertEqualsWithDelta( 0.0, $pricing['output'], 0.001 ); + } + + public function test_get_model_pricing_respects_custom_overrides(): void { + global $_test_options; + $_test_options['peptide_news_custom_model_pricing'] = array( + 'custom/my-model' => array( 'input' => 1.50, 'output' => 5.00 ), + ); + + $pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'custom/my-model' ); + + $this->assertEqualsWithDelta( 1.50, $pricing['input'], 0.001 ); + $this->assertEqualsWithDelta( 5.00, $pricing['output'], 0.001 ); + } + + public function test_custom_pricing_overrides_defaults(): void { + global $_test_options; + // Override the default pricing for gemini flash. + $_test_options['peptide_news_custom_model_pricing'] = array( + 'google/gemini-2.0-flash-001' => array( 'input' => 0.50, 'output' => 1.00 ), + ); + + $pricing = Peptide_News_Cost_Tracker::get_model_pricing( 'google/gemini-2.0-flash-001' ); + + $this->assertEqualsWithDelta( 0.50, $pricing['input'], 0.001 ); + $this->assertEqualsWithDelta( 1.00, $pricing['output'], 0.001 ); + } + + // ============================================= + // is_budget_exceeded() — budget enforcement tests + // ============================================= + + public function test_budget_disabled_never_exceeds(): void { + global $_test_options; + $_test_options['peptide_news_budget_mode'] = 'disabled'; + $_test_options['peptide_news_monthly_budget'] = 10.0; + + $this->assertFalse( Peptide_News_Cost_Tracker::is_budget_exceeded() ); + } + + public function test_budget_zero_limit_never_exceeds(): void { + global $_test_options; + $_test_options['peptide_news_budget_mode'] = 'hard_stop'; + $_test_options['peptide_news_monthly_budget'] = 0.0; + + $this->assertFalse( Peptide_News_Cost_Tracker::is_budget_exceeded() ); + } + + public function test_budget_warn_only_never_exceeds(): void { + global $_test_options; + $_test_options['peptide_news_budget_mode'] = 'warn_only'; + $_test_options['peptide_news_monthly_budget'] = 1.0; + + // warn_only should never return true (it only logs warnings). + $this->assertFalse( Peptide_News_Cost_Tracker::is_budget_exceeded() ); + } + + // ============================================= + // DEFAULT_MODEL_PRICING — structure validation + // ============================================= + + public function test_all_default_pricing_has_required_keys(): void { + foreach ( Peptide_News_Cost_Tracker::DEFAULT_MODEL_PRICING as $model => $pricing ) { + $this->assertArrayHasKey( 'input', $pricing, "Model {$model} missing 'input' key" ); + $this->assertArrayHasKey( 'output', $pricing, "Model {$model} missing 'output' key" ); + $this->assertIsFloat( $pricing['input'], "Model {$model} 'input' should be float" ); + $this->assertIsFloat( $pricing['output'], "Model {$model} 'output' should be float" ); + $this->assertGreaterThanOrEqual( 0.0, $pricing['input'], "Model {$model} 'input' should be non-negative" ); + $this->assertGreaterThanOrEqual( 0.0, $pricing['output'], "Model {$model} 'output' should be non-negative" ); + } + } + + public function test_default_pricing_includes_key_models(): void { + $required_models = array( + 'google/gemini-2.0-flash-001', + 'anthropic/claude-3.5-sonnet', + 'openai/gpt-4o-mini', + ); + + foreach ( $required_models as $model ) { + $this->assertArrayHasKey( + $model, + Peptide_News_Cost_Tracker::DEFAULT_MODEL_PRICING, + "Default pricing should include {$model}" + ); + } + } + + // ============================================= + // Budget mode constants — sanity checks + // ============================================= + + public function test_budget_mode_constants_are_distinct(): void { + $modes = array( + Peptide_News_Cost_Tracker::BUDGET_MODE_HARD_STOP, + Peptide_News_Cost_Tracker::BUDGET_MODE_WARN_ONLY, + Peptide_News_Cost_Tracker::BUDGET_MODE_DISABLED, + ); + + $this->assertCount( 3, array_unique( $modes ), 'Budget mode constants must be distinct' ); + } +} diff --git a/tests/unit/FetcherTest.php b/tests/unit/FetcherTest.php old mode 100644 new mode 100755 diff --git a/tests/unit/LlmUsageTest.php b/tests/unit/LlmUsageTest.php new file mode 100755 index 0000000..49d64c6 --- /dev/null +++ b/tests/unit/LlmUsageTest.php @@ -0,0 +1,82 @@ +assertTrue( Peptide_News_LLM::is_valid_model( 'google/gemini-2.0-flash-001' ) ); + $this->assertTrue( Peptide_News_LLM::is_valid_model( 'anthropic/claude-3.5-sonnet' ) ); + $this->assertTrue( Peptide_News_LLM::is_valid_model( 'google/gemma-3-27b-it:free' ) ); + } + + public function test_invalid_model_format(): void { + $this->assertFalse( Peptide_News_LLM::is_valid_model( '' ) ); + $this->assertFalse( Peptide_News_LLM::is_valid_model( 'no-slash-model' ) ); + $this->assertFalse( Peptide_News_LLM::is_valid_model( 'has spaces/model' ) ); + } + + // ============================================= + // is_enabled() — budget-aware checks + // ============================================= + + public function test_is_enabled_false_when_no_api_key(): void { + global $_test_options; + $_test_options['peptide_news_llm_enabled'] = 1; + $_test_options['peptide_news_openrouter_api_key'] = ''; + + $this->assertFalse( Peptide_News_LLM::is_enabled() ); + } + + public function test_is_enabled_false_when_disabled(): void { + global $_test_options; + $_test_options['peptide_news_llm_enabled'] = 0; + $_test_options['peptide_news_openrouter_api_key'] = 'sk-test-key'; + + $this->assertFalse( Peptide_News_LLM::is_enabled() ); + } + + // ============================================= + // process_article() — budget gate behavior + // ============================================= + + public function test_process_article_returns_early_when_disabled(): void { + global $_test_options; + $_test_options['peptide_news_llm_enabled'] = 0; + $_test_options['peptide_news_openrouter_api_key'] = ''; + + $article = (object) array( + 'id' => 1, + 'title' => 'Test Article', + 'excerpt' => 'Test excerpt.', + 'content' => 'Test content.', + 'categories' => 'test', + 'tags' => '', + 'ai_summary' => '', + ); + + $result = Peptide_News_LLM::process_article( $article ); + + $this->assertFalse( $result['success'] ); + $this->assertEmpty( $result['keywords'] ); + $this->assertEmpty( $result['summary'] ); + } +} diff --git a/tests/unit/PluginBootstrapTest.php b/tests/unit/PluginBootstrapTest.php old mode 100644 new mode 100755 diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php old mode 100644 new mode 100755 diff --git a/uninstall.php b/uninstall.php old mode 100644 new mode 100755 index c8b2d98..d8e808a --- a/uninstall.php +++ b/uninstall.php @@ -1,13 +1,17 @@ prefix; -// Drop custom tables. +// Drop custom tables (order matters: child tables first due to FK constraints). $wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_clicks" ); $wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_daily_stats" ); +$wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_log" ); +$wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_llm_costs" ); $wpdb->query( "DROP TABLE IF EXISTS {$prefix}peptide_news_articles" ); // Delete all plugin options.