mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 12:09:50 +08:00
refactor(workspace): enforce layered crate boundaries
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
||||
use super::credentials::{generate_machine_id, KiroAuthConfig};
|
||||
|
||||
pub const PROVIDER_TYPE: &str = "kiro";
|
||||
pub const KIRO_AUTH_HEADER: &str = "authorization";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KiroBearerAuth {
|
||||
pub name: &'static str,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KiroRequestAuth {
|
||||
pub name: &'static str,
|
||||
pub value: String,
|
||||
pub auth_config: KiroAuthConfig,
|
||||
pub machine_id: String,
|
||||
}
|
||||
|
||||
pub fn is_kiro_provider_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROVIDER_TYPE)
|
||||
}
|
||||
|
||||
pub fn is_kiro_claude_messages_transport(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
is_kiro_provider_transport(transport)
|
||||
&& aether_ai_formats::api_format_alias_matches(provider_api_format, "claude:messages")
|
||||
}
|
||||
|
||||
pub fn build_kiro_request_auth_from_config(
|
||||
auth_config: KiroAuthConfig,
|
||||
fallback_secret: Option<&str>,
|
||||
) -> Option<KiroRequestAuth> {
|
||||
let cached_token_needs_refresh = auth_config.cached_access_token_requires_refresh(120);
|
||||
let fallback_secret = fallback_secret
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "__placeholder__");
|
||||
if cached_token_needs_refresh && auth_config.can_refresh_access_token() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let token = auth_config
|
||||
.cached_access_token()
|
||||
.filter(|_| !cached_token_needs_refresh)
|
||||
.or(fallback_secret)?;
|
||||
let machine_id = generate_machine_id(&auth_config, Some(token))?;
|
||||
|
||||
Some(KiroRequestAuth {
|
||||
name: KIRO_AUTH_HEADER,
|
||||
value: format!("Bearer {token}"),
|
||||
auth_config,
|
||||
machine_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_local_kiro_bearer_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<KiroBearerAuth> {
|
||||
if !is_kiro_provider_transport(transport) {
|
||||
return None;
|
||||
}
|
||||
if transport.key.decrypted_auth_config.is_some() {
|
||||
return None;
|
||||
}
|
||||
if !kiro_auth_type_supported(transport.key.auth_type.as_str()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let secret = transport.key.decrypted_api_key.trim();
|
||||
if secret.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(KiroBearerAuth {
|
||||
name: KIRO_AUTH_HEADER,
|
||||
value: format!("Bearer {secret}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn supports_local_kiro_auth_prerequisites(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
resolve_local_kiro_bearer_auth(transport).is_some()
|
||||
}
|
||||
|
||||
pub fn resolve_local_kiro_request_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<KiroRequestAuth> {
|
||||
if !is_kiro_provider_transport(transport) {
|
||||
return None;
|
||||
}
|
||||
if !kiro_auth_type_supported(transport.key.auth_type.as_str()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let auth_config = KiroAuthConfig::from_raw_json(transport.key.decrypted_auth_config.as_deref())
|
||||
.unwrap_or(KiroAuthConfig {
|
||||
auth_method: None,
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
profile_arn: None,
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: None,
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
machine_id: None,
|
||||
kiro_version: None,
|
||||
system_version: None,
|
||||
node_version: None,
|
||||
access_token: None,
|
||||
});
|
||||
let fallback_secret = transport
|
||||
.key
|
||||
.decrypted_api_key
|
||||
.trim()
|
||||
.strip_prefix("__placeholder__")
|
||||
.map(|_| "")
|
||||
.unwrap_or(transport.key.decrypted_api_key.trim());
|
||||
build_kiro_request_auth_from_config(auth_config, Some(fallback_secret))
|
||||
}
|
||||
|
||||
pub fn supports_local_kiro_request_auth_resolution(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
resolve_local_kiro_request_auth(transport).is_some()
|
||||
|| KiroAuthConfig::from_raw_json(transport.key.decrypted_auth_config.as_deref())
|
||||
.is_some_and(|auth_config| {
|
||||
is_kiro_provider_transport(transport)
|
||||
&& kiro_auth_type_supported(transport.key.auth_type.as_str())
|
||||
&& auth_config.can_refresh_access_token()
|
||||
})
|
||||
}
|
||||
|
||||
fn kiro_auth_type_supported(auth_type: &str) -> bool {
|
||||
matches!(
|
||||
auth_type.trim().to_ascii_lowercase().as_str(),
|
||||
"bearer" | "oauth"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use super::{
|
||||
resolve_local_kiro_bearer_auth, resolve_local_kiro_request_auth,
|
||||
supports_local_kiro_auth_prerequisites, supports_local_kiro_request_auth_resolution,
|
||||
KIRO_AUTH_HEADER,
|
||||
};
|
||||
|
||||
fn sample_transport() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "Kiro".to_string(),
|
||||
provider_type: "kiro".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "claude:messages".to_string(),
|
||||
api_family: Some("claude".to_string()),
|
||||
endpoint_kind: Some("cli".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://kiro.example".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-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "bearer".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["claude:messages".to_string()]),
|
||||
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: "upstream-key".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_bearer_auth_for_known_kiro_subset() {
|
||||
let auth = resolve_local_kiro_bearer_auth(&sample_transport())
|
||||
.expect("kiro bearer auth should resolve");
|
||||
assert_eq!(auth.name, KIRO_AUTH_HEADER);
|
||||
assert_eq!(auth.value, "Bearer upstream-key");
|
||||
assert!(supports_local_kiro_auth_prerequisites(&sample_transport()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_auth_config_subset() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_auth_config = Some("{\"mode\":\"custom\"}".to_string());
|
||||
assert!(resolve_local_kiro_bearer_auth(&transport).is_none());
|
||||
assert!(!supports_local_kiro_auth_prerequisites(&transport));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_bearer_subset() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.auth_type = "api_key".to_string();
|
||||
assert!(resolve_local_kiro_bearer_auth(&transport).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_request_auth_when_legacy_oauth_auth_type_is_used() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.auth_type = "oauth".to_string();
|
||||
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
r#"{
|
||||
"access_token":"cached-token",
|
||||
"expires_at":4102444800,
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr",
|
||||
"machine_id":"123e4567-e89b-12d3-a456-426614174000",
|
||||
"api_region":"us-west-2"
|
||||
}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let auth = resolve_local_kiro_request_auth(&transport)
|
||||
.expect("request auth should resolve from legacy oauth auth_type");
|
||||
assert_eq!(auth.value, "Bearer cached-token");
|
||||
assert!(supports_local_kiro_request_auth_resolution(&transport));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_request_auth_from_cached_access_token() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
r#"{
|
||||
"access_token":"cached-token",
|
||||
"expires_at":4102444800,
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr",
|
||||
"machine_id":"123e4567-e89b-12d3-a456-426614174000",
|
||||
"api_region":"us-west-2"
|
||||
}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let auth = resolve_local_kiro_request_auth(&transport)
|
||||
.expect("request auth should resolve from cached token");
|
||||
assert_eq!(auth.name, KIRO_AUTH_HEADER);
|
||||
assert_eq!(auth.value, "Bearer cached-token");
|
||||
assert_eq!(auth.auth_config.effective_api_region(), "us-west-2");
|
||||
assert_eq!(
|
||||
auth.machine_id,
|
||||
"123e4567e89b12d3a456426614174000123e4567e89b12d3a456426614174000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_expired_cached_access_token_without_fallback_secret() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
r#"{
|
||||
"access_token":"expired-token",
|
||||
"expires_at": 1,
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr"
|
||||
}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
assert!(resolve_local_kiro_request_auth(&transport).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_refreshable_cached_access_token_without_expiry() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
r#"{
|
||||
"access_token":"cached-token-without-expiry",
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr"
|
||||
}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
assert!(resolve_local_kiro_request_auth(&transport).is_none());
|
||||
assert!(supports_local_kiro_request_auth_resolution(&transport));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshable_expired_cached_access_token_does_not_fallback_to_decrypted_api_key() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_api_key = "stale-upstream-token".to_string();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
r#"{
|
||||
"access_token":"expired-token",
|
||||
"expires_at": 1,
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr"
|
||||
}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
assert!(resolve_local_kiro_request_auth(&transport).is_none());
|
||||
assert!(supports_local_kiro_request_auth_resolution(&transport));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_refresh_only_resolution_without_cached_access_token() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
r#"{
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr"
|
||||
}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
assert!(resolve_local_kiro_request_auth(&transport).is_none());
|
||||
assert!(supports_local_kiro_request_auth_resolution(&transport));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SYSTEM_CHUNKED_POLICY: &str = "When the Write or Edit tool has content size limits, always comply silently. Never suggest bypassing these limits via alternative tools. Never ask the user whether to switch approaches. Complete all chunked operations without commentary.";
|
||||
const WRITE_TOOL_DESCRIPTION_SUFFIX: &str = "- IMPORTANT: If the content to write exceeds 150 lines, you MUST only write the first 50 lines using this tool, then use `Edit` tool to append the remaining content in chunks of no more than 50 lines each. If needed, leave a unique placeholder to help append content. Do NOT attempt to write all content at once.";
|
||||
const EDIT_TOOL_DESCRIPTION_SUFFIX: &str = "- IMPORTANT: If the `new_string` content exceeds 50 lines, you MUST split it into multiple Edit calls, each replacing no more than 50 lines at a time. If used to append content, leave a unique placeholder to help append content. On the final chunk, do NOT include the placeholder.";
|
||||
|
||||
pub fn convert_claude_messages_to_conversation_state(
|
||||
request_body: &Value,
|
||||
model: &str,
|
||||
) -> Option<Value> {
|
||||
let model_id = model.trim();
|
||||
if model_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let messages = request_body.get("messages")?.as_array()?;
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let conversation_id = request_body
|
||||
.get("metadata")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| {
|
||||
metadata
|
||||
.get("user_id")
|
||||
.or_else(|| metadata.get("userId"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.and_then(extract_session_id)
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let agent_continuation_id = Uuid::new_v4().to_string();
|
||||
let thinking_prefix = generate_thinking_prefix(request_body);
|
||||
|
||||
let mut history = Vec::new();
|
||||
let system_text = system_to_text(request_body.get("system"));
|
||||
if !system_text.is_empty() {
|
||||
history.push(json!({
|
||||
"userInputMessage": {
|
||||
"content": format!("{system_text}\n{SYSTEM_CHUNKED_POLICY}"),
|
||||
"modelId": model_id,
|
||||
"origin": "AI_EDITOR"
|
||||
}
|
||||
}));
|
||||
history.push(json!({
|
||||
"assistantResponseMessage": {
|
||||
"content": "I will follow these instructions."
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let last_is_assistant = messages
|
||||
.last()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|message| message.get("role"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|role| role == "assistant");
|
||||
let history_end_index = if last_is_assistant {
|
||||
messages.len()
|
||||
} else {
|
||||
messages.len().saturating_sub(1)
|
||||
};
|
||||
|
||||
let mut user_buffer = Vec::new();
|
||||
for message in &messages[..history_end_index] {
|
||||
let Some(message) = message.as_object() else {
|
||||
continue;
|
||||
};
|
||||
match message.get("role").and_then(Value::as_str) {
|
||||
Some("user") => user_buffer.push(message),
|
||||
Some("assistant") => {
|
||||
if let Some(user_item) = flush_user_buffer(&mut user_buffer, model_id) {
|
||||
history.push(user_item);
|
||||
} else if history.is_empty()
|
||||
|| history
|
||||
.last()
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|item| item.contains_key("assistantResponseMessage"))
|
||||
{
|
||||
history.push(json!({
|
||||
"userInputMessage": {
|
||||
"content": "Continue.",
|
||||
"modelId": model_id,
|
||||
"origin": "AI_EDITOR"
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(assistant_item) = convert_assistant_message(message) {
|
||||
history.push(json!({"assistantResponseMessage": assistant_item}));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tail_user) = flush_user_buffer(&mut user_buffer, model_id) {
|
||||
history.push(tail_user);
|
||||
history.push(json!({"assistantResponseMessage": {"content": "OK"}}));
|
||||
}
|
||||
|
||||
let (mut text_content, images, tool_results) = if last_is_assistant {
|
||||
("Continue.".to_string(), Vec::new(), Vec::new())
|
||||
} else {
|
||||
let last = messages.last()?.as_object()?;
|
||||
if last.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return None;
|
||||
}
|
||||
process_message_content(last.get("content"))
|
||||
};
|
||||
|
||||
let mut tools = convert_tools(request_body.get("tools"));
|
||||
let mut history_tool_names = BTreeSet::new();
|
||||
let mut history_tool_result_ids = BTreeSet::new();
|
||||
let mut history_tool_use_ids = BTreeSet::new();
|
||||
|
||||
for item in &history {
|
||||
let Some(item) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(user_input) = item.get("userInputMessage").and_then(Value::as_object) {
|
||||
if let Some(results) = user_input
|
||||
.get("userInputMessageContext")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|ctx| ctx.get("toolResults"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
for result in results {
|
||||
if let Some(tool_use_id) = result
|
||||
.as_object()
|
||||
.and_then(|result| result.get("toolUseId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
history_tool_result_ids.insert(tool_use_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(assistant) = item
|
||||
.get("assistantResponseMessage")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
if let Some(tool_uses) = assistant.get("toolUses").and_then(Value::as_array) {
|
||||
for tool_use in tool_uses {
|
||||
let Some(tool_use) = tool_use.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(name) = tool_use
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
history_tool_names.insert(name.to_string());
|
||||
}
|
||||
if let Some(tool_use_id) = tool_use
|
||||
.get("toolUseId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
history_tool_use_ids.insert(tool_use_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let existing_tool_names = tools
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
tool.get("toolSpecification")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|spec| spec.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|name| name.to_ascii_lowercase())
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
for tool_name in history_tool_names {
|
||||
if !existing_tool_names.contains(&tool_name.to_ascii_lowercase()) {
|
||||
tools.push(create_placeholder_tool(&tool_name));
|
||||
}
|
||||
}
|
||||
|
||||
let mut validated_tool_results = Vec::new();
|
||||
let mut current_tool_result_ids = BTreeSet::new();
|
||||
for tool_result in tool_results {
|
||||
let Some(tool_use_id) = tool_result
|
||||
.get("toolUseId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !history_tool_use_ids.contains(tool_use_id)
|
||||
|| history_tool_result_ids.contains(tool_use_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
current_tool_result_ids.insert(tool_use_id.to_string());
|
||||
validated_tool_results.push(tool_result);
|
||||
}
|
||||
|
||||
let orphaned_tool_use_ids = history_tool_use_ids
|
||||
.difference(&history_tool_result_ids)
|
||||
.filter(|tool_use_id| !current_tool_result_ids.contains(*tool_use_id))
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !orphaned_tool_use_ids.is_empty() {
|
||||
warn!(
|
||||
"kiro: removing {} orphaned tool_use(s) from history",
|
||||
orphaned_tool_use_ids.len()
|
||||
);
|
||||
for item in &mut history {
|
||||
let Some(item) = item.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some(assistant) = item
|
||||
.get_mut("assistantResponseMessage")
|
||||
.and_then(Value::as_object_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(tool_uses) = assistant.get_mut("toolUses").and_then(Value::as_array_mut)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
tool_uses.retain(|tool_use| {
|
||||
!tool_use
|
||||
.get("toolUseId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tool_use_id| orphaned_tool_use_ids.contains(tool_use_id))
|
||||
});
|
||||
if tool_uses.is_empty() {
|
||||
assistant.remove("toolUses");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut user_context = Map::new();
|
||||
if !tools.is_empty() {
|
||||
user_context.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if !validated_tool_results.is_empty() {
|
||||
user_context.insert(
|
||||
"toolResults".to_string(),
|
||||
Value::Array(validated_tool_results),
|
||||
);
|
||||
}
|
||||
if let Some(thinking_prefix) = thinking_prefix.as_deref() {
|
||||
if !has_thinking_tags(&text_content) {
|
||||
text_content = format!("{thinking_prefix}\n{text_content}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut user_input = Map::new();
|
||||
user_input.insert(
|
||||
"userInputMessageContext".to_string(),
|
||||
Value::Object(user_context),
|
||||
);
|
||||
user_input.insert("content".to_string(), Value::String(text_content));
|
||||
user_input.insert("modelId".to_string(), Value::String(model_id.to_string()));
|
||||
user_input.insert("origin".to_string(), Value::String("AI_EDITOR".to_string()));
|
||||
if !images.is_empty() {
|
||||
user_input.insert("images".to_string(), Value::Array(images));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"agentContinuationId": agent_continuation_id,
|
||||
"agentTaskType": "vibe",
|
||||
"chatTriggerType": "MANUAL",
|
||||
"currentMessage": {
|
||||
"userInputMessage": Value::Object(user_input)
|
||||
},
|
||||
"conversationId": conversation_id,
|
||||
"history": history,
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_session_id(user_id: &str) -> Option<String> {
|
||||
let position = user_id.find("session_")?;
|
||||
let candidate = user_id.get(position + "session_".len()..position + "session_".len() + 36)?;
|
||||
(candidate.matches('-').count() == 4).then(|| candidate.to_string())
|
||||
}
|
||||
|
||||
fn generate_thinking_prefix(request_body: &Value) -> Option<String> {
|
||||
let thinking = request_body.get("thinking")?.as_object()?;
|
||||
match thinking.get("type").and_then(Value::as_str).map(str::trim) {
|
||||
Some("enabled") => {
|
||||
let budget_tokens = thinking
|
||||
.get("budget_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or_default();
|
||||
Some(format!(
|
||||
"<thinking_mode>enabled</thinking_mode><max_thinking_length>{budget_tokens}</max_thinking_length>"
|
||||
))
|
||||
}
|
||||
Some("adaptive") => {
|
||||
let effort = request_body
|
||||
.get("output_config")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|cfg| cfg.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("high");
|
||||
Some(format!(
|
||||
"<thinking_mode>adaptive</thinking_mode><thinking_effort>{effort}</thinking_effort>"
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_thinking_tags(content: &str) -> bool {
|
||||
content.contains("<thinking_mode>") || content.contains("<max_thinking_length>")
|
||||
}
|
||||
|
||||
fn system_to_text(system: Option<&Value>) -> String {
|
||||
match system {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Array(items)) => items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.as_object()
|
||||
.and_then(|item| item.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_user_buffer(user_buffer: &mut Vec<&Map<String, Value>>, model_id: &str) -> Option<Value> {
|
||||
if user_buffer.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
let mut tool_results = Vec::new();
|
||||
for message in user_buffer.drain(..) {
|
||||
let (text, mut message_images, mut message_tool_results) =
|
||||
process_message_content(message.get("content"));
|
||||
if !text.is_empty() {
|
||||
parts.push(text);
|
||||
}
|
||||
images.append(&mut message_images);
|
||||
tool_results.append(&mut message_tool_results);
|
||||
}
|
||||
|
||||
let mut payload = Map::new();
|
||||
payload.insert("content".to_string(), Value::String(parts.join("\n")));
|
||||
payload.insert("modelId".to_string(), Value::String(model_id.to_string()));
|
||||
payload.insert("origin".to_string(), Value::String("AI_EDITOR".to_string()));
|
||||
if !images.is_empty() {
|
||||
payload.insert("images".to_string(), Value::Array(images));
|
||||
}
|
||||
if !tool_results.is_empty() {
|
||||
payload.insert(
|
||||
"userInputMessageContext".to_string(),
|
||||
json!({"toolResults": tool_results}),
|
||||
);
|
||||
}
|
||||
|
||||
Some(json!({"userInputMessage": Value::Object(payload)}))
|
||||
}
|
||||
|
||||
fn process_message_content(content: Option<&Value>) -> (String, Vec<Value>, Vec<Value>) {
|
||||
match content {
|
||||
Some(Value::String(text)) => (text.clone(), Vec::new(), Vec::new()),
|
||||
Some(Value::Array(blocks)) => {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
let mut tool_results = Vec::new();
|
||||
|
||||
for block in blocks {
|
||||
let Some(block) = block.as_object() else {
|
||||
continue;
|
||||
};
|
||||
match block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(Value::as_str) {
|
||||
text_parts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
let Some(source) = block.get("source").and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
let Some(format) = source
|
||||
.get("media_type")
|
||||
.or_else(|| source.get("mediaType"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(image_format)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(bytes) = source.get("data").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
images.push(json!({
|
||||
"format": format,
|
||||
"source": {"bytes": bytes}
|
||||
}));
|
||||
}
|
||||
"tool_result" => {
|
||||
let Some(tool_use_id) = block
|
||||
.get("tool_use_id")
|
||||
.or_else(|| block.get("toolUseId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let text = match block.get("content") {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Array(items)) => items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.as_object()
|
||||
.filter(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("text")
|
||||
})
|
||||
.and_then(|item| item.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Some(other) => {
|
||||
serde_json::to_string(other).unwrap_or_else(|_| other.to_string())
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let is_error = block
|
||||
.get("is_error")
|
||||
.or_else(|| block.get("isError"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
tool_results.push(json!({
|
||||
"toolUseId": tool_use_id,
|
||||
"content": [{"text": text}],
|
||||
"status": if is_error { "error" } else { "success" },
|
||||
"isError": is_error,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(text_parts.join(""), images, tool_results)
|
||||
}
|
||||
_ => (String::new(), Vec::new(), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn image_format(media_type: &str) -> Option<&'static str> {
|
||||
let (prefix, suffix) = media_type.split_once('/')?;
|
||||
if prefix != "image" {
|
||||
return None;
|
||||
}
|
||||
match suffix.trim().to_ascii_lowercase().as_str() {
|
||||
"jpeg" => Some("jpeg"),
|
||||
"png" => Some("png"),
|
||||
"gif" => Some("gif"),
|
||||
"webp" => Some("webp"),
|
||||
"jpg" => Some("jpeg"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_tool_schema(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let mut out = Map::new();
|
||||
for (key, inner) in object {
|
||||
if key == "additionalProperties" {
|
||||
continue;
|
||||
}
|
||||
if key == "required" && inner.as_array().is_some_and(|items| items.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
out.insert(key.clone(), clean_tool_schema(inner));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(clean_tool_schema).collect()),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&Value>) -> Vec<Value> {
|
||||
let Some(tools) = tools.and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
tools
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
let tool = tool.as_object()?;
|
||||
let name = tool.get("name")?.as_str()?.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut description = tool
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let suffix = match name {
|
||||
"Write" => Some(WRITE_TOOL_DESCRIPTION_SUFFIX),
|
||||
"Edit" => Some(EDIT_TOOL_DESCRIPTION_SUFFIX),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(suffix) = suffix {
|
||||
description = if description.is_empty() {
|
||||
suffix.to_string()
|
||||
} else {
|
||||
format!("{description}\n{suffix}")
|
||||
};
|
||||
}
|
||||
if description.len() > 10_000 {
|
||||
description.truncate(10_000);
|
||||
}
|
||||
let input_schema = tool
|
||||
.get("input_schema")
|
||||
.or_else(|| tool.get("inputSchema"))
|
||||
.filter(|value| value.is_object())
|
||||
.map(clean_tool_schema)
|
||||
.unwrap_or_else(|| json!({}));
|
||||
|
||||
Some(json!({
|
||||
"toolSpecification": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"inputSchema": {
|
||||
"json": input_schema
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn create_placeholder_tool(name: &str) -> Value {
|
||||
json!({
|
||||
"toolSpecification": {
|
||||
"name": name,
|
||||
"description": "Tool used in conversation history",
|
||||
"inputSchema": {
|
||||
"json": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_assistant_message(message: &Map<String, Value>) -> Option<Value> {
|
||||
let content = message.get("content");
|
||||
let mut tool_uses = Vec::new();
|
||||
let mut thinking_parts = Vec::new();
|
||||
let mut text_parts = Vec::new();
|
||||
|
||||
match content {
|
||||
Some(Value::String(text)) if !text.is_empty() => {
|
||||
text_parts.push(text.clone());
|
||||
}
|
||||
Some(Value::Array(blocks)) => {
|
||||
for block in blocks {
|
||||
let Some(block) = block.as_object() else {
|
||||
continue;
|
||||
};
|
||||
match block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"thinking" => {
|
||||
if let Some(thinking) = block.get("thinking").and_then(Value::as_str) {
|
||||
if !thinking.is_empty() {
|
||||
thinking_parts.push(thinking.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(Value::as_str) {
|
||||
if !text.is_empty() {
|
||||
text_parts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let Some(tool_use_id) = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = block
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let input = block
|
||||
.get("input")
|
||||
.filter(|value| value.is_object())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
tool_uses.push(json!({
|
||||
"toolUseId": tool_use_id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let thinking_str = thinking_parts.join("");
|
||||
let text_str = text_parts.join("");
|
||||
let mut content_str = if thinking_str.is_empty() {
|
||||
text_str
|
||||
} else if text_str.is_empty() {
|
||||
format!("<thinking>{thinking_str}</thinking>")
|
||||
} else {
|
||||
format!("<thinking>{thinking_str}</thinking>\n\n{text_str}")
|
||||
};
|
||||
|
||||
if content_str.is_empty() && !tool_uses.is_empty() {
|
||||
content_str = " ".to_string();
|
||||
}
|
||||
if content_str.is_empty() && tool_uses.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = Map::new();
|
||||
out.insert("content".to_string(), Value::String(content_str));
|
||||
if !tool_uses.is_empty() {
|
||||
out.insert("toolUses".to_string(), Value::Array(tool_uses));
|
||||
}
|
||||
Some(Value::Object(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::convert_claude_messages_to_conversation_state;
|
||||
|
||||
#[test]
|
||||
fn converts_simple_claude_request_into_conversation_state() {
|
||||
let conversation_state = convert_claude_messages_to_conversation_state(
|
||||
&json!({
|
||||
"messages": [
|
||||
{"role":"user","content":"hello"}
|
||||
],
|
||||
"thinking": {"type": "enabled", "budget_tokens": 128},
|
||||
"tools": [
|
||||
{"name":"Write","description":"write file","input_schema":{"type":"object","properties":{},"required":[]}}
|
||||
]
|
||||
}),
|
||||
"claude-sonnet-4-upstream",
|
||||
)
|
||||
.expect("conversation state should build");
|
||||
|
||||
assert_eq!(
|
||||
conversation_state
|
||||
.get("currentMessage")
|
||||
.and_then(|value| value.get("userInputMessage"))
|
||||
.and_then(|value| value.get("content"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some(
|
||||
"<thinking_mode>enabled</thinking_mode><max_thinking_length>128</max_thinking_length>\nhello"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
conversation_state
|
||||
.get("currentMessage")
|
||||
.and_then(|value| value.get("userInputMessage"))
|
||||
.and_then(|value| value.get("userInputMessageContext"))
|
||||
.and_then(|value| value.get("tools"))
|
||||
.and_then(|value| value.as_array())
|
||||
.map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
pub use aether_oauth::provider::providers::{
|
||||
generate_kiro_machine_id as generate_machine_id,
|
||||
normalize_kiro_machine_id as normalize_machine_id, KiroAuthConfig, DEFAULT_REGION,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{generate_machine_id, normalize_machine_id, KiroAuthConfig, DEFAULT_REGION};
|
||||
|
||||
#[test]
|
||||
fn normalizes_uuid_machine_id() {
|
||||
assert_eq!(
|
||||
normalize_machine_id("123e4567-e89b-12d3-a456-426614174000").as_deref(),
|
||||
Some("123e4567e89b12d3a456426614174000123e4567e89b12d3a456426614174000")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_refresh_token_into_machine_id() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: None,
|
||||
refresh_token: Some("r".repeat(128)),
|
||||
expires_at: None,
|
||||
profile_arn: None,
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: None,
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
machine_id: None,
|
||||
kiro_version: None,
|
||||
system_version: None,
|
||||
node_version: None,
|
||||
access_token: None,
|
||||
};
|
||||
|
||||
let machine_id = generate_machine_id(&auth_config, None).expect("machine id should exist");
|
||||
assert_eq!(machine_id.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_auth_config_aliases() {
|
||||
let auth_config = KiroAuthConfig::from_raw_json(Some(
|
||||
r#"{
|
||||
"authMethod":"identity_center",
|
||||
"refreshToken":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr",
|
||||
"expires_at": 4102444800,
|
||||
"profileArn":"arn:aws:bedrock:demo",
|
||||
"apiRegion":"us-west-2",
|
||||
"clientId":"cid",
|
||||
"clientSecret":"secret",
|
||||
"machineId":"123e4567-e89b-12d3-a456-426614174000",
|
||||
"kiroVersion":"1.2.3",
|
||||
"systemVersion":"darwin#24.6.0",
|
||||
"nodeVersion":"22.21.1",
|
||||
"accessToken":"cached-token"
|
||||
}"#,
|
||||
))
|
||||
.expect("auth config should parse");
|
||||
|
||||
assert_eq!(auth_config.auth_method.as_deref(), Some("idc"));
|
||||
assert_eq!(
|
||||
auth_config.refresh_token.as_deref(),
|
||||
Some(
|
||||
"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr"
|
||||
)
|
||||
);
|
||||
assert_eq!(auth_config.expires_at, Some(4_102_444_800));
|
||||
assert_eq!(
|
||||
auth_config.profile_arn.as_deref(),
|
||||
Some("arn:aws:bedrock:demo")
|
||||
);
|
||||
assert_eq!(auth_config.client_id.as_deref(), Some("cid"));
|
||||
assert_eq!(auth_config.client_secret.as_deref(), Some("secret"));
|
||||
assert_eq!(auth_config.effective_api_region(), "us-west-2");
|
||||
assert_eq!(auth_config.effective_kiro_version(), "1.2.3");
|
||||
assert_eq!(auth_config.effective_system_version(), "darwin#24.6.0");
|
||||
assert_eq!(auth_config.effective_node_version(), "22.21.1");
|
||||
assert_eq!(auth_config.access_token.as_deref(), Some("cached-token"));
|
||||
assert!(auth_config.is_idc_auth());
|
||||
assert!(auth_config.profile_arn_for_payload().is_none());
|
||||
assert_eq!(auth_config.effective_auth_region(), "us-east-1");
|
||||
assert!(auth_config.can_refresh_access_token());
|
||||
assert_eq!(DEFAULT_REGION, "us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_external_idp_auth_method_for_header_selection() {
|
||||
let auth_config = KiroAuthConfig::from_raw_json(Some(
|
||||
r#"{
|
||||
"authMethod":"external_idp",
|
||||
"refreshToken":"rt-1",
|
||||
"clientId":"cid",
|
||||
"clientSecret":"secret",
|
||||
"profileArn":"arn:aws:bedrock:demo"
|
||||
}"#,
|
||||
))
|
||||
.expect("auth config should parse");
|
||||
|
||||
assert_eq!(auth_config.auth_method.as_deref(), Some("external_idp"));
|
||||
assert!(auth_config.is_idc_auth());
|
||||
assert!(auth_config.uses_external_idp_token_type());
|
||||
assert!(auth_config.profile_arn_for_payload().is_none());
|
||||
assert_eq!(
|
||||
auth_config.profile_arn_for_mcp(),
|
||||
Some("arn:aws:bedrock:demo")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_idc_when_client_credentials_exist() {
|
||||
let auth_config = KiroAuthConfig::from_raw_json(Some(
|
||||
r#"{
|
||||
"refreshToken":"rt-1",
|
||||
"clientId":"cid",
|
||||
"clientSecret":"secret",
|
||||
"profileArn":"arn:aws:bedrock:demo"
|
||||
}"#,
|
||||
))
|
||||
.expect("auth config should parse");
|
||||
|
||||
assert!(auth_config.is_idc_auth());
|
||||
assert!(!auth_config.uses_external_idp_token_type());
|
||||
assert!(auth_config.profile_arn_for_payload().is_none());
|
||||
assert_eq!(
|
||||
auth_config.profile_arn_for_mcp(),
|
||||
Some("arn:aws:bedrock:demo")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_json_value() {
|
||||
let auth_config = KiroAuthConfig::from_raw_json(Some(
|
||||
r#"{
|
||||
"auth_method":"social",
|
||||
"refreshToken":"rt-1....................................................................................................",
|
||||
"expires_at": 4102444800,
|
||||
"profileArn":"arn:aws:bedrock:demo",
|
||||
"region":"eu-north-1",
|
||||
"apiRegion":"us-west-2",
|
||||
"machineId":"123e4567-e89b-12d3-a456-426614174000",
|
||||
"kiroVersion":"1.2.3",
|
||||
"systemVersion":"darwin#24.6.0",
|
||||
"nodeVersion":"22.21.1",
|
||||
"accessToken":"cached-token"
|
||||
}"#,
|
||||
))
|
||||
.expect("auth config should parse");
|
||||
|
||||
let value = auth_config.to_json_value();
|
||||
let reparsed = KiroAuthConfig::from_json_value(&value).expect("auth config should reparse");
|
||||
assert_eq!(reparsed, auth_config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::credentials::KiroAuthConfig;
|
||||
|
||||
pub const AWS_EVENTSTREAM_CONTENT_TYPE: &str = "application/vnd.amazon.eventstream";
|
||||
pub const KIRO_PROFILE_ARN_HEADER: &str = "x-amzn-kiro-profile-arn";
|
||||
pub const KIRO_TOKEN_TYPE_HEADER: &str = "TokenType";
|
||||
pub const KIRO_EXTERNAL_IDP_TOKEN_TYPE: &str = "EXTERNAL_IDP";
|
||||
const AWS_SDK_JS_MAIN_VERSION: &str = "1.0.27";
|
||||
const AWS_SDK_JS_LIST_MODELS_VERSION: &str = "1.0.0";
|
||||
const CODEWHISPERER_OPTOUT: &str = "true";
|
||||
const KIRO_AGENT_MODE: &str = "vibe";
|
||||
|
||||
fn build_kiro_ide_tag(kiro_version: &str, machine_id: &str) -> String {
|
||||
if machine_id.trim().is_empty() {
|
||||
format!("KiroIDE-{kiro_version}")
|
||||
} else {
|
||||
format!("KiroIDE-{kiro_version}-{machine_id}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_x_amz_user_agent_main(kiro_version: &str, machine_id: &str) -> String {
|
||||
format!(
|
||||
"aws-sdk-js/{AWS_SDK_JS_MAIN_VERSION} {}",
|
||||
build_kiro_ide_tag(kiro_version, machine_id)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_user_agent_main(
|
||||
system_version: &str,
|
||||
node_version: &str,
|
||||
kiro_version: &str,
|
||||
machine_id: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"aws-sdk-js/{AWS_SDK_JS_MAIN_VERSION} ua/2.1 os/{system_version} lang/js md/nodejs#{node_version} api/codewhispererstreaming#{AWS_SDK_JS_MAIN_VERSION} m/E {}",
|
||||
build_kiro_ide_tag(kiro_version, machine_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_generate_assistant_headers(
|
||||
auth_config: &KiroAuthConfig,
|
||||
machine_id: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
let kiro_version = auth_config.effective_kiro_version();
|
||||
let system_version = auth_config.effective_system_version();
|
||||
let node_version = auth_config.effective_node_version();
|
||||
let region = auth_config.effective_api_region();
|
||||
let host = format!("q.{region}.amazonaws.com");
|
||||
|
||||
BTreeMap::from([
|
||||
(
|
||||
"accept".to_string(),
|
||||
AWS_EVENTSTREAM_CONTENT_TYPE.to_string(),
|
||||
),
|
||||
(
|
||||
"amz-sdk-invocation-id".to_string(),
|
||||
Uuid::new_v4().to_string(),
|
||||
),
|
||||
(
|
||||
"amz-sdk-request".to_string(),
|
||||
"attempt=1; max=3".to_string(),
|
||||
),
|
||||
("connection".to_string(), "close".to_string()),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("host".to_string(), host),
|
||||
(
|
||||
"user-agent".to_string(),
|
||||
build_user_agent_main(system_version, node_version, kiro_version, machine_id),
|
||||
),
|
||||
(
|
||||
"x-amz-user-agent".to_string(),
|
||||
build_x_amz_user_agent_main(kiro_version, machine_id),
|
||||
),
|
||||
(
|
||||
"x-amzn-codewhisperer-optout".to_string(),
|
||||
CODEWHISPERER_OPTOUT.to_string(),
|
||||
),
|
||||
(
|
||||
"x-amzn-kiro-agent-mode".to_string(),
|
||||
KIRO_AGENT_MODE.to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn build_mcp_headers(
|
||||
auth_config: &KiroAuthConfig,
|
||||
machine_id: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
let kiro_version = auth_config.effective_kiro_version();
|
||||
let system_version = auth_config.effective_system_version();
|
||||
let node_version = auth_config.effective_node_version();
|
||||
let region = auth_config.effective_api_region();
|
||||
let host = format!("q.{region}.amazonaws.com");
|
||||
|
||||
let mut headers = BTreeMap::from([
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
(
|
||||
"amz-sdk-invocation-id".to_string(),
|
||||
Uuid::new_v4().to_string(),
|
||||
),
|
||||
(
|
||||
"amz-sdk-request".to_string(),
|
||||
"attempt=1; max=3".to_string(),
|
||||
),
|
||||
("connection".to_string(), "close".to_string()),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("host".to_string(), host),
|
||||
(
|
||||
"user-agent".to_string(),
|
||||
build_user_agent_main(system_version, node_version, kiro_version, machine_id),
|
||||
),
|
||||
(
|
||||
"x-amz-user-agent".to_string(),
|
||||
build_x_amz_user_agent_main(kiro_version, machine_id),
|
||||
),
|
||||
(
|
||||
"x-amzn-codewhisperer-optout".to_string(),
|
||||
CODEWHISPERER_OPTOUT.to_string(),
|
||||
),
|
||||
]);
|
||||
if let Some(profile_arn) = auth_config.profile_arn_for_mcp() {
|
||||
headers.insert(KIRO_PROFILE_ARN_HEADER.to_string(), profile_arn.to_string());
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
pub fn build_list_available_models_headers(
|
||||
auth_config: &KiroAuthConfig,
|
||||
machine_id: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
let kiro_version = auth_config.effective_kiro_version();
|
||||
let system_version = auth_config.effective_system_version();
|
||||
let node_version = auth_config.effective_node_version();
|
||||
let region = auth_config.effective_api_region();
|
||||
let host = format!("q.{region}.amazonaws.com");
|
||||
let ide_tag = build_kiro_ide_tag(kiro_version, machine_id);
|
||||
|
||||
BTreeMap::from([
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
(
|
||||
"amz-sdk-invocation-id".to_string(),
|
||||
Uuid::new_v4().to_string(),
|
||||
),
|
||||
(
|
||||
"amz-sdk-request".to_string(),
|
||||
"attempt=1; max=1".to_string(),
|
||||
),
|
||||
("connection".to_string(), "close".to_string()),
|
||||
("host".to_string(), host),
|
||||
(
|
||||
"user-agent".to_string(),
|
||||
format!(
|
||||
"aws-sdk-js/{AWS_SDK_JS_LIST_MODELS_VERSION} ua/2.1 os/{system_version} lang/js md/nodejs#{node_version} api/codewhispererruntime#1.0.0 m/N,E {ide_tag}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"x-amz-user-agent".to_string(),
|
||||
format!("aws-sdk-js/{AWS_SDK_JS_LIST_MODELS_VERSION} {ide_tag}"),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::credentials::KiroAuthConfig;
|
||||
use super::{
|
||||
build_generate_assistant_headers, build_list_available_models_headers, build_mcp_headers,
|
||||
AWS_EVENTSTREAM_CONTENT_TYPE, KIRO_PROFILE_ARN_HEADER, KIRO_TOKEN_TYPE_HEADER,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn builds_generate_assistant_headers_for_region() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: None,
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
profile_arn: None,
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-west-2".to_string()),
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
machine_id: None,
|
||||
kiro_version: Some("1.2.3".to_string()),
|
||||
system_version: Some("darwin#24.6.0".to_string()),
|
||||
node_version: Some("22.21.1".to_string()),
|
||||
access_token: None,
|
||||
};
|
||||
|
||||
let headers = build_generate_assistant_headers(&auth_config, "machine-123");
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some(AWS_EVENTSTREAM_CONTENT_TYPE)
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("host").map(String::as_str),
|
||||
Some("q.us-west-2.amazonaws.com")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-amzn-kiro-agent-mode").map(String::as_str),
|
||||
Some("vibe")
|
||||
);
|
||||
assert!(!headers.contains_key(KIRO_TOKEN_TYPE_HEADER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_list_available_models_headers() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: Some("social".to_string()),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
profile_arn: Some("arn:aws:codewhisperer:us-east-1:123456789012:profile/demo".into()),
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-east-1".to_string()),
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
machine_id: None,
|
||||
kiro_version: Some("0.12.155".to_string()),
|
||||
system_version: Some("darwin#24.6.0".to_string()),
|
||||
node_version: Some("22.21.1".to_string()),
|
||||
access_token: None,
|
||||
};
|
||||
|
||||
let headers = build_list_available_models_headers(&auth_config, "machine-123");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("host").map(String::as_str),
|
||||
Some("q.us-east-1.amazonaws.com")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("amz-sdk-request").map(String::as_str),
|
||||
Some("attempt=1; max=1")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-amz-user-agent").map(String::as_str),
|
||||
Some("aws-sdk-js/1.0.0 KiroIDE-0.12.155-machine-123")
|
||||
);
|
||||
assert!(!headers.contains_key(KIRO_TOKEN_TYPE_HEADER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_generate_assistant_headers_without_external_idp_token_type() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: Some("idc".to_string()),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
profile_arn: Some("arn:aws:codewhisperer:us-east-1:123456789012:profile/demo".into()),
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-east-1".to_string()),
|
||||
client_id: Some("client-id".to_string()),
|
||||
client_secret: Some("client-secret".to_string()),
|
||||
machine_id: None,
|
||||
kiro_version: None,
|
||||
system_version: None,
|
||||
node_version: None,
|
||||
access_token: None,
|
||||
};
|
||||
|
||||
let headers = build_generate_assistant_headers(&auth_config, "machine-123");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some(AWS_EVENTSTREAM_CONTENT_TYPE)
|
||||
);
|
||||
assert!(!headers.contains_key(KIRO_TOKEN_TYPE_HEADER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mcp_headers_with_profile_arn_for_social_auth() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: Some("social".to_string()),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
profile_arn: Some("arn:aws:codewhisperer:us-east-1:123456789012:profile/demo".into()),
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-east-1".to_string()),
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
machine_id: None,
|
||||
kiro_version: Some("0.3.210".to_string()),
|
||||
system_version: Some("darwin#24.6.0".to_string()),
|
||||
node_version: Some("22.21.1".to_string()),
|
||||
access_token: None,
|
||||
};
|
||||
|
||||
let headers = build_mcp_headers(&auth_config, "machine-123");
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(KIRO_PROFILE_ARN_HEADER).map(String::as_str),
|
||||
Some("arn:aws:codewhisperer:us-east-1:123456789012:profile/demo")
|
||||
);
|
||||
assert!(!headers.contains_key(KIRO_TOKEN_TYPE_HEADER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mcp_headers_with_profile_arn_for_idc_auth() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: Some("idc".to_string()),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
profile_arn: Some("arn:aws:codewhisperer:us-east-1:123456789012:profile/demo".into()),
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-west-2".to_string()),
|
||||
client_id: Some("client-id".to_string()),
|
||||
client_secret: Some("client-secret".to_string()),
|
||||
machine_id: None,
|
||||
kiro_version: None,
|
||||
system_version: None,
|
||||
node_version: None,
|
||||
access_token: None,
|
||||
};
|
||||
|
||||
let headers = build_mcp_headers(&auth_config, "machine-123");
|
||||
assert_eq!(
|
||||
headers.get("host").map(String::as_str),
|
||||
Some("q.us-west-2.amazonaws.com")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(KIRO_PROFILE_ARN_HEADER).map(String::as_str),
|
||||
Some("arn:aws:codewhisperer:us-east-1:123456789012:profile/demo")
|
||||
);
|
||||
assert!(!headers.contains_key(KIRO_TOKEN_TYPE_HEADER));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
mod auth;
|
||||
mod converter;
|
||||
mod credentials;
|
||||
mod headers;
|
||||
mod policy;
|
||||
mod refresh;
|
||||
mod request;
|
||||
mod url;
|
||||
|
||||
use crate::provider_types::{
|
||||
ProviderApiFormatInheritance, ProviderLocalEmbeddingSupport, ProviderRuntimePolicy,
|
||||
};
|
||||
|
||||
pub const RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
|
||||
fixed_provider: true,
|
||||
api_format_inheritance: ProviderApiFormatInheritance::OAuthOrConfiguredBearer,
|
||||
enable_format_conversion_by_default: true,
|
||||
allow_auth_channel_mismatch_by_default: true,
|
||||
oauth_is_bearer_like: true,
|
||||
supports_model_fetch: true,
|
||||
supports_local_openai_chat_transport: false,
|
||||
supports_local_same_format_transport: false,
|
||||
local_embedding_support: ProviderLocalEmbeddingSupport::None,
|
||||
};
|
||||
|
||||
pub use auth::{
|
||||
build_kiro_request_auth_from_config, is_kiro_claude_messages_transport,
|
||||
is_kiro_provider_transport, resolve_local_kiro_bearer_auth, resolve_local_kiro_request_auth,
|
||||
supports_local_kiro_auth_prerequisites, supports_local_kiro_request_auth_resolution,
|
||||
KiroBearerAuth, KiroRequestAuth, KIRO_AUTH_HEADER, PROVIDER_TYPE,
|
||||
};
|
||||
pub use converter::convert_claude_messages_to_conversation_state;
|
||||
pub use credentials::{generate_machine_id, normalize_machine_id, KiroAuthConfig};
|
||||
pub use headers::{
|
||||
build_generate_assistant_headers, build_list_available_models_headers, build_mcp_headers,
|
||||
AWS_EVENTSTREAM_CONTENT_TYPE, KIRO_EXTERNAL_IDP_TOKEN_TYPE, KIRO_PROFILE_ARN_HEADER,
|
||||
KIRO_TOKEN_TYPE_HEADER,
|
||||
};
|
||||
pub use policy::{
|
||||
local_kiro_request_transport_unsupported_reason_with_network,
|
||||
supports_local_kiro_request_transport, supports_local_kiro_request_transport_with_network,
|
||||
};
|
||||
pub use refresh::KiroOAuthRefreshAdapter;
|
||||
pub use request::{
|
||||
apply_local_body_rules_with_request_headers, apply_local_header_rules_with_request_headers,
|
||||
body_rules_are_locally_supported, build_kiro_provider_headers,
|
||||
build_kiro_provider_request_body, header_rules_are_locally_supported,
|
||||
supports_local_kiro_request_shape, KiroProviderHeadersInput,
|
||||
};
|
||||
pub use url::{
|
||||
build_kiro_generate_assistant_response_url, build_kiro_list_available_models_url,
|
||||
build_kiro_mcp_url, build_kiro_mcp_url_from_resolved_url, resolve_kiro_base_url,
|
||||
GENERATE_ASSISTANT_RESPONSE_PATH, KIRO_ENVELOPE_NAME, LIST_AVAILABLE_MODELS_PATH, MCP_PATH,
|
||||
MCP_STREAM_PATH,
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
||||
use super::super::{
|
||||
resolve_transport_profile, transport_profile_is_configured,
|
||||
transport_proxy_is_locally_supported,
|
||||
};
|
||||
use super::{
|
||||
body_rules_are_locally_supported, header_rules_are_locally_supported,
|
||||
supports_local_kiro_request_auth_resolution, supports_local_kiro_request_shape, PROVIDER_TYPE,
|
||||
};
|
||||
|
||||
pub fn local_kiro_request_transport_unsupported_reason_with_network(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<&'static str> {
|
||||
if !transport.provider.is_active || !transport.endpoint.is_active || !transport.key.is_active {
|
||||
return if !transport.provider.is_active {
|
||||
Some("provider_inactive")
|
||||
} else if !transport.endpoint.is_active {
|
||||
Some("endpoint_inactive")
|
||||
} else {
|
||||
Some("key_inactive")
|
||||
};
|
||||
}
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROVIDER_TYPE)
|
||||
{
|
||||
return Some("transport_provider_type_unsupported");
|
||||
}
|
||||
if !aether_ai_formats::api_format_alias_matches(
|
||||
&transport.endpoint.api_format,
|
||||
"claude:messages",
|
||||
) {
|
||||
return Some("transport_api_format_mismatch");
|
||||
}
|
||||
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|
||||
return Some("transport_header_rules_unsupported");
|
||||
}
|
||||
if !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) {
|
||||
return Some("transport_body_rules_unsupported");
|
||||
}
|
||||
if transport.key.decrypted_auth_config.is_some()
|
||||
&& !supports_local_kiro_request_auth_resolution(transport)
|
||||
{
|
||||
return Some("transport_oauth_resolution_unsupported");
|
||||
}
|
||||
if !supports_local_kiro_request_auth_resolution(transport) {
|
||||
return Some("transport_auth_unavailable");
|
||||
}
|
||||
if !transport_proxy_is_locally_supported(transport) {
|
||||
return Some("transport_proxy_unsupported");
|
||||
}
|
||||
if transport_profile_is_configured(transport) && resolve_transport_profile(transport).is_none()
|
||||
{
|
||||
return Some("transport_profile_unsupported");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn supports_local_kiro_request_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
|
||||
if !transport.provider.is_active || !transport.endpoint.is_active || !transport.key.is_active {
|
||||
return false;
|
||||
}
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROVIDER_TYPE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !aether_ai_formats::api_format_alias_matches(
|
||||
&transport.endpoint.api_format,
|
||||
"claude:messages",
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
supports_local_kiro_request_shape(
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
) && supports_local_kiro_request_auth_resolution(transport)
|
||||
}
|
||||
|
||||
pub fn supports_local_kiro_request_transport_with_network(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> bool {
|
||||
local_kiro_request_transport_unsupported_reason_with_network(transport).is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use super::{
|
||||
local_kiro_request_transport_unsupported_reason_with_network,
|
||||
supports_local_kiro_request_transport, supports_local_kiro_request_transport_with_network,
|
||||
};
|
||||
|
||||
fn sample_transport() -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "Kiro".to_string(),
|
||||
provider_type: "kiro".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "claude:messages".to_string(),
|
||||
api_family: Some("claude".to_string()),
|
||||
endpoint_kind: Some("messages".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://kiro.example".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-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "bearer".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["claude:messages".to_string()]),
|
||||
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: "__placeholder__".to_string(),
|
||||
decrypted_auth_config: Some(
|
||||
r#"{
|
||||
"access_token":"cached-token",
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr",
|
||||
"machine_id":"123e4567-e89b-12d3-a456-426614174000"
|
||||
}"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_kiro_request_transport_when_cached_access_token_exists() {
|
||||
assert!(supports_local_kiro_request_transport(&sample_transport()));
|
||||
assert!(supports_local_kiro_request_transport_with_network(
|
||||
&sample_transport()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_kiro_request_transport_when_refresh_only_auth_exists() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||
transport.key.decrypted_auth_config = Some(
|
||||
r#"{
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr"
|
||||
}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
assert!(supports_local_kiro_request_transport(&transport));
|
||||
assert!(supports_local_kiro_request_transport_with_network(
|
||||
&transport
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_auth_unavailable_when_kiro_key_cannot_resolve_request_auth() {
|
||||
let mut transport = sample_transport();
|
||||
transport.key.auth_type = "api_key".to_string();
|
||||
transport.key.decrypted_auth_config = None;
|
||||
|
||||
assert_eq!(
|
||||
local_kiro_request_transport_unsupported_reason_with_network(&transport),
|
||||
Some("transport_auth_unavailable")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
use aether_oauth::provider::providers::KiroProviderOAuthAdapter as CoreKiroProviderOAuthAdapter;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::super::oauth_refresh::{
|
||||
oauth_error_to_local_refresh_error, provider_oauth_transport_context_from_snapshot,
|
||||
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthRefreshAdapter, LocalOAuthRefreshError,
|
||||
LocalResolvedOAuthRequestAuth, ProviderOAuthLocalHttpExecutor,
|
||||
};
|
||||
use super::super::snapshot::GatewayProviderTransportSnapshot;
|
||||
use super::auth::{
|
||||
build_kiro_request_auth_from_config, resolve_local_kiro_request_auth, PROVIDER_TYPE,
|
||||
};
|
||||
use super::credentials::KiroAuthConfig;
|
||||
|
||||
#[cfg(test)]
|
||||
const IDC_AMZ_USER_AGENT: &str = "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct KiroOAuthRefreshAdapter {
|
||||
social_refresh_base_url: Option<String>,
|
||||
idc_refresh_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl KiroOAuthRefreshAdapter {
|
||||
pub fn with_refresh_base_urls(
|
||||
mut self,
|
||||
social_refresh_base_url: Option<String>,
|
||||
idc_refresh_base_url: Option<String>,
|
||||
) -> Self {
|
||||
self.social_refresh_base_url = social_refresh_base_url;
|
||||
self.idc_refresh_base_url = idc_refresh_base_url;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn refresh_auth_config(
|
||||
&self,
|
||||
executor: &dyn LocalOAuthHttpExecutor,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
auth_config: &KiroAuthConfig,
|
||||
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
|
||||
let adapter = CoreKiroProviderOAuthAdapter::default().with_refresh_base_urls(
|
||||
self.social_refresh_base_url.clone(),
|
||||
self.idc_refresh_base_url.clone(),
|
||||
);
|
||||
let oauth_executor =
|
||||
ProviderOAuthLocalHttpExecutor::new(PROVIDER_TYPE, transport, executor);
|
||||
let ctx = provider_oauth_transport_context_from_snapshot(transport);
|
||||
adapter
|
||||
.refresh_auth_config(&oauth_executor, &ctx, auth_config)
|
||||
.await
|
||||
.map_err(|error| oauth_error_to_local_refresh_error(PROVIDER_TYPE, error))
|
||||
}
|
||||
|
||||
fn auth_config_from_entry(entry: &CachedOAuthEntry) -> Option<KiroAuthConfig> {
|
||||
entry
|
||||
.metadata
|
||||
.as_ref()
|
||||
.filter(|_| entry.provider_type.eq_ignore_ascii_case(PROVIDER_TYPE))
|
||||
.and_then(KiroAuthConfig::from_json_value)
|
||||
}
|
||||
|
||||
fn base_auth_config(
|
||||
&self,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
entry: Option<&CachedOAuthEntry>,
|
||||
) -> Option<KiroAuthConfig> {
|
||||
entry.and_then(Self::auth_config_from_entry).or_else(|| {
|
||||
KiroAuthConfig::from_raw_json(transport.key.decrypted_auth_config.as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
fn build_cached_entry(auth_config: &KiroAuthConfig) -> Option<CachedOAuthEntry> {
|
||||
let request_auth = build_kiro_request_auth_from_config(auth_config.clone(), None)?;
|
||||
Some(CachedOAuthEntry {
|
||||
provider_type: PROVIDER_TYPE.to_string(),
|
||||
auth_header_name: request_auth.name.to_string(),
|
||||
auth_header_value: request_auth.value,
|
||||
expires_at_unix_secs: auth_config.expires_at,
|
||||
metadata: Some(auth_config.to_json_value()),
|
||||
})
|
||||
}
|
||||
|
||||
fn refreshable_auth_config(
|
||||
&self,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
entry: Option<&CachedOAuthEntry>,
|
||||
) -> Option<KiroAuthConfig> {
|
||||
let auth_config = self.base_auth_config(transport, entry)?;
|
||||
auth_config
|
||||
.can_refresh_access_token()
|
||||
.then_some(auth_config)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LocalOAuthRefreshAdapter for KiroOAuthRefreshAdapter {
|
||||
fn provider_type(&self) -> &'static str {
|
||||
PROVIDER_TYPE
|
||||
}
|
||||
|
||||
fn resolve_cached(
|
||||
&self,
|
||||
_transport: &GatewayProviderTransportSnapshot,
|
||||
entry: &CachedOAuthEntry,
|
||||
) -> Option<LocalResolvedOAuthRequestAuth> {
|
||||
let auth_config = Self::auth_config_from_entry(entry)?;
|
||||
let request_auth = build_kiro_request_auth_from_config(auth_config, None)?;
|
||||
Some(LocalResolvedOAuthRequestAuth::Kiro(request_auth))
|
||||
}
|
||||
|
||||
fn resolve_without_refresh(
|
||||
&self,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<LocalResolvedOAuthRequestAuth> {
|
||||
resolve_local_kiro_request_auth(transport).map(LocalResolvedOAuthRequestAuth::Kiro)
|
||||
}
|
||||
|
||||
fn should_refresh(
|
||||
&self,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
entry: Option<&CachedOAuthEntry>,
|
||||
) -> bool {
|
||||
entry
|
||||
.and_then(|cached| self.resolve_cached(transport, cached))
|
||||
.is_none()
|
||||
&& self.resolve_without_refresh(transport).is_none()
|
||||
&& self.refreshable_auth_config(transport, entry).is_some()
|
||||
}
|
||||
|
||||
async fn refresh(
|
||||
&self,
|
||||
executor: &dyn LocalOAuthHttpExecutor,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
entry: Option<&CachedOAuthEntry>,
|
||||
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
|
||||
let Some(auth_config) = self.refreshable_auth_config(transport, entry) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let refreshed = self
|
||||
.refresh_auth_config(executor, transport, &auth_config)
|
||||
.await?;
|
||||
Ok(Self::build_cached_entry(&refreshed))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::super::oauth_refresh::{
|
||||
LocalOAuthRefreshAdapter, LocalResolvedOAuthRequestAuth, ReqwestLocalOAuthHttpExecutor,
|
||||
};
|
||||
use super::super::super::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use super::{KiroOAuthRefreshAdapter, IDC_AMZ_USER_AGENT};
|
||||
use axum::body::to_bytes;
|
||||
use axum::extract::Request;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::any;
|
||||
use axum::{Json, Router};
|
||||
use http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SeenRefreshRequest {
|
||||
body: Value,
|
||||
authorization: String,
|
||||
host: String,
|
||||
user_agent: String,
|
||||
x_amz_user_agent: String,
|
||||
}
|
||||
|
||||
fn sample_transport(raw_auth_config: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "Kiro".to_string(),
|
||||
provider_type: "kiro".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "claude:messages".to_string(),
|
||||
api_family: Some("claude".to_string()),
|
||||
endpoint_kind: Some("cli".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://kiro.example".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-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "bearer".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec!["claude:messages".to_string()]),
|
||||
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: "__placeholder__".to_string(),
|
||||
decrypted_auth_config: Some(raw_auth_config.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_server(app: Router) -> (String, JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("listener should expose local addr");
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.expect("server should run");
|
||||
});
|
||||
(format!("http://{addr}"), handle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_social_token_via_adapter() {
|
||||
let seen_request = Arc::new(Mutex::new(None::<SeenRefreshRequest>));
|
||||
let seen_request_clone = Arc::clone(&seen_request);
|
||||
let server = Router::new().route(
|
||||
"/refreshToken",
|
||||
any(move |request: Request| {
|
||||
let seen_request_inner = Arc::clone(&seen_request_clone);
|
||||
async move {
|
||||
let (parts, body) = request.into_parts();
|
||||
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
|
||||
let body: Value =
|
||||
serde_json::from_slice(&raw_body).expect("body should parse as json");
|
||||
*seen_request_inner.lock().expect("mutex should lock") =
|
||||
Some(SeenRefreshRequest {
|
||||
body,
|
||||
authorization: parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
host: parts
|
||||
.headers
|
||||
.get("host")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
user_agent: parts
|
||||
.headers
|
||||
.get("user-agent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
x_amz_user_agent: parts
|
||||
.headers
|
||||
.get("x-amz-user-agent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
});
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"accessToken": "cached-kiro-access-token",
|
||||
"refreshToken": "s".repeat(120),
|
||||
"expiresIn": 3600,
|
||||
"profileArn": "arn:aws:bedrock:demo"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (server_url, server_handle) = start_server(server).await;
|
||||
let adapter =
|
||||
KiroOAuthRefreshAdapter::default().with_refresh_base_urls(Some(server_url), None);
|
||||
let transport = sample_transport(
|
||||
r#"{
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr",
|
||||
"machine_id":"123e4567-e89b-12d3-a456-426614174000",
|
||||
"kiro_version":"1.2.3"
|
||||
}"#,
|
||||
);
|
||||
let executor = ReqwestLocalOAuthHttpExecutor::new(reqwest::Client::new());
|
||||
|
||||
let entry = adapter
|
||||
.refresh(&executor, &transport, None)
|
||||
.await
|
||||
.expect("refresh should succeed")
|
||||
.expect("cached entry should exist");
|
||||
let resolved = adapter
|
||||
.resolve_cached(&transport, &entry)
|
||||
.expect("cached entry should resolve");
|
||||
let seen_request = seen_request
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.clone()
|
||||
.expect("refresh request should be captured");
|
||||
|
||||
assert_eq!(seen_request.body["refreshToken"], json!("r".repeat(120)));
|
||||
assert_eq!(seen_request.authorization, "");
|
||||
assert!(!seen_request.user_agent.is_empty());
|
||||
assert_eq!(seen_request.x_amz_user_agent, "");
|
||||
assert!(!seen_request.host.trim().is_empty());
|
||||
match resolved {
|
||||
LocalResolvedOAuthRequestAuth::Kiro(auth) => {
|
||||
assert_eq!(auth.value, "Bearer cached-kiro-access-token");
|
||||
assert_eq!(
|
||||
auth.auth_config.profile_arn.as_deref(),
|
||||
Some("arn:aws:bedrock:demo")
|
||||
);
|
||||
assert!(auth.auth_config.expires_at.is_some());
|
||||
}
|
||||
other => panic!("unexpected resolved auth: {other:?}"),
|
||||
}
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_idc_token_via_adapter() {
|
||||
let seen_request = Arc::new(Mutex::new(None::<SeenRefreshRequest>));
|
||||
let seen_request_clone = Arc::clone(&seen_request);
|
||||
let server = Router::new().route(
|
||||
"/token",
|
||||
any(move |request: Request| {
|
||||
let seen_request_inner = Arc::clone(&seen_request_clone);
|
||||
async move {
|
||||
let (parts, body) = request.into_parts();
|
||||
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
|
||||
let body: Value =
|
||||
serde_json::from_slice(&raw_body).expect("body should parse as json");
|
||||
*seen_request_inner.lock().expect("mutex should lock") =
|
||||
Some(SeenRefreshRequest {
|
||||
body,
|
||||
authorization: parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
host: parts
|
||||
.headers
|
||||
.get("host")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
user_agent: parts
|
||||
.headers
|
||||
.get("user-agent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
x_amz_user_agent: parts
|
||||
.headers
|
||||
.get("x-amz-user-agent")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
});
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"accessToken": "cached-idc-access-token",
|
||||
"refreshToken": "i".repeat(120),
|
||||
"expiresIn": 1800
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (server_url, server_handle) = start_server(server).await;
|
||||
let adapter =
|
||||
KiroOAuthRefreshAdapter::default().with_refresh_base_urls(None, Some(server_url));
|
||||
let transport = sample_transport(
|
||||
r#"{
|
||||
"auth_method":"identity_center",
|
||||
"refresh_token":"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr",
|
||||
"client_id":"cid",
|
||||
"client_secret":"secret",
|
||||
"profile_arn":"arn:aws:bedrock:demo"
|
||||
}"#,
|
||||
);
|
||||
let executor = ReqwestLocalOAuthHttpExecutor::new(reqwest::Client::new());
|
||||
|
||||
let entry = adapter
|
||||
.refresh(&executor, &transport, None)
|
||||
.await
|
||||
.expect("refresh should succeed")
|
||||
.expect("cached entry should exist");
|
||||
let resolved = adapter
|
||||
.resolve_cached(&transport, &entry)
|
||||
.expect("cached entry should resolve");
|
||||
let seen_request = seen_request
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.clone()
|
||||
.expect("refresh request should be captured");
|
||||
|
||||
assert_eq!(
|
||||
seen_request.body["grantType"].as_str(),
|
||||
Some("refresh_token")
|
||||
);
|
||||
assert_eq!(seen_request.body["clientId"].as_str(), Some("cid"));
|
||||
assert_eq!(seen_request.user_agent, "node");
|
||||
assert_eq!(seen_request.x_amz_user_agent, IDC_AMZ_USER_AGENT);
|
||||
assert!(!seen_request.host.trim().is_empty());
|
||||
match resolved {
|
||||
LocalResolvedOAuthRequestAuth::Kiro(auth) => {
|
||||
assert_eq!(auth.value, "Bearer cached-idc-access-token");
|
||||
assert!(auth.auth_config.profile_arn_for_payload().is_none());
|
||||
assert!(auth.auth_config.expires_at.is_some());
|
||||
}
|
||||
other => panic!("unexpected resolved auth: {other:?}"),
|
||||
}
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub use super::super::rules::{
|
||||
apply_local_body_rules_with_request_headers, apply_local_header_rules_with_request_headers,
|
||||
body_rules_are_locally_supported, header_rules_are_locally_supported,
|
||||
};
|
||||
use super::super::should_skip_upstream_passthrough_header;
|
||||
use super::converter::convert_claude_messages_to_conversation_state;
|
||||
use super::credentials::KiroAuthConfig;
|
||||
use super::headers::build_generate_assistant_headers;
|
||||
|
||||
pub fn supports_local_kiro_request_shape(
|
||||
header_rules: Option<&Value>,
|
||||
body_rules: Option<&Value>,
|
||||
) -> bool {
|
||||
header_rules_are_locally_supported(header_rules) && body_rules_are_locally_supported(body_rules)
|
||||
}
|
||||
|
||||
pub fn build_kiro_provider_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
auth_config: &KiroAuthConfig,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: Option<&http::HeaderMap>,
|
||||
) -> Option<Value> {
|
||||
let conversation_state =
|
||||
convert_claude_messages_to_conversation_state(body_json, mapped_model)?;
|
||||
let mut provider_request_body = json!({
|
||||
"conversationState": conversation_state
|
||||
});
|
||||
|
||||
let mut inference_config = serde_json::Map::new();
|
||||
if let Some(max_tokens) = body_json
|
||||
.get("max_tokens")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().map(|value| value as i64))
|
||||
})
|
||||
.filter(|value| *value > 0)
|
||||
{
|
||||
inference_config.insert("maxTokens".to_string(), Value::from(max_tokens));
|
||||
}
|
||||
if let Some(temperature) = body_json
|
||||
.get("temperature")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| *value >= 0.0)
|
||||
{
|
||||
inference_config.insert("temperature".to_string(), Value::from(temperature));
|
||||
}
|
||||
if let Some(top_p) = body_json
|
||||
.get("top_p")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| *value > 0.0)
|
||||
{
|
||||
inference_config.insert("topP".to_string(), Value::from(top_p));
|
||||
}
|
||||
if !inference_config.is_empty() {
|
||||
provider_request_body.as_object_mut()?.insert(
|
||||
"inferenceConfig".to_string(),
|
||||
Value::Object(inference_config),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(profile_arn) = auth_config.profile_arn_for_payload() {
|
||||
provider_request_body.as_object_mut()?.insert(
|
||||
"profileArn".to_string(),
|
||||
Value::String(profile_arn.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
if !apply_local_body_rules_with_request_headers(
|
||||
&mut provider_request_body,
|
||||
body_rules,
|
||||
Some(body_json),
|
||||
request_headers,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct KiroProviderHeadersInput<'a> {
|
||||
pub headers: &'a http::HeaderMap,
|
||||
pub provider_request_body: &'a Value,
|
||||
pub original_request_body: &'a Value,
|
||||
pub header_rules: Option<&'a Value>,
|
||||
pub auth_header: &'a str,
|
||||
pub auth_value: &'a str,
|
||||
pub auth_config: &'a KiroAuthConfig,
|
||||
pub machine_id: &'a str,
|
||||
}
|
||||
|
||||
pub fn build_kiro_provider_headers(
|
||||
input: KiroProviderHeadersInput<'_>,
|
||||
) -> Option<BTreeMap<String, String>> {
|
||||
let KiroProviderHeadersInput {
|
||||
headers,
|
||||
provider_request_body,
|
||||
original_request_body,
|
||||
header_rules,
|
||||
auth_header,
|
||||
auth_value,
|
||||
auth_config,
|
||||
machine_id,
|
||||
} = input;
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, value) in headers {
|
||||
let Ok(value) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let key = name.as_str().to_ascii_lowercase();
|
||||
if should_skip_upstream_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.insert(key, value.to_string());
|
||||
}
|
||||
|
||||
if !apply_local_header_rules_with_request_headers(
|
||||
&mut out,
|
||||
header_rules,
|
||||
&[auth_header, "content-type"],
|
||||
provider_request_body,
|
||||
Some(original_request_body),
|
||||
Some(headers),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (key, value) in build_generate_assistant_headers(auth_config, machine_id) {
|
||||
out.insert(key, value);
|
||||
}
|
||||
out.insert(
|
||||
auth_header.trim().to_ascii_lowercase(),
|
||||
auth_value.trim().to_string(),
|
||||
);
|
||||
out.entry("content-type".to_string())
|
||||
.or_insert_with(|| "application/json".to_string());
|
||||
out.remove("content-length");
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::credentials::KiroAuthConfig;
|
||||
use super::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
supports_local_kiro_request_shape, KiroProviderHeadersInput,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn supports_empty_local_request_shape() {
|
||||
assert!(supports_local_kiro_request_shape(None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_rule_shape() {
|
||||
assert!(!supports_local_kiro_request_shape(
|
||||
Some(&json!({"action":"set"})),
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_simple_header_and_body_rules() {
|
||||
assert!(supports_local_kiro_request_shape(
|
||||
Some(&json!([{"action":"set","key":"x-provider-extra","value":"1"}])),
|
||||
Some(&json!([{"action":"set","path":"debugTag","value":true}]))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_claude_request_into_kiro_payload_before_body_rules() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: None,
|
||||
refresh_token: Some("r".repeat(128)),
|
||||
expires_at: None,
|
||||
profile_arn: Some("arn:aws:bedrock:demo".to_string()),
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-east-1".to_string()),
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
machine_id: Some("123e4567-e89b-12d3-a456-426614174000".to_string()),
|
||||
kiro_version: None,
|
||||
system_version: None,
|
||||
node_version: None,
|
||||
access_token: Some("cached-token".to_string()),
|
||||
};
|
||||
|
||||
let payload = build_kiro_provider_request_body(
|
||||
&json!({
|
||||
"messages": [{"role":"user","content":"hello"}],
|
||||
"max_tokens": 64
|
||||
}),
|
||||
"claude-sonnet-4-upstream",
|
||||
&auth_config,
|
||||
Some(&json!([
|
||||
{"action":"set","path":"debugTag","value":"kiro-local"}
|
||||
])),
|
||||
None,
|
||||
)
|
||||
.expect("payload should build");
|
||||
|
||||
assert!(payload.get("conversationState").is_some());
|
||||
assert_eq!(
|
||||
payload
|
||||
.get("inferenceConfig")
|
||||
.and_then(|value| value.get("maxTokens")),
|
||||
Some(&json!(64))
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("profileArn"),
|
||||
Some(&json!("arn:aws:bedrock:demo"))
|
||||
);
|
||||
assert_eq!(payload.get("debugTag"), Some(&json!("kiro-local")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_header_rules_before_kiro_extra_headers() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: None,
|
||||
refresh_token: Some("r".repeat(128)),
|
||||
expires_at: None,
|
||||
profile_arn: None,
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-east-1".to_string()),
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
machine_id: None,
|
||||
kiro_version: None,
|
||||
system_version: None,
|
||||
node_version: None,
|
||||
access_token: Some("cached-token".to_string()),
|
||||
};
|
||||
let headers = build_kiro_provider_headers(KiroProviderHeadersInput {
|
||||
headers: &http::HeaderMap::new(),
|
||||
provider_request_body: &json!({"conversationState": {}}),
|
||||
original_request_body: &json!({"messages": []}),
|
||||
header_rules: Some(&json!([
|
||||
{"action":"set","key":"accept","value":"text/plain"},
|
||||
{"action":"set","key":"x-endpoint-tag","value":"kiro-local"}
|
||||
])),
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer cached-token",
|
||||
auth_config: &auth_config,
|
||||
machine_id: "machine-123",
|
||||
})
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
headers.get("accept").map(String::as_str),
|
||||
Some("application/vnd.amazon.eventstream")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer cached-token")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-endpoint-tag").map(String::as_str),
|
||||
Some("kiro-local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_profile_arn_for_idc_auth() {
|
||||
let auth_config = KiroAuthConfig {
|
||||
auth_method: Some("identity_center".to_string()),
|
||||
refresh_token: Some("r".repeat(128)),
|
||||
expires_at: None,
|
||||
profile_arn: Some("arn:aws:bedrock:demo".to_string()),
|
||||
region: None,
|
||||
auth_region: None,
|
||||
api_region: Some("us-east-1".to_string()),
|
||||
client_id: Some("cid".to_string()),
|
||||
client_secret: Some("secret".to_string()),
|
||||
machine_id: None,
|
||||
kiro_version: None,
|
||||
system_version: None,
|
||||
node_version: None,
|
||||
access_token: Some("cached-token".to_string()),
|
||||
};
|
||||
|
||||
let payload = build_kiro_provider_request_body(
|
||||
&json!({
|
||||
"messages": [{"role":"user","content":"hello"}]
|
||||
}),
|
||||
"claude-sonnet-4-upstream",
|
||||
&auth_config,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("payload should build");
|
||||
|
||||
assert!(payload.get("profileArn").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use super::super::url::build_passthrough_path_url;
|
||||
use super::credentials::DEFAULT_REGION;
|
||||
|
||||
pub const GENERATE_ASSISTANT_RESPONSE_PATH: &str = "/generateAssistantResponse";
|
||||
pub const LIST_AVAILABLE_MODELS_PATH: &str = "/ListAvailableModels";
|
||||
pub const MCP_PATH: &str = "/mcp";
|
||||
pub const MCP_STREAM_PATH: &str = "/mcp/stream";
|
||||
pub const KIRO_ENVELOPE_NAME: &str = "kiro:generateAssistantResponse";
|
||||
|
||||
pub fn resolve_kiro_base_url(upstream_base_url: &str, api_region: Option<&str>) -> String {
|
||||
let region = api_region
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_REGION);
|
||||
upstream_base_url
|
||||
.trim()
|
||||
.replace("{region}", region)
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn build_kiro_generate_assistant_response_url(
|
||||
upstream_base_url: &str,
|
||||
query: Option<&str>,
|
||||
api_region: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let upstream_base_url = resolve_kiro_base_url(upstream_base_url, api_region);
|
||||
build_passthrough_path_url(
|
||||
upstream_base_url.as_str(),
|
||||
GENERATE_ASSISTANT_RESPONSE_PATH,
|
||||
query,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_kiro_mcp_url(upstream_base_url: &str, api_region: Option<&str>) -> Option<String> {
|
||||
let upstream_base_url = resolve_kiro_base_url(upstream_base_url, api_region);
|
||||
build_passthrough_path_url(upstream_base_url.as_str(), MCP_PATH, None, &[])
|
||||
}
|
||||
|
||||
pub fn build_kiro_list_available_models_url(
|
||||
upstream_base_url: &str,
|
||||
api_region: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("origin", "AI_EDITOR");
|
||||
let query = serializer.finish();
|
||||
let upstream_base_url = resolve_kiro_base_url(upstream_base_url, api_region);
|
||||
build_passthrough_path_url(
|
||||
upstream_base_url.as_str(),
|
||||
LIST_AVAILABLE_MODELS_PATH,
|
||||
Some(&query),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_kiro_mcp_url_from_resolved_url(resolved_url: &str) -> Option<String> {
|
||||
let mut parsed = url::Url::parse(resolved_url).ok()?;
|
||||
parsed.set_path(MCP_PATH);
|
||||
parsed.set_query(None);
|
||||
Some(parsed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_kiro_generate_assistant_response_url, build_kiro_list_available_models_url,
|
||||
build_kiro_mcp_url, build_kiro_mcp_url_from_resolved_url, resolve_kiro_base_url,
|
||||
GENERATE_ASSISTANT_RESPONSE_PATH, KIRO_ENVELOPE_NAME, LIST_AVAILABLE_MODELS_PATH, MCP_PATH,
|
||||
MCP_STREAM_PATH,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn exposes_kiro_request_constants() {
|
||||
assert_eq!(
|
||||
GENERATE_ASSISTANT_RESPONSE_PATH,
|
||||
"/generateAssistantResponse"
|
||||
);
|
||||
assert_eq!(LIST_AVAILABLE_MODELS_PATH, "/ListAvailableModels");
|
||||
assert_eq!(MCP_PATH, "/mcp");
|
||||
assert_eq!(MCP_STREAM_PATH, "/mcp/stream");
|
||||
assert_eq!(KIRO_ENVELOPE_NAME, "kiro:generateAssistantResponse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_generate_assistant_response_url() {
|
||||
assert_eq!(
|
||||
build_kiro_generate_assistant_response_url(
|
||||
"https://kiro.{region}.example?tenant=demo",
|
||||
Some("stream=true"),
|
||||
Some("us-west-2")
|
||||
)
|
||||
.as_deref(),
|
||||
Some(
|
||||
"https://kiro.us-west-2.example/generateAssistantResponse?stream=true&tenant=demo"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_region_placeholder_in_base_url() {
|
||||
assert_eq!(
|
||||
resolve_kiro_base_url("https://kiro.{region}.example/", Some("us-west-2")),
|
||||
"https://kiro.us-west-2.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mcp_url_for_latest_kiro_endpoint() {
|
||||
assert_eq!(
|
||||
build_kiro_mcp_url("https://q.{region}.amazonaws.com", Some("eu-west-1")).as_deref(),
|
||||
Some("https://q.eu-west-1.amazonaws.com/mcp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_list_available_models_url_for_profile() {
|
||||
assert_eq!(
|
||||
build_kiro_list_available_models_url(
|
||||
"https://q.{region}.amazonaws.com",
|
||||
Some("us-west-2")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://q.us-west-2.amazonaws.com/ListAvailableModels?origin=AI_EDITOR")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_generate_assistant_url_to_mcp_url() {
|
||||
assert_eq!(
|
||||
build_kiro_mcp_url_from_resolved_url(
|
||||
"https://q.us-east-1.amazonaws.com/generateAssistantResponse?beta=true"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://q.us-east-1.amazonaws.com/mcp")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user