use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use super::config::resolve_model_for_api;
use super::pricing::{estimate_cost_usd, pricing_for_model};
use super::stable_working_dir;
use super::TokenUsage;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_env_vars<T>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().expect("env lock should acquire");
let old_values = vars
.iter()
.map(|(key, _)| ((*key).to_string(), std::env::var(key).ok()))
.collect::<Vec<_>>();
for (key, value) in vars {
match value {
Some(value) => unsafe { std::env::set_var(key, value) },
None => unsafe { std::env::remove_var(key) },
}
}
let result = f();
for (key, value) in old_values {
match value {
Some(value) => unsafe { std::env::set_var(&key, value) },
None => unsafe { std::env::remove_var(&key) },
}
}
result
}
fn with_pricing_config<T>(body: &str, vars: &[(&str, Option<&str>)], f: impl FnOnce() -> T) -> T {
with_env_vars(vars, || {
let path = std::env::temp_dir().join(format!(
"remem-ai-pricing-{}-{}.toml",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
std::fs::write(&path, body).expect("write pricing config");
let old = std::env::var("REMEM_CONFIG").ok();
unsafe { std::env::set_var("REMEM_CONFIG", &path) };
let result = f();
match old {
Some(value) => unsafe { std::env::set_var("REMEM_CONFIG", value) },
None => unsafe { std::env::remove_var("REMEM_CONFIG") },
}
let _ = std::fs::remove_file(path);
result
})
}
#[test]
fn resolve_model_for_api_maps_short_names() {
assert_eq!(resolve_model_for_api("haiku"), "claude-haiku-4-5-20251001");
assert_eq!(
resolve_model_for_api("sonnet"),
"claude-sonnet-4-5-20250514"
);
assert_eq!(resolve_model_for_api("opus"), "claude-opus-4-20250514");
assert_eq!(resolve_model_for_api("custom-model"), "custom-model");
}
#[test]
fn pricing_for_model_uses_model_defaults() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_INPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_OUTPUT_PER_MTOK", None),
],
|| {
assert_eq!(pricing_for_model("haiku"), (1.0, 5.0));
},
);
}
#[test]
fn pricing_for_gpt_52_uses_current_flagship_rate() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_GPT5_CODEX_INPUT_PER_MTOK", None),
("REMEM_PRICE_GPT5_CODEX_OUTPUT_PER_MTOK", None),
],
|| {
assert_eq!(pricing_for_model("gpt-5.2"), (1.75, 14.0));
},
);
}
#[test]
fn gpt_56_codex_credit_models_do_not_use_generic_gpt5_usd_pricing() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
],
|| {
let usage = TokenUsage::estimated(1_000_000, 1_000_000);
for model in ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"] {
assert_eq!(pricing_for_model(model), (0.0, 0.0));
assert_eq!(
estimate_cost_usd(model, &usage).expect("pricing"),
(0.0, "unknown_pricing")
);
}
},
);
}
#[test]
fn explicit_usd_override_still_applies_to_gpt_56_credit_models() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", Some("0.25")),
("REMEM_PRICE_OUTPUT_PER_MTOK", Some("1.5")),
],
|| {
assert_eq!(pricing_for_model("gpt-5.6-luna"), (0.25, 1.5));
},
);
}
#[test]
fn pricing_for_model_prefers_env_override() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", Some("1.25")),
("REMEM_PRICE_OUTPUT_PER_MTOK", Some("6.5")),
],
|| {
assert_eq!(pricing_for_model("sonnet"), (1.25, 6.5));
},
);
}
#[test]
fn estimate_cost_usd_combines_input_and_output_prices() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", Some("2.0")),
("REMEM_PRICE_OUTPUT_PER_MTOK", Some("8.0")),
],
|| {
let usage = TokenUsage::estimated(500_000, 250_000);
let (cost, pricing_source) = estimate_cost_usd("any-model", &usage).expect("pricing");
assert_eq!(pricing_source, "env_override");
assert!((cost - 3.0).abs() < f64::EPSILON);
},
);
}
#[test]
fn estimate_cost_usd_charges_cache_and_reasoning_separately() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_REASONING_PER_MTOK", None),
("REMEM_PRICE_CACHE_READ_PER_MTOK", None),
("REMEM_PRICE_CACHE_CREATION_PER_MTOK", None),
],
|| {
let usage = TokenUsage {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
reasoning_tokens: 1_000_000,
cache_read_tokens: 1_000_000,
..TokenUsage::default()
};
let (cost, pricing_source) = estimate_cost_usd("gpt-5.5", &usage).expect("pricing");
assert_eq!(pricing_source, "remem_static");
assert!((cost - 65.5).abs() < f64::EPSILON);
},
);
}
#[test]
fn pricing_for_model_prefers_config_override() {
with_pricing_config(
"[pricing]\ninput_per_mtok = 0.5\noutput_per_mtok = 2.0\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
],
|| {
assert_eq!(pricing_for_model("sonnet"), (0.5, 2.0));
assert_eq!(pricing_for_model("gpt-5.6-luna"), (0.5, 2.0));
let usage = TokenUsage::estimated(1_000_000, 1_000_000);
let (cost, source) = estimate_cost_usd("sonnet", &usage).expect("pricing");
assert_eq!(source, "config_override");
assert!((cost - 2.5).abs() < f64::EPSILON);
},
);
}
#[test]
fn env_override_wins_over_pricing_config() {
with_pricing_config(
"[pricing]\ninput_per_mtok = 0.5\noutput_per_mtok = 2.0\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", Some("1.25")),
("REMEM_PRICE_OUTPUT_PER_MTOK", Some("6.5")),
],
|| {
assert_eq!(pricing_for_model("sonnet"), (1.25, 6.5));
},
);
}
#[test]
fn family_pricing_config_overlays_only_that_family() {
with_pricing_config(
"[pricing.haiku]\ninput_per_mtok = 9.0\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_INPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_OUTPUT_PER_MTOK", None),
],
|| {
assert_eq!(pricing_for_model("haiku"), (9.0, 5.0));
assert_eq!(pricing_for_model("sonnet"), (3.0, 15.0));
let usage = TokenUsage::estimated(1_000_000, 1_000_000);
let (_, source) = estimate_cost_usd("haiku", &usage).expect("pricing");
assert_eq!(source, "config_override");
},
);
}
#[test]
fn family_env_wins_over_family_pricing_config() {
with_pricing_config(
"[pricing.haiku]\ninput_per_mtok = 9.0\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_INPUT_PER_MTOK", Some("4.0")),
("REMEM_PRICE_HAIKU_OUTPUT_PER_MTOK", None),
],
|| {
assert_eq!(pricing_for_model("haiku"), (4.0, 5.0));
let usage = TokenUsage::estimated(1_000_000, 0);
let (cost, source) = estimate_cost_usd("haiku", &usage).expect("pricing");
assert_eq!(source, "env_override");
assert!((cost - 4.0).abs() < f64::EPSILON);
},
);
}
#[test]
fn family_reasoning_config_survives_without_family_env_override() {
with_pricing_config(
"[pricing.haiku]\nreasoning_per_mtok = 99.0\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_INPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_REASONING_PER_MTOK", None),
("REMEM_PRICE_HAIKU_CACHE_CREATION_PER_MTOK", None),
("REMEM_PRICE_HAIKU_CACHE_READ_PER_MTOK", None),
],
|| {
let usage = TokenUsage {
reasoning_tokens: 1_000_000,
..TokenUsage::default()
};
let (cost, source) = estimate_cost_usd("haiku", &usage).expect("pricing");
assert_eq!(source, "config_override");
assert!((cost - 99.0).abs() < f64::EPSILON);
},
);
}
#[test]
fn family_env_only_reports_env_provenance() {
with_pricing_config(
"version = 1\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_INPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_OUTPUT_PER_MTOK", Some("7.0")),
("REMEM_PRICE_HAIKU_REASONING_PER_MTOK", None),
("REMEM_PRICE_HAIKU_CACHE_CREATION_PER_MTOK", None),
("REMEM_PRICE_HAIKU_CACHE_READ_PER_MTOK", None),
],
|| {
let usage = TokenUsage {
output_tokens: 1_000_000,
reasoning_tokens: 1_000_000,
..TokenUsage::default()
};
let (cost, source) = estimate_cost_usd("haiku", &usage).expect("pricing");
assert_eq!(source, "env_override");
assert!((cost - 12.0).abs() < f64::EPSILON);
},
);
}
#[test]
fn family_output_env_preserves_configured_reasoning_rate() {
with_pricing_config(
"[pricing.haiku]\nreasoning_per_mtok = 99.0\n",
&[
("REMEM_PRICE_INPUT_PER_MTOK", None),
("REMEM_PRICE_OUTPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_INPUT_PER_MTOK", None),
("REMEM_PRICE_HAIKU_OUTPUT_PER_MTOK", Some("7.0")),
("REMEM_PRICE_HAIKU_REASONING_PER_MTOK", None),
("REMEM_PRICE_HAIKU_CACHE_CREATION_PER_MTOK", None),
("REMEM_PRICE_HAIKU_CACHE_READ_PER_MTOK", None),
],
|| {
let usage = TokenUsage {
output_tokens: 1_000_000,
reasoning_tokens: 1_000_000,
..TokenUsage::default()
};
let (cost, source) = estimate_cost_usd("haiku", &usage).expect("pricing");
assert_eq!(source, "env_override");
assert!((cost - 106.0).abs() < f64::EPSILON);
},
);
}
#[test]
fn stable_working_dir_uses_data_dir_even_if_caller_cwd_disappears() {
let data_dir = crate::db::test_support::ScopedTestDataDir::new("ai-stable-cwd");
let got = stable_working_dir();
assert_eq!(got, data_dir.path);
assert!(got.is_dir());
}
#[cfg(unix)]
#[test]
fn claude_cli_child_disables_remem_hooks() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system time should be after unix epoch")?
.as_nanos();
let temp_dir = std::env::temp_dir().join(format!(
"remem-fake-claude-{}-{}",
std::process::id(),
unique
));
std::fs::create_dir_all(&temp_dir).context("fake claude temp dir should be created")?;
let script_path = temp_dir.join("claude");
let env_path = temp_dir.join("env.txt");
std::fs::write(
&script_path,
r#"#!/bin/sh
{
printf 'REMEM_DISABLE_HOOKS=%s\n' "${REMEM_DISABLE_HOOKS-}"
printf 'CLAUDECODE=%s\n' "${CLAUDECODE-__unset__}"
} > "$REMEM_FAKE_CLAUDE_ENV_OUT"
cat >/dev/null
printf 'ok\n'
"#,
)
.context("fake claude script should be written")?;
let mut permissions = std::fs::metadata(&script_path)
.context("fake claude metadata should be readable")?
.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&script_path, permissions)
.context("fake claude script should be executable")?;
let script = script_path
.to_str()
.context("fake claude path should be valid utf-8")?
.to_string();
let env_out = env_path
.to_str()
.context("fake claude env path should be valid utf-8")?
.to_string();
with_env_vars(
&[
("REMEM_FAKE_CLAUDE_ENV_OUT", Some(env_out.as_str())),
("REMEM_DISABLE_HOOKS", None),
("CLAUDECODE", Some("parent-session")),
],
|| -> Result<()> {
let profile = crate::runtime_config::ResolvedMemoryAiProfile {
profile_name: "test-claude".to_string(),
executor: crate::runtime_config::MemoryAiExecutor::ClaudeCli,
model: Some("haiku".to_string()),
cli_path: Some(script.clone()),
base_url: None,
reasoning_effort: None,
};
let _data_dir = crate::db::test_support::ScopedTestDataDir::new("ai-claude-child");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("tokio runtime should build")?;
let result = runtime
.block_on(super::cli::call_cli("system", "user", &profile))
.context("fake claude call should succeed")?;
assert_eq!(result.text, "ok");
Ok(())
},
)?;
let captured =
std::fs::read_to_string(&env_path).context("fake claude should capture child env")?;
assert!(captured.contains("REMEM_DISABLE_HOOKS=1"), "{captured:?}");
assert!(captured.contains("CLAUDECODE=__unset__"), "{captured:?}");
std::fs::remove_dir_all(&temp_dir)
.with_context(|| format!("failed to remove {}", temp_dir.display()))?;
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "current_thread")]
async fn resolved_profile_scope_does_not_reread_removed_config_profile() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system time should be after unix epoch")?
.as_nanos();
let temp_dir = std::env::temp_dir().join(format!(
"remem-frozen-profile-{}-{unique}",
std::process::id()
));
std::fs::create_dir_all(&temp_dir)?;
let script_path = temp_dir.join("claude");
std::fs::write(
&script_path,
"#!/bin/sh\ncat >/dev/null\nprintf 'frozen-profile\\n'\n",
)?;
let mut permissions = std::fs::metadata(&script_path)?.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&script_path, permissions)?;
let _data_dir = crate::db::test_support::ScopedTestDataDir::new("ai-frozen-profile");
let profile = crate::runtime_config::ResolvedMemoryAiProfile {
profile_name: "removed-after-validation".to_string(),
executor: crate::runtime_config::MemoryAiExecutor::ClaudeCli,
model: Some("haiku".to_string()),
cli_path: Some(script_path.to_string_lossy().into_owned()),
base_url: None,
reasoning_effort: None,
};
let response = super::with_resolved_profile(
profile,
super::call_ai(
"system",
"user",
super::UsageContext {
project: Some("/tmp/remem"),
session_id: Some("frozen-profile-session"),
operation: "frozen_profile_test",
host: None,
profile: Some("removed-after-validation"),
},
),
)
.await?;
assert_eq!(response, "frozen-profile");
std::fs::remove_dir_all(&temp_dir)?;
Ok(())
}