From 5885a4af51115adea62f611fae7a8ae1eb3efa9b Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 15:55:52 +0700 Subject: [PATCH 1/7] Add Soniox usage provider Port the Soniox organization balance and monthly spend from upstream CodexBar (575a2446a). Reads the balance and monthly usage endpoints with SONIOX_ORG_API_KEY, shows the monthly budget as a spend meter when a budget is set, and lists the balance and spend under a Billing section. --- README.md | 1 + .../providers/icons/ProviderIcon-soniox.svg | 3 + .../providers/providerIcons.test.ts | 1 + .../src/components/providers/providerIcons.ts | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 35 ++ rust/src/core/provider/id.rs | 3 +- rust/src/core/provider/spec.rs | 8 +- rust/src/core/provider_factory.rs | 12 +- rust/src/core/provider_registry_snapshot.txt | 4 + rust/src/core/token_accounts.rs | 1 + rust/src/providers/mod.rs | 2 + rust/src/providers/soniox/mod.rs | 335 ++++++++++++++ rust/src/providers/soniox/tests.rs | 435 ++++++++++++++++++ rust/src/settings/api_keys.rs | 11 + 15 files changed, 848 insertions(+), 7 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-soniox.svg create mode 100644 rust/src/providers/soniox/mod.rs create mode 100644 rust/src/providers/soniox/tests.rs diff --git a/README.md b/README.md index b54847e5e1a..2f8df05dca3 100755 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ For Claude, browser cookies (the `sessionKey`) are preferred, because they match | FLORA | API Key + workspace ID | Workspace available balance, member limit, month-to-date spend | | Future AGI | API Key + secret key | Billing-period AI credits and gateway requests against free allowances, estimated cost | | Munito | Admin API Key | Today's and this month's organization spend against spend limits | +| Soniox | Organization API key | Prepaid USD balance, this month's spend against the monthly budget, billing method | | Claude Platform | Browser cookies / manual Cookie header | Console API credit and purchased credit, other grants, month-to-date spend | diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-soniox.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-soniox.svg new file mode 100644 index 00000000000..1389f5f230d --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-soniox.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index d9215bbff6a..1c301832c80 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -173,6 +173,7 @@ describe("provider icon registry", () => { "manus", "parallel", "replicate", + "soniox", "synthetic", "typesafe", "v0", diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 83eba181051..5638a86e7af 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -81,6 +81,7 @@ import replicate from "./icons/ProviderIcon-replicate.svg?raw"; import sailresearch from "./icons/ProviderIcon-sailresearch.svg?raw"; import sakana from "./icons/ProviderIcon-sakana.svg?raw"; import sofya from "./icons/ProviderIcon-sofya.svg?raw"; +import soniox from "./icons/ProviderIcon-soniox.svg?raw"; import stepfun from "./icons/ProviderIcon-stepfun.svg?raw"; import sub2api from "./icons/ProviderIcon-sub2api.svg?raw"; import synthetic from "./icons/ProviderIcon-synthetic.svg?raw"; @@ -198,6 +199,7 @@ const RAW: Record = { sailresearch: tint(sailresearch), sakana: tint(sakana), sofya: tint(sofya), + soniox: tint(soniox), stepfun: tint(stepfun), sub2api: tint(sub2api), synthetic: tint(synthetic), @@ -305,6 +307,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { codebuddy: { id: "codebuddy", brandColor: "#0052d9", fallbackLetter: "C" }, sakana: { id: "sakana", brandColor: "#2975db", fallbackLetter: "S", svgPath: RAW.sakana }, sofya: { id: "sofya", brandColor: "#b0b820", fallbackLetter: "S", svgPath: RAW.sofya }, + soniox: { id: "soniox", brandColor: "#253772", fallbackLetter: "S", svgPath: RAW.soniox }, stepfun: { id: "stepfun", brandColor: "#2196f2", fallbackLetter: "S", svgPath: RAW.stepfun }, sub2api: { id: "sub2api", brandColor: "#14b8a6", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index a95586ca598..550d6c639f3 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -107,5 +107,6 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["flora", "FLORA"], ["futureagi", "Future AGI"], ["munito", "Munito"], + ["soniox", "Soniox"], ["claudeplatform", "Claude Platform"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index d55efe6ac07..bf3e0933f25 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -715,6 +715,41 @@ and name are missing. an ISO 8601 timestamp; anything else fails parsing. Response bodies are never echoed in errors. +### Soniox + +Soniox is disabled by default. In Settings → Providers → Soniox, enter an +**organization API key** with the "Balance and spend" and "Usage and limits" +permissions, or set `SONIOX_ORG_API_KEY`. Project API keys cannot read +organization billing. Create keys in the +[organization API guide](https://soniox.com/docs/guides/organization-api#organization-api-keys). +The key is stored with the other API keys and is never written to logs. + +The provider makes two read-only requests in order. Refresh never submits +transcription or synthesis work: + +```text +GET https://api.soniox.com/v1/organization/balance +GET https://api.soniox.com/v1/organization/usage/monthly +Authorization: Bearer +``` + +- The **Spend** section shows **Monthly budget** as `$used of $budget` with a + progress bar when the budget is positive. The budget meter also feeds the tray + and float bar. Unlike upstream, it is not a hideable card row; the progress + row is always shown on the card. +- The **Billing** section shows **Prepaid** or **Invoice billing** (value `USD`). + With a zero budget it also shows **Monthly budget** as `$0.00`, and no progress + bar or percentage. +- Cost is this month's spend against the budget, reset at the start of the next + UTC month. Prepaid accounts also report the remaining balance. Invoice billing + accounts omit the balance so a zero prepaid amount is not shown as exhausted + credits. A negative prepaid balance is accepted but not shown. +- 401 reports that authentication is required. 403 reports that the key needs + organization permissions. 429 reports rate limiting. Other HTTP errors report + the status. Amounts must be plain decimal strings, `month` must be `YYYY-MM`, + and `invoice_billing_enabled` must be a boolean; anything else fails parsing. + Response bodies are never echoed in errors. + ### Claude Platform Claude Platform is disabled by default and shows the prepaid API credits of a diff --git a/rust/src/core/provider/id.rs b/rust/src/core/provider/id.rs index aa48a3144c7..9555f0c778c 100755 --- a/rust/src/core/provider/id.rs +++ b/rust/src/core/provider/id.rs @@ -130,6 +130,7 @@ pub enum ProviderId { FutureAgi, Munito, ClaudePlatform, + Soniox, } impl ProviderId { @@ -304,7 +305,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 111); + assert_eq!(all.len(), 112); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); diff --git a/rust/src/core/provider/spec.rs b/rust/src/core/provider/spec.rs index 8f119ab2b57..d03c761e05b 100644 --- a/rust/src/core/provider/spec.rs +++ b/rust/src/core/provider/spec.rs @@ -148,11 +148,11 @@ const fn spec( } } -const COUNT: usize = 111; +const COUNT: usize = 112; pub(crate) static ALL: [ProviderId; COUNT] = { // Indexing SPECS by discriminant relies on rows following declaration order. - assert!(P::ClaudePlatform as usize + 1 == COUNT); + assert!(P::Soniox as usize + 1 == COUNT); let mut ids = [P::Codex; COUNT]; let mut i = 0; while i < COUNT { @@ -778,4 +778,8 @@ pub(crate) static SPECS: [ProviderSpec; COUNT] = [ .status("https://status.claude.com/") .aliases(&["claude-platform"]) .parse_aliases(&["claude platform"]), + spec(P::Soniox, "soniox", "Soniox", "#253772") + .labels("Monthly budget", "Usage") + .dashboard("https://console.soniox.com") + .status("https://status.soniox.com"), ]; diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 6169a4cdde9..7ce3da345d4 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -42,10 +42,10 @@ use crate::providers::{ NousProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PiProvider, PoeProvider, QoderProvider, QwenCloudProvider, RaycastProvider, ReplicateProvider, SailResearchProvider, SakanaProvider, - SofyaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, TinyApiProvider, - TypeSafeProvider, V0Provider, VeniceProvider, VercelProvider, VertexAIProvider, WarpProvider, - WayfinderProvider, WindsurfProvider, XKiroProvider, XaiProvider, XapiProvider, ZaiProvider, - ZedProvider, ZenMuxProvider, ZoomMateProvider, + SofyaProvider, SonioxProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, + TinyApiProvider, TypeSafeProvider, V0Provider, VeniceProvider, VercelProvider, + VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, XKiroProvider, + XaiProvider, XapiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -164,6 +164,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Flora => Box::new(FloraProvider::new()), ProviderId::FutureAgi => Box::new(FutureAgiProvider::new()), ProviderId::Munito => Box::new(MunitoProvider::new()), + ProviderId::Soniox => Box::new(SonioxProvider::new()), ProviderId::ClaudePlatform => Box::new(ClaudePlatformProvider::new()), } } @@ -285,6 +286,7 @@ pub fn pace_capability(id: ProviderId) -> ProviderPaceCapability { | ProviderId::Flora | ProviderId::FutureAgi | ProviderId::Munito + | ProviderId::Soniox | ProviderId::ClaudePlatform => ProviderPaceCapability::UNSUPPORTED, } } @@ -406,6 +408,7 @@ pub fn limit_reset_policy(id: ProviderId) -> ProviderLimitResetPolicy { | ProviderId::Flora | ProviderId::FutureAgi | ProviderId::Munito + | ProviderId::Soniox | ProviderId::ClaudePlatform => ProviderLimitResetPolicy::DEFAULT, } } @@ -527,6 +530,7 @@ pub(crate) fn cost_cli_extension_for(id: ProviderId) -> Option<&'static dyn Cost | ProviderId::Flora | ProviderId::FutureAgi | ProviderId::Munito + | ProviderId::Soniox | ProviderId::ClaudePlatform => None, } } diff --git a/rust/src/core/provider_registry_snapshot.txt b/rust/src/core/provider_registry_snapshot.txt index d2be401361f..03a779462f3 100644 --- a/rust/src/core/provider_registry_snapshot.txt +++ b/rust/src/core/provider_registry_snapshot.txt @@ -442,3 +442,7 @@ ClaudePlatform cli=claudeplatform display="Claude Platform" cookie=Some("platfor map=["claude-platform", "claudeplatform"] metadata=ProviderMetadata { id: ClaudePlatform, display_name: "Claude Platform", session_label: "API credit", weekly_label: "Purchased credit", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://platform.claude.com/settings/billing"), status_page_url: Some("https://status.claude.com/"), tertiary_label_key: None } sources=[Auto, Web] oauth=false web=true cli=false +Soniox cli=soniox display="Soniox" cookie=None color=#253772 deprecated=false blocking=None + map=["soniox"] + metadata=ProviderMetadata { id: Soniox, display_name: "Soniox", session_label: "Monthly budget", weekly_label: "Usage", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.soniox.com"), status_page_url: Some("https://status.soniox.com"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 75c29505800..9b2fe260c04 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -386,6 +386,7 @@ impl TokenAccountSupport { | ProviderId::Flora | ProviderId::FutureAgi | ProviderId::Munito + | ProviderId::Soniox | ProviderId::ClaudePlatform => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index d39cd7cfc82..3a256229df2 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -101,6 +101,7 @@ pub mod replicate; pub mod sailresearch; pub mod sakana; pub mod sofya; +pub mod soniox; pub mod stepfun; pub mod sub2api; pub mod synthetic; @@ -216,6 +217,7 @@ pub use replicate::ReplicateProvider; pub use sailresearch::SailResearchProvider; pub use sakana::SakanaProvider; pub use sofya::SofyaProvider; +pub use soniox::SonioxProvider; pub use stepfun::StepFunProvider; pub use sub2api::Sub2ApiProvider; pub use synthetic::SyntheticProvider; diff --git a/rust/src/providers/soniox/mod.rs b/rust/src/providers/soniox/mod.rs new file mode 100644 index 00000000000..4643ee89ef3 --- /dev/null +++ b/rust/src/providers/soniox/mod.rs @@ -0,0 +1,335 @@ +//! Soniox organization billing. +//! +//! Ported from upstream CodexBar (`Resources/Plugins/soniox.ts`, +//! `Providers/Soniox/SonioxProviderDescriptor.swift`, upstream #4678). Reads two +//! read-only organization endpoints with an organization API key as a bearer +//! token: `GET https://api.soniox.com/v1/organization/balance` for the prepaid +//! USD balance and invoice billing state, then +//! `GET /v1/organization/usage/monthly` for this UTC month's USD usage and +//! budget. Refresh never submits transcription or synthesis work. Project API +//! keys cannot read organization billing. Response bodies are never echoed in +//! errors and the key is never logged. + +#[cfg(test)] +mod tests; + +use std::sync::LazyLock; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use regex_lite::Regex; +use reqwest::{Client, StatusCode, header::ACCEPT}; +use serde_json::{Map, Value}; + +use crate::core::{ + CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, + ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; +use crate::providers::{BoundedBodyError, format, read_bounded_response, resolve_api_key}; + +const BASE_URL: &str = "https://api.soniox.com/v1/organization"; +const CREDENTIAL_TARGET: &str = "codexbar-soniox"; +pub const API_KEY_ENV: &str = "SONIOX_ORG_API_KEY"; +const MAX_RESPONSE_BYTES: usize = 256 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const LOGIN_METHOD: &str = "Organization API key"; +const SPEND_SECTION: &str = "Spend"; +const BILLING_SECTION: &str = "Billing"; +const COST_PERIOD: &str = "This month"; +const MONTHLY_BUDGET: &str = "Monthly budget"; +const NO_BUDGET: &str = "No monthly budget"; + +const MISSING_KEY: &str = "Set a Soniox organization API key in Settings or SONIOX_ORG_API_KEY. Project API keys are not supported."; +const PERMISSION_DENIED: &str = "Soniox requires an organization API key with Balance and spend and Usage and limits access. Project API keys are not supported."; +const RATE_LIMITED: &str = "Soniox rate limit reached (HTTP 429). Wait a moment, then refresh."; + +static MONEY_TEXT: LazyLock = + LazyLock::new(|| Regex::new(r"^-?\d+(?:\.\d+)?$").expect("static money regex")); +static MONTH_TEXT: LazyLock = + LazyLock::new(|| Regex::new(r"^\d{4}-(0[1-9]|1[0-2])$").expect("static month regex")); + +pub struct SonioxProvider { + client: Client, + base_url: String, +} + +impl SonioxProvider { + pub fn new() -> Self { + Self::with_base_url(BASE_URL) + } + + fn with_base_url(base_url: &str) -> Self { + Self { + client: crate::core::credentialed_http_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| Client::new()), + base_url: base_url.trim_end_matches('/').to_owned(), + } + } + + async fn fetch_usage_impl( + &self, + ctx: &FetchContext, + ) -> Result { + let key = resolve_key(ctx.api_key.as_deref())?; + let billing = parse_billing(&self.get_object(&key, "balance").await?)?; + let monthly = parse_monthly(&self.get_object(&key, "usage/monthly").await?)?; + Ok(build_result(&billing, &monthly)) + } + + async fn get_object(&self, key: &str, path: &str) -> Result, ProviderError> { + let response = self + .client + .get(format!("{}/{path}", self.base_url)) + .bearer_auth(key) + .header(ACCEPT, "application/json") + .send() + .await?; + let status = response.status(); + if status != StatusCode::OK { + return Err(status_error(status)); + } + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::Read(error) => ProviderError::Network(error), + BoundedBodyError::TooLarge => invalid("response body"), + })?; + match serde_json::from_slice::(&body) { + Ok(Value::Object(object)) => Ok(object), + Ok(_) => Err(invalid("expected an object")), + Err(_) => Err(invalid("invalid JSON")), + } + } +} + +impl Default for SonioxProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for SonioxProvider { + fn id(&self) -> ProviderId { + ProviderId::Soniox + } + + fn metadata(&self) -> &ProviderMetadata { + &SONIOX_METADATA + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_impl(ctx).await, + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +/// Settings key, then the keyring, then `SONIOX_ORG_API_KEY`. +fn resolve_key(explicit: Option<&str>) -> Result { + resolve_api_key(explicit, CREDENTIAL_TARGET, &[API_KEY_ENV]) + .map_err(|_| ProviderError::NotInstalled(MISSING_KEY.into())) +} + +fn status_error(status: StatusCode) -> ProviderError { + let code = status.as_u16(); + match code { + 401 => ProviderError::AuthRequired, + 403 => ProviderError::Other(PERMISSION_DENIED.into()), + 429 => ProviderError::Other(RATE_LIMITED.into()), + 500..=599 => ProviderError::Other(format!( + "Soniox is temporarily unavailable (HTTP {code}). Try again later or check https://status.soniox.com." + )), + _ => ProviderError::Other(format!( + "Soniox billing request failed (HTTP {code}). Refresh to try again." + )), + } +} + +fn invalid(field: &str) -> ProviderError { + ProviderError::Parse(format!( + "Soniox returned invalid billing data: {field}. Refresh to try again. If this persists, update CodexBar or report the issue." + )) +} + +/// A decimal string such as `"18.50"`, the only money encoding Soniox uses. +/// Negative values are accepted only where the field may be signed. +fn money(value: Option<&Value>, field: &str, signed: bool) -> Result { + let text = value + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| MONEY_TEXT.is_match(text)) + .ok_or_else(|| invalid(field))?; + let amount: f64 = text.parse().map_err(|_| invalid(field))?; + if !amount.is_finite() || (!signed && amount < 0.0) { + return Err(invalid(field)); + } + Ok(amount) +} + +struct Billing { + available: f64, + invoice_billing: bool, +} + +fn parse_billing(body: &Map) -> Result { + let available = money( + body.get("available_balance_usd"), + "available_balance_usd", + true, + )?; + let invoice_billing = body + .get("invoice_billing_enabled") + .and_then(Value::as_bool) + .ok_or_else(|| invalid("invoice_billing_enabled"))?; + Ok(Billing { + available, + invoice_billing, + }) +} + +struct Monthly { + used: f64, + budget: f64, + resets_at: DateTime, +} + +fn parse_monthly(body: &Map) -> Result { + let used = money(body.get("usage_usd"), "usage_usd", false)?; + let budget = money(body.get("monthly_budget_usd"), "monthly_budget_usd", false)?; + let month = body + .get("month") + .and_then(Value::as_str) + .filter(|month| MONTH_TEXT.is_match(month)) + .ok_or_else(|| invalid("month"))?; + Ok(Monthly { + used, + budget, + resets_at: next_month_start(month)?, + }) +} + +/// First instant of the UTC month after `YYYY-MM`, the monthly reset. +fn next_month_start(month: &str) -> Result, ProviderError> { + let (year, number) = month.split_once('-').ok_or_else(|| invalid("month"))?; + let year: i32 = year.parse().map_err(|_| invalid("month"))?; + let number: u32 = number.parse().map_err(|_| invalid("month"))?; + if !(1..=12).contains(&number) { + return Err(invalid("month")); + } + let (year, number) = if number == 12 { + (year + 1, 1) + } else { + (year, number + 1) + }; + Utc.with_ymd_and_hms(year, number, 1, 0, 0, 0) + .single() + .ok_or_else(|| invalid("month")) +} + +/// Upstream `ctx.pct`: clamped to 0...100. +fn percent(used: f64, budget: f64) -> f64 { + (used / budget * 100.0).clamp(0.0, 100.0) +} + +fn spend_row(id: &str, title: &str, value: String) -> Option { + ProviderDisplayDetail::new(id, title, value) + .and_then(|row| row.with_section_title(SPEND_SECTION)) +} + +fn billing_row(id: &str, title: &str, value: &str) -> Option { + ProviderDisplayDetail::new(id, title, value) + .and_then(|row| row.with_section_title(BILLING_SECTION)) +} + +fn build_result(billing: &Billing, monthly: &Monthly) -> ProviderFetchResult { + let Monthly { + used, + budget, + resets_at, + } = *monthly; + + // Upstream shows the budget meter only when a budget is set, and hides it + // from the card by default. Windows has no default-hidden usage item, so + // the meter stays off the card (it still feeds the tray and float bar) and + // the card shows the same numbers as a progress detail row instead. + let primary = if budget > 0.0 { + RateWindow::with_details( + percent(used, budget), + None, + Some(resets_at), + Some(format!("{} / {}", format::usd(used), format::usd(budget))), + ) + .hidden_on_card() + } else { + RateWindow::informational(NO_BUDGET).hidden_on_card() + }; + let usage = UsageSnapshot::new(primary).with_login_method(LOGIN_METHOD); + + let mut rows = Vec::new(); + if budget > 0.0 { + rows.extend( + spend_row( + "monthly-budget", + MONTHLY_BUDGET, + format!("{} of {}", format::usd(used), format::usd(budget)), + ) + .and_then(|row| row.with_progress(used.min(budget), budget)), + ); + } + let billing_method = if billing.invoice_billing { + "Invoice billing" + } else { + "Prepaid" + }; + rows.extend(billing_row("billing-method", billing_method, "USD")); + if budget <= 0.0 { + rows.extend(billing_row( + "billing-monthly-budget", + MONTHLY_BUDGET, + &format::usd(budget), + )); + } + + let cost = CostSnapshot::new(used, "USD", COST_PERIOD) + .with_limit(budget) + .with_resets_at(resets_at); + // Invoice accounts show spend only; a zero prepaid balance there would + // read as exhausted credits. A negative prepaid balance is omitted too, + // because CostSnapshot balances are non-negative and clamping it to zero + // would also read as exhausted credits. + let cost = if billing.invoice_billing || billing.available < 0.0 { + cost + } else { + cost.with_balance(billing.available) + }; + + ProviderFetchResult::new(usage, "api") + .with_display_details(rows) + .with_cost(cost) +} + +const SONIOX_METADATA: ProviderMetadata = ProviderMetadata { + id: ProviderId::Soniox, + display_name: "Soniox", + session_label: "Monthly budget", + weekly_label: "Usage", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://console.soniox.com"), + status_page_url: Some("https://status.soniox.com"), + tertiary_label_key: None, +}; diff --git a/rust/src/providers/soniox/tests.rs b/rust/src/providers/soniox/tests.rs new file mode 100644 index 00000000000..554db483545 --- /dev/null +++ b/rust/src/providers/soniox/tests.rs @@ -0,0 +1,435 @@ +//! Fixture tests for the Soniox organization billing provider, ported from +//! upstream `TestsPlugin/SonioxPluginTests.swift` and +//! `Tests/CodexBarTests/SonioxProviderTests.swift`. Fetch tests use an injected +//! API key and a local mock server, so no test reads the keyring, the +//! environment, or the real Soniox API. + +use super::*; +use mockito::{Matcher, Server, ServerGuard}; + +const KEY: &str = "fixture-key"; +const BALANCE_PATH: &str = "/v1/organization/balance"; +const MONTHLY_PATH: &str = "/v1/organization/usage/monthly"; + +/// Synthetic bodies in the documented Soniox shape. +const BALANCE_FIXTURE: &str = + r#"{"available_balance_usd":"18.50","invoice_billing_enabled":false}"#; +const MONTHLY_FIXTURE: &str = + r#"{"month":"2026-10","usage_usd":"6.00","monthly_budget_usd":"100.00"}"#; + +fn context(source_mode: SourceMode) -> FetchContext { + FetchContext { + source_mode, + api_key: Some(KEY.to_string()), + ..FetchContext::default() + } +} + +async fn serve(balance: (usize, &str), monthly: (usize, &str)) -> ServerGuard { + let mut server = Server::new_async().await; + for (path, (code, body)) in [(BALANCE_PATH, balance), (MONTHLY_PATH, monthly)] { + server + .mock("GET", path) + .match_header("authorization", format!("Bearer {KEY}").as_str()) + .match_header("cookie", Matcher::Missing) + .with_status(code) + .with_header("content-type", "application/json") + .with_body(body) + .create_async() + .await; + } + server +} + +async fn fetch_with( + balance: (usize, &str), + monthly: (usize, &str), +) -> Result { + let server = serve(balance, monthly).await; + SonioxProvider::with_base_url(&format!("{}/v1/organization", server.url())) + .fetch_usage(&context(SourceMode::Auto)) + .await +} + +async fn fetch_bodies(balance: &str, monthly: &str) -> Result { + fetch_with((200, balance), (200, monthly)).await +} + +async fn fetch() -> ProviderFetchResult { + fetch_bodies(BALANCE_FIXTURE, MONTHLY_FIXTURE) + .await + .expect("fixture fetch succeeds") +} + +fn sections(result: &ProviderFetchResult) -> Vec { + let mut titles: Vec = Vec::new(); + for detail in result.display_details() { + let title = detail.section_title().unwrap_or_default().to_string(); + if !titles.contains(&title) { + titles.push(title); + } + } + titles +} + +fn rows_in(result: &ProviderFetchResult, section: &str) -> Vec<(String, String)> { + result + .display_details() + .iter() + .filter(|detail| detail.section_title() == Some(section)) + .map(|detail| (detail.title().to_string(), detail.value().to_string())) + .collect() +} + +fn utc(year: i32, month: u32, day: u32) -> DateTime { + Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) + .single() + .expect("valid fixture date") +} + +#[test] +fn metadata_and_source_contract_match_the_api_key_provider() { + let provider = SonioxProvider::new(); + assert_eq!(provider.id(), ProviderId::Soniox); + assert_eq!(provider.metadata().display_name, "Soniox"); + assert_eq!(provider.metadata().session_label, "Monthly budget"); + assert_eq!(provider.metadata().weekly_label, "Usage"); + assert_eq!( + provider.metadata().dashboard_url, + Some("https://console.soniox.com") + ); + assert_eq!( + provider.metadata().status_page_url, + Some("https://status.soniox.com") + ); + assert!(!provider.metadata().default_enabled); + assert_eq!( + provider.available_sources(), + vec![SourceMode::Auto, SourceMode::OAuth] + ); + assert_eq!(ProviderId::Soniox.cookie_domain(), None); + assert_eq!( + ProviderId::from_cli_name("soniox"), + Some(ProviderId::Soniox) + ); + assert_eq!( + ProviderId::from_cli_name("SONIOX"), + Some(ProviderId::Soniox) + ); +} + +#[tokio::test] +async fn budget_meter_and_prepaid_balance_stay_separate() { + let result = fetch().await; + + let primary = &result.usage.primary; + assert!(!primary.is_informational); + assert!(primary.hidden_on_card); + assert_eq!(primary.used_percent, 6.0); + assert_eq!(primary.resets_at, Some(utc(2026, 11, 1))); + assert_eq!( + primary.reset_description.as_deref(), + Some("$6.00 / $100.00") + ); + assert!(result.usage.secondary.is_none()); + + let cost = result.cost.as_ref().expect("cost"); + assert_eq!(cost.used, 6.0); + assert_eq!(cost.limit, Some(100.0)); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.period, "This month"); + assert_eq!(cost.resets_at, primary.resets_at); + assert_eq!(cost.balance, Some(18.5)); + + assert_eq!(sections(&result), vec!["Spend", "Billing"]); + assert_eq!( + rows_in(&result, "Spend"), + vec![("Monthly budget".into(), "$6.00 of $100.00".into())] + ); + let progress = result.display_details()[0] + .progress() + .expect("budget progress"); + assert!((progress.used() / progress.total() * 100.0 - 6.0).abs() < 0.000_001); + assert_eq!( + rows_in(&result, "Billing"), + vec![("Prepaid".into(), "USD".into())] + ); + + assert_eq!( + result.usage.login_method.as_deref(), + Some("Organization API key") + ); + assert_eq!(result.usage.account_email, None); + assert_eq!(result.account_identity(), None); + assert_eq!(result.source_label, "api"); +} + +#[tokio::test] +async fn invoice_billing_does_not_present_zero_prepaid_credits_as_exhausted() { + let result = fetch_bodies( + r#"{"available_balance_usd":"0","invoice_billing_enabled":true}"#, + MONTHLY_FIXTURE, + ) + .await + .expect("invoice fetch succeeds"); + + assert_eq!(result.usage.primary.used_percent, 6.0); + let cost = result.cost.as_ref().expect("cost"); + assert_eq!(cost.used, 6.0); + assert_eq!(cost.balance, None); + assert_eq!( + rows_in(&result, "Billing"), + vec![("Invoice billing".into(), "USD".into())] + ); +} + +#[tokio::test] +async fn zero_and_subcent_prepaid_balances_are_kept() { + for amount in ["0", "0.001"] { + let balance = + format!(r#"{{"available_balance_usd":"{amount}","invoice_billing_enabled":false}}"#); + let result = fetch_bodies(&balance, MONTHLY_FIXTURE) + .await + .expect("balance fetch succeeds"); + assert_eq!( + result.cost.as_ref().and_then(|cost| cost.balance), + Some(amount.parse::().expect("fixture amount")), + "{amount}" + ); + } +} + +#[tokio::test] +async fn negative_prepaid_balance_is_accepted_but_not_shown_as_credits() { + // Upstream displays a negative balance. The shared CostSnapshot balance is + // non-negative, so the card omits it instead of inventing a value. + let result = fetch_bodies( + r#"{"available_balance_usd":"-0.50","invoice_billing_enabled":false}"#, + MONTHLY_FIXTURE, + ) + .await + .expect("negative balance is not a parse failure"); + assert_eq!(result.cost.as_ref().and_then(|cost| cost.balance), None); + assert_eq!(result.usage.primary.used_percent, 6.0); +} + +#[tokio::test] +async fn zero_budget_keeps_spend_without_a_percentage() { + let result = fetch_bodies( + BALANCE_FIXTURE, + r#"{"month":"2026-10","usage_usd":"0","monthly_budget_usd":"0"}"#, + ) + .await + .expect("zero budget fetch succeeds"); + + assert!(result.usage.primary.is_informational); + assert!(result.usage.primary.hidden_on_card); + let cost = result.cost.as_ref().expect("cost"); + assert_eq!(cost.used, 0.0); + assert_eq!(cost.limit, Some(0.0)); + assert_eq!( + rows_in(&result, "Spend"), + Vec::<(String, String)>::new(), + "no budget progress row without a budget" + ); + assert_eq!( + rows_in(&result, "Billing"), + vec![ + ("Prepaid".into(), "USD".into()), + ("Monthly budget".into(), "$0.00".into()), + ] + ); +} + +#[tokio::test] +async fn exceeded_budget_clamps_the_percentage_and_keeps_the_true_spend() { + let result = fetch_bodies( + BALANCE_FIXTURE, + r#"{"month":"2026-10","usage_usd":"100.25","monthly_budget_usd":"100"}"#, + ) + .await + .expect("exceeded budget fetch succeeds"); + + assert_eq!(result.usage.primary.used_percent, 100.0); + assert_eq!(result.cost.as_ref().map(|cost| cost.used), Some(100.25)); + let progress = result.display_details()[0] + .progress() + .expect("budget progress"); + assert_eq!(progress.used(), 100.0); + assert_eq!(progress.total(), 100.0); +} + +#[tokio::test] +async fn reset_rolls_over_the_year_and_leap_day_boundaries() { + for (month, expected) in [("2026-12", utc(2027, 1, 1)), ("2028-02", utc(2028, 3, 1))] { + let monthly = + format!(r#"{{"month":"{month}","usage_usd":"0","monthly_budget_usd":"100"}}"#); + let result = fetch_bodies(BALANCE_FIXTURE, &monthly) + .await + .expect("reset fetch succeeds"); + assert_eq!(result.usage.primary.resets_at, Some(expected), "{month}"); + } +} + +#[tokio::test] +async fn malformed_billing_is_rejected_rather_than_reported_as_zero_usage() { + let cases: [(&str, &str, &str); 10] = [ + ("balance", "not-json", "invalid JSON"), + ("balance", "[]", "expected an object"), + ( + "balance", + r#"{"available_balance_usd":18.5,"invoice_billing_enabled":false}"#, + "available_balance_usd", + ), + ( + "balance", + r#"{"available_balance_usd":"","invoice_billing_enabled":false}"#, + "available_balance_usd", + ), + ( + "balance", + r#"{"available_balance_usd":"NaN","invoice_billing_enabled":false}"#, + "available_balance_usd", + ), + ( + "balance", + r#"{"available_balance_usd":"18.5","invoice_billing_enabled":"false"}"#, + "invoice_billing_enabled", + ), + ("monthly", "{}", "usage_usd"), + ( + "monthly", + r#"{"month":"2026-10","usage_usd":"-1","monthly_budget_usd":"100"}"#, + "usage_usd", + ), + ( + "monthly", + r#"{"month":"2026-10","usage_usd":"6","monthly_budget_usd":null}"#, + "monthly_budget_usd", + ), + ( + "monthly", + r#"{"month":"2026-13","usage_usd":"6","monthly_budget_usd":"100"}"#, + "month", + ), + ]; + for (endpoint, body, field) in cases { + let (balance, monthly) = if endpoint == "balance" { + (body, MONTHLY_FIXTURE) + } else { + (BALANCE_FIXTURE, body) + }; + let error = fetch_bodies(balance, monthly) + .await + .expect_err("malformed billing must fail"); + let message = error.to_string(); + assert!( + message.contains(&format!("invalid billing data: {field}.")), + "{endpoint} {body}: {message}" + ); + assert!(message.contains("Refresh to try again"), "{message}"); + assert!( + message.contains("update CodexBar or report the issue"), + "{message}" + ); + assert!(matches!(error, ProviderError::Parse(_)), "{message}"); + } +} + +#[tokio::test] +async fn http_failures_are_classified_without_echoing_response_bodies() { + let private = r#"{"detail":"private-response-marker"}"#; + for endpoint in ["balance", "monthly"] { + for status in [401, 403, 429, 500, 503, 400, 404] { + let (balance, monthly) = if endpoint == "balance" { + ((status, private), (200, MONTHLY_FIXTURE)) + } else { + ((200, BALANCE_FIXTURE), (status, private)) + }; + let error = fetch_with(balance, monthly) + .await + .expect_err("HTTP failure must surface"); + let message = error.to_string(); + assert!(!message.contains("private-response-marker"), "{message}"); + match status { + 401 => assert!(matches!(error, ProviderError::AuthRequired), "{message}"), + 403 => assert_eq!(message, PERMISSION_DENIED), + 429 => assert_eq!(message, RATE_LIMITED), + 500..=599 => assert_eq!( + message, + format!( + "Soniox is temporarily unavailable (HTTP {status}). Try again later or check https://status.soniox.com." + ) + ), + _ => assert_eq!( + message, + format!("Soniox billing request failed (HTTP {status}). Refresh to try again.") + ), + } + } + } +} + +#[tokio::test] +async fn a_failed_balance_read_stops_before_the_monthly_request() { + // The monthly mock would succeed, so a surfaced 401 proves the balance + // error decides the outcome and the monthly read was never needed. + let error = fetch_with((401, "{}"), (200, MONTHLY_FIXTURE)) + .await + .expect_err("balance 401 must surface"); + assert!(matches!(error, ProviderError::AuthRequired)); +} + +#[tokio::test] +async fn web_and_cli_sources_are_unsupported_and_oauth_uses_the_key_lane() { + let provider = SonioxProvider::new(); + for source in [SourceMode::Web, SourceMode::Cli] { + let error = provider + .fetch_usage(&context(source)) + .await + .expect_err("unsupported source"); + assert!(matches!(error, ProviderError::UnsupportedSource(_))); + } + + let server = serve((200, BALANCE_FIXTURE), (200, MONTHLY_FIXTURE)).await; + let oauth = SonioxProvider::with_base_url(&format!("{}/v1/organization", server.url())) + .fetch_usage(&context(SourceMode::OAuth)) + .await + .expect("oauth uses the same key lane"); + assert_eq!(oauth.source_label, "api"); +} + +#[test] +fn explicit_key_is_trimmed_and_used_before_any_lookup() { + assert_eq!( + resolve_key(Some(" snx_org_fixture ")).expect("explicit key"), + "snx_org_fixture" + ); +} + +#[test] +fn money_accepts_only_plain_decimal_strings() { + let body = |value: &str| serde_json::json!({ "usage_usd": value }); + assert_eq!( + money(body("6.00").get("usage_usd"), "usage_usd", false).ok(), + Some(6.0) + ); + assert_eq!( + money(body(" 6 ").get("usage_usd"), "usage_usd", false).ok(), + Some(6.0) + ); + assert!(money(body("1e3").get("usage_usd"), "usage_usd", false).is_err()); + assert!(money(body("+1").get("usage_usd"), "usage_usd", false).is_err()); + assert!(money(body("-1").get("usage_usd"), "usage_usd", false).is_err()); + assert_eq!( + money(body("-1").get("usage_usd"), "usage_usd", true).ok(), + Some(-1.0) + ); +} + +#[test] +fn next_month_start_handles_december_and_leap_february() { + assert_eq!(next_month_start("2026-12").ok(), Some(utc(2027, 1, 1))); + assert_eq!(next_month_start("2028-02").ok(), Some(utc(2028, 3, 1))); + assert!(next_month_start("2026-00").is_err()); +} diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 1e8e048deb5..f05e04613f9 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -765,6 +765,17 @@ pub fn get_api_key_providers() -> Vec { ), dashboard_url: Some("https://console.munito.ai/keys"), }, + ProviderConfigInfo { + id: ProviderId::Soniox, + name: "Soniox", + api_key_env_var: Some("SONIOX_ORG_API_KEY"), + api_key_help: Some( + "Organization API key with Balance and spend and Usage and limits access. Project API keys are not supported. Or set SONIOX_ORG_API_KEY.", + ), + dashboard_url: Some( + "https://soniox.com/docs/guides/organization-api#organization-api-keys", + ), + }, ProviderConfigInfo { id: ProviderId::Cosmic, name: "Cosmic AI", From dcff7abd79b896c6d132c40e41907c5d494fe0d5 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 17:06:56 +0700 Subject: [PATCH 2/7] Add SONIOX_BASE_URL override for proof runs --- docs/PROVIDERS.md | 5 ++ rust/src/providers/soniox/mod.rs | 84 +++++++++++++++++++++++++++++- rust/src/providers/soniox/tests.rs | 54 +++++++++++++++++++ rust/src/settings/api_keys.rs | 2 +- 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index bf3e0933f25..44c0c1bb485 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -724,6 +724,11 @@ organization billing. Create keys in the [organization API guide](https://soniox.com/docs/guides/organization-api#organization-api-keys). The key is stored with the other API keys and is never written to logs. +For proof runs and fake servers, `SONIOX_BASE_URL` replaces the organization API +base (default `https://api.soniox.com/v1/organization`). It accepts `https://`, +or `http://` only for a loopback host (`127.0.0.1`, `localhost`, `[::1]`), with +no credentials, query, or fragment. Any other value is ignored with a warning. + The provider makes two read-only requests in order. Refresh never submits transcription or synthesis work: diff --git a/rust/src/providers/soniox/mod.rs b/rust/src/providers/soniox/mod.rs index 4643ee89ef3..a885002987d 100644 --- a/rust/src/providers/soniox/mod.rs +++ b/rust/src/providers/soniox/mod.rs @@ -29,6 +29,9 @@ use crate::core::{ use crate::providers::{BoundedBodyError, format, read_bounded_response, resolve_api_key}; const BASE_URL: &str = "https://api.soniox.com/v1/organization"; +/// Optional override for the organization API base (proof runs and fake servers). +/// Accepts `https://` or loopback `http://` only; anything else is ignored. +const BASE_URL_ENV: &str = "SONIOX_BASE_URL"; const CREDENTIAL_TARGET: &str = "codexbar-soniox"; pub const API_KEY_ENV: &str = "SONIOX_ORG_API_KEY"; const MAX_RESPONSE_BYTES: usize = 256 * 1024; @@ -56,7 +59,8 @@ pub struct SonioxProvider { impl SonioxProvider { pub fn new() -> Self { - Self::with_base_url(BASE_URL) + let override_url = std::env::var(BASE_URL_ENV).ok(); + Self::with_base_url(&base_url_from_override(override_url.as_deref())) } fn with_base_url(base_url: &str) -> Self { @@ -136,6 +140,84 @@ impl Provider for SonioxProvider { } } +/// The organization API base: a valid `SONIOX_BASE_URL` override, otherwise +/// production. An invalid override is ignored with a warning. +fn base_url_from_override(raw: Option<&str>) -> String { + let Some(raw) = raw.map(clean_override).filter(|raw| !raw.is_empty()) else { + return BASE_URL.to_owned(); + }; + match validated_base_url(&raw) { + Some(base) => base, + None => { + tracing::warn!( + "Ignoring {BASE_URL_ENV}: use an https:// URL or a loopback http:// URL without credentials, query, or fragment" + ); + BASE_URL.to_owned() + } + } +} + +fn clean_override(raw: &str) -> String { + let value = raw.trim(); + let unquoted = value + .strip_prefix('"') + .and_then(|inner| inner.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|inner| inner.strip_suffix('\'')) + }) + .unwrap_or(value); + unquoted.trim().to_owned() +} + +/// Same policy as the Sub2API base URL: `https://`, or `http://` only for a +/// loopback host, with no userinfo, query, fragment, or encoded host delimiters. +/// Returns the URL without a trailing slash. +fn validated_base_url(raw: &str) -> Option { + let lower = raw.to_ascii_lowercase(); + if ["%2f", "%5c", "%3f", "%23", "%40", "%3a"] + .iter() + .any(|encoded| lower.contains(encoded)) + { + return None; + } + let url = reqwest::Url::parse(raw).ok()?; + let host = url.host_str()?; + let scheme_ok = match url.scheme() { + "https" => true, + "http" => is_loopback_host(host), + _ => false, + }; + if !scheme_ok + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || host.contains('%') + || host.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return None; + } + Some(url.as_str().trim_end_matches('/').to_owned()) +} + +fn is_loopback_host(host: &str) -> bool { + let normalized = host.trim_end_matches('.').to_ascii_lowercase(); + if normalized == "localhost" { + return true; + } + let ip_candidate = normalized + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(&normalized); + match ip_candidate.parse::() { + Ok(std::net::IpAddr::V4(ip)) => ip.is_loopback(), + Ok(std::net::IpAddr::V6(ip)) => ip.is_loopback(), + Err(_) => false, + } +} + /// Settings key, then the keyring, then `SONIOX_ORG_API_KEY`. fn resolve_key(explicit: Option<&str>) -> Result { resolve_api_key(explicit, CREDENTIAL_TARGET, &[API_KEY_ENV]) diff --git a/rust/src/providers/soniox/tests.rs b/rust/src/providers/soniox/tests.rs index 554db483545..d2ad94f02e3 100644 --- a/rust/src/providers/soniox/tests.rs +++ b/rust/src/providers/soniox/tests.rs @@ -433,3 +433,57 @@ fn next_month_start_handles_december_and_leap_february() { assert_eq!(next_month_start("2028-02").ok(), Some(utc(2028, 3, 1))); assert!(next_month_start("2026-00").is_err()); } + +#[test] +fn base_url_override_accepts_https_and_loopback_http() { + assert_eq!( + validated_base_url("https://api.soniox.com/v1/organization").as_deref(), + Some("https://api.soniox.com/v1/organization") + ); + assert_eq!( + validated_base_url("http://127.0.0.1:4010/v1/organization/").as_deref(), + Some("http://127.0.0.1:4010/v1/organization") + ); + assert_eq!( + validated_base_url("http://localhost:9/v1/organization").as_deref(), + Some("http://localhost:9/v1/organization") + ); + assert_eq!( + validated_base_url("http://[::1]:9/v1/organization").as_deref(), + Some("http://[::1]:9/v1/organization") + ); +} + +#[test] +fn base_url_override_rejects_remote_plain_http_and_unsafe_forms() { + for raw in [ + "http://api.soniox.com/v1/organization", + "http://localhost.attacker.test/v1/organization", + "ftp://127.0.0.1/v1/organization", + "https://user:pass@api.soniox.com/v1/organization", + "https://api.soniox.com/v1/organization?x=1", + "https://api.soniox.com/v1/organization#frag", + "http://127.0.0.1%2f.attacker.test/v1/organization", + "not a url", + "", + ] { + assert!( + validated_base_url(raw).is_none(), + "{raw} should be rejected" + ); + } +} + +#[test] +fn base_url_override_falls_back_to_production_when_unset_or_invalid() { + assert_eq!(base_url_from_override(None), BASE_URL); + assert_eq!(base_url_from_override(Some(" ")), BASE_URL); + assert_eq!( + base_url_from_override(Some("http://attacker.example.com/v1/organization")), + BASE_URL + ); + assert_eq!( + base_url_from_override(Some("\"http://127.0.0.1:4010/v1/organization\"")), + "http://127.0.0.1:4010/v1/organization" + ); +} diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index f05e04613f9..8b8993ef774 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -770,7 +770,7 @@ pub fn get_api_key_providers() -> Vec { name: "Soniox", api_key_env_var: Some("SONIOX_ORG_API_KEY"), api_key_help: Some( - "Organization API key with Balance and spend and Usage and limits access. Project API keys are not supported. Or set SONIOX_ORG_API_KEY.", + "Organization API key with Balance and spend and Usage and limits access. Project API keys are not supported. Or set SONIOX_ORG_API_KEY. Optional base URL override: SONIOX_BASE_URL.", ), dashboard_url: Some( "https://soniox.com/docs/guides/organization-api#organization-api-keys", From 9a535850448a0f0b667df4b2dc7acbc9ea468477 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 03:02:15 +0700 Subject: [PATCH 3/7] Correct the Soniox budget card visibility note --- docs/PROVIDERS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 44c0c1bb485..a379f623493 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -739,9 +739,10 @@ Authorization: Bearer ``` - The **Spend** section shows **Monthly budget** as `$used of $budget` with a - progress bar when the budget is positive. The budget meter also feeds the tray - and float bar. Unlike upstream, it is not a hideable card row; the progress - row is always shown on the card. + progress bar when the budget is positive. The budget meter itself is hidden on + the card and only feeds the tray and float-bar percentages. Upstream hides it + from the card by default; Windows has no default-hidden usage item, so the + card shows the same numbers as this Spend detail row instead. - The **Billing** section shows **Prepaid** or **Invoice billing** (value `USD`). With a zero budget it also shows **Monthly budget** as `$0.00`, and no progress bar or percentage. From 006ac7f7ac39294bcc437be9c98f9538fca0c48d Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 03:41:45 +0700 Subject: [PATCH 4/7] Retrigger CI after runner out-of-memory From 2e6d9f10299bdbb6798b6d82c250b090c15b8120 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 04:07:40 +0700 Subject: [PATCH 5/7] Warn when SONIOX_BASE_URL is blank --- rust/src/providers/soniox/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rust/src/providers/soniox/mod.rs b/rust/src/providers/soniox/mod.rs index a885002987d..289f45c051a 100644 --- a/rust/src/providers/soniox/mod.rs +++ b/rust/src/providers/soniox/mod.rs @@ -141,12 +141,15 @@ impl Provider for SonioxProvider { } /// The organization API base: a valid `SONIOX_BASE_URL` override, otherwise -/// production. An invalid override is ignored with a warning. +/// production. An invalid or blank override is ignored with a warning. fn base_url_from_override(raw: Option<&str>) -> String { - let Some(raw) = raw.map(clean_override).filter(|raw| !raw.is_empty()) else { + let Some(raw) = raw.map(clean_override) else { return BASE_URL.to_owned(); }; - match validated_base_url(&raw) { + match Some(raw.as_str()) + .filter(|raw| !raw.is_empty()) + .and_then(validated_base_url) + { Some(base) => base, None => { tracing::warn!( From 5bc5c90e4a012ea8b41bd8130e2f9a12b538f3ae Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 04:20:58 +0700 Subject: [PATCH 6/7] Require the organization path in SONIOX_BASE_URL --- docs/PROVIDERS.md | 3 ++- rust/src/providers/soniox/mod.rs | 9 ++++++++- rust/src/providers/soniox/tests.rs | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index a379f623493..9d30ec6bdb9 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -727,7 +727,8 @@ The key is stored with the other API keys and is never written to logs. For proof runs and fake servers, `SONIOX_BASE_URL` replaces the organization API base (default `https://api.soniox.com/v1/organization`). It accepts `https://`, or `http://` only for a loopback host (`127.0.0.1`, `localhost`, `[::1]`), with -no credentials, query, or fragment. Any other value is ignored with a warning. +no credentials, query, or fragment. The path must end in `/v1/organization`. +Any other value, including a blank one, is ignored with a warning. The provider makes two read-only requests in order. Refresh never submits transcription or synthesis work: diff --git a/rust/src/providers/soniox/mod.rs b/rust/src/providers/soniox/mod.rs index 289f45c051a..a56917f9df8 100644 --- a/rust/src/providers/soniox/mod.rs +++ b/rust/src/providers/soniox/mod.rs @@ -32,6 +32,7 @@ const BASE_URL: &str = "https://api.soniox.com/v1/organization"; /// Optional override for the organization API base (proof runs and fake servers). /// Accepts `https://` or loopback `http://` only; anything else is ignored. const BASE_URL_ENV: &str = "SONIOX_BASE_URL"; +const ORGANIZATION_PATH: &str = "/v1/organization"; const CREDENTIAL_TARGET: &str = "codexbar-soniox"; pub const API_KEY_ENV: &str = "SONIOX_ORG_API_KEY"; const MAX_RESPONSE_BYTES: usize = 256 * 1024; @@ -153,7 +154,7 @@ fn base_url_from_override(raw: Option<&str>) -> String { Some(base) => base, None => { tracing::warn!( - "Ignoring {BASE_URL_ENV}: use an https:// URL or a loopback http:// URL without credentials, query, or fragment" + "Ignoring {BASE_URL_ENV}: use an https:// URL or a loopback http:// URL ending in {ORGANIZATION_PATH}, without credentials, query, or fragment" ); BASE_URL.to_owned() } @@ -176,6 +177,8 @@ fn clean_override(raw: &str) -> String { /// Same policy as the Sub2API base URL: `https://`, or `http://` only for a /// loopback host, with no userinfo, query, fragment, or encoded host delimiters. +/// The path must end in `/v1/organization`, because requests append only the +/// endpoint name (`/balance`, `/usage/monthly`). /// Returns the URL without a trailing slash. fn validated_base_url(raw: &str) -> Option { let lower = raw.to_ascii_lowercase(); @@ -199,6 +202,10 @@ fn validated_base_url(raw: &str) -> Option { || url.fragment().is_some() || host.contains('%') || host.chars().any(|c| c.is_control() || c.is_whitespace()) + || !url + .path() + .trim_end_matches('/') + .ends_with(ORGANIZATION_PATH) { return None; } diff --git a/rust/src/providers/soniox/tests.rs b/rust/src/providers/soniox/tests.rs index d2ad94f02e3..9dcf2b5ab80 100644 --- a/rust/src/providers/soniox/tests.rs +++ b/rust/src/providers/soniox/tests.rs @@ -452,6 +452,10 @@ fn base_url_override_accepts_https_and_loopback_http() { validated_base_url("http://[::1]:9/v1/organization").as_deref(), Some("http://[::1]:9/v1/organization") ); + assert_eq!( + validated_base_url("http://127.0.0.1:9/proxy/v1/organization").as_deref(), + Some("http://127.0.0.1:9/proxy/v1/organization") + ); } #[test] @@ -464,6 +468,9 @@ fn base_url_override_rejects_remote_plain_http_and_unsafe_forms() { "https://api.soniox.com/v1/organization?x=1", "https://api.soniox.com/v1/organization#frag", "http://127.0.0.1%2f.attacker.test/v1/organization", + "https://api.soniox.com", + "https://api.soniox.com/v1", + "http://127.0.0.1:4010/", "not a url", "", ] { From 753a9f244f0af4ef7340757e132c3682e15d7f89 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 04:35:15 +0700 Subject: [PATCH 7/7] Retrigger CI after runner paging-file OOM