mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
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:
@@ -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
|
||||
}))));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -754,6 +754,63 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_responses_transport_converts_standard_client_protocols() {
|
||||
let transport = transport_snapshot("xai", "openai:responses", "oauth", true, None);
|
||||
|
||||
for client_api_format in ["openai:chat", "claude:messages", "gemini:generate_content"] {
|
||||
assert!(
|
||||
request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
client_api_format,
|
||||
"openai:responses"
|
||||
),
|
||||
"{client_api_format} should convert onto xAI Responses"
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_transport_pair_skip_reason(&transport, client_api_format),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert!(request_conversion_transport_supported(
|
||||
&transport,
|
||||
RequestConversionKind::ToOpenAiResponses
|
||||
));
|
||||
assert!(
|
||||
!request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
"openai:responses:compact",
|
||||
"openai:responses"
|
||||
),
|
||||
"compact must not convert onto xAI Responses"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_compact_endpoint_is_same_format_only() {
|
||||
let compact = transport_snapshot("xai", "openai:responses:compact", "oauth", true, None);
|
||||
assert!(request_pair_allowed_for_transport(
|
||||
&compact,
|
||||
"openai:responses:compact",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
for client_api_format in [
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
] {
|
||||
assert!(
|
||||
!request_pair_allowed_for_transport(
|
||||
&compact,
|
||||
client_api_format,
|
||||
"openai:responses:compact"
|
||||
),
|
||||
"{client_api_format} must not convert onto xAI compact"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_openai_chat_anchor_supports_cross_format_conversion_via_cascade() {
|
||||
let mut transport = transport_snapshot("windsurf", "openai:chat", "oauth", true, None);
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod url;
|
||||
pub mod vertex;
|
||||
mod video;
|
||||
pub mod windsurf;
|
||||
pub mod xai;
|
||||
|
||||
pub use aether_oauth as oauth;
|
||||
pub use agent_identity::{
|
||||
@@ -195,3 +196,10 @@ pub use windsurf::{
|
||||
local_windsurf_request_transport_unsupported_reason_with_network, GET_CHAT_MESSAGE_PATH,
|
||||
WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
pub use xai::{
|
||||
extract_xai_user_id_from_auth_config, extract_xai_user_id_from_value,
|
||||
insert_cli_identity_headers, insert_cli_identity_headers_if_needed, is_xai_provider_transport,
|
||||
resolved_xai_request_base_url, resolved_xai_upstream_base_url,
|
||||
should_attach_cli_identity_headers, xai_auth_uses_api, xai_uses_official_api, XAI_API_BASE_URL,
|
||||
XAI_CHAT_PROXY_BASE_URL, XAI_PROVIDER_TYPE,
|
||||
};
|
||||
|
||||
@@ -275,6 +275,17 @@ const WINDSURF_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
|
||||
const XAI_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
fixed_provider: true,
|
||||
api_format_inheritance: ProviderApiFormatInheritance::OAuthOrBearer,
|
||||
enable_format_conversion_by_default: true,
|
||||
oauth_is_bearer_like: true,
|
||||
supports_model_fetch: false,
|
||||
supports_local_openai_chat_transport: false,
|
||||
supports_local_same_format_transport: true,
|
||||
..STANDARD_RUNTIME_POLICY
|
||||
};
|
||||
|
||||
const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "claude_code",
|
||||
version: 2,
|
||||
@@ -446,6 +457,27 @@ const WINDSURF_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTem
|
||||
runtime_policy: WINDSURF_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
const XAI_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "xai",
|
||||
version: 2,
|
||||
base_url: crate::xai::XAI_CHAT_PROXY_BASE_URL,
|
||||
endpoints: &[
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "openai:responses",
|
||||
api_format: "openai:responses",
|
||||
custom_path: None,
|
||||
config_defaults: FORCE_STREAM_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "openai:responses:compact",
|
||||
api_format: "openai:responses:compact",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
],
|
||||
runtime_policy: XAI_RUNTIME_POLICY,
|
||||
};
|
||||
|
||||
pub fn provider_type_is_fixed(provider_type: &str) -> bool {
|
||||
provider_runtime_policy(provider_type).fixed_provider
|
||||
}
|
||||
@@ -498,6 +530,7 @@ pub fn fixed_provider_template(provider_type: &str) -> Option<&'static FixedProv
|
||||
"vertex_ai" => Some(&VERTEX_AI_FIXED_PROVIDER_TEMPLATE),
|
||||
"antigravity" => Some(&ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE),
|
||||
"windsurf" => Some(&WINDSURF_FIXED_PROVIDER_TEMPLATE),
|
||||
"xai" => Some(&XAI_FIXED_PROVIDER_TEMPLATE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -613,6 +646,16 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
|
||||
redirect_uri: "show-auth-token",
|
||||
use_pkce: false,
|
||||
}),
|
||||
"xai" => Some(ProviderOAuthTemplate {
|
||||
provider_type: "xai",
|
||||
display_name: "xAI",
|
||||
authorize_url: aether_oauth::provider::providers::XAI_DEVICE_CODE_URL,
|
||||
token_url: aether_oauth::provider::providers::XAI_TOKEN_URL,
|
||||
client_id: aether_oauth::provider::providers::XAI_CLIENT_ID,
|
||||
scopes: aether_oauth::provider::providers::XAI_OAUTH_SCOPES,
|
||||
redirect_uri: "",
|
||||
use_pkce: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -825,6 +868,45 @@ mod tests {
|
||||
assert!(ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES.contains(&"windsurf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_fixed_provider_template_exposes_responses_endpoints() {
|
||||
let template = fixed_provider_template("xai").expect("xai template should exist");
|
||||
assert_eq!(template.provider_type, "xai");
|
||||
assert_eq!(template.base_url, crate::xai::XAI_CHAT_PROXY_BASE_URL);
|
||||
assert_eq!(template.version, 2);
|
||||
assert_eq!(
|
||||
template
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|item| item.api_format)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["openai:responses", "openai:responses:compact"]
|
||||
);
|
||||
|
||||
let policy = provider_runtime_policy("xai");
|
||||
assert!(policy.fixed_provider);
|
||||
assert!(policy.enable_format_conversion_by_default);
|
||||
assert!(policy.oauth_is_bearer_like);
|
||||
assert!(!policy.supports_model_fetch);
|
||||
assert!(policy.supports_local_same_format_transport);
|
||||
assert!(!policy.supports_local_openai_chat_transport);
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
"xai", "oauth", None
|
||||
));
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
"xai", "bearer", None
|
||||
));
|
||||
|
||||
let template = provider_type_admin_oauth_template("xai").expect("xai oauth template");
|
||||
assert_eq!(template.provider_type, "xai");
|
||||
assert_eq!(template.display_name, "xAI");
|
||||
assert_eq!(
|
||||
template.token_url,
|
||||
aether_oauth::provider::providers::XAI_TOKEN_URL
|
||||
);
|
||||
assert!(!ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES.contains(&"xai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_provider_key_inheritance_keeps_oauth_and_kiro_configured_bearer_keys_open() {
|
||||
assert!(fixed_provider_key_inherits_api_formats(
|
||||
|
||||
@@ -42,6 +42,11 @@ pub fn apply_transport_request_body_semantics(
|
||||
{
|
||||
sanitize_claude_code_request_body(provider_request_body);
|
||||
}
|
||||
aether_ai_formats::apply_xai_upstream_payload_edits(
|
||||
provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
);
|
||||
if provider_api_format == "gemini:embedding" && is_vertex_transport_context(transport) {
|
||||
apply_vertex_gemini_embedding_body_semantics(provider_request_body)?;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,12 @@ fn build_transport_request_url_inner(
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
let xai_base =
|
||||
crate::xai::resolved_xai_upstream_base_url(transport, &normalized_provider_api_format);
|
||||
let request_base_url = xai_base
|
||||
.as_deref()
|
||||
.unwrap_or(transport.endpoint.base_url.as_str());
|
||||
|
||||
let custom_path_template = transport
|
||||
.endpoint
|
||||
.custom_path
|
||||
@@ -164,7 +170,7 @@ fn build_transport_request_url_inner(
|
||||
path.to_string()
|
||||
};
|
||||
let mut url = build_passthrough_path_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
normalized_path.as_str(),
|
||||
params.request_query,
|
||||
blocked_keys,
|
||||
@@ -190,75 +196,68 @@ fn build_transport_request_url_inner(
|
||||
|
||||
let url = match normalized_provider_api_format.as_str() {
|
||||
"openai:chat" => Some(build_openai_chat_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
params.request_query,
|
||||
)),
|
||||
"openai:responses" => Some(build_openai_responses_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
params.request_query,
|
||||
false,
|
||||
)),
|
||||
"openai:responses:compact" => Some(build_openai_responses_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
params.request_query,
|
||||
true,
|
||||
)),
|
||||
"openai:search" => Some(build_openai_search_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
params.request_query,
|
||||
)),
|
||||
"openai:realtime" => build_passthrough_path_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
"/v1/realtime",
|
||||
params.request_query,
|
||||
GATEWAY_CREDENTIAL_QUERY_KEYS,
|
||||
)
|
||||
.and_then(|url| replace_realtime_model_query(url, params.mapped_model?)),
|
||||
"codex:live" => build_passthrough_path_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
"/live",
|
||||
params.request_query,
|
||||
GATEWAY_CREDENTIAL_QUERY_KEYS,
|
||||
),
|
||||
"openai:embedding" | "jina:embedding" => {
|
||||
build_provider_embedding_v1_url(&transport.endpoint.base_url, params.request_query)
|
||||
build_provider_embedding_v1_url(request_base_url, params.request_query)
|
||||
}
|
||||
"aliyun:multimodal_embedding" => {
|
||||
build_aliyun_multimodal_embedding_url(request_base_url, params.request_query)
|
||||
}
|
||||
"aliyun:multimodal_embedding" => build_aliyun_multimodal_embedding_url(
|
||||
&transport.endpoint.base_url,
|
||||
params.request_query,
|
||||
),
|
||||
"openai:rerank" | "jina:rerank" => {
|
||||
build_provider_rerank_v1_url(&transport.endpoint.base_url, params.request_query)
|
||||
build_provider_rerank_v1_url(request_base_url, params.request_query)
|
||||
}
|
||||
"claude:messages" => Some(if is_claude_count_tokens {
|
||||
build_default_claude_count_tokens_url(
|
||||
&transport.endpoint.base_url,
|
||||
params.request_query,
|
||||
)
|
||||
build_default_claude_count_tokens_url(request_base_url, params.request_query)
|
||||
} else {
|
||||
build_claude_messages_url(&transport.endpoint.base_url, params.request_query)
|
||||
build_claude_messages_url(request_base_url, params.request_query)
|
||||
}),
|
||||
"gemini:generate_content" => build_gemini_content_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
params.mapped_model?,
|
||||
params.upstream_is_stream,
|
||||
params.request_query,
|
||||
),
|
||||
"gemini:embedding" => build_gemini_embedding_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_base_url,
|
||||
params.mapped_model?,
|
||||
params.request_query,
|
||||
gemini_embedding_batch,
|
||||
),
|
||||
"gemini:interactions" => {
|
||||
build_gemini_interactions_url(&transport.endpoint.base_url, params.request_query)
|
||||
build_gemini_interactions_url(request_base_url, params.request_query)
|
||||
}
|
||||
"doubao:embedding" => {
|
||||
build_passthrough_path_url(request_base_url, "/embeddings", params.request_query, &[])
|
||||
}
|
||||
"doubao:embedding" => build_passthrough_path_url(
|
||||
&transport.endpoint.base_url,
|
||||
"/embeddings",
|
||||
params.request_query,
|
||||
&[],
|
||||
),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
@@ -2417,4 +2416,82 @@ mod tests {
|
||||
"https://api.example.com/v1/messages?model=claude%26admin%3Dtrue%23fragment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_oauth_responses_use_cli_chat_proxy() {
|
||||
let mut transport = sample_transport(
|
||||
"xai",
|
||||
"openai:responses",
|
||||
"https://cli-chat-proxy.grok.com/v1",
|
||||
None,
|
||||
);
|
||||
transport.key.auth_type = "oauth".to_string();
|
||||
transport.key.decrypted_auth_config =
|
||||
Some(r#"{"refresh_token":"rt","using_api":false}"#.to_string());
|
||||
|
||||
let url = build_transport_request_url(
|
||||
&transport,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "openai:responses",
|
||||
mapped_model: Some("grok-4"),
|
||||
upstream_is_stream: true,
|
||||
request_query: None,
|
||||
kiro_api_region: None,
|
||||
api_operation: None,
|
||||
},
|
||||
)
|
||||
.expect("xai oauth responses URL");
|
||||
|
||||
assert_eq!(url, "https://cli-chat-proxy.grok.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_compact_and_using_api_use_official_api() {
|
||||
let mut oauth = sample_transport(
|
||||
"xai",
|
||||
"openai:responses:compact",
|
||||
"https://cli-chat-proxy.grok.com/v1",
|
||||
None,
|
||||
);
|
||||
oauth.key.auth_type = "oauth".to_string();
|
||||
oauth.key.decrypted_auth_config =
|
||||
Some(r#"{"refresh_token":"rt","using_api":false}"#.to_string());
|
||||
|
||||
let compact = build_transport_request_url(
|
||||
&oauth,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "openai:responses:compact",
|
||||
mapped_model: Some("grok-4"),
|
||||
upstream_is_stream: false,
|
||||
request_query: None,
|
||||
kiro_api_region: None,
|
||||
api_operation: None,
|
||||
},
|
||||
)
|
||||
.expect("xai compact URL");
|
||||
assert_eq!(compact, "https://api.x.ai/v1/responses/compact");
|
||||
|
||||
let mut api_key = sample_transport(
|
||||
"xai",
|
||||
"openai:responses",
|
||||
"https://cli-chat-proxy.grok.com/v1",
|
||||
None,
|
||||
);
|
||||
api_key.key.auth_type = "oauth".to_string();
|
||||
api_key.key.decrypted_auth_config = Some(r#"{"using_api":true}"#.to_string());
|
||||
|
||||
let official = build_transport_request_url(
|
||||
&api_key,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "openai:responses",
|
||||
mapped_model: Some("grok-4"),
|
||||
upstream_is_stream: true,
|
||||
request_query: None,
|
||||
kiro_api_region: None,
|
||||
api_operation: None,
|
||||
},
|
||||
)
|
||||
.expect("xai api key URL");
|
||||
assert_eq!(official, "https://api.x.ai/v1/responses");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +396,12 @@ pub fn build_standard_provider_request_headers(
|
||||
force_identity_accept_encoding(&mut headers);
|
||||
}
|
||||
|
||||
crate::xai::insert_cli_identity_headers_if_needed(
|
||||
input.transport,
|
||||
input.provider_api_format,
|
||||
&mut headers,
|
||||
);
|
||||
|
||||
let declared_connection_headers =
|
||||
crate::headers::declared_connection_header_names(input.headers, input.extra_headers);
|
||||
crate::headers::remove_declared_connection_headers(&mut headers, &declared_connection_headers);
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_ai_formats::normalize_api_format_alias;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
pub const XAI_PROVIDER_TYPE: &str = "xai";
|
||||
pub const XAI_CHAT_PROXY_BASE_URL: &str = "https://cli-chat-proxy.grok.com/v1";
|
||||
pub const XAI_API_BASE_URL: &str = "https://api.x.ai/v1";
|
||||
pub const XAI_CLIENT_VERSION: &str = "0.2.120";
|
||||
pub const XAI_TOKEN_AUTH_HEADER: &str = "x-xai-token-auth";
|
||||
pub const XAI_TOKEN_AUTH_VALUE: &str = "xai-grok-cli";
|
||||
pub const XAI_CLIENT_VERSION_HEADER: &str = "x-grok-client-version";
|
||||
pub const XAI_CLIENT_IDENTIFIER_HEADER: &str = "x-grok-client-identifier";
|
||||
pub const XAI_CLIENT_IDENTIFIER_VALUE: &str = "grok-shell";
|
||||
pub const XAI_AUTHENTICATE_RESPONSE_HEADER: &str = "x-authenticateresponse";
|
||||
pub const XAI_AUTHENTICATE_RESPONSE_VALUE: &str = "authenticate-response";
|
||||
|
||||
pub fn xai_cli_user_agent() -> String {
|
||||
format!("xai-grok-workspace/{XAI_CLIENT_VERSION}")
|
||||
}
|
||||
|
||||
pub fn is_xai_provider_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(XAI_PROVIDER_TYPE)
|
||||
}
|
||||
|
||||
pub fn xai_uses_official_api(api_format: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:responses:compact"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolved_xai_upstream_base_url(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
api_format: &str,
|
||||
) -> Option<String> {
|
||||
if !is_xai_provider_transport(transport) {
|
||||
return None;
|
||||
}
|
||||
let stored = transport.endpoint.base_url.trim();
|
||||
if xai_uses_official_api(api_format) {
|
||||
if stored.is_empty()
|
||||
|| is_cli_chat_proxy_base_url(stored)
|
||||
|| is_official_api_base_url(stored)
|
||||
{
|
||||
return Some(XAI_API_BASE_URL.to_string());
|
||||
}
|
||||
return Some(trim_base_url(stored));
|
||||
}
|
||||
if xai_using_api(transport) {
|
||||
if stored.is_empty() || is_cli_chat_proxy_base_url(stored) {
|
||||
return Some(XAI_API_BASE_URL.to_string());
|
||||
}
|
||||
return Some(trim_base_url(stored));
|
||||
}
|
||||
if stored.is_empty() || is_official_api_base_url(stored) {
|
||||
return Some(XAI_CHAT_PROXY_BASE_URL.to_string());
|
||||
}
|
||||
Some(trim_base_url(stored))
|
||||
}
|
||||
|
||||
pub fn resolved_xai_request_base_url(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
api_format: &str,
|
||||
) -> String {
|
||||
resolved_xai_upstream_base_url(transport, api_format)
|
||||
.unwrap_or_else(|| trim_base_url(&transport.endpoint.base_url))
|
||||
}
|
||||
|
||||
pub fn should_attach_cli_identity_headers(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
api_format: &str,
|
||||
) -> bool {
|
||||
if !is_xai_provider_transport(transport) {
|
||||
return false;
|
||||
}
|
||||
if xai_uses_official_api(api_format) {
|
||||
return false;
|
||||
}
|
||||
resolved_xai_upstream_base_url(transport, api_format)
|
||||
.as_deref()
|
||||
.is_some_and(is_cli_chat_proxy_base_url)
|
||||
}
|
||||
|
||||
pub fn insert_cli_identity_headers(headers: &mut BTreeMap<String, String>) {
|
||||
let user_agent = xai_cli_user_agent();
|
||||
for (name, value) in [
|
||||
(XAI_TOKEN_AUTH_HEADER, XAI_TOKEN_AUTH_VALUE),
|
||||
(XAI_CLIENT_VERSION_HEADER, XAI_CLIENT_VERSION),
|
||||
("user-agent", user_agent.as_str()),
|
||||
(XAI_CLIENT_IDENTIFIER_HEADER, XAI_CLIENT_IDENTIFIER_VALUE),
|
||||
(
|
||||
XAI_AUTHENTICATE_RESPONSE_HEADER,
|
||||
XAI_AUTHENTICATE_RESPONSE_VALUE,
|
||||
),
|
||||
] {
|
||||
if !headers
|
||||
.keys()
|
||||
.any(|existing| existing.eq_ignore_ascii_case(name))
|
||||
{
|
||||
headers.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_cli_identity_headers_if_needed(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
api_format: &str,
|
||||
headers: &mut BTreeMap<String, String>,
|
||||
) {
|
||||
if should_attach_cli_identity_headers(transport, api_format) {
|
||||
insert_cli_identity_headers(headers);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xai_auth_uses_api(auth_type: &str, decrypted_auth_config: Option<&str>) -> bool {
|
||||
if let Some(value) = auth_config_using_api(decrypted_auth_config) {
|
||||
return value;
|
||||
}
|
||||
let auth_type = auth_type.trim().to_ascii_lowercase();
|
||||
if auth_type == "oauth" || auth_config_has_refresh_token(decrypted_auth_config) {
|
||||
return false;
|
||||
}
|
||||
matches!(auth_type.as_str(), "api_key" | "bearer" | "apikey")
|
||||
}
|
||||
|
||||
pub fn extract_xai_user_id_from_auth_config(raw_auth_config: Option<&str>) -> Option<String> {
|
||||
let value = parse_auth_config(raw_auth_config)?;
|
||||
extract_xai_user_id_from_value(&value)
|
||||
}
|
||||
|
||||
pub fn extract_xai_user_id_from_value(value: &Value) -> Option<String> {
|
||||
const PATHS: &[&[&str]] = &[
|
||||
&["userId"],
|
||||
&["user_id"],
|
||||
&["id"],
|
||||
&["sub"],
|
||||
&["user", "userId"],
|
||||
&["user", "id"],
|
||||
&["user", "user_id"],
|
||||
&["user", "sub"],
|
||||
];
|
||||
PATHS.iter().find_map(|path| {
|
||||
let mut current = value;
|
||||
for key in *path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
coerce_xai_id(current)
|
||||
})
|
||||
}
|
||||
|
||||
fn xai_using_api(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
xai_auth_uses_api(
|
||||
transport.key.auth_type.as_str(),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn coerce_xai_id(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
Value::Number(number) => {
|
||||
let rendered = number.to_string();
|
||||
(!rendered.is_empty()).then_some(rendered)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_config_using_api(raw_auth_config: Option<&str>) -> Option<bool> {
|
||||
let value = parse_auth_config(raw_auth_config)?;
|
||||
let using_api = value.get("using_api")?;
|
||||
match using_api {
|
||||
Value::Bool(value) => Some(*value),
|
||||
Value::String(value) => value.trim().parse::<bool>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_config_has_refresh_token(raw_auth_config: Option<&str>) -> bool {
|
||||
let value = match parse_auth_config(raw_auth_config) {
|
||||
Some(value) => value,
|
||||
None => return false,
|
||||
};
|
||||
["refresh_token", "refreshToken"]
|
||||
.iter()
|
||||
.find_map(|field| value.get(*field).and_then(Value::as_str))
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn parse_auth_config(raw_auth_config: Option<&str>) -> Option<Value> {
|
||||
raw_auth_config
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
}
|
||||
|
||||
fn trim_base_url(url: &str) -> String {
|
||||
url.trim().trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn normalize_base_url(url: &str) -> String {
|
||||
trim_base_url(url).to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn is_official_api_base_url(url: &str) -> bool {
|
||||
normalize_base_url(url) == normalize_base_url(XAI_API_BASE_URL)
|
||||
}
|
||||
|
||||
fn is_cli_chat_proxy_base_url(url: &str) -> bool {
|
||||
normalize_base_url(url) == normalize_base_url(XAI_CHAT_PROXY_BASE_URL)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
insert_cli_identity_headers_if_needed, is_xai_provider_transport,
|
||||
resolved_xai_upstream_base_url, should_attach_cli_identity_headers, XAI_API_BASE_URL,
|
||||
XAI_CHAT_PROXY_BASE_URL, XAI_CLIENT_IDENTIFIER_VALUE, XAI_TOKEN_AUTH_VALUE,
|
||||
};
|
||||
use crate::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn sample_transport(
|
||||
auth_type: &str,
|
||||
auth_config: Option<&str>,
|
||||
base_url: &str,
|
||||
) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-xai".to_string(),
|
||||
name: "xAI".to_string(),
|
||||
provider_type: "xai".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-xai".to_string(),
|
||||
provider_id: "provider-xai".to_string(),
|
||||
api_format: "openai:responses".to_string(),
|
||||
api_family: None,
|
||||
endpoint_kind: None,
|
||||
is_active: true,
|
||||
base_url: base_url.to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-xai".to_string(),
|
||||
provider_id: "provider-xai".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: auth_type.to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
auth_type_by_format: None,
|
||||
allow_auth_channel_mismatch_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
upstream_metadata: None,
|
||||
decrypted_api_key: "access-token".to_string(),
|
||||
decrypted_auth_config: auth_config.map(ToOwned::to_owned),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_defaults_to_cli_chat_proxy_for_responses() {
|
||||
let transport = sample_transport(
|
||||
"oauth",
|
||||
Some(r#"{"refresh_token":"rt","using_api":false}"#),
|
||||
XAI_CHAT_PROXY_BASE_URL,
|
||||
);
|
||||
assert!(is_xai_provider_transport(&transport));
|
||||
assert_eq!(
|
||||
resolved_xai_upstream_base_url(&transport, "openai:responses").as_deref(),
|
||||
Some(XAI_CHAT_PROXY_BASE_URL)
|
||||
);
|
||||
assert!(should_attach_cli_identity_headers(
|
||||
&transport,
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_and_using_api_stay_on_official_api() {
|
||||
let oauth = sample_transport(
|
||||
"oauth",
|
||||
Some(r#"{"refresh_token":"rt","using_api":false}"#),
|
||||
XAI_CHAT_PROXY_BASE_URL,
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_xai_upstream_base_url(&oauth, "openai:responses:compact").as_deref(),
|
||||
Some(XAI_API_BASE_URL)
|
||||
);
|
||||
assert!(!should_attach_cli_identity_headers(
|
||||
&oauth,
|
||||
"openai:responses:compact"
|
||||
));
|
||||
|
||||
let api_key = sample_transport(
|
||||
"oauth",
|
||||
Some(r#"{"using_api":true}"#),
|
||||
XAI_CHAT_PROXY_BASE_URL,
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_xai_upstream_base_url(&api_key, "openai:responses").as_deref(),
|
||||
Some(XAI_API_BASE_URL)
|
||||
);
|
||||
assert!(!should_attach_cli_identity_headers(
|
||||
&api_key,
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_without_refresh_uses_official_api() {
|
||||
let transport = sample_transport("bearer", None, XAI_CHAT_PROXY_BASE_URL);
|
||||
assert_eq!(
|
||||
resolved_xai_upstream_base_url(&transport, "openai:responses").as_deref(),
|
||||
Some(XAI_API_BASE_URL)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_headers_do_not_override_existing_values() {
|
||||
let transport = sample_transport(
|
||||
"oauth",
|
||||
Some(r#"{"refresh_token":"rt"}"#),
|
||||
XAI_CHAT_PROXY_BASE_URL,
|
||||
);
|
||||
let mut headers = BTreeMap::from([(
|
||||
"x-grok-client-identifier".to_string(),
|
||||
"custom-client".to_string(),
|
||||
)]);
|
||||
insert_cli_identity_headers_if_needed(&transport, "openai:responses", &mut headers);
|
||||
assert_eq!(
|
||||
headers.get("x-grok-client-identifier").map(String::as_str),
|
||||
Some("custom-client")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-xai-token-auth").map(String::as_str),
|
||||
Some(XAI_TOKEN_AUTH_VALUE)
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-authenticateresponse").map(String::as_str),
|
||||
Some("authenticate-response")
|
||||
);
|
||||
assert_ne!(
|
||||
headers.get("x-grok-client-identifier").map(String::as_str),
|
||||
Some(XAI_CLIENT_IDENTIFIER_VALUE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_user_id_from_user_payload_and_auth_config_sub() {
|
||||
use super::{
|
||||
extract_xai_user_id_from_auth_config, extract_xai_user_id_from_value, xai_auth_uses_api,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
assert_eq!(
|
||||
extract_xai_user_id_from_value(&json!({"userId": "user-42"})).as_deref(),
|
||||
Some("user-42")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_xai_user_id_from_auth_config(Some(r#"{"sub":"subject-1"}"#)).as_deref(),
|
||||
Some("subject-1")
|
||||
);
|
||||
assert!(!xai_auth_uses_api(
|
||||
"oauth",
|
||||
Some(r#"{"refresh_token":"rt","using_api":false}"#)
|
||||
));
|
||||
assert!(xai_auth_uses_api(
|
||||
"bearer",
|
||||
Some(r#"{"api_key":"xai-key","using_api":true}"#)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user