Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# recost — Python Middleware

Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension.
Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension.

## Tech Stack

Expand All@@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again
```
recost/
__init__.py # Public API surface (re-exports only)
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode
_provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority
_init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle
_types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode
_provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority
_interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request
_aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation
_transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread)
frameworks/
__init__.py
fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # EcoAPI — Flask extension with init_app() pattern
fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette
flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias)
tests/
conftest.py # Fixtures — cleanup interceptor after each test
test_scaffold.py # Smoke tests for public API exports
test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion)
test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority
test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority
test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling
test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention
test_transport.py # Mode detection, HTTP server mocking, retry logic
Expand DownExpand Up@@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode)

## Provider Registry

21+ built-in rules covering:
34 built-in rules across 14 providers:
- **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules)
- **Payments**: Stripe (5 rules)
- **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules)
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...")

```python
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
ReCost(app, api_key="...", project_id="...")
RecostExtension(app, api_key="...", project_id="...")
```

Or using the `init_app` pattern:

```python
recost = ReCost()
recost.init_app(app, api_key="...", project_id="...")
ext = RecostExtension()
ext.init_app(app, api_key="...", project_id="...")
```

> **Note:** the old class name `ReCost` is still importable as a deprecated
> alias and will continue to work for one release with a `DeprecationWarning`.
> Migrate to `RecostExtension`.

## Configuration

All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance.
Expand All@@ -103,16 +107,19 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. |
| `project_id` | `str` | — | Recost project ID. Required in cloud mode. |
| `environment` | `str` | `"development"` | Environment tag attached to all telemetry. |
| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. |
| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. |
| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. |
| `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). |
| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. |
| `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. |
| `debug` | `bool` | `False` | Log telemetry activity to stderr. |
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `on_error` | `Callable` | — | Called on internal SDK errors. |
| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. |
| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. |

### Custom providers

Expand Down
4 changes: 2 additions & 2 deletions recost/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
"""
recost — Python SDK for ReCost.
recost — Python SDK for Recost.

Tracks outbound HTTP API calls and reports cost, latency, and usage patterns
to the ReCost dashboard or your local VS Code extension.
to the Recost dashboard or your local VS Code extension.
"""

from ._types import (
Expand Down
2 changes: 1 addition & 1 deletion recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -80,7 +80,7 @@ def dispose(self) -> None:

def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Initialize the ReCost SDK.
Initialize the Recost SDK.

- Patches urllib3, httpx, and aiohttp.
- Starts a flush interval that sends aggregated telemetry.
Expand Down
2 changes: 1 addition & 1 deletion recost/_transport.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
"""
Transport — delivers WindowSummary payloads to either:
- api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or
- the ReCost VS Code extension (local mode) via WebSocket on localhost.
- the Recost VS Code extension (local mode) via WebSocket on localhost.

Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation
(the interceptor patches urllib3, not urllib.request).
Expand Down
2 changes: 1 addition & 1 deletion recost/frameworks/fastapi.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@
from starlette.responses import Response

class RecostMiddleware(BaseHTTPMiddleware):
"""ASGI middleware that initializes ReCost telemetry."""
"""ASGI middleware that initializes Recost telemetry."""

def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
super().__init__(app)
Expand Down
55 changes: 47 additions & 8 deletions recost/frameworks/flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,14 +3,15 @@

Usage:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, api_key="...", project_id="...")
ext = RecostExtension(app, api_key="...", project_id="...")
"""

from __future__ import annotations

import warnings
from typing import Any, Optional

from .._init import init
Expand All@@ -19,25 +20,63 @@
try:
from flask import Flask

class ReCost:
"""Flask extension that initializes ReCost telemetry."""
class RecostExtension:
"""Flask extension that initializes Recost telemetry."""

def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def __init__(
self,
app: Optional[Flask] = None,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
self._handle = None
if app is not None:
self.init_app(app, config, **kwargs)

def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None:
def init_app(
self,
app: Flask,
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)

class ReCost(RecostExtension):
"""Deprecated alias for :class:`RecostExtension`.

Will be removed in a future release. Switch to
``from recost.frameworks.flask import RecostExtension``.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated and will be "
"removed in a future release; use RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

except ImportError:
class ReCost: # type: ignore[no-redef]
class RecostExtension: # type: ignore[no-redef]
"""Stub — install 'flask' to use: pip install recost[flask]"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"flask is required for ReCost extension. "
"flask is required for the Recost extension. "
"Install it with: pip install recost[flask]"
)

class ReCost(RecostExtension): # type: ignore[no-redef]
"""Stub deprecated alias — install 'flask' to use."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"recost.frameworks.flask.ReCost is deprecated; use "
"RecostExtension instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
44 changes: 36 additions & 8 deletions tests/test_flask.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,43 +2,71 @@
Tests for recost/frameworks/flask.py
"""

import pytest
import warnings

from recost._interceptor import is_installed, uninstall
from recost._types import RecostConfig


class TestFlaskExtension:
class TestRecostExtension:
def test_extension_initializes_interceptor(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
eco = ReCost(app, config=RecostConfig(enabled=True))
RecostExtension(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_init_app_pattern(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost
from recost.frameworks.flask import RecostExtension

eco = ReCost()
ext = RecostExtension()
app = Flask(__name__)
eco.init_app(app, config=RecostConfig(enabled=True))
ext.init_app(app, config=RecostConfig(enabled=True))
assert is_installed()
finally:
uninstall()

def test_extension_accepts_kwargs(self):
try:
from flask import Flask
from recost.frameworks.flask import RecostExtension

app = Flask(__name__)
RecostExtension(app, enabled=True, debug=False)
assert is_installed()
finally:
uninstall()


class TestReCostDeprecationAlias:
"""The old `ReCost` name must keep working for one release but emit a
DeprecationWarning so users migrate to `RecostExtension`."""

def test_old_name_still_constructs(self):
try:
from flask import Flask
from recost.frameworks.flask import ReCost

app = Flask(__name__)
eco = ReCost(app, enabled=True, debug=False)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
ReCost(app, config=RecostConfig(enabled=True))
assert is_installed()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert deprecations, "expected a DeprecationWarning on ReCost(...)"
assert "RecostExtension" in str(deprecations[0].message)
finally:
uninstall()

def test_old_name_is_subclass_or_alias_of_new(self):
"""Importing the old name must yield the same class as the new name
so isinstance checks and existing type annotations keep working."""
from recost.frameworks.flask import ReCost, RecostExtension

assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension)
17 changes: 16 additions & 1 deletion tests/test_provider_registry.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@


class TestBuiltinProviders:
"""Tests for all 21 built-in provider rules."""
"""Tests for all 34 built-in provider rules (14 unique providers)."""

def setup_method(self):
self.registry = ProviderRegistry()
Expand DownExpand Up@@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self):

# Need to import ProviderDef for custom provider tests
from recost import ProviderDef # noqa: E402


# ---------------------------------------------------------------------------
# Built-in provider count — pins the published claim
# ---------------------------------------------------------------------------

def test_builtin_providers_count_is_pinned():
"""If you add or remove a built-in provider rule, update this assertion
AND update the provider-count claims in README.md and CLAUDE.md."""
from recost._provider_registry import BUILTIN_PROVIDERS

assert len(BUILTIN_PROVIDERS) == 34, (
f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; "
f"docs claim 34. Update docs and this assertion together."
)