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

@@ -12,6 +12,10 @@ enum RewriteMode {
OpenAiImage(OpenAiImageStreamState), OpenAiImage(OpenAiImageStreamState),
Standard(StreamingStandardConversionState), Standard(StreamingStandardConversionState),
KiroToClaudeCli(KiroToClaudeCliStreamState), KiroToClaudeCli(KiroToClaudeCliStreamState),
KiroToClaudeCliThenStandard {
kiro: KiroToClaudeCliStreamState,
standard: StreamingStandardConversionState,
},
} }
pub(crate) struct LocalStreamRewriter<'a> { pub(crate) struct LocalStreamRewriter<'a> {
@@ -35,6 +39,12 @@ pub(crate) fn maybe_build_local_stream_rewriter<'a>(
FinalizeStreamRewriteMode::KiroToClaudeCli => { FinalizeStreamRewriteMode::KiroToClaudeCli => {
RewriteMode::KiroToClaudeCli(KiroToClaudeCliStreamState::new(report_context)) RewriteMode::KiroToClaudeCli(KiroToClaudeCliStreamState::new(report_context))
} }
FinalizeStreamRewriteMode::KiroToClaudeCliThenStandard => {
RewriteMode::KiroToClaudeCliThenStandard {
kiro: KiroToClaudeCliStreamState::new(report_context),
standard: StreamingStandardConversionState::default(),
}
}
}; };
Some(LocalStreamRewriter { Some(LocalStreamRewriter {
@@ -52,6 +62,10 @@ impl LocalStreamRewriter<'_> {
if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode { if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode {
return state.push_chunk(self.report_context, chunk); return state.push_chunk(self.report_context, chunk);
} }
if let RewriteMode::KiroToClaudeCliThenStandard { kiro, standard } = &mut self.mode {
let claude_bytes = kiro.push_chunk(self.report_context, chunk)?;
return transform_standard_bytes(standard, self.report_context, claude_bytes);
}
self.buffered.extend_from_slice(chunk); self.buffered.extend_from_slice(chunk);
let mut output = Vec::new(); let mut output = Vec::new();
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') { while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
@@ -68,11 +82,21 @@ impl LocalStreamRewriter<'_> {
if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode { if let RewriteMode::KiroToClaudeCli(state) = &mut self.mode {
return state.finish(self.report_context); return state.finish(self.report_context);
} }
if let RewriteMode::KiroToClaudeCliThenStandard { kiro, standard } = &mut self.mode {
let mut output = transform_standard_bytes(
standard,
self.report_context,
kiro.finish(self.report_context)?,
)?;
output.extend(standard.finish(self.report_context)?);
return Ok(output);
}
if self.buffered.is_empty() { if self.buffered.is_empty() {
match &mut self.mode { match &mut self.mode {
RewriteMode::Standard(state) => return state.finish(self.report_context), RewriteMode::Standard(state) => return state.finish(self.report_context),
RewriteMode::OpenAiImage(_) => {} RewriteMode::OpenAiImage(_) => {}
RewriteMode::KiroToClaudeCli(_) => {} RewriteMode::KiroToClaudeCli(_) => {}
RewriteMode::KiroToClaudeCliThenStandard { .. } => {}
RewriteMode::EnvelopeUnwrap => {} RewriteMode::EnvelopeUnwrap => {}
} }
return Ok(Vec::new()); return Ok(Vec::new());
@@ -85,6 +109,7 @@ impl LocalStreamRewriter<'_> {
} }
RewriteMode::OpenAiImage(_) => {} RewriteMode::OpenAiImage(_) => {}
RewriteMode::KiroToClaudeCli(_) => {} RewriteMode::KiroToClaudeCli(_) => {}
RewriteMode::KiroToClaudeCliThenStandard { .. } => {}
RewriteMode::EnvelopeUnwrap => {} RewriteMode::EnvelopeUnwrap => {}
} }
Ok(output) Ok(output)
@@ -97,10 +122,26 @@ impl LocalStreamRewriter<'_> {
RewriteMode::OpenAiImage(_) => Ok(Vec::new()), RewriteMode::OpenAiImage(_) => Ok(Vec::new()),
RewriteMode::Standard(state) => state.transform_line(self.report_context, line), RewriteMode::Standard(state) => state.transform_line(self.report_context, line),
RewriteMode::KiroToClaudeCli(_) => Ok(Vec::new()), RewriteMode::KiroToClaudeCli(_) => Ok(Vec::new()),
RewriteMode::KiroToClaudeCliThenStandard { .. } => Ok(Vec::new()),
} }
} }
} }
fn transform_standard_bytes(
standard: &mut StreamingStandardConversionState,
report_context: &Value,
bytes: Vec<u8>,
) -> Result<Vec<u8>, GatewayError> {
if bytes.is_empty() {
return Ok(Vec::new());
}
let mut output = Vec::new();
for line in bytes.split_inclusive(|byte| *byte == b'\n') {
output.extend(standard.transform_line(report_context, line.to_vec())?);
}
Ok(output)
}
#[derive(Default)] #[derive(Default)]
struct OpenAiImageStreamState { struct OpenAiImageStreamState {
buffered: Vec<u8>, buffered: Vec<u8>,

View File

@@ -52,6 +52,12 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
{ {
extra_fields.insert("proxy".to_string(), proxy_value); extra_fields.insert("proxy".to_string(), proxy_value);
} }
if let Some(envelope_name) = resolved.envelope_name {
extra_fields.insert(
"envelope_name".to_string(),
serde_json::Value::String(envelope_name.to_string()),
);
}
let report_context = append_local_failover_policy_to_value( let report_context = append_local_failover_policy_to_value(
append_execution_contract_fields_to_value( append_execution_contract_fields_to_value(
build_local_execution_report_context(LocalExecutionReportContextParts { build_local_execution_report_context(LocalExecutionReportContextParts {
@@ -83,7 +89,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
.and_then(serde_json::Value::as_bool) .and_then(serde_json::Value::as_bool)
.unwrap_or(false), .unwrap_or(false),
upstream_is_stream: resolved.upstream_is_stream, upstream_is_stream: resolved.upstream_is_stream,
has_envelope: false, has_envelope: resolved.envelope_name.is_some(),
needs_conversion: true, needs_conversion: true,
extra_fields, extra_fields,
}), }),
@@ -105,6 +111,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
provider_request_headers, provider_request_headers,
upstream_url, upstream_url,
upstream_is_stream, upstream_is_stream,
envelope_name: _,
transport, transport,
} = resolved; } = resolved;

View File

@@ -13,8 +13,12 @@ use crate::ai_pipeline::transport::apply_local_header_rules;
use crate::ai_pipeline::transport::auth::{ use crate::ai_pipeline::transport::auth::{
build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header, build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
}; };
use crate::ai_pipeline::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body, KiroProviderHeadersInput,
KiroRequestAuth, KIRO_ENVELOPE_NAME,
};
use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth; use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth;
use crate::ai_pipeline::GatewayProviderTransportSnapshot; use crate::ai_pipeline::{GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth};
use crate::AppState; use crate::AppState;
use super::payload::mark_skipped_local_standard_candidate; use super::payload::mark_skipped_local_standard_candidate;
@@ -29,6 +33,7 @@ pub(crate) struct LocalStandardCandidatePayloadParts {
pub(super) provider_request_headers: BTreeMap<String, String>, pub(super) provider_request_headers: BTreeMap<String, String>,
pub(super) upstream_url: String, pub(super) upstream_url: String,
pub(super) upstream_is_stream: bool, pub(super) upstream_is_stream: bool,
pub(super) envelope_name: Option<&'static str>,
pub(super) transport: Arc<GatewayProviderTransportSnapshot>, pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
} }
@@ -46,6 +51,12 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
let candidate = &attempt.eligible.candidate; let candidate = &attempt.eligible.candidate;
let transport = &attempt.eligible.transport; let transport = &attempt.eligible.transport;
let provider_api_format = attempt.eligible.provider_api_format.as_str(); let provider_api_format = attempt.eligible.provider_api_format.as_str();
let is_kiro_claude_cli = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro")
&& provider_api_format.eq_ignore_ascii_case("claude:cli");
let Some(conversion_kind) = crate::ai_pipeline::conversion::request_conversion_kind( let Some(conversion_kind) = crate::ai_pipeline::conversion::request_conversion_kind(
spec_metadata.api_format, spec_metadata.api_format,
provider_api_format, provider_api_format,
@@ -72,32 +83,90 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
return None; return None;
} }
let prepared_candidate = match prepare_header_authenticated_candidate( let oauth_context = OauthPreparationContext {
planner_state, trace_id,
transport, api_format: provider_api_format,
candidate, operation: "standard_family_cross_format",
crate::ai_pipeline::conversion::request_conversion_direct_auth(transport, conversion_kind), };
OauthPreparationContext { let kiro_auth = if is_kiro_claude_cli {
trace_id, match crate::ai_pipeline::planner::candidate_preparation::resolve_candidate_oauth_auth(
api_format: provider_api_format, planner_state,
operation: "standard_family_cross_format", transport,
}, oauth_context,
) )
.await .await
{ {
Ok(prepared) => prepared, Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
Err(skip_reason) => { _ => {
mark_skipped_local_standard_candidate( mark_skipped_local_standard_candidate(
state, state,
input, input,
trace_id, trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
}
}
} else {
None
};
let prepared_candidate = if let Some(kiro_auth) = kiro_auth.as_ref() {
let mapped_model =
match crate::ai_pipeline::planner::candidate_preparation::resolve_candidate_mapped_model(
candidate, candidate,
attempt.candidate_index, ) {
&attempt.candidate_id, Ok(mapped_model) => mapped_model,
skip_reason, Err(skip_reason) => {
) mark_skipped_local_standard_candidate(
.await; state,
return None; input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
skip_reason,
)
.await;
return None;
}
};
crate::ai_pipeline::planner::candidate_preparation::PreparedHeaderAuthenticatedCandidate {
auth_header: kiro_auth.name.to_string(),
auth_value: kiro_auth.value.clone(),
mapped_model,
}
} else {
match prepare_header_authenticated_candidate(
planner_state,
transport,
candidate,
crate::ai_pipeline::conversion::request_conversion_direct_auth(
transport,
conversion_kind,
),
oauth_context,
)
.await
{
Ok(prepared) => prepared,
Err(skip_reason) => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
skip_reason,
)
.await;
return None;
}
} }
}; };
@@ -115,7 +184,11 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
provider_api_format, provider_api_format,
parts.uri.path(), parts.uri.path(),
upstream_is_stream, upstream_is_stream,
transport.endpoint.body_rules.as_ref(), if is_kiro_claude_cli {
None
} else {
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()), Some(input.auth_context.api_key_id.as_str()),
) { ) {
Some(body) => body, Some(body) => body,
@@ -134,6 +207,26 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
} }
}; };
if let Some(kiro_auth) = kiro_auth.as_ref() {
return build_kiro_cross_format_payload_parts(
state,
parts,
trace_id,
body_json,
input,
attempt,
transport,
provider_api_format,
prepared_candidate.mapped_model,
prepared_candidate.auth_header,
prepared_candidate.auth_value,
provider_request_body,
upstream_is_stream,
kiro_auth,
)
.await;
}
let upstream_url = match crate::ai_pipeline::planner::standard::build_standard_upstream_url( let upstream_url = match crate::ai_pipeline::planner::standard::build_standard_upstream_url(
parts, parts,
transport, transport,
@@ -237,6 +330,109 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
provider_request_headers, provider_request_headers,
upstream_url, upstream_url,
upstream_is_stream, upstream_is_stream,
envelope_name: None,
transport: Arc::clone(transport),
})
}
#[allow(clippy::too_many_arguments)]
async fn build_kiro_cross_format_payload_parts(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
original_body_json: &serde_json::Value,
input: &LocalStandardDecisionInput,
attempt: &LocalStandardCandidateAttempt,
transport: &Arc<GatewayProviderTransportSnapshot>,
provider_api_format: &str,
mapped_model: String,
auth_header: String,
auth_value: String,
claude_request_body: Value,
upstream_is_stream: bool,
kiro_auth: &KiroRequestAuth,
) -> Option<LocalStandardCandidatePayloadParts> {
let candidate = &attempt.eligible.candidate;
let provider_request_body = match build_kiro_provider_request_body(
&claude_request_body,
&mapped_model,
&kiro_auth.auth_config,
transport.endpoint.body_rules.as_ref(),
) {
Some(body) => body,
None => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
};
let upstream_url = match crate::ai_pipeline::build_provider_transport_request_url(
transport,
provider_api_format,
Some(&mapped_model),
upstream_is_stream,
parts.uri.query(),
Some(kiro_auth.auth_config.effective_api_region()),
) {
Some(url) => url,
None => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
"upstream_url_missing",
)
.await;
return None;
}
};
let provider_request_headers = match build_kiro_provider_headers(KiroProviderHeadersInput {
headers: &parts.headers,
provider_request_body: &provider_request_body,
original_request_body: original_body_json,
header_rules: transport.endpoint.header_rules.as_ref(),
auth_header: &auth_header,
auth_value: &auth_value,
auth_config: &kiro_auth.auth_config,
machine_id: kiro_auth.machine_id.as_str(),
}) {
Some(headers) => headers,
None => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
};
Some(LocalStandardCandidatePayloadParts {
auth_header,
auth_value,
mapped_model,
provider_api_format: provider_api_format.to_string(),
provider_request_body,
provider_request_headers,
upstream_url,
upstream_is_stream,
envelope_name: Some(KIRO_ENVELOPE_NAME),
transport: Arc::clone(transport), transport: Arc::clone(transport),
}) })
} }

View File

@@ -69,6 +69,12 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
{ {
extra_fields.insert("proxy".to_string(), proxy_value); extra_fields.insert("proxy".to_string(), proxy_value);
} }
if let Some(envelope_name) = resolved.envelope_name {
extra_fields.insert(
"envelope_name".to_string(),
serde_json::Value::String(envelope_name.to_string()),
);
}
let report_context = append_local_failover_policy_to_value( let report_context = append_local_failover_policy_to_value(
append_execution_contract_fields_to_value( append_execution_contract_fields_to_value(
build_local_execution_report_context(LocalExecutionReportContextParts { build_local_execution_report_context(LocalExecutionReportContextParts {
@@ -100,7 +106,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
.and_then(serde_json::Value::as_bool) .and_then(serde_json::Value::as_bool)
.unwrap_or(false), .unwrap_or(false),
upstream_is_stream, upstream_is_stream,
has_envelope: false, has_envelope: resolved.envelope_name.is_some(),
needs_conversion: matches!( needs_conversion: matches!(
resolved.conversion_mode, resolved.conversion_mode,
crate::ai_pipeline::ConversionMode::Bidirectional crate::ai_pipeline::ConversionMode::Bidirectional
@@ -125,6 +131,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
execution_strategy, execution_strategy,
conversion_mode, conversion_mode,
report_kind, report_kind,
envelope_name: _,
transport, transport,
} = resolved; } = resolved;

View File

@@ -20,9 +20,16 @@ use crate::ai_pipeline::transport::auth::{
build_openai_passthrough_headers, ensure_upstream_auth_header, build_openai_passthrough_headers, ensure_upstream_auth_header,
resolve_local_openai_bearer_auth, resolve_local_openai_bearer_auth,
}; };
use crate::ai_pipeline::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body, KiroProviderHeadersInput,
KiroRequestAuth, KIRO_ENVELOPE_NAME,
};
use crate::ai_pipeline::transport::local_openai_chat_transport_unsupported_reason; use crate::ai_pipeline::transport::local_openai_chat_transport_unsupported_reason;
use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth; use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth;
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot}; use crate::ai_pipeline::{
ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot,
LocalResolvedOAuthRequestAuth,
};
use crate::AppState; use crate::AppState;
use super::support::{mark_skipped_local_openai_chat_candidate, LocalOpenAiChatDecisionInput}; use super::support::{mark_skipped_local_openai_chat_candidate, LocalOpenAiChatDecisionInput};
@@ -38,6 +45,7 @@ pub(crate) struct LocalOpenAiChatCandidatePayloadParts {
pub(super) execution_strategy: ExecutionStrategy, pub(super) execution_strategy: ExecutionStrategy,
pub(super) conversion_mode: ConversionMode, pub(super) conversion_mode: ConversionMode,
pub(super) report_kind: String, pub(super) report_kind: String,
pub(super) envelope_name: Option<&'static str>,
pub(super) transport: Arc<GatewayProviderTransportSnapshot>, pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
} }
@@ -194,6 +202,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
execution_strategy: ExecutionStrategy::LocalSameFormat, execution_strategy: ExecutionStrategy::LocalSameFormat,
conversion_mode: ConversionMode::None, conversion_mode: ConversionMode::None,
report_kind: report_kind.to_string(), report_kind: report_kind.to_string(),
envelope_name: None,
transport: Arc::clone(transport), transport: Arc::clone(transport),
}); });
} }
@@ -232,32 +241,92 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
.await; .await;
return None; return None;
} }
let prepared_candidate = match prepare_header_authenticated_candidate( let is_kiro_claude_cli = transport
planner_state, .provider
transport, .provider_type
candidate, .trim()
request_conversion_direct_auth(transport, conversion_kind), .eq_ignore_ascii_case("kiro")
OauthPreparationContext { && provider_api_format.eq_ignore_ascii_case("claude:cli");
trace_id, let oauth_context = OauthPreparationContext {
api_format: provider_api_format.as_str(), trace_id,
operation: "openai_chat_cross_format", api_format: provider_api_format.as_str(),
}, operation: "openai_chat_cross_format",
) };
.await let kiro_auth = if is_kiro_claude_cli {
{ match crate::ai_pipeline::planner::candidate_preparation::resolve_candidate_oauth_auth(
Ok(prepared) => prepared, planner_state,
Err(skip_reason) => { transport,
mark_skipped_local_openai_chat_candidate( oauth_context,
state, )
input, .await
trace_id, {
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
_ => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
}
}
} else {
None
};
let prepared_candidate = if let Some(kiro_auth) = kiro_auth.as_ref() {
let mapped_model =
match crate::ai_pipeline::planner::candidate_preparation::resolve_candidate_mapped_model(
candidate, candidate,
candidate_index, ) {
candidate_id, Ok(mapped_model) => mapped_model,
skip_reason, Err(skip_reason) => {
) mark_skipped_local_openai_chat_candidate(
.await; state,
return None; input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return None;
}
};
crate::ai_pipeline::planner::candidate_preparation::PreparedHeaderAuthenticatedCandidate {
auth_header: kiro_auth.name.to_string(),
auth_value: kiro_auth.value.clone(),
mapped_model,
}
} else {
match prepare_header_authenticated_candidate(
planner_state,
transport,
candidate,
request_conversion_direct_auth(transport, conversion_kind),
oauth_context,
)
.await
{
Ok(prepared) => prepared,
Err(skip_reason) => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return None;
}
} }
}; };
@@ -267,7 +336,11 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
transport.provider.provider_type.as_str(), transport.provider.provider_type.as_str(),
provider_api_format.as_str(), provider_api_format.as_str(),
upstream_is_stream, upstream_is_stream,
transport.endpoint.body_rules.as_ref(), if is_kiro_claude_cli {
None
} else {
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()), Some(input.auth_context.api_key_id.as_str()),
) else { ) else {
mark_skipped_local_openai_chat_candidate( mark_skipped_local_openai_chat_candidate(
@@ -283,6 +356,29 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
return None; return None;
}; };
if let Some(kiro_auth) = kiro_auth.as_ref() {
return build_kiro_openai_chat_cross_format_payload_parts(
state,
parts,
trace_id,
body_json,
input,
eligible,
candidate_index,
candidate_id,
decision_kind,
transport,
provider_api_format.as_str(),
prepared_candidate.mapped_model,
prepared_candidate.auth_header,
prepared_candidate.auth_value,
provider_request_body,
upstream_is_stream,
kiro_auth,
)
.await;
}
let Some(upstream_url) = build_cross_format_openai_chat_upstream_url( let Some(upstream_url) = build_cross_format_openai_chat_upstream_url(
parts, parts,
transport, transport,
@@ -392,6 +488,119 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
execution_strategy: ExecutionStrategy::LocalCrossFormat, execution_strategy: ExecutionStrategy::LocalCrossFormat,
conversion_mode: ConversionMode::Bidirectional, conversion_mode: ConversionMode::Bidirectional,
report_kind: resolved_report_kind, report_kind: resolved_report_kind,
envelope_name: None,
transport: Arc::clone(transport),
})
}
#[allow(clippy::too_many_arguments)]
async fn build_kiro_openai_chat_cross_format_payload_parts(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
original_body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
decision_kind: &str,
transport: &Arc<GatewayProviderTransportSnapshot>,
provider_api_format: &str,
mapped_model: String,
auth_header: String,
auth_value: String,
claude_request_body: Value,
upstream_is_stream: bool,
kiro_auth: &KiroRequestAuth,
) -> Option<LocalOpenAiChatCandidatePayloadParts> {
let candidate = &eligible.candidate;
let provider_request_body = match build_kiro_provider_request_body(
&claude_request_body,
&mapped_model,
&kiro_auth.auth_config,
transport.endpoint.body_rules.as_ref(),
) {
Some(body) => body,
None => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
};
let upstream_url = match crate::ai_pipeline::build_provider_transport_request_url(
transport,
provider_api_format,
Some(&mapped_model),
upstream_is_stream,
parts.uri.query(),
Some(kiro_auth.auth_config.effective_api_region()),
) {
Some(url) => url,
None => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"upstream_url_missing",
)
.await;
return None;
}
};
let provider_request_headers = match build_kiro_provider_headers(KiroProviderHeadersInput {
headers: &parts.headers,
provider_request_body: &provider_request_body,
original_request_body: original_body_json,
header_rules: transport.endpoint.header_rules.as_ref(),
auth_header: &auth_header,
auth_value: &auth_value,
auth_config: &kiro_auth.auth_config,
machine_id: kiro_auth.machine_id.as_str(),
}) {
Some(headers) => headers,
None => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
};
let resolved_report_kind = if decision_kind == OPENAI_CHAT_STREAM_PLAN_KIND {
"openai_chat_stream_success".to_string()
} else {
"openai_chat_sync_finalize".to_string()
};
Some(LocalOpenAiChatCandidatePayloadParts {
auth_header,
auth_value,
mapped_model,
provider_api_format: provider_api_format.to_string(),
provider_request_body,
provider_request_headers,
upstream_url,
execution_strategy: ExecutionStrategy::LocalCrossFormat,
conversion_mode: ConversionMode::Bidirectional,
report_kind: resolved_report_kind,
envelope_name: Some(KIRO_ENVELOPE_NAME),
transport: Arc::clone(transport), transport: Arc::clone(transport),
}) })
} }

View File

@@ -70,8 +70,8 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
{ {
extra_fields.insert("proxy".to_string(), proxy_value); extra_fields.insert("proxy".to_string(), proxy_value);
} }
if resolved.is_antigravity { if let Some(envelope_name) = resolved.envelope_name {
extra_fields.insert("envelope_name".to_string(), json!("antigravity:v1internal")); extra_fields.insert("envelope_name".to_string(), json!(envelope_name));
} }
let report_context = append_local_failover_policy_to_value( let report_context = append_local_failover_policy_to_value(
append_execution_contract_fields_to_value( append_execution_contract_fields_to_value(
@@ -104,7 +104,7 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
.and_then(serde_json::Value::as_bool) .and_then(serde_json::Value::as_bool)
.unwrap_or(false), .unwrap_or(false),
upstream_is_stream: resolved.upstream_is_stream, upstream_is_stream: resolved.upstream_is_stream,
has_envelope: resolved.is_antigravity, has_envelope: resolved.envelope_name.is_some(),
needs_conversion: matches!( needs_conversion: matches!(
resolved.conversion_mode, resolved.conversion_mode,
crate::ai_pipeline::ConversionMode::Bidirectional crate::ai_pipeline::ConversionMode::Bidirectional
@@ -139,7 +139,7 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
upstream_base_url = %resolved.transport.endpoint.base_url, upstream_base_url = %resolved.transport.endpoint.base_url,
upstream_url = %resolved.upstream_url, upstream_url = %resolved.upstream_url,
upstream_is_stream = resolved.upstream_is_stream, upstream_is_stream = resolved.upstream_is_stream,
has_envelope = resolved.is_antigravity, has_envelope = resolved.envelope_name.is_some(),
"gateway built local openai cli decision payload" "gateway built local openai cli decision payload"
); );
let super::request::LocalOpenAiCliCandidatePayloadParts { let super::request::LocalOpenAiCliCandidatePayloadParts {
@@ -153,6 +153,7 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
execution_strategy, execution_strategy,
conversion_mode, conversion_mode,
is_antigravity: _, is_antigravity: _,
envelope_name: _,
upstream_is_stream, upstream_is_stream,
transport, transport,
} = resolved; } = resolved;

View File

@@ -27,10 +27,17 @@ use crate::ai_pipeline::transport::auth::{
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth, build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
resolve_local_openai_bearer_auth, resolve_local_standard_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth,
}; };
use crate::ai_pipeline::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body,
local_kiro_request_transport_unsupported_reason_with_network, KiroProviderHeadersInput,
KiroRequestAuth, KIRO_ENVELOPE_NAME,
};
use crate::ai_pipeline::transport::local_standard_transport_unsupported_reason_with_network; use crate::ai_pipeline::transport::local_standard_transport_unsupported_reason_with_network;
use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth; use crate::ai_pipeline::transport::vertex::uses_vertex_api_key_query_auth;
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy}; use crate::ai_pipeline::{ConversionMode, ExecutionStrategy};
use crate::ai_pipeline::{GatewayProviderTransportSnapshot, PlannerAppState}; use crate::ai_pipeline::{
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth, PlannerAppState,
};
use crate::AppState; use crate::AppState;
use super::support::{mark_skipped_local_openai_cli_candidate, LocalOpenAiCliDecisionInput}; use super::support::{mark_skipped_local_openai_cli_candidate, LocalOpenAiCliDecisionInput};
@@ -49,6 +56,7 @@ pub(crate) struct LocalOpenAiCliCandidatePayloadParts {
pub(super) execution_strategy: ExecutionStrategy, pub(super) execution_strategy: ExecutionStrategy,
pub(super) conversion_mode: ConversionMode, pub(super) conversion_mode: ConversionMode,
pub(super) is_antigravity: bool, pub(super) is_antigravity: bool,
pub(super) envelope_name: Option<&'static str>,
pub(super) upstream_is_stream: bool, pub(super) upstream_is_stream: bool,
pub(super) transport: Arc<GatewayProviderTransportSnapshot>, pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
} }
@@ -76,10 +84,18 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
.provider_type .provider_type
.trim() .trim()
.eq_ignore_ascii_case("antigravity"); .eq_ignore_ascii_case("antigravity");
let is_kiro_claude_cli = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("kiro")
&& provider_api_format.eq_ignore_ascii_case("claude:cli");
let same_format = provider_api_format == client_api_format; let same_format = provider_api_format == client_api_format;
let conversion_kind = request_conversion_kind(spec_metadata.api_format, provider_api_format); let conversion_kind = request_conversion_kind(spec_metadata.api_format, provider_api_format);
let transport_unsupported_reason = if same_format { let transport_unsupported_reason = if same_format && is_kiro_claude_cli {
local_kiro_request_transport_unsupported_reason_with_network(transport)
} else if same_format {
local_standard_transport_unsupported_reason_with_network(transport, provider_api_format) local_standard_transport_unsupported_reason_with_network(transport, provider_api_format)
} else { } else {
match conversion_kind { match conversion_kind {
@@ -106,7 +122,41 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
return None; return None;
} }
let direct_auth = if same_format { let oauth_context = OauthPreparationContext {
trace_id,
api_format: provider_api_format,
operation: "openai_cli_candidate_request",
};
let kiro_auth = if is_kiro_claude_cli {
match crate::ai_pipeline::planner::candidate_preparation::resolve_candidate_oauth_auth(
planner_state,
transport,
oauth_context,
)
.await
{
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
_ => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_auth_unavailable",
)
.await;
return None;
}
}
} else {
None
};
let direct_auth = if kiro_auth.is_some() {
None
} else if same_format {
match provider_api_format { match provider_api_format {
"gemini:cli" => resolve_local_gemini_auth(transport), "gemini:cli" => resolve_local_gemini_auth(transport),
"claude:cli" => resolve_local_standard_auth(transport), "claude:cli" => resolve_local_standard_auth(transport),
@@ -116,32 +166,55 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
} else { } else {
conversion_kind.and_then(|kind| request_conversion_direct_auth(transport, kind)) conversion_kind.and_then(|kind| request_conversion_direct_auth(transport, kind))
}; };
let prepared_candidate = match prepare_header_authenticated_candidate( let prepared_candidate = if let Some(kiro_auth) = kiro_auth.as_ref() {
planner_state, let mapped_model =
transport, match crate::ai_pipeline::planner::candidate_preparation::resolve_candidate_mapped_model(
candidate,
direct_auth,
OauthPreparationContext {
trace_id,
api_format: provider_api_format,
operation: "openai_cli_candidate_request",
},
)
.await
{
Ok(prepared) => prepared,
Err(skip_reason) => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
candidate, candidate,
candidate_index, ) {
candidate_id, Ok(mapped_model) => mapped_model,
skip_reason, Err(skip_reason) => {
) mark_skipped_local_openai_cli_candidate(
.await; state,
return None; input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return None;
}
};
crate::ai_pipeline::planner::candidate_preparation::PreparedHeaderAuthenticatedCandidate {
auth_header: kiro_auth.name.to_string(),
auth_value: kiro_auth.value.clone(),
mapped_model,
}
} else {
match prepare_header_authenticated_candidate(
planner_state,
transport,
candidate,
direct_auth,
oauth_context,
)
.await
{
Ok(prepared) => prepared,
Err(skip_reason) => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return None;
}
} }
}; };
let auth_header = prepared_candidate.auth_header; let auth_header = prepared_candidate.auth_header;
@@ -163,7 +236,11 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
provider_api_format, provider_api_format,
upstream_is_stream, upstream_is_stream,
transport.provider.provider_type.as_str(), transport.provider.provider_type.as_str(),
transport.endpoint.body_rules.as_ref(), if is_kiro_claude_cli {
None
} else {
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()), Some(input.auth_context.api_key_id.as_str()),
) )
} else { } else {
@@ -173,7 +250,11 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
upstream_is_stream, upstream_is_stream,
transport.provider.provider_type.as_str(), transport.provider.provider_type.as_str(),
provider_api_format, provider_api_format,
transport.endpoint.body_rules.as_ref(), if is_kiro_claude_cli {
None
} else {
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()), Some(input.auth_context.api_key_id.as_str()),
) )
}) else { }) else {
@@ -240,6 +321,30 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
base_provider_request_body base_provider_request_body
}; };
if let Some(kiro_auth) = kiro_auth.as_ref() {
return build_kiro_openai_cli_payload_parts(
state,
parts,
trace_id,
body_json,
input,
eligible,
candidate_index,
candidate_id,
spec_metadata.api_format,
transport,
provider_api_format,
mapped_model,
auth_header,
auth_value,
provider_request_body,
upstream_is_stream,
needs_bidirectional_conversion,
kiro_auth,
)
.await;
}
let Some(upstream_url) = (if needs_bidirectional_conversion { let Some(upstream_url) = (if needs_bidirectional_conversion {
build_cross_format_openai_cli_upstream_url( build_cross_format_openai_cli_upstream_url(
parts, parts,
@@ -392,6 +497,149 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
conversion_mode, conversion_mode,
is_antigravity: is_antigravity is_antigravity: is_antigravity
|| antigravity_auth.is_some() && ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal", || antigravity_auth.is_some() && ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal",
envelope_name: if is_antigravity || antigravity_auth.is_some() {
Some(ANTIGRAVITY_ENVELOPE_NAME)
} else {
None
},
upstream_is_stream,
transport: Arc::clone(transport),
})
}
#[allow(clippy::too_many_arguments)]
async fn build_kiro_openai_cli_payload_parts(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
original_body_json: &serde_json::Value,
input: &LocalOpenAiCliDecisionInput,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
client_api_format: &str,
transport: &Arc<GatewayProviderTransportSnapshot>,
provider_api_format: &str,
mapped_model: String,
auth_header: String,
auth_value: String,
claude_request_body: Value,
upstream_is_stream: bool,
needs_bidirectional_conversion: bool,
kiro_auth: &KiroRequestAuth,
) -> Option<LocalOpenAiCliCandidatePayloadParts> {
let candidate = &eligible.candidate;
let provider_request_body = match build_kiro_provider_request_body(
&claude_request_body,
&mapped_model,
&kiro_auth.auth_config,
transport.endpoint.body_rules.as_ref(),
) {
Some(body) => body,
None => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_missing",
)
.await;
return None;
}
};
let upstream_url = match crate::ai_pipeline::build_provider_transport_request_url(
transport,
provider_api_format,
Some(&mapped_model),
upstream_is_stream,
parts.uri.query(),
Some(kiro_auth.auth_config.effective_api_region()),
) {
Some(url) => url,
None => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"upstream_url_missing",
)
.await;
return None;
}
};
let provider_request_headers = match build_kiro_provider_headers(KiroProviderHeadersInput {
headers: &parts.headers,
provider_request_body: &provider_request_body,
original_request_body: original_body_json,
header_rules: transport.endpoint.header_rules.as_ref(),
auth_header: &auth_header,
auth_value: &auth_value,
auth_config: &kiro_auth.auth_config,
machine_id: kiro_auth.machine_id.as_str(),
}) {
Some(headers) => headers,
None => {
mark_skipped_local_openai_cli_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
)
.await;
return None;
}
};
let execution_strategy = if needs_bidirectional_conversion {
ExecutionStrategy::LocalCrossFormat
} else {
ExecutionStrategy::LocalSameFormat
};
let conversion_mode = if needs_bidirectional_conversion {
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
debug!(
event_name = "local_openai_cli_kiro_upstream_url_resolved",
log_type = "debug",
trace_id = %trace_id,
candidate_id = %candidate_id,
candidate_index,
provider_id = %candidate.provider_id,
endpoint_id = %candidate.endpoint_id,
key_id = %candidate.key_id,
provider_type = %transport.provider.provider_type,
client_api_format = client_api_format,
provider_api_format = %provider_api_format,
execution_strategy = execution_strategy.as_str(),
conversion_mode = conversion_mode.as_str(),
upstream_url = %upstream_url,
upstream_is_stream,
"gateway resolved local openai cli kiro upstream url"
);
Some(LocalOpenAiCliCandidatePayloadParts {
auth_header,
auth_value,
mapped_model,
provider_api_format: provider_api_format.to_string(),
provider_request_body,
provider_request_headers,
upstream_url,
execution_strategy,
conversion_mode,
is_antigravity: false,
envelope_name: Some(KIRO_ENVELOPE_NAME),
upstream_is_stream, upstream_is_stream,
transport: Arc::clone(transport), transport: Arc::clone(transport),
}) })

View File

@@ -3,6 +3,7 @@
use aether_provider_transport::auth::{ use aether_provider_transport::auth::{
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_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::{ use aether_provider_transport::policy::{
local_gemini_transport_unsupported_reason_with_network, local_gemini_transport_unsupported_reason_with_network,
local_openai_chat_transport_unsupported_reason, 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() { if request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str()).is_none() {
return false; 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( request_conversion_enabled_for_transport(
transport, transport,
client_api_format.as_str(), client_api_format.as_str(),
@@ -240,6 +255,20 @@ pub fn request_conversion_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind, _kind: RequestConversionKind,
) -> Option<&'static str> { ) -> 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 match transport
.endpoint .endpoint
.api_format .api_format
@@ -898,4 +927,68 @@ mod tests {
Some(("key".to_string(), "vertex-secret".to_string())) 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 { pub struct OpenAICliClientEmitter {
response_id: Option<String>, response_id: Option<String>,
model: Option<String>, model: Option<String>,
message_item_id: Option<String>,
reasoning_item_id: Option<String>,
started: bool, started: bool,
finished: bool, finished: bool,
sequence_number: u64, sequence_number: u64,
@@ -1166,11 +1168,29 @@ impl OpenAICliClientEmitter {
} }
fn message_item_id(&self) -> String { 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 { 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 { fn in_progress_response(&self) -> Value {
@@ -1269,6 +1289,7 @@ impl OpenAICliClientEmitter {
fn ensure_reasoning_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> { fn ensure_reasoning_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = self.ensure_started()?; let mut out = self.ensure_started()?;
let output_index = self.ensure_reasoning_output_index(); let output_index = self.ensure_reasoning_output_index();
let item_id = self.ensure_reasoning_item_id();
if !self.reasoning_item_started { if !self.reasoning_item_started {
out.extend(self.encode_response_event( out.extend(self.encode_response_event(
"response.output_item.added", "response.output_item.added",
@@ -1278,7 +1299,7 @@ impl OpenAICliClientEmitter {
"output_index": output_index, "output_index": output_index,
"item": { "item": {
"type": "reasoning", "type": "reasoning",
"id": self.reasoning_item_id(), "id": item_id.clone(),
"summary": [], "summary": [],
} }
}), }),
@@ -1291,7 +1312,7 @@ impl OpenAICliClientEmitter {
json!({ json!({
"type": "response.reasoning_summary_part.added", "type": "response.reasoning_summary_part.added",
"response_id": self.response_id(), "response_id": self.response_id(),
"item_id": self.reasoning_item_id(), "item_id": item_id,
"output_index": output_index, "output_index": output_index,
"summary_index": 0, "summary_index": 0,
"part": { "part": {
@@ -1308,8 +1329,8 @@ impl OpenAICliClientEmitter {
fn ensure_text_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> { fn ensure_text_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = self.ensure_started()?; let mut out = self.ensure_started()?;
let output_index = self.ensure_message_output_index(); let output_index = self.ensure_message_output_index();
let item_id = self.ensure_message_item_id();
if !self.text_item_started { if !self.text_item_started {
let item_id = self.message_item_id();
out.extend(self.encode_response_event( out.extend(self.encode_response_event(
"response.output_item.added", "response.output_item.added",
json!({ json!({
@@ -1318,7 +1339,7 @@ impl OpenAICliClientEmitter {
"output_index": output_index, "output_index": output_index,
"item": { "item": {
"type": "message", "type": "message",
"id": item_id, "id": item_id.clone(),
"status": "in_progress", "status": "in_progress",
"role": "assistant", "role": "assistant",
"content": [], "content": [],
@@ -1328,7 +1349,6 @@ impl OpenAICliClientEmitter {
self.text_item_started = true; self.text_item_started = true;
} }
if !self.text_part_started { if !self.text_part_started {
let item_id = self.message_item_id();
out.extend(self.encode_response_event( out.extend(self.encode_response_event(
"response.content_part.added", "response.content_part.added",
json!({ json!({
@@ -1849,6 +1869,31 @@ mod tests {
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>()); 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] #[test]
fn openai_cli_provider_state_accepts_done_events_without_deltas() { fn openai_cli_provider_state_accepts_done_events_without_deltas() {
let mut state = OpenAICliProviderState::default(); let mut state = OpenAICliProviderState::default();

View File

@@ -10,6 +10,7 @@ pub enum FinalizeStreamRewriteMode {
OpenAiImage, OpenAiImage,
Standard, Standard,
KiroToClaudeCli, KiroToClaudeCli,
KiroToClaudeCliThenStandard,
} }
pub fn resolve_finalize_stream_rewrite_mode( pub fn resolve_finalize_stream_rewrite_mode(
@@ -38,6 +39,17 @@ pub fn resolve_finalize_stream_rewrite_mode(
.trim() .trim()
.to_ascii_lowercase(); .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 { if needs_conversion {
return supports_standard_stream_rewrite( return supports_standard_stream_rewrite(
provider_api_format.as_str(), provider_api_format.as_str(),