diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-sub2api.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-sub2api.svg
new file mode 100644
index 0000000000..c1f61af468
--- /dev/null
+++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-sub2api.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts
index fa31578a0d..6f6b31e684 100644
--- a/apps/desktop-tauri/src/components/providers/providerIcons.ts
+++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts
@@ -41,6 +41,7 @@ import perplexity from "./icons/ProviderIcon-perplexity.svg?raw";
import qoder from "./icons/ProviderIcon-qoder.svg?raw";
import sakana from "./icons/ProviderIcon-sakana.svg?raw";
import stepfun from "./icons/ProviderIcon-stepfun.svg?raw";
+import sub2api from "./icons/ProviderIcon-sub2api.svg?raw";
import t3chat from "./icons/ProviderIcon-t3chat.svg?raw";
import venice from "./icons/ProviderIcon-venice.svg?raw";
import vertexai from "./icons/ProviderIcon-vertexai.svg?raw";
@@ -112,6 +113,7 @@ const RAW: Record = {
qoder: tint(qoder),
sakana: tint(sakana),
stepfun: tint(stepfun),
+ sub2api: tint(sub2api),
t3chat: tint(t3chat),
venice: tint(venice),
vertexai: tint(vertexai),
@@ -177,6 +179,7 @@ export const PROVIDER_ICON_REGISTRY: Record = {
qoder: { id: "qoder", brandColor: "#2563eb", fallbackLetter: "Q", svgPath: RAW.qoder },
sakana: { id: "sakana", brandColor: "#0ea5e9", fallbackLetter: "S", svgPath: RAW.sakana },
stepfun: { id: "stepfun", brandColor: "#999999", fallbackLetter: "S", svgPath: RAW.stepfun },
+ sub2api: { id: "sub2api", brandColor: "#2dc6d8", fallbackLetter: "S", svgPath: RAW.sub2api },
venice: { id: "venice", brandColor: "#111827", fallbackLetter: "V", svgPath: RAW.venice },
openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" },
chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" },
@@ -219,6 +222,8 @@ const ALIASES: Record = {
"sakana-ai": "sakana",
"step fun": "stepfun",
"step-fun": "stepfun",
+ "sub-2-api": "sub2api",
+ "sub 2 api": "sub2api",
"openai api": "openaiapi",
"openai-api": "openaiapi",
"azure openai": "azureopenai",
diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts
index d3dc6a9797..ef95b03f8b 100644
--- a/apps/desktop-tauri/src/i18n/keys.ts
+++ b/apps/desktop-tauri/src/i18n/keys.ts
@@ -693,6 +693,10 @@ export const ALL_LOCALE_KEYS = [
"ZedApiUrlLabel",
"ZedApiUrlPlaceholder",
"ZedApiUrlHelp",
+ "Sub2ApiTitle",
+ "Sub2ApiBaseUrlLabel",
+ "Sub2ApiBaseUrlPlaceholder",
+ "Sub2ApiBaseUrlHelp",
// Tray icon visibility (Windows 11 hidden-icons overflow)
"PromoteTrayIconLabel",
diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx
index 22e1c79270..5dcbc39746 100644
--- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx
+++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx
@@ -556,6 +556,7 @@ function CredentialsDispatcher({
case "devin":
case "opencodego":
case "zed":
+ case "sub2api":
return ;
default:
return null;
diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx
index 14565e606b..da0caea1e1 100644
--- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx
+++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx
@@ -131,6 +131,13 @@ export function OpenAiExtras({ providerId = "codex", t }: Props) {
placeholder: t("ZedApiUrlPlaceholder"),
help: t("ZedApiUrlHelp"),
};
+ case "sub2api":
+ return {
+ title: t("Sub2ApiTitle"),
+ label: t("Sub2ApiBaseUrlLabel"),
+ placeholder: t("Sub2ApiBaseUrlPlaceholder"),
+ help: t("Sub2ApiBaseUrlHelp"),
+ };
default:
return null;
}
diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts
index 7245a58a0b..c20db8e493 100644
--- a/apps/desktop-tauri/src/test/providerCatalog.ts
+++ b/apps/desktop-tauri/src/test/providerCatalog.ts
@@ -55,4 +55,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [
["crossmodel", "CrossModel"],
["qoder", "Qoder"],
["sakana", "Sakana AI"],
+ ["sub2api", "sub2api"],
];
diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts
index f6e98c2333..6d5a0a01ad 100644
--- a/apps/desktop-tauri/src/types/bridge.ts
+++ b/apps/desktop-tauri/src/types/bridge.ts
@@ -111,6 +111,7 @@ export type ProofProviderId =
| "crossmodel"
| "qoder"
| "sakana"
+ | "sub2api"
| "wayfinder";
export type TrayPanelSurfaceTarget = { kind: "summary" };
diff --git a/rust/assets/icons/ProviderIcon-sub2api.svg b/rust/assets/icons/ProviderIcon-sub2api.svg
new file mode 100644
index 0000000000..c1f61af468
--- /dev/null
+++ b/rust/assets/icons/ProviderIcon-sub2api.svg
@@ -0,0 +1,4 @@
+
diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs
index 36f9d634c6..63be3eeeda 100755
--- a/rust/src/core/provider.rs
+++ b/rust/src/core/provider.rs
@@ -67,6 +67,7 @@ pub enum ProviderId {
CrossModel,
Qoder,
Sakana,
+ Sub2Api,
Wayfinder,
}
@@ -130,6 +131,7 @@ impl ProviderId {
ProviderId::CrossModel,
ProviderId::Qoder,
ProviderId::Sakana,
+ ProviderId::Sub2Api,
ProviderId::Wayfinder,
]
}
@@ -193,6 +195,7 @@ impl ProviderId {
ProviderId::CrossModel => "crossmodel",
ProviderId::Qoder => "qoder",
ProviderId::Sakana => "sakana",
+ ProviderId::Sub2Api => "sub2api",
ProviderId::Wayfinder => "wayfinder",
}
}
@@ -256,6 +259,7 @@ impl ProviderId {
ProviderId::CrossModel => "CrossModel",
ProviderId::Qoder => "Qoder",
ProviderId::Sakana => "Sakana AI",
+ ProviderId::Sub2Api => "sub2api",
ProviderId::Wayfinder => "Wayfinder",
}
}
@@ -324,6 +328,7 @@ impl ProviderId {
ProviderId::Devin => None,
ProviderId::Zed => None,
ProviderId::CrossModel => None,
+ ProviderId::Sub2Api => None,
ProviderId::Wayfinder => None,
}
}
@@ -394,6 +399,7 @@ impl ProviderId {
"crossmodel" | "cross-model" | "cross model" => Some(ProviderId::CrossModel),
"qoder" => Some(ProviderId::Qoder),
"sakana" | "sakana-ai" | "sakana ai" => Some(ProviderId::Sakana),
+ "sub2api" | "sub-2-api" | "sub 2 api" => Some(ProviderId::Sub2Api),
"wayfinder" => Some(ProviderId::Wayfinder),
_ => None,
}
@@ -617,6 +623,7 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> {
map.insert("llm-proxy", ProviderId::LLMProxy);
map.insert("cross-model", ProviderId::CrossModel);
map.insert("sakana-ai", ProviderId::Sakana);
+ map.insert("sub-2-api", ProviderId::Sub2Api);
map
}
@@ -627,7 +634,7 @@ mod tests {
#[test]
fn test_provider_id_all() {
let all = ProviderId::all();
- assert_eq!(all.len(), 57);
+ assert_eq!(all.len(), 58);
assert!(all.contains(&ProviderId::Claude));
assert!(all.contains(&ProviderId::Codex));
assert!(all.contains(&ProviderId::Kimi));
@@ -664,6 +671,7 @@ mod tests {
assert!(all.contains(&ProviderId::CrossModel));
assert!(all.contains(&ProviderId::Qoder));
assert!(all.contains(&ProviderId::Sakana));
+ assert!(all.contains(&ProviderId::Sub2Api));
assert!(all.contains(&ProviderId::Wayfinder));
}
diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs
index feab9e0d0d..e57669947e 100644
--- a/rust/src/core/provider_factory.rs
+++ b/rust/src/core/provider_factory.rs
@@ -16,8 +16,8 @@ use crate::providers::{
KiroProvider, LLMProxyProvider, LiteLLMProvider, ManusProvider, MiMoProvider, MiniMaxProvider,
MistralProvider, NanoGPTProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider,
OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider,
- SakanaProvider, StepFunProvider, T3ChatProvider, VeniceProvider, VertexAIProvider,
- WarpProvider, WayfinderProvider, WindsurfProvider, ZaiProvider, ZedProvider,
+ SakanaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, VeniceProvider,
+ VertexAIProvider, WarpProvider, WayfinderProvider, WindsurfProvider, ZaiProvider, ZedProvider,
};
/// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`].
@@ -82,6 +82,7 @@ pub fn instantiate(id: ProviderId) -> Box {
ProviderId::CrossModel => Box::new(CrossModelProvider::new()),
ProviderId::Qoder => Box::new(QoderProvider::new()),
ProviderId::Sakana => Box::new(SakanaProvider::new()),
+ ProviderId::Sub2Api => Box::new(Sub2ApiProvider::new()),
ProviderId::Wayfinder => Box::new(WayfinderProvider::new()),
}
}
diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs
index fed6002cab..b29a4fd851 100755
--- a/rust/src/core/token_accounts.rs
+++ b/rust/src/core/token_accounts.rs
@@ -189,6 +189,16 @@ impl TokenAccountSupport {
requires_manual_cookie_source: true,
cookie_name: None,
}),
+ ProviderId::Sub2Api => Some(TokenAccountSupport {
+ title: "Group API keys",
+ subtitle: "Store multiple sub2api group API keys with labels such as Claude, Codex, or Gemini.",
+ placeholder: "sk-...",
+ injection: TokenInjection::Environment {
+ key: "SUB2API_API_KEY".to_string(),
+ },
+ requires_manual_cookie_source: false,
+ cookie_name: None,
+ }),
ProviderId::Copilot => Some(TokenAccountSupport {
title: "GitHub accounts",
subtitle: "Store GitHub OAuth tokens for Copilot plan usage.",
diff --git a/rust/src/locale.rs b/rust/src/locale.rs
index f936d1eb9e..e39f14c48d 100644
--- a/rust/src/locale.rs
+++ b/rust/src/locale.rs
@@ -945,6 +945,10 @@ locale_keys! {
ZedApiUrlLabel,
ZedApiUrlPlaceholder,
ZedApiUrlHelp,
+ Sub2ApiTitle,
+ Sub2ApiBaseUrlLabel,
+ Sub2ApiBaseUrlPlaceholder,
+ Sub2ApiBaseUrlHelp,
// Tray icon visibility (Windows 11 hidden-icons overflow)
PromoteTrayIconLabel,
diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl
index f28391dbbb..f50226b2c5 100644
--- a/rust/src/locale/en-US.ftl
+++ b/rust/src/locale/en-US.ftl
@@ -667,6 +667,10 @@ ZedApiTitle = Zed API
ZedApiUrlLabel = API URL
ZedApiUrlPlaceholder = https://cloud.zed.dev/client/users/me
ZedApiUrlHelp = Optional. Leave blank for the default Zed Cloud API URL.
+Sub2ApiTitle = sub2api
+Sub2ApiBaseUrlLabel = Base URL
+Sub2ApiBaseUrlPlaceholder = https://sub2api.example.com
+Sub2ApiBaseUrlHelp = Deployment URL for GET /v1/usage. Use HTTPS, or loopback HTTP (127.0.0.1 / localhost) for local development.
PromoteTrayIconLabel = Pin to Taskbar (Windows 11)
PromoteTrayIconHelper = Always show the CodexBar icon on the taskbar instead of inside the overflow chevron
PromoteTrayIconUnsupportedHint = Not available on this operating system. Drag the icon out of the ^ overflow area in Windows Settings > Taskbar to pin it manually.
diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl
index 4b3fb138f3..9e11a942d4 100644
--- a/rust/src/locale/ja-JP.ftl
+++ b/rust/src/locale/ja-JP.ftl
@@ -665,6 +665,10 @@ TokenAccountTokenPlaceholder = トークンを貼り付け…
WaitingForUsage = 使用量データを待機中
ZedApiTitle = Zed API
ZedApiUrlHelp = 任意。既定の Zed Cloud API URL を使用する場合は空欄にします。
+Sub2ApiTitle = sub2api
+Sub2ApiBaseUrlLabel = Base URL
+Sub2ApiBaseUrlPlaceholder = https://sub2api.example.com
+Sub2ApiBaseUrlHelp = Deployment URL for GET /v1/usage. Use HTTPS, or loopback HTTP for local development.
ZedApiUrlLabel = API URL
ZedApiUrlPlaceholder = https://cloud.zed.dev/client/users/me
PromoteTrayIconLabel = タスクバーにピン留め (Windows 11)
diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl
index db09063326..ece77c1edf 100644
--- a/rust/src/locale/zh-CN.ftl
+++ b/rust/src/locale/zh-CN.ftl
@@ -667,6 +667,10 @@ ZedApiTitle = Zed API
ZedApiUrlLabel = API URL
ZedApiUrlPlaceholder = https://cloud.zed.dev/client/users/me
ZedApiUrlHelp = 可选。留空则使用默认的 Zed Cloud API URL。
+Sub2ApiTitle = sub2api
+Sub2ApiBaseUrlLabel = Base URL
+Sub2ApiBaseUrlPlaceholder = https://sub2api.example.com
+Sub2ApiBaseUrlHelp = Deployment URL for GET /v1/usage. Use HTTPS, or loopback HTTP for local development.
PromoteTrayIconLabel = 固定到任务栏(Windows 11)
PromoteTrayIconHelper = 始终将 CodexBar 图标显示在任务栏上,而不是隐藏在溢出区域中
PromoteTrayIconUnsupportedHint = 此操作系统不支持该功能。请在 Windows 设置 > 任务栏 中将图标从 ^ 溢出区域手动拖出固定。
diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl
index 11fdf02975..cdbbbd359b 100644
--- a/rust/src/locale/zh-TW.ftl
+++ b/rust/src/locale/zh-TW.ftl
@@ -667,6 +667,10 @@ ZedApiTitle = Zed API
ZedApiUrlLabel = API URL
ZedApiUrlPlaceholder = https://cloud.zed.dev/client/users/me
ZedApiUrlHelp = 選用。留空則使用預設的 Zed Cloud API URL。
+Sub2ApiTitle = sub2api
+Sub2ApiBaseUrlLabel = Base URL
+Sub2ApiBaseUrlPlaceholder = https://sub2api.example.com
+Sub2ApiBaseUrlHelp = Deployment URL for GET /v1/usage. Use HTTPS, or loopback HTTP for local development.
PromoteTrayIconLabel = 釘選到工作列(Windows 11)
PromoteTrayIconHelper = 一律在工作列顯示 CodexBar 圖示,而非隱藏於溢位區域
PromoteTrayIconUnsupportedHint = 此作業系統不支援此功能。請在 Windows 設定 > 工作列中,從 ^ 溢位區域手動拖出圖示以釘選。
diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs
index 1584fdbba0..ed7ffd7300 100755
--- a/rust/src/providers/mod.rs
+++ b/rust/src/providers/mod.rs
@@ -52,6 +52,7 @@ pub mod poe;
pub mod qoder;
pub mod sakana;
pub mod stepfun;
+pub mod sub2api;
pub mod t3chat;
pub mod venice;
pub mod vertexai;
@@ -111,6 +112,7 @@ pub use poe::PoeProvider;
pub use qoder::QoderProvider;
pub use sakana::SakanaProvider;
pub use stepfun::StepFunProvider;
+pub use sub2api::Sub2ApiProvider;
pub use t3chat::T3ChatProvider;
pub use venice::VeniceProvider;
pub use vertexai::VertexAIProvider;
diff --git a/rust/src/providers/sub2api/mod.rs b/rust/src/providers/sub2api/mod.rs
new file mode 100644
index 0000000000..45c950a6f3
--- /dev/null
+++ b/rust/src/providers/sub2api/mod.rs
@@ -0,0 +1,929 @@
+//! sub2api provider implementation.
+//!
+//! Mirrors upstream v0.43.0: fetch group key quota, subscription limits,
+//! usage totals, and wallet balance from `GET /v1/usage`.
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use reqwest::{Client, Url};
+use serde::Deserialize;
+
+use crate::core::{
+ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId,
+ ProviderMetadata, RateWindow, SourceMode, UsageSnapshot,
+};
+
+const CREDENTIAL_TARGET: &str = "codexbar-sub2api";
+const API_KEY_ENV: &str = "SUB2API_API_KEY";
+const BASE_URL_ENV: &str = "SUB2API_BASE_URL";
+
+pub struct Sub2ApiProvider {
+ metadata: ProviderMetadata,
+ client: Client,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct ParsedUsage {
+ mode: String,
+ is_valid: bool,
+ plan_name: Option,
+ unit: String,
+ balance: Option,
+ quota: Option,
+ rate_limits: Vec,
+ subscription: Option,
+ today: Option,
+ total: Option,
+ expires_at: Option>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct ParsedQuota {
+ limit: f64,
+ used: f64,
+ remaining: f64,
+ unit: String,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct ParsedRateLimit {
+ window: String,
+ limit: f64,
+ used: f64,
+ remaining: f64,
+ reset_at: Option>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct ParsedSubscription {
+ daily_usage_usd: f64,
+ weekly_usage_usd: f64,
+ monthly_usage_usd: f64,
+ daily_limit_usd: Option,
+ weekly_limit_usd: Option,
+ monthly_limit_usd: Option,
+ expires_at: Option>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct ParsedTotals {
+ requests: i64,
+ total_tokens: i64,
+ actual_cost_usd: f64,
+}
+
+#[derive(Debug, Deserialize)]
+struct UsageResponse {
+ mode: Option,
+ #[serde(rename = "isValid", default)]
+ is_valid: Option,
+ #[allow(dead_code)]
+ status: Option,
+ #[serde(rename = "planName")]
+ plan_name: Option,
+ #[allow(dead_code)]
+ remaining: Option,
+ unit: Option,
+ balance: Option,
+ quota: Option,
+ #[serde(default, rename = "rate_limits")]
+ rate_limits: Option>,
+ subscription: Option,
+ usage: Option,
+ #[serde(rename = "expires_at")]
+ expires_at: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct QuotaResponse {
+ limit: f64,
+ used: f64,
+ remaining: f64,
+ unit: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct RateLimitResponse {
+ window: String,
+ limit: f64,
+ used: f64,
+ remaining: f64,
+ #[serde(rename = "reset_at")]
+ reset_at: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct SubscriptionResponse {
+ #[serde(rename = "daily_usage_usd")]
+ daily_usage_usd: Option,
+ #[serde(rename = "weekly_usage_usd")]
+ weekly_usage_usd: Option,
+ #[serde(rename = "monthly_usage_usd")]
+ monthly_usage_usd: Option,
+ #[serde(rename = "daily_limit_usd")]
+ daily_limit_usd: Option,
+ #[serde(rename = "weekly_limit_usd")]
+ weekly_limit_usd: Option,
+ #[serde(rename = "monthly_limit_usd")]
+ monthly_limit_usd: Option,
+ #[serde(rename = "expires_at")]
+ expires_at: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct UsageBlockResponse {
+ today: Option,
+ total: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct TotalsResponse {
+ requests: Option,
+ #[serde(rename = "total_tokens")]
+ total_tokens: Option,
+ #[serde(rename = "actual_cost")]
+ actual_cost: Option,
+}
+
+impl Sub2ApiProvider {
+ pub fn new() -> Self {
+ Self {
+ metadata: ProviderMetadata {
+ id: ProviderId::Sub2Api,
+ display_name: "sub2api",
+ session_label: "Quota",
+ weekly_label: "Weekly quota",
+ supports_opus: true,
+ supports_credits: false,
+ default_enabled: false,
+ is_primary: false,
+ dashboard_url: None,
+ status_page_url: None,
+ },
+ client: crate::core::credentialed_http_client_builder()
+ .timeout(std::time::Duration::from_secs(15))
+ .build()
+ .unwrap_or_else(|_| Client::new()),
+ }
+ }
+
+ async fn fetch_api(
+ &self,
+ api_key: &str,
+ base_url: &Url,
+ ) -> Result {
+ let request_url = usage_request_url(base_url)?;
+ let response = self
+ .client
+ .get(request_url)
+ .header("Authorization", format!("Bearer {api_key}"))
+ .header("Accept", "application/json")
+ .send()
+ .await?;
+
+ let status = response.status();
+ if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
+ return Err(ProviderError::AuthRequired);
+ }
+ if !status.is_success() {
+ return Err(ProviderError::Other(format!(
+ "sub2api usage returned status {status}"
+ )));
+ }
+
+ let body = response.text().await.map_err(|e| {
+ ProviderError::Parse(format!("Failed to read sub2api usage response: {e}"))
+ })?;
+ let parsed = parse_usage_body(&body)?;
+ if !parsed.is_valid {
+ return Err(ProviderError::AuthRequired);
+ }
+ Ok(snapshot_from_parsed(parsed))
+ }
+}
+
+impl Default for Sub2ApiProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl Provider for Sub2ApiProvider {
+ fn id(&self) -> ProviderId {
+ ProviderId::Sub2Api
+ }
+
+ fn metadata(&self) -> &ProviderMetadata {
+ &self.metadata
+ }
+
+ async fn fetch_usage(&self, ctx: &FetchContext) -> Result {
+ match ctx.source_mode {
+ SourceMode::Auto | SourceMode::OAuth => {
+ let key = resolve_api_key(ctx)?;
+ let base = resolve_base_url(ctx)?;
+ self.fetch_api(&key, &base).await
+ }
+ SourceMode::Web | SourceMode::Cli => {
+ Err(ProviderError::UnsupportedSource(ctx.source_mode))
+ }
+ }
+ }
+
+ fn available_sources(&self) -> Vec {
+ // Upstream: Auto + API. Win maps API-token sources to OAuth.
+ vec![SourceMode::Auto, SourceMode::OAuth]
+ }
+}
+
+fn resolve_api_key(ctx: &FetchContext) -> Result {
+ let key = crate::providers::resolve_api_key(
+ ctx.api_key.as_deref(),
+ CREDENTIAL_TARGET,
+ &[API_KEY_ENV],
+ )?;
+ let cleaned = clean_env_value(&key).ok_or(ProviderError::AuthRequired)?;
+ Ok(cleaned)
+}
+
+fn resolve_base_url(ctx: &FetchContext) -> Result {
+ if let Some(base) = ctx
+ .workspace_id
+ .as_deref()
+ .and_then(clean_env_value)
+ .filter(|value| !value.is_empty())
+ {
+ return validated_sub2api_base_url(&base);
+ }
+
+ let raw = std::env::var(BASE_URL_ENV)
+ .ok()
+ .and_then(|value| clean_env_value(&value))
+ .ok_or_else(|| {
+ ProviderError::NotInstalled(
+ "Missing or invalid sub2api base URL. Add one in Settings or set SUB2API_BASE_URL."
+ .into(),
+ )
+ })?;
+ validated_sub2api_base_url(&raw)
+}
+
+/// Strip whitespace and surrounding quotes from env/settings values.
+pub(crate) fn clean_env_value(raw: &str) -> Option {
+ let mut value = raw.trim().to_string();
+ if value.is_empty() {
+ return None;
+ }
+ if (value.starts_with('"') && value.ends_with('"'))
+ || (value.starts_with('\'') && value.ends_with('\''))
+ {
+ value = value[1..value.len().saturating_sub(1)].to_string();
+ value = value.trim().to_string();
+ }
+ if value.is_empty() { None } else { Some(value) }
+}
+
+/// Validate a sub2api base URL: HTTPS or loopback HTTP, no userinfo/query/fragment.
+pub(crate) fn validated_sub2api_base_url(raw: &str) -> Result {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return Err(ProviderError::Other(
+ "sub2api base URL must use HTTPS, or loopback HTTP for local development, without embedded credentials."
+ .into(),
+ ));
+ }
+ let lower = trimmed.to_ascii_lowercase();
+ if ["%2f", "%5c", "%3f", "%23", "%40", "%3a"]
+ .iter()
+ .any(|encoded| lower.contains(encoded))
+ {
+ return Err(ProviderError::Other(
+ "sub2api base URL must not contain encoded host delimiters".into(),
+ ));
+ }
+
+ let url = Url::parse(trimmed).map_err(|e| {
+ ProviderError::Other(format!(
+ "Missing or invalid sub2api base URL ({e}). Add one in Settings or set SUB2API_BASE_URL."
+ ))
+ })?;
+
+ let host = url
+ .host_str()
+ .ok_or_else(|| ProviderError::Other("sub2api base URL must include a host".into()))?;
+
+ 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 Err(ProviderError::Other(
+ "sub2api base URL must use HTTPS, or loopback HTTP for local development, without embedded credentials."
+ .into(),
+ ));
+ }
+
+ Ok(url)
+}
+
+fn is_loopback_host(host: &str) -> bool {
+ let normalized = host.trim_end_matches('.').to_ascii_lowercase();
+ if normalized == "localhost" || 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,
+ }
+}
+
+/// Join `{base}` with `/v1/usage` following upstream path rules.
+pub(crate) fn usage_url(base_url: &Url) -> Result {
+ let path = base_url.path().trim_end_matches('/');
+ let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+
+ if segments.len() >= 2 && segments[segments.len() - 2..] == ["v1", "usage"] {
+ // Normalize trailing slash differences while keeping path intact.
+ let mut exact = base_url.clone();
+ exact.set_path(&format!("/{}", segments.join("/")));
+ exact.set_query(None);
+ exact.set_fragment(None);
+ return Ok(exact);
+ }
+
+ let mut joined = base_url.clone();
+ joined.set_query(None);
+ joined.set_fragment(None);
+ if segments.last() == Some(&"v1") {
+ joined.set_path(&format!("{path}/usage"));
+ return Ok(joined);
+ }
+
+ if path.is_empty() {
+ joined.set_path("/v1/usage");
+ } else {
+ joined.set_path(&format!("{path}/v1/usage"));
+ }
+ Ok(joined)
+}
+
+fn usage_request_url(base_url: &Url) -> Result {
+ let mut url = usage_url(base_url)?;
+ let timezone = local_timezone_identifier();
+ url.query_pairs_mut()
+ .append_pair("days", "30")
+ .append_pair("timezone", &timezone);
+ Ok(url)
+}
+
+fn local_timezone_identifier() -> String {
+ iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string())
+}
+
+fn parse_usage_body(body: &str) -> Result {
+ let response: UsageResponse = serde_json::from_str(body)
+ .map_err(|e| ProviderError::Parse(format!("Could not parse sub2api usage: {e}")))?;
+ Ok(parse_usage_response(response))
+}
+
+fn parse_usage_response(response: UsageResponse) -> ParsedUsage {
+ let unit = response
+ .unit
+ .clone()
+ .or_else(|| response.quota.as_ref().and_then(|q| q.unit.clone()))
+ .unwrap_or_else(|| "USD".to_string());
+
+ ParsedUsage {
+ mode: response.mode.unwrap_or_else(|| "unknown".to_string()),
+ is_valid: response.is_valid.unwrap_or(true),
+ plan_name: response.plan_name,
+ unit: unit.clone(),
+ balance: response.balance,
+ quota: response.quota.map(|q| ParsedQuota {
+ limit: q.limit,
+ used: q.used,
+ remaining: q.remaining,
+ unit: q.unit.unwrap_or_else(|| unit.clone()),
+ }),
+ rate_limits: response
+ .rate_limits
+ .unwrap_or_default()
+ .into_iter()
+ .map(|rl| ParsedRateLimit {
+ window: rl.window,
+ limit: rl.limit,
+ used: rl.used,
+ remaining: rl.remaining,
+ reset_at: parse_date(rl.reset_at.as_deref()),
+ })
+ .collect(),
+ subscription: response.subscription.map(|s| ParsedSubscription {
+ daily_usage_usd: s.daily_usage_usd.unwrap_or(0.0),
+ weekly_usage_usd: s.weekly_usage_usd.unwrap_or(0.0),
+ monthly_usage_usd: s.monthly_usage_usd.unwrap_or(0.0),
+ daily_limit_usd: s.daily_limit_usd,
+ weekly_limit_usd: s.weekly_limit_usd,
+ monthly_limit_usd: s.monthly_limit_usd,
+ expires_at: parse_date(s.expires_at.as_deref()),
+ }),
+ today: usage_totals(response.usage.as_ref().and_then(|u| u.today.as_ref())),
+ total: usage_totals(response.usage.as_ref().and_then(|u| u.total.as_ref())),
+ expires_at: parse_date(response.expires_at.as_deref()),
+ }
+}
+
+fn usage_totals(totals: Option<&TotalsResponse>) -> Option {
+ totals.map(|t| ParsedTotals {
+ requests: t.requests.unwrap_or(0),
+ total_tokens: t.total_tokens.unwrap_or(0),
+ actual_cost_usd: t.actual_cost.unwrap_or(0.0),
+ })
+}
+
+fn parse_date(raw: Option<&str>) -> Option> {
+ let raw = raw?.trim();
+ if raw.is_empty() {
+ return None;
+ }
+ DateTime::parse_from_rfc3339(raw)
+ .map(|dt| dt.with_timezone(&Utc))
+ .ok()
+ .or_else(|| {
+ // Accept fractional-second variants serde/chrono sometimes miss.
+ chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.fZ")
+ .ok()
+ .map(|naive| naive.and_utc())
+ })
+}
+
+fn snapshot_from_parsed(parsed: ParsedUsage) -> ProviderFetchResult {
+ let kind = if parsed.subscription.is_some() {
+ Kind::Subscription
+ } else if parsed.quota.is_some() || !parsed.rate_limits.is_empty() {
+ Kind::KeyQuota
+ } else if parsed.balance.is_some() {
+ Kind::Wallet
+ } else {
+ Kind::Unknown
+ };
+
+ let mut cost: Option = None;
+ let mut primary: Option = None;
+ let mut secondary: Option = None;
+ let mut tertiary: Option = None;
+
+ if let Some(sub) = &parsed.subscription {
+ primary = rate_window_from_usage(
+ sub.daily_usage_usd,
+ sub.daily_limit_usd,
+ Some(24 * 60),
+ &parsed.unit,
+ );
+ secondary = rate_window_from_usage(
+ sub.weekly_usage_usd,
+ sub.weekly_limit_usd,
+ Some(7 * 24 * 60),
+ &parsed.unit,
+ );
+ tertiary = rate_window_from_usage(
+ sub.monthly_usage_usd,
+ sub.monthly_limit_usd,
+ Some(30 * 24 * 60),
+ &parsed.unit,
+ );
+ } else if let Some(quota) = &parsed.quota {
+ primary = Some(RateWindow::with_details(
+ used_percent(quota.used, quota.limit),
+ None,
+ None,
+ Some(amount_description(quota.used, quota.limit, "a.unit)),
+ ));
+ }
+
+ let mut snapshot = match primary {
+ Some(window) => UsageSnapshot::new(window),
+ None if kind == Kind::Wallet => {
+ let balance = parsed.balance.unwrap_or(0.0);
+ let description = format!("{} balance", currency_string(balance, &parsed.unit));
+ cost = Some(
+ CostSnapshot::new(0.0, normalize_currency(&parsed.unit), "balance")
+ .with_limit(balance.max(0.0)),
+ );
+ UsageSnapshot::new(RateWindow::informational(description))
+ }
+ None => UsageSnapshot::new(RateWindow::informational("No quota data")),
+ };
+
+ if let Some(secondary) = secondary {
+ snapshot = snapshot.with_secondary(secondary);
+ }
+ if let Some(tertiary) = tertiary {
+ snapshot = snapshot.with_tertiary(tertiary);
+ }
+
+ for rate_limit in &parsed.rate_limits {
+ snapshot = snapshot.with_extra_rate_window(
+ rate_limit.window.clone(),
+ rate_limit_title(&rate_limit.window),
+ RateWindow::with_details(
+ used_percent(rate_limit.used, rate_limit.limit),
+ window_minutes(&rate_limit.window),
+ rate_limit.reset_at,
+ Some(amount_description(
+ rate_limit.used,
+ rate_limit.limit,
+ &parsed.unit,
+ )),
+ ),
+ );
+ }
+
+ if let Some(today) = &parsed.today {
+ snapshot = snapshot.with_extra_rate_window(
+ "today",
+ "Today",
+ RateWindow::informational(totals_description(today, &parsed.unit)),
+ );
+ }
+ if let Some(total) = &parsed.total {
+ snapshot = snapshot.with_extra_rate_window(
+ "total",
+ "Total",
+ RateWindow::informational(totals_description(total, &parsed.unit)),
+ );
+ }
+
+ if let Some(plan) = parsed.plan_name.as_ref().filter(|p| !p.trim().is_empty()) {
+ snapshot = snapshot
+ .with_organization(plan.clone())
+ .with_login_method(plan.clone());
+ }
+
+ let expires = parsed
+ .subscription
+ .as_ref()
+ .and_then(|s| s.expires_at)
+ .or(parsed.expires_at);
+ if let Some(expires_at) = expires {
+ snapshot = snapshot.with_extra_rate_window(
+ "expires",
+ "Expires",
+ RateWindow::with_details(
+ 0.0,
+ None,
+ Some(expires_at),
+ Some(expires_at.format("%Y-%m-%d").to_string()),
+ ),
+ );
+ }
+
+ // Surface mode/kind lightly without a dedicated schema field.
+ let _ = (kind, parsed.mode);
+
+ let mut result = ProviderFetchResult::new(snapshot, "api");
+ if let Some(cost) = cost {
+ result = result.with_cost(cost);
+ }
+ result
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Kind {
+ KeyQuota,
+ Subscription,
+ Wallet,
+ Unknown,
+}
+
+fn rate_window_from_usage(
+ usage: f64,
+ limit: Option,
+ window_minutes: Option,
+ unit: &str,
+) -> Option {
+ let limit = limit.filter(|l| *l > 0.0)?;
+ Some(RateWindow::with_details(
+ used_percent(usage, limit),
+ window_minutes,
+ None,
+ Some(amount_description(usage, limit, unit)),
+ ))
+}
+
+fn used_percent(usage: f64, limit: f64) -> f64 {
+ if limit > 0.0 {
+ (usage / limit * 100.0).clamp(0.0, 100.0)
+ } else {
+ 0.0
+ }
+}
+
+fn amount_description(used: f64, limit: f64, unit: &str) -> String {
+ format!(
+ "{} / {}",
+ currency_string(used, unit),
+ currency_string(limit, unit)
+ )
+}
+
+fn currency_string(value: f64, unit: &str) -> String {
+ if unit.eq_ignore_ascii_case("USD") {
+ format!("${value:.2}")
+ } else {
+ format!("{value:.2} {unit}")
+ }
+}
+
+fn normalize_currency(raw: &str) -> String {
+ let trimmed = raw.trim().to_ascii_uppercase();
+ if trimmed.is_empty() {
+ "USD".to_string()
+ } else {
+ trimmed
+ }
+}
+
+fn window_minutes(window: &str) -> Option {
+ match window.to_ascii_lowercase().as_str() {
+ "5h" => Some(5 * 60),
+ "1d" => Some(24 * 60),
+ "7d" => Some(7 * 24 * 60),
+ _ => None,
+ }
+}
+
+fn rate_limit_title(window: &str) -> String {
+ match window.to_ascii_lowercase().as_str() {
+ "5h" => "5 hour limit".to_string(),
+ "1d" => "Daily limit".to_string(),
+ "7d" => "7 day limit".to_string(),
+ other => format!("{other} limit"),
+ }
+}
+
+fn totals_description(totals: &ParsedTotals, unit: &str) -> String {
+ format!(
+ "{} requests, {} tokens, {}",
+ totals.requests,
+ totals.total_tokens,
+ currency_string(totals.actual_cost_usd, unit)
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_quota_limited_key_usage() {
+ let json = r#"
+ {
+ "mode": "quota_limited",
+ "isValid": true,
+ "status": "active",
+ "remaining": 75,
+ "unit": "USD",
+ "quota": {
+ "limit": 100,
+ "used": 25,
+ "remaining": 75,
+ "unit": "USD"
+ },
+ "rate_limits": [
+ {
+ "window": "5h",
+ "limit": 20,
+ "used": 5,
+ "remaining": 15,
+ "reset_at": "2026-07-11T12:30:00Z"
+ },
+ {
+ "window": "7d",
+ "limit": 200,
+ "used": 40,
+ "remaining": 160
+ }
+ ],
+ "expires_at": "2026-08-01T00:00:00Z",
+ "usage": {
+ "today": {
+ "requests": 4,
+ "total_tokens": 1200,
+ "actual_cost": 1.25
+ },
+ "total": {
+ "requests": 40,
+ "total_tokens": 12000,
+ "actual_cost": 25
+ }
+ }
+ }
+ "#;
+
+ let parsed = parse_usage_body(json).unwrap();
+ assert_eq!(parsed.mode, "quota_limited");
+ assert_eq!(parsed.quota.as_ref().unwrap().remaining, 75.0);
+ assert_eq!(parsed.rate_limits.len(), 2);
+ assert_eq!(parsed.today.as_ref().unwrap().total_tokens, 1200);
+
+ let result = snapshot_from_parsed(parsed);
+ assert!((result.usage.primary.used_percent - 25.0).abs() < f64::EPSILON);
+ assert!(result.cost.is_none());
+ assert!(
+ result
+ .usage
+ .extra_rate_windows
+ .iter()
+ .any(|w| w.id == "5h" && w.window.window_minutes == Some(300))
+ );
+ assert!(result.usage.extra_rate_windows.iter().any(|w| {
+ w.id == "today"
+ && w.window
+ .reset_description
+ .as_deref()
+ .is_some_and(|d| d.contains("1200 tokens"))
+ }));
+ assert!(
+ result
+ .usage
+ .extra_rate_windows
+ .iter()
+ .any(|w| w.id == "expires")
+ );
+ }
+
+ #[test]
+ fn parses_subscription_usage_windows() {
+ let json = r#"
+ {
+ "mode": "unrestricted",
+ "isValid": true,
+ "planName": "Claude Team",
+ "remaining": 8,
+ "unit": "USD",
+ "subscription": {
+ "daily_usage_usd": 2,
+ "weekly_usage_usd": 10,
+ "monthly_usage_usd": 30,
+ "daily_limit_usd": 10,
+ "weekly_limit_usd": 40,
+ "monthly_limit_usd": 100,
+ "expires_at": "2026-08-15T00:00:00.123Z"
+ }
+ }
+ "#;
+
+ let result = snapshot_from_parsed(parse_usage_body(json).unwrap());
+ assert!((result.usage.primary.used_percent - 20.0).abs() < f64::EPSILON);
+ assert!(
+ (result.usage.secondary.as_ref().unwrap().used_percent - 25.0).abs() < f64::EPSILON
+ );
+ assert!((result.usage.tertiary.as_ref().unwrap().used_percent - 30.0).abs() < f64::EPSILON);
+ assert_eq!(
+ result.usage.account_organization.as_deref(),
+ Some("Claude Team")
+ );
+ assert_eq!(result.usage.login_method.as_deref(), Some("Claude Team"));
+ assert!(
+ result
+ .usage
+ .extra_rate_windows
+ .iter()
+ .any(|w| w.id == "expires")
+ );
+ }
+
+ #[test]
+ fn preserves_authoritative_subscription_windows() {
+ let json = r#"
+ {
+ "mode": "unrestricted",
+ "subscription": {
+ "daily_usage_usd": 120.23,
+ "weekly_usage_usd": 229.20,
+ "monthly_usage_usd": 1296.23,
+ "daily_limit_usd": 120,
+ "weekly_limit_usd": 700,
+ "monthly_limit_usd": 2800
+ }
+ }
+ "#;
+
+ let result = snapshot_from_parsed(parse_usage_body(json).unwrap());
+ assert!((result.usage.primary.used_percent - 100.0).abs() < f64::EPSILON);
+ assert!(
+ (result.usage.secondary.as_ref().unwrap().used_percent - (229.20 / 700.0 * 100.0))
+ .abs()
+ < 0.001
+ );
+ assert_eq!(
+ result.usage.primary.reset_description.as_deref(),
+ Some("$120.23 / $120.00")
+ );
+ assert_eq!(
+ result
+ .usage
+ .secondary
+ .as_ref()
+ .and_then(|w| w.reset_description.as_deref()),
+ Some("$229.20 / $700.00")
+ );
+ }
+
+ #[test]
+ fn parses_wallet_balance_only() {
+ let json = r#"
+ {
+ "mode": "unrestricted",
+ "isValid": true,
+ "planName": "Wallet plan",
+ "remaining": 42.5,
+ "unit": "USD",
+ "balance": 42.5
+ }
+ "#;
+
+ let result = snapshot_from_parsed(parse_usage_body(json).unwrap());
+ assert!(result.usage.primary.is_informational);
+ assert_eq!(
+ result.usage.primary.reset_description.as_deref(),
+ Some("$42.50 balance")
+ );
+ assert_eq!(result.usage.login_method.as_deref(), Some("Wallet plan"));
+ let cost = result.cost.unwrap();
+ assert_eq!(cost.limit, Some(42.5));
+ assert_eq!(cost.period, "balance");
+ }
+
+ #[test]
+ fn invalid_credentials_flag_is_detected() {
+ let json = r#"{"mode":"unrestricted","isValid":false}"#;
+ let parsed = parse_usage_body(json).unwrap();
+ assert!(!parsed.is_valid);
+ }
+
+ #[test]
+ fn usage_url_accepts_root_versioned_and_complete_urls() {
+ let root = Url::parse("https://api.example.com").unwrap();
+ assert_eq!(
+ usage_url(&root).unwrap().as_str(),
+ "https://api.example.com/v1/usage"
+ );
+
+ let versioned = Url::parse("https://api.example.com/v1").unwrap();
+ assert_eq!(
+ usage_url(&versioned).unwrap().as_str(),
+ "https://api.example.com/v1/usage"
+ );
+
+ let complete = Url::parse("https://api.example.com/v1/usage").unwrap();
+ assert_eq!(
+ usage_url(&complete).unwrap().as_str(),
+ "https://api.example.com/v1/usage"
+ );
+ }
+
+ #[test]
+ fn settings_allow_https_and_loopback_http_only() {
+ assert!(validated_sub2api_base_url("https://api.example.com").is_ok());
+ assert!(validated_sub2api_base_url("http://127.0.0.1:8080").is_ok());
+ assert!(validated_sub2api_base_url("http://api.example.com").is_err());
+ assert!(validated_sub2api_base_url("https://user:pass@api.example.com").is_err());
+ assert!(validated_sub2api_base_url("https://api.example.com?token=secret").is_err());
+ assert!(validated_sub2api_base_url("https://api.example.com#fragment").is_err());
+ }
+
+ #[test]
+ fn cleans_quoted_env_values() {
+ assert_eq!(
+ clean_env_value(" \"sk-test\" ").as_deref(),
+ Some("sk-test")
+ );
+ assert_eq!(clean_env_value("''"), None);
+ }
+
+ #[test]
+ fn usage_request_includes_days_and_timezone() {
+ let base = Url::parse("https://api.example.com").unwrap();
+ let url = usage_request_url(&base).unwrap();
+ let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
+ assert_eq!(query.get("days").map(String::as_str), Some("30"));
+ assert!(query.contains_key("timezone"));
+ assert!(!query.get("timezone").unwrap().is_empty());
+ }
+}
diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs
index 564e45edef..4626f06457 100644
--- a/rust/src/settings/api_keys.rs
+++ b/rust/src/settings/api_keys.rs
@@ -459,5 +459,16 @@ pub fn get_api_key_providers() -> Vec {
config_file_path: None,
dashboard_url: Some("https://crossmodel.ai"),
},
+ ProviderConfigInfo {
+ id: ProviderId::Sub2Api,
+ name: "sub2api",
+ requires_api_key: true,
+ api_key_env_var: Some("SUB2API_API_KEY + SUB2API_BASE_URL"),
+ api_key_help: Some(
+ "Paste a group API key and set the base URL in provider extras or SUB2API_BASE_URL. HTTPS required (loopback HTTP allowed for local dev).",
+ ),
+ config_file_path: None,
+ dashboard_url: None,
+ },
]
}
diff --git a/rust/src/settings/provider_workspace.rs b/rust/src/settings/provider_workspace.rs
index 9f28cb0bf7..41b8ce214f 100644
--- a/rust/src/settings/provider_workspace.rs
+++ b/rust/src/settings/provider_workspace.rs
@@ -32,10 +32,18 @@ pub fn validate_provider_workspace_value(
}),
ProviderId::Zed => validate_zed_url(trimmed),
ProviderId::LiteLLM => validate_token_endpoint(trimmed, "LiteLLM base URL", |_| true),
+ ProviderId::Sub2Api => validate_sub2api_base_url(trimmed),
_ => Ok(trimmed.to_string()),
}
}
+fn validate_sub2api_base_url(raw: &str) -> Result {
+ match crate::providers::sub2api::validated_sub2api_base_url(raw) {
+ Ok(url) => Ok(url.to_string().trim_end_matches('/').to_string()),
+ Err(err) => Err(err.to_string()),
+ }
+}
+
fn validate_id(
value: &str,
label: &str,
@@ -187,6 +195,29 @@ mod tests {
}
}
+ #[test]
+ fn validates_sub2api_base_url() {
+ assert_eq!(
+ validate_provider_workspace_value(ProviderId::Sub2Api, "https://api.example.com/")
+ .unwrap(),
+ "https://api.example.com"
+ );
+ assert!(
+ validate_provider_workspace_value(ProviderId::Sub2Api, "http://127.0.0.1:8080").is_ok()
+ );
+ assert!(
+ validate_provider_workspace_value(ProviderId::Sub2Api, "http://api.example.com")
+ .is_err()
+ );
+ assert!(
+ validate_provider_workspace_value(
+ ProviderId::Sub2Api,
+ "https://user:pass@api.example.com"
+ )
+ .is_err()
+ );
+ }
+
#[test]
fn zed_allows_only_default_cloud_endpoint() {
assert_eq!(