language_models: Add SuperGrok OAuth provider (#63248)
# Objective Grok in the agent is BYOK only today (`x_ai` plus an API key from console.x.ai). SuperGrok is a separate subscription, so you either pay twice or you skip Grok in Zed. This makes SuperGrok a first-class LLM provider, same idea as ChatGPT Subscription or GitHub Copilot Chat. Discussion: https://github.com/zed-industries/zed/discussions/63114#discussioncomment-18158663 ## Solution Adds a SuperGrok provider next to the API-key one. Sign in / Sign out is in Settings → AI → LLM Providers, same shape as ChatGPT Subscription. Tokens go in the OS keychain, not settings.json. Shipped models: Grok 4.6 (default), Grok 4.5, Grok Build 0.1. Reasoning effort on 4.5/4.6 matches the BYOK xAI provider (no `none`, default high, 4.6 has xhigh). Some Grok plans still get HTTP 403 after a successful login. The error says the account cannot use this API and points at the API-key provider. ## Testing I have been running this locally for a few days on macOS 27 (public beta) with a SuperGrok Heavy subscription. No issues so far. `cargo test -p x_ai_subscribed --lib` and `cargo test -p language_models --lib provider::x_ai_subscribed` pass. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase SuperGrok LLM provider settings: <img width="956" height="816" alt="Screenshot 2026-08-26 at 13 41 17" src="https://github.com/user-attachments/assets/ef2d464a-66ad-416a-a924-725a18d41164" /> --- Release Notes: - Added SuperGrok sign-in so a SuperGrok subscription can use Grok models in the agent
This commit is contained in:
1 parent
a5be476362
commit
fc13b40a69
10 files changed
+1895
No files matched your search
Generated
+26
@@ -9747,6 +9747,7 @@ dependencies = [
|
||||
"ui_input",
|
||||
"util",
|
||||
"x_ai",
|
||||
"x_ai_subscribed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -22531,6 +22532,31 @@ dependencies = [
|
||||
"strum 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x_ai_subscribed"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"credentials_provider",
|
||||
"futures 0.3.32",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"language_model",
|
||||
"log",
|
||||
"oauth_callback_server",
|
||||
"open_ai",
|
||||
"parking_lot",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smol",
|
||||
"url",
|
||||
"util",
|
||||
"x_ai",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
|
||||
@@ -237,6 +237,7 @@ members = [
|
||||
"crates/worktree",
|
||||
"crates/worktree_benchmarks",
|
||||
"crates/x_ai",
|
||||
"crates/x_ai_subscribed",
|
||||
"crates/zed",
|
||||
"crates/zed_actions",
|
||||
"crates/zed_credentials_provider",
|
||||
@@ -501,6 +502,7 @@ which_key = { path = "crates/which_key" }
|
||||
workspace = { path = "crates/workspace" }
|
||||
worktree = { path = "crates/worktree" }
|
||||
x_ai = { path = "crates/x_ai" }
|
||||
x_ai_subscribed = { path = "crates/x_ai_subscribed" }
|
||||
zed_actions = { path = "crates/zed_actions" }
|
||||
zed_credentials_provider = { path = "crates/zed_credentials_provider" }
|
||||
zed_env_vars = { path = "crates/zed_env_vars" }
|
||||
|
||||
@@ -69,6 +69,7 @@ ui.workspace = true
|
||||
ui_input.workspace = true
|
||||
util.workspace = true
|
||||
x_ai = { workspace = true, features = ["schemars"] }
|
||||
x_ai_subscribed.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
client = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::provider::openai_subscribed::OpenAiSubscribedProvider;
|
||||
use crate::provider::opencode::OpenCodeLanguageModelProvider;
|
||||
use crate::provider::vercel_ai_gateway::VercelAiGatewayLanguageModelProvider;
|
||||
use crate::provider::x_ai::XAiLanguageModelProvider;
|
||||
use crate::provider::x_ai_subscribed::XAiSubscribedProvider;
|
||||
pub use crate::settings::*;
|
||||
|
||||
pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
|
||||
@@ -324,6 +325,14 @@ fn register_language_model_providers(
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
registry.register_provider(
|
||||
Arc::new(XAiSubscribedProvider::new(
|
||||
client.http_client(),
|
||||
credentials_provider.clone(),
|
||||
cx,
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
registry.register_provider(
|
||||
Arc::new(OpenCodeLanguageModelProvider::new(
|
||||
client.http_client(),
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod opencode;
|
||||
|
||||
pub mod vercel_ai_gateway;
|
||||
pub mod x_ai;
|
||||
pub mod x_ai_subscribed;
|
||||
|
||||
const COMMON_RESERVED_HEADER_NAMES: &[&str] = &["Authorization", "Content-Type", "Accept"];
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use credentials_provider::CredentialsProvider;
|
||||
use futures::future::Shared;
|
||||
use gpui::{App, Context, Entity, SharedString, Task, Window};
|
||||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
AuthenticateError, IconOrSvg, InlineDescription, LanguageModel, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
ProviderSettingsView,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use ui::{ConfiguredApiCard, prelude::*};
|
||||
use x_ai_subscribed::{PROVIDER_ID, PROVIDER_NAME, State, SuperGrokModel, create_language_model};
|
||||
|
||||
const SUBSCRIPTION_DESCRIPTION: &str =
|
||||
"Sign in with your SuperGrok subscription to use Grok models in Zed's agent.";
|
||||
|
||||
pub struct XAiSubscribedProvider {
|
||||
state: Entity<State>,
|
||||
}
|
||||
|
||||
impl XAiSubscribedProvider {
|
||||
pub fn new(
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
credentials_provider: Arc<dyn CredentialsProvider>,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
let state = cx.new(|cx| State::new(http_client, credentials_provider, cx));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
fn create_model(&self, model: SuperGrokModel, cx: &App) -> Arc<dyn LanguageModel> {
|
||||
create_language_model(model, &self.state, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModelProviderState for XAiSubscribedProvider {
|
||||
type ObservableEntity = State;
|
||||
|
||||
fn observable_entity(&self) -> Option<Entity<Self::ObservableEntity>> {
|
||||
Some(self.state.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageModelProvider for XAiSubscribedProvider {
|
||||
fn id(&self) -> LanguageModelProviderId {
|
||||
PROVIDER_ID
|
||||
}
|
||||
|
||||
fn name(&self) -> LanguageModelProviderName {
|
||||
PROVIDER_NAME
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconOrSvg {
|
||||
IconOrSvg::Icon(IconName::AiXAi)
|
||||
}
|
||||
|
||||
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
Some(self.create_model(SuperGrokModel::Grok46, cx))
|
||||
}
|
||||
|
||||
fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
|
||||
Some(self.create_model(SuperGrokModel::GrokBuild01, cx))
|
||||
}
|
||||
|
||||
fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
|
||||
if !self.is_authenticated(cx) {
|
||||
return Vec::new();
|
||||
}
|
||||
SuperGrokModel::all()
|
||||
.into_iter()
|
||||
.map(|model| self.create_model(model, cx))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn recommended_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
|
||||
self.default_model(cx).into_iter().collect()
|
||||
}
|
||||
|
||||
fn is_authenticated(&self, cx: &App) -> bool {
|
||||
self.state.read(cx).is_authenticated()
|
||||
}
|
||||
|
||||
fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
|
||||
if self.is_authenticated(cx) {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
let load_task: Option<Shared<_>> = self.state.read(cx).load_task();
|
||||
if let Some(load_task) = load_task {
|
||||
let weak_state = self.state.downgrade();
|
||||
cx.spawn(async move |cx| {
|
||||
load_task
|
||||
.await
|
||||
.map_err(|error| AuthenticateError::Other(anyhow!("{error:#}")))?;
|
||||
let is_auth = weak_state
|
||||
.read_with(&*cx, |state, _| state.is_authenticated())
|
||||
.map_err(AuthenticateError::Other)?;
|
||||
if is_auth {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AuthenticateError::CredentialsNotFound)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Task::ready(Err(AuthenticateError::CredentialsNotFound))
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_view(&self, cx: &mut App) -> Option<ProviderSettingsView> {
|
||||
let is_authenticated = self.state.read(cx).is_authenticated();
|
||||
let title = if is_authenticated {
|
||||
None
|
||||
} else {
|
||||
Some("Configure SuperGrok".into())
|
||||
};
|
||||
let description = if is_authenticated {
|
||||
None
|
||||
} else {
|
||||
Some(InlineDescription::Text(SUBSCRIPTION_DESCRIPTION.into()))
|
||||
};
|
||||
|
||||
Some(ProviderSettingsView::Inline(
|
||||
language_model::InlineProviderSettings {
|
||||
title,
|
||||
description,
|
||||
create_view: Arc::new({
|
||||
let state = self.state.clone();
|
||||
move |_window, cx| {
|
||||
cx.new(|_cx| ConfigurationView {
|
||||
state: state.clone(),
|
||||
compact: true,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn authentication_error_message(&self) -> SharedString {
|
||||
"Your SuperGrok session is invalid or has expired. \
|
||||
Sign in again via Settings > AI > LLM Providers to continue."
|
||||
.into()
|
||||
}
|
||||
|
||||
fn missing_credentials_error_message(&self) -> SharedString {
|
||||
"You are not signed in to SuperGrok. \
|
||||
Sign in via Settings > AI > LLM Providers to continue."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigurationView {
|
||||
state: Entity<State>,
|
||||
/// When `true`, the description is rendered elsewhere (the settings row's
|
||||
/// left column), so it's omitted here to avoid duplication.
|
||||
compact: bool,
|
||||
}
|
||||
|
||||
impl Render for ConfigurationView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let state = self.state.read(cx);
|
||||
|
||||
if state.is_authenticated() {
|
||||
let label = state
|
||||
.email()
|
||||
.map(|email| format!("Signed in as {email}"))
|
||||
.unwrap_or_else(|| "Signed in".to_string());
|
||||
let state_entity = self.state.clone();
|
||||
|
||||
return v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
ConfiguredApiCard::new("x-ai-subscribed-sign-out", SharedString::from(label))
|
||||
.button_label("Sign Out")
|
||||
.on_click(cx.listener(move |_this, _, _window, cx| {
|
||||
state_entity
|
||||
.update(cx, |state, cx| state.sign_out(cx))
|
||||
.detach_and_log_err(cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let last_auth_error = state.last_auth_error();
|
||||
let provider_state = self.state.clone();
|
||||
let is_signing_in = state.is_signing_in();
|
||||
let button_label = if is_signing_in {
|
||||
"Signing in…"
|
||||
} else {
|
||||
"Sign In"
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.when(!self.compact, |this| {
|
||||
this.child(Label::new(SUBSCRIPTION_DESCRIPTION))
|
||||
})
|
||||
.child(
|
||||
Button::new("sign-in", button_label)
|
||||
.when(!self.compact, |this| this.full_width())
|
||||
.style(ButtonStyle::Outlined)
|
||||
.size(ButtonSize::Medium)
|
||||
.loading(is_signing_in)
|
||||
.disabled(is_signing_in)
|
||||
.on_click(move |_, _window, cx| {
|
||||
provider_state.update(cx, |state, cx| state.sign_in(cx));
|
||||
}),
|
||||
)
|
||||
.when_some(last_auth_error, |this, error| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_center()
|
||||
.child(
|
||||
Icon::new(IconName::XCircle)
|
||||
.color(Color::Error)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
.child(Label::new(error).color(Color::Muted)),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::AsyncApp;
|
||||
use http_client::FakeHttpClient;
|
||||
use parking_lot::Mutex;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_authenticate_awaits_initial_load(cx: &mut gpui::TestAppContext) {
|
||||
let creds_json = serde_json::json!({
|
||||
"access_token": "fresh_access",
|
||||
"refresh_token": "fresh_refresh",
|
||||
"expires_at_ms": u64::MAX,
|
||||
"email": null,
|
||||
});
|
||||
let creds_provider = Arc::new(FakeCredentialsProvider::new());
|
||||
creds_provider.storage.lock().replace((
|
||||
"Bearer".to_string(),
|
||||
serde_json::to_vec(&creds_json).unwrap(),
|
||||
));
|
||||
|
||||
let http_client = FakeHttpClient::create(|_| async {
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body(http_client::AsyncBody::default())?)
|
||||
});
|
||||
|
||||
let provider = cx.update(|cx| XAiSubscribedProvider::new(http_client, creds_provider, cx));
|
||||
let auth_task = cx.update(|cx| provider.authenticate(cx));
|
||||
cx.run_until_parked();
|
||||
auth_task
|
||||
.await
|
||||
.expect("authenticate should succeed after load completes with valid credentials");
|
||||
cx.update(|cx| {
|
||||
assert!(provider.is_authenticated(cx));
|
||||
});
|
||||
}
|
||||
|
||||
struct FakeCredentialsProvider {
|
||||
storage: Mutex<Option<(String, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl FakeCredentialsProvider {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
storage: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CredentialsProvider for FakeCredentialsProvider {
|
||||
fn read_credentials<'a>(
|
||||
&'a self,
|
||||
_url: &'a str,
|
||||
_cx: &'a AsyncApp,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
|
||||
Box::pin(async { Ok(self.storage.lock().clone()) })
|
||||
}
|
||||
|
||||
fn write_credentials<'a>(
|
||||
&'a self,
|
||||
_url: &'a str,
|
||||
username: &'a str,
|
||||
password: &'a [u8],
|
||||
_cx: &'a AsyncApp,
|
||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
||||
self.storage
|
||||
.lock()
|
||||
.replace((username.to_string(), password.to_vec()));
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn delete_credentials<'a>(
|
||||
&'a self,
|
||||
_url: &'a str,
|
||||
_cx: &'a AsyncApp,
|
||||
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
||||
*self.storage.lock() = None;
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -657,6 +657,7 @@ impl JsonSchema for LanguageModelProviderSetting {
|
||||
"openrouter",
|
||||
"vercel_ai_gateway",
|
||||
"x_ai",
|
||||
"x_ai_subscribed",
|
||||
"zed.dev"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "x_ai_subscribed"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/x_ai_subscribed.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
credentials_provider.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
http_client.workspace = true
|
||||
language_model.workspace = true
|
||||
log.workspace = true
|
||||
oauth_callback_server.workspace = true
|
||||
open_ai.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
smol.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
x_ai.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
http_client = { workspace = true, features = ["test-support"] }
|
||||
parking_lot.workspace = true
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user