Files
Aether/apps/aether-gateway/src/provider_key_auth.rs
T
stabeyandClaude Opus 5 e83399db2f 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]>
2026-09-14 21:09:03 +08:00

662 lines
21 KiB
Rust

use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_provider_transport::provider_types::{
fixed_provider_key_inherits_api_formats, provider_type_is_fixed,
};
use serde_json::{Map, Value};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProviderKeyCredentialKind {
RawSecret,
OAuthSession,
ServiceAccount,
}
impl ProviderKeyCredentialKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::RawSecret => "raw_secret",
Self::OAuthSession => "oauth_session",
Self::ServiceAccount => "service_account",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProviderKeyRuntimeAuthKind {
ApiKey,
Bearer,
ServiceAccount,
Mixed,
Unknown,
}
impl ProviderKeyRuntimeAuthKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::ApiKey => "api_key",
Self::Bearer => "bearer",
Self::ServiceAccount => "service_account",
Self::Mixed => "mixed",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ProviderKeyAuthSemantics {
credential_kind: ProviderKeyCredentialKind,
runtime_auth_kind: ProviderKeyRuntimeAuthKind,
oauth_managed: bool,
can_refresh_oauth: bool,
}
impl ProviderKeyAuthSemantics {
pub(crate) const fn credential_kind(self) -> ProviderKeyCredentialKind {
self.credential_kind
}
pub(crate) const fn runtime_auth_kind(self) -> ProviderKeyRuntimeAuthKind {
self.runtime_auth_kind
}
pub(crate) const fn oauth_managed(self) -> bool {
self.oauth_managed
}
pub(crate) const fn can_refresh_oauth(self) -> bool {
self.can_refresh_oauth
}
pub(crate) const fn can_export_oauth(self) -> bool {
self.oauth_managed
}
pub(crate) const fn can_edit_oauth(self) -> bool {
self.oauth_managed
}
pub(crate) const fn can_show_oauth_metadata(self) -> bool {
self.oauth_managed
}
}
pub(crate) fn provider_key_can_refresh_oauth(
auth_semantics: ProviderKeyAuthSemantics,
provider_type: &str,
auth_config: Option<&Map<String, Value>>,
) -> bool {
auth_semantics.can_refresh_oauth()
&& (provider_key_auth_config_is_agent_identity(provider_type, auth_config)
|| auth_config.is_some_and(|config| {
["refresh_token", "refreshToken"].iter().any(|field| {
config
.get(*field)
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
})
}))
}
pub(crate) fn provider_key_can_export_oauth(
auth_semantics: ProviderKeyAuthSemantics,
provider_type: &str,
auth_config: Option<&Map<String, Value>>,
) -> bool {
auth_semantics.can_export_oauth()
&& !provider_key_auth_config_is_agent_identity(provider_type, auth_config)
}
pub(crate) fn provider_key_auth_config_uses_header_authorization(
auth_config: Option<&Map<String, Value>>,
) -> bool {
auth_config
.and_then(|config| config.get("headers"))
.and_then(Value::as_object)
.is_some_and(|headers| {
headers.iter().any(|(key, value)| {
key.trim().eq_ignore_ascii_case("authorization")
&& value
.as_str()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
})
})
}
/// Returns whether a Codex key uses the registered Agent Identity credential shape.
///
/// The key itself remains `auth_type = oauth` so it can use the existing OAuth
/// pipeline. Keep this as an explicit server-side classification rather than
/// making the frontend infer it from refresh capabilities.
pub(crate) fn provider_key_auth_config_is_agent_identity(
provider_type: &str,
auth_config: Option<&Map<String, Value>>,
) -> bool {
provider_type.trim().eq_ignore_ascii_case("codex")
&& auth_config.is_some_and(|config| {
aether_provider_transport::is_codex_agent_identity_auth_config_value(&Value::Object(
config.clone(),
))
})
}
fn normalized_auth_type(key: &StoredProviderCatalogKey) -> String {
key.auth_type.trim().to_ascii_lowercase()
}
fn key_has_auth_config(key: &StoredProviderCatalogKey) -> bool {
key.encrypted_auth_config
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
fn key_has_auth_type_overrides(key: &StoredProviderCatalogKey) -> bool {
key.auth_type_by_format
.as_ref()
.and_then(serde_json::Value::as_object)
.is_some_and(|items| !items.is_empty())
}
fn provider_uses_bearer_oauth_runtime(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"claude_code"
| "codex"
| "chatgpt_web"
| "gemini_cli"
| "antigravity"
| "kiro"
| "windsurf"
| "xai"
)
}
fn provider_uses_grok_session_runtime(provider_type: &str) -> bool {
provider_type.trim().eq_ignore_ascii_case("grok")
}
fn provider_key_is_legacy_kiro_oauth_session(
key: &StoredProviderCatalogKey,
provider_type: &str,
auth_type: &str,
) -> bool {
provider_type.trim().eq_ignore_ascii_case("kiro")
&& auth_type.eq_ignore_ascii_case("bearer")
&& key_has_auth_config(key)
}
pub(crate) fn provider_key_auth_semantics(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> ProviderKeyAuthSemantics {
let auth_type = normalized_auth_type(key);
let oauth_managed = auth_type == "oauth"
|| provider_key_is_legacy_kiro_oauth_session(key, provider_type, &auth_type)
|| (provider_uses_grok_session_runtime(provider_type) && key_has_auth_config(key));
let credential_kind = if oauth_managed {
ProviderKeyCredentialKind::OAuthSession
} else if matches!(auth_type.as_str(), "service_account" | "vertex_ai") {
ProviderKeyCredentialKind::ServiceAccount
} else {
ProviderKeyCredentialKind::RawSecret
};
let runtime_auth_kind = match credential_kind {
ProviderKeyCredentialKind::OAuthSession => {
if provider_uses_bearer_oauth_runtime(provider_type) {
ProviderKeyRuntimeAuthKind::Bearer
} else if provider_uses_grok_session_runtime(provider_type) {
ProviderKeyRuntimeAuthKind::Unknown
} else {
ProviderKeyRuntimeAuthKind::Unknown
}
}
ProviderKeyCredentialKind::ServiceAccount => ProviderKeyRuntimeAuthKind::ServiceAccount,
ProviderKeyCredentialKind::RawSecret => {
if key_has_auth_type_overrides(key) {
ProviderKeyRuntimeAuthKind::Mixed
} else {
match auth_type.as_str() {
"bearer" => ProviderKeyRuntimeAuthKind::Bearer,
"api_key" => ProviderKeyRuntimeAuthKind::ApiKey,
_ => ProviderKeyRuntimeAuthKind::Unknown,
}
}
}
};
let provider_type_normalized = provider_type.trim().to_ascii_lowercase();
ProviderKeyAuthSemantics {
credential_kind,
runtime_auth_kind,
oauth_managed,
can_refresh_oauth: oauth_managed && provider_type_normalized != "windsurf",
}
}
pub(crate) fn provider_key_is_oauth_managed(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> bool {
provider_key_auth_semantics(key, provider_type).oauth_managed()
}
pub(crate) fn provider_key_configured_api_formats(key: &StoredProviderCatalogKey) -> Vec<String> {
let mut seen = BTreeSet::new();
key.api_formats
.as_ref()
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(crate::ai_serving::normalize_api_format_alias)
.filter(|value| seen.insert(value.clone()))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(crate) fn provider_active_api_formats(
endpoints: &[StoredProviderCatalogEndpoint],
) -> Vec<String> {
let mut formats = Vec::new();
let mut seen = BTreeSet::new();
for endpoint in endpoints.iter().filter(|endpoint| endpoint.is_active) {
let api_format = crate::ai_serving::normalize_api_format_alias(&endpoint.api_format);
if api_format.is_empty() || !seen.insert(api_format.clone()) {
continue;
}
formats.push(api_format);
}
formats
}
pub(crate) fn provider_key_inherits_provider_api_formats(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> bool {
fixed_provider_key_inherits_api_formats(
provider_type,
&key.auth_type,
key.encrypted_auth_config.as_deref(),
)
}
pub(crate) fn provider_key_effective_api_formats(
key: &StoredProviderCatalogKey,
provider_type: &str,
endpoints: &[StoredProviderCatalogEndpoint],
) -> Vec<String> {
if provider_key_inherits_provider_api_formats(key, provider_type) {
provider_active_api_formats(endpoints)
} else {
provider_key_configured_api_formats(key)
}
}
#[cfg(test)]
mod tests {
use super::{
provider_active_api_formats, provider_key_auth_config_is_agent_identity,
provider_key_auth_config_uses_header_authorization, provider_key_auth_semantics,
provider_key_can_export_oauth, provider_key_can_refresh_oauth,
provider_key_configured_api_formats, provider_key_effective_api_formats,
provider_key_inherits_provider_api_formats, ProviderKeyCredentialKind,
ProviderKeyRuntimeAuthKind,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use serde_json::json;
fn sample_key(auth_type: &str) -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
"key-1".to_string(),
auth_type.to_string(),
None,
true,
)
.expect("key should build")
}
fn sample_endpoint(api_format: &str, is_active: bool) -> StoredProviderCatalogEndpoint {
let mut endpoint = StoredProviderCatalogEndpoint::new(
format!("endpoint-{api_format}"),
"provider-1".to_string(),
api_format.to_string(),
None,
None,
true,
)
.expect("endpoint should build");
endpoint.is_active = is_active;
endpoint.base_url = "https://example.invalid".to_string();
endpoint
}
#[test]
fn recognizes_oauth_managed_key() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "codex");
assert!(semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn recognizes_chatgpt_web_oauth_as_bearer_runtime() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "chatgpt_web");
assert!(semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn recognizes_grok_oauth_session_as_managed_without_bearer_runtime() {
let mut key = sample_key("oauth");
key.encrypted_auth_config = Some(r#"{"sso_token":"abc"}"#.to_string());
let semantics = provider_key_auth_semantics(&key, "grok");
assert!(semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Unknown
);
}
#[test]
fn recognizes_windsurf_oauth_as_bearer_runtime() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "windsurf");
assert!(semantics.oauth_managed());
assert!(!semantics.can_refresh_oauth());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn recognizes_xai_oauth_as_bearer_runtime() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "xai");
assert!(semantics.oauth_managed());
assert!(semantics.can_refresh_oauth());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn refresh_capability_requires_stored_refresh_token() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "codex");
assert!(!provider_key_can_refresh_oauth(
semantics,
"codex",
json!({
"access_token": "access-token",
"access_token_import_temporary": true
})
.as_object()
));
assert!(!provider_key_can_refresh_oauth(
semantics,
"codex",
json!({ "refresh_token": " " }).as_object()
));
assert!(provider_key_can_refresh_oauth(
semantics,
"codex",
json!({ "refresh_token": "refresh-token" }).as_object()
));
assert!(provider_key_can_refresh_oauth(
provider_key_auth_semantics(&sample_key("oauth"), "antigravity"),
"antigravity",
json!({ "refreshToken": "legacy-refresh-token" }).as_object()
));
assert!(provider_key_can_refresh_oauth(
semantics,
"codex",
json!({
"auth_mode": "agentIdentity",
"agent_runtime_id": "runtime-1",
"agent_private_key": "private-key-present"
})
.as_object()
));
}
#[test]
fn detects_oauth_header_authorization_in_auth_config() {
assert!(provider_key_auth_config_uses_header_authorization(
json!({
"headers": {
"Authorization": "Bearer imported-session"
}
})
.as_object()
));
assert!(!provider_key_auth_config_uses_header_authorization(
json!({
"headers": {
"authorization": " "
}
})
.as_object()
));
}
#[test]
fn detects_codex_agent_identity_auth_config() {
let config = json!({
"auth_mode": "agentIdentity",
"agent_runtime_id": "runtime-1",
"agent_private_key": "base64-private-key",
"task_id": "task-1"
});
assert!(provider_key_auth_config_is_agent_identity(
"codex",
config.as_object()
));
assert!(!provider_key_auth_config_is_agent_identity(
"openai",
config.as_object()
));
assert!(!provider_key_auth_config_is_agent_identity(
"codex",
json!({ "refresh_token": "refresh-token" }).as_object()
));
}
#[test]
fn agent_identity_is_not_exportable_through_generic_oauth_export() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "codex");
let agent_identity = json!({
"auth_mode": "agentIdentity",
"agent_runtime_id": "runtime-1",
"agent_private_key": "base64-private-key",
"task_id": "task-1"
});
assert!(!provider_key_can_export_oauth(
semantics,
"codex",
agent_identity.as_object()
));
assert!(provider_key_can_export_oauth(
semantics,
"codex",
json!({ "refresh_token": "refresh-token" }).as_object()
));
}
#[test]
fn recognizes_legacy_kiro_bearer_key_with_auth_config_as_oauth_managed() {
let mut key = sample_key("bearer");
key.encrypted_auth_config = Some("ciphertext".to_string());
let semantics = provider_key_auth_semantics(&key, "kiro");
assert!(semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn keeps_plain_bearer_key_as_raw_secret() {
let semantics = provider_key_auth_semantics(&sample_key("bearer"), "kiro");
assert!(!semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::RawSecret
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn recognizes_service_account_key() {
let semantics = provider_key_auth_semantics(&sample_key("service_account"), "vertex_ai");
assert!(!semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::ServiceAccount
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::ServiceAccount
);
}
#[test]
fn deduplicates_active_provider_api_formats() {
let endpoints = vec![
sample_endpoint("openai:responses", true),
sample_endpoint("openai:image", true),
sample_endpoint("openai:responses", true),
sample_endpoint("openai:responses:compact", false),
];
assert_eq!(
provider_active_api_formats(&endpoints),
vec!["openai:responses".to_string(), "openai:image".to_string()]
);
}
#[test]
fn fixed_oauth_key_with_null_formats_inherits_provider_formats() {
let key = sample_key("oauth");
let endpoints = vec![
sample_endpoint("openai:responses", true),
sample_endpoint("openai:image", true),
];
assert!(provider_key_inherits_provider_api_formats(&key, "codex"));
assert_eq!(
provider_key_effective_api_formats(&key, "codex", &endpoints),
vec!["openai:responses".to_string(), "openai:image".to_string()]
);
}
#[test]
fn fixed_oauth_key_with_legacy_explicit_formats_still_inherits_provider_formats() {
let mut key = sample_key("oauth");
key.api_formats = Some(json!(["openai:responses:compact"]));
let endpoints = vec![
sample_endpoint("openai:responses", true),
sample_endpoint("openai:image", true),
];
assert!(provider_key_inherits_provider_api_formats(&key, "codex"));
assert_eq!(
provider_key_effective_api_formats(&key, "codex", &endpoints),
vec!["openai:responses".to_string(), "openai:image".to_string()]
);
}
#[test]
fn configured_kiro_bearer_key_inherits_provider_formats() {
let mut key = sample_key("bearer");
key.encrypted_auth_config = Some("encrypted-auth-config".to_string());
key.api_formats = Some(json!(["openai:responses:compact"]));
let endpoints = vec![
sample_endpoint("claude:messages", true),
sample_endpoint("openai:chat", true),
];
assert!(provider_key_inherits_provider_api_formats(&key, "kiro"));
assert_eq!(
provider_key_effective_api_formats(&key, "kiro", &endpoints),
vec!["claude:messages".to_string(), "openai:chat".to_string()]
);
}
#[test]
fn explicit_formats_do_not_inherit_for_non_fixed_key() {
let mut key = sample_key("oauth");
key.api_formats = Some(json!(["openai:responses:compact"]));
let endpoints = vec![
sample_endpoint("openai:responses", true),
sample_endpoint("openai:image", true),
];
assert!(!provider_key_inherits_provider_api_formats(&key, "openai"));
assert_eq!(
provider_key_configured_api_formats(&key),
vec!["openai:responses:compact".to_string()]
);
assert_eq!(
provider_key_effective_api_formats(&key, "openai", &endpoints),
vec!["openai:responses:compact".to_string()]
);
}
}