fix(kiro): 修复 Claude CLI 跨格式 Responses 流式转换 (#329)

This commit is contained in:
Entropy.Xu
2026-04-24 20:23:02 +08:00
committed by GitHub
parent 657fcd595c
commit 343345529d
10 changed files with 956 additions and 97 deletions

View File

@@ -3,6 +3,7 @@
use aether_provider_transport::auth::{
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth,
};
use aether_provider_transport::kiro::local_kiro_request_transport_unsupported_reason_with_network;
use aether_provider_transport::policy::{
local_gemini_transport_unsupported_reason_with_network,
local_openai_chat_transport_unsupported_reason,
@@ -222,6 +223,20 @@ pub fn request_pair_allowed_for_transport(
if request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str()).is_none() {
return false;
}
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro")
&& provider_api_format.eq_ignore_ascii_case("claude:cli")
{
return request_conversion_enabled_for_transport(
transport,
client_api_format.as_str(),
provider_api_format.as_str(),
) && local_kiro_request_transport_unsupported_reason_with_network(transport)
.is_none();
}
request_conversion_enabled_for_transport(
transport,
client_api_format.as_str(),
@@ -240,6 +255,20 @@ pub fn request_conversion_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind,
) -> Option<&'static str> {
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro")
&& transport
.endpoint
.api_format
.trim()
.eq_ignore_ascii_case("claude:cli")
{
return local_kiro_request_transport_unsupported_reason_with_network(transport);
}
match transport
.endpoint
.api_format
@@ -898,4 +927,68 @@ mod tests {
Some(("key".to_string(), "vertex-secret".to_string()))
);
}
#[test]
fn kiro_claude_cli_transport_supports_cross_format_conversion_via_envelope() {
let transport = GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-kiro".to_string(),
name: "kiro".to_string(),
provider_type: "kiro".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: true,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-kiro".to_string(),
provider_id: "provider-kiro".to_string(),
api_format: "claude:cli".to_string(),
api_family: Some("claude".to_string()),
endpoint_kind: Some("cli".to_string()),
is_active: true,
base_url: "https://q.{region}.amazonaws.com".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-kiro".to_string(),
provider_id: "provider-kiro".to_string(),
name: "key".to_string(),
auth_type: "bearer".to_string(),
is_active: true,
api_formats: Some(vec!["claude:cli".to_string()]),
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
decrypted_api_key: "kiro-secret".to_string(),
decrypted_auth_config: None,
},
};
assert!(request_pair_allowed_for_transport(
&transport,
"openai:chat",
"claude:cli"
));
assert!(request_conversion_transport_supported(
&transport,
RequestConversionKind::ToClaudeStandard
));
}
}

View File

@@ -946,6 +946,8 @@ struct OpenAICliClientToolState {
pub struct OpenAICliClientEmitter {
response_id: Option<String>,
model: Option<String>,
message_item_id: Option<String>,
reasoning_item_id: Option<String>,
started: bool,
finished: bool,
sequence_number: u64,
@@ -1166,11 +1168,29 @@ impl OpenAICliClientEmitter {
}
fn message_item_id(&self) -> String {
format!("{}_msg", self.response_id())
self.message_item_id
.clone()
.unwrap_or_else(|| format!("{}_msg", self.response_id()))
}
fn reasoning_item_id(&self) -> String {
format!("{}_rs_0", self.response_id())
self.reasoning_item_id
.clone()
.unwrap_or_else(|| format!("{}_rs_0", self.response_id()))
}
fn ensure_message_item_id(&mut self) -> String {
if self.message_item_id.is_none() {
self.message_item_id = Some(format!("{}_msg", self.response_id()));
}
self.message_item_id()
}
fn ensure_reasoning_item_id(&mut self) -> String {
if self.reasoning_item_id.is_none() {
self.reasoning_item_id = Some(format!("{}_rs_0", self.response_id()));
}
self.reasoning_item_id()
}
fn in_progress_response(&self) -> Value {
@@ -1269,6 +1289,7 @@ impl OpenAICliClientEmitter {
fn ensure_reasoning_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = self.ensure_started()?;
let output_index = self.ensure_reasoning_output_index();
let item_id = self.ensure_reasoning_item_id();
if !self.reasoning_item_started {
out.extend(self.encode_response_event(
"response.output_item.added",
@@ -1278,7 +1299,7 @@ impl OpenAICliClientEmitter {
"output_index": output_index,
"item": {
"type": "reasoning",
"id": self.reasoning_item_id(),
"id": item_id.clone(),
"summary": [],
}
}),
@@ -1291,7 +1312,7 @@ impl OpenAICliClientEmitter {
json!({
"type": "response.reasoning_summary_part.added",
"response_id": self.response_id(),
"item_id": self.reasoning_item_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": 0,
"part": {
@@ -1308,8 +1329,8 @@ impl OpenAICliClientEmitter {
fn ensure_text_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = self.ensure_started()?;
let output_index = self.ensure_message_output_index();
let item_id = self.ensure_message_item_id();
if !self.text_item_started {
let item_id = self.message_item_id();
out.extend(self.encode_response_event(
"response.output_item.added",
json!({
@@ -1318,7 +1339,7 @@ impl OpenAICliClientEmitter {
"output_index": output_index,
"item": {
"type": "message",
"id": item_id,
"id": item_id.clone(),
"status": "in_progress",
"role": "assistant",
"content": [],
@@ -1328,7 +1349,6 @@ impl OpenAICliClientEmitter {
self.text_item_started = true;
}
if !self.text_part_started {
let item_id = self.message_item_id();
out.extend(self.encode_response_event(
"response.content_part.added",
json!({
@@ -1849,6 +1869,31 @@ mod tests {
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
}
#[test]
fn openai_cli_client_emitter_keeps_text_item_id_stable_after_text_started() {
let mut emitter = OpenAICliClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_first".to_string(),
model: "claude-haiku-4-5-20251001".to_string(),
event: CanonicalStreamEvent::TextDelta("Hel".to_string()),
})
.expect("first text should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_second".to_string(),
model: "claude-haiku-4-5-20251001".to_string(),
event: CanonicalStreamEvent::TextDelta("lo".to_string()),
})
.expect("second text should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"item_id\":\"msg_first_msg\""));
assert!(!sse.contains("\"item_id\":\"msg_second_msg\""));
}
#[test]
fn openai_cli_provider_state_accepts_done_events_without_deltas() {
let mut state = OpenAICliProviderState::default();

View File

@@ -10,6 +10,7 @@ pub enum FinalizeStreamRewriteMode {
OpenAiImage,
Standard,
KiroToClaudeCli,
KiroToClaudeCliThenStandard,
}
pub fn resolve_finalize_stream_rewrite_mode(
@@ -38,6 +39,17 @@ pub fn resolve_finalize_stream_rewrite_mode(
.trim()
.to_ascii_lowercase();
if needs_conversion
&& envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME)
&& provider_api_format == "claude:cli"
{
return supports_standard_stream_rewrite(
provider_api_format.as_str(),
client_api_format.as_str(),
)
.then_some(FinalizeStreamRewriteMode::KiroToClaudeCliThenStandard);
}
if needs_conversion {
return supports_standard_stream_rewrite(
provider_api_format.as_str(),