Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/brokkai_anvil.json
T

1 line
43 KiB
JSON

{"repo": "brokkai/anvil", "files": {"src/deepseek_auth.rs": {"80a22046c5830d9fbbfba96c1ef093ae028c3b78": {"t": 1785745833.0922973, "n": 5437, "h": "503196bde0256ec5", "c": "//! Credential access for hosted DeepSeek.\n//!\n//! DeepSeek keys are static (no refresh, no expiry): the user pastes one\n//! once via `/setup deepseek key <key>` and we reuse it until they rotate\n//! or disconnect. Persistence is opt-in: users who export\n//! `DEEPSEEK_API_KEY` in their shell get the zero-config behaviour, and\n//! on-disk state is only created by the setup command.\n//!\n//! Storage lives in the consolidated [`crate::secrets`] store\n//! (`<config>/brokk/secrets.json`, 0600, atomic), like OpenRouter and\n//! Bedrock. Unlike those two there is no legacy per-provider file to fall\n//! back to or migrate -- DeepSeek predates no store but the env var.\n\nuse anyhow::Result;\nuse serde::{Deserialize, Serialize};\n\nuse crate::discovery::DEEPSEEK_API_KEY_ENV;\n\n/// Flat one-field record. Like OpenRouter, DeepSeek keys are static so\n/// there's nothing more to persist.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DeepSeekAuth {\n pub api_key: String,\n}\n\n/// Snapshot of where DeepSeek credentials currently come from. Single\n/// source of truth for the \"env owns\" contract, mirroring the OpenRouter\n/// and Bedrock `CredentialState` types: whenever `DEEPSEEK_API_KEY` is\n/// non-empty the environment owns the credential lifecycle and `/setup\n/// deepseek key` explains rather than mutating state.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub struct CredentialState {\n pub env_set: bool,\n pub file_present: bool,\n}\n\nimpl CredentialState {\n pub fn snapshot() -> Self {\n let env_set = std::env::var(DEEPSEEK_API_KEY_ENV)\n .ok()\n .map(|s| !s.trim().is_empty())\n .unwrap_or(false);\n let file_present = match read() {\n Ok(Some(auth)) => !auth.api_key.trim().is_empty(),\n _ => false,\n };\n Self {\n env_set,\n file_present,\n }\n }\n\n /// Where the active credential, if any, is being read from. Mirrors\n /// the precedence in `build_deepseek_backend`: env wins over file,\n /// file wins over nothing.\n pub fn active_source(&self) -> &'static str {\n if self.env_set {\n \"env\"\n } else if self.file_present {\n \"file\"\n } else {\n \"none\"\n }\n }\n\n /// True when the environment owns the credential lifecycle.\n pub fn env_owns(&self) -> bool {\n self.env_set\n }\n}\n\n/// Read the stored DeepSeek credentials from the consolidated store. A\n/// malformed store degrades to \"no stored key\" with a warning (matching\n/// the sibling providers' resilience posture); the env var keeps working\n/// regardless.\npub fn read() -> Result<Option<DeepSeekAuth>> {\n match crate::secrets::read() {\n Ok(Some(secrets)) => Ok(secrets.deepseek),\n Ok(None) => Ok(None),\n Err(e) => {\n tracing::warn!(\"secrets store unreadable; treating DeepSeek as not stored: {e:#}\");\n Ok(None)\n }\n }\n}\n\n/// Persist the key into the consolidated secrets store.\npub fn write(auth: &DeepSeekAuth) -> Result<()> {\n crate::secrets::update(|secrets| secrets.deepseek = Some(auth.clone()))\n}\n\n/// Best-effort logout: clear the stored key. Missing state is not an\n/// error -- `/setup deepseek disconnect` is idempotent and, mirroring the\n/// sibling providers, must not create a store when nothing was ever\n/// saved. A malformed store is quarantined by `update` so disconnect\n/// self-heals broken state instead of reporting failure.\npub fn logout() -> Result<()> {\n match crate::secrets::read() {\n Ok(Some(secrets)) if secrets.deepseek.is_some() => {\n crate::secrets::update(|secrets| secrets.deepseek = None)\n }\n Ok(_) => Ok(()),\n Err(_) => crate::secrets::update(|secrets| secrets.deepseek = None),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::openrouter_auth::test_support::{ENV_GUARD, EnvScope};\n\n #[test]\n fn credential_state_reports_sources() {\n let _lock = ENV_GUARD.blocking_lock();\n let tmp = tempfile::tempdir().unwrap();\n let _brokk = EnvScope::set(\"BROKK_CONFIG_HOME\", tmp.path());\n\n {\n let _env = EnvScope::set(\"DEEPSEEK_API_KEY\", \"sk-ds-env\");\n let state = CredentialState::snapshot();\n assert!(state.env_set && state.env_owns());\n assert_eq!(state.active_source(), \"env\");\n }\n\n let _env = EnvScope::remove(\"DEEPSEEK_API_KEY\");\n let state = CredentialState::snapshot();\n assert_eq!(state.active_source(), \"none\");\n\n write(&DeepSeekAuth {\n api_key: \"sk-ds-file\".into(),\n })\n .unwrap();\n let state = CredentialState::snapshot();\n assert!(state.file_present && !state.env_owns());\n assert_eq!(state.active_source(), \"file\");\n }\n\n #[test]\n fn logout_is_idempotent_and_never_creates_a_store() {\n let _lock = ENV_GUARD.blocking_lock();\n let tmp = tempfile::tempdir().unwrap();\n let _brokk = EnvScope::set(\"BROKK_CONFIG_HOME\", tmp.path());\n\n logout().unwrap();\n assert!(\n !crate::secrets::secrets_path().unwrap().exists(),\n \"disconnect with nothing saved must not create secrets.json\"\n );\n\n write(&DeepSeekAuth {\n api_key: \"sk-ds\".into(),\n })\n .unwrap();\n logout().unwrap();\n assert!(read().unwrap().is_none());\n logout().unwrap();\n }\n}\n"}, "07d88e8695b85389084de950867426df2421e226": {"t": 1785756978.2866712, "n": 5437, "h": "503196bde0256ec5", "c": "//! Credential access for hosted DeepSeek.\n//!\n//! DeepSeek keys are static (no refresh, no expiry): the user pastes one\n//! once via `/setup deepseek key <key>` and we reuse it until they rotate\n//! or disconnect. Persistence is opt-in: users who export\n//! `DEEPSEEK_API_KEY` in their shell get the zero-config behaviour, and\n//! on-disk state is only created by the setup command.\n//!\n//! Storage lives in the consolidated [`crate::secrets`] store\n//! (`<config>/brokk/secrets.json`, 0600, atomic), like OpenRouter and\n//! Bedrock. Unlike those two there is no legacy per-provider file to fall\n//! back to or migrate -- DeepSeek predates no store but the env var.\n\nuse anyhow::Result;\nuse serde::{Deserialize, Serialize};\n\nuse crate::discovery::DEEPSEEK_API_KEY_ENV;\n\n/// Flat one-field record. Like OpenRouter, DeepSeek keys are static so\n/// there's nothing more to persist.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DeepSeekAuth {\n pub api_key: String,\n}\n\n/// Snapshot of where DeepSeek credentials currently come from. Single\n/// source of truth for the \"env owns\" contract, mirroring the OpenRouter\n/// and Bedrock `CredentialState` types: whenever `DEEPSEEK_API_KEY` is\n/// non-empty the environment owns the credential lifecycle and `/setup\n/// deepseek key` explains rather than mutating state.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub struct CredentialState {\n pub env_set: bool,\n pub file_present: bool,\n}\n\nimpl CredentialState {\n pub fn snapshot() -> Self {\n let env_set = std::env::var(DEEPSEEK_API_KEY_ENV)\n .ok()\n .map(|s| !s.trim().is_empty())\n .unwrap_or(false);\n let file_present = match read() {\n Ok(Some(auth)) => !auth.api_key.trim().is_empty(),\n _ => false,\n };\n Self {\n env_set,\n file_present,\n }\n }\n\n /// Where the active credential, if any, is being read from. Mirrors\n /// the precedence in `build_deepseek_backend`: env wins over file,\n /// file wins over nothing.\n pub fn active_source(&self) -> &'static str {\n if self.env_set {\n \"env\"\n } else if self.file_present {\n \"file\"\n } else {\n \"none\"\n }\n }\n\n /// True when the environment owns the credential lifecycle.\n pub fn env_owns(&self) -> bool {\n self.env_set\n }\n}\n\n/// Read the stored DeepSeek credentials from the consolidated store. A\n/// malformed store degrades to \"no stored key\" with a warning (matching\n/// the sibling providers' resilience posture); the env var keeps working\n/// regardless.\npub fn read() -> Result<Option<DeepSeekAuth>> {\n match crate::secrets::read() {\n Ok(Some(secrets)) => Ok(secrets.deepseek),\n Ok(None) => Ok(None),\n Err(e) => {\n tracing::warn!(\"secrets store unreadable; treating DeepSeek as not stored: {e:#}\");\n Ok(None)\n }\n }\n}\n\n/// Persist the key into the consolidated secrets store.\npub fn write(auth: &DeepSeekAuth) -> Result<()> {\n crate::secrets::update(|secrets| secrets.deepseek = Some(auth.clone()))\n}\n\n/// Best-effort logout: clear the stored key. Missing state is not an\n/// error -- `/setup deepseek disconnect` is idempotent and, mirroring the\n/// sibling providers, must not create a store when nothing was ever\n/// saved. A malformed store is quarantined by `update` so disconnect\n/// self-heals broken state instead of reporting failure.\npub fn logout() -> Result<()> {\n match crate::secrets::read() {\n Ok(Some(secrets)) if secrets.deepseek.is_some() => {\n crate::secrets::update(|secrets| secrets.deepseek = None)\n }\n Ok(_) => Ok(()),\n Err(_) => crate::secrets::update(|secrets| secrets.deepseek = None),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::openrouter_auth::test_support::{ENV_GUARD, EnvScope};\n\n #[test]\n fn credential_state_reports_sources() {\n let _lock = ENV_GUARD.blocking_lock();\n let tmp = tempfile::tempdir().unwrap();\n let _brokk = EnvScope::set(\"BROKK_CONFIG_HOME\", tmp.path());\n\n {\n let _env = EnvScope::set(\"DEEPSEEK_API_KEY\", \"sk-ds-env\");\n let state = CredentialState::snapshot();\n assert!(state.env_set && state.env_owns());\n assert_eq!(state.active_source(), \"env\");\n }\n\n let _env = EnvScope::remove(\"DEEPSEEK_API_KEY\");\n let state = CredentialState::snapshot();\n assert_eq!(state.active_source(), \"none\");\n\n write(&DeepSeekAuth {\n api_key: \"sk-ds-file\".into(),\n })\n .unwrap();\n let state = CredentialState::snapshot();\n assert!(state.file_present && !state.env_owns());\n assert_eq!(state.active_source(), \"file\");\n }\n\n #[test]\n fn logout_is_idempotent_and_never_creates_a_store() {\n let _lock = ENV_GUARD.blocking_lock();\n let tmp = tempfile::tempdir().unwrap();\n let _brokk = EnvScope::set(\"BROKK_CONFIG_HOME\", tmp.path());\n\n logout().unwrap();\n assert!(\n !crate::secrets::secrets_path().unwrap().exists(),\n \"disconnect with nothing saved must not create secrets.json\"\n );\n\n write(&DeepSeekAuth {\n api_key: \"sk-ds\".into(),\n })\n .unwrap();\n logout().unwrap();\n assert!(read().unwrap().is_none());\n logout().unwrap();\n }\n}\n"}}, "src/deepseek_balance.rs": {"80a22046c5830d9fbbfba96c1ef093ae028c3b78": {"t": 1785745851.4341092, "n": 15650, "h": "a75372357f12ea30", "c": "//! Best-effort `GET /user/balance` telemetry for hosted DeepSeek sessions.\n//!\n//! DeepSeek returns money values as decimal strings. Keep those strings opaque:\n//! the ACP payload must preserve precision, formatting, and record order.\n\nuse std::sync::{OnceLock, RwLock};\nuse std::time::{Duration, Instant};\n\nuse anyhow::{Context, Result, anyhow};\nuse serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\n\nuse crate::discovery::{DEEPSEEK_API_KEY_ENV, DEEPSEEK_BASE_URL};\nuse crate::llm_client::OpenAiClient;\n\nconst CACHE_TTL: Duration = Duration::from_secs(300);\n\n#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]\npub struct Balance {\n pub currency: String,\n #[serde(rename(deserialize = \"total_balance\", serialize = \"totalBalance\"))]\n pub total_balance: String,\n #[serde(rename(deserialize = \"granted_balance\", serialize = \"grantedBalance\"))]\n pub granted_balance: String,\n #[serde(rename(deserialize = \"topped_up_balance\", serialize = \"toppedUpBalance\"))]\n pub topped_up_balance: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Deserialize)]\npub struct BalanceResponse {\n pub is_available: bool,\n pub balance_infos: Vec<Balance>,\n}\n\n/// Provider-owned DeepSeek balance telemetry for ACP usage updates.\n#[derive(Debug, Clone, PartialEq, Serialize)]\n#[serde(tag = \"status\", rename_all = \"camelCase\")]\npub enum Status {\n Available {\n balances: Vec<Balance>,\n #[serde(rename = \"asOf\")]\n as_of: String,\n },\n Unavailable {\n reason: String,\n #[serde(rename = \"asOf\")]\n as_of: String,\n },\n}\n\n#[derive(Default)]\nstruct Cache {\n status: Option<Status>,\n refreshed: Option<Instant>,\n refresh_in_flight: bool,\n credential_fingerprint: Option<[u8; 32]>,\n}\n\nstatic CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();\n\nfn cache() -> &'static RwLock<Cache> {\n CACHE.get_or_init(|| RwLock::new(Cache::default()))\n}\n\n/// Return the cached balance immediately and refresh stale data in the\n/// background. Usage updates must never wait on provider HTTP I/O.\npub fn status() -> Status {\n let api_key = active_api_key();\n let fingerprint = api_key.as_deref().map(credential_fingerprint);\n let (status, start_refresh) = {\n let mut cache = cache().write().expect(\"DeepSeek balance cache poisoned\");\n status_from_cache(&mut cache, fingerprint, Instant::now(), as_of())\n };\n\n if start_refresh {\n let api_key = api_key.expect(\"a refresh requires a DeepSeek credential\");\n let fingerprint = fingerprint.expect(\"a refresh requires a credential fingerprint\");\n match tokio::runtime::Handle::try_current() {\n Ok(handle) => {\n handle.spawn(async move {\n let status = refreshed_status(&api_key).await;\n let mut cache = cache().write().expect(\"DeepSeek balance cache poisoned\");\n if cache.credential_fingerprint == Some(fingerprint) {\n cache.status = Some(status);\n cache.refreshed = Some(Instant::now());\n cache.refresh_in_flight = false;\n }\n });\n }\n Err(_) => {\n let mut cache = cache().write().expect(\"DeepSeek balance cache poisoned\");\n if cache.credential_fingerprint == Some(fingerprint) {\n cache.refresh_in_flight = false;\n }\n }\n }\n }\n status\n}\n\nfn status_from_cache(\n cache: &mut Cache,\n fingerprint: Option<[u8; 32]>,\n now: Instant,\n as_of: String,\n) -> (Status, bool) {\n let Some(fingerprint) = fingerprint else {\n *cache = Cache::default();\n return (\n unavailable(\"DeepSeek credentials are unavailable\", as_of),\n false,\n );\n };\n if cache.credential_fingerprint != Some(fingerprint) {\n *cache = Cache {\n credential_fingerprint: Some(fingerprint),\n ..Cache::default()\n };\n }\n let fresh = cache\n .refreshed\n .is_some_and(|refreshed| now.duration_since(refreshed) < CACHE_TTL);\n if let Some(status) = cache.status.clone() {\n if fresh {\n return (status, false);\n }\n let start_refresh = !cache.refresh_in_flight;\n cache.refresh_in_flight = true;\n return (status, start_refresh);\n }\n let start_refresh = !cache.refresh_in_flight;\n cache.refresh_in_flight = true;\n (unavailable(\"refresh pending\", as_of), start_refresh)\n}\n\nfn credential_fingerprint(api_key: &str) -> [u8; 32] {\n Sha256::digest(api_key.as_bytes()).into()\n}\n\nfn as_of() -> String {\n chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)\n}\n\nfn unavailable(reason: impl Into<String>, as_of: impl Into<String>) -> Status {\n Status::Unavailable {\n reason: reason.into(),\n as_of: as_of.into(),\n }\n}\n\nasync fn refreshed_status(api_key: &str) -> Status {\n match query(api_key).await.and_then(status_from_response) {\n Ok(status) => status,\n Err(error) => {\n tracing::debug!(\n reason = error.user_reason(),\n \"DeepSeek balance refresh unavailable\"\n );\n unavailable(error.user_reason(), as_of())\n }\n }\n}\n\nfn status_from_response(response: BalanceResponse) -> Result<Status, QueryError> {\n if !response.is_available {\n return Err(QueryError::ProviderUnavailable);\n }\n if response.balance_infos.iter().any(|balance| {\n balance.currency.trim().is_empty()\n || balance.total_balance.trim().is_empty()\n || balance.granted_balance.trim().is_empty()\n || balance.topped_up_balance.trim().is_empty()\n }) {\n return Err(QueryError::ResponseUnavailable);\n }\n Ok(Status::Available {\n balances: response.balance_infos,\n as_of: as_of(),\n })\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum QueryError {\n Unauthorized,\n RateLimited,\n TimedOut,\n ResponseUnavailable,\n ProviderUnavailable,\n}\n\nimpl QueryError {\n fn user_reason(self) -> &'static str {\n match self {\n Self::Unauthorized => \"DeepSeek credentials were rejected\",\n Self::RateLimited => \"DeepSeek request was rate limited\",\n Self::TimedOut => \"DeepSeek request timed out\",\n Self::ResponseUnavailable => \"DeepSeek balance response unavailable\",\n Self::ProviderUnavailable => \"DeepSeek balance is unavailable\",\n }\n }\n}\n\n/// Resolve credentials with the same environment-first precedence as the\n/// DeepSeek backend. Empty environment values fall through to saved secrets.\npub fn active_api_key() -> Option<String> {\n if let Ok(raw) = std::env::var(DEEPSEEK_API_KEY_ENV) {\n let trimmed = raw.trim();\n if !trimmed.is_empty() {\n return Some(trimmed.to_string());\n }\n }\n match crate::deepseek_auth::read() {\n Ok(Some(auth)) => {\n let trimmed = auth.api_key.trim();\n (!trimmed.is_empty()).then(|| trimmed.to_string())\n }\n _ => None,\n }\n}\n\nfn balance_http_client() -> Result<reqwest::Client> {\n OpenAiClient::apply_runtime_tls_workarounds(\n reqwest::Client::builder()\n .connect_timeout(Duration::from_secs(3))\n .timeout(Duration::from_secs(5)),\n DEEPSEEK_BASE_URL,\n )\n .build()\n .context(\"building DeepSeek balance HTTP client\")\n}\n\n/// Fetch the provider response with a short timeout.\n#[allow(dead_code)] // Public injection seam for callers outside the refresh path.\npub async fn fetch(api_key: &str) -> Result<BalanceResponse> {\n let http = balance_http_client()?;\n fetch_with(&http, DEEPSEEK_BASE_URL, api_key).await\n}\n\n/// Injectable client/base URL seam for unit tests and alternate transports.\n#[allow(dead_code)] // Used by tests and available to alternate transports.\npub async fn fetch_with(\n http: &reqwest::Client,\n base_url: &str,\n api_key: &str,\n) -> Result<BalanceResponse> {\n fetch_impl(http, base_url, api_key)\n .await\n .map_err(FetchError::into_anyhow)\n}\n\nasync fn query(api_key: &str) -> Result<BalanceResponse, QueryError> {\n let http = balance_http_client().map_err(|_| QueryError::ResponseUnavailable)?;\n query_with(&http, DEEPSEEK_BASE_URL, api_key).await\n}\n\nasync fn query_with(\n http: &reqwest::Client,\n base_url: &str,\n api_key: &str,\n) -> Result<BalanceResponse, QueryError> {\n fetch_impl(http, base_url, api_key)\n .await\n .map_err(QueryError::from)\n}\n\nenum FetchError {\n Transport(reqwest::Error),\n Response(reqwest::StatusCode),\n Parse,\n}\n\nimpl FetchError {\n fn into_anyhow(self) -> anyhow::Error {\n match self {\n Self::Transport(error) => anyhow!(error).context(\"GET DeepSeek /user/balance\"),\n Self::Response(status) => anyhow!(\"DeepSeek /user/balance returned HTTP {status}\"),\n Self::Parse => anyhow!(\"parsing DeepSeek /user/balance JSON\"),\n }\n }\n}\n\nimpl From<FetchError> for QueryError {\n fn from(error: FetchError) -> Self {\n match error {\n FetchError::Response(status) if status.as_u16() == 401 || status.as_u16() == 403 => {\n Self::Unauthorized\n }\n FetchError::Response(status) if status.as_u16() == 429 => Self::RateLimited,\n FetchError::Transport(error) if error.is_timeout() => Self::TimedOut,\n FetchError::Transport(_) | FetchError::Response(_) | FetchError::Parse => {\n Self::ResponseUnavailable\n }\n }\n }\n}\n\nasync fn fetch_impl(\n http: &reqwest::Client,\n base_url: &str,\n api_key: &str,\n) -> std::result::Result<BalanceResponse, FetchError> {\n let url = format!(\"{}/user/balance\", base_url.trim_end_matches('/'));\n let response = http\n .get(url)\n .bearer_auth(api_key)\n .send()\n .await\n .map_err(FetchError::Transport)?;\n if !response.status().is_success() {\n return Err(FetchError::Response(response.status()));\n }\n response.json().await.map_err(|_| FetchError::Parse)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use wiremock::matchers::{header, method, path};\n use wiremock::{Mock, MockServer, ResponseTemplate};\n\n fn official_response() -> serde_json::Value {\n serde_json::json!({\"is_available\": true, \"balance_infos\": [\n {\"currency\":\"CNY\",\"total_balance\":\"123.4500\",\"granted_balance\":\"23.0000\",\"topped_up_balance\":\"100.4500\"},\n {\"currency\":\"USD\",\"total_balance\":\"0.010\",\"granted_balance\":\"0.000\",\"topped_up_balance\":\"0.010\"}\n ]})\n }\n\n #[tokio::test]\n async fn fetch_preserves_official_multi_currency_strings_and_order() {\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .and(path(\"/user/balance\"))\n .and(header(\"authorization\", \"Bearer sk-ds-test\"))\n .respond_with(ResponseTemplate::new(200).set_body_json(official_response()))\n .mount(&server)\n .await;\n let response = fetch_with(&reqwest::Client::new(), &server.uri(), \"sk-ds-test\")\n .await\n .unwrap();\n let status = status_from_response(response).unwrap();\n let json = serde_json::to_value(status).unwrap();\n assert_eq!(json[\"balances\"][0][\"currency\"], \"CNY\");\n assert_eq!(json[\"balances\"][0][\"totalBalance\"], \"123.4500\");\n assert_eq!(json[\"balances\"][1][\"currency\"], \"USD\");\n assert_eq!(json[\"balances\"][1][\"totalBalance\"], \"0.010\");\n }\n\n #[test]\n fn provider_unavailable_is_safe_unavailable_status() {\n let response: BalanceResponse =\n serde_json::from_value(serde_json::json!({\"is_available\": false, \"balance_infos\": []}))\n .unwrap();\n assert_eq!(\n status_from_response(response),\n Err(QueryError::ProviderUnavailable)\n );\n }\n\n #[tokio::test]\n async fn query_classifies_safe_failures() {\n for (status, expected) in [\n (401, QueryError::Unauthorized),\n (403, QueryError::Unauthorized),\n (429, QueryError::RateLimited),\n ] {\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .respond_with(ResponseTemplate::new(status).set_body_string(\"do not disclose this\"))\n .mount(&server)\n .await;\n assert_eq!(\n query_with(&reqwest::Client::new(), &server.uri(), \"secret\")\n .await\n .unwrap_err(),\n expected\n );\n }\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1)))\n .mount(&server)\n .await;\n let http = reqwest::Client::builder()\n .timeout(Duration::from_millis(10))\n .build()\n .unwrap();\n assert_eq!(\n query_with(&http, &server.uri(), \"secret\")\n .await\n .unwrap_err(),\n QueryError::TimedOut\n );\n }\n\n #[tokio::test]\n async fn malformed_and_blank_responses_are_unavailable() {\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .respond_with(ResponseTemplate::new(200).set_body_string(\"not json\"))\n .mount(&server)\n .await;\n assert_eq!(\n query_with(&reqwest::Client::new(), &server.uri(), \"secret\")\n .await\n .unwrap_err(),\n QueryError::ResponseUnavailable\n );\n let response: BalanceResponse = serde_json::from_value(serde_json::json!({\"is_available\": true, \"balance_infos\": [{\"currency\":\" \", \"total_balance\":\"1\", \"granted_balance\":\"0\", \"topped_up_balance\":\"1\"}]})).unwrap();\n assert_eq!(\n status_from_response(response),\n Err(QueryError::ResponseUnavailable)\n );\n let response: BalanceResponse = serde_json::from_value(serde_json::json!({\"is_available\": true, \"balance_infos\": [{\"currency\":\"USD\", \"total_balance\":\" \", \"granted_balance\":\"0\", \"topped_up_balance\":\"1\"}]})).unwrap();\n assert_eq!(\n status_from_response(response),\n Err(QueryError::ResponseUnavailable)\n );\n }\n\n #[test]\n fn cache_is_single_flight_and_invalidates_rotated_credentials() {\n let now = Instant::now();\n let first = credential_fingerprint(\"first\");\n let second = credential_fingerprint(\"second\");\n let mut cache = Cache::default();\n assert!(status_from_cache(&mut cache, Some(first), now, \"now\".into()).1);\n assert!(!status_from_cache(&mut cache, Some(first), now, \"now\".into()).1);\n cache.status = Some(Status::Available {\n balances: vec![],\n as_of: \"old\".into(),\n });\n cache.refreshed = Some(now);\n cache.refresh_in_flight = false;\n assert!(!status_from_cache(&mut cache, Some(first), now, \"now\".into()).1);\n let (status, starts) = status_from_cache(&mut cache, Some(second), now, \"now\".into());\n assert!(starts);\n assert!(\n matches!(status, Status::Unavailable { reason, .. } if reason == \"refresh pending\")\n );\n let (status, starts) = status_from_cache(&mut cache, None, now, \"now\".into());\n assert!(!starts);\n assert!(\n matches!(status, Status::Unavailable { reason, .. } if reason == \"DeepSeek credentials are unavailable\")\n );\n }\n}\n"}, "2e47d483f1a39c7d6507670bbcfaf313567f774f": {"t": 1785756978.3392003, "n": 15650, "h": "a75372357f12ea30", "c": "//! Best-effort `GET /user/balance` telemetry for hosted DeepSeek sessions.\n//!\n//! DeepSeek returns money values as decimal strings. Keep those strings opaque:\n//! the ACP payload must preserve precision, formatting, and record order.\n\nuse std::sync::{OnceLock, RwLock};\nuse std::time::{Duration, Instant};\n\nuse anyhow::{Context, Result, anyhow};\nuse serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\n\nuse crate::discovery::{DEEPSEEK_API_KEY_ENV, DEEPSEEK_BASE_URL};\nuse crate::llm_client::OpenAiClient;\n\nconst CACHE_TTL: Duration = Duration::from_secs(300);\n\n#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]\npub struct Balance {\n pub currency: String,\n #[serde(rename(deserialize = \"total_balance\", serialize = \"totalBalance\"))]\n pub total_balance: String,\n #[serde(rename(deserialize = \"granted_balance\", serialize = \"grantedBalance\"))]\n pub granted_balance: String,\n #[serde(rename(deserialize = \"topped_up_balance\", serialize = \"toppedUpBalance\"))]\n pub topped_up_balance: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Deserialize)]\npub struct BalanceResponse {\n pub is_available: bool,\n pub balance_infos: Vec<Balance>,\n}\n\n/// Provider-owned DeepSeek balance telemetry for ACP usage updates.\n#[derive(Debug, Clone, PartialEq, Serialize)]\n#[serde(tag = \"status\", rename_all = \"camelCase\")]\npub enum Status {\n Available {\n balances: Vec<Balance>,\n #[serde(rename = \"asOf\")]\n as_of: String,\n },\n Unavailable {\n reason: String,\n #[serde(rename = \"asOf\")]\n as_of: String,\n },\n}\n\n#[derive(Default)]\nstruct Cache {\n status: Option<Status>,\n refreshed: Option<Instant>,\n refresh_in_flight: bool,\n credential_fingerprint: Option<[u8; 32]>,\n}\n\nstatic CACHE: OnceLock<RwLock<Cache>> = OnceLock::new();\n\nfn cache() -> &'static RwLock<Cache> {\n CACHE.get_or_init(|| RwLock::new(Cache::default()))\n}\n\n/// Return the cached balance immediately and refresh stale data in the\n/// background. Usage updates must never wait on provider HTTP I/O.\npub fn status() -> Status {\n let api_key = active_api_key();\n let fingerprint = api_key.as_deref().map(credential_fingerprint);\n let (status, start_refresh) = {\n let mut cache = cache().write().expect(\"DeepSeek balance cache poisoned\");\n status_from_cache(&mut cache, fingerprint, Instant::now(), as_of())\n };\n\n if start_refresh {\n let api_key = api_key.expect(\"a refresh requires a DeepSeek credential\");\n let fingerprint = fingerprint.expect(\"a refresh requires a credential fingerprint\");\n match tokio::runtime::Handle::try_current() {\n Ok(handle) => {\n handle.spawn(async move {\n let status = refreshed_status(&api_key).await;\n let mut cache = cache().write().expect(\"DeepSeek balance cache poisoned\");\n if cache.credential_fingerprint == Some(fingerprint) {\n cache.status = Some(status);\n cache.refreshed = Some(Instant::now());\n cache.refresh_in_flight = false;\n }\n });\n }\n Err(_) => {\n let mut cache = cache().write().expect(\"DeepSeek balance cache poisoned\");\n if cache.credential_fingerprint == Some(fingerprint) {\n cache.refresh_in_flight = false;\n }\n }\n }\n }\n status\n}\n\nfn status_from_cache(\n cache: &mut Cache,\n fingerprint: Option<[u8; 32]>,\n now: Instant,\n as_of: String,\n) -> (Status, bool) {\n let Some(fingerprint) = fingerprint else {\n *cache = Cache::default();\n return (\n unavailable(\"DeepSeek credentials are unavailable\", as_of),\n false,\n );\n };\n if cache.credential_fingerprint != Some(fingerprint) {\n *cache = Cache {\n credential_fingerprint: Some(fingerprint),\n ..Cache::default()\n };\n }\n let fresh = cache\n .refreshed\n .is_some_and(|refreshed| now.duration_since(refreshed) < CACHE_TTL);\n if let Some(status) = cache.status.clone() {\n if fresh {\n return (status, false);\n }\n let start_refresh = !cache.refresh_in_flight;\n cache.refresh_in_flight = true;\n return (status, start_refresh);\n }\n let start_refresh = !cache.refresh_in_flight;\n cache.refresh_in_flight = true;\n (unavailable(\"refresh pending\", as_of), start_refresh)\n}\n\nfn credential_fingerprint(api_key: &str) -> [u8; 32] {\n Sha256::digest(api_key.as_bytes()).into()\n}\n\nfn as_of() -> String {\n chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)\n}\n\nfn unavailable(reason: impl Into<String>, as_of: impl Into<String>) -> Status {\n Status::Unavailable {\n reason: reason.into(),\n as_of: as_of.into(),\n }\n}\n\nasync fn refreshed_status(api_key: &str) -> Status {\n match query(api_key).await.and_then(status_from_response) {\n Ok(status) => status,\n Err(error) => {\n tracing::debug!(\n reason = error.user_reason(),\n \"DeepSeek balance refresh unavailable\"\n );\n unavailable(error.user_reason(), as_of())\n }\n }\n}\n\nfn status_from_response(response: BalanceResponse) -> Result<Status, QueryError> {\n if !response.is_available {\n return Err(QueryError::ProviderUnavailable);\n }\n if response.balance_infos.iter().any(|balance| {\n balance.currency.trim().is_empty()\n || balance.total_balance.trim().is_empty()\n || balance.granted_balance.trim().is_empty()\n || balance.topped_up_balance.trim().is_empty()\n }) {\n return Err(QueryError::ResponseUnavailable);\n }\n Ok(Status::Available {\n balances: response.balance_infos,\n as_of: as_of(),\n })\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum QueryError {\n Unauthorized,\n RateLimited,\n TimedOut,\n ResponseUnavailable,\n ProviderUnavailable,\n}\n\nimpl QueryError {\n fn user_reason(self) -> &'static str {\n match self {\n Self::Unauthorized => \"DeepSeek credentials were rejected\",\n Self::RateLimited => \"DeepSeek request was rate limited\",\n Self::TimedOut => \"DeepSeek request timed out\",\n Self::ResponseUnavailable => \"DeepSeek balance response unavailable\",\n Self::ProviderUnavailable => \"DeepSeek balance is unavailable\",\n }\n }\n}\n\n/// Resolve credentials with the same environment-first precedence as the\n/// DeepSeek backend. Empty environment values fall through to saved secrets.\npub fn active_api_key() -> Option<String> {\n if let Ok(raw) = std::env::var(DEEPSEEK_API_KEY_ENV) {\n let trimmed = raw.trim();\n if !trimmed.is_empty() {\n return Some(trimmed.to_string());\n }\n }\n match crate::deepseek_auth::read() {\n Ok(Some(auth)) => {\n let trimmed = auth.api_key.trim();\n (!trimmed.is_empty()).then(|| trimmed.to_string())\n }\n _ => None,\n }\n}\n\nfn balance_http_client() -> Result<reqwest::Client> {\n OpenAiClient::apply_runtime_tls_workarounds(\n reqwest::Client::builder()\n .connect_timeout(Duration::from_secs(3))\n .timeout(Duration::from_secs(5)),\n DEEPSEEK_BASE_URL,\n )\n .build()\n .context(\"building DeepSeek balance HTTP client\")\n}\n\n/// Fetch the provider response with a short timeout.\n#[allow(dead_code)] // Public injection seam for callers outside the refresh path.\npub async fn fetch(api_key: &str) -> Result<BalanceResponse> {\n let http = balance_http_client()?;\n fetch_with(&http, DEEPSEEK_BASE_URL, api_key).await\n}\n\n/// Injectable client/base URL seam for unit tests and alternate transports.\n#[allow(dead_code)] // Used by tests and available to alternate transports.\npub async fn fetch_with(\n http: &reqwest::Client,\n base_url: &str,\n api_key: &str,\n) -> Result<BalanceResponse> {\n fetch_impl(http, base_url, api_key)\n .await\n .map_err(FetchError::into_anyhow)\n}\n\nasync fn query(api_key: &str) -> Result<BalanceResponse, QueryError> {\n let http = balance_http_client().map_err(|_| QueryError::ResponseUnavailable)?;\n query_with(&http, DEEPSEEK_BASE_URL, api_key).await\n}\n\nasync fn query_with(\n http: &reqwest::Client,\n base_url: &str,\n api_key: &str,\n) -> Result<BalanceResponse, QueryError> {\n fetch_impl(http, base_url, api_key)\n .await\n .map_err(QueryError::from)\n}\n\nenum FetchError {\n Transport(reqwest::Error),\n Response(reqwest::StatusCode),\n Parse,\n}\n\nimpl FetchError {\n fn into_anyhow(self) -> anyhow::Error {\n match self {\n Self::Transport(error) => anyhow!(error).context(\"GET DeepSeek /user/balance\"),\n Self::Response(status) => anyhow!(\"DeepSeek /user/balance returned HTTP {status}\"),\n Self::Parse => anyhow!(\"parsing DeepSeek /user/balance JSON\"),\n }\n }\n}\n\nimpl From<FetchError> for QueryError {\n fn from(error: FetchError) -> Self {\n match error {\n FetchError::Response(status) if status.as_u16() == 401 || status.as_u16() == 403 => {\n Self::Unauthorized\n }\n FetchError::Response(status) if status.as_u16() == 429 => Self::RateLimited,\n FetchError::Transport(error) if error.is_timeout() => Self::TimedOut,\n FetchError::Transport(_) | FetchError::Response(_) | FetchError::Parse => {\n Self::ResponseUnavailable\n }\n }\n }\n}\n\nasync fn fetch_impl(\n http: &reqwest::Client,\n base_url: &str,\n api_key: &str,\n) -> std::result::Result<BalanceResponse, FetchError> {\n let url = format!(\"{}/user/balance\", base_url.trim_end_matches('/'));\n let response = http\n .get(url)\n .bearer_auth(api_key)\n .send()\n .await\n .map_err(FetchError::Transport)?;\n if !response.status().is_success() {\n return Err(FetchError::Response(response.status()));\n }\n response.json().await.map_err(|_| FetchError::Parse)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use wiremock::matchers::{header, method, path};\n use wiremock::{Mock, MockServer, ResponseTemplate};\n\n fn official_response() -> serde_json::Value {\n serde_json::json!({\"is_available\": true, \"balance_infos\": [\n {\"currency\":\"CNY\",\"total_balance\":\"123.4500\",\"granted_balance\":\"23.0000\",\"topped_up_balance\":\"100.4500\"},\n {\"currency\":\"USD\",\"total_balance\":\"0.010\",\"granted_balance\":\"0.000\",\"topped_up_balance\":\"0.010\"}\n ]})\n }\n\n #[tokio::test]\n async fn fetch_preserves_official_multi_currency_strings_and_order() {\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .and(path(\"/user/balance\"))\n .and(header(\"authorization\", \"Bearer sk-ds-test\"))\n .respond_with(ResponseTemplate::new(200).set_body_json(official_response()))\n .mount(&server)\n .await;\n let response = fetch_with(&reqwest::Client::new(), &server.uri(), \"sk-ds-test\")\n .await\n .unwrap();\n let status = status_from_response(response).unwrap();\n let json = serde_json::to_value(status).unwrap();\n assert_eq!(json[\"balances\"][0][\"currency\"], \"CNY\");\n assert_eq!(json[\"balances\"][0][\"totalBalance\"], \"123.4500\");\n assert_eq!(json[\"balances\"][1][\"currency\"], \"USD\");\n assert_eq!(json[\"balances\"][1][\"totalBalance\"], \"0.010\");\n }\n\n #[test]\n fn provider_unavailable_is_safe_unavailable_status() {\n let response: BalanceResponse =\n serde_json::from_value(serde_json::json!({\"is_available\": false, \"balance_infos\": []}))\n .unwrap();\n assert_eq!(\n status_from_response(response),\n Err(QueryError::ProviderUnavailable)\n );\n }\n\n #[tokio::test]\n async fn query_classifies_safe_failures() {\n for (status, expected) in [\n (401, QueryError::Unauthorized),\n (403, QueryError::Unauthorized),\n (429, QueryError::RateLimited),\n ] {\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .respond_with(ResponseTemplate::new(status).set_body_string(\"do not disclose this\"))\n .mount(&server)\n .await;\n assert_eq!(\n query_with(&reqwest::Client::new(), &server.uri(), \"secret\")\n .await\n .unwrap_err(),\n expected\n );\n }\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1)))\n .mount(&server)\n .await;\n let http = reqwest::Client::builder()\n .timeout(Duration::from_millis(10))\n .build()\n .unwrap();\n assert_eq!(\n query_with(&http, &server.uri(), \"secret\")\n .await\n .unwrap_err(),\n QueryError::TimedOut\n );\n }\n\n #[tokio::test]\n async fn malformed_and_blank_responses_are_unavailable() {\n let server = MockServer::start().await;\n Mock::given(method(\"GET\"))\n .respond_with(ResponseTemplate::new(200).set_body_string(\"not json\"))\n .mount(&server)\n .await;\n assert_eq!(\n query_with(&reqwest::Client::new(), &server.uri(), \"secret\")\n .await\n .unwrap_err(),\n QueryError::ResponseUnavailable\n );\n let response: BalanceResponse = serde_json::from_value(serde_json::json!({\"is_available\": true, \"balance_infos\": [{\"currency\":\" \", \"total_balance\":\"1\", \"granted_balance\":\"0\", \"topped_up_balance\":\"1\"}]})).unwrap();\n assert_eq!(\n status_from_response(response),\n Err(QueryError::ResponseUnavailable)\n );\n let response: BalanceResponse = serde_json::from_value(serde_json::json!({\"is_available\": true, \"balance_infos\": [{\"currency\":\"USD\", \"total_balance\":\" \", \"granted_balance\":\"0\", \"topped_up_balance\":\"1\"}]})).unwrap();\n assert_eq!(\n status_from_response(response),\n Err(QueryError::ResponseUnavailable)\n );\n }\n\n #[test]\n fn cache_is_single_flight_and_invalidates_rotated_credentials() {\n let now = Instant::now();\n let first = credential_fingerprint(\"first\");\n let second = credential_fingerprint(\"second\");\n let mut cache = Cache::default();\n assert!(status_from_cache(&mut cache, Some(first), now, \"now\".into()).1);\n assert!(!status_from_cache(&mut cache, Some(first), now, \"now\".into()).1);\n cache.status = Some(Status::Available {\n balances: vec![],\n as_of: \"old\".into(),\n });\n cache.refreshed = Some(now);\n cache.refresh_in_flight = false;\n assert!(!status_from_cache(&mut cache, Some(first), now, \"now\".into()).1);\n let (status, starts) = status_from_cache(&mut cache, Some(second), now, \"now\".into());\n assert!(starts);\n assert!(\n matches!(status, Status::Unavailable { reason, .. } if reason == \"refresh pending\")\n );\n let (status, starts) = status_from_cache(&mut cache, None, now, \"now\".into());\n assert!(!starts);\n assert!(\n matches!(status, Status::Unavailable { reason, .. } if reason == \"DeepSeek credentials are unavailable\")\n );\n }\n}\n"}}}}