use super::*;
use crate::errors::AppError;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbeddingErrorKind {
OAuth,
Quota,
SlotExhausted,
BackendMismatch,
ZeroDimension,
Unknown,
}
impl EmbeddingErrorKind {
pub fn classify(msg: &str) -> Self {
let m = msg.to_lowercase();
if m.contains("oauth") {
Self::OAuth
} else if m.contains("quota") {
Self::Quota
} else if m.contains("slot exhausted") {
Self::SlotExhausted
} else if m.contains("backend mismatch") {
Self::BackendMismatch
} else if m.contains("dim") && m.contains("zero") {
Self::ZeroDimension
} else {
Self::Unknown
}
}
pub fn code(&self) -> &'static str {
match self {
Self::OAuth => "oauth",
Self::Quota => "quota",
Self::SlotExhausted => "slot-exhausted",
Self::BackendMismatch => "backend-mismatch",
Self::ZeroDimension => "zero-dimension",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for EmbeddingErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum FallbackReason {
EmbeddingFailed(String),
SlotExhausted,
OAuthQuota {
backend: &'static str,
},
BackendMismatch {
requested: &'static str,
resolved: &'static str,
},
DimZero,
Cancelled,
Timeout {
operation: String,
duration_secs: u64,
},
}
impl FallbackReason {
pub fn reason_code(&self) -> &'static str {
match self {
Self::EmbeddingFailed(_) => "embedding_failed",
Self::SlotExhausted => "slot_exhausted",
Self::OAuthQuota { .. } => "oauth_quota",
Self::BackendMismatch { .. } => "backend_mismatch",
Self::DimZero => "dim_zero",
Self::Cancelled => "cancelled",
Self::Timeout { .. } => "timeout",
}
}
}
impl std::fmt::Display for FallbackReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmbeddingFailed(msg) => write!(f, "embedding failed: {msg}"),
Self::SlotExhausted => write!(
f,
"slot exhausted: failed to acquire LLM slot after backoff window (max=8 concurrent, total backoff=750ms)"
),
Self::OAuthQuota { backend } => {
write!(f, "OAuth usage quota exhausted on backend '{backend}'")
}
Self::BackendMismatch {
requested,
resolved,
} => {
write!(
f,
"backend mismatch: user requested '{requested}' but '{resolved}' was invoked"
)
}
Self::DimZero => write!(f, "embedding returned zero-dimensional vector"),
Self::Cancelled => write!(f, "embedding cancelled by external signal"),
Self::Timeout {
operation,
duration_secs,
} => {
write!(
f,
"embedding timed out after {duration_secs}s during {operation}"
)
}
}
}
}
impl std::error::Error for FallbackReason {}
pub fn try_embed_query_with_fallback(
models_dir: &Path,
query: &str,
) -> Result<(Vec<f32>, LlmBackendKind), FallbackReason> {
match embed_query_local(models_dir, query) {
Ok(v) => Ok((v, LlmBackendKind::None)),
Err(e) => Err(classify_embedding_error(e)),
}
}
pub fn try_embed_query_with_deterministic_fallback(
models_dir: &Path,
query: &str,
choice: Option<crate::cli::LlmBackendChoice>,
) -> Result<(Vec<f32>, LlmBackendKind), FallbackReason> {
match try_embed_query_with_choice(models_dir, query, choice) {
Ok(t) => Ok(t),
Err(reason @ FallbackReason::OAuthQuota { backend }) => {
let alt = match backend {
"codex" => Some(crate::cli::LlmBackendChoice::Claude),
"claude" => Some(crate::cli::LlmBackendChoice::Codex),
"opencode" => Some(crate::cli::LlmBackendChoice::Codex),
"openrouter" => Some(crate::cli::LlmBackendChoice::Codex),
_ => None,
};
if let Some(alt_choice) = alt {
try_embed_query_with_choice(models_dir, query, Some(alt_choice))
} else {
Err(reason)
}
}
Err(reason @ FallbackReason::SlotExhausted) => {
std::thread::sleep(std::time::Duration::from_millis(750));
try_embed_query_with_choice(models_dir, query, choice).or(Err(reason))
}
Err(other) => Err(other),
}
}
pub fn classify_embedding_error(err: AppError) -> FallbackReason {
match err {
AppError::Timeout {
operation,
duration_secs,
} => FallbackReason::Timeout {
operation,
duration_secs,
},
AppError::Embedding(msg) => match EmbeddingErrorKind::classify(&msg) {
EmbeddingErrorKind::SlotExhausted => FallbackReason::SlotExhausted,
EmbeddingErrorKind::OAuth => {
let backend = if msg.contains("codex") {
"codex"
} else if msg.contains("claude") || msg.contains("anthropic-ratelimit") {
"claude"
} else if msg.contains("opencode") {
"opencode"
} else {
"unknown"
};
FallbackReason::OAuthQuota { backend }
}
EmbeddingErrorKind::Quota => {
let backend = if msg.contains("codex") {
"codex"
} else if msg.contains("claude") || msg.contains("anthropic-ratelimit") {
"claude"
} else if msg.contains("opencode") {
"opencode"
} else {
"unknown"
};
FallbackReason::OAuthQuota { backend }
}
EmbeddingErrorKind::BackendMismatch => {
let (requested, resolved) =
if msg.contains("requested claude") && msg.contains("but codex") {
("claude", "codex")
} else if msg.contains("requested codex") && msg.contains("but claude") {
("codex", "claude")
} else if msg.contains("requested claude") {
("claude", "unknown")
} else if msg.contains("requested codex") {
("codex", "unknown")
} else {
("unknown", "unknown")
};
FallbackReason::BackendMismatch {
requested,
resolved,
}
}
EmbeddingErrorKind::ZeroDimension => FallbackReason::DimZero,
EmbeddingErrorKind::Unknown => {
if msg.contains("cancelled") {
FallbackReason::Cancelled
} else {
FallbackReason::EmbeddingFailed(msg)
}
}
},
e => FallbackReason::EmbeddingFailed(e.to_string()),
}
}
pub fn embed_with_fallback(
models_dir: &Path,
text: &str,
chain: &[LlmBackendKind],
skip_on_failure: bool,
) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
use crate::llm::exit_code_hints::LlmBackendError;
let effective: Vec<LlmBackendKind> = if chain.is_empty() {
vec![
LlmBackendKind::Codex,
LlmBackendKind::Claude,
LlmBackendKind::Opencode,
LlmBackendKind::None,
]
} else {
chain.to_vec()
};
let mut last_err: Option<AppError> = None;
for backend in &effective {
if let Err(probe_err) = backend_ready_probe(backend) {
tracing::warn!(
target: "embedding",
backend = ?backend,
error = %probe_err,
"embed_with_fallback: backend probe failed, skipping"
);
last_err = Some(probe_err);
continue;
}
match embed_via_backend_strict(
models_dir,
text,
backend,
last_err.as_ref(),
skip_on_failure,
) {
Ok((v, resolved_kind)) => return Ok((v, resolved_kind)),
Err(e) => {
if matches!(e, AppError::Validation(_)) {
return Err(e);
}
tracing::warn!(
target: "embedding",
backend = ?backend,
error = %e,
"embed_with_fallback: backend failed, trying next"
);
last_err = Some(e);
}
}
}
if skip_on_failure {
return Ok((Vec::new(), LlmBackendKind::None));
}
Err(last_err.unwrap_or_else(|| {
AppError::Embedding(crate::i18n::validation::embedding_detail(
LlmBackendError::NoBackendsAvailable,
))
}))
}