language_models: Improve subscription states in the Agent configuration view (#30252)

Marshall Bowers created

This PR improves the subscription states in the Agent configuration view
to the new billing system.

Zed Free (legacy):

<img width="638" alt="Screenshot 2025-05-08 at 8 42 59 AM"
src="https://github.com/user-attachments/assets/7b62d4c1-2a9c-4c6a-aa8f-060730b6d7b3"
/>

Zed Free (new):

<img width="640" alt="Screenshot 2025-05-08 at 8 43 56 AM"
src="https://github.com/user-attachments/assets/8a48448e-813e-4633-955d-623d3e6d603c"
/>

Zed Pro trial:

<img width="641" alt="Screenshot 2025-05-08 at 8 45 52 AM"
src="https://github.com/user-attachments/assets/1ec7ee62-e954-48e7-8447-4584527307c9"
/>

Zed Pro:

<img width="636" alt="Screenshot 2025-05-08 at 8 47 21 AM"
src="https://github.com/user-attachments/assets/f934b2e3-0943-4b78-b8dc-0a31e731d8fb"
/>

Release Notes:

- agent: Improved the subscription-related information in the
configuration view.

Change summary

crates/client/src/user.rs                    | 15 +++
crates/collab/src/rpc.rs                     | 16 +++-
crates/language_models/src/provider/cloud.rs | 78 ++++++++++++---------
crates/proto/proto/app.proto                 |  6 +
4 files changed, 75 insertions(+), 40 deletions(-)

Detailed changes

crates/client/src/user.rs 🔗

@@ -11,7 +11,7 @@ use postage::{sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
 use std::sync::{Arc, Weak};
 use text::ReplicaId;
-use util::TryFutureExt as _;
+use util::{TryFutureExt as _, maybe};
 
 pub type UserId = u64;
 
@@ -101,6 +101,7 @@ pub struct UserStore {
     participant_indices: HashMap<u64, ParticipantIndex>,
     update_contacts_tx: mpsc::UnboundedSender<UpdateContacts>,
     current_plan: Option<proto::Plan>,
+    subscription_period: Option<(DateTime<Utc>, DateTime<Utc>)>,
     trial_started_at: Option<DateTime<Utc>>,
     model_request_usage_amount: Option<u32>,
     model_request_usage_limit: Option<proto::UsageLimit>,
@@ -166,6 +167,7 @@ impl UserStore {
             by_github_login: Default::default(),
             current_user: current_user_rx,
             current_plan: None,
+            subscription_period: None,
             trial_started_at: None,
             model_request_usage_amount: None,
             model_request_usage_limit: None,
@@ -333,6 +335,13 @@ impl UserStore {
     ) -> Result<()> {
         this.update(&mut cx, |this, cx| {
             this.current_plan = Some(message.payload.plan());
+            this.subscription_period = maybe!({
+                let period = message.payload.subscription_period?;
+                let started_at = DateTime::from_timestamp(period.started_at as i64, 0)?;
+                let ended_at = DateTime::from_timestamp(period.ended_at as i64, 0)?;
+
+                Some((started_at, ended_at))
+            });
             this.trial_started_at = message
                 .payload
                 .trial_started_at
@@ -713,6 +722,10 @@ impl UserStore {
         self.current_plan
     }
 
+    pub fn subscription_period(&self) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
+        self.subscription_period
+    }
+
     pub fn trial_started_at(&self) -> Option<DateTime<Utc>> {
         self.trial_started_at
     }

crates/collab/src/rpc.rs 🔗

@@ -2709,7 +2709,7 @@ async fn update_user_plan(user_id: UserId, session: &Session) -> Result<()> {
     let billing_customer = db.get_billing_customer_by_user_id(user_id).await?;
     let billing_preferences = db.get_billing_preferences(user_id).await?;
 
-    let usage = if let Some(llm_db) = session.app_state.llm_db.clone() {
+    let (subscription_period, usage) = if let Some(llm_db) = session.app_state.llm_db.clone() {
         let subscription = db.get_active_billing_subscription(user_id).await?;
 
         let subscription_period = crate::db::billing_subscription::Model::current_period(
@@ -2717,15 +2717,17 @@ async fn update_user_plan(user_id: UserId, session: &Session) -> Result<()> {
             session.is_staff(),
         );
 
-        if let Some((period_start_at, period_end_at)) = subscription_period {
+        let usage = if let Some((period_start_at, period_end_at)) = subscription_period {
             llm_db
                 .get_subscription_usage_for_period(user_id, period_start_at, period_end_at)
                 .await?
         } else {
             None
-        }
+        };
+
+        (subscription_period, usage)
     } else {
-        None
+        (None, None)
     };
 
     session
@@ -2743,6 +2745,12 @@ async fn update_user_plan(user_id: UserId, session: &Session) -> Result<()> {
                     billing_preferences
                         .map(|preferences| preferences.model_request_overages_enabled)
                 },
+                subscription_period: subscription_period.map(|(started_at, ended_at)| {
+                    proto::SubscriptionPeriod {
+                        started_at: started_at.timestamp() as u64,
+                        ended_at: ended_at.timestamp() as u64,
+                    }
+                }),
                 usage: usage.map(|usage| {
                     let plan = match plan {
                         proto::Plan::Free => zed_llm_client::Plan::Free,

crates/language_models/src/provider/cloud.rs 🔗

@@ -2,7 +2,7 @@ use anthropic::{AnthropicModelMode, parse_prompt_too_long};
 use anyhow::{Result, anyhow};
 use client::{Client, UserStore, zed_urls};
 use collections::BTreeMap;
-use feature_flags::{FeatureFlagAppExt, LlmClosedBetaFeatureFlag, ZedProFeatureFlag};
+use feature_flags::{FeatureFlagAppExt, LlmClosedBetaFeatureFlag};
 use futures::{
     AsyncBufReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream,
 };
@@ -1036,48 +1036,56 @@ impl ConfigurationView {
 
 impl Render for ConfigurationView {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        const ZED_AI_URL: &str = "https://zed.dev/ai";
+        const ZED_PRICING_URL: &str = "https://zed.dev/pricing";
 
         let is_connected = !self.state.read(cx).is_signed_out();
-        let plan = self.state.read(cx).user_store.read(cx).current_plan();
+        let user_store = self.state.read(cx).user_store.read(cx);
+        let plan = user_store.current_plan();
+        let subscription_period = user_store.subscription_period();
+        let eligible_for_trial = user_store.trial_started_at().is_none();
         let has_accepted_terms = self.state.read(cx).has_accepted_terms_of_service(cx);
 
         let is_pro = plan == Some(proto::Plan::ZedPro);
-        let subscription_text = Label::new(if is_pro {
-            "You have access to Zed's hosted LLMs through your Zed Pro subscription."
+        let subscription_text = match (plan, subscription_period) {
+            (Some(proto::Plan::ZedPro), Some(_)) => {
+                "You have access to Zed's hosted LLMs through your Zed Pro subscription."
+            }
+            (Some(proto::Plan::ZedProTrial), Some(_)) => {
+                "You have access to Zed's hosted LLMs through your Zed Pro trial."
+            }
+            (Some(proto::Plan::Free), Some(_)) => {
+                "You have basic access to Zed's hosted LLMs through your Zed Free subscription."
+            }
+            _ => {
+                if eligible_for_trial {
+                    "Subscribe for access to Zed's hosted LLMs. Start with a 14 day free trial."
+                } else {
+                    "Subscribe for access to Zed's hosted LLMs."
+                }
+            }
+        };
+        let manage_subscription_buttons = if is_pro {
+            h_flex().child(
+                Button::new("manage_settings", "Manage Subscription")
+                    .style(ButtonStyle::Tinted(TintColor::Accent))
+                    .on_click(cx.listener(|_, _, _, cx| cx.open_url(&zed_urls::account_url(cx)))),
+            )
         } else {
-            "You have basic access to models from Anthropic through the Zed AI Free plan."
-        });
-        let manage_subscription_button = if is_pro {
-            Some(
-                h_flex().child(
-                    Button::new("manage_settings", "Manage Subscription")
-                        .style(ButtonStyle::Tinted(TintColor::Accent))
+            h_flex()
+                .gap_2()
+                .child(
+                    Button::new("learn_more", "Learn more")
+                        .style(ButtonStyle::Subtle)
+                        .on_click(cx.listener(|_, _, _, cx| cx.open_url(ZED_PRICING_URL))),
+                )
+                .child(
+                    Button::new("upgrade", "Upgrade")
+                        .style(ButtonStyle::Subtle)
+                        .color(Color::Accent)
                         .on_click(
                             cx.listener(|_, _, _, cx| cx.open_url(&zed_urls::account_url(cx))),
                         ),
-                ),
-            )
-        } else if cx.has_flag::<ZedProFeatureFlag>() {
-            Some(
-                h_flex()
-                    .gap_2()
-                    .child(
-                        Button::new("learn_more", "Learn more")
-                            .style(ButtonStyle::Subtle)
-                            .on_click(cx.listener(|_, _, _, cx| cx.open_url(ZED_AI_URL))),
-                    )
-                    .child(
-                        Button::new("upgrade", "Upgrade")
-                            .style(ButtonStyle::Subtle)
-                            .color(Color::Accent)
-                            .on_click(
-                                cx.listener(|_, _, _, cx| cx.open_url(&zed_urls::account_url(cx))),
-                            ),
-                    ),
-            )
-        } else {
-            None
+                )
         };
 
         if is_connected {
@@ -1091,7 +1099,7 @@ impl Render for ConfigurationView {
                 ))
                 .when(has_accepted_terms, |this| {
                     this.child(subscription_text)
-                        .children(manage_subscription_button)
+                        .child(manage_subscription_buttons)
                 })
         } else {
             v_flex()

crates/proto/proto/app.proto 🔗

@@ -26,6 +26,12 @@ message UpdateUserPlan {
     optional uint64 trial_started_at = 2;
     optional bool is_usage_based_billing_enabled = 3;
     optional SubscriptionUsage usage = 4;
+    optional SubscriptionPeriod subscription_period = 5;
+}
+
+message SubscriptionPeriod {
+    uint64 started_at = 1;
+    uint64 ended_at = 2;
 }
 
 message SubscriptionUsage {