From 5fc17f3b09d02c46a047d6b8ad0f3f57914c64b5 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:21:38 +0700 Subject: [PATCH 1/9] Add OpenAI live usage and model limit commands --- .../src-tauri/src/commands/bridge.rs | 16 ++ .../src/commands/bridge/openai_live.rs | 68 ++++++++ .../src/commands/bridge/openai_live_tests.rs | 66 ++++++++ .../src-tauri/src/commands/mod.rs | 4 + .../src-tauri/src/commands/openai_live.rs | 151 ++++++++++++++++++ .../src/commands/openai_live_tests.rs | 146 +++++++++++++++++ .../src-tauri/src/commands/settings.rs | 25 ++- apps/desktop-tauri/src-tauri/src/main.rs | 2 + 8 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live.rs create mode 100644 apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live_tests.rs create mode 100644 apps/desktop-tauri/src-tauri/src/commands/openai_live.rs create mode 100644 apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 2fa559fb0fe..9d85ae7cb0d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -1,3 +1,6 @@ +mod openai_live; +#[cfg(test)] +mod openai_live_tests; mod openai_usage; #[cfg(test)] mod openai_usage_tests; @@ -7,6 +10,7 @@ mod status; mod throughput; #[cfg(test)] mod throughput_tests; +pub(crate) use openai_live::{ModelRateLimitSnapshot, OrgRateLimitsSnapshot}; pub(crate) use openai_usage::OpenAiApiUsageSnapshot; pub use quota_block::MonthlyLimitBlockSnapshot; pub(crate) use status::{compact_tray_status_label, friendly_provider_error}; @@ -1057,6 +1061,10 @@ pub struct SettingsSnapshot { codex_external_oauth_sources_allowed: bool, codex_local_session_cost_ledger_enabled: bool, codex_openai_web_battery_saver_enabled: bool, + /// OpenAI API Live Usage (upstream #4495): fetch Tokens/min on each refresh. + openai_live_usage_enabled: bool, + /// Manual per-model organization limits, read by the card's limit line and the editor. + openai_model_rate_limits: Vec, disable_keychain_access: bool, wayfinder_gateway_url: String, provider_metrics: std::collections::HashMap, @@ -1118,6 +1126,12 @@ impl From for SettingsSnapshot { let claude_swap_enabled = settings.claude_swap_enabled(); let claude_swap_executable_path = settings.claude_swap_executable_path().to_string(); let codex_spark_usage_visible = settings.codex_spark_usage_visible(); + let openai_live_usage_enabled = settings.live_usage_enabled(ProviderId::OpenAIApi); + let openai_model_rate_limits = settings + .model_rate_limits(ProviderId::OpenAIApi) + .iter() + .map(ModelRateLimitSnapshot::from) + .collect(); let codex_openai_web_extras = settings.codex_openai_web_extras(); let codex_historical_tracking = settings.codex_historical_tracking(); let codex_local_session_cost_ledger_enabled = @@ -1208,6 +1222,8 @@ impl From for SettingsSnapshot { stay_awake_enabled: settings.stay_awake_enabled, agent_session_ssh_hosts: settings.agent_session_ssh_hosts, hooks_enabled: settings.hooks_enabled, + openai_live_usage_enabled, + openai_model_rate_limits, http_proxy_enabled: settings.http_proxy_enabled, http_proxy_url: settings.http_proxy_url, http_proxy_username: settings.http_proxy_username, diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live.rs new file mode 100644 index 00000000000..2d78527a31b --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live.rs @@ -0,0 +1,68 @@ +//! Bridge DTOs for OpenAI Live Usage (upstream 0.75.0 #4495): the manual per-model +//! organization limits the Settings editor and the card read, and the project caps the +//! Live Usage window lists as context. +//! +//! Mirrors `ModelRateLimitSnapshot` and `OrgRateLimitsSnapshot` in `src/types/bridge.ts`. +//! The Tokens/min window itself reuses `ProviderThroughputSnapshot`. + +use codexbar::providers::openaiapi::org_limits::OrgRateLimits; +use codexbar::settings::ModelRateLimit; +use serde::{Deserialize, Serialize}; + +/// One manual organization limit, as stored in settings and sent by the editor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelRateLimitSnapshot { + pub model: String, + pub tokens_per_minute: u64, + #[serde(default)] + pub requests_per_minute: Option, +} + +impl From<&ModelRateLimit> for ModelRateLimitSnapshot { + fn from(limit: &ModelRateLimit) -> Self { + Self { + model: limit.model.clone(), + tokens_per_minute: limit.tokens_per_minute, + requests_per_minute: limit.requests_per_minute, + } + } +} + +/// Project caps discovered across the organization, sorted by model ID. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgRateLimitsSnapshot { + pub by_model: Vec, + pub projects_sampled: usize, + pub truncated: bool, + /// Epoch seconds. + pub fetched_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgModelRateLimitSnapshot { + pub model: String, + pub tokens_per_minute: u64, + pub requests_per_minute: Option, +} + +impl From<&OrgRateLimits> for OrgRateLimitsSnapshot { + fn from(limits: &OrgRateLimits) -> Self { + Self { + by_model: limits + .by_model + .iter() + .map(|(model, limit)| OrgModelRateLimitSnapshot { + model: model.clone(), + tokens_per_minute: limit.tokens_per_minute, + requests_per_minute: limit.requests_per_minute, + }) + .collect(), + projects_sampled: limits.projects_sampled, + truncated: limits.truncated, + fetched_at: limits.fetched_at, + } + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live_tests.rs new file mode 100644 index 00000000000..c91104dbdf7 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live_tests.rs @@ -0,0 +1,66 @@ +use std::collections::BTreeMap; + +use codexbar::providers::openaiapi::org_limits::{OrgRateLimit, OrgRateLimits}; +use codexbar::settings::ModelRateLimit; + +use super::openai_live::{ModelRateLimitSnapshot, OrgRateLimitsSnapshot}; + +#[test] +fn model_rate_limit_snapshot_is_camel_case_with_null_rpm() { + let limit = ModelRateLimit::new("gpt-5", 40_000_000, None); + let json = serde_json::to_value(ModelRateLimitSnapshot::from(&limit)).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "model": "gpt-5", + "tokensPerMinute": 40_000_000, + "requestsPerMinute": null, + }) + ); +} + +#[test] +fn model_rate_limit_snapshot_accepts_a_missing_rpm_from_the_editor() { + let snapshot: ModelRateLimitSnapshot = + serde_json::from_str(r#"{"model":"gpt-5","tokensPerMinute":1}"#).unwrap(); + assert_eq!(snapshot.requests_per_minute, None); + assert_eq!(snapshot.tokens_per_minute, 1); +} + +#[test] +fn org_rate_limits_snapshot_lists_models_sorted_with_camel_case_fields() { + let mut by_model = BTreeMap::new(); + by_model.insert( + "gpt-5-mini".to_string(), + OrgRateLimit { + tokens_per_minute: 2, + requests_per_minute: Some(3), + }, + ); + by_model.insert( + "gpt-4o".to_string(), + OrgRateLimit { + tokens_per_minute: 5, + requests_per_minute: None, + }, + ); + let limits = OrgRateLimits { + by_model, + projects_sampled: 2, + truncated: true, + fetched_at: 1_800, + }; + let json = serde_json::to_value(OrgRateLimitsSnapshot::from(&limits)).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "byModel": [ + { "model": "gpt-4o", "tokensPerMinute": 5, "requestsPerMinute": null }, + { "model": "gpt-5-mini", "tokensPerMinute": 2, "requestsPerMinute": 3 }, + ], + "projectsSampled": 2, + "truncated": true, + "fetchedAt": 1_800, + }) + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 793e20af21b..404715e240d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -45,6 +45,7 @@ mod diagnostics; mod grok_accounts; mod hooks; mod locale_cmd; +mod openai_live; mod preferences_transfer; mod provider_detail; mod provider_refresh; @@ -70,6 +71,7 @@ pub use diagnostics::*; pub use grok_accounts::*; pub use hooks::*; pub use locale_cmd::*; +pub use openai_live::*; pub use preferences_transfer::*; pub use provider_detail::*; #[cfg(test)] @@ -88,6 +90,8 @@ mod credential_lane_tests; #[cfg(test)] mod last_good_owner_tests; #[cfg(test)] +mod openai_live_tests; +#[cfg(test)] mod session_cookie_scope_tests; #[cfg(test)] mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/openai_live.rs b/apps/desktop-tauri/src-tauri/src/commands/openai_live.rs new file mode 100644 index 00000000000..e32143238da --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/openai_live.rs @@ -0,0 +1,151 @@ +//! OpenAI Live Usage commands (upstream 0.75.0 #4495): the on-demand Tokens/min window +//! and the organization project caps, plus the validation for manual per-model limits. +//! +//! Both fetches resolve the stored Admin key and the saved project scope the same way the +//! refresh path does. Errors are mapped to friendly text without the key or raw bodies. + +use std::collections::HashSet; + +use codexbar::providers::OpenAIApiProvider; +use codexbar::providers::openaiapi::live::LiveUsageError; +use codexbar::providers::openaiapi::org_limits::OrgRateLimitsError; +use codexbar::settings::ModelRateLimit; + +use super::bridge::{ModelRateLimitSnapshot, OrgRateLimitsSnapshot, ProviderThroughputSnapshot}; +use super::*; + +/// Largest integer a JavaScript number holds exactly; the editor cannot send more. +const MAX_RATE_LIMIT: u64 = 9_007_199_254_740_991; +const MAX_MODEL_ID_LEN: usize = 200; + +/// Friendly, key-free text for a failed Live Usage window. +pub(crate) fn live_usage_error_message(error: &LiveUsageError) -> String { + match error { + LiveUsageError::MissingCredential => { + "Add an OpenAI organization Admin API key in Settings > Providers > OpenAI to see live usage." + .to_string() + } + LiveUsageError::AdminKeyRequired => { + "Live usage needs an OpenAI organization Admin API key (sk-admin-...). Project and service-account keys cannot read organization usage." + .to_string() + } + LiveUsageError::RateLimited => { + "OpenAI live usage is rate limited. Try again in a minute.".to_string() + } + LiveUsageError::ApiFailure(code) => { + format!("OpenAI live usage API failed (HTTP {code}).") + } + LiveUsageError::ParseFailure => { + "Could not read the OpenAI live usage response.".to_string() + } + LiveUsageError::InvalidWindow => { + "OpenAI live usage supports 15-minute, 1-hour, and 6-hour windows.".to_string() + } + LiveUsageError::Network => "Could not reach the OpenAI live usage API.".to_string(), + } +} + +/// Friendly, key-free text for a failed organization limits read. +pub(crate) fn org_rate_limits_error_message(error: &OrgRateLimitsError) -> String { + match error { + OrgRateLimitsError::MissingCredential => { + "Add an OpenAI organization Admin API key to read project limits.".to_string() + } + OrgRateLimitsError::AdminKeyRequired => { + "Project limits need an OpenAI Admin API key with project read permissions.".to_string() + } + OrgRateLimitsError::RateLimited => { + "OpenAI project limits are temporarily rate limited. Try again in a minute.".to_string() + } + OrgRateLimitsError::ApiFailure(code) => { + format!("OpenAI project limits API failed (HTTP {code}).") + } + OrgRateLimitsError::ParseFailure => { + "Could not read the OpenAI project limits response.".to_string() + } + OrgRateLimitsError::Network => "Could not reach the OpenAI project limits API.".to_string(), + } +} + +/// Validates the editor's rows before they replace the stored list. Unlike the settings +/// loader, which silently drops malformed rows, a bad editor row is reported to the user. +/// A zero requests-per-minute means "no RPM limit", as upstream. +pub(crate) fn validated_model_rate_limits( + rows: &[ModelRateLimitSnapshot], +) -> Result, String> { + let mut seen = HashSet::new(); + let mut limits = Vec::with_capacity(rows.len()); + for row in rows { + let model = row.model.trim(); + if model.is_empty() { + return Err("Each rate limit needs a model ID.".to_string()); + } + if model.len() > MAX_MODEL_ID_LEN || model.chars().any(char::is_control) { + return Err(format!( + "Model IDs must be on one line and under {MAX_MODEL_ID_LEN} characters." + )); + } + if !seen.insert(model) { + return Err(format!("Model {model} is listed more than once.")); + } + if row.tokens_per_minute == 0 || row.tokens_per_minute > MAX_RATE_LIMIT { + return Err(format!( + "Tokens per minute for {model} must be between 1 and {MAX_RATE_LIMIT}." + )); + } + if row + .requests_per_minute + .is_some_and(|rpm| rpm > MAX_RATE_LIMIT) + { + return Err(format!( + "Requests per minute for {model} must be at most {MAX_RATE_LIMIT}." + )); + } + limits.push(ModelRateLimit::new( + model, + row.tokens_per_minute, + row.requests_per_minute.filter(|rpm| *rpm > 0), + )); + } + Ok(ModelRateLimit::validated(limits)) +} + +/// The saved OpenAI project scope, or `None` for the whole organization. +pub(super) fn openai_project_scope(settings: &Settings) -> Option { + let id = settings.workspace_id(ProviderId::OpenAIApi).trim(); + (!id.is_empty()).then(|| id.to_string()) +} + +/// The stored Admin/API key, read the way the refresh path reads it. Never logged. +fn stored_openai_api_key() -> Option { + ApiKeys::load() + .get(ProviderId::OpenAIApi.cli_name()) + .map(str::to_string) +} + +/// Last-hour-or-longer Tokens/min window for the OpenAI API card's Live Usage view. +/// `window_minutes` must be 15, 60 or 360. +#[tauri::command] +pub async fn get_openai_live_usage( + window_minutes: u32, +) -> Result { + let project_id = openai_project_scope(&Settings::load()); + let key = stored_openai_api_key(); + let usage = OpenAIApiProvider::new() + .fetch_live_usage(key.as_deref(), project_id.as_deref(), window_minutes) + .await + .map_err(|error| live_usage_error_message(&error))?; + Ok(ProviderThroughputSnapshot::from(&usage.throughput())) +} + +/// Project caps across the organization, shown as context in the Live Usage view. The +/// caller skips this when a project scope is saved. +#[tauri::command] +pub async fn get_openai_org_rate_limits() -> Result { + let key = stored_openai_api_key(); + let limits = OpenAIApiProvider::new() + .fetch_org_rate_limits(key.as_deref()) + .await + .map_err(|error| org_rate_limits_error_message(&error))?; + Ok(OrgRateLimitsSnapshot::from(&limits)) +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs new file mode 100644 index 00000000000..8bc08038aa1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs @@ -0,0 +1,146 @@ +use super::*; +use codexbar::providers::openaiapi::live::LiveUsageError; +use codexbar::providers::openaiapi::org_limits::OrgRateLimitsError; + +use codexbar::settings::ModelRateLimit; + +use super::bridge::ModelRateLimitSnapshot; +use super::openai_live::{ + live_usage_error_message, openai_project_scope, org_rate_limits_error_message, + validated_model_rate_limits, +}; + +fn row(model: &str, tpm: u64, rpm: Option) -> ModelRateLimitSnapshot { + ModelRateLimitSnapshot { + model: model.to_string(), + tokens_per_minute: tpm, + requests_per_minute: rpm, + } +} + +#[test] +fn live_usage_errors_map_to_friendly_text() { + assert!(live_usage_error_message(&LiveUsageError::MissingCredential).contains("Admin API key")); + assert!(live_usage_error_message(&LiveUsageError::AdminKeyRequired).contains("sk-admin")); + assert!(live_usage_error_message(&LiveUsageError::RateLimited).contains("rate limited")); + assert!( + live_usage_error_message(&LiveUsageError::ApiFailure(503)).contains("HTTP 503"), + "API failures keep the status code but no body" + ); + assert!(live_usage_error_message(&LiveUsageError::ParseFailure).contains("response")); + assert!(live_usage_error_message(&LiveUsageError::InvalidWindow).contains("15-minute")); + assert!(live_usage_error_message(&LiveUsageError::Network).contains("reach")); +} + +#[test] +fn org_rate_limit_errors_map_to_friendly_text() { + assert!( + org_rate_limits_error_message(&OrgRateLimitsError::MissingCredential) + .contains("Admin API key") + ); + assert!( + org_rate_limits_error_message(&OrgRateLimitsError::AdminKeyRequired) + .contains("project read") + ); + assert!( + org_rate_limits_error_message(&OrgRateLimitsError::RateLimited).contains("rate limited") + ); + assert!( + org_rate_limits_error_message(&OrgRateLimitsError::ApiFailure(500)).contains("HTTP 500") + ); + assert!(org_rate_limits_error_message(&OrgRateLimitsError::ParseFailure).contains("response")); + assert!(org_rate_limits_error_message(&OrgRateLimitsError::Network).contains("reach")); +} + +#[test] +fn validated_rows_trim_drop_zero_rpm_and_keep_order() { + let limits = validated_model_rate_limits(&[ + row(" gpt-5 ", 40_000_000, Some(0)), + row("gpt-4o", 2_000_000, Some(5_000)), + ]) + .unwrap(); + assert_eq!( + limits, + vec![ + ModelRateLimit::new("gpt-5", 40_000_000, None), + ModelRateLimit::new("gpt-4o", 2_000_000, Some(5_000)), + ] + ); +} + +#[test] +fn validated_rows_reject_bad_input_instead_of_dropping_it() { + let empty = validated_model_rate_limits(&[row(" ", 1, None)]).unwrap_err(); + assert!(empty.contains("model ID")); + + let zero = validated_model_rate_limits(&[row("gpt-5", 0, None)]).unwrap_err(); + assert!(zero.contains("gpt-5")); + + let too_big = + validated_model_rate_limits(&[row("gpt-5", 9_007_199_254_740_992, None)]).unwrap_err(); + assert!(too_big.contains("Tokens per minute")); + + let rpm_too_big = + validated_model_rate_limits(&[row("gpt-5", 1, Some(9_007_199_254_740_992))]).unwrap_err(); + assert!(rpm_too_big.contains("Requests per minute")); + + let duplicate = + validated_model_rate_limits(&[row("gpt-5", 1, None), row(" gpt-5", 2, None)]).unwrap_err(); + assert!(duplicate.contains("more than once")); + + let multiline = validated_model_rate_limits(&[row("gpt-\n5", 1, None)]).unwrap_err(); + assert!(multiline.contains("one line")); +} + +#[test] +fn saving_limits_and_the_live_switch_round_trips_through_settings() { + let mut settings = Settings::default(); + SettingsUpdate { + openai_live_usage_enabled: Some(false), + openai_model_rate_limits: Some(vec![row(" gpt-5 ", 40_000_000, Some(0))]), + ..Default::default() + } + .apply_openai_live_settings(&mut settings) + .unwrap(); + + assert!(!settings.live_usage_enabled(ProviderId::OpenAIApi)); + assert_eq!( + settings.model_rate_limits(ProviderId::OpenAIApi).to_vec(), + vec![ModelRateLimit::new("gpt-5", 40_000_000, None)] + ); + + // A rejected editor row leaves the stored list as it was. + let rejected = SettingsUpdate { + openai_model_rate_limits: Some(vec![row("", 1, None)]), + ..Default::default() + } + .apply_openai_live_settings(&mut settings); + assert!(rejected.is_err()); + assert_eq!(settings.model_rate_limits(ProviderId::OpenAIApi).len(), 1); + + // An empty list clears the limits. + SettingsUpdate { + openai_model_rate_limits: Some(Vec::new()), + ..Default::default() + } + .apply_openai_live_settings(&mut settings) + .unwrap(); + assert!(settings.model_rate_limits(ProviderId::OpenAIApi).is_empty()); +} + +#[test] +fn live_usage_defaults_on_and_is_not_offered_for_other_providers() { + let settings = Settings::default(); + assert!(settings.live_usage_enabled(ProviderId::OpenAIApi)); + assert!(!settings.live_usage_enabled(ProviderId::Codex)); +} + +#[test] +fn project_scope_is_trimmed_and_empty_means_organization() { + let mut settings = Settings::default(); + assert_eq!(openai_project_scope(&settings), None); + settings.set_workspace_id(ProviderId::OpenAIApi, " "); + assert_eq!(openai_project_scope(&settings), None); + settings.set_workspace_id(ProviderId::OpenAIApi, " proj_123 "); + assert_eq!(openai_project_scope(&settings).as_deref(), Some("proj_123")); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index fdcaf18209f..0ca3a556d96 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -88,6 +88,11 @@ pub struct SettingsUpdate { pub codex_external_oauth_sources_allowed: Option, pub codex_local_session_cost_ledger_enabled: Option, pub codex_openai_web_battery_saver_enabled: Option, + /// OpenAI API: fetch the last hour of Tokens/min on each refresh (upstream #4495). + pub openai_live_usage_enabled: Option, + /// OpenAI API: manual per-model organization limits; replaces the stored list, and an + /// empty list clears it. + pub openai_model_rate_limits: Option>, pub disable_keychain_access: Option, /// Map of provider CLI name → metric preference label. pub provider_metrics: Option>, @@ -130,6 +135,7 @@ impl SettingsUpdate { self.enabled_providers.is_some() || self.claude_allow_reading_claude_code_credentials.is_some() || self.codex_openai_web_extras.is_some() + || self.openai_live_usage_enabled.is_some() || self.codex_external_oauth_sources_allowed.is_some() || self.alibaba_token_plan_region.is_some() || self.copilot_seat_credit_entitlement.is_some() @@ -220,6 +226,22 @@ impl SettingsUpdate { Ok(()) } + /// OpenAI API Live Usage switches. Limits are validated strictly: a bad row is + /// reported and nothing is saved. + pub(super) fn apply_openai_live_settings( + self, + settings: &mut Settings, + ) -> Result { + if let Some(v) = self.openai_live_usage_enabled { + settings.set_live_usage_enabled(ProviderId::OpenAIApi, v); + } + if let Some(rows) = self.openai_model_rate_limits.as_deref() { + let limits = super::openai_live::validated_model_rate_limits(rows)?; + settings.set_model_rate_limits(ProviderId::OpenAIApi, limits); + } + Ok(self) + } + fn apply_provider_settings(self, settings: &mut Settings) -> Self { if let Some(providers) = self.enabled_providers.clone() { settings.enabled_providers = providers.into_iter().collect::>(); @@ -609,7 +631,8 @@ impl SettingsUpdate { settings.set_seat_credit_entitlement(codexbar::core::ProviderId::Copilot, value)?; } let float_bar_patch = self.float_bar_patch(); - self.apply_provider_settings(settings) + self.apply_openai_live_settings(settings)? + .apply_provider_settings(settings) .apply_general_settings(settings)? .apply_display_settings(settings) .apply_notification_settings(settings)? diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 2b5f02b9de5..deeff0ab02f 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -288,6 +288,8 @@ fn main() { commands::set_provider_usage_source, commands::set_provider_auto_resume_after_quota_reset, commands::set_provider_optional_details, + commands::get_openai_live_usage, + commands::get_openai_org_rate_limits, commands::has_openrouter_management_api_key, commands::set_openrouter_management_api_key, commands::remove_openrouter_management_api_key, From 78581ed5c4f0b74f23386c867f97b63f69d7dc25 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:44:54 +0700 Subject: [PATCH 2/9] Add OpenAI Live Usage bridge types and helpers Typed DTOs and invoke wrappers for the Live Usage window and org limits, plus pure helpers for the Tokens/min grid, the manual limit editor drafts, and the limit line. --- .../src/lib/openaiThroughput.test.ts | 166 ++++++++++++++++++ .../desktop-tauri/src/lib/openaiThroughput.ts | 159 +++++++++++++++++ apps/desktop-tauri/src/lib/tauri.ts | 12 ++ apps/desktop-tauri/src/types/bridge.ts | 31 ++++ 4 files changed, 368 insertions(+) create mode 100644 apps/desktop-tauri/src/lib/openaiThroughput.test.ts create mode 100644 apps/desktop-tauri/src/lib/openaiThroughput.ts diff --git a/apps/desktop-tauri/src/lib/openaiThroughput.test.ts b/apps/desktop-tauri/src/lib/openaiThroughput.test.ts new file mode 100644 index 00000000000..5a3dab8c89d --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiThroughput.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import type { + ModelRateLimitSnapshot, + ProviderThroughputSnapshot, + ThroughputModelSnapshot, +} from "../types/bridge"; +import { + draftsFromLimits, + hasReportedTraffic, + type LimitDraft, + MAX_RATE_LIMIT, + parseLimitDrafts, + throughputLimitLine, + tpmBars, +} from "./openaiThroughput"; + +const START = 1_788_220_800; + +function model(name: string, overrides: Partial = {}): ThroughputModelSnapshot { + return { + name, + latestTpm: 0, + peakTpm: 0, + totalTokens: 0, + peakLimitCountedTokens: 0, + limitCountedTokensByMinute: [], + ...overrides, + }; +} + +/** Three reported minutes, then two pending minutes after the frontier. */ +function throughput(overrides: Partial = {}): ProviderThroughputSnapshot { + return { + minutes: [ + { start: START, totalTokens: 1_000, requests: 1, isPending: false }, + { start: START + 60, totalTokens: 4_000, requests: 2, isPending: false }, + { start: START + 120, totalTokens: 2_000, requests: 1, isPending: false }, + { start: START + 180, totalTokens: 0, requests: 0, isPending: true }, + { start: START + 240, totalTokens: 0, requests: 0, isPending: true }, + ], + frontier: START + 120, + latestTokensPerMinute: 2_000, + peakTokensPerMinute: 4_000, + peakAt: START + 60, + fiveMinuteAverageTokensPerMinute: 2_333, + models: [], + windowMinutes: 60, + fetchedAt: START + 300, + projectId: null, + ...overrides, + }; +} + +describe("tpmBars", () => { + it("keeps the regular grid order and flags pending minutes", () => { + const bars = tpmBars(throughput()); + expect(bars.map((bar) => bar.tokens)).toEqual([1_000, 4_000, 2_000, 0, 0]); + expect(bars.map((bar) => bar.isPending)).toEqual([false, false, false, true, true]); + }); +}); + +describe("hasReportedTraffic", () => { + it("ignores pending minutes", () => { + const pendingOnly = throughput({ + minutes: [{ start: START, totalTokens: 0, requests: 0, isPending: true }], + }); + expect(hasReportedTraffic(pendingOnly)).toBe(false); + expect(hasReportedTraffic(throughput())).toBe(true); + }); +}); + +describe("throughputLimitLine", () => { + it("picks the busiest model that has a manual limit", () => { + const snapshot = throughput({ + models: [ + model("gpt-5", { peakLimitCountedTokens: 3_000 }), + model("gpt-4o", { peakLimitCountedTokens: 9_000 }), + model("unlimited", { peakLimitCountedTokens: 50_000 }), + ], + }); + const limits: ModelRateLimitSnapshot[] = [ + { model: "gpt-5", tokensPerMinute: 10_000 }, + { model: "gpt-4o", tokensPerMinute: 12_000 }, + ]; + expect(throughputLimitLine(snapshot, limits)).toEqual({ + model: "gpt-4o", + peakTokens: 9_000, + tokensPerMinuteLimit: 12_000, + percent: 75, + }); + }); + + it("returns null without a matching limit", () => { + const snapshot = throughput({ models: [model("gpt-5", { peakLimitCountedTokens: 1 })] }); + expect(throughputLimitLine(snapshot, [])).toBeNull(); + expect(throughputLimitLine(snapshot, [{ model: "other", tokensPerMinute: 10 }])).toBeNull(); + }); + + it("never applies organization limits to a project-scoped snapshot", () => { + const scoped = throughput({ + projectId: "proj_1", + models: [model("gpt-5", { peakLimitCountedTokens: 9_000 })], + }); + expect(throughputLimitLine(scoped, [{ model: "gpt-5", tokensPerMinute: 10_000 }])).toBeNull(); + }); +}); + +describe("parseLimitDrafts", () => { + const row = ( + model: string, + tokensPerMinute: string, + requestsPerMinute = "", + ): LimitDraft => ({ model, tokensPerMinute, requestsPerMinute }); + + it("trims rows, maps an empty or zero requests field to no limit, and keeps order", () => { + const result = parseLimitDrafts([ + row(" gpt-5 ", "40000000", "0"), + row("gpt-4o", " 2000000 ", " 5000 "), + row("o3", "1"), + ]); + expect(result).toEqual({ + ok: true, + limits: [ + { model: "gpt-5", tokensPerMinute: 40_000_000, requestsPerMinute: null }, + { model: "gpt-4o", tokensPerMinute: 2_000_000, requestsPerMinute: 5_000 }, + { model: "o3", tokensPerMinute: 1, requestsPerMinute: null }, + ], + }); + }); + + it("reports a bad row by its error key and index instead of dropping it", () => { + expect(parseLimitDrafts([row("", "1")])).toEqual({ + ok: false, + error: "OpenAiLimitErrorModel", + index: 0, + }); + expect(parseLimitDrafts([row("gpt-5", "1"), row("gpt-5", "2")])).toEqual({ + ok: false, + error: "OpenAiLimitErrorDuplicate", + index: 1, + }); + expect(parseLimitDrafts([row("gpt-5", "0")])).toMatchObject({ error: "OpenAiLimitErrorTpm" }); + expect(parseLimitDrafts([row("gpt-5", "1.5")])).toMatchObject({ error: "OpenAiLimitErrorTpm" }); + expect(parseLimitDrafts([row("gpt-5", "abc")])).toMatchObject({ error: "OpenAiLimitErrorTpm" }); + expect(parseLimitDrafts([row("gpt-5", String(MAX_RATE_LIMIT + 1))])).toMatchObject({ + error: "OpenAiLimitErrorTpm", + }); + expect(parseLimitDrafts([row("gpt-5", "1", "x")])).toMatchObject({ error: "OpenAiLimitErrorRpm" }); + expect(parseLimitDrafts([row("gpt-\n5", "1")])).toMatchObject({ error: "OpenAiLimitErrorLine" }); + expect(parseLimitDrafts([row("m".repeat(201), "1")])).toMatchObject({ + error: "OpenAiLimitErrorLine", + }); + }); + + it("accepts an empty list, which clears the stored limits", () => { + expect(parseLimitDrafts([])).toEqual({ ok: true, limits: [] }); + }); + + it("round-trips saved limits through the editor drafts", () => { + const saved: ModelRateLimitSnapshot[] = [ + { model: "gpt-5", tokensPerMinute: 40_000_000, requestsPerMinute: null }, + { model: "gpt-4o", tokensPerMinute: 2_000_000, requestsPerMinute: 5_000 }, + ]; + expect(parseLimitDrafts(draftsFromLimits(saved))).toEqual({ ok: true, limits: saved }); + }); +}); diff --git a/apps/desktop-tauri/src/lib/openaiThroughput.ts b/apps/desktop-tauri/src/lib/openaiThroughput.ts new file mode 100644 index 00000000000..65ffdb069e5 --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiThroughput.ts @@ -0,0 +1,159 @@ +import type { LocaleKey } from "../i18n/keys"; +import type { + ModelRateLimitSnapshot, + ProviderThroughputSnapshot, +} from "../types/bridge"; + +/** + * Pure helpers for the OpenAI API Tokens/min chart and the manual per-model + * limit editor (upstream 0.75.0 #4495). The shell validates the same rules + * again on save; these keep the editor from sending rows it would reject. + */ + +/** Largest integer a JavaScript number holds exactly. */ +export const MAX_RATE_LIMIT = Number.MAX_SAFE_INTEGER; +export const MAX_MODEL_ID_LENGTH = 200; + +export const LIVE_WINDOW_MINUTES = [15, 60, 360] as const; +export type LiveWindowMinutes = (typeof LIVE_WINDOW_MINUTES)[number]; + +export interface TpmBar { + /** Minute start, epoch seconds. */ + start: number; + tokens: number; + /** Not reported yet: drawn as pending, never as a zero or a peak. */ + isPending: boolean; +} + +/** The regular one-minute grid, oldest first. */ +export function tpmBars(snapshot: ProviderThroughputSnapshot): TpmBar[] { + return snapshot.minutes.map((minute) => ({ + start: minute.start, + tokens: minute.totalTokens, + isPending: minute.isPending, + })); +} + +/** True when at least one reported (non-pending) minute carries tokens. */ +export function hasReportedTraffic(snapshot: ProviderThroughputSnapshot): boolean { + return snapshot.minutes.some((minute) => !minute.isPending && minute.totalTokens > 0); +} + +export interface ThroughputLimitLine { + model: string; + /** Peak non-pending limit-counted tokens in one minute. */ + peakTokens: number; + tokensPerMinuteLimit: number; + percent: number; +} + +/** + * The limit line for the busiest model that has a manual limit, mirroring + * `throughput_limit_utilization`. Project-scoped snapshots never get one (their + * caps are per project, not per organization). Matches model IDs exactly; the + * shell's shared-pool aliasing is not mirrored here. + */ +export function throughputLimitLine( + snapshot: ProviderThroughputSnapshot, + limits: readonly ModelRateLimitSnapshot[], +): ThroughputLimitLine | null { + if (snapshot.projectId !== null) return null; + let best: ThroughputLimitLine | null = null; + for (const model of snapshot.models) { + const limit = limits.find((row) => row.model === model.name); + if (!limit || limit.tokensPerMinute <= 0) continue; + const percent = (model.peakLimitCountedTokens / limit.tokensPerMinute) * 100; + // A tie keeps the earlier model, as the shell does. + if (!best || percent > best.percent) { + best = { + model: model.name, + peakTokens: model.peakLimitCountedTokens, + tokensPerMinuteLimit: limit.tokensPerMinute, + percent, + }; + } + } + return best; +} + +/** One editor row. Numbers stay strings until validation. */ +export interface LimitDraft { + model: string; + tokensPerMinute: string; + requestsPerMinute: string; +} + +export type LimitDraftResult = + | { ok: true; limits: ModelRateLimitSnapshot[] } + | { ok: false; error: LocaleKey; index: number }; + +export function draftsFromLimits(limits: readonly ModelRateLimitSnapshot[]): LimitDraft[] { + return limits.map((limit) => ({ + model: limit.model, + tokensPerMinute: String(limit.tokensPerMinute), + requestsPerMinute: + limit.requestsPerMinute == null ? "" : String(limit.requestsPerMinute), + })); +} + +const WHOLE_NUMBER = /^\d+$/; + +/** + * Validates every row before save. A bad row is reported with its index; the + * caller keeps the editor state so nothing is dropped silently. A requests + * limit of 0 or an empty field means "no requests limit", as upstream. + */ +export function parseLimitDrafts(drafts: readonly LimitDraft[]): LimitDraftResult { + const seen = new Set(); + const limits: ModelRateLimitSnapshot[] = []; + for (let index = 0; index < drafts.length; index += 1) { + const draft = drafts[index]; + const model = draft.model.trim(); + if (model.length === 0) return { ok: false, error: "OpenAiLimitErrorModel", index }; + if (model.length > MAX_MODEL_ID_LENGTH || /[\u0000-\u001f\u007f]/.test(model)) { + return { ok: false, error: "OpenAiLimitErrorLine", index }; + } + if (seen.has(model)) return { ok: false, error: "OpenAiLimitErrorDuplicate", index }; + seen.add(model); + + const tpm = draft.tokensPerMinute.trim(); + const tokensPerMinute = Number(tpm); + if ( + !WHOLE_NUMBER.test(tpm) || + tokensPerMinute < 1 || + tokensPerMinute > MAX_RATE_LIMIT + ) { + return { ok: false, error: "OpenAiLimitErrorTpm", index }; + } + + const rpm = draft.requestsPerMinute.trim(); + let requestsPerMinute: number | null = null; + if (rpm.length > 0) { + if (!WHOLE_NUMBER.test(rpm) || Number(rpm) > MAX_RATE_LIMIT) { + return { ok: false, error: "OpenAiLimitErrorRpm", index }; + } + requestsPerMinute = Number(rpm) > 0 ? Number(rpm) : null; + } + + limits.push({ model, tokensPerMinute, requestsPerMinute }); + } + return { ok: true, limits }; +} + +const compactFormat = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +/** Compact token count, e.g. 1.2K or 3.4M. */ +export function formatTokens(value: number): string { + return compactFormat.format(value); +} + +/** Local clock time of an epoch-seconds value. */ +export function formatClock(epochSeconds: number): string { + return new Date(epochSeconds * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index e752e57912e..c007452ba8e 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -49,6 +49,8 @@ import type { CodexSwitchResult, DeepSeekPricingStatus, CurrencyRatesSnapshot, + OrgRateLimitsSnapshot, + ProviderThroughputSnapshot, } from "../types/bridge"; export const claudeAccountsList = () => invoke("get_claude_accounts_state"); @@ -458,6 +460,16 @@ export function setProviderOptionalDetails( return invoke("set_provider_optional_details", { providerId, enabled }); } +/** On-demand Tokens/min window for OpenAI API Live Usage; 15, 60 or 360 minutes. */ +export function getOpenAiLiveUsage(windowMinutes: 15 | 60 | 360): Promise { + return invoke("get_openai_live_usage", { windowMinutes }); +} + +/** Organization project caps; the caller skips this when a project scope is saved. */ +export function getOpenAiOrgRateLimits(): Promise { + return invoke("get_openai_org_rate_limits"); +} + export function setProviderAutoResumeAfterQuotaReset( providerId: string, enabled: boolean, diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 427b0362fec..813df026a29 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -306,6 +306,10 @@ export interface SettingsSnapshot { codexExternalOauthSourcesAllowed?: boolean; codexLocalSessionCostLedgerEnabled?: boolean; codexOpenaiWebBatterySaverEnabled?: boolean; + /** OpenAI API Live Usage (on by default); absent on older shells. */ + openAiLiveUsageEnabled?: boolean; + /** OpenAI API manual per-model organization limits. */ + openAiModelRateLimits?: ModelRateLimitSnapshot[]; disableKeychainAccess: boolean; wayfinderGatewayUrl?: string; providerMetrics: Record; @@ -454,6 +458,10 @@ export interface SettingsUpdate { codexExternalOauthSourcesAllowed?: boolean; codexLocalSessionCostLedgerEnabled?: boolean; codexOpenaiWebBatterySaverEnabled?: boolean; + /** Fetch the last hour of OpenAI API Tokens/min on each refresh. */ + openAiLiveUsageEnabled?: boolean; + /** Replaces the stored OpenAI API limit list; an empty list clears it. */ + openAiModelRateLimits?: ModelRateLimitSnapshot[]; disableKeychainAccess?: boolean; /** Map of provider CLI name → metric preference label. */ providerMetrics?: Record; @@ -1111,6 +1119,29 @@ export interface ProviderThroughputSnapshot { projectId: string | null; } +/** Manual organization limit for one model (OpenAI API Live Usage, upstream #4495). */ +export interface ModelRateLimitSnapshot { + model: string; + tokensPerMinute: number; + /** `null` or absent means no requests-per-minute limit. */ + requestsPerMinute?: number | null; +} + +export interface OrgModelRateLimitSnapshot { + model: string; + tokensPerMinute: number; + requestsPerMinute: number | null; +} + +/** Project caps read across the organization, sorted by model ID. */ +export interface OrgRateLimitsSnapshot { + byModel: OrgModelRateLimitSnapshot[]; + projectsSampled: number; + truncated: boolean; + /** Epoch seconds. */ + fetchedAt: number; +} + export interface RefreshCompletePayload { providerCount: number; errorCount: number; From 813ea12f8849824c565fec48dd6207aefaba2fd4 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:44:59 +0700 Subject: [PATCH 3/9] Add OpenAI Live Usage locale strings Thirty-six keys for the Tokens/min grid, the model limits editor, and the org caps list, translated in every locale with matching placeholders. --- apps/desktop-tauri/src/i18n/keys.ts | 36 +++++++++++++++++++++++++++++ rust/src/locale.rs | 36 +++++++++++++++++++++++++++++ rust/src/locale/en-US.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/es-MX.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/ja-JP.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/ko-KR.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/pt-BR.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/ru-RU.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/tr-TR.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/uk-UA.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/zh-CN.ftl | 36 +++++++++++++++++++++++++++++ rust/src/locale/zh-TW.ftl | 36 +++++++++++++++++++++++++++++ 12 files changed, 432 insertions(+) diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 57c0f2296ab..0ac86903c51 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -873,6 +873,42 @@ export const ALL_LOCALE_KEYS = [ "OpenAIChartTitle", "OpenAIChartMetricCost", "OpenAIChartMetricTokens", + "OpenAiLiveUsageTitle", + "OpenAiLiveUsageToggle", + "OpenAiLiveUsageToggleHelp", + "OpenAiLiveUsageLoad", + "OpenAiLiveUsageLoading", + "OpenAiLiveWindowLabel", + "OpenAiLiveWindow15", + "OpenAiLiveWindow60", + "OpenAiLiveWindow360", + "OpenAiTpmChartTitle", + "OpenAiTpmLatest", + "OpenAiTpmPeak", + "OpenAiTpmFiveMinuteAverage", + "OpenAiTpmFrontier", + "OpenAiTpmPendingLegend", + "OpenAiTpmEmpty", + "OpenAiTpmModels", + "OpenAiTpmLimitLine", + "OpenAiLimitsTitle", + "OpenAiLimitsHelp", + "OpenAiLimitModelLabel", + "OpenAiLimitTpmLabel", + "OpenAiLimitRpmLabel", + "OpenAiLimitAdd", + "OpenAiLimitRemove", + "OpenAiLimitSave", + "OpenAiLimitErrorModel", + "OpenAiLimitErrorLine", + "OpenAiLimitErrorDuplicate", + "OpenAiLimitErrorTpm", + "OpenAiLimitErrorRpm", + "OpenAiOrgLimitsTitle", + "OpenAiOrgLimitsLoad", + "OpenAiOrgLimitsEmpty", + "OpenAiOrgLimitsScoped", + "OpenAiOrgLimitsTruncated", "OpenAIChartHint", "OpenAIChartRequests", "OpenAIChartInputTokens", diff --git a/rust/src/locale.rs b/rust/src/locale.rs index a6b2a385b96..84f8f88cbb4 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1151,6 +1151,42 @@ locale_keys! { OpenAIChartTitle, OpenAIChartMetricCost, OpenAIChartMetricTokens, + OpenAiLiveUsageTitle, + OpenAiLiveUsageToggle, + OpenAiLiveUsageToggleHelp, + OpenAiLiveUsageLoad, + OpenAiLiveUsageLoading, + OpenAiLiveWindowLabel, + OpenAiLiveWindow15, + OpenAiLiveWindow60, + OpenAiLiveWindow360, + OpenAiTpmChartTitle, + OpenAiTpmLatest, + OpenAiTpmPeak, + OpenAiTpmFiveMinuteAverage, + OpenAiTpmFrontier, + OpenAiTpmPendingLegend, + OpenAiTpmEmpty, + OpenAiTpmModels, + OpenAiTpmLimitLine, + OpenAiLimitsTitle, + OpenAiLimitsHelp, + OpenAiLimitModelLabel, + OpenAiLimitTpmLabel, + OpenAiLimitRpmLabel, + OpenAiLimitAdd, + OpenAiLimitRemove, + OpenAiLimitSave, + OpenAiLimitErrorModel, + OpenAiLimitErrorLine, + OpenAiLimitErrorDuplicate, + OpenAiLimitErrorTpm, + OpenAiLimitErrorRpm, + OpenAiOrgLimitsTitle, + OpenAiOrgLimitsLoad, + OpenAiOrgLimitsEmpty, + OpenAiOrgLimitsScoped, + OpenAiOrgLimitsTruncated, OpenAIChartHint, OpenAIChartRequests, OpenAIChartInputTokens, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index b83dbe84c07..6b16084eff2 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1657,3 +1657,39 @@ UsageItemPlanRenewalExpiry = Plan renewal / expiry UsageItemHiddenByCostSummary = Hidden while Cost summary display is set to Hidden. PlanRenewsOn = Renews: { "{}" } PlanExpiresOn = Plan expires: { "{}" } +OpenAiLiveUsageTitle = Live usage +OpenAiLiveUsageToggle = Live usage (Tokens per minute) +OpenAiLiveUsageToggleHelp = Reads the last hour of Tokens per minute on each refresh. Needs an organization Admin API key and adds one usage request per refresh. +OpenAiLiveUsageLoad = Load live usage +OpenAiLiveUsageLoading = Loading live usage... +OpenAiLiveWindowLabel = Window +OpenAiLiveWindow15 = 15 minutes +OpenAiLiveWindow60 = 1 hour +OpenAiLiveWindow360 = 6 hours +OpenAiTpmChartTitle = Tokens per minute +OpenAiTpmLatest = Latest +OpenAiTpmPeak = Peak +OpenAiTpmFiveMinuteAverage = 5-minute average +OpenAiTpmFrontier = Reported through +OpenAiTpmPendingLegend = Pending: not reported yet +OpenAiTpmEmpty = No traffic reported in this window yet. +OpenAiTpmModels = Models +OpenAiTpmLimitLine = { "{}" } peaked at { "{}" }% of its { "{}" } tokens-per-minute limit +OpenAiLimitsTitle = Model rate limits +OpenAiLimitsHelp = Enter your organization's per-model limits from the OpenAI dashboard to see utilization. Leave requests per minute empty when the model has no request limit. +OpenAiLimitModelLabel = Model ID +OpenAiLimitTpmLabel = Tokens per minute +OpenAiLimitRpmLabel = Requests per minute +OpenAiLimitAdd = Add model limit +OpenAiLimitRemove = Remove +OpenAiLimitSave = Save model limits +OpenAiLimitErrorModel = Enter a model ID for every limit. +OpenAiLimitErrorLine = Model IDs must be on one line and under 200 characters. +OpenAiLimitErrorDuplicate = Each model can have only one limit. +OpenAiLimitErrorTpm = Tokens per minute must be a whole number from 1 up to 9007199254740991. +OpenAiLimitErrorRpm = Requests per minute must be empty or a whole number. +OpenAiOrgLimitsTitle = Organization project caps +OpenAiOrgLimitsLoad = Load project caps +OpenAiOrgLimitsEmpty = No project caps were found. +OpenAiOrgLimitsScoped = Project caps apply to the whole organization. Clear the project ID to load them. +OpenAiOrgLimitsTruncated = Only some projects were checked. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index b8bce674f9f..3bb72a68ca9 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1654,3 +1654,39 @@ UsageItemPlanRenewalExpiry = Renovación / vencimiento del plan UsageItemHiddenByCostSummary = Oculto mientras el estilo de resumen de costos está en Oculto. PlanRenewsOn = Se renueva: { "{}" } PlanExpiresOn = El plan vence: { "{}" } +OpenAiLiveUsageTitle = Uso en vivo +OpenAiLiveUsageToggle = Uso en vivo (tokens por minuto) +OpenAiLiveUsageToggleHelp = En cada actualización lee la última hora de tokens por minuto. Requiere una clave de Admin API de la organización y agrega una solicitud de uso por actualización. +OpenAiLiveUsageLoad = Cargar uso en vivo +OpenAiLiveUsageLoading = Cargando uso en vivo… +OpenAiLiveWindowLabel = Ventana +OpenAiLiveWindow15 = 15 minutos +OpenAiLiveWindow60 = 1 hora +OpenAiLiveWindow360 = 6 horas +OpenAiTpmChartTitle = Tokens por minuto +OpenAiTpmLatest = Último +OpenAiTpmPeak = Pico +OpenAiTpmFiveMinuteAverage = Promedio de 5 minutos +OpenAiTpmFrontier = Reportado hasta +OpenAiTpmPendingLegend = Pendiente: aún no reportado +OpenAiTpmEmpty = Aún no hay tráfico reportado en esta ventana. +OpenAiTpmModels = Modelos +OpenAiTpmLimitLine = El pico de { "{}" } usó el { "{}" }% de su límite de tokens por minuto ({ "{}" }) +OpenAiLimitsTitle = Límites de tasa por modelo +OpenAiLimitsHelp = Escribe los límites por modelo de tu organización desde el panel de OpenAI para ver el uso. Deja solicitudes por minuto vacío si el modelo no tiene límite de solicitudes. +OpenAiLimitModelLabel = ID del modelo +OpenAiLimitTpmLabel = Tokens por minuto +OpenAiLimitRpmLabel = Solicitudes por minuto +OpenAiLimitAdd = Agregar límite de modelo +OpenAiLimitRemove = Quitar +OpenAiLimitSave = Guardar límites de modelos +OpenAiLimitErrorModel = Escribe un ID de modelo para cada límite. +OpenAiLimitErrorLine = Los IDs de modelo deben estar en una sola línea y tener menos de 200 caracteres. +OpenAiLimitErrorDuplicate = Cada modelo puede tener un solo límite. +OpenAiLimitErrorTpm = Los tokens por minuto deben ser un número entero de 1 a 9007199254740991. +OpenAiLimitErrorRpm = Las solicitudes por minuto deben estar vacías o ser un número entero. +OpenAiOrgLimitsTitle = Límites de proyectos de la organización +OpenAiOrgLimitsLoad = Cargar límites de proyectos +OpenAiOrgLimitsEmpty = No se encontraron límites de proyectos. +OpenAiOrgLimitsScoped = Los límites de proyecto se aplican a toda la organización. Borra el ID de proyecto para cargarlos. +OpenAiOrgLimitsTruncated = Solo se revisaron algunos proyectos. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index b79d46f0b25..4c9f7e297e3 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1655,3 +1655,39 @@ UsageItemPlanRenewalExpiry = プランの更新日 / 有効期限 UsageItemHiddenByCostSummary = 費用サマリー表示スタイルが「非表示」の間は表示されません。 PlanRenewsOn = 更新日: { "{}" } PlanExpiresOn = プランの有効期限: { "{}" } +OpenAiLiveUsageTitle = ライブ使用量 +OpenAiLiveUsageToggle = ライブ使用量(毎分トークン数) +OpenAiLiveUsageToggleHelp = 更新のたびに直近1時間の毎分トークン数を取得します。組織の Admin API キーが必要で、更新ごとに使用量リクエストが1回増えます。 +OpenAiLiveUsageLoad = ライブ使用量を読み込む +OpenAiLiveUsageLoading = ライブ使用量を読み込み中… +OpenAiLiveWindowLabel = 期間 +OpenAiLiveWindow15 = 15分 +OpenAiLiveWindow60 = 1時間 +OpenAiLiveWindow360 = 6時間 +OpenAiTpmChartTitle = 毎分トークン数 +OpenAiTpmLatest = 最新 +OpenAiTpmPeak = ピーク +OpenAiTpmFiveMinuteAverage = 5分平均 +OpenAiTpmFrontier = 報告済みの時刻 +OpenAiTpmPendingLegend = 保留中:まだ報告されていません +OpenAiTpmEmpty = この期間にはまだ報告されたトラフィックがありません。 +OpenAiTpmModels = モデル +OpenAiTpmLimitLine = { "{}" } のピークは上限の { "{}" }%(毎分 { "{}" } トークン) +OpenAiLimitsTitle = モデル別レート制限 +OpenAiLimitsHelp = OpenAI ダッシュボードで確認した組織のモデル別上限を入力すると、使用率が表示されます。モデルにリクエスト数の上限がない場合、毎分リクエスト数は空欄のままにしてください。 +OpenAiLimitModelLabel = モデル ID +OpenAiLimitTpmLabel = 毎分トークン数 +OpenAiLimitRpmLabel = 毎分リクエスト数 +OpenAiLimitAdd = モデルの上限を追加 +OpenAiLimitRemove = 削除 +OpenAiLimitSave = モデルの上限を保存 +OpenAiLimitErrorModel = 各上限にモデル ID を入力してください。 +OpenAiLimitErrorLine = モデル ID は1行で、200文字未満にしてください。 +OpenAiLimitErrorDuplicate = 1つのモデルに設定できる上限は1つだけです。 +OpenAiLimitErrorTpm = 毎分トークン数は 1 から 9007199254740991 までの整数で入力してください。 +OpenAiLimitErrorRpm = 毎分リクエスト数は空欄か整数で入力してください。 +OpenAiOrgLimitsTitle = 組織のプロジェクト上限 +OpenAiOrgLimitsLoad = プロジェクト上限を読み込む +OpenAiOrgLimitsEmpty = プロジェクト上限は見つかりませんでした。 +OpenAiOrgLimitsScoped = プロジェクト上限は組織全体に適用されます。読み込むにはプロジェクト ID を空にしてください。 +OpenAiOrgLimitsTruncated = 一部のプロジェクトのみ確認しました。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 23b1fad68b8..5e6b880b7d3 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1654,3 +1654,39 @@ UsageItemPlanRenewalExpiry = 요금제 갱신 / 만료 UsageItemHiddenByCostSummary = 비용 요약 표시 스타일이 숨김이면 표시되지 않습니다. PlanRenewsOn = 갱신: { "{}" } PlanExpiresOn = 요금제 만료: { "{}" } +OpenAiLiveUsageTitle = 실시간 사용량 +OpenAiLiveUsageToggle = 실시간 사용량(분당 토큰) +OpenAiLiveUsageToggleHelp = 새로 고칠 때마다 최근 1시간의 분당 토큰 수를 가져옵니다. 조직 Admin API 키가 필요하며, 새로 고칠 때마다 사용량 요청이 하나 추가됩니다. +OpenAiLiveUsageLoad = 실시간 사용량 불러오기 +OpenAiLiveUsageLoading = 실시간 사용량을 불러오는 중… +OpenAiLiveWindowLabel = 기간 +OpenAiLiveWindow15 = 15분 +OpenAiLiveWindow60 = 1시간 +OpenAiLiveWindow360 = 6시간 +OpenAiTpmChartTitle = 분당 토큰 수 +OpenAiTpmLatest = 최신 +OpenAiTpmPeak = 최고 +OpenAiTpmFiveMinuteAverage = 5분 평균 +OpenAiTpmFrontier = 보고된 시점 +OpenAiTpmPendingLegend = 대기 중: 아직 보고되지 않음 +OpenAiTpmEmpty = 이 기간에 보고된 트래픽이 아직 없습니다. +OpenAiTpmModels = 모델 +OpenAiTpmLimitLine = { "{}" }의 최고치가 분당 토큰 한도의 { "{}" }%입니다(한도 { "{}" }) +OpenAiLimitsTitle = 모델별 속도 제한 +OpenAiLimitsHelp = OpenAI 대시보드에서 확인한 조직의 모델별 한도를 입력하면 사용률을 볼 수 있습니다. 모델에 요청 한도가 없으면 분당 요청 수를 비워 두세요. +OpenAiLimitModelLabel = 모델 ID +OpenAiLimitTpmLabel = 분당 토큰 수 +OpenAiLimitRpmLabel = 분당 요청 수 +OpenAiLimitAdd = 모델 한도 추가 +OpenAiLimitRemove = 삭제 +OpenAiLimitSave = 모델 한도 저장 +OpenAiLimitErrorModel = 모든 한도에 모델 ID를 입력하세요. +OpenAiLimitErrorLine = 모델 ID는 한 줄이어야 하며 200자 미만이어야 합니다. +OpenAiLimitErrorDuplicate = 모델마다 한도는 하나만 설정할 수 있습니다. +OpenAiLimitErrorTpm = 분당 토큰 수는 1부터 9007199254740991 사이의 정수여야 합니다. +OpenAiLimitErrorRpm = 분당 요청 수는 비워 두거나 정수여야 합니다. +OpenAiOrgLimitsTitle = 조직 프로젝트 한도 +OpenAiOrgLimitsLoad = 프로젝트 한도 불러오기 +OpenAiOrgLimitsEmpty = 프로젝트 한도를 찾지 못했습니다. +OpenAiOrgLimitsScoped = 프로젝트 한도는 조직 전체에 적용됩니다. 불러오려면 프로젝트 ID를 지우세요. +OpenAiOrgLimitsTruncated = 일부 프로젝트만 확인했습니다. diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 76bcab0ed79..56f8f6b5453 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1659,3 +1659,39 @@ UsageItemPlanRenewalExpiry = Renovação / expiração do plano UsageItemHiddenByCostSummary = Oculto enquanto a exibição do resumo de custos estiver em Oculto. PlanRenewsOn = Renova: { "{}" } PlanExpiresOn = O plano expira: { "{}" } +OpenAiLiveUsageTitle = Uso ao vivo +OpenAiLiveUsageToggle = Uso ao vivo (tokens por minuto) +OpenAiLiveUsageToggleHelp = A cada atualização, lê a última hora de tokens por minuto. Requer uma chave Admin API da organização e adiciona uma requisição de uso por atualização. +OpenAiLiveUsageLoad = Carregar uso ao vivo +OpenAiLiveUsageLoading = Carregando uso ao vivo… +OpenAiLiveWindowLabel = Janela +OpenAiLiveWindow15 = 15 minutos +OpenAiLiveWindow60 = 1 hora +OpenAiLiveWindow360 = 6 horas +OpenAiTpmChartTitle = Tokens por minuto +OpenAiTpmLatest = Último +OpenAiTpmPeak = Pico +OpenAiTpmFiveMinuteAverage = Média de 5 minutos +OpenAiTpmFrontier = Informado até +OpenAiTpmPendingLegend = Pendente: ainda não informado +OpenAiTpmEmpty = Nenhum tráfego informado nesta janela ainda. +OpenAiTpmModels = Modelos +OpenAiTpmLimitLine = O pico de { "{}" } usou { "{}" }% do limite de tokens por minuto ({ "{}" }) +OpenAiLimitsTitle = Limites de taxa por modelo +OpenAiLimitsHelp = Informe os limites por modelo da sua organização no painel da OpenAI para ver a utilização. Deixe requisições por minuto vazio quando o modelo não tiver limite de requisições. +OpenAiLimitModelLabel = ID do modelo +OpenAiLimitTpmLabel = Tokens por minuto +OpenAiLimitRpmLabel = Requisições por minuto +OpenAiLimitAdd = Adicionar limite de modelo +OpenAiLimitRemove = Remover +OpenAiLimitSave = Salvar limites de modelos +OpenAiLimitErrorModel = Informe um ID de modelo para cada limite. +OpenAiLimitErrorLine = Os IDs de modelo devem ter uma linha e menos de 200 caracteres. +OpenAiLimitErrorDuplicate = Cada modelo pode ter apenas um limite. +OpenAiLimitErrorTpm = Tokens por minuto deve ser um número inteiro de 1 a 9007199254740991. +OpenAiLimitErrorRpm = Requisições por minuto deve estar vazio ou ser um número inteiro. +OpenAiOrgLimitsTitle = Limites de projetos da organização +OpenAiOrgLimitsLoad = Carregar limites de projetos +OpenAiOrgLimitsEmpty = Nenhum limite de projeto encontrado. +OpenAiOrgLimitsScoped = Os limites de projeto se aplicam à organização inteira. Limpe o ID do projeto para carregá-los. +OpenAiOrgLimitsTruncated = Apenas alguns projetos foram verificados. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index c8673d5fb85..e03e80b49a8 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1655,3 +1655,39 @@ UsageItemPlanRenewalExpiry = Продление / окончание тариф UsageItemHiddenByCostSummary = Скрыто, пока стиль отображения стоимости — «Скрытый». PlanRenewsOn = Продление: { "{}" } PlanExpiresOn = Тариф истекает: { "{}" } +OpenAiLiveUsageTitle = Живое использование +OpenAiLiveUsageToggle = Живое использование (токены в минуту) +OpenAiLiveUsageToggleHelp = При каждом обновлении читает токены в минуту за последний час. Нужен Admin API-ключ организации; каждое обновление добавляет один запрос использования. +OpenAiLiveUsageLoad = Загрузить живое использование +OpenAiLiveUsageLoading = Загрузка живого использования… +OpenAiLiveWindowLabel = Период +OpenAiLiveWindow15 = 15 минут +OpenAiLiveWindow60 = 1 час +OpenAiLiveWindow360 = 6 часов +OpenAiTpmChartTitle = Токены в минуту +OpenAiTpmLatest = Последнее +OpenAiTpmPeak = Пик +OpenAiTpmFiveMinuteAverage = Среднее за 5 минут +OpenAiTpmFrontier = Данные получены до +OpenAiTpmPendingLegend = Ожидается: данные ещё не получены +OpenAiTpmEmpty = За этот период пока нет переданного трафика. +OpenAiTpmModels = Модели +OpenAiTpmLimitLine = Пик { "{}" } составил { "{}" }% от лимита токенов в минуту ({ "{}" }) +OpenAiLimitsTitle = Лимиты скорости по моделям +OpenAiLimitsHelp = Введите лимиты организации по моделям из панели OpenAI, чтобы видеть загрузку. Если у модели нет лимита запросов, оставьте поле запросов в минуту пустым. +OpenAiLimitModelLabel = ID модели +OpenAiLimitTpmLabel = Токены в минуту +OpenAiLimitRpmLabel = Запросы в минуту +OpenAiLimitAdd = Добавить лимит модели +OpenAiLimitRemove = Удалить +OpenAiLimitSave = Сохранить лимиты моделей +OpenAiLimitErrorModel = Укажите ID модели для каждого лимита. +OpenAiLimitErrorLine = ID модели должен занимать одну строку и быть короче 200 символов. +OpenAiLimitErrorDuplicate = Для каждой модели можно задать только один лимит. +OpenAiLimitErrorTpm = Токены в минуту должны быть целым числом от 1 до 9007199254740991. +OpenAiLimitErrorRpm = Запросы в минуту должны быть пустыми или целым числом. +OpenAiOrgLimitsTitle = Лимиты проектов организации +OpenAiOrgLimitsLoad = Загрузить лимиты проектов +OpenAiOrgLimitsEmpty = Лимиты проектов не найдены. +OpenAiOrgLimitsScoped = Лимиты проектов действуют на всю организацию. Очистите ID проекта, чтобы загрузить их. +OpenAiOrgLimitsTruncated = Проверены не все проекты. diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 71698e3c5ba..1e52dbd2cf4 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1655,3 +1655,39 @@ UsageItemPlanRenewalExpiry = Plan yenileme / bitiş UsageItemHiddenByCostSummary = Maliyet özeti görünümü Gizli olarak ayarlıyken gizlenir. PlanRenewsOn = Yenilenme: { "{}" } PlanExpiresOn = Plan bitişi: { "{}" } +OpenAiLiveUsageTitle = Canlı kullanım +OpenAiLiveUsageToggle = Canlı kullanım (dakikadaki token) +OpenAiLiveUsageToggleHelp = Her yenilemede son bir saatin dakikadaki token sayısını okur. Kuruluş Admin API anahtarı gerekir ve her yenilemede bir kullanım isteği ekler. +OpenAiLiveUsageLoad = Canlı kullanımı yükle +OpenAiLiveUsageLoading = Canlı kullanım yükleniyor… +OpenAiLiveWindowLabel = Pencere +OpenAiLiveWindow15 = 15 dakika +OpenAiLiveWindow60 = 1 saat +OpenAiLiveWindow360 = 6 saat +OpenAiTpmChartTitle = Dakikadaki token sayısı +OpenAiTpmLatest = Son +OpenAiTpmPeak = Zirve +OpenAiTpmFiveMinuteAverage = 5 dakikalık ortalama +OpenAiTpmFrontier = Bildirilen son zaman +OpenAiTpmPendingLegend = Bekliyor: henüz bildirilmedi +OpenAiTpmEmpty = Bu pencerede henüz bildirilmiş trafik yok. +OpenAiTpmModels = Modeller +OpenAiTpmLimitLine = { "{}" } zirvesi, dakikadaki token limitinin %{ "{}" }'ine ulaştı (limit { "{}" }) +OpenAiLimitsTitle = Modele göre hız limitleri +OpenAiLimitsHelp = Kuruluşunuzun modele göre limitlerini OpenAI panelinden girin; kullanım oranını görürsünüz. Modelde istek limiti yoksa dakikadaki istek alanını boş bırakın. +OpenAiLimitModelLabel = Model kimliği +OpenAiLimitTpmLabel = Dakikadaki token +OpenAiLimitRpmLabel = Dakikadaki istek +OpenAiLimitAdd = Model limiti ekle +OpenAiLimitRemove = Kaldır +OpenAiLimitSave = Model limitlerini kaydet +OpenAiLimitErrorModel = Her limit için bir model kimliği girin. +OpenAiLimitErrorLine = Model kimlikleri tek satırda ve 200 karakterden kısa olmalıdır. +OpenAiLimitErrorDuplicate = Her modelin yalnızca bir limiti olabilir. +OpenAiLimitErrorTpm = Dakikadaki token, 1 ile 9007199254740991 arasında bir tam sayı olmalıdır. +OpenAiLimitErrorRpm = Dakikadaki istek boş ya da bir tam sayı olmalıdır. +OpenAiOrgLimitsTitle = Kuruluş proje limitleri +OpenAiOrgLimitsLoad = Proje limitlerini yükle +OpenAiOrgLimitsEmpty = Proje limiti bulunamadı. +OpenAiOrgLimitsScoped = Proje limitleri tüm kuruluş için geçerlidir. Yüklemek için proje kimliğini temizleyin. +OpenAiOrgLimitsTruncated = Yalnızca bazı projeler kontrol edildi. diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index faa9aefef54..3ab1ae3a7ce 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1657,3 +1657,39 @@ UsageItemPlanRenewalExpiry = Поновлення / завершення тар UsageItemHiddenByCostSummary = Приховано, поки показ вартості встановлено на «Приховано». PlanRenewsOn = Поновлення: { "{}" } PlanExpiresOn = Тариф спливає: { "{}" } +OpenAiLiveUsageTitle = Живе використання +OpenAiLiveUsageToggle = Живе використання (токени за хвилину) +OpenAiLiveUsageToggleHelp = Під час кожного оновлення читає токени за хвилину за останню годину. Потрібен Admin API-ключ організації; кожне оновлення додає один запит використання. +OpenAiLiveUsageLoad = Завантажити живе використання +OpenAiLiveUsageLoading = Завантаження живого використання… +OpenAiLiveWindowLabel = Період +OpenAiLiveWindow15 = 15 хвилин +OpenAiLiveWindow60 = 1 година +OpenAiLiveWindow360 = 6 годин +OpenAiTpmChartTitle = Токени за хвилину +OpenAiTpmLatest = Останнє +OpenAiTpmPeak = Пік +OpenAiTpmFiveMinuteAverage = Середнє за 5 хвилин +OpenAiTpmFrontier = Дані отримано до +OpenAiTpmPendingLegend = Очікується: дані ще не отримано +OpenAiTpmEmpty = За цей період ще немає переданого трафіку. +OpenAiTpmModels = Моделі +OpenAiTpmLimitLine = Пік { "{}" } становить { "{}" }% ліміту токенів за хвилину ({ "{}" }) +OpenAiLimitsTitle = Ліміти швидкості за моделями +OpenAiLimitsHelp = Введіть ліміти організації за моделями з панелі OpenAI, щоб бачити завантаження. Якщо для моделі немає ліміту запитів, залиште поле запитів за хвилину порожнім. +OpenAiLimitModelLabel = ID моделі +OpenAiLimitTpmLabel = Токени за хвилину +OpenAiLimitRpmLabel = Запити за хвилину +OpenAiLimitAdd = Додати ліміт моделі +OpenAiLimitRemove = Видалити +OpenAiLimitSave = Зберегти ліміти моделей +OpenAiLimitErrorModel = Вкажіть ID моделі для кожного ліміту. +OpenAiLimitErrorLine = ID моделі має бути в один рядок і коротшим за 200 символів. +OpenAiLimitErrorDuplicate = Для кожної моделі можна задати лише один ліміт. +OpenAiLimitErrorTpm = Токени за хвилину мають бути цілим числом від 1 до 9007199254740991. +OpenAiLimitErrorRpm = Запити за хвилину мають бути порожніми або цілим числом. +OpenAiOrgLimitsTitle = Ліміти проєктів організації +OpenAiOrgLimitsLoad = Завантажити ліміти проєктів +OpenAiOrgLimitsEmpty = Лімітів проєктів не знайдено. +OpenAiOrgLimitsScoped = Ліміти проєктів діють для всієї організації. Очистіть ID проєкту, щоб їх завантажити. +OpenAiOrgLimitsTruncated = Перевірено не всі проєкти. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 5711873033e..2fb4fd23431 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1655,3 +1655,39 @@ UsageItemPlanRenewalExpiry = 套餐续订 / 到期 UsageItemHiddenByCostSummary = 费用摘要显示方式设为“隐藏”时不显示。 PlanRenewsOn = 续订:{ "{}" } PlanExpiresOn = 套餐到期:{ "{}" } +OpenAiLiveUsageTitle = 实时用量 +OpenAiLiveUsageToggle = 实时用量(每分钟 Token) +OpenAiLiveUsageToggleHelp = 每次刷新时读取最近一小时的每分钟 Token 数。需要组织 Admin API 密钥,每次刷新多一次用量请求。 +OpenAiLiveUsageLoad = 加载实时用量 +OpenAiLiveUsageLoading = 正在加载实时用量… +OpenAiLiveWindowLabel = 时间窗口 +OpenAiLiveWindow15 = 15 分钟 +OpenAiLiveWindow60 = 1 小时 +OpenAiLiveWindow360 = 6 小时 +OpenAiTpmChartTitle = 每分钟 Token 数 +OpenAiTpmLatest = 最新 +OpenAiTpmPeak = 峰值 +OpenAiTpmFiveMinuteAverage = 5 分钟平均 +OpenAiTpmFrontier = 已报告至 +OpenAiTpmPendingLegend = 待定:尚未报告 +OpenAiTpmEmpty = 此时间窗口内尚无已报告的流量。 +OpenAiTpmModels = 模型 +OpenAiTpmLimitLine = { "{}" } 的峰值达到其每分钟 Token 上限的 { "{}" }%(上限 { "{}" }) +OpenAiLimitsTitle = 模型速率限制 +OpenAiLimitsHelp = 请填写 OpenAI 控制台中组织的各模型限额以查看使用率。若模型没有请求数限制,请将每分钟请求数留空。 +OpenAiLimitModelLabel = 模型 ID +OpenAiLimitTpmLabel = 每分钟 Token 数 +OpenAiLimitRpmLabel = 每分钟请求数 +OpenAiLimitAdd = 添加模型限额 +OpenAiLimitRemove = 删除 +OpenAiLimitSave = 保存模型限额 +OpenAiLimitErrorModel = 请为每条限额填写模型 ID。 +OpenAiLimitErrorLine = 模型 ID 须为单行,且少于 200 个字符。 +OpenAiLimitErrorDuplicate = 每个模型只能设置一条限额。 +OpenAiLimitErrorTpm = 每分钟 Token 数须为 1 至 9007199254740991 之间的整数。 +OpenAiLimitErrorRpm = 每分钟请求数须为空或整数。 +OpenAiOrgLimitsTitle = 组织项目上限 +OpenAiOrgLimitsLoad = 加载项目上限 +OpenAiOrgLimitsEmpty = 未找到项目上限。 +OpenAiOrgLimitsScoped = 项目上限适用于整个组织。请先清空项目 ID 再加载。 +OpenAiOrgLimitsTruncated = 仅检查了部分项目。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index b519c31e713..8bb7545b077 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1655,3 +1655,39 @@ UsageItemPlanRenewalExpiry = 方案續訂 / 到期 UsageItemHiddenByCostSummary = 費用摘要顯示方式設為「隱藏」時不顯示。 PlanRenewsOn = 續訂:{ "{}" } PlanExpiresOn = 方案到期:{ "{}" } +OpenAiLiveUsageTitle = 即時用量 +OpenAiLiveUsageToggle = 即時用量(每分鐘 Token) +OpenAiLiveUsageToggleHelp = 每次重新整理時讀取最近一小時的每分鐘 Token 數。需要組織 Admin API 金鑰,每次重新整理會多一次用量請求。 +OpenAiLiveUsageLoad = 載入即時用量 +OpenAiLiveUsageLoading = 正在載入即時用量… +OpenAiLiveWindowLabel = 時間範圍 +OpenAiLiveWindow15 = 15 分鐘 +OpenAiLiveWindow60 = 1 小時 +OpenAiLiveWindow360 = 6 小時 +OpenAiTpmChartTitle = 每分鐘 Token 數 +OpenAiTpmLatest = 最新 +OpenAiTpmPeak = 高峰 +OpenAiTpmFiveMinuteAverage = 5 分鐘平均 +OpenAiTpmFrontier = 已回報至 +OpenAiTpmPendingLegend = 待定:尚未回報 +OpenAiTpmEmpty = 此時間範圍內尚無已回報的流量。 +OpenAiTpmModels = 模型 +OpenAiTpmLimitLine = { "{}" } 的高峰達到其每分鐘 Token 上限的 { "{}" }%(上限 { "{}" }) +OpenAiLimitsTitle = 模型速率限制 +OpenAiLimitsHelp = 請填入 OpenAI 後台中組織的各模型限額以查看使用率。若模型沒有請求數限制,請將每分鐘請求數留空。 +OpenAiLimitModelLabel = 模型 ID +OpenAiLimitTpmLabel = 每分鐘 Token 數 +OpenAiLimitRpmLabel = 每分鐘請求數 +OpenAiLimitAdd = 新增模型限額 +OpenAiLimitRemove = 移除 +OpenAiLimitSave = 儲存模型限額 +OpenAiLimitErrorModel = 請為每條限額填寫模型 ID。 +OpenAiLimitErrorLine = 模型 ID 須為單行,且少於 200 個字元。 +OpenAiLimitErrorDuplicate = 每個模型只能設定一條限額。 +OpenAiLimitErrorTpm = 每分鐘 Token 數須為 1 至 9007199254740991 之間的整數。 +OpenAiLimitErrorRpm = 每分鐘請求數須為空白或整數。 +OpenAiOrgLimitsTitle = 組織專案上限 +OpenAiOrgLimitsLoad = 載入專案上限 +OpenAiOrgLimitsEmpty = 找不到專案上限。 +OpenAiOrgLimitsScoped = 專案上限適用於整個組織。請先清空專案 ID 再載入。 +OpenAiOrgLimitsTruncated = 僅檢查了部分專案。 From 65fa8f17410cfe50061ad20d2fccdfebfc535eba Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:45:06 +0700 Subject: [PATCH 4/9] Show the OpenAI Tokens/min grid on the API card Render the last-hour grid in the full card for the OpenAI API only. Pending minutes are outlines and never count as zero or peak; the compact Overview leaves the grid out. --- .../desktop-tauri/src/components/MenuCard.tsx | 5 + .../components/MenuCardOpenAIUsage.test.tsx | 42 +++++ .../OpenAITokensPerMinuteChart.test.tsx | 108 +++++++++++++ .../components/OpenAITokensPerMinuteChart.tsx | 147 ++++++++++++++++++ .../src/components/card/describeCard.ts | 10 ++ apps/desktop-tauri/src/styles.css | 120 ++++++++++++++ 6 files changed, 432 insertions(+) create mode 100644 apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx create mode 100644 apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 5ec8f1a8079..d9953e60439 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -26,6 +26,7 @@ import DetailSections from "./card/DetailSections"; import InlineDashboard from "./card/InlineDashboard"; import ExtraUsageBlock from "./card/ExtraUsageBlock"; import WayfinderBlock from "./card/WayfinderBlock"; +import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart"; import { describeCard } from "./card/describeCard"; import { hideEmailsInText } from "../lib/personalInfo"; @@ -297,12 +298,16 @@ export default function MenuCard({ /> )} {presence.wayfinderUsage && } + {presence.openAiThroughput && ( + + )} {dashboard && } {showCost && provider.cost && ( <> {(presence.hasMetrics || presence.planDateNote || showDetailSections || + presence.openAiThroughput || dashboard) && (
)} diff --git a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx index 7b6c4c10472..2aa6cc58b39 100644 --- a/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCardOpenAIUsage.test.tsx @@ -132,6 +132,12 @@ describe("MenuCard OpenAI daily usage", () => { expect(container).not.toHaveTextContent("OpenAIChartTitle"); }); + it("renders the Tokens/min grid in the full card", async () => { + const { container } = renderCard(withThroughput(snapshot("openaiapi", null))); + await waitFor(() => expect(container.querySelector(".card-header__name")).not.toBeNull()); + expect(container.querySelector(".openai-tpm")).not.toBeNull(); + }); + it("masks the Admin project id in the card identity when privacy is enabled", async () => { const provider = snapshot("openaiapi", usage()); provider.planName = "Admin API: proj-private"; @@ -142,6 +148,42 @@ describe("MenuCard OpenAI daily usage", () => { }); }); +function withThroughput(snap: ProviderUsageSnapshot): ProviderUsageSnapshot { + snap.throughput = { + minutes: [{ start: START, totalTokens: 1_000, requests: 1, isPending: false }], + frontier: START, + latestTokensPerMinute: 1_000, + peakTokensPerMinute: 1_000, + peakAt: START, + fiveMinuteAverageTokensPerMinute: 1_000, + models: [], + windowMinutes: 60, + fetchedAt: START, + projectId: null, + }; + return snap; +} + +describe("describeCard OpenAI Tokens/min grid", () => { + it("surfaces the grid as full-card content for the OpenAI API", () => { + const presence = describeCard(withThroughput(snapshot("openaiapi", null)), null, []); + expect(presence.openAiThroughput).not.toBeNull(); + expect(presence.hasDetails).toBe(true); + }); + + it("keeps the grid out of the compact Overview", () => { + const presence = describeCard(withThroughput(snapshot("openaiapi", null)), null, [], "detailed", true); + expect(presence.openAiThroughput).toBeNull(); + }); + + it("keeps the grid out of other providers and errored cards", () => { + expect(describeCard(withThroughput(snapshot("codex", null)), null, []).openAiThroughput).toBeNull(); + expect( + describeCard(withThroughput(snapshot("openaiapi", null, "boom")), null, []).openAiThroughput, + ).toBeNull(); + }); +}); + describe("describeCard", () => { it("does not count the per-day API history as card content", () => { const presence = describeCard(snapshot("openaiapi", usage()), null, []); diff --git a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx new file mode 100644 index 00000000000..babc247e10e --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.test.tsx @@ -0,0 +1,108 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import { formatTokens } from "../lib/openaiThroughput"; +import type { + ModelRateLimitSnapshot, + ProviderThroughputSnapshot, + ThroughputModelSnapshot, +} from "../types/bridge"; +import { OpenAITokensPerMinuteChart } from "./OpenAITokensPerMinuteChart"; + +const START = 1_788_220_800; +const t = (key: LocaleKey) => key as string; + +function model(name: string, overrides: Partial = {}): ThroughputModelSnapshot { + return { + name, + latestTpm: 0, + peakTpm: 0, + totalTokens: 0, + peakLimitCountedTokens: 0, + limitCountedTokensByMinute: [], + ...overrides, + }; +} + +/** Three reported minutes (peak 4,000 at START + 60), then two pending minutes. */ +function throughput(overrides: Partial = {}): ProviderThroughputSnapshot { + return { + minutes: [ + { start: START, totalTokens: 1_000, requests: 1, isPending: false }, + { start: START + 60, totalTokens: 4_000, requests: 2, isPending: false }, + { start: START + 120, totalTokens: 2_000, requests: 1, isPending: false }, + { start: START + 180, totalTokens: 0, requests: 0, isPending: true }, + { start: START + 240, totalTokens: 0, requests: 0, isPending: true }, + ], + frontier: START + 120, + latestTokensPerMinute: 2_000, + peakTokensPerMinute: 4_000, + peakAt: START + 60, + fiveMinuteAverageTokensPerMinute: 2_333, + models: [], + windowMinutes: 60, + fetchedAt: START + 300, + projectId: null, + ...overrides, + }; +} + +describe("OpenAITokensPerMinuteChart", () => { + it("draws reported minutes as solid bars and pending minutes as outlines", () => { + const { container } = render(); + const bars = container.querySelectorAll("rect.openai-tpm__bar"); + expect(bars).toHaveLength(5); + expect(container.querySelectorAll("rect.openai-tpm__bar--pending")).toHaveLength(2); + expect(container.querySelector(".openai-tpm__legend")).not.toBeNull(); + }); + + it("never reports pending minutes as the peak", () => { + const { container } = render(); + const peakCell = Array.from(container.querySelectorAll(".openai-tpm__stats > div")).find( + (cell) => cell.querySelector("dt")?.textContent === "OpenAiTpmPeak", + ); + expect(peakCell?.querySelector("dd")?.textContent).toContain(formatTokens(4_000)); + }); + + it("shows the empty state when no minute has reported traffic", () => { + const pendingOnly = throughput({ + minutes: [{ start: START, totalTokens: 0, requests: 0, isPending: true }], + peakTokensPerMinute: 0, + latestTokensPerMinute: null, + }); + const { container } = render(); + expect(container.querySelector("svg")).toBeNull(); + expect(container.querySelector(".openai-tpm__empty")?.textContent).toBe("OpenAiTpmEmpty"); + }); + + it("shows the limit line only when a manual limit matches a model in the window", () => { + const busy = throughput({ + models: [model("gpt-5", { peakLimitCountedTokens: 4_000 })], + }); + const limits: ModelRateLimitSnapshot[] = [{ model: "gpt-5", tokensPerMinute: 8_000 }]; + const withLimit = render( + , + ); + expect(withLimit.container.querySelector(".openai-tpm__limit")).not.toBeNull(); + withLimit.unmount(); + + const unmatched = render( + , + ); + expect(unmatched.container.querySelector(".openai-tpm__limit")).toBeNull(); + }); + + it("lists per-model rows only when showModels is set", () => { + const busy = throughput({ models: [model("gpt-5", { latestTpm: 900, peakTpm: 4_000 })] }); + const card = render(); + expect(card.container.querySelector(".openai-tpm__models")).toBeNull(); + card.unmount(); + + const live = render(); + expect(live.container.querySelector(".openai-tpm__model-name")?.textContent).toBe("gpt-5"); + }); +}); diff --git a/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx new file mode 100644 index 00000000000..0c04777d523 --- /dev/null +++ b/apps/desktop-tauri/src/components/OpenAITokensPerMinuteChart.tsx @@ -0,0 +1,147 @@ +import type { LocaleKey } from "../i18n/keys"; +import { + formatClock, + formatTokens, + hasReportedTraffic, + throughputLimitLine, + tpmBars, +} from "../lib/openaiThroughput"; +import type { + ModelRateLimitSnapshot, + ProviderThroughputSnapshot, +} from "../types/bridge"; + +/** + * Last-hour Tokens/min grid for the OpenAI API (upstream 0.75.0 #4495). Reported + * minutes are solid bars; minutes after the reporting frontier are drawn as + * pending outlines and never count as zero or as a peak. + */ + +type T = (key: LocaleKey) => string; + +export interface OpenAITokensPerMinuteChartProps { + throughput: ProviderThroughputSnapshot; + t: T; + /** Manual organization limits; the limit line needs an organization-scoped snapshot. */ + limits?: readonly ModelRateLimitSnapshot[]; + /** Per-model rows; the card omits them, the Live Usage view shows them. */ + showModels?: boolean; +} + +const NO_LIMITS: readonly ModelRateLimitSnapshot[] = []; + +export function OpenAITokensPerMinuteChart({ + throughput, + t, + limits = NO_LIMITS, + showModels = false, +}: OpenAITokensPerMinuteChartProps) { + const bars = tpmBars(throughput); + const reported = hasReportedTraffic(throughput); + const maxTokens = Math.max(1, ...bars.filter((bar) => !bar.isPending).map((bar) => bar.tokens)); + const hasPending = bars.some((bar) => bar.isPending); + const limit = throughputLimitLine(throughput, limits); + + return ( +
+
{t("OpenAiTpmChartTitle")}
+
+
+
{t("OpenAiTpmLatest")}
+
+ {throughput.latestTokensPerMinute == null + ? "—" + : formatTokens(throughput.latestTokensPerMinute)} +
+
+
+
{t("OpenAiTpmPeak")}
+
+ {formatTokens(throughput.peakTokensPerMinute)} + {throughput.peakAt != null && ( + {formatClock(throughput.peakAt)} + )} +
+
+
+
{t("OpenAiTpmFiveMinuteAverage")}
+
{formatTokens(throughput.fiveMinuteAverageTokensPerMinute)}
+
+ {throughput.frontier != null && ( +
+
{t("OpenAiTpmFrontier")}
+
{formatClock(throughput.frontier)}
+
+ )} +
+ + {reported && bars.length > 0 ? ( + + {bars.map((bar, index) => + bar.isPending ? ( + + ) : ( + + ), + )} + + ) : ( +

{t("OpenAiTpmEmpty")}

+ )} + + {hasPending && ( +
+
+ )} + + {limit && ( +

+ {t("OpenAiTpmLimitLine") + .replace("{}", limit.model) + .replace("{}", String(Math.round(limit.percent))) + .replace("{}", formatTokens(limit.tokensPerMinuteLimit))} +

+ )} + + {showModels && throughput.models.length > 0 && ( +
+
{t("OpenAiTpmModels")}
+
    + {throughput.models.map((model) => ( +
  • + {model.name} + + {t("OpenAiTpmLatest")} {formatTokens(model.latestTpm)} · {t("OpenAiTpmPeak")}{" "} + {formatTokens(model.peakTpm)} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/desktop-tauri/src/components/card/describeCard.ts b/apps/desktop-tauri/src/components/card/describeCard.ts index cd0183955bb..39a0f07a28b 100644 --- a/apps/desktop-tauri/src/components/card/describeCard.ts +++ b/apps/desktop-tauri/src/components/card/describeCard.ts @@ -1,6 +1,7 @@ import type { CostSummaryDisplayStyle, ProviderChartData, + ProviderThroughputSnapshot, ProviderUsageSnapshot, } from "../../types/bridge"; import { formatCostAmount } from "../../lib/cardCost"; @@ -19,6 +20,8 @@ export interface MenuCardPresence { hasCost: boolean; localUsage: ProviderChartData["localUsage"] | null; wayfinderUsage: ProviderUsageSnapshot["wayfinderUsage"] | null; + /** Last-hour Tokens/min grid (OpenAI API Live Usage); full card only. */ + openAiThroughput: ProviderThroughputSnapshot | null; hasDetails: boolean; /** * Plan renewal / expiry line under the quota rows. The compact Overview @@ -71,6 +74,11 @@ export function describeCard( const wayfinderUsage = !hasError && provider.providerId === "wayfinder" ? provider.wayfinderUsage ?? null : null; const planDateNote = !hasError && planNote && (!compactOverview || !hasMetrics) ? planNote : null; + // The Tokens/min grid is a full-card section; the compact Overview leaves it out. + const openAiThroughput = + !hasError && !compactOverview && provider.providerId === "openaiapi" + ? provider.throughput ?? null + : null; const hasDetails = compactOverview ? hasMetrics || showsDetailSections || !!wayfinderUsage || !!planDateNote : hasMetrics || @@ -78,6 +86,7 @@ export function describeCard( hasCost || !!localUsage || !!wayfinderUsage || + !!openAiThroughput || !!planDateNote; return { hasMetrics, @@ -86,6 +95,7 @@ export function describeCard( hasCost, localUsage, wayfinderUsage, + openAiThroughput, hasDetails, planDateNote, }; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 1d8fa38a16b..50cf0ec9da9 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -2760,6 +2760,126 @@ body:has(.tray-panel-reveal), margin-bottom: 2px; } +/* OpenAI API Tokens/min grid (Live Usage, upstream #4495). */ +.openai-tpm { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; +} + +.openai-tpm__title { + font-size: 11px; + color: var(--text-secondary); +} + +.openai-tpm__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); + gap: 6px 16px; + margin: 0; +} + +.openai-tpm__stats > div { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.openai-tpm__stats dt { + font-size: 10px; + color: var(--text-secondary); +} + +.openai-tpm__stats dd { + margin: 0; + font-variant-numeric: tabular-nums; +} + +.openai-tpm__chart { + width: 100%; + height: 56px; + display: block; +} + +.openai-tpm__bar { + fill: var(--provider-accent, #6aa9ff); +} + +.openai-tpm__bar--pending { + fill: none; + stroke: var(--text-secondary); + stroke-width: 0.5; + stroke-dasharray: 2 2; + vector-effect: non-scaling-stroke; + opacity: 0.6; +} + +.openai-tpm__legend { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-secondary); +} + +.openai-tpm__swatch--pending { + display: inline-block; + width: 10px; + height: 10px; + border: 1px dashed var(--text-secondary); + box-sizing: border-box; +} + +.openai-tpm__empty, +.openai-tpm__limit { + margin: 0; + font-size: 11px; + color: var(--text-secondary); +} + +.openai-tpm__models ul { + list-style: none; + margin: 0; + padding: 0; +} + +.openai-tpm__models li { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 11px; + padding: 2px 0; +} + +.openai-tpm__model-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.openai-tpm__muted { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.openai-limits__row { + display: grid; + grid-template-columns: 2fr 1fr 1fr auto; + gap: 8px; + align-items: end; + margin-bottom: 6px; +} + +.openai-live { + display: flex; + flex-direction: column; + gap: 8px; +} + .openai-usage__list ul { list-style: none; margin: 0; From fa4f31b66e5aabc2c3e722291d70521f74be0151 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:45:11 +0700 Subject: [PATCH 5/9] Add OpenAI Live Usage settings section Settings > Providers > OpenAI gets the live-usage switch, the per-model organization limits editor, an on-demand Tokens/min window with per-model rows, and the organization caps list. Org caps stay hidden when a project id is saved. --- .../sections/OpenAiLiveUsageSection.test.tsx | 103 +++++++++ .../sections/OpenAiLiveUsageSection.tsx | 213 ++++++++++++++++++ .../sections/OpenAiModelLimitsEditor.test.tsx | 67 ++++++ .../sections/OpenAiModelLimitsEditor.tsx | 120 ++++++++++ .../providers/sections/OptionsSection.tsx | 11 + 5 files changed, 514 insertions(+) create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.tsx diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx new file mode 100644 index 00000000000..17e7461fe6f --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { LocaleKey } from "../../../../i18n/keys"; +import type { + OrgRateLimitsSnapshot, + ProviderThroughputSnapshot, +} from "../../../../types/bridge"; + +const tauriMocks = vi.hoisted(() => ({ + getOpenAiLiveUsage: vi.fn(), + getOpenAiOrgRateLimits: vi.fn(), + getProviderWorkspaceId: vi.fn(), +})); + +vi.mock("../../../../lib/tauri", async (importOriginal) => ({ + ...(await importOriginal()), + ...tauriMocks, +})); + +import { OpenAiLiveUsageSection } from "./OpenAiLiveUsageSection"; + +const t = (key: LocaleKey) => key as string; + +const START = 1_788_220_800; + +function liveUsage(): ProviderThroughputSnapshot { + return { + minutes: [ + { start: START, totalTokens: 1_500, requests: 1, isPending: false }, + { start: START + 60, totalTokens: 0, requests: 0, isPending: true }, + ], + frontier: START, + latestTokensPerMinute: 1_500, + peakTokensPerMinute: 1_500, + peakAt: START, + fiveMinuteAverageTokensPerMinute: 1_500, + models: [], + windowMinutes: 60, + fetchedAt: START + 60, + projectId: null, + }; +} + +function orgCaps(): OrgRateLimitsSnapshot { + return { + byModel: [{ model: "gpt-5", tokensPerMinute: 90_000, requestsPerMinute: 500 }], + projectsSampled: 2, + truncated: false, + fetchedAt: START, + }; +} + +function renderSection(onSettingsChange = vi.fn()) { + const view = render( + , + ); + return { ...view, onSettingsChange }; +} + +describe("OpenAiLiveUsageSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getProviderWorkspaceId.mockResolvedValue(null); + tauriMocks.getOpenAiLiveUsage.mockResolvedValue(liveUsage()); + tauriMocks.getOpenAiOrgRateLimits.mockResolvedValue(orgCaps()); + }); + + it("writes the live-usage switch through the settings patch", () => { + const { container, onSettingsChange } = renderSection(); + const toggle = container.querySelector("input.toggle") as HTMLInputElement; + expect(toggle.checked).toBe(true); + fireEvent.click(toggle); + expect(onSettingsChange).toHaveBeenCalledWith({ openAiLiveUsageEnabled: false }); + }); + + it("loads the selected window and renders the Tokens/min chart", async () => { + const { container } = renderSection(); + fireEvent.click(screen.getByText("OpenAiLiveUsageLoad")); + await waitFor(() => expect(container.querySelector(".openai-tpm")).not.toBeNull()); + expect(tauriMocks.getOpenAiLiveUsage).toHaveBeenCalledWith(60); + }); + + it("hides organization caps and shows the scoped note when a project id is saved", async () => { + tauriMocks.getProviderWorkspaceId.mockResolvedValue("proj_abc"); + renderSection(); + fireEvent.click(screen.getByText("OpenAiOrgLimitsLoad")); + await waitFor(() => expect(screen.getByText("OpenAiOrgLimitsScoped")).toBeTruthy()); + expect(tauriMocks.getOpenAiOrgRateLimits).not.toHaveBeenCalled(); + expect(screen.queryByText("gpt-5")).toBeNull(); + }); + + it("lists organization caps when no project id is saved", async () => { + renderSection(); + fireEvent.click(screen.getByText("OpenAiOrgLimitsLoad")); + await waitFor(() => expect(screen.getByText("gpt-5")).toBeTruthy()); + expect(tauriMocks.getOpenAiOrgRateLimits).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx new file mode 100644 index 00000000000..7317d795ccd --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx @@ -0,0 +1,213 @@ +import { useState } from "react"; +import { Field, Toggle } from "../../../../components/FormControls"; +import { OpenAITokensPerMinuteChart } from "../../../../components/OpenAITokensPerMinuteChart"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { + type LiveWindowMinutes, + formatTokens, + LIVE_WINDOW_MINUTES, +} from "../../../../lib/openaiThroughput"; +import { + getOpenAiLiveUsage, + getOpenAiOrgRateLimits, + getProviderWorkspaceId, +} from "../../../../lib/tauri"; +import type { + ModelRateLimitSnapshot, + OrgRateLimitsSnapshot, + ProviderThroughputSnapshot, + SettingsUpdate, +} from "../../../../types/bridge"; +import { OpenAiModelLimitsEditor } from "./OpenAiModelLimitsEditor"; + +type T = (key: LocaleKey) => string; + +export interface OpenAiLiveUsageSettings { + /** Absent on older shells; the shell defaults it on. */ + openAiLiveUsageEnabled?: boolean; + openAiModelRateLimits?: ModelRateLimitSnapshot[]; +} + +interface Props { + settings: OpenAiLiveUsageSettings; + disabled: boolean; + t: T; + onSettingsChange: (patch: SettingsUpdate) => void; +} + +const NO_LIMITS: readonly ModelRateLimitSnapshot[] = []; + +const WINDOW_LABELS: Record = { + 15: "OpenAiLiveWindow15", + 60: "OpenAiLiveWindow60", + 360: "OpenAiLiveWindow360", +}; + +/** + * OpenAI API Live Usage controls in Settings > Providers > OpenAI (upstream + * 0.75.0 #4495): the refresh switch, the per-model limits editor, and an + * on-demand Tokens/min window with the organization project caps. + */ +export function OpenAiLiveUsageSection({ settings, disabled, t, onSettingsChange }: Props) { + const limits = settings.openAiModelRateLimits ?? NO_LIMITS; + return ( + <> + + onSettingsChange({ openAiLiveUsageEnabled: on })} + /> + + onSettingsChange({ openAiModelRateLimits: next })} + /> + + + ); +} + +function LiveUsageView({ + limits, + disabled, + t, +}: { + limits: readonly ModelRateLimitSnapshot[]; + disabled: boolean; + t: T; +}) { + const [windowMinutes, setWindowMinutes] = useState(60); + const [usageBusy, setUsageBusy] = useState(false); + const [usageError, setUsageError] = useState(null); + const [usage, setUsage] = useState(null); + const [orgBusy, setOrgBusy] = useState(false); + const [orgError, setOrgError] = useState(null); + const [orgScoped, setOrgScoped] = useState(false); + const [org, setOrg] = useState(null); + + const loadUsage = async () => { + setUsageBusy(true); + setUsageError(null); + try { + setUsage(await getOpenAiLiveUsage(windowMinutes)); + } catch (error) { + setUsage(null); + setUsageError(String(error)); + } finally { + setUsageBusy(false); + } + }; + + const loadOrg = async () => { + setOrgBusy(true); + setOrgError(null); + setOrgScoped(false); + try { + // A saved project scope limits the view to that project, so org caps stay hidden. + const projectId = (await getProviderWorkspaceId("openaiapi"))?.trim() ?? ""; + if (projectId.length > 0) { + setOrg(null); + setOrgScoped(true); + return; + } + setOrg(await getOpenAiOrgRateLimits()); + } catch (error) { + setOrg(null); + setOrgError(String(error)); + } finally { + setOrgBusy(false); + } + }; + + return ( +
+

{t("OpenAiLiveUsageTitle")}

+ +
+ +
+ {usageError && ( +
+ {usageError} +
+ )} + {usage && ( + + )} + +
{t("OpenAiOrgLimitsTitle")}
+
+ +
+ {orgScoped &&
{t("OpenAiOrgLimitsScoped")}
} + {orgError && ( +
+ {orgError} +
+ )} + {org && } +
+ ); +} + +function OrgLimitsList({ org, t }: { org: OrgRateLimitsSnapshot; t: T }) { + if (org.byModel.length === 0) { + return
{t("OpenAiOrgLimitsEmpty")}
; + } + return ( + <> +
    + {org.byModel.map((row) => ( +
  • + {row.model} + + {t("OpenAiLimitTpmLabel")}: {formatTokens(row.tokensPerMinute)} + {row.requestsPerMinute != null && + ` · ${t("OpenAiLimitRpmLabel")}: ${formatTokens(row.requestsPerMinute)}`} + +
  • + ))} +
+ {org.truncated && ( +
{t("OpenAiOrgLimitsTruncated")}
+ )} + + ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.test.tsx new file mode 100644 index 00000000000..94c26feaa16 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { LocaleKey } from "../../../../i18n/keys"; +import type { ModelRateLimitSnapshot } from "../../../../types/bridge"; +import { OpenAiModelLimitsEditor } from "./OpenAiModelLimitsEditor"; + +const t = (key: LocaleKey) => key as string; + +function renderEditor(limits: ModelRateLimitSnapshot[] = [], onSave = vi.fn()) { + const view = render( + , + ); + return { ...view, onSave }; +} + +describe("OpenAiModelLimitsEditor", () => { + it("saves trimmed rows with a blank requests limit as null", () => { + const { onSave } = renderEditor(); + fireEvent.click(screen.getByText("OpenAiLimitAdd")); + const [model, tpm] = screen.getAllByRole("textbox"); + fireEvent.change(model, { target: { value: " gpt-5 " } }); + fireEvent.change(tpm, { target: { value: "90000" } }); + fireEvent.click(screen.getByText("OpenAiLimitSave")); + expect(onSave).toHaveBeenCalledWith([ + { model: "gpt-5", tokensPerMinute: 90_000, requestsPerMinute: null }, + ]); + }); + + it("shows the error and does not save a row with a bad tokens-per-minute value", () => { + const { onSave, container } = renderEditor(); + fireEvent.click(screen.getByText("OpenAiLimitAdd")); + const inputs = container.querySelectorAll("input"); + fireEvent.change(inputs[0], { target: { value: "gpt-5" } }); + fireEvent.change(inputs[1], { target: { value: "lots" } }); + fireEvent.click(screen.getByText("OpenAiLimitSave")); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toBe("OpenAiLimitErrorTpm"); + }); + + it("rejects a duplicate model and keeps the rows the user typed", () => { + const { onSave, container } = renderEditor([ + { model: "gpt-5", tokensPerMinute: 1_000, requestsPerMinute: null }, + ]); + fireEvent.click(screen.getByText("OpenAiLimitAdd")); + const inputs = container.querySelectorAll("input"); + // Row 0 uses inputs 0-2; the new row 1 uses inputs 3-5. + fireEvent.change(inputs[3], { target: { value: "gpt-5" } }); + fireEvent.change(inputs[4], { target: { value: "500" } }); + fireEvent.click(screen.getByText("OpenAiLimitSave")); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toBe("OpenAiLimitErrorDuplicate"); + expect((container.querySelectorAll("input")[3] as HTMLInputElement).value).toBe("gpt-5"); + }); + + it("removes a row before save", () => { + const { onSave, container } = renderEditor([ + { model: "gpt-5", tokensPerMinute: 1_000, requestsPerMinute: 10 }, + { model: "gpt-4o", tokensPerMinute: 2_000, requestsPerMinute: null }, + ]); + fireEvent.click(screen.getAllByText("OpenAiLimitRemove")[0]); + expect(container.querySelectorAll(".openai-limits__row")).toHaveLength(1); + fireEvent.click(screen.getByText("OpenAiLimitSave")); + expect(onSave).toHaveBeenCalledWith([ + { model: "gpt-4o", tokensPerMinute: 2_000, requestsPerMinute: null }, + ]); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.tsx new file mode 100644 index 00000000000..0138a4995b1 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiModelLimitsEditor.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import type { LocaleKey } from "../../../../i18n/keys"; +import { + draftsFromLimits, + type LimitDraft, + parseLimitDrafts, +} from "../../../../lib/openaiThroughput"; +import type { ModelRateLimitSnapshot } from "../../../../types/bridge"; + +interface Props { + /** The saved list; the editor resets to it whenever the reference changes. */ + limits: readonly ModelRateLimitSnapshot[]; + disabled: boolean; + t: (key: LocaleKey) => string; + onSave: (limits: ModelRateLimitSnapshot[]) => void; +} + +const EMPTY_DRAFT: LimitDraft = { model: "", tokensPerMinute: "", requestsPerMinute: "" }; + +/** + * Manual per-model organization limits (upstream 0.75.0 #4495). Rows are checked + * before save; a bad row keeps the editor as it is and shows why. + */ +export function OpenAiModelLimitsEditor({ limits, disabled, t, onSave }: Props) { + const [drafts, setDrafts] = useState(() => draftsFromLimits(limits)); + const [error, setError] = useState(null); + + useEffect(() => { + setDrafts(draftsFromLimits(limits)); + setError(null); + }, [limits]); + + const updateRow = (index: number, patch: Partial) => { + setDrafts((current) => + current.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)), + ); + }; + + const save = () => { + const result = parseLimitDrafts(drafts); + if (!result.ok) { + setError(t(result.error)); + return; + } + setError(null); + onSave(result.limits); + }; + + return ( +
+

{t("OpenAiLimitsTitle")}

+
{t("OpenAiLimitsHelp")}
+ {drafts.map((draft, index) => ( +
+ + + + +
+ ))} +
+ + +
+ {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx index e46a591568a..8dfa2008aa7 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OptionsSection.tsx @@ -10,10 +10,13 @@ import { AutoResumeSection } from "./AutoResumeSection"; import { OptionalDetailsSection } from "./OptionalDetailsSection"; import { ClaudeSwapPathField } from "./credentials/ClaudeSwapPathField"; import { AntigravityProfileHomesField } from "./AntigravityProfileHomesField"; +import { OpenAiLiveUsageSection } from "./OpenAiLiveUsageSection"; export type OptionSettings = Pick< SettingsSnapshot, | "antigravityAdditionalProfileHomes" + | "openAiLiveUsageEnabled" + | "openAiModelRateLimits" | "codexOpenaiWebExtras" | "codexHistoricalTracking" | "codexExternalOauthSourcesAllowed" @@ -192,6 +195,14 @@ export function OptionsSection({ )} ))} + {detail.id === "openaiapi" && ( + + )} {detail.id === "antigravity" && ( Date: Fri, 16 Oct 2026 09:45:24 +0700 Subject: [PATCH 6/9] Note OpenAI Live Usage UI in changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeed216403d..eaa06598876 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ ## [Windows] Unreleased -- OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The chart, menu bar token and Settings controls come in a later release (upstream #4495). +- OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The menu bar token comes in a later release (upstream #4495). +- OpenAI API: the card shows the last-hour Tokens/min grid (latest, peak, 5-minute average, reporting frontier, and pending minutes drawn as outlines), and Settings > Providers > OpenAI adds a Live Usage section with a refresh switch, a per-model organization limits editor, a 15-minute / 1-hour / 6-hour window with per-model rows, and the organization caps list, which stays hidden when a project id is saved (upstream #4495). - **Claude**: show the plan renewal or expiry date from claude.ai billing under the quota rows when your cookie session supplies it, and make saved limit-reset credits, Extra usage (including a prepaid balance), and plan dates independently selectable under Visible usage items. Existing settings keep all three visible; Admin API spend is unaffected (upstream #4324, #4484). - Devin: use Devin's official transparent hexagon mark for the provider icon in Settings and the CLI dashboard, instead of the settings-gear badge and the letter fallback (upstream #4458). - Kimi (China): use the current Kimi Desktop session from its Local Storage (`%APPDATA%\kimi-desktop`) for web usage and monthly membership enrichment, and check that session again before each request, so a signed-out, replaced, or expired Desktop token is never sent or recovered from an older record (upstream #4416, #3536). From 8518c4632b1b34ee4817e4e45d57831fbbe31ef0 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 09:46:15 +0700 Subject: [PATCH 7/9] Check limit model IDs for control characters without regex --- apps/desktop-tauri/src/lib/openaiThroughput.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/lib/openaiThroughput.ts b/apps/desktop-tauri/src/lib/openaiThroughput.ts index 65ffdb069e5..09b1ebfa2ad 100644 --- a/apps/desktop-tauri/src/lib/openaiThroughput.ts +++ b/apps/desktop-tauri/src/lib/openaiThroughput.ts @@ -98,6 +98,15 @@ export function draftsFromLimits(limits: readonly ModelRateLimitSnapshot[]): Lim const WHOLE_NUMBER = /^\d+$/; +/** C0 controls and DEL; checked by code point so the pattern needs no control escapes. */ +function hasControlCharacter(value: string): boolean { + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + /** * Validates every row before save. A bad row is reported with its index; the * caller keeps the editor state so nothing is dropped silently. A requests @@ -110,7 +119,7 @@ export function parseLimitDrafts(drafts: readonly LimitDraft[]): LimitDraftResul const draft = drafts[index]; const model = draft.model.trim(); if (model.length === 0) return { ok: false, error: "OpenAiLimitErrorModel", index }; - if (model.length > MAX_MODEL_ID_LENGTH || /[\u0000-\u001f\u007f]/.test(model)) { + if (model.length > MAX_MODEL_ID_LENGTH || hasControlCharacter(model)) { return { ok: false, error: "OpenAiLimitErrorLine", index }; } if (seen.has(model)) return { ok: false, error: "OpenAiLimitErrorDuplicate", index }; From d9f01361f24c3302da6e0dad7ea871a33a92c95b Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 10:05:36 +0700 Subject: [PATCH 8/9] Count model ID length in code points and reset Live Usage on project change --- .../src-tauri/src/commands/openai_live.rs | 4 ++-- .../src/commands/openai_live_tests.rs | 5 ++++ .../src/lib/openaiThroughput.test.ts | 2 ++ .../desktop-tauri/src/lib/openaiThroughput.ts | 3 ++- .../sections/OpenAiLiveUsageSection.test.tsx | 23 ++++++++++++++++++- .../sections/OpenAiLiveUsageSection.tsx | 17 ++++++++++++-- .../sections/credentials/OpenAiExtras.tsx | 6 +++++ 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/openai_live.rs b/apps/desktop-tauri/src-tauri/src/commands/openai_live.rs index e32143238da..57036e32243 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/openai_live.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/openai_live.rs @@ -80,9 +80,9 @@ pub(crate) fn validated_model_rate_limits( if model.is_empty() { return Err("Each rate limit needs a model ID.".to_string()); } - if model.len() > MAX_MODEL_ID_LEN || model.chars().any(char::is_control) { + if model.chars().count() > MAX_MODEL_ID_LEN || model.chars().any(char::is_control) { return Err(format!( - "Model IDs must be on one line and under {MAX_MODEL_ID_LEN} characters." + "Model IDs must be on one line and at most {MAX_MODEL_ID_LEN} characters." )); } if !seen.insert(model) { diff --git a/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs index 8bc08038aa1..9b2124f86de 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs @@ -90,6 +90,11 @@ fn validated_rows_reject_bad_input_instead_of_dropping_it() { let multiline = validated_model_rate_limits(&[row("gpt-\n5", 1, None)]).unwrap_err(); assert!(multiline.contains("one line")); + + let at_limit = "é".repeat(200); + assert!(validated_model_rate_limits(&[row(&at_limit, 1, None)]).is_ok()); + let over_limit = "é".repeat(201); + assert!(validated_model_rate_limits(&[row(&over_limit, 1, None)]).is_err()); } #[test] diff --git a/apps/desktop-tauri/src/lib/openaiThroughput.test.ts b/apps/desktop-tauri/src/lib/openaiThroughput.test.ts index 5a3dab8c89d..2666631b703 100644 --- a/apps/desktop-tauri/src/lib/openaiThroughput.test.ts +++ b/apps/desktop-tauri/src/lib/openaiThroughput.test.ts @@ -150,6 +150,8 @@ describe("parseLimitDrafts", () => { expect(parseLimitDrafts([row("m".repeat(201), "1")])).toMatchObject({ error: "OpenAiLimitErrorLine", }); + // Counted in code points, like the shell: 200 astral characters still fit. + expect(parseLimitDrafts([row("😀".repeat(200), "1")])).toMatchObject({ ok: true }); }); it("accepts an empty list, which clears the stored limits", () => { diff --git a/apps/desktop-tauri/src/lib/openaiThroughput.ts b/apps/desktop-tauri/src/lib/openaiThroughput.ts index 09b1ebfa2ad..140c7c33605 100644 --- a/apps/desktop-tauri/src/lib/openaiThroughput.ts +++ b/apps/desktop-tauri/src/lib/openaiThroughput.ts @@ -119,7 +119,8 @@ export function parseLimitDrafts(drafts: readonly LimitDraft[]): LimitDraftResul const draft = drafts[index]; const model = draft.model.trim(); if (model.length === 0) return { ok: false, error: "OpenAiLimitErrorModel", index }; - if (model.length > MAX_MODEL_ID_LENGTH || hasControlCharacter(model)) { + // Code points, matching the shell's `chars().count()` check. + if ([...model].length > MAX_MODEL_ID_LENGTH || hasControlCharacter(model)) { return { ok: false, error: "OpenAiLimitErrorLine", index }; } if (seen.has(model)) return { ok: false, error: "OpenAiLimitErrorDuplicate", index }; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx index 17e7461fe6f..3d9c5ee4675 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { LocaleKey } from "../../../../i18n/keys"; import type { @@ -17,6 +17,7 @@ vi.mock("../../../../lib/tauri", async (importOriginal) => ({ ...tauriMocks, })); +import { PROVIDER_WORKSPACE_ID_SAVED_EVENT } from "./credentials/OpenAiExtras"; import { OpenAiLiveUsageSection } from "./OpenAiLiveUsageSection"; const t = (key: LocaleKey) => key as string; @@ -100,4 +101,24 @@ describe("OpenAiLiveUsageSection", () => { await waitFor(() => expect(screen.getByText("gpt-5")).toBeTruthy()); expect(tauriMocks.getOpenAiOrgRateLimits).toHaveBeenCalledTimes(1); }); + + it("clears loaded caps when an OpenAI project id is saved", async () => { + renderSection(); + fireEvent.click(screen.getByText("OpenAiOrgLimitsLoad")); + await waitFor(() => expect(screen.getByText("gpt-5")).toBeTruthy()); + + act(() => { + window.dispatchEvent( + new CustomEvent(PROVIDER_WORKSPACE_ID_SAVED_EVENT, { detail: "litellm" }), + ); + }); + expect(screen.getByText("gpt-5")).toBeTruthy(); + + act(() => { + window.dispatchEvent( + new CustomEvent(PROVIDER_WORKSPACE_ID_SAVED_EVENT, { detail: "openaiapi" }), + ); + }); + expect(screen.queryByText("gpt-5")).toBeNull(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx index 7317d795ccd..390454fc162 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Field, Toggle } from "../../../../components/FormControls"; import { OpenAITokensPerMinuteChart } from "../../../../components/OpenAITokensPerMinuteChart"; import type { LocaleKey } from "../../../../i18n/keys"; @@ -18,6 +18,7 @@ import type { ProviderThroughputSnapshot, SettingsUpdate, } from "../../../../types/bridge"; +import { PROVIDER_WORKSPACE_ID_SAVED_EVENT } from "./credentials/OpenAiExtras"; import { OpenAiModelLimitsEditor } from "./OpenAiModelLimitsEditor"; type T = (key: LocaleKey) => string; @@ -50,6 +51,18 @@ const WINDOW_LABELS: Record = { */ export function OpenAiLiveUsageSection({ settings, disabled, t, onSettingsChange }: Props) { const limits = settings.openAiModelRateLimits ?? NO_LIMITS; + // Usage and org caps depend on the saved project scope; a new scope remounts the + // view so loaded data and any pending load from the old scope are discarded. + const [scopeVersion, setScopeVersion] = useState(0); + useEffect(() => { + const onSaved = (event: Event) => { + if ((event as CustomEvent).detail === "openaiapi") { + setScopeVersion((version) => version + 1); + } + }; + window.addEventListener(PROVIDER_WORKSPACE_ID_SAVED_EVENT, onSaved); + return () => window.removeEventListener(PROVIDER_WORKSPACE_ID_SAVED_EVENT, onSaved); + }, []); return ( <> onSettingsChange({ openAiModelRateLimits: next })} /> - + ); } 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 8897bb299bf..3891aa065b9 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 @@ -5,6 +5,9 @@ import { setProviderWorkspaceId, } from "../../../../../lib/tauri"; +/** Fired on `window` after a workspace ID is saved; `detail` is the provider id. */ +export const PROVIDER_WORKSPACE_ID_SAVED_EVENT = "codexbar:provider-workspace-id-saved"; + interface Props { providerId?: string; t: (key: LocaleKey) => string; @@ -47,6 +50,9 @@ export function OpenAiExtras({ providerId = "openaiapi", t }: Props) { const next = projectId.trim(); await setProviderWorkspaceId(providerId, next); setSavedProjectId(next); + window.dispatchEvent( + new CustomEvent(PROVIDER_WORKSPACE_ID_SAVED_EVENT, { detail: providerId }), + ); } catch (e) { setError(String(e)); } finally { From c60537db811152014669a6167b3972381dd949e9 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 16 Oct 2026 11:19:55 +0700 Subject: [PATCH 9/9] Match OpenAI Live Usage setting names to the frontend --- .../src-tauri/src/commands/bridge.rs | 2 ++ .../src/commands/openai_live_tests.rs | 18 ++++++++++++++++++ .../src-tauri/src/commands/settings.rs | 2 ++ 3 files changed, 22 insertions(+) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 9d85ae7cb0d..0a98f8e6673 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -1062,8 +1062,10 @@ pub struct SettingsSnapshot { codex_local_session_cost_ledger_enabled: bool, codex_openai_web_battery_saver_enabled: bool, /// OpenAI API Live Usage (upstream #4495): fetch Tokens/min on each refresh. + #[serde(rename = "openAiLiveUsageEnabled")] openai_live_usage_enabled: bool, /// Manual per-model organization limits, read by the card's limit line and the editor. + #[serde(rename = "openAiModelRateLimits")] openai_model_rate_limits: Vec, disable_keychain_access: bool, wayfinder_gateway_url: String, diff --git a/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs index 9b2124f86de..c24673ebd80 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs @@ -133,6 +133,24 @@ fn saving_limits_and_the_live_switch_round_trips_through_settings() { assert!(settings.model_rate_limits(ProviderId::OpenAIApi).is_empty()); } +#[test] +fn live_usage_fields_use_the_frontend_wire_names() { + let update: SettingsUpdate = serde_json::from_value(serde_json::json!({ + "openAiLiveUsageEnabled": false, + "openAiModelRateLimits": [{ "model": "gpt-5", "tokensPerMinute": 1000 }], + })) + .unwrap(); + assert_eq!(update.openai_live_usage_enabled, Some(false)); + assert_eq!( + update.openai_model_rate_limits.as_ref().map(Vec::len), + Some(1) + ); + + let snapshot = serde_json::to_value(SettingsSnapshot::from(Settings::default())).unwrap(); + assert_eq!(snapshot["openAiLiveUsageEnabled"], serde_json::json!(true)); + assert!(snapshot["openAiModelRateLimits"].is_array()); +} + #[test] fn live_usage_defaults_on_and_is_not_offered_for_other_providers() { let settings = Settings::default(); diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 0ca3a556d96..d0966ae9908 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -89,9 +89,11 @@ pub struct SettingsUpdate { pub codex_local_session_cost_ledger_enabled: Option, pub codex_openai_web_battery_saver_enabled: Option, /// OpenAI API: fetch the last hour of Tokens/min on each refresh (upstream #4495). + #[serde(rename = "openAiLiveUsageEnabled")] pub openai_live_usage_enabled: Option, /// OpenAI API: manual per-model organization limits; replaces the stored list, and an /// empty list clears it. + #[serde(rename = "openAiModelRateLimits")] pub openai_model_rate_limits: Option>, pub disable_keychain_access: Option, /// Map of provider CLI name → metric preference label.