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.
- prospect_ai/__init__.py +9 -0
- prospect_ai/cli.py +38 -0
- prospect_ai/core/__init__.py +35 -0
- prospect_ai/core/embedder.py +80 -0
- prospect_ai/core/normalizer.py +74 -0
- prospect_ai/core/router.py +78 -0
- prospect_ai/core/similarity.py +95 -0
- prospect_ai/domain/__init__.py +23 -0
- prospect_ai/domain/types.py +160 -0
- prospect_ai/infrastructure/__init__.py +1 -0
- prospect_ai/infrastructure/proxy_gateway.py +185 -0
- prospect_ai/infrastructure/proxy_gateway.py.bak +176 -0
- prospect_ai/infrastructure/server.py +231 -0
- prospect_ai/infrastructure/server.py.bak +145 -0
- prospect_ai/infrastructure/storage/base.py +48 -0
- prospect_ai/infrastructure/storage/memory.py +71 -0
- prospect_ai/py.typed +0 -0
- prospect_cache_ai-1.0.0.dist-info/METADATA +687 -0
- prospect_cache_ai-1.0.0.dist-info/RECORD +21 -0
- prospect_cache_ai-1.0.0.dist-info/WHEEL +4 -0
- prospect_cache_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
prospect_ai/__init__.py
ADDED
|
@@ -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)."""
|