Skip to content

Commit 41f91c6

Browse files
warp-agent-staging[bot]jefflloydoz-agent
authored
[REV-1714] Use server-authoritative AI credit availability in the client (#14634)
## Summary Supersedes #14352 with its conflicts resolved against current `master`. Consumes the server-authoritative `User.aiCreditAvailability` GraphQL field (warp-server [#12935](warpdotdev/warp-server#12935) / [#13369](warpdotdev/warp-server#13369)) so the client no longer infers overall AI availability from stale request limits, grants, or workspace metadata. `AIRequestUsageModel` holds the server decision with last-known-good semantics. Prompt alerts, the buy-credits banner, AI Assistant, Agent Mode, and code review use the shared availability. An `OUT_OF_CREDITS` denial is refined by usable local API keys or loaded local-chain Bedrock credentials when policy permits; delinquency and spend-limit denials are hard rejections. ## Client behavior - Mirrors `AICreditAvailability` GraphQL types with forward-compatible unknown enum handling. - Receives availability through workspace metadata and coalesced targeted refreshes after auth, credential, workspace, add-on credit, and overage changes. - Resets availability on logout and keeps the last successful decision across transient refresh failures. - Maps delinquency and enterprise spend-limit reasons to existing presentation states. - Uses authoritative availability for the buy-credits banner and re-renders it on `CreditAvailabilityUpdated`. - Renames the legacy helper to `has_base_plan_requests_remaining()` and narrows it to `pub(crate)`. Its remaining consumers are intentionally base-quota-specific: anonymous request-limit gates, overage refresh heuristics, default-model-switch presentation, and the pre-fetch fallback. ## Server dependency Requires: - [warp-server#13369](warpdotdev/warp-server#13369) (merged): shared inference-admission and availability semantics. - [warp-server#13719](warpdotdev/warp-server#13719): direct-federation Gemini Enterprise availability and post-model GEAP/Bedrock credential recognition. The server follow-up must deploy before this client change ships. Without it, a valid direct-federation Gemini Enterprise configuration can be reported as out of credits, or the client can be enabled and then receive an out-of-credits response when submitting. ## Conflict resolution Merged `origin/master` into the source branch. Six files conflicted, all mechanical "both sides added a field / test" collisions between this change (`ai_credit_availability`) and master's user-level add-on-credits purchase policy (`user_purchase_policy`)—both were kept: - `app/src/workspaces/user_workspaces.rs` - `app/src/workspaces/gql_convert.rs` - `app/src/workspaces/update_manager.rs` - `crates/graphql/src/api/queries/get_workspaces_metadata_for_user.rs` - `app/src/workspaces/update_manager_tests.rs` - `app/src/workspaces/user_workspaces_tests.rs` Master's new `gql_user` helper was extended with `ai_credit_availability`, and affected workspace test harnesses register the telemetry and request-usage models required by the combined changes. ## Verification - `cargo nextest run -p warp -E 'test(request_usage_model) + test(prompt_alert)' --no-fail-fast` — 56 passed. - `cargo nextest run -p warp -E 'test(credit_availability) + test(request_usage_model) + test(prompt_alert) + test(update_manager) + test(user_workspaces)'` — 171 passed after the master merge. - `cargo nextest run -p warp_graphql` — 7 passed. - `./script/format` - `cargo clippy -p warp --all-targets --tests -- -D warnings` - `cargo clippy --workspace --exclude warp_completer --all-targets --tests -- -D warnings` ## Manual decision-matrix validation Validated with the client connected to a live local warp-server while forcing only the `aiCreditAvailability` resolver response. Request middleware and admission logic remained production-identical. - **Warp credits available, no local BYO:** no prompt restriction, no buy-credits banner, normal model controls. - **No credits or BYO:** buy-credits banner and composer out-of-credits state shown with the expected add-credits CTA. - **OUT_OF_CREDITS + local API key:** both restrictions disappear; a real request succeeds through the production admission path. - **OUT_OF_CREDITS + local-chain Bedrock:** restrictions remain before `aws login`; after credentials load, both disappear. - **Server-managed availability (`available=true`, null credit source):** trusted without local credentials. - **DELINQUENT + local key:** remains blocked with "Restricted due to payment issue." - **Enterprise spend limit + local key:** remains blocked with the spend-limit presentation. The first live case exposed that the buy-credits banner still used base request counts even though the composer used authoritative availability. This PR now drives both from the same decision and includes focused banner coverage. ## Gemini Enterprise end-to-end control Using the real production resolver and an enterprise direct-federation Gemini configuration: 1. Removed Warp inference credit sources while keeping Gemini configured. 2. Reproduced the out-of-credits availability mismatch. 3. Applied server PR #13719; the client restriction disappeared. 4. Reproduced and fixed the downstream request credit guard; the Gemini request succeeded. 5. Restored WARP_PLAN and repeated the request: provider-inference cost remained zero and the local Oz task recorded 0.250051 platform credits (displayed as 0.3), confirming customer-inference billing. <!-- oz:computer-use-screenshots start --> ### Computer-use screenshots ![The Warp OSS client rendered a terminal UI with a "bash" tab, top bar icons, black terminal area, and a command input prompt at the bottom showing a "~" home directory indicator and a blinking cursor.](https://staging.warp.dev/api/v1/agent/artifacts/019fc300-9e89-7554-a02d-6cbdb75b84a2/download) <!-- oz:computer-use-screenshots end --> Originating thread: https://warp-dev.slack.com/archives/C0BDQDW8V5E/p1785680392740989 _Conversation: https://staging.warp.dev/conversation/e786e128-f77e-4ff6-9a41-528e9eb60953_ _Run: https://oz.staging.warp.dev/runs/019fc2e3-2949-72e9-808f-7f6e05af09e9_ _Plans:_ - _[Client: server-authoritative AI credit availability (REV-1714)](https://staging.warp.dev/drive/notebook/CuLVyr2mh4mGV0EVEQzBqP)_ Co-Authored-By: Oz <oz-agent@warp.dev> CHANGELOG-IMPROVEMENT: Out-of-credits, delinquency, and spend-limit states across AI surfaces now reflect the server's authoritative credit availability, so they stay accurate as your plan, credits, or API keys change. <!-- factory-agent: {"source":"factory-agent","task_id":"REV-1714","task_source":"linear","task_url":"https://linear.app/warpdotdev/issue/REV-1714/add-gql-api-endpoint-for-server-authoritative-credit-availability","linear_issue_id":"REV-1714","oz_run_id":"019fc2e3-2949-72e9-808f-7f6e05af09e9","repo":"warpdotdev/warp","review_rework_attempts":1,"ci_fix_attempts":1} --> _This PR was generated with [Oz](https://warp.dev/oz)._ --------- Co-authored-by: Jeff Lloyd <jeff@warp.dev> Co-authored-by: Oz <oz-agent@warp.dev>
1 parent 89af536 commit 41f91c6

29 files changed

Lines changed: 1372 additions & 42 deletions

app/src/ai/blocklist/agent_view/agent_message_bar.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ impl AgentMessageBar {
231231
if matches!(
232232
event,
233233
AIRequestUsageModelEvent::RequestUsageUpdated
234+
| AIRequestUsageModelEvent::CreditAvailabilityUpdated
234235
| AIRequestUsageModelEvent::AmbientCreditsBannerDismissed
235236
) {
236237
ctx.notify();

app/src/ai/blocklist/controller.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3217,11 +3217,12 @@ impl BlocklistAIController {
32173217

32183218
// If a user is below their personal limits, then we know that they won't eat into overages,
32193219
// so we don't need to refresh.
3220-
let has_no_requests_remaining = !AIRequestUsageModel::as_ref(ctx).has_requests_remaining();
3220+
let has_no_base_plan_requests_remaining =
3221+
!AIRequestUsageModel::as_ref(ctx).has_base_plan_requests_remaining();
32213222
// If overages aren't enabled, we're not going to reap the benefit of refreshing at all anyway.
32223223
let are_overages_enabled = workspace.are_overages_enabled();
32233224

3224-
if are_overages_enabled && has_no_requests_remaining {
3225+
if are_overages_enabled && has_no_base_plan_requests_remaining {
32253226
// Give a one second delay to ensure that Stripe has been charged and the database is completely updated,
32263227
// before syncing new AI overages data.
32273228
ctx.spawn(

app/src/ai/blocklist/prompt/prompt_alert.rs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use warpui::{
1212

1313
use crate::ai::AIRequestUsageModel;
1414
use crate::ai::blocklist::error_color;
15+
use crate::ai::credit_availability::{AICreditAvailability, AICreditDenialReason};
1516
use crate::auth::AuthStateProvider;
1617
use crate::network::NetworkStatus;
1718
use crate::server::ids::ServerId;
@@ -123,7 +124,10 @@ impl PromptAlertView {
123124
}
124125

125126
let request_usage_model = AIRequestUsageModel::as_ref(app);
126-
let has_requests_remaining = request_usage_model.has_requests_remaining();
127+
// Anonymous soft/hard gates are based on the base-plan request quota,
128+
// not overall AI availability (bonus grants / BYO / etc.).
129+
let has_base_plan_requests_remaining =
130+
request_usage_model.has_base_plan_requests_remaining();
127131
let auth_state = AuthStateProvider::as_ref(app).get();
128132

129133
// Next, if the user is anonymous, we check if they have reached a certain percentage of requests used.
@@ -134,14 +138,24 @@ impl PromptAlertView {
134138
let percentage_used = request_usage_model.request_percentage_used();
135139

136140
if percentage_used >= ANONYMOUS_USER_REQUEST_LIMIT_SOFT_GATE_PERCENTAGE {
137-
if has_requests_remaining {
141+
if has_base_plan_requests_remaining {
138142
return PromptAlertState::AnonymousUserRequestLimitSoftGate;
139143
} else {
140144
return PromptAlertState::AnonymousUserRequestLimitHardGate;
141145
}
142146
}
143147
}
144148

149+
// The server-authoritative availability decision drives the alert once
150+
// it has been fetched; local data below is only a pre-fetch fallback.
151+
if let Some(availability) = request_usage_model.server_availability() {
152+
return Self::state_from_server_availability(availability, app);
153+
}
154+
155+
// Legacy locally derived fallback, used only before the first
156+
// successful availability fetch (e.g. right after startup or against
157+
// servers that don't support the availability field yet).
158+
145159
// Next, make sure the user isn't delinquent in their plan.
146160
let workspace = UserWorkspaces::as_ref(app).current_workspace();
147161
if workspace.is_some_and(|w| w.billing_metadata.is_delinquent_due_to_payment_issue()) {
@@ -153,8 +167,46 @@ impl PromptAlertView {
153167
return PromptAlertState::NoAlert;
154168
}
155169

170+
Self::out_of_credits_presentation(app)
171+
}
172+
173+
/// Maps the server-authoritative availability decision to presentation
174+
/// state. The server decides *whether* AI is available; workspace policy
175+
/// only shapes the call-to-action copy.
176+
fn state_from_server_availability(
177+
availability: AICreditAvailability,
178+
app: &AppContext,
179+
) -> PromptAlertState {
180+
if availability.available {
181+
return PromptAlertState::NoAlert;
182+
}
183+
184+
match availability.denial_reason {
185+
AICreditDenialReason::Delinquent => PromptAlertState::DelinquentDueToPaymentIssue,
186+
AICreditDenialReason::EnterpriseTeamSpendLimitHit
187+
| AICreditDenialReason::EnterprisePerUserSpendLimitHit
188+
| AICreditDenialReason::EnterpriseWorkspaceSpendLimitHit => {
189+
PromptAlertState::MonthlyOveragesSpendLimitReached
190+
}
191+
AICreditDenialReason::None
192+
| AICreditDenialReason::OutOfCredits
193+
| AICreditDenialReason::Unknown => {
194+
// An out-of-credits denial only means the server found no path
195+
// it can see; a locally stored API key still permits requests,
196+
// which `has_any_ai_remaining` accounts for.
197+
if AIRequestUsageModel::as_ref(app).has_any_ai_remaining(app) {
198+
return PromptAlertState::NoAlert;
199+
}
200+
Self::out_of_credits_presentation(app)
201+
}
202+
}
203+
}
204+
205+
/// Picks the most actionable presentation for an out-of-credits denial
206+
/// based on the current workspace's overage policy.
207+
fn out_of_credits_presentation(app: &AppContext) -> PromptAlertState {
156208
// Check if overages are available.
157-
if let Some(workspace) = workspace {
209+
if let Some(workspace) = UserWorkspaces::as_ref(app).current_workspace() {
158210
let are_overages_toggleable = workspace.are_overages_toggleable();
159211
let are_overages_enabled = workspace.are_overages_enabled();
160212

@@ -481,3 +533,7 @@ impl TypedActionView for PromptAlertView {
481533
}
482534
}
483535
}
536+
537+
#[cfg(test)]
538+
#[path = "prompt_alert_tests.rs"]
539+
mod tests;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
use std::sync::Arc;
2+
3+
use ai::LLMProvider;
4+
use warpui::App;
5+
6+
use super::*;
7+
use crate::ai::credit_availability::AICreditSource;
8+
use crate::server::server_api::ServerApiProvider;
9+
use crate::server::server_api::team::MockTeamClient;
10+
use crate::server::server_api::workspace::MockWorkspaceClient;
11+
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
12+
use crate::workspaces::workspace::{ByoApiKeyPolicy, Workspace, WorkspaceUid};
13+
14+
fn initialize_app(app: &mut App) {
15+
initialize_app_with_workspaces(app, vec![]);
16+
}
17+
18+
fn initialize_app_with_workspaces(app: &mut App, workspaces: Vec<Workspace>) {
19+
app.add_singleton_model(|_| NetworkStatus::new());
20+
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
21+
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
22+
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
23+
app.add_singleton_model(|ctx| {
24+
UserWorkspaces::mock(
25+
Arc::new(MockTeamClient::new()),
26+
Arc::new(MockWorkspaceClient::new()),
27+
workspaces,
28+
ctx,
29+
)
30+
});
31+
if app
32+
.models_of_type::<settings::PrivatePreferences>()
33+
.is_empty()
34+
{
35+
app.update(crate::settings::init_and_register_user_preferences);
36+
}
37+
app.update(|ctx| {
38+
warpui_extras::secure_storage::register_noop("test", ctx);
39+
ctx.add_singleton_model(ApiKeyManager::new);
40+
});
41+
app.add_singleton_model(|_| crate::pricing::PricingInfoModel::new());
42+
app.add_singleton_model(|ctx| {
43+
AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx)
44+
});
45+
}
46+
47+
fn apply_server_availability(app: &mut App, availability: AICreditAvailability) {
48+
AIRequestUsageModel::handle(app).update(app, |model, ctx| {
49+
model.apply_server_availability(Ok(availability), ctx);
50+
});
51+
}
52+
53+
fn determine_state(app: &mut App) -> PromptAlertState {
54+
app.read(PromptAlertView::determine_state)
55+
}
56+
57+
#[test]
58+
fn test_server_available_maps_to_no_alert() {
59+
App::test((), |mut app| async move {
60+
initialize_app(&mut app);
61+
apply_server_availability(
62+
&mut app,
63+
AICreditAvailability::available_with_source(Some(AICreditSource::BaseLimit)),
64+
);
65+
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
66+
});
67+
}
68+
69+
#[test]
70+
fn test_server_delinquent_maps_to_delinquency_alert() {
71+
App::test((), |mut app| async move {
72+
initialize_app(&mut app);
73+
apply_server_availability(
74+
&mut app,
75+
AICreditAvailability::unavailable(AICreditDenialReason::Delinquent),
76+
);
77+
assert_eq!(
78+
determine_state(&mut app),
79+
PromptAlertState::DelinquentDueToPaymentIssue
80+
);
81+
});
82+
}
83+
84+
#[test]
85+
fn test_server_spend_limit_reasons_map_to_spend_limit_alert() {
86+
App::test((), |mut app| async move {
87+
initialize_app(&mut app);
88+
for reason in [
89+
AICreditDenialReason::EnterpriseTeamSpendLimitHit,
90+
AICreditDenialReason::EnterprisePerUserSpendLimitHit,
91+
AICreditDenialReason::EnterpriseWorkspaceSpendLimitHit,
92+
] {
93+
apply_server_availability(&mut app, AICreditAvailability::unavailable(reason));
94+
assert_eq!(
95+
determine_state(&mut app),
96+
PromptAlertState::MonthlyOveragesSpendLimitReached,
97+
"unexpected alert state for {reason:?}",
98+
);
99+
}
100+
});
101+
}
102+
103+
#[test]
104+
fn test_server_out_of_credits_maps_to_request_limit_reached() {
105+
App::test((), |mut app| async move {
106+
initialize_app(&mut app);
107+
// With no workspace overage policy in play, an out-of-credits denial
108+
// falls through to the generic request limit alert.
109+
for reason in [
110+
AICreditDenialReason::OutOfCredits,
111+
AICreditDenialReason::Unknown,
112+
] {
113+
apply_server_availability(&mut app, AICreditAvailability::unavailable(reason));
114+
assert_eq!(
115+
determine_state(&mut app),
116+
PromptAlertState::RequestLimitReached,
117+
"unexpected alert state for {reason:?}",
118+
);
119+
}
120+
});
121+
}
122+
123+
#[test]
124+
fn test_legacy_fallback_used_before_first_server_response() {
125+
App::test((), |mut app| async move {
126+
initialize_app(&mut app);
127+
// No server availability applied: the default request limit info has
128+
// requests remaining, so the legacy derivation reports no alert.
129+
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
130+
});
131+
}
132+
133+
#[test]
134+
fn test_server_managed_availability_maps_to_no_alert() {
135+
App::test((), |mut app| async move {
136+
initialize_app(&mut app);
137+
// `available` with no credit source means a server-managed BYO path
138+
// is configured — definite availability, no local key required.
139+
apply_server_availability(&mut app, AICreditAvailability::available_with_source(None));
140+
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
141+
});
142+
}
143+
144+
#[test]
145+
fn test_out_of_credits_with_local_key_maps_to_no_alert() {
146+
App::test((), |mut app| async move {
147+
let uid = WorkspaceUid::from(crate::server::ids::ServerId::from(1_i64));
148+
let mut workspace = Workspace::from_local_cache(uid, "Test Workspace".to_string(), None);
149+
workspace.billing_metadata.tier.byo_api_key_policy =
150+
Some(ByoApiKeyPolicy { enabled: true });
151+
initialize_app_with_workspaces(&mut app, vec![workspace]);
152+
153+
ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| {
154+
manager.set_provider_key(LLMProvider::OpenAI, Some("test-key".to_string()), ctx);
155+
});
156+
157+
// The server cannot see the locally stored key; the client refines
158+
// its OUT_OF_CREDITS answer.
159+
apply_server_availability(
160+
&mut app,
161+
AICreditAvailability::unavailable(AICreditDenialReason::OutOfCredits),
162+
);
163+
assert_eq!(determine_state(&mut app), PromptAlertState::NoAlert);
164+
});
165+
}

0 commit comments

Comments
 (0)