feat(providers): add xAI provider with device code OAuth

Add a separate `xai` provider type for xAI Grok CLI subscription accounts.
It is independent of the existing `grok` provider, which reverse-proxies
grok.com with browser cookies; behavior of `grok` is unchanged.

Account binding uses the xAI device code flow, so no local callback
listener is needed and headless deployments can bind accounts. Refresh
tokens can also be imported individually or in batches, and are rotated
on refresh.

OAuth requests default to the cli-chat-proxy Responses API; API keys and
compact stay on api.x.ai. Explicit custom gateways are preserved. Only
`openai:responses` and `openai:responses:compact` are exposed; Chat,
Claude and Gemini clients reach the provider through Aether's existing
cross-format conversion rather than new native endpoints.

Upstream Responses payloads are sanitized for what xAI actually rejects:
`previous_response_id` and `metadata.user_id` are dropped, hosted
`tool_choice` is rewritten, `web_search` is restored for converted
clients, `image_generation` is stripped on older Grok conversation
models, unsupported reasoning effort is removed, and requested
`reasoning.encrypted_content` is preserved with a replay policy keyed on
the configured provider type rather than the model name.

Quota refresh reads /user and /billing?format=credits and stores a
structured usage snapshot; a prepaid balance keeps an account selectable
after the weekly allowance is exhausted. API-key accounts skip the
subscription billing surface. The admin UI shows remaining weekly quota
as a labeled bar in the provider drawer and the pool list.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
stabey
2026-09-14 21:09:03 +08:00
co-authored by Claude Opus 5
parent 60b89cc840
commit e83399db2f
71 changed files with 5495 additions and 148 deletions
+17 -12
View File
@@ -22,18 +22,20 @@ pub use providers::{
build_windsurf_pool_model_configs_request,
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
build_windsurf_pool_rate_limit_request_with_base_url, enrich_chatgpt_web_quota_metadata,
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
build_windsurf_pool_rate_limit_request_with_base_url, build_xai_pool_billing_request,
build_xai_pool_user_request, enrich_chatgpt_web_quota_metadata, grok_mode_id_for_model,
grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, ANTIGRAVITY_RETRIEVE_USER_QUOTA_SUMMARY_PATH,
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL,
CODEX_WHAM_RESET_CREDITS_CONSUME_URL, CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL,
GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH, GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH,
KIRO_USAGE_SDK_VERSION, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
WINDSURF_USER_STATUS_PATH,
XaiProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
ANTIGRAVITY_RETRIEVE_USER_QUOTA_SUMMARY_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH,
XAI_BILLING_PATH, XAI_USER_PATH,
};
pub use quota::{
provider_pool_key_account_quota_exhausted, provider_pool_key_model_quota_exhausted,
@@ -81,7 +83,8 @@ mod tests {
"grok",
"kiro",
"vertex_ai",
"windsurf"
"windsurf",
"xai"
]
);
assert!(service
@@ -104,7 +107,8 @@ mod tests {
"gemini_cli",
"grok",
"kiro",
"windsurf"
"windsurf",
"xai"
]
);
assert!(service.supports_quota_refresh("codex"));
@@ -112,6 +116,7 @@ mod tests {
assert!(service.supports_quota_refresh("grok"));
assert!(service.supports_quota_refresh("gemini_cli"));
assert!(service.supports_quota_refresh("windsurf"));
assert!(service.supports_quota_refresh("xai"));
assert_eq!(
service.quota_refresh_unsupported_message("claude_code"),
"Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口"
@@ -642,11 +647,11 @@ mod tests {
assert_eq!(
free_first["providers"],
json!(["codex", "grok", "kiro", "windsurf"])
json!(["codex", "grok", "kiro", "windsurf", "xai"])
);
assert_eq!(
recent_refresh["providers"],
json!(["codex", "grok", "kiro", "windsurf"])
json!(["codex", "grok", "kiro", "windsurf", "xai"])
);
assert_eq!(free_first["default_enabled"], json!(false));
assert_eq!(recent_refresh["default_enabled"], json!(false));
@@ -7,6 +7,7 @@ pub mod grok;
pub mod kiro;
pub mod unsupported;
pub mod windsurf;
pub mod xai;
pub use antigravity::AntigravityProviderPoolAdapter;
pub use antigravity::{
@@ -51,3 +52,7 @@ pub use windsurf::{
WINDSURF_DEFAULT_BASE_URL, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
WINDSURF_USER_STATUS_PATH,
};
pub use xai::{
build_xai_pool_billing_request, build_xai_pool_user_request, XaiProviderPoolAdapter,
XAI_BILLING_PATH, XAI_USER_PATH,
};
@@ -0,0 +1,255 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
use aether_provider_transport::xai::{
insert_cli_identity_headers, XAI_CHAT_PROXY_BASE_URL, XAI_PROVIDER_TYPE,
};
use serde_json::{Map, Value};
use crate::capability::ProviderPoolCapabilities;
use crate::provider::{
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
ProviderPoolMemberInput,
};
use crate::quota::{
provider_pool_current_unix_secs, provider_pool_json_bool, provider_pool_json_f64,
provider_pool_metadata_bucket, provider_pool_model_quota_exhausted,
provider_pool_quota_snapshot_exhausted_decision, provider_pool_reset_deadline_elapsed,
provider_pool_timestamp_unix_secs,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const XAI_USER_PATH: &str = "/user";
pub const XAI_BILLING_PATH: &str = "/billing?format=credits";
#[derive(Debug, Clone, Default)]
pub struct XaiProviderPoolAdapter;
impl ProviderPoolAdapter for XaiProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
XAI_PROVIDER_TYPE
}
fn capabilities(&self) -> ProviderPoolCapabilities {
ProviderPoolCapabilities {
plan_tier: true,
quota_reset: true,
quota_refresh: true,
}
}
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
if let Some(exhausted) = input.provider_model_name.and_then(|model| {
provider_pool_model_quota_exhausted(input.key, input.provider_type, model)
}) {
return exhausted;
}
if let Some(exhausted) =
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
{
return exhausted;
}
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
.is_some_and(quota_exhausted_from_bucket)
}
fn quota_refresh_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
provider_pool_endpoint_format_matches(endpoint, "openai:responses")
})
.or_else(|| provider_pool_matching_endpoint(endpoints, include_inactive, |_| true))
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 openai:responses 端点".to_string()
}
}
pub fn build_xai_pool_user_request(
key_id: &str,
authorization: (String, String),
) -> ProviderPoolQuotaRequestSpec {
build_xai_pool_request(
format!("xai-user:{key_id}"),
"xai:user",
"user",
XAI_USER_PATH,
authorization,
None,
)
}
pub fn build_xai_pool_billing_request(
key_id: &str,
authorization: (String, String),
user_id: Option<&str>,
) -> ProviderPoolQuotaRequestSpec {
build_xai_pool_request(
format!("xai-billing:{key_id}"),
"xai:billing",
"billing",
XAI_BILLING_PATH,
authorization,
user_id,
)
}
fn build_xai_pool_request(
request_id: String,
provider_api_format: &str,
model_name: &str,
path: &str,
authorization: (String, String),
user_id: Option<&str>,
) -> ProviderPoolQuotaRequestSpec {
let mut headers = BTreeMap::from([
(authorization.0, authorization.1),
("accept".to_string(), "application/json".to_string()),
]);
insert_cli_identity_headers(&mut headers);
if let Some(user_id) = user_id.map(str::trim).filter(|value| !value.is_empty()) {
headers.insert("x-userid".to_string(), user_id.to_string());
}
ProviderPoolQuotaRequestSpec {
request_id,
provider_name: XAI_PROVIDER_TYPE.to_string(),
quota_kind: XAI_PROVIDER_TYPE.to_string(),
method: "GET".to_string(),
url: format!("{}{path}", XAI_CHAT_PROXY_BASE_URL.trim_end_matches('/')),
headers,
content_type: None,
json_body: None,
client_api_format: "openai:responses".to_string(),
provider_api_format: provider_api_format.to_string(),
model_name: Some(model_name.to_string()),
}
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
if provider_pool_current_unix_secs().is_some_and(|now| {
provider_pool_reset_deadline_elapsed(
bucket,
provider_pool_timestamp_unix_secs(bucket.get("updated_at")),
now,
)
}) {
return false;
}
let usage_exhausted = provider_pool_json_f64(bucket.get("remaining"))
.is_some_and(|value| value <= 0.0)
|| provider_pool_json_f64(bucket.get("usage_percentage"))
.is_some_and(|value| value >= 100.0 - 1e-6)
|| match (
provider_pool_json_f64(bucket.get("usage_limit")),
provider_pool_json_f64(bucket.get("current_usage")),
) {
(Some(limit), Some(current)) if limit > 0.0 => current >= limit,
_ => false,
};
if !usage_exhausted {
return false;
}
let prepaid_available =
provider_pool_json_f64(bucket.get("prepaid_balance")).is_some_and(|value| value > 0.0);
if prepaid_available {
return false;
}
let on_demand_enabled = provider_pool_json_bool(bucket.get("on_demand_enabled")) != Some(false);
let on_demand_cap = provider_pool_json_f64(bucket.get("on_demand_cap")).unwrap_or(0.0);
let on_demand_used = provider_pool_json_f64(bucket.get("on_demand_used")).unwrap_or(0.0);
if on_demand_enabled && on_demand_cap > 0.0 && on_demand_used < on_demand_cap {
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::{
build_xai_pool_billing_request, build_xai_pool_user_request, quota_exhausted_from_bucket,
};
use aether_provider_transport::xai::{
XAI_CHAT_PROXY_BASE_URL, XAI_CLIENT_IDENTIFIER_VALUE, XAI_TOKEN_AUTH_VALUE,
};
use serde_json::{json, Map};
fn bucket(value: serde_json::Value) -> Map<String, serde_json::Value> {
value.as_object().cloned().expect("bucket should be object")
}
#[test]
fn user_and_billing_requests_pin_cli_chat_proxy_and_identity_headers() {
let authorization = ("authorization".to_string(), "Bearer xai-access".to_string());
let user = build_xai_pool_user_request("key-1", authorization.clone());
let billing = build_xai_pool_billing_request("key-1", authorization, Some("user-42"));
assert_eq!(
user.url,
format!("{}/user", XAI_CHAT_PROXY_BASE_URL.trim_end_matches('/'))
);
assert_eq!(
billing.url,
format!(
"{}/billing?format=credits",
XAI_CHAT_PROXY_BASE_URL.trim_end_matches('/')
)
);
assert_eq!(
user.headers.get("x-xai-token-auth").map(String::as_str),
Some(XAI_TOKEN_AUTH_VALUE)
);
assert_eq!(
user.headers
.get("x-grok-client-identifier")
.map(String::as_str),
Some(XAI_CLIENT_IDENTIFIER_VALUE)
);
assert!(!user.headers.contains_key("x-userid"));
assert_eq!(
billing.headers.get("x-userid").map(String::as_str),
Some("user-42")
);
assert_eq!(
billing.headers.get("authorization").map(String::as_str),
Some("Bearer xai-access")
);
}
#[test]
fn percent_exhausted_without_prepaid_or_on_demand_is_exhausted() {
assert!(quota_exhausted_from_bucket(&bucket(json!({
"usage_percentage": 100.0,
"prepaid_balance": 0.0,
"on_demand_cap": 0.0,
"on_demand_used": 0.0
}))));
}
#[test]
fn unified_billing_zero_on_demand_cap_is_not_exhausted_when_percent_remains() {
assert!(!quota_exhausted_from_bucket(&bucket(json!({
"usage_percentage": 46.0,
"prepaid_balance": 0.0,
"on_demand_cap": 0.0,
"on_demand_used": 0.0
}))));
}
#[test]
fn prepaid_balance_keeps_account_available_after_weekly_pool_hits_100() {
assert!(!quota_exhausted_from_bucket(&bucket(json!({
"usage_percentage": 100.0,
"prepaid_balance": 12.5,
"on_demand_cap": 0.0
}))));
}
}
+3 -2
View File
@@ -13,8 +13,8 @@ use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
use crate::providers::{
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
VERTEX_AI_PROVIDER_POOL_ADAPTER,
KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, XaiProviderPoolAdapter,
CLAUDE_CODE_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
};
#[derive(Clone)]
@@ -55,6 +55,7 @@ impl ProviderPoolService {
.with_adapter(Arc::new(KiroProviderPoolAdapter))
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
.with_adapter(Arc::new(WindsurfProviderPoolAdapter))
.with_adapter(Arc::new(XaiProviderPoolAdapter))
.with_adapter(Arc::new(VERTEX_AI_PROVIDER_POOL_ADAPTER))
}