Repository navigation
Add OpenAI Live Usage view and model limits #897
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
5fc17f3
Add OpenAI live usage and model limit commands
78581ed
Add OpenAI Live Usage bridge types and helpers
813ea12
Add OpenAI Live Usage locale strings
65fa8f1
Show the OpenAI Tokens/min grid on the API card
fa4f31b
Add OpenAI Live Usage settings section
ccb14bc
Note OpenAI Live Usage UI in changelog
8518c46
Check limit model IDs for control characters without regex
d9f0136
Count model ID length in code points and reset Live Usage on project …
c60537d
Match OpenAI Live Usage setting names to the frontend
90cddd3
Merge remote-tracking branch 'origin/main' into feat/openai-live-usag…
faef7c3
Merge remote-tracking branch 'origin/main' into feat/openai-live-usag…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
68 changes: 68 additions & 0 deletions
68
apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<u64>, | ||
| } | ||
|
|
||
| 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<OrgModelRateLimitSnapshot>, | ||
| 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<u64>, | ||
| } | ||
|
|
||
| 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, | ||
| } | ||
| } | ||
| } |
66 changes: 66 additions & 0 deletions
66
apps/desktop-tauri/src-tauri/src/commands/bridge/openai_live_tests.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }) | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
apps/desktop-tauri/src-tauri/src/commands/openai_live.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Vec<ModelRateLimit>, 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<String> { | ||
| 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<String> { | ||
| 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<ProviderThroughputSnapshot, String> { | ||
| 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<OrgRateLimitsSnapshot, String> { | ||
| 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)) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove the superseded OpenAI API release note.
Lines 12–13 say the chart and Settings controls ship now. Line 17 repeats the feature description but says both will ship later. Remove or update the older entry so the Unreleased notes give one accurate status.
🤖 Prompt for AI Agents