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..9d30ec6bdb9 100644
--- a/docs/PROVIDERS.md
+++ b/docs/PROVIDERS.md
@@ -715,6 +715,48 @@ 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.
+
+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. 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:
+
+```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 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.
+- 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..a56917f9df8
--- /dev/null
+++ b/rust/src/providers/soniox/mod.rs
@@ -0,0 +1,427 @@
+//! 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";
+/// 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;
+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 {
+ 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 {
+ 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