feat: semantic (embedding-based) response cache
- cache.rs: cosine-similarity store (threshold, TTL, ring buffer), partitioned by routing signature; tracks hit rate and model cost saved - pipeline: embed routing text, look up before the model call (route_source=cache on hit), insert successful responses; stats record cache hits - config [cache] + admin settings card; dashboard shows hit-rate and savings - embedding model resolved from the pool (or first kind="embedding")
This commit is contained in:
@@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
SSE), so Claude-native clients can target the gateway.
|
SSE), so Claude-native clients can target the gateway.
|
||||||
- **Embeddings**: `POST /v1/embeddings` inbound + provider `embed()`; embedding models
|
- **Embeddings**: `POST /v1/embeddings` inbound + provider `embed()`; embedding models
|
||||||
are now resolvable from the pool.
|
are now resolvable from the pool.
|
||||||
|
- **Semantic cache**: embedding-based cache that returns a stored answer for prompts
|
||||||
|
semantically near a previous one (cosine threshold, TTL, ring buffer), skipping the
|
||||||
|
model call. Partitioned by routing signature; hit-rate and cost saved are tracked and
|
||||||
|
shown on the dashboard; configurable from Settings (`[cache]`).
|
||||||
|
|
||||||
## [0.1.0] - 2026-06-30
|
## [0.1.0] - 2026-06-30
|
||||||
|
|
||||||
|
|||||||
@@ -150,3 +150,15 @@ enabled = true
|
|||||||
file = "config/stats.jsonl" # empty = in-memory only
|
file = "config/stats.jsonl" # empty = in-memory only
|
||||||
ring_size = 500 # how many recent requests to keep in the feed
|
ring_size = 500 # how many recent requests to keep in the feed
|
||||||
retention = "7d" # aggregate/series depth (replayed on startup)
|
retention = "7d" # aggregate/series depth (replayed on startup)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Semantic cache — returns a cached answer for prompts semantically near a
|
||||||
|
# previous one, skipping the model call. Requires an embedding model in the pool.
|
||||||
|
# Disabled by default. (Cache settings take effect on restart.)
|
||||||
|
# ============================================================================
|
||||||
|
[cache]
|
||||||
|
enabled = false
|
||||||
|
threshold = 0.92 # cosine similarity for a hit (0..1)
|
||||||
|
ttl = "1h"
|
||||||
|
max_entries = 1000
|
||||||
|
# embed_model = "local-embed" # defaults to the first kind = "embedding" model
|
||||||
|
|||||||
+12
-3
@@ -8,8 +8,8 @@
|
|||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AdminCfg, ApiKeyCfg, BreakerCfg, Config, CortiqCfg, LogCfg, ModelCfg, ProtocolsCfg, RouteCfg,
|
AdminCfg, ApiKeyCfg, BreakerCfg, CacheCfg, Config, CortiqCfg, LogCfg, ModelCfg, ProtocolsCfg,
|
||||||
RouterCfg, RoutingCfg, RoutingPolicy, StatsCfg, TelemetryCfg, TierTargets,
|
RouteCfg, RouterCfg, RoutingCfg, RoutingPolicy, StatsCfg, TelemetryCfg, TierTargets,
|
||||||
};
|
};
|
||||||
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective};
|
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective};
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
@@ -477,6 +477,7 @@ async fn get_settings(State(state): State<SharedState>) -> ApiResult {
|
|||||||
"cortiq": c.cortiq,
|
"cortiq": c.cortiq,
|
||||||
"stats": c.stats,
|
"stats": c.stats,
|
||||||
"admin": c.admin,
|
"admin": c.admin,
|
||||||
|
"cache": c.cache,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,6 +492,7 @@ struct SettingsBody {
|
|||||||
cortiq: Option<CortiqCfg>,
|
cortiq: Option<CortiqCfg>,
|
||||||
stats: Option<StatsCfg>,
|
stats: Option<StatsCfg>,
|
||||||
admin: Option<AdminCfg>,
|
admin: Option<AdminCfg>,
|
||||||
|
cache: Option<CacheCfg>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBody>) -> ApiResult {
|
async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBody>) -> ApiResult {
|
||||||
@@ -529,6 +531,11 @@ async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBo
|
|||||||
}
|
}
|
||||||
cfg.admin = v;
|
cfg.admin = v;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = b.cache {
|
||||||
|
// the cache is built at startup; persist now, apply on restart
|
||||||
|
needs_restart = true;
|
||||||
|
cfg.cache = v;
|
||||||
|
}
|
||||||
state.reload(cfg)?;
|
state.reload(cfg)?;
|
||||||
ok(json!({ "ok": true, "needs_restart": needs_restart }))
|
ok(json!({ "ok": true, "needs_restart": needs_restart }))
|
||||||
}
|
}
|
||||||
@@ -666,7 +673,9 @@ async fn get_stats(State(state): State<SharedState>, Query(q): Query<StatsQuery>
|
|||||||
.and_then(parse_duration_secs)
|
.and_then(parse_duration_secs)
|
||||||
.unwrap_or(24 * 3600);
|
.unwrap_or(24 * 3600);
|
||||||
let groupby = q.groupby.as_deref().unwrap_or("model");
|
let groupby = q.groupby.as_deref().unwrap_or("model");
|
||||||
ok(state.stats.snapshot(range_secs, groupby))
|
let mut snap = state.stats.snapshot(range_secs, groupby);
|
||||||
|
snap["cache"] = state.cache.snapshot();
|
||||||
|
ok(snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
//! Semantic (embedding-based) response cache.
|
||||||
|
//!
|
||||||
|
//! Each entry stores an L2-normalized embedding of the request plus the response.
|
||||||
|
//! A lookup returns a cached answer when the cosine similarity to a previous request
|
||||||
|
//! (with the same routing signature, within TTL) meets the configured threshold —
|
||||||
|
//! skipping the expensive model call. In-memory ring buffer; counters track hit
|
||||||
|
//! rate and the model cost saved.
|
||||||
|
|
||||||
|
use crate::config::CacheCfg;
|
||||||
|
use crate::stats::parse_duration_secs;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
sig: String,
|
||||||
|
vec: Vec<f32>, // L2-normalized
|
||||||
|
ts: u64,
|
||||||
|
model_used: String,
|
||||||
|
content: String,
|
||||||
|
prompt_tokens: u32,
|
||||||
|
completion_tokens: u32,
|
||||||
|
cost: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CacheHit {
|
||||||
|
pub model_used: String,
|
||||||
|
pub content: String,
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub similarity: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SemanticCache {
|
||||||
|
enabled: bool,
|
||||||
|
threshold: f32,
|
||||||
|
ttl_secs: u64,
|
||||||
|
max_entries: usize,
|
||||||
|
embed_model: Option<String>,
|
||||||
|
entries: Mutex<VecDeque<Entry>>,
|
||||||
|
hits: AtomicU64,
|
||||||
|
misses: AtomicU64,
|
||||||
|
saved_micro_usd: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SemanticCache {
|
||||||
|
pub fn new(cfg: &CacheCfg) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
enabled: cfg.enabled,
|
||||||
|
threshold: cfg.threshold,
|
||||||
|
ttl_secs: parse_duration_secs(&cfg.ttl).unwrap_or(3600),
|
||||||
|
max_entries: cfg.max_entries.max(1),
|
||||||
|
embed_model: cfg.embed_model.clone(),
|
||||||
|
entries: Mutex::new(VecDeque::new()),
|
||||||
|
hits: AtomicU64::new(0),
|
||||||
|
misses: AtomicU64::new(0),
|
||||||
|
saved_micro_usd: AtomicU64::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
}
|
||||||
|
pub fn embed_model(&self) -> Option<&str> {
|
||||||
|
self.embed_model.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a cached response by routing signature + query embedding.
|
||||||
|
pub fn lookup(&self, sig: &str, query: &[f32]) -> Option<CacheHit> {
|
||||||
|
if !self.enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let q = normalized(query);
|
||||||
|
let cutoff = now_secs().saturating_sub(self.ttl_secs);
|
||||||
|
let inner = self.entries.lock().unwrap();
|
||||||
|
let mut best: Option<(f32, &Entry)> = None;
|
||||||
|
for e in inner.iter() {
|
||||||
|
if e.sig != sig || e.ts < cutoff {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sim = dot(&e.vec, &q);
|
||||||
|
if sim >= self.threshold && best.map(|(b, _)| sim > b).unwrap_or(true) {
|
||||||
|
best = Some((sim, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match best {
|
||||||
|
Some((sim, e)) => {
|
||||||
|
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.saved_micro_usd
|
||||||
|
.fetch_add((e.cost * 1_000_000.0) as u64, Ordering::Relaxed);
|
||||||
|
Some(CacheHit {
|
||||||
|
model_used: e.model_used.clone(),
|
||||||
|
content: e.content.clone(),
|
||||||
|
prompt_tokens: e.prompt_tokens,
|
||||||
|
completion_tokens: e.completion_tokens,
|
||||||
|
similarity: sim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a response under its routing signature + query embedding.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn insert(
|
||||||
|
&self,
|
||||||
|
sig: String,
|
||||||
|
query: Vec<f32>,
|
||||||
|
model_used: String,
|
||||||
|
content: String,
|
||||||
|
prompt_tokens: u32,
|
||||||
|
completion_tokens: u32,
|
||||||
|
cost: f64,
|
||||||
|
) {
|
||||||
|
if !self.enabled || query.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let vec = normalized(&query);
|
||||||
|
let mut inner = self.entries.lock().unwrap();
|
||||||
|
inner.push_back(Entry {
|
||||||
|
sig,
|
||||||
|
vec,
|
||||||
|
ts: now_secs(),
|
||||||
|
model_used,
|
||||||
|
content,
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
cost,
|
||||||
|
});
|
||||||
|
while inner.len() > self.max_entries {
|
||||||
|
inner.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> serde_json::Value {
|
||||||
|
let h = self.hits.load(Ordering::Relaxed);
|
||||||
|
let m = self.misses.load(Ordering::Relaxed);
|
||||||
|
serde_json::json!({
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"hits": h,
|
||||||
|
"misses": m,
|
||||||
|
"hit_rate": if h + m > 0 { h as f64 / (h + m) as f64 } else { 0.0 },
|
||||||
|
"entries": self.entries.lock().unwrap().len(),
|
||||||
|
"saved_usd": self.saved_micro_usd.load(Ordering::Relaxed) as f64 / 1_000_000.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||||
|
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized(v: &[f32]) -> Vec<f32> {
|
||||||
|
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if n > 0.0 {
|
||||||
|
v.iter().map(|x| x / n).collect()
|
||||||
|
} else {
|
||||||
|
v.to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ pub struct Config {
|
|||||||
pub admin: AdminCfg,
|
pub admin: AdminCfg,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stats: StatsCfg,
|
pub stats: StatsCfg,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache: CacheCfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -320,6 +322,46 @@ fn default_stats_retention() -> String {
|
|||||||
"7d".into()
|
"7d".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Semantic (embedding-based) response cache — returns a cached answer for prompts
|
||||||
|
/// that are semantically near a previous one, skipping the (expensive) model call.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CacheCfg {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Cosine-similarity threshold for a hit (0..1).
|
||||||
|
#[serde(default = "default_semcache_threshold")]
|
||||||
|
pub threshold: f32,
|
||||||
|
/// Entry time-to-live, e.g. `1h`.
|
||||||
|
#[serde(default = "default_semcache_ttl")]
|
||||||
|
pub ttl: String,
|
||||||
|
/// Maximum number of cached entries (ring buffer).
|
||||||
|
#[serde(default = "default_semcache_max")]
|
||||||
|
pub max_entries: usize,
|
||||||
|
/// Embedding model id (defaults to the first `kind = "embedding"` model).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub embed_model: Option<String>,
|
||||||
|
}
|
||||||
|
impl Default for CacheCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
threshold: default_semcache_threshold(),
|
||||||
|
ttl: default_semcache_ttl(),
|
||||||
|
max_entries: default_semcache_max(),
|
||||||
|
embed_model: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn default_semcache_threshold() -> f32 {
|
||||||
|
0.92
|
||||||
|
}
|
||||||
|
fn default_semcache_ttl() -> String {
|
||||||
|
"1h".into()
|
||||||
|
}
|
||||||
|
fn default_semcache_max() -> usize {
|
||||||
|
1000
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Load and validate the config from a file.
|
/// Load and validate the config from a file.
|
||||||
pub fn load(path: &str) -> anyhow::Result<Self> {
|
pub fn load(path: &str) -> anyhow::Result<Self> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod cache;
|
||||||
mod config;
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
mod model;
|
mod model;
|
||||||
|
|||||||
+124
-2
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
use crate::error::{GatewayError, Result};
|
use crate::error::{GatewayError, Result};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
ChatRequest, ChatResponse, Message, RouteDecision, RouteInfo, RoutingDirective,
|
ChatRequest, ChatResponse, Choice, Message, RouteDecision, RouteInfo, RoutingDirective, Usage,
|
||||||
};
|
};
|
||||||
use crate::state::SharedState;
|
use crate::state::{Live, SharedState};
|
||||||
use crate::stats::RequestRecord;
|
use crate::stats::RequestRecord;
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -28,6 +28,45 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
// --- semantic cache lookup (short-circuit before the model call) ---
|
||||||
|
let cache = &state.cache;
|
||||||
|
let mut cache_key: Option<(String, Vec<f32>)> = None;
|
||||||
|
if cache.enabled() {
|
||||||
|
let live = state.live();
|
||||||
|
let sig = cache_sig(&req);
|
||||||
|
let text = self.extract_text(&req.messages, "concat_all", live.cfg.route.max_chars);
|
||||||
|
let embed_model = cache
|
||||||
|
.embed_model()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| first_embedding_model(&live));
|
||||||
|
if let Some(model_id) = embed_model {
|
||||||
|
if let Some(vec) = embed(&live, &model_id, &text).await {
|
||||||
|
if let Some(hit) = cache.lookup(&sig, &vec) {
|
||||||
|
state.stats.record(RequestRecord {
|
||||||
|
ts: now_secs(),
|
||||||
|
account,
|
||||||
|
protocol,
|
||||||
|
directive,
|
||||||
|
task_label: "cache".into(),
|
||||||
|
tier: "cache".into(),
|
||||||
|
score: 0.0,
|
||||||
|
model_id: hit.model_used.clone(),
|
||||||
|
route_source: "cache".into(),
|
||||||
|
prompt_tokens: hit.prompt_tokens,
|
||||||
|
completion_tokens: hit.completion_tokens,
|
||||||
|
cost_usd: 0.0,
|
||||||
|
latency_ms: started.elapsed().as_millis() as u64,
|
||||||
|
outcome: "ok".into(),
|
||||||
|
failover: false,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
return Ok(cached_response(&hit));
|
||||||
|
}
|
||||||
|
cache_key = Some((sig, vec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result = self.run_inner(req, state).await;
|
let result = self.run_inner(req, state).await;
|
||||||
|
|
||||||
// record statistics (for both success and error)
|
// record statistics (for both success and error)
|
||||||
@@ -76,6 +115,26 @@ impl Pipeline {
|
|||||||
};
|
};
|
||||||
state.stats.record(rec);
|
state.stats.record(rec);
|
||||||
|
|
||||||
|
// --- store a successful response in the semantic cache ---
|
||||||
|
if let (Ok(resp), Some((sig, vec))) = (&result, cache_key) {
|
||||||
|
let content = resp
|
||||||
|
.choices
|
||||||
|
.first()
|
||||||
|
.map(|c| c.message.content.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !content.is_empty() {
|
||||||
|
state.cache.insert(
|
||||||
|
sig,
|
||||||
|
vec,
|
||||||
|
resp.cortiq.selected_model.clone(),
|
||||||
|
content,
|
||||||
|
resp.usage.prompt_tokens,
|
||||||
|
resp.usage.completion_tokens,
|
||||||
|
resp.cortiq.cost_usd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +409,69 @@ fn now_secs() -> u64 {
|
|||||||
.as_secs()
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routing signature used to partition cache entries (so a pinned model's cached
|
||||||
|
/// answer is never returned for a different model/profile).
|
||||||
|
fn cache_sig(req: &ChatRequest) -> String {
|
||||||
|
match &req.routing {
|
||||||
|
RoutingDirective::Auto { profile } => {
|
||||||
|
format!("auto:{}", profile.as_deref().unwrap_or(""))
|
||||||
|
}
|
||||||
|
RoutingDirective::Pinned { model_id } => format!("pinned:{model_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_embedding_model(live: &Live) -> Option<String> {
|
||||||
|
live.cfg
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.kind == "embedding")
|
||||||
|
.map(|m| m.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed `text` with the given model; returns the embedding vector (or None on error).
|
||||||
|
async fn embed(live: &Live, model_id: &str, text: &str) -> Option<Vec<f32>> {
|
||||||
|
let provider = live.registry.get(model_id)?;
|
||||||
|
let body = provider.embed(serde_json::json!(text)).await.ok()?;
|
||||||
|
let arr = body["data"][0]["embedding"].as_array()?;
|
||||||
|
Some(
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|x| x.as_f64())
|
||||||
|
.map(|x| x as f32)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_response(hit: &crate::cache::CacheHit) -> ChatResponse {
|
||||||
|
ChatResponse {
|
||||||
|
id: "cache".to_string(),
|
||||||
|
model_used: hit.model_used.clone(),
|
||||||
|
choices: vec![Choice {
|
||||||
|
index: 0,
|
||||||
|
message: Message {
|
||||||
|
role: "assistant".into(),
|
||||||
|
content: hit.content.clone(),
|
||||||
|
tool_calls: vec![],
|
||||||
|
},
|
||||||
|
finish_reason: "stop".into(),
|
||||||
|
}],
|
||||||
|
usage: Usage {
|
||||||
|
prompt_tokens: hit.prompt_tokens,
|
||||||
|
completion_tokens: hit.completion_tokens,
|
||||||
|
total_tokens: hit.prompt_tokens + hit.completion_tokens,
|
||||||
|
},
|
||||||
|
cortiq: RouteInfo {
|
||||||
|
task_label: "cache".into(),
|
||||||
|
complexity_score: 0.0,
|
||||||
|
complexity_tier: "cache".into(),
|
||||||
|
selected_model: hit.model_used.clone(),
|
||||||
|
route_source: "cache".into(),
|
||||||
|
router_request_id: None,
|
||||||
|
cost_usd: 0.0,
|
||||||
|
failover: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn record_error(
|
fn record_error(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
account: &str,
|
account: &str,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! a restart: the admin API validates the new config, builds a new `Live`,
|
//! a restart: the admin API validates the new config, builds a new `Live`,
|
||||||
//! atomically writes the TOML, and swaps the pointer.
|
//! atomically writes the TOML, and swaps the pointer.
|
||||||
|
|
||||||
|
use crate::cache::SemanticCache;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::registry::Registry;
|
use crate::registry::Registry;
|
||||||
use crate::router_client::RouterClient;
|
use crate::router_client::RouterClient;
|
||||||
@@ -42,6 +43,7 @@ impl Live {
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub live: ArcSwap<Live>,
|
pub live: ArcSwap<Live>,
|
||||||
pub stats: Arc<Stats>,
|
pub stats: Arc<Stats>,
|
||||||
|
pub cache: Arc<SemanticCache>,
|
||||||
pub secrets: SecretStore,
|
pub secrets: SecretStore,
|
||||||
pub config_path: String,
|
pub config_path: String,
|
||||||
pub pipeline: crate::pipeline::Pipeline,
|
pub pipeline: crate::pipeline::Pipeline,
|
||||||
@@ -59,10 +61,12 @@ impl SharedState {
|
|||||||
let secrets_path = sibling(&config_path, "secrets.toml");
|
let secrets_path = sibling(&config_path, "secrets.toml");
|
||||||
let secrets = SecretStore::load(&secrets_path);
|
let secrets = SecretStore::load(&secrets_path);
|
||||||
let stats = Stats::new(&cfg.stats);
|
let stats = Stats::new(&cfg.stats);
|
||||||
|
let cache = SemanticCache::new(&cfg.cache);
|
||||||
let live = Live::build(cfg, &secrets)?;
|
let live = Live::build(cfg, &secrets)?;
|
||||||
Ok(SharedState(Arc::new(AppState {
|
Ok(SharedState(Arc::new(AppState {
|
||||||
live: ArcSwap::from_pointee(live),
|
live: ArcSwap::from_pointee(live),
|
||||||
stats,
|
stats,
|
||||||
|
cache,
|
||||||
secrets,
|
secrets,
|
||||||
config_path,
|
config_path,
|
||||||
pipeline: crate::pipeline::Pipeline::new(),
|
pipeline: crate::pipeline::Pipeline::new(),
|
||||||
|
|||||||
Vendored
+11
@@ -254,4 +254,15 @@ export default {
|
|||||||
"toast.deleted": "Gelöscht",
|
"toast.deleted": "Gelöscht",
|
||||||
"toast.probing": "Prüfe…",
|
"toast.probing": "Prüfe…",
|
||||||
"toast.imported": "Konfiguration importiert",
|
"toast.imported": "Konfiguration importiert",
|
||||||
|
"dash.cache": "Semantischer Cache",
|
||||||
|
"dash.cache.hitRate": "Trefferquote",
|
||||||
|
"dash.cache.hits": "Treffer",
|
||||||
|
"dash.cache.saved": "Gespart",
|
||||||
|
"dash.cache.disabled": "Semantischer Cache ist deaktiviert — in den Einstellungen aktivieren.",
|
||||||
|
"settings.cache": "Semantischer Cache",
|
||||||
|
"settings.cache.enabled": "Semantischen Cache aktivieren",
|
||||||
|
"settings.cache.threshold": "Ähnlichkeitsschwelle",
|
||||||
|
"settings.cache.ttl": "Eintrags-TTL",
|
||||||
|
"settings.cache.embed": "Embedding-Modell",
|
||||||
|
"settings.cache.note": "Cache-Einstellungen werden nach einem Neustart wirksam.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+11
@@ -255,4 +255,15 @@ export default {
|
|||||||
"toast.deleted": "Deleted",
|
"toast.deleted": "Deleted",
|
||||||
"toast.probing": "Probing…",
|
"toast.probing": "Probing…",
|
||||||
"toast.imported": "Config imported",
|
"toast.imported": "Config imported",
|
||||||
|
"dash.cache": "Semantic cache",
|
||||||
|
"dash.cache.hitRate": "Hit rate",
|
||||||
|
"dash.cache.hits": "Hits",
|
||||||
|
"dash.cache.saved": "Saved",
|
||||||
|
"dash.cache.disabled": "Semantic cache is disabled — enable it in Settings.",
|
||||||
|
"settings.cache": "Semantic cache",
|
||||||
|
"settings.cache.enabled": "Enable semantic cache",
|
||||||
|
"settings.cache.threshold": "Similarity threshold",
|
||||||
|
"settings.cache.ttl": "Entry TTL",
|
||||||
|
"settings.cache.embed": "Embedding model",
|
||||||
|
"settings.cache.note": "Cache settings take effect after a restart.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+11
@@ -254,4 +254,15 @@ export default {
|
|||||||
"toast.deleted": "Eliminado",
|
"toast.deleted": "Eliminado",
|
||||||
"toast.probing": "Sondeando…",
|
"toast.probing": "Sondeando…",
|
||||||
"toast.imported": "Configuración importada",
|
"toast.imported": "Configuración importada",
|
||||||
|
"dash.cache": "Caché semántico",
|
||||||
|
"dash.cache.hitRate": "Aciertos",
|
||||||
|
"dash.cache.hits": "Aciertos",
|
||||||
|
"dash.cache.saved": "Ahorrado",
|
||||||
|
"dash.cache.disabled": "El caché semántico está desactivado — actívalo en Ajustes.",
|
||||||
|
"settings.cache": "Caché semántico",
|
||||||
|
"settings.cache.enabled": "Activar caché semántico",
|
||||||
|
"settings.cache.threshold": "Umbral de similitud",
|
||||||
|
"settings.cache.ttl": "TTL de entrada",
|
||||||
|
"settings.cache.embed": "Modelo de embeddings",
|
||||||
|
"settings.cache.note": "Los ajustes del caché se aplican tras reiniciar.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+11
@@ -254,4 +254,15 @@ export default {
|
|||||||
"toast.deleted": "Supprimé",
|
"toast.deleted": "Supprimé",
|
||||||
"toast.probing": "Sondage…",
|
"toast.probing": "Sondage…",
|
||||||
"toast.imported": "Config importée",
|
"toast.imported": "Config importée",
|
||||||
|
"dash.cache": "Cache sémantique",
|
||||||
|
"dash.cache.hitRate": "Taux de succès",
|
||||||
|
"dash.cache.hits": "Succès",
|
||||||
|
"dash.cache.saved": "Économisé",
|
||||||
|
"dash.cache.disabled": "Le cache sémantique est désactivé — activez-le dans les Paramètres.",
|
||||||
|
"settings.cache": "Cache sémantique",
|
||||||
|
"settings.cache.enabled": "Activer le cache sémantique",
|
||||||
|
"settings.cache.threshold": "Seuil de similarité",
|
||||||
|
"settings.cache.ttl": "TTL d'entrée",
|
||||||
|
"settings.cache.embed": "Modèle d'embeddings",
|
||||||
|
"settings.cache.note": "Les paramètres du cache prennent effet après un redémarrage.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+11
@@ -254,4 +254,15 @@ export default {
|
|||||||
"toast.deleted": "Удалено",
|
"toast.deleted": "Удалено",
|
||||||
"toast.probing": "Проверка…",
|
"toast.probing": "Проверка…",
|
||||||
"toast.imported": "Конфигурация импортирована",
|
"toast.imported": "Конфигурация импортирована",
|
||||||
|
"dash.cache": "Семантический кэш",
|
||||||
|
"dash.cache.hitRate": "Попаданий",
|
||||||
|
"dash.cache.hits": "Попадания",
|
||||||
|
"dash.cache.saved": "Сэкономлено",
|
||||||
|
"dash.cache.disabled": "Семантический кэш выключен — включите в Настройках.",
|
||||||
|
"settings.cache": "Семантический кэш",
|
||||||
|
"settings.cache.enabled": "Включить семантический кэш",
|
||||||
|
"settings.cache.threshold": "Порог сходства",
|
||||||
|
"settings.cache.ttl": "TTL записи",
|
||||||
|
"settings.cache.embed": "Модель эмбеддингов",
|
||||||
|
"settings.cache.note": "Настройки кэша применяются после перезапуска.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+11
@@ -254,4 +254,15 @@ export default {
|
|||||||
"toast.deleted": "Silindi",
|
"toast.deleted": "Silindi",
|
||||||
"toast.probing": "Sınanıyor…",
|
"toast.probing": "Sınanıyor…",
|
||||||
"toast.imported": "Yapılandırma içe aktarıldı",
|
"toast.imported": "Yapılandırma içe aktarıldı",
|
||||||
|
"dash.cache": "Anlamsal önbellek",
|
||||||
|
"dash.cache.hitRate": "İsabet oranı",
|
||||||
|
"dash.cache.hits": "İsabet",
|
||||||
|
"dash.cache.saved": "Tasarruf",
|
||||||
|
"dash.cache.disabled": "Anlamsal önbellek kapalı — Ayarlar'dan etkinleştirin.",
|
||||||
|
"settings.cache": "Anlamsal önbellek",
|
||||||
|
"settings.cache.enabled": "Anlamsal önbelleği etkinleştir",
|
||||||
|
"settings.cache.threshold": "Benzerlik eşiği",
|
||||||
|
"settings.cache.ttl": "Giriş TTL",
|
||||||
|
"settings.cache.embed": "Gömme modeli",
|
||||||
|
"settings.cache.note": "Anlamsal önbellek ayarları yeniden başlatmadan sonra geçerli olur.",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+11
@@ -254,4 +254,15 @@ export default {
|
|||||||
"toast.deleted": "已删除",
|
"toast.deleted": "已删除",
|
||||||
"toast.probing": "探测中…",
|
"toast.probing": "探测中…",
|
||||||
"toast.imported": "配置已导入",
|
"toast.imported": "配置已导入",
|
||||||
|
"dash.cache": "语义缓存",
|
||||||
|
"dash.cache.hitRate": "命中率",
|
||||||
|
"dash.cache.hits": "命中",
|
||||||
|
"dash.cache.saved": "已节省",
|
||||||
|
"dash.cache.disabled": "语义缓存已禁用 — 在设置中启用。",
|
||||||
|
"settings.cache": "语义缓存",
|
||||||
|
"settings.cache.enabled": "启用语义缓存",
|
||||||
|
"settings.cache.threshold": "相似度阈值",
|
||||||
|
"settings.cache.ttl": "条目 TTL",
|
||||||
|
"settings.cache.embed": "嵌入模型",
|
||||||
|
"settings.cache.note": "缓存设置将在重启后生效。",
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+16
@@ -108,6 +108,22 @@ export async function renderDashboard() {
|
|||||||
barList(breakdown, num)
|
barList(breakdown, num)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// semantic cache
|
||||||
|
stats.cache && stats.cache.enabled
|
||||||
|
? h(
|
||||||
|
"div",
|
||||||
|
{ class: "card" },
|
||||||
|
h("div", { class: "card-head" }, h("h3", {}, t("dash.cache"))),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "kpis" },
|
||||||
|
kpi(pct(stats.cache.hit_rate), t("dash.cache.hitRate")),
|
||||||
|
kpi(num(stats.cache.hits), t("dash.cache.hits")),
|
||||||
|
kpi(money(stats.cache.saved_usd), t("dash.cache.saved"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
|
||||||
// recent
|
// recent
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
|
|||||||
Vendored
+29
@@ -42,6 +42,18 @@ export async function renderSettings() {
|
|||||||
const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled);
|
const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled);
|
||||||
const statsFile = h("input", { value: s.stats?.file || "" });
|
const statsFile = h("input", { value: s.stats?.file || "" });
|
||||||
const statsRet = h("input", { value: s.stats?.retention || "7d" });
|
const statsRet = h("input", { value: s.stats?.retention || "7d" });
|
||||||
|
// cache
|
||||||
|
const modelsData = await api.listModels();
|
||||||
|
const embedModels = (modelsData.models || []).filter((m) => m.kind === "embedding").map((m) => m.id);
|
||||||
|
const cacheEnabled = check(t("settings.cache.enabled"), s.cache?.enabled);
|
||||||
|
const cacheThresh = h("input", { type: "number", step: "0.01", value: s.cache?.threshold ?? 0.92 });
|
||||||
|
const cacheTtlIn = h("input", { value: s.cache?.ttl || "1h" });
|
||||||
|
const cacheEmbedSel = h(
|
||||||
|
"select",
|
||||||
|
{},
|
||||||
|
h("option", { value: "" }, "auto"),
|
||||||
|
...embedModels.map((id) => h("option", { value: id, selected: id === s.cache?.embed_model }, id))
|
||||||
|
);
|
||||||
|
|
||||||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||||||
saveBtn.addEventListener("click", async () => {
|
saveBtn.addEventListener("click", async () => {
|
||||||
@@ -71,11 +83,19 @@ export async function renderSettings() {
|
|||||||
retention: statsRet.value.trim() || "7d",
|
retention: statsRet.value.trim() || "7d",
|
||||||
ring_size: s.stats?.ring_size ?? 500,
|
ring_size: s.stats?.ring_size ?? 500,
|
||||||
},
|
},
|
||||||
|
cache: {
|
||||||
|
enabled: cacheEnabled.cb.checked,
|
||||||
|
threshold: parseFloat(cacheThresh.value) || 0.92,
|
||||||
|
ttl: cacheTtlIn.value.trim() || "1h",
|
||||||
|
max_entries: s.cache?.max_entries ?? 1000,
|
||||||
|
embed_model: cacheEmbedSel.value || null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
// serde: drop null optionals
|
// serde: drop null optionals
|
||||||
if (!patch.router.api_key_env) delete patch.router.api_key_env;
|
if (!patch.router.api_key_env) delete patch.router.api_key_env;
|
||||||
if (!patch.router.taxonomy_id) delete patch.router.taxonomy_id;
|
if (!patch.router.taxonomy_id) delete patch.router.taxonomy_id;
|
||||||
if (!patch.telemetry.otlp_endpoint_env) delete patch.telemetry.otlp_endpoint_env;
|
if (!patch.telemetry.otlp_endpoint_env) delete patch.telemetry.otlp_endpoint_env;
|
||||||
|
if (!patch.cache.embed_model) delete patch.cache.embed_model;
|
||||||
try {
|
try {
|
||||||
const r = await api.putSettings(patch);
|
const r = await api.putSettings(patch);
|
||||||
toast(r.needs_restart ? t("settings.needsRestart") : t("toast.saved"), r.needs_restart ? "warn" : "good");
|
toast(r.needs_restart ? t("settings.needsRestart") : t("toast.saved"), r.needs_restart ? "warn" : "good");
|
||||||
@@ -170,6 +190,15 @@ export async function renderSettings() {
|
|||||||
statsEnabled.node,
|
statsEnabled.node,
|
||||||
field(t("settings.stats.file"), statsFile),
|
field(t("settings.stats.file"), statsFile),
|
||||||
field(t("settings.stats.retention"), statsRet)
|
field(t("settings.stats.retention"), statsRet)
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "card" },
|
||||||
|
h("div", { class: "card-head" }, h("h3", {}, t("settings.cache"))),
|
||||||
|
cacheEnabled.node,
|
||||||
|
h("div", { class: "row" }, field(t("settings.cache.threshold"), cacheThresh), field(t("settings.cache.ttl"), cacheTtlIn)),
|
||||||
|
field(t("settings.cache.embed"), cacheEmbedSel),
|
||||||
|
h("div", { class: "hint" }, t("settings.cache.note"))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
|
|||||||
Reference in New Issue
Block a user