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.
|
||||
- **Embeddings**: `POST /v1/embeddings` inbound + provider `embed()`; embedding models
|
||||
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
|
||||
|
||||
|
||||
@@ -150,3 +150,15 @@ enabled = true
|
||||
file = "config/stats.jsonl" # empty = in-memory only
|
||||
ring_size = 500 # how many recent requests to keep in the feed
|
||||
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;
|
||||
|
||||
use crate::config::{
|
||||
AdminCfg, ApiKeyCfg, BreakerCfg, Config, CortiqCfg, LogCfg, ModelCfg, ProtocolsCfg, RouteCfg,
|
||||
RouterCfg, RoutingCfg, RoutingPolicy, StatsCfg, TelemetryCfg, TierTargets,
|
||||
AdminCfg, ApiKeyCfg, BreakerCfg, CacheCfg, Config, CortiqCfg, LogCfg, ModelCfg, ProtocolsCfg,
|
||||
RouteCfg, RouterCfg, RoutingCfg, RoutingPolicy, StatsCfg, TelemetryCfg, TierTargets,
|
||||
};
|
||||
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective};
|
||||
use crate::state::SharedState;
|
||||
@@ -477,6 +477,7 @@ async fn get_settings(State(state): State<SharedState>) -> ApiResult {
|
||||
"cortiq": c.cortiq,
|
||||
"stats": c.stats,
|
||||
"admin": c.admin,
|
||||
"cache": c.cache,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -491,6 +492,7 @@ struct SettingsBody {
|
||||
cortiq: Option<CortiqCfg>,
|
||||
stats: Option<StatsCfg>,
|
||||
admin: Option<AdminCfg>,
|
||||
cache: Option<CacheCfg>,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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)?;
|
||||
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)
|
||||
.unwrap_or(24 * 3600);
|
||||
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)]
|
||||
|
||||
+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,
|
||||
#[serde(default)]
|
||||
pub stats: StatsCfg,
|
||||
#[serde(default)]
|
||||
pub cache: CacheCfg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -320,6 +322,46 @@ fn default_stats_retention() -> String {
|
||||
"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 {
|
||||
/// Load and validate the config from a file.
|
||||
pub fn load(path: &str) -> anyhow::Result<Self> {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod admin;
|
||||
mod cache;
|
||||
mod config;
|
||||
mod error;
|
||||
mod model;
|
||||
|
||||
+124
-2
@@ -4,9 +4,9 @@
|
||||
|
||||
use crate::error::{GatewayError, Result};
|
||||
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 std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -28,6 +28,45 @@ impl Pipeline {
|
||||
}
|
||||
.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;
|
||||
|
||||
// record statistics (for both success and error)
|
||||
@@ -76,6 +115,26 @@ impl Pipeline {
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
@@ -350,6 +409,69 @@ fn now_secs() -> u64 {
|
||||
.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(
|
||||
state: &SharedState,
|
||||
account: &str,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! a restart: the admin API validates the new config, builds a new `Live`,
|
||||
//! atomically writes the TOML, and swaps the pointer.
|
||||
|
||||
use crate::cache::SemanticCache;
|
||||
use crate::config::Config;
|
||||
use crate::registry::Registry;
|
||||
use crate::router_client::RouterClient;
|
||||
@@ -42,6 +43,7 @@ impl Live {
|
||||
pub struct AppState {
|
||||
pub live: ArcSwap<Live>,
|
||||
pub stats: Arc<Stats>,
|
||||
pub cache: Arc<SemanticCache>,
|
||||
pub secrets: SecretStore,
|
||||
pub config_path: String,
|
||||
pub pipeline: crate::pipeline::Pipeline,
|
||||
@@ -59,10 +61,12 @@ impl SharedState {
|
||||
let secrets_path = sibling(&config_path, "secrets.toml");
|
||||
let secrets = SecretStore::load(&secrets_path);
|
||||
let stats = Stats::new(&cfg.stats);
|
||||
let cache = SemanticCache::new(&cfg.cache);
|
||||
let live = Live::build(cfg, &secrets)?;
|
||||
Ok(SharedState(Arc::new(AppState {
|
||||
live: ArcSwap::from_pointee(live),
|
||||
stats,
|
||||
cache,
|
||||
secrets,
|
||||
config_path,
|
||||
pipeline: crate::pipeline::Pipeline::new(),
|
||||
|
||||
Vendored
+11
@@ -254,4 +254,15 @@ export default {
|
||||
"toast.deleted": "Gelöscht",
|
||||
"toast.probing": "Prüfe…",
|
||||
"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.probing": "Probing…",
|
||||
"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.probing": "Sondeando…",
|
||||
"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.probing": "Sondage…",
|
||||
"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.probing": "Проверка…",
|
||||
"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.probing": "Sınanıyor…",
|
||||
"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.probing": "探测中…",
|
||||
"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)
|
||||
),
|
||||
|
||||
// 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
|
||||
h(
|
||||
"div",
|
||||
|
||||
Vendored
+29
@@ -42,6 +42,18 @@ export async function renderSettings() {
|
||||
const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled);
|
||||
const statsFile = h("input", { value: s.stats?.file || "" });
|
||||
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"));
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
@@ -71,11 +83,19 @@ export async function renderSettings() {
|
||||
retention: statsRet.value.trim() || "7d",
|
||||
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
|
||||
if (!patch.router.api_key_env) delete patch.router.api_key_env;
|
||||
if (!patch.router.taxonomy_id) delete patch.router.taxonomy_id;
|
||||
if (!patch.telemetry.otlp_endpoint_env) delete patch.telemetry.otlp_endpoint_env;
|
||||
if (!patch.cache.embed_model) delete patch.cache.embed_model;
|
||||
try {
|
||||
const r = await api.putSettings(patch);
|
||||
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,
|
||||
field(t("settings.stats.file"), statsFile),
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user