diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b04879486..3ba763be40 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +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 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). - Tray: add a **Progress bars stacked** menu bar style that draws the session and weekly meters as two equal-height rows on the single tray icon, with the same windows and pace tint as **Meter bars**; older settings files keep their style (upstream #4436). - Settings providers: show only enabled providers from the sidebar options menu, search by CLI alias (such as `openai` for Codex), restore the default provider order, and undo a drag reorder or a restore with Ctrl+Z or the options menu. Hidden providers keep their slots when the order changes (upstream #4440). - Add **Color by provider** to Settings > Menu Bar (off by default): tints the tray usage bars and percent text with the shown provider's brand or accent color, keeps pace colors and the error colors unchanged, and falls back to the usual colors when a tint would be hard to see on the tray background (upstream #4321). diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index b999c12604..cc81c51959 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}; @@ -1061,6 +1065,12 @@ 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. + #[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, provider_metrics: std::collections::HashMap, @@ -1122,6 +1132,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 = @@ -1214,6 +1230,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 0000000000..2d78527a31 --- /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 0000000000..c91104dbdf --- /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 793e20af21..404715e240 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 0000000000..57036e3224 --- /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.chars().count() > MAX_MODEL_ID_LEN || model.chars().any(char::is_control) { + return Err(format!( + "Model IDs must be on one line and at most {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 0000000000..c24673ebd8 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/openai_live_tests.rs @@ -0,0 +1,169 @@ +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")); + + 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] +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_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(); + 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 09a89c3df1..12a7609d22 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -90,6 +90,13 @@ 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). + #[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. pub provider_metrics: Option>, @@ -132,6 +139,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() @@ -223,6 +231,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::>(); @@ -618,7 +642,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 c42aedbee7..87f5ca0183 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -290,6 +290,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, diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 5ec8f1a807..d9953e6043 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 7b6c4c1047..2aa6cc58b3 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 0000000000..babc247e10 --- /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 0000000000..0c04777d52 --- /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 cd0183955b..39a0f07a28 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/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index ca956eae8c..ffc43b89d4 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -875,6 +875,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/apps/desktop-tauri/src/lib/openaiThroughput.test.ts b/apps/desktop-tauri/src/lib/openaiThroughput.test.ts new file mode 100644 index 0000000000..2666631b70 --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiThroughput.test.ts @@ -0,0 +1,168 @@ +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", + }); + // 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", () => { + 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 0000000000..140c7c3360 --- /dev/null +++ b/apps/desktop-tauri/src/lib/openaiThroughput.ts @@ -0,0 +1,169 @@ +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+$/; + +/** 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 + * 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 }; + // 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 }; + 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 59691560ad..4842e7bfa4 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"); @@ -468,6 +470,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/styles.css b/apps/desktop-tauri/src/styles.css index 1d8fa38a16..50cf0ec9da 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; 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 0000000000..3d9c5ee467 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.test.tsx @@ -0,0 +1,124 @@ +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 { + 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 { PROVIDER_WORKSPACE_ID_SAVED_EVENT } from "./credentials/OpenAiExtras"; +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); + }); + + 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 new file mode 100644 index 0000000000..390454fc16 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/OpenAiLiveUsageSection.tsx @@ -0,0 +1,226 @@ +import { useEffect, 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 { PROVIDER_WORKSPACE_ID_SAVED_EVENT } from "./credentials/OpenAiExtras"; +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; + // 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({ 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 0000000000..94c26feaa1 --- /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 0000000000..0138a4995b --- /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 e46a591568..8dfa2008aa 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" && ( 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 { diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index e965075350..a34d45d36e 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -312,6 +312,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; @@ -462,6 +466,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; @@ -1119,6 +1127,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; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 595172c49f..d1e9285eeb 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1153,6 +1153,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 6c06eb9d30..e548149d06 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1664,3 +1664,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 9393fe3138..aa28762d9b 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1661,3 +1661,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 c0b9bab15a..204a0c307c 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1662,3 +1662,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 bf3ab71d22..fe0f9a2f35 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1661,3 +1661,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 efb11a60e9..386b456634 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1666,3 +1666,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 a156a6b9fb..f51bacd5fb 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1662,3 +1662,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 c9fa917fa5..99fe6f7c04 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1662,3 +1662,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 5c8388b0c9..0723f8c24e 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1664,3 +1664,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 6d58d49dee..34dd65caa3 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1662,3 +1662,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 dbaa9194b3..3e5163b3d9 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1662,3 +1662,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 = 僅檢查了部分專案。