prospect-cache-ai 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """
2
+ Crucible: High-performance semantic cache and reverse proxy for LLM inference.
3
+
4
+ Drop-in OpenAI API compatibility with 30-60% cost savings and <15ms cache latency.
5
+ """
6
+
7
+ __version__ = "0.1.0-dev"
8
+ __author__ = "CraftedWithIntent"
9
+ __license__ = "MIT"
prospect_ai/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ """Command-line interface for Crucible."""
2
+
3
+ import typer
4
+
5
+ from prospect_ai import __version__
6
+
7
+ app = typer.Typer(help="Crucible: Semantic cache & reverse proxy for LLM inference")
8
+
9
+
10
+ @app.command()
11
+ def version() -> None:
12
+ """Show version."""
13
+ typer.echo(f"crucible {__version__}")
14
+
15
+
16
+ @app.command()
17
+ def start(
18
+ port: int = typer.Option(8080, help="Port to listen on"),
19
+ similarity_threshold: float = typer.Option(0.92, help="Semantic similarity threshold"),
20
+ backend: str = typer.Option("memory", help="Cache backend (memory, sqlite-vec, redis)"),
21
+ ) -> None:
22
+ """Start Crucible proxy server."""
23
+ typer.echo(
24
+ f"Starting Crucible on port {port} "
25
+ f"(similarity_threshold={similarity_threshold}, backend={backend})"
26
+ )
27
+ typer.echo("Phase 1: Server implementation in progress")
28
+
29
+
30
+ @app.command()
31
+ def stats() -> None:
32
+ """Show cache statistics."""
33
+ typer.echo("Cache Statistics:")
34
+ typer.echo("Phase 1: Stats aggregation in progress")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ app()
@@ -0,0 +1,35 @@
1
+ """Crucible core functional logic."""
2
+
3
+ from prospect_ai.core.normalizer import (
4
+ normalize_messages,
5
+ normalize_payload,
6
+ payload_to_cache_key,
7
+ strip_whitespace,
8
+ )
9
+ from prospect_ai.core.router import (
10
+ is_rate_limited,
11
+ select_fallback_route,
12
+ select_primary_route,
13
+ should_fallback,
14
+ )
15
+ from prospect_ai.core.similarity import (
16
+ batch_similarity_scores,
17
+ cosine_similarity,
18
+ evaluate_similarity,
19
+ find_best_match,
20
+ )
21
+
22
+ __all__ = [
23
+ "normalize_messages",
24
+ "normalize_payload",
25
+ "payload_to_cache_key",
26
+ "strip_whitespace",
27
+ "is_rate_limited",
28
+ "select_fallback_route",
29
+ "select_primary_route",
30
+ "should_fallback",
31
+ "batch_similarity_scores",
32
+ "cosine_similarity",
33
+ "evaluate_similarity",
34
+ "find_best_match",
35
+ ]
@@ -0,0 +1,80 @@
1
+ """Local embedding generation using ONNX FastEmbed."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class EmbeddingModel:
10
+ """Lightweight wrapper for ONNX FastEmbed model."""
11
+
12
+ def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
13
+ """Initialize embedder with model name.
14
+
15
+ Args:
16
+ model_name: HuggingFace model ID for FastEmbed
17
+ """
18
+ self.model_name = model_name
19
+ self.model: Any = None
20
+ self._load_model()
21
+
22
+ def _load_model(self) -> None:
23
+ """Load ONNX FastEmbed model lazily."""
24
+ try:
25
+ from fastembed import FlagModel # type: ignore[import-untyped]
26
+
27
+ self.model = FlagModel(self.model_name, cache_folder=".cache/fastembed")
28
+ logger.info(f"Loaded embedding model: {self.model_name}")
29
+ except ImportError:
30
+ logger.warning("fastembed not installed. Embeddings disabled.")
31
+ self.model = None
32
+
33
+ def embed(self, text: str) -> list[float]:
34
+ """Generate embedding for text.
35
+
36
+ Args:
37
+ text: Input text to embed
38
+
39
+ Returns:
40
+ List of floats (embedding vector), or empty list if disabled
41
+ """
42
+ if not self.model or not text:
43
+ return []
44
+
45
+ embeddings = self.model.embed([text])
46
+ return embeddings[0].tolist() if embeddings else []
47
+
48
+ def embed_batch(self, texts: list[str]) -> list[list[float]]:
49
+ """Generate embeddings for multiple texts.
50
+
51
+ Args:
52
+ texts: List of texts to embed
53
+
54
+ Returns:
55
+ List of embedding vectors
56
+ """
57
+ if not self.model or not texts:
58
+ return []
59
+
60
+ embeddings = self.model.embed(texts)
61
+ return [e.tolist() for e in embeddings]
62
+
63
+
64
+ # Global singleton
65
+ _embedder: EmbeddingModel | None = None
66
+
67
+
68
+ def get_embedder(model_name: str = "BAAI/bge-small-en-v1.5") -> EmbeddingModel:
69
+ """Get or create global embedder instance.
70
+
71
+ Args:
72
+ model_name: HuggingFace model ID
73
+
74
+ Returns:
75
+ EmbeddingModel singleton
76
+ """
77
+ global _embedder
78
+ if _embedder is None:
79
+ _embedder = EmbeddingModel(model_name)
80
+ return _embedder
@@ -0,0 +1,74 @@
1
+ """
2
+ Pure functional request normalization for caching.
3
+
4
+ Canonicalize requests so semantically identical queries hash to the same value.
5
+ """
6
+
7
+ import json
8
+ from typing import Any
9
+
10
+
11
+ def strip_whitespace(text: str) -> str:
12
+ """Remove extra whitespace and normalize line breaks."""
13
+ return " ".join(text.split())
14
+
15
+
16
+ def normalize_messages(messages: list[dict[str, Any]]) -> str:
17
+ """
18
+ Normalize message list for caching.
19
+
20
+ - Strips system prompts (configurable in Phase 2)
21
+ - Canonicalizes JSON representation
22
+ - Removes extra whitespace
23
+ """
24
+ normalized: list[dict[str, str]] = []
25
+
26
+ for msg in messages:
27
+ role = msg.get("role", "").strip().lower()
28
+ content = msg.get("content", "").strip()
29
+
30
+ # Skip system prompts (Phase 1 simplified)
31
+ if role == "system":
32
+ continue
33
+
34
+ # Normalize content
35
+ normalized_content = strip_whitespace(content)
36
+
37
+ normalized.append({
38
+ "role": role,
39
+ "content": normalized_content,
40
+ })
41
+
42
+ return json.dumps(normalized, separators=(",", ":"), sort_keys=True)
43
+
44
+
45
+ def normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
46
+ """
47
+ Normalize OpenAI-format LLM request for caching.
48
+
49
+ Returns a canonical version suitable for hashing.
50
+ """
51
+ normalized = {
52
+ "model": payload.get("model", "").strip(),
53
+ "messages": normalize_messages(payload.get("messages", [])),
54
+ "temperature": round(payload.get("temperature", 0.7), 2),
55
+ "max_tokens": payload.get("max_tokens"),
56
+ "top_p": payload.get("top_p"),
57
+ "frequency_penalty": payload.get("frequency_penalty", 0.0),
58
+ "presence_penalty": payload.get("presence_penalty", 0.0),
59
+ }
60
+
61
+ return normalized
62
+
63
+
64
+ def payload_to_cache_key(payload: dict[str, Any]) -> str:
65
+ """
66
+ Convert normalized payload to deterministic cache key (SHA-256 hash).
67
+
68
+ Used for L1 exact match.
69
+ """
70
+ import hashlib
71
+
72
+ normalized = normalize_payload(payload)
73
+ canonical_json = json.dumps(normalized, separators=(",", ":"), sort_keys=True)
74
+ return hashlib.sha256(canonical_json.encode()).hexdigest()
@@ -0,0 +1,78 @@
1
+ """
2
+ Pure functional routing logic for provider fallback and selection.
3
+
4
+ Determines which upstream provider to use based on availability and fallback rules.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from prospect_ai.domain.types import ProviderName, UpstreamRoute
10
+
11
+
12
+ def select_primary_route(payload: dict[str, Any]) -> UpstreamRoute:
13
+ """
14
+ Select primary upstream route based on request model and config.
15
+
16
+ Phase 1: Simple route (one primary provider).
17
+ Phase 2: Model affinity routing, load balancing, cost optimization.
18
+ """
19
+ model = payload.get("model", "gpt-4")
20
+
21
+ # Simplified Phase 1 routing: infer provider from model name
22
+ if "gpt" in model.lower():
23
+ provider = ProviderName.OPENAI
24
+ elif "claude" in model.lower():
25
+ provider = ProviderName.ANTHROPIC
26
+ elif "bedrock" in model.lower():
27
+ provider = ProviderName.BEDROCK
28
+ else:
29
+ provider = ProviderName.OPENAI # Default
30
+
31
+ return UpstreamRoute(
32
+ provider=provider,
33
+ model=model,
34
+ api_key_idx=0,
35
+ is_fallback=False,
36
+ )
37
+
38
+
39
+ def select_fallback_route(
40
+ primary: UpstreamRoute,
41
+ available_providers: list[ProviderName],
42
+ ) -> UpstreamRoute | None:
43
+ """
44
+ Select fallback provider if primary fails.
45
+
46
+ Prefers alternative providers in order of likelihood.
47
+ """
48
+ if not available_providers:
49
+ return None
50
+
51
+ # Prioritize fallback order: Anthropic -> Azure -> Bedrock -> OpenAI
52
+ fallback_order = [
53
+ ProviderName.ANTHROPIC,
54
+ ProviderName.AZURE,
55
+ ProviderName.BEDROCK,
56
+ ProviderName.OPENAI,
57
+ ]
58
+
59
+ for preferred_provider in fallback_order:
60
+ if preferred_provider in available_providers and preferred_provider != primary.provider:
61
+ return UpstreamRoute(
62
+ provider=preferred_provider,
63
+ model=primary.model, # Attempt same model on fallback
64
+ api_key_idx=0,
65
+ is_fallback=True,
66
+ )
67
+
68
+ return None
69
+
70
+
71
+ def is_rate_limited(status_code: int) -> bool:
72
+ """Check if HTTP status code indicates rate limiting."""
73
+ return status_code in (429, 503)
74
+
75
+
76
+ def should_fallback(status_code: int) -> bool:
77
+ """Determine if upstream error warrants immediate fallback."""
78
+ return status_code >= 500 or status_code == 429
@@ -0,0 +1,95 @@
1
+ """
2
+ Pure functional similarity scoring for semantic cache matching.
3
+
4
+ No I/O, no side effects. Pure mathematical operations.
5
+ """
6
+
7
+ import math
8
+
9
+ from prospect_ai.domain.types import SimilarityScore
10
+
11
+
12
+ def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
13
+ """
14
+ Compute cosine similarity between two vectors.
15
+
16
+ Returns: Float in range [0, 1], where 1.0 = identical, 0.0 = orthogonal
17
+ """
18
+ if len(vec_a) != len(vec_b):
19
+ return 0.0
20
+
21
+ if len(vec_a) == 0:
22
+ return 0.0
23
+
24
+ dot_product = sum(a * b for a, b in zip(vec_a, vec_b))
25
+ magnitude_a = math.sqrt(sum(a * a for a in vec_a))
26
+ magnitude_b = math.sqrt(sum(b * b for b in vec_b))
27
+
28
+ if magnitude_a == 0 or magnitude_b == 0:
29
+ return 0.0
30
+
31
+ return dot_product / (magnitude_a * magnitude_b)
32
+
33
+
34
+ def evaluate_similarity(
35
+ query_embedding: list[float],
36
+ cached_embedding: list[float],
37
+ threshold: float,
38
+ ) -> SimilarityScore:
39
+ """
40
+ Evaluate semantic similarity and return cache hit decision.
41
+
42
+ Args:
43
+ query_embedding: Embedding of incoming request
44
+ cached_embedding: Embedding of cached response
45
+ threshold: Minimum similarity required (e.g., 0.92)
46
+
47
+ Returns:
48
+ SimilarityScore with hit/miss decision
49
+ """
50
+ score = cosine_similarity(query_embedding, cached_embedding)
51
+ return SimilarityScore.evaluate(score, threshold)
52
+
53
+
54
+ def batch_similarity_scores(
55
+ query_embedding: list[float],
56
+ cached_embeddings: list[list[float]],
57
+ threshold: float,
58
+ ) -> list[SimilarityScore]:
59
+ """
60
+ Compute similarity scores for query against multiple cached embeddings.
61
+
62
+ Useful for finding the best semantic match from a cache pool.
63
+ """
64
+ return [
65
+ evaluate_similarity(query_embedding, cached_emb, threshold)
66
+ for cached_emb in cached_embeddings
67
+ ]
68
+
69
+
70
+ def find_best_match(
71
+ query_embedding: list[float],
72
+ cached_embeddings: list[list[float]],
73
+ threshold: float,
74
+ ) -> tuple[int, SimilarityScore] | None:
75
+ """
76
+ Find the cached embedding with highest similarity to query.
77
+
78
+ Returns:
79
+ Tuple of (index, SimilarityScore) if score >= threshold, else None
80
+ """
81
+ scores = batch_similarity_scores(query_embedding, cached_embeddings, threshold)
82
+
83
+ best_idx = -1
84
+ best_score = None
85
+
86
+ for idx, score in enumerate(scores):
87
+ if score.is_hit:
88
+ if best_score is None or score.score > best_score.score:
89
+ best_idx = idx
90
+ best_score = score
91
+
92
+ if best_score is not None:
93
+ return (best_idx, best_score)
94
+
95
+ return None
@@ -0,0 +1,23 @@
1
+ """Crucible domain models."""
2
+
3
+ from prospect_ai.domain.types import (
4
+ CachedResponse,
5
+ CacheEntry,
6
+ CacheHitType,
7
+ NormalizedRequest,
8
+ ProviderName,
9
+ SimilarityScore,
10
+ TokenMetrics,
11
+ UpstreamRoute,
12
+ )
13
+
14
+ __all__ = [
15
+ "CacheEntry",
16
+ "CacheHitType",
17
+ "CachedResponse",
18
+ "NormalizedRequest",
19
+ "ProviderName",
20
+ "SimilarityScore",
21
+ "TokenMetrics",
22
+ "UpstreamRoute",
23
+ ]
@@ -0,0 +1,160 @@
1
+ """
2
+ Immutable domain types for Crucible semantic cache.
3
+
4
+ Pure data structures representing cache entries, routing decisions, and metrics.
5
+ No I/O, no side effects — just value objects.
6
+ """
7
+
8
+ import hashlib
9
+ from dataclasses import dataclass, field
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+
14
+ class CacheHitType(StrEnum):
15
+ """Type of cache hit."""
16
+ EXACT = "exact" # L1: SHA-256 hash match
17
+ SEMANTIC = "semantic" # L2: Cosine similarity >= threshold
18
+ MISS = "miss" # No hit (upstream required)
19
+ FALLBACK = "fallback" # Hit from secondary provider
20
+
21
+
22
+ class ProviderName(StrEnum):
23
+ """Supported LLM providers."""
24
+ OPENAI = "openai"
25
+ ANTHROPIC = "anthropic"
26
+ AZURE = "azure"
27
+ BEDROCK = "bedrock"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CacheEntry:
32
+ """Immutable cached LLM response."""
33
+ request_hash: str # SHA-256 of normalized request
34
+ embedding_vector: list[float] # Embedding for semantic matching
35
+ response_text: str # Full cached response
36
+ finish_reason: str # stop, length, etc.
37
+ tokens_used: int # Cached token count
38
+ cached_at: float # Unix timestamp (seconds)
39
+ metadata: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
40
+
41
+ @staticmethod
42
+ def from_request(
43
+ normalized_request: str,
44
+ embedding: list[float],
45
+ response: str,
46
+ finish_reason: str,
47
+ tokens: int,
48
+ cached_at: float,
49
+ ) -> "CacheEntry":
50
+ """Construct cache entry from request and response."""
51
+ request_hash = hashlib.sha256(normalized_request.encode()).hexdigest()
52
+ return CacheEntry(
53
+ request_hash=request_hash,
54
+ embedding_vector=embedding,
55
+ response_text=response,
56
+ finish_reason=finish_reason,
57
+ tokens_used=tokens,
58
+ cached_at=cached_at,
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class SimilarityScore:
64
+ """Result of semantic similarity comparison."""
65
+ score: float # 0.0-1.0 cosine similarity
66
+ threshold: float # Required threshold for cache hit
67
+ is_hit: bool # score >= threshold
68
+ distance: float = field(init=False) # Euclidean distance (1 - score)
69
+
70
+ def __post_init__(self) -> None:
71
+ object.__setattr__(self, "distance", 1.0 - self.score)
72
+
73
+ @staticmethod
74
+ def evaluate(score: float, threshold: float) -> "SimilarityScore":
75
+ """Evaluate if score meets threshold."""
76
+ return SimilarityScore(
77
+ score=score,
78
+ threshold=threshold,
79
+ is_hit=score >= threshold,
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class UpstreamRoute:
85
+ """Routing decision for upstream provider."""
86
+ provider: ProviderName
87
+ model: str # Model name (gpt-4, claude-3, etc.)
88
+ api_key_idx: int = 0 # Index of API key to use (for multi-key fallback)
89
+ is_fallback: bool = False # True if primary provider failed
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class CachedResponse:
94
+ """Response metadata from cache retrieval."""
95
+ hit_type: CacheHitType
96
+ response_text: str
97
+ tokens_saved: int
98
+ latency_ms: float
99
+ cache_entry: CacheEntry | None = None
100
+ fallback_route: UpstreamRoute | None = None
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class TokenMetrics:
105
+ """Token spend and cache efficiency metrics."""
106
+ total_requests: int
107
+ cache_hits: int
108
+ cache_misses: int
109
+ exact_hits: int
110
+ semantic_hits: int
111
+ fallback_hits: int
112
+ tokens_requested: int
113
+ tokens_cached_saved: int
114
+ tokens_upstream: int
115
+
116
+ @property
117
+ def cache_hit_rate(self) -> float:
118
+ """Percentage of requests served from cache."""
119
+ if self.total_requests == 0:
120
+ return 0.0
121
+ return (self.cache_hits / self.total_requests) * 100
122
+
123
+ @property
124
+ def token_savings_rate(self) -> float:
125
+ """Percentage of requested tokens saved by cache."""
126
+ if self.tokens_requested == 0:
127
+ return 0.0
128
+ return (self.tokens_cached_saved / self.tokens_requested) * 100
129
+
130
+ @property
131
+ def cost_savings_usd(self) -> float:
132
+ """Estimated USD savings (assuming GPT-4 pricing: $0.03 per 1K tokens)."""
133
+ # Rough estimate; actual depends on model mix
134
+ return (self.tokens_cached_saved / 1000) * 0.03
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class NormalizedRequest:
139
+ """Canonical form of LLM request for caching."""
140
+ messages: str # JSON-serialized and normalized messages
141
+ model: str
142
+ temperature: float
143
+ max_tokens: int | None
144
+ top_p: float | None
145
+ system_prompt_removed: bool # Flag: system prompt was stripped
146
+
147
+ @staticmethod
148
+ def from_openai_payload(payload: dict[str, Any]) -> "NormalizedRequest":
149
+ """Construct from OpenAI-format request payload."""
150
+ import json
151
+
152
+ messages_text = json.dumps(payload.get("messages", []), separators=(",", ":"))
153
+ return NormalizedRequest(
154
+ messages=messages_text,
155
+ model=payload.get("model", ""),
156
+ temperature=payload.get("temperature", 0.7),
157
+ max_tokens=payload.get("max_tokens"),
158
+ top_p=payload.get("top_p"),
159
+ system_prompt_removed=False,
160
+ )
@@ -0,0 +1 @@
1
+ """Crucible infrastructure (I/O, server, storage, telemetry)."""