refactor: fold ai surfaces into formats

This commit is contained in:
fawney19
2026-05-02 18:19:39 +08:00
parent bfd1ea72b3
commit 47ee8b9c13
114 changed files with 600 additions and 622 deletions

View File

@@ -0,0 +1,204 @@
pub use crate::contracts::{
core_error_background_report_kind, core_error_default_client_api_format,
core_success_background_report_kind, implicit_sync_finalize_report_kind,
is_openai_responses_stream_plan_kind, is_openai_responses_sync_plan_kind, AiControlPlanRequest,
ExecutionRuntimeAuthContext, CLAUDE_CHAT_STREAM_PLAN_KIND,
CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND,
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND, GEMINI_CHAT_SYNC_ERROR_REPORT_KIND,
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND,
OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND,
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND,
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND,
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
pub use crate::provider_compat::kiro_stream::{
build_kiro_final_message_sse_events, build_kiro_initial_sse_events,
build_kiro_stream_error_sse_events, calculate_kiro_context_input_tokens,
encode_kiro_sse_events, estimate_kiro_tokens, find_kiro_real_thinking_end_tag,
find_kiro_real_thinking_end_tag_at_buffer_end, find_kiro_real_thinking_start_tag, kiro_crc32,
KiroToClaudeCliStreamState, KIRO_MAX_THINKING_BUFFER,
};
pub use crate::provider_compat::private_envelope::{
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
stream_body_contains_error_event, transform_provider_private_stream_line,
ProviderPrivateStreamNormalizer,
};
pub use crate::provider_compat::surfaces::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope, ProviderAdaptationDescriptor,
ProviderAdaptationSurface, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
};
pub use crate::request::common::{
force_upstream_streaming_for_provider, parse_direct_request_body,
};
pub use crate::request::matrix::build_standard_request_body_from_canonical;
pub use crate::request::openai::{
copy_request_number_field, copy_request_number_field_as,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
};
pub use crate::request::passthrough::provider::{
resolve_stream_spec as resolve_local_same_format_stream_spec,
resolve_sync_spec as resolve_local_same_format_sync_spec, LocalSameFormatProviderFamily,
LocalSameFormatProviderSpec,
};
pub use crate::request::route::{
is_matching_stream_http_request, is_matching_stream_request,
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
supports_stream_execution_decision_kind, supports_sync_execution_decision_kind,
};
pub use crate::request::specialized::{
files::{
resolve_stream_spec as resolve_gemini_files_stream_spec,
resolve_sync_spec as resolve_gemini_files_sync_spec, LocalGeminiFilesSpec,
},
image::{
build_openai_image_provider_request_body, default_model_for_openai_image_operation,
is_openai_image_stream_request, normalize_openai_image_request,
openai_image_operation_from_path, resolve_requested_openai_image_model_for_request,
resolve_stream_spec as resolve_local_image_stream_spec,
resolve_sync_spec as resolve_local_image_sync_spec, LocalOpenAiImageSpec,
NormalizedOpenAiImageRequest, OpenAiImageOperation, OpenAiImageResponseFormat,
},
video::{
resolve_sync_spec as resolve_local_video_sync_spec, LocalVideoCreateFamily,
LocalVideoCreateSpec,
},
};
pub use crate::request::standard::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
apply_openai_responses_compact_special_body_edits, build_cross_format_openai_chat_request_body,
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
build_local_openai_responses_request_body, build_standard_request_body,
claude::{
resolve_stream_spec as resolve_claude_stream_spec,
resolve_sync_spec as resolve_claude_sync_spec,
},
gemini::{
resolve_stream_spec as resolve_gemini_stream_spec,
resolve_sync_spec as resolve_gemini_sync_spec,
},
normalize_standard_request_to_openai_chat_request,
openai_responses::{
resolve_stream_spec as resolve_openai_responses_stream_spec,
resolve_sync_spec as resolve_openai_responses_sync_spec, LocalOpenAiResponsesSpec,
},
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
CODEX_OPENAI_IMAGE_DEFAULT_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT,
CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
};
pub use crate::response::common::{
build_generated_tool_call_id, build_local_success_background_report,
build_local_success_conversion_background_report, canonicalize_tool_arguments,
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
LocalSyncReportParts,
};
pub use crate::response::error_body::{
build_core_error_body_for_client_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
pub use crate::response::openai_image_stream::{
maybe_build_openai_image_sync_finalize_product, OpenAiImageStreamState,
OpenAiImageSyncFinalizeProduct,
};
pub use crate::response::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
pub use crate::response::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
pub use crate::response::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
pub use crate::response::standard::openai::stream::{
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
OpenAIResponsesProviderState,
};
pub use crate::response::standard::stream_core::common::*;
pub use crate::response::standard::stream_core::{
CanonicalStreamFrame, StreamingStandardFormatMatrix, StreamingStandardTerminalObserver,
};
pub use crate::response::sync_products::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_chat_stream_sync_response, aggregate_openai_responses_stream_sync_response,
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
convert_standard_chat_response, convert_standard_cli_response,
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload,
maybe_build_standard_cross_format_sync_product,
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
maybe_build_standard_same_format_sync_body_from_normalized_payload,
maybe_build_standard_sync_finalize_product_from_normalized_payload,
StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
};
pub use crate::response::sync_to_stream::{
maybe_bridge_standard_sync_json_to_stream, SyncToStreamBridgeOutcome,
};
pub use crate::response::{
maybe_build_ai_surface_stream_rewriter, resolve_finalize_stream_rewrite_mode,
AiSurfaceFinalizeError, AiSurfaceStreamRewriter, FinalizeStreamRewriteMode,
};
pub use aether_ai_formats::protocol::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request, extract_openai_text_content,
normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub use aether_ai_formats::protocol::conversion::response::{
build_openai_responses_response, build_openai_responses_response_with_content,
build_openai_responses_response_with_reasoning, convert_claude_chat_response_to_openai_chat,
convert_claude_response_to_openai_responses, convert_gemini_chat_response_to_openai_chat,
convert_gemini_response_to_openai_responses, convert_openai_chat_response_to_claude_chat,
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_responses,
convert_openai_responses_response_to_openai_chat, OpenAiResponsesResponseUsage,
};
pub use aether_ai_formats::{
api_format_alias_matches, api_format_storage_aliases, is_openai_responses_compact_format,
is_openai_responses_family_format, is_openai_responses_format, normalize_api_format_alias,
};
pub use aether_ai_formats::{
canonical_request_unknown_block_count, canonical_response_unknown_block_count,
canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request,
canonical_to_gemini_response, canonical_to_openai_chat_request,
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
canonical_to_openai_responses_response, canonical_unknown_block_count, convert_request,
convert_response, from_claude_to_canonical_request, from_claude_to_canonical_response,
from_gemini_to_canonical_request, from_gemini_to_canonical_response,
from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response,
from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response,
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_kind, request_conversion_requires_enable_flag,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, CanonicalContentBlock,
CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage, CanonicalRequest,
CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
CanonicalUsage, FormatContext, FormatError, FormatFamily, FormatId, FormatProfile,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};

View File

@@ -0,0 +1,4 @@
pub const EXECUTION_RUNTIME_SYNC_ACTION: &str = "execution_runtime_sync";
pub const EXECUTION_RUNTIME_SYNC_DECISION_ACTION: &str = "execution_runtime_sync_decision";
pub const EXECUTION_RUNTIME_STREAM_ACTION: &str = "execution_runtime_stream";
pub const EXECUTION_RUNTIME_STREAM_DECISION_ACTION: &str = "execution_runtime_stream_decision";

View File

@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExecutionRuntimeAuthContext {
pub user_id: String,
pub api_key_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_name: Option<String>,
pub balance_remaining: Option<f64>,
pub access_allowed: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub api_key_is_standalone: bool,
}

View File

@@ -0,0 +1,88 @@
use std::collections::BTreeMap;
use serde::Serialize;
use crate::contracts::ExecutionRuntimeAuthContext;
#[derive(Debug, Serialize)]
pub struct AiControlPlanRequest {
pub trace_id: String,
pub method: String,
pub path: String,
pub query_string: Option<String>,
pub headers: BTreeMap<String, String>,
pub body_json: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_base64: Option<String>,
pub auth_context: Option<ExecutionRuntimeAuthContext>,
}
#[allow(clippy::too_many_arguments)]
pub fn build_ai_control_plan_request(
trace_id: &str,
method: &str,
path: &str,
query_string: Option<&str>,
headers: BTreeMap<String, String>,
body_json: serde_json::Value,
body_base64: Option<String>,
auth_context: Option<ExecutionRuntimeAuthContext>,
) -> AiControlPlanRequest {
AiControlPlanRequest {
trace_id: trace_id.to_string(),
method: method.to_string(),
path: path.to_string(),
query_string: query_string.map(ToOwned::to_owned),
headers,
body_json,
body_base64,
auth_context,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{build_ai_control_plan_request, ExecutionRuntimeAuthContext};
#[test]
fn build_ai_control_plan_request_preserves_request_shape() {
let payload = build_ai_control_plan_request(
"trace-123",
"POST",
"/v1/chat/completions",
Some("stream=true"),
BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
serde_json::json!({"model": "gpt-5"}),
Some("eyJmb28iOiJiYXIifQ==".to_string()),
Some(ExecutionRuntimeAuthContext {
user_id: "user-1".to_string(),
api_key_id: "key-1".to_string(),
username: None,
api_key_name: None,
balance_remaining: Some(12.5),
access_allowed: true,
api_key_is_standalone: false,
}),
);
assert_eq!(payload.trace_id, "trace-123");
assert_eq!(payload.method, "POST");
assert_eq!(payload.path, "/v1/chat/completions");
assert_eq!(payload.query_string.as_deref(), Some("stream=true"));
assert_eq!(
payload.headers.get("content-type").map(String::as_str),
Some("application/json")
);
assert_eq!(payload.body_json, serde_json::json!({"model": "gpt-5"}));
assert_eq!(payload.body_base64.as_deref(), Some("eyJmb28iOiJiYXIifQ=="));
assert_eq!(
payload
.auth_context
.as_ref()
.map(|ctx| ctx.user_id.as_str()),
Some("user-1")
);
}
}

View File

@@ -0,0 +1,49 @@
mod actions;
mod auth_context;
mod control_payloads;
mod plan_kinds;
mod report_kinds;
pub use actions::{
EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
pub use auth_context::ExecutionRuntimeAuthContext;
pub use control_payloads::{build_ai_control_plan_request, AiControlPlanRequest};
pub use plan_kinds::{
is_openai_responses_stream_plan_kind, is_openai_responses_sync_plan_kind,
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
pub use report_kinds::{
core_error_background_report_kind, core_error_default_client_api_format,
core_success_background_report_kind, implicit_stream_success_report_kind,
implicit_sync_finalize_report_kind, CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND,
CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND, CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND,
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND,
CLAUDE_CLI_SYNC_ERROR_REPORT_KIND, CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND,
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND,
GEMINI_CHAT_SYNC_ERROR_REPORT_KIND, GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND,
GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND,
GEMINI_CLI_SYNC_ERROR_REPORT_KIND, GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND,
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND, OPENAI_CHAT_SYNC_ERROR_REPORT_KIND,
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND, OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND,
OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND, OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND,
OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
};

View File

@@ -0,0 +1,42 @@
pub const GEMINI_FILES_GET_PLAN_KIND: &str = "gemini_files_get";
pub const GEMINI_FILES_UPLOAD_PLAN_KIND: &str = "gemini_files_upload";
pub const GEMINI_FILES_LIST_PLAN_KIND: &str = "gemini_files_list";
pub const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete";
pub const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download";
pub const OPENAI_IMAGE_STREAM_PLAN_KIND: &str = "openai_image_stream";
pub const OPENAI_IMAGE_SYNC_PLAN_KIND: &str = "openai_image_sync";
pub const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
pub const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
pub const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";
pub const OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND: &str = "openai_video_delete_sync";
pub const GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "gemini_video_create_sync";
pub const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
pub const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
pub const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
pub const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
pub const OPENAI_RESPONSES_STREAM_PLAN_KIND: &str = "openai_responses_stream";
pub const OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND: &str = "openai_responses_compact_stream";
pub const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
pub const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
pub const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
pub const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
pub const OPENAI_RESPONSES_SYNC_PLAN_KIND: &str = "openai_responses_sync";
pub const OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND: &str = "openai_responses_compact_sync";
pub const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync";
pub const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync";
pub const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync";
pub const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync";
pub fn is_openai_responses_stream_plan_kind(plan_kind: &str) -> bool {
matches!(
plan_kind,
OPENAI_RESPONSES_STREAM_PLAN_KIND | OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
)
}
pub fn is_openai_responses_sync_plan_kind(plan_kind: &str) -> bool {
matches!(
plan_kind,
OPENAI_RESPONSES_SYNC_PLAN_KIND | OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
)
}

View File

@@ -0,0 +1,133 @@
use crate::contracts::{
CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CLI_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
OPENAI_RESPONSES_SYNC_PLAN_KIND,
};
pub const OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_chat_sync_finalize";
pub const CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "claude_chat_sync_finalize";
pub const GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_chat_sync_finalize";
pub const OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND: &str = "openai_responses_sync_finalize";
pub const OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str =
"openai_responses_compact_sync_finalize";
pub const OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_image_sync_finalize";
pub const CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "claude_cli_sync_finalize";
pub const GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_cli_sync_finalize";
pub const OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_video_create_sync_finalize";
pub const GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_video_create_sync_finalize";
const LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "openai_cli_sync_finalize";
const LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sync_finalize";
pub const OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_chat_sync_success";
pub const CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "claude_chat_sync_success";
pub const GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_chat_sync_success";
pub const OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND: &str = "openai_responses_sync_success";
pub const OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND: &str =
"openai_responses_compact_sync_success";
pub const OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND: &str = "openai_image_sync_success";
pub const CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "claude_cli_sync_success";
pub const GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_cli_sync_success";
pub const OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "openai_chat_stream_success";
pub const CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "claude_chat_stream_success";
pub const GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_chat_stream_success";
pub const OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND: &str = "openai_responses_stream_success";
pub const OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND: &str =
"openai_responses_compact_stream_success";
pub const OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND: &str = "openai_image_stream_success";
pub const CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "claude_cli_stream_success";
pub const GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_cli_stream_success";
pub const OPENAI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "openai_chat_sync_error";
pub const CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND: &str = "claude_chat_sync_error";
pub const GEMINI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "gemini_chat_sync_error";
pub const OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND: &str = "openai_responses_sync_error";
pub const OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND: &str =
"openai_responses_compact_sync_error";
pub const CLAUDE_CLI_SYNC_ERROR_REPORT_KIND: &str = "claude_cli_sync_error";
pub const GEMINI_CLI_SYNC_ERROR_REPORT_KIND: &str = "gemini_cli_sync_error";
pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static str> {
match plan_kind {
OPENAI_CHAT_SYNC_PLAN_KIND => Some(OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND),
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND),
GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND),
OPENAI_RESPONSES_SYNC_PLAN_KIND => Some(OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND),
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => {
Some(OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND)
}
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND),
CLAUDE_CLI_SYNC_PLAN_KIND => Some(CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND),
GEMINI_CLI_SYNC_PLAN_KIND => Some(GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND),
_ => None,
}
}
pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
match report_kind {
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("openai:chat"),
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("claude:messages"),
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:generate_content"),
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"),
LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"),
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some("openai:image"),
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some("claude:messages"),
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("gemini:generate_content"),
_ => None,
}
}
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
match report_kind {
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_ERROR_REPORT_KIND),
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND),
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_ERROR_REPORT_KIND),
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND),
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND)
}
LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND)
}
LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND)
}
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_ERROR_REPORT_KIND),
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_ERROR_REPORT_KIND),
_ => None,
}
}
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
match report_kind {
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND),
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND),
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND),
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND),
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND)
}
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND)
}
LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND)
}
LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND)
}
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND),
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND),
_ => None,
}
}
pub fn implicit_stream_success_report_kind(plan_kind: &str) -> Option<&'static str> {
match plan_kind {
OPENAI_IMAGE_STREAM_PLAN_KIND => Some(OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND),
_ => None,
}
}

View File

@@ -1,14 +1,13 @@
pub mod canonical;
pub mod context;
pub mod conversion;
pub mod formats;
pub mod matrix;
pub mod planner;
pub mod proxy;
pub mod registry;
pub mod stream;
extern crate self as aether_ai_formats;
pub use canonical::{
pub mod api;
pub mod contracts;
pub mod protocol;
pub mod provider_compat;
pub mod request;
pub mod response;
pub use protocol::canonical::{
canonical_request_unknown_block_count, canonical_response_unknown_block_count,
canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request,
canonical_to_gemini_response, canonical_to_openai_chat_request,
@@ -24,16 +23,16 @@ pub use canonical::{
CanonicalRole, CanonicalStopReason, CanonicalStreamEvent, CanonicalStreamFrame,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
};
pub use context::{FormatContext, FormatError};
pub use formats::{
pub use protocol::context::{FormatContext, FormatError};
pub use protocol::formats::{
api_format_alias_matches, api_format_storage_aliases, is_openai_responses_compact_format,
is_openai_responses_family_format, is_openai_responses_format, normalize_api_format_alias,
FormatFamily, FormatId, FormatProfile,
};
pub use matrix::{
pub use protocol::matrix::{
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_kind, request_conversion_requires_enable_flag,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
pub use registry::{build_stream_transcoder, convert_request, convert_response};
pub use protocol::registry::{build_stream_transcoder, convert_request, convert_response};

View File

@@ -1 +0,0 @@
pub mod openai;

View File

@@ -3,9 +3,9 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::planner::openai::map_thinking_budget_to_openai_reasoning_effort;
use crate::request::openai::map_thinking_budget_to_openai_reasoning_effort;
pub use crate::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses";
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
@@ -293,15 +293,15 @@ pub struct CanonicalResponse {
}
pub fn from_openai_chat_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
crate::formats::openai_chat::request::from_raw(body_json)
crate::protocol::formats::openai_chat::request::from_raw(body_json)
}
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Value {
crate::formats::openai_chat::request::to_raw(canonical)
crate::protocol::formats::openai_chat::request::to_raw(canonical)
}
pub fn from_openai_responses_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
crate::formats::openai_responses::request::from_raw(body_json)
crate::protocol::formats::openai_responses::request::from_raw(body_json)
}
pub(crate) fn canonical_to_openai_responses_request_with_profile(
@@ -310,7 +310,7 @@ pub(crate) fn canonical_to_openai_responses_request_with_profile(
upstream_is_stream: bool,
compact: bool,
) -> Option<Value> {
crate::formats::openai_responses::request::to_raw(
crate::protocol::formats::openai_responses::request::to_raw(
canonical,
mapped_model,
upstream_is_stream,
@@ -339,7 +339,7 @@ pub fn canonical_to_openai_responses_compact_request(
}
pub fn from_claude_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
crate::formats::claude_messages::request::from_raw(body_json)
crate::protocol::formats::claude_messages::request::from_raw(body_json)
}
pub fn canonical_to_claude_request(
@@ -347,14 +347,18 @@ pub fn canonical_to_claude_request(
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
crate::formats::claude_messages::request::to_raw(canonical, mapped_model, upstream_is_stream)
crate::protocol::formats::claude_messages::request::to_raw(
canonical,
mapped_model,
upstream_is_stream,
)
}
pub fn from_gemini_to_canonical_request(
body_json: &Value,
request_path: &str,
) -> Option<CanonicalRequest> {
crate::formats::gemini_generate_content::request::from_raw(body_json, request_path)
crate::protocol::formats::gemini_generate_content::request::from_raw(body_json, request_path)
}
pub fn canonical_to_gemini_request(
@@ -362,7 +366,7 @@ pub fn canonical_to_gemini_request(
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
crate::formats::gemini_generate_content::request::to_raw(
crate::protocol::formats::gemini_generate_content::request::to_raw(
canonical,
mapped_model,
upstream_is_stream,
@@ -370,23 +374,23 @@ pub fn canonical_to_gemini_request(
}
pub fn from_openai_chat_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
crate::formats::openai_chat::response::from_raw(body_json)
crate::protocol::formats::openai_chat::response::from_raw(body_json)
}
pub fn from_openai_responses_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
crate::formats::openai_responses::response::from_raw(body_json)
crate::protocol::formats::openai_responses::response::from_raw(body_json)
}
pub fn from_claude_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
crate::formats::claude_messages::response::from_raw(body_json)
crate::protocol::formats::claude_messages::response::from_raw(body_json)
}
pub fn from_gemini_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
crate::formats::gemini_generate_content::response::from_raw(body_json)
crate::protocol::formats::gemini_generate_content::response::from_raw(body_json)
}
pub fn canonical_to_openai_chat_response(canonical: &CanonicalResponse) -> Value {
crate::formats::openai_chat::response::to_raw(canonical)
crate::protocol::formats::openai_chat::response::to_raw(canonical)
}
pub(crate) fn canonical_blocks_to_openai_chat_message(content: &[CanonicalContentBlock]) -> Value {
@@ -524,7 +528,7 @@ pub(crate) fn canonical_to_openai_responses_response_with_profile(
report_context: &Value,
compact: bool,
) -> Value {
crate::formats::openai_responses::response::to_raw(canonical, report_context, compact)
crate::protocol::formats::openai_responses::response::to_raw(canonical, report_context, compact)
}
pub fn canonical_to_openai_responses_response(
@@ -542,14 +546,14 @@ pub fn canonical_to_openai_responses_compact_response(
}
pub fn canonical_to_claude_response(canonical: &CanonicalResponse) -> Value {
crate::formats::claude_messages::response::to_raw(canonical)
crate::protocol::formats::claude_messages::response::to_raw(canonical)
}
pub fn canonical_to_gemini_response(
canonical: &CanonicalResponse,
report_context: &Value,
) -> Option<Value> {
crate::formats::gemini_generate_content::response::to_raw(canonical, report_context)
crate::protocol::formats::gemini_generate_content::response::to_raw(canonical, report_context)
}
pub fn canonical_unknown_block_count(blocks: &[CanonicalContentBlock]) -> usize {

View File

@@ -6,7 +6,7 @@
use serde_json::Value;
use crate::{context::FormatContext, registry};
use crate::protocol::{context::FormatContext, registry};
pub fn convert_openai_chat_request_to_claude_request(
body_json: &Value,

View File

@@ -6,7 +6,7 @@
use serde_json::{json, Value};
use crate::{context::FormatContext, registry};
use crate::protocol::{context::FormatContext, registry};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenAiResponsesResponseUsage {

View File

@@ -1,7 +1,7 @@
use serde_json::{json, Map, Value};
use crate::{
canonical::{
protocol::canonical::{
canonical_extension_object_mut, canonical_instructions_to_claude_system,
canonical_messages_to_claude, canonical_openai_reasoning_effort,
canonical_tool_choice_to_claude, canonical_tools_to_claude, claude_extensions,
@@ -11,8 +11,8 @@ use crate::{
compact_canonical_claude_messages, insert_f64, namespace_extension_object,
CanonicalRequest,
},
context::FormatContext,
planner::openai::{
protocol::context::FormatContext,
request::openai::{
map_openai_reasoning_effort_to_claude_output,
map_openai_reasoning_effort_to_thinking_budget,
},

View File

@@ -3,13 +3,13 @@ use std::collections::BTreeMap;
use serde_json::{json, Value};
use crate::{
canonical::{
protocol::canonical::{
canonical_blocks_to_claude, canonical_stop_reason_to_claude, canonical_usage_to_claude,
claude_content_to_canonical_blocks, claude_extensions, claude_stop_reason_to_canonical,
claude_usage_to_canonical, namespace_extension_object, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole,
},
context::FormatContext,
protocol::context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::{
canonical::{
protocol::canonical::{
apply_gemini_request_extensions, canonical_extension_object_mut,
canonical_openai_reasoning_effort, extract_gemini_model_from_path,
gemini_contents_to_canonical_messages, gemini_extensions, gemini_generation_config,
@@ -14,8 +14,8 @@ use crate::{
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
planner::openai::map_openai_reasoning_effort_to_gemini_budget,
protocol::context::FormatContext,
request::openai::map_openai_reasoning_effort_to_gemini_budget,
};
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {

View File

@@ -1,13 +1,13 @@
use serde_json::{json, Map, Value};
use crate::{
canonical::{
protocol::canonical::{
canonical_extension_object_mut, gemini_extensions, gemini_part_to_canonical_block,
gemini_stop_reason_to_canonical, gemini_usage_to_canonical, CanonicalContentBlock,
CanonicalResponse, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
CanonicalUsage,
},
context::FormatContext,
protocol::context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
@@ -134,7 +134,7 @@ fn canonical_to_gemini_response(
let outputs = if canonical.outputs.is_empty() {
vec![CanonicalResponseOutput {
index: 0,
role: crate::canonical::CanonicalRole::Assistant,
role: crate::protocol::canonical::CanonicalRole::Assistant,
content: canonical.content.clone(),
stop_reason: canonical.stop_reason.clone(),
extensions: Default::default(),

View File

@@ -1,7 +1,7 @@
use serde_json::{json, Value};
use crate::{
canonical::{
protocol::canonical::{
canonical_extension_object_mut, canonical_message_to_openai_chat,
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
canonical_tool_to_openai, namespace_extension_object, openai_content_text,
@@ -11,7 +11,7 @@ use crate::{
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
protocol::context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
@@ -61,14 +61,16 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
}
continue;
}
canonical.messages.push(crate::canonical::CanonicalMessage {
role,
content: openai_message_content_blocks(message_object)?,
extensions: openai_extensions(
message_object,
&["role", "content", "tool_calls", "tool_call_id"],
),
});
canonical
.messages
.push(crate::protocol::canonical::CanonicalMessage {
role,
content: openai_message_content_blocks(message_object)?,
extensions: openai_extensions(
message_object,
&["role", "content", "tool_calls", "tool_call_id"],
),
});
}
}

View File

@@ -3,14 +3,14 @@ use std::collections::BTreeMap;
use serde_json::{json, Value};
use crate::{
canonical::{
protocol::canonical::{
canonical_blocks_to_openai_chat_message, canonical_stop_reason_to_openai,
canonical_usage_to_openai, openai_extensions, openai_finish_reason_to_canonical,
openai_message_content_blocks, openai_usage_to_canonical, CanonicalContentBlock,
CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
protocol::context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {

View File

@@ -1,7 +1,7 @@
use serde_json::{json, Map, Value};
use crate::{
canonical::{
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments, media_data_or_url,
namespace_extension_object, openai_content_text, openai_extensions,
openai_response_format_to_canonical, openai_responses_extension,
@@ -11,8 +11,8 @@ use crate::{
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
planner::openai::map_thinking_budget_to_openai_reasoning_effort,
protocol::context::FormatContext,
request::openai::map_thinking_budget_to_openai_reasoning_effort,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::{
canonical::{
protocol::canonical::{
canonical_content_block_to_openai_responses_part,
canonical_usage_to_openai_responses_usage, canonicalize_tool_arguments,
flush_openai_responses_message_item, namespace_extension_object,
@@ -12,7 +12,7 @@ use crate::{
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
protocol::context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {

View File

@@ -1,6 +1,6 @@
use crate::{
api_format_alias_matches,
formats::{is_openai_responses_compact_format, normalize_api_format_alias},
protocol::formats::{is_openai_responses_compact_format, normalize_api_format_alias},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -0,0 +1,7 @@
pub mod canonical;
pub mod context;
pub mod conversion;
pub mod formats;
pub mod matrix;
pub mod registry;
pub mod stream;

View File

@@ -1,11 +1,13 @@
use serde_json::Value;
use crate::{
canonical::{CanonicalRequest, CanonicalResponse},
formats::{claude_messages, gemini_generate_content, openai_chat, openai_responses, FormatId},
protocol::canonical::{CanonicalRequest, CanonicalResponse},
protocol::formats::{
claude_messages, gemini_generate_content, openai_chat, openai_responses, FormatId,
},
};
pub use crate::context::{FormatContext, FormatError};
pub use crate::protocol::context::{FormatContext, FormatError};
pub fn parse_request(
source_format: &str,
@@ -144,7 +146,7 @@ mod tests {
use serde_json::json;
use super::{convert_request, FormatContext};
use crate::formats::FormatId;
use crate::protocol::formats::FormatId;
#[test]
fn openai_cli_alias_is_not_a_primary_format() {
@@ -184,7 +186,7 @@ mod tests {
] {
assert!(
!implementation.contains(forbidden),
"registry should dispatch through formats::<format> adapters, found {forbidden}"
"registry should dispatch through protocol::formats::<format> adapters, found {forbidden}"
);
}
}

View File

@@ -0,0 +1,180 @@
use serde_json::{json, Value};
pub use self::state::KiroToClaudeCliStreamState;
mod state;
pub const KIRO_CONTEXT_WINDOW_TOKENS: f64 = 200_000.0;
pub const KIRO_MAX_THINKING_BUFFER: usize = 1024 * 1024;
const KIRO_QUOTE_CHARS: &str = "`\"'\\#!@$%^&*()-_=+[]{};:<>,.?/";
pub fn encode_kiro_sse_events(events: Vec<Value>) -> Result<Vec<u8>, serde_json::Error> {
let mut output = Vec::new();
for event in events {
output.extend(encode_kiro_sse_event(&event)?);
}
Ok(output)
}
pub fn encode_kiro_sse_event(event: &Value) -> Result<Vec<u8>, serde_json::Error> {
let encoded = serde_json::to_string(event)?;
if let Some(event_type) = event.get("type").and_then(Value::as_str) {
Ok(format!("event: {event_type}\ndata: {encoded}\n\n").into_bytes())
} else {
Ok(format!("data: {encoded}\n\n").into_bytes())
}
}
pub fn build_kiro_initial_sse_events(
message_id: &str,
model: &str,
estimated_input_tokens: usize,
) -> Vec<Value> {
vec![json!({
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"content": [],
"model": model,
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
"usage": {
"input_tokens": estimated_input_tokens as u64,
"output_tokens": 1,
},
}
})]
}
pub fn build_kiro_stream_error_sse_events(error_type: &str, message: &str) -> Vec<Value> {
vec![json!({
"type": "error",
"error": {
"type": error_type,
"message": message,
}
})]
}
pub fn build_kiro_final_message_sse_events(
stop_reason: &str,
input_tokens: usize,
output_tokens: usize,
) -> Vec<Value> {
vec![
json!({
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": Value::Null,
},
"usage": {
"input_tokens": input_tokens as u64,
"output_tokens": output_tokens as u64,
}
}),
json!({"type": "message_stop"}),
]
}
pub fn calculate_kiro_context_input_tokens(percentage: f64) -> usize {
((percentage * KIRO_CONTEXT_WINDOW_TOKENS) / 100.0) as usize
}
pub fn estimate_kiro_tokens(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let mut chinese = 0usize;
let mut other = 0usize;
for ch in text.chars() {
if ('\u{4e00}'..='\u{9fff}').contains(&ch) {
chinese += 1;
} else {
other += 1;
}
}
let chinese_tokens = (chinese * 2).div_ceil(3);
let other_tokens = other.div_ceil(4);
(chinese_tokens + other_tokens).max(1)
}
pub fn find_kiro_real_thinking_start_tag(buffer: &str) -> Option<usize> {
let tag = "<thinking>";
let mut search = 0usize;
loop {
let pos = buffer[search..].find(tag).map(|value| value + search)?;
let has_before = pos > 0 && is_kiro_quote_char(buffer, pos - 1);
let after_pos = pos + tag.len();
let has_after = is_kiro_quote_char(buffer, after_pos);
if !has_before && !has_after {
return Some(pos);
}
search = pos + 1;
}
}
pub fn find_kiro_real_thinking_end_tag(buffer: &str) -> Option<usize> {
let tag = "</thinking>";
let mut search = 0usize;
loop {
let pos = buffer[search..].find(tag).map(|value| value + search)?;
let has_before = pos > 0 && is_kiro_quote_char(buffer, pos - 1);
let after_pos = pos + tag.len();
let has_after = is_kiro_quote_char(buffer, after_pos);
if has_before || has_after {
search = pos + 1;
continue;
}
let after = &buffer[after_pos..];
if after.len() < 2 {
return None;
}
if after.starts_with("\n\n") {
return Some(pos);
}
search = pos + 1;
}
}
pub fn find_kiro_real_thinking_end_tag_at_buffer_end(buffer: &str) -> Option<usize> {
let tag = "</thinking>";
let mut search = 0usize;
loop {
let pos = buffer[search..].find(tag).map(|value| value + search)?;
let has_before = pos > 0 && is_kiro_quote_char(buffer, pos - 1);
let after_pos = pos + tag.len();
let has_after = is_kiro_quote_char(buffer, after_pos);
if has_before || has_after {
search = pos + 1;
continue;
}
if buffer[after_pos..].trim().is_empty() {
return Some(pos);
}
search = pos + 1;
}
}
pub fn kiro_crc32(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = if crc & 1 == 1 { 0xedb8_8320 } else { 0 };
crc = (crc >> 1) ^ mask;
}
}
!crc
}
fn is_kiro_quote_char(buffer: &str, pos: usize) -> bool {
buffer
.as_bytes()
.get(pos)
.map(|byte| KIRO_QUOTE_CHARS.as_bytes().contains(byte))
.unwrap_or(false)
}

View File

@@ -0,0 +1,71 @@
use std::collections::BTreeMap;
const MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
const MAX_BUFFER_SIZE: usize = MAX_MESSAGE_SIZE;
const MAX_ERRORS: usize = 5;
#[derive(Default)]
pub struct KiroToClaudeCliStreamState {
decoder: EventStreamDecoder,
state: KiroClaudeStreamState,
started: bool,
}
#[derive(Default)]
struct KiroClaudeStreamState {
model: String,
thinking_enabled: bool,
estimated_input_tokens: usize,
message_id: String,
output_tokens: usize,
context_input_tokens: Option<usize>,
next_block_index: usize,
open_blocks: BTreeMap<usize, String>,
text_block_index: Option<usize>,
thinking_block_index: Option<usize>,
tool_block_indices: BTreeMap<String, usize>,
thinking_buffer: String,
in_thinking_block: bool,
thinking_extracted: bool,
strip_thinking_leading_newline: bool,
has_tool_use: bool,
stop_reason_override: Option<String>,
had_error: bool,
last_content: String,
}
#[derive(Default)]
struct EventStreamDecoder {
buffer: Vec<u8>,
error_count: usize,
stopped: bool,
}
#[derive(Default)]
struct AwsHeaders {
values: BTreeMap<String, AwsHeaderValue>,
}
enum AwsHeaderValue {
Ignored,
String(String),
}
struct AwsEventFrame {
headers: AwsHeaders,
payload: Vec<u8>,
}
enum FrameParseError {
Incomplete,
Invalid(String),
}
#[path = "stream/decoder.rs"]
mod decoder;
#[path = "stream/state.rs"]
mod stream_state;
#[cfg(test)]
#[path = "stream/tests.rs"]
mod tests;

View File

@@ -0,0 +1,222 @@
use std::collections::BTreeMap;
use super::{
AwsEventFrame, AwsHeaderValue, AwsHeaders, EventStreamDecoder, FrameParseError,
MAX_BUFFER_SIZE, MAX_ERRORS, MAX_MESSAGE_SIZE,
};
use crate::provider_compat::kiro_stream::kiro_crc32 as crc32;
impl EventStreamDecoder {
pub(super) fn feed(&mut self, data: &[u8]) -> Result<(), String> {
if self.stopped || data.is_empty() {
return Ok(());
}
let new_size = self.buffer.len() + data.len();
if new_size > MAX_BUFFER_SIZE {
self.stopped = true;
return Err(format!(
"buffer overflow: size={new_size} max={MAX_BUFFER_SIZE}"
));
}
self.buffer.extend_from_slice(data);
Ok(())
}
pub(super) fn decode_available(&mut self) -> Result<Vec<AwsEventFrame>, String> {
let mut out = Vec::new();
if self.stopped {
return Ok(out);
}
loop {
match parse_frame(&self.buffer) {
Ok(Some((frame, consumed))) => {
if consumed == 0 {
break;
}
out.push(frame);
self.buffer.drain(..consumed);
self.error_count = 0;
}
Ok(None) => break,
Err(FrameParseError::Incomplete) => break,
Err(FrameParseError::Invalid(message)) => {
self.error_count += 1;
if self.error_count >= MAX_ERRORS {
self.stopped = true;
return Err(message);
}
if self.buffer.is_empty() {
break;
}
self.buffer.drain(..1);
}
}
}
Ok(out)
}
}
impl AwsHeaders {
fn get_string(&self, name: &str) -> Option<&str> {
match self.values.get(name) {
Some(AwsHeaderValue::String(value)) => Some(value.as_str()),
_ => None,
}
}
pub(super) fn message_type(&self) -> Option<&str> {
self.get_string(":message-type")
}
pub(super) fn event_type(&self) -> Option<&str> {
self.get_string(":event-type")
}
pub(super) fn exception_type(&self) -> Option<&str> {
self.get_string(":exception-type")
}
pub(super) fn error_code(&self) -> Option<&str> {
self.get_string(":error-code")
}
}
fn parse_frame(buffer: &[u8]) -> Result<Option<(AwsEventFrame, usize)>, FrameParseError> {
if buffer.len() < 12 {
return Ok(None);
}
let total_length = u32::from_be_bytes(buffer[0..4].try_into().expect("slice size")) as usize;
let header_length = u32::from_be_bytes(buffer[4..8].try_into().expect("slice size")) as usize;
let prelude_crc = u32::from_be_bytes(buffer[8..12].try_into().expect("slice size"));
if total_length < 16 {
return Err(FrameParseError::Invalid(format!(
"message too small: length={total_length}"
)));
}
if total_length > MAX_MESSAGE_SIZE {
return Err(FrameParseError::Invalid(format!(
"message too large: length={total_length}"
)));
}
if buffer.len() < total_length {
return Ok(None);
}
if crc32(&buffer[0..8]) != prelude_crc {
return Err(FrameParseError::Invalid("prelude crc mismatch".to_string()));
}
let message_crc = u32::from_be_bytes(
buffer[total_length - 4..total_length]
.try_into()
.expect("slice size"),
);
if crc32(&buffer[..total_length - 4]) != message_crc {
return Err(FrameParseError::Invalid("message crc mismatch".to_string()));
}
let headers_start = 12;
let headers_end = headers_start + header_length;
if headers_end > total_length - 4 {
return Err(FrameParseError::Invalid(
"header length exceeds frame boundary".to_string(),
));
}
let headers = parse_headers(&buffer[headers_start..headers_end], header_length)?;
let payload = buffer[headers_end..total_length - 4].to_vec();
Ok(Some((AwsEventFrame { headers, payload }, total_length)))
}
fn parse_headers(data: &[u8], header_length: usize) -> Result<AwsHeaders, FrameParseError> {
if data.len() < header_length {
return Err(FrameParseError::Incomplete);
}
let mut values = BTreeMap::new();
let mut offset = 0usize;
while offset < header_length {
ensure_header_bytes(data, offset, 1)?;
let name_len = data[offset] as usize;
offset += 1;
if name_len == 0 {
return Err(FrameParseError::Invalid(
"header name length cannot be 0".to_string(),
));
}
ensure_header_bytes(data, offset, name_len)?;
let name = String::from_utf8_lossy(&data[offset..offset + name_len]).to_string();
offset += name_len;
ensure_header_bytes(data, offset, 1)?;
let value_type = data[offset];
offset += 1;
let value = match value_type {
0 => AwsHeaderValue::Ignored,
1 => AwsHeaderValue::Ignored,
2 => {
ensure_header_bytes(data, offset, 1)?;
let _ = i8::from_be_bytes([data[offset]]);
offset += 1;
AwsHeaderValue::Ignored
}
3 => {
ensure_header_bytes(data, offset, 2)?;
let _ = i16::from_be_bytes(data[offset..offset + 2].try_into().expect("slice"));
offset += 2;
AwsHeaderValue::Ignored
}
4 => {
ensure_header_bytes(data, offset, 4)?;
let _ = i32::from_be_bytes(data[offset..offset + 4].try_into().expect("slice"));
offset += 4;
AwsHeaderValue::Ignored
}
5 | 8 => {
ensure_header_bytes(data, offset, 8)?;
let _ = i64::from_be_bytes(data[offset..offset + 8].try_into().expect("slice"));
offset += 8;
AwsHeaderValue::Ignored
}
6 => {
ensure_header_bytes(data, offset, 2)?;
let length = u16::from_be_bytes(data[offset..offset + 2].try_into().expect("slice"))
as usize;
offset += 2;
ensure_header_bytes(data, offset, length)?;
offset += length;
AwsHeaderValue::Ignored
}
7 => {
ensure_header_bytes(data, offset, 2)?;
let length = u16::from_be_bytes(data[offset..offset + 2].try_into().expect("slice"))
as usize;
offset += 2;
ensure_header_bytes(data, offset, length)?;
let out = String::from_utf8_lossy(&data[offset..offset + length]).to_string();
offset += length;
AwsHeaderValue::String(out)
}
9 => {
ensure_header_bytes(data, offset, 16)?;
offset += 16;
AwsHeaderValue::Ignored
}
other => {
return Err(FrameParseError::Invalid(format!(
"invalid header type: {other}"
)));
}
};
values.insert(name, value);
}
Ok(AwsHeaders { values })
}
fn ensure_header_bytes(data: &[u8], offset: usize, needed: usize) -> Result<(), FrameParseError> {
let available = data.len().saturating_sub(offset);
if available < needed {
return Err(FrameParseError::Incomplete);
}
Ok(())
}

View File

@@ -0,0 +1,8 @@
#[path = "state/blocks.rs"]
mod blocks;
#[path = "state/events.rs"]
mod events;
#[path = "state/finalize.rs"]
mod finalize;
#[path = "state/lifecycle.rs"]
mod lifecycle;

View File

@@ -0,0 +1,97 @@
use serde_json::{json, Value};
use super::super::KiroClaudeStreamState;
impl KiroClaudeStreamState {
pub(super) fn ensure_text_block_open(&mut self) -> Vec<Value> {
if let Some(idx) = self.text_block_index {
if self
.open_blocks
.get(&idx)
.map(|value| value == "text")
.unwrap_or(false)
{
return Vec::new();
}
}
let idx = self.next_block_index;
self.next_block_index += 1;
self.text_block_index = Some(idx);
self.open_blocks.insert(idx, "text".to_string());
vec![json!({
"type": "content_block_start",
"index": idx,
"content_block": {"type": "text", "text": ""}
})]
}
pub(super) fn ensure_thinking_block_open(&mut self) -> Vec<Value> {
if let Some(idx) = self.thinking_block_index {
if self
.open_blocks
.get(&idx)
.map(|value| value == "thinking")
.unwrap_or(false)
{
return Vec::new();
}
}
let idx = self.next_block_index;
self.next_block_index += 1;
self.thinking_block_index = Some(idx);
self.open_blocks.insert(idx, "thinking".to_string());
vec![json!({
"type": "content_block_start",
"index": idx,
"content_block": {"type": "thinking", "thinking": ""}
})]
}
pub(super) fn close_block(&mut self, idx: usize) -> Vec<Value> {
if self.open_blocks.remove(&idx).is_none() {
return Vec::new();
}
vec![json!({"type": "content_block_stop", "index": idx})]
}
pub(super) fn emit_text_delta(&mut self, text: &str) -> Vec<Value> {
if text.is_empty() {
return Vec::new();
}
let mut events = self.ensure_text_block_open();
let idx = self.text_block_index.unwrap_or_default();
events.push(json!({
"type": "content_block_delta",
"index": idx,
"delta": {"type": "text_delta", "text": text}
}));
events
}
pub(super) fn emit_thinking_delta(&mut self, thinking: &str) -> Vec<Value> {
if thinking.is_empty() {
return Vec::new();
}
let mut events = self.ensure_thinking_block_open();
let idx = self.thinking_block_index.unwrap_or_default();
events.push(json!({
"type": "content_block_delta",
"index": idx,
"delta": {"type": "thinking_delta", "thinking": thinking}
}));
events
}
pub(super) fn close_thinking_block(&mut self) -> Vec<Value> {
let Some(idx) = self.thinking_block_index else {
return Vec::new();
};
let mut events = vec![json!({
"type": "content_block_delta",
"index": idx,
"delta": {"type": "thinking_delta", "thinking": ""}
})];
events.extend(self.close_block(idx));
events
}
}

View File

@@ -0,0 +1,331 @@
use serde_json::{json, Value};
use crate::provider_compat::kiro_stream::{
calculate_kiro_context_input_tokens, encode_kiro_sse_events, estimate_kiro_tokens,
find_kiro_real_thinking_end_tag, find_kiro_real_thinking_end_tag_at_buffer_end,
find_kiro_real_thinking_start_tag, KIRO_MAX_THINKING_BUFFER,
};
use crate::response::AiSurfaceFinalizeError;
use super::super::AwsEventFrame;
use super::super::KiroClaudeStreamState;
fn floor_char_boundary(text: &str, index: usize) -> usize {
let mut boundary = index.min(text.len());
while boundary > 0 && !text.is_char_boundary(boundary) {
boundary -= 1;
}
boundary
}
fn split_preserving_trailing_bytes(
buffer: &str,
trailing_bytes: usize,
) -> Option<(String, String)> {
if buffer.len() <= trailing_bytes {
return None;
}
let split = floor_char_boundary(buffer, buffer.len() - trailing_bytes);
if split == 0 {
return None;
}
Some((buffer[..split].to_string(), buffer[split..].to_string()))
}
impl KiroClaudeStreamState {
pub(super) fn process_frame(
&mut self,
frame: AwsEventFrame,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let message_type = frame.headers.message_type().unwrap_or("event");
match message_type {
"event" => self.process_event_frame(frame),
"exception" => self.process_exception_frame(frame),
"error" => self.process_error_frame(frame),
_ => Ok(Vec::new()),
}
}
pub(super) fn process_event_frame(
&mut self,
frame: AwsEventFrame,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let event_type = frame.headers.event_type().unwrap_or_default();
let payload: Value = if frame.payload.is_empty() {
json!({})
} else {
serde_json::from_slice(&frame.payload).unwrap_or_else(|_| json!({}))
};
let payload_object = payload.as_object();
let mut events = Vec::new();
match event_type {
"assistantResponseEvent" => {
if let Some(content) = payload_object
.and_then(|value| value.get("content"))
.and_then(Value::as_str)
{
events.extend(self.process_assistant_response(content));
}
}
"toolUseEvent" => {
if let Some(payload_object) = payload_object {
let name = payload_object
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let tool_use_id = payload_object
.get("toolUseId")
.or_else(|| payload_object.get("tool_use_id"))
.and_then(Value::as_str)
.unwrap_or_default();
let input_json = match payload_object.get("input") {
None | Some(Value::Null) => String::new(),
Some(Value::String(text)) => text.clone(),
Some(other) => {
serde_json::to_string(other).map_err(AiSurfaceFinalizeError::from)?
}
};
let stop = payload_object
.get("stop")
.and_then(Value::as_bool)
.unwrap_or(false);
events.extend(self.process_tool_use(name, tool_use_id, &input_json, stop));
}
}
"contextUsageEvent" => {
if let Some(percentage) = payload_object
.and_then(|value| value.get("contextUsagePercentage"))
.and_then(Value::as_f64)
{
self.context_input_tokens =
Some(calculate_kiro_context_input_tokens(percentage));
}
}
_ => {}
}
encode_kiro_sse_events(events).map_err(AiSurfaceFinalizeError::from)
}
pub(super) fn process_exception_frame(
&mut self,
frame: AwsEventFrame,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let exception_type = frame
.headers
.exception_type()
.unwrap_or("UnknownException")
.to_string();
if exception_type == "ContentLengthExceededException" {
self.stop_reason_override = Some("max_tokens".to_string());
return Ok(Vec::new());
}
self.emit_stream_error("upstream_exception", &exception_type)
}
pub(super) fn process_error_frame(
&mut self,
frame: AwsEventFrame,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let error_code = frame
.headers
.error_code()
.unwrap_or("UnknownError")
.to_string();
self.emit_stream_error("upstream_error", &error_code)
}
pub(super) fn process_assistant_response(&mut self, content: &str) -> Vec<Value> {
if content.is_empty() || content == self.last_content {
return Vec::new();
}
self.last_content = content.to_string();
self.output_tokens += estimate_kiro_tokens(content);
if !self.thinking_enabled {
return self.emit_text_delta(content);
}
self.thinking_buffer.push_str(content);
if self.thinking_buffer.len() > KIRO_MAX_THINKING_BUFFER {
let overflow = std::mem::take(&mut self.thinking_buffer);
if self.in_thinking_block {
let mut events = self.emit_thinking_delta(&overflow);
events.extend(self.close_thinking_block());
self.in_thinking_block = false;
self.thinking_extracted = true;
return events;
}
return self.emit_text_delta(&overflow);
}
let mut events = Vec::new();
loop {
if !self.in_thinking_block && !self.thinking_extracted {
if let Some(start_pos) = find_kiro_real_thinking_start_tag(&self.thinking_buffer) {
let before = self.thinking_buffer[..start_pos].to_string();
if !before.trim().is_empty() {
events.extend(self.emit_text_delta(&before));
}
self.in_thinking_block = true;
self.strip_thinking_leading_newline = true;
self.thinking_buffer =
self.thinking_buffer[start_pos + "<thinking>".len()..].to_string();
events.extend(self.ensure_thinking_block_open());
continue;
}
let keep = "<thinking>".len();
if let Some((safe, remaining)) =
split_preserving_trailing_bytes(&self.thinking_buffer, keep)
{
if !safe.trim().is_empty() {
events.extend(self.emit_text_delta(&safe));
self.thinking_buffer = remaining;
}
}
break;
}
if self.in_thinking_block {
if self.strip_thinking_leading_newline {
if self.thinking_buffer.starts_with('\n') {
self.thinking_buffer.remove(0);
self.strip_thinking_leading_newline = false;
} else if !self.thinking_buffer.is_empty() {
self.strip_thinking_leading_newline = false;
}
}
if let Some(end_pos) = find_kiro_real_thinking_end_tag(&self.thinking_buffer) {
let thinking_text = self.thinking_buffer[..end_pos].to_string();
if !thinking_text.is_empty() {
events.extend(self.emit_thinking_delta(&thinking_text));
}
events.extend(self.close_thinking_block());
self.in_thinking_block = false;
self.thinking_extracted = true;
self.thinking_buffer =
self.thinking_buffer[end_pos + "</thinking>".len()..].to_string();
continue;
}
let keep = "</thinking>".len();
if let Some((safe, remaining)) =
split_preserving_trailing_bytes(&self.thinking_buffer, keep)
{
if !safe.is_empty() {
events.extend(self.emit_thinking_delta(&safe));
self.thinking_buffer = remaining;
}
}
break;
}
if !self.thinking_buffer.is_empty() {
let remaining = std::mem::take(&mut self.thinking_buffer);
events.extend(self.emit_text_delta(&remaining));
}
break;
}
events
}
pub(super) fn process_tool_use(
&mut self,
name: &str,
tool_use_id: &str,
input_json: &str,
stop: bool,
) -> Vec<Value> {
if tool_use_id.is_empty() {
return Vec::new();
}
self.has_tool_use = true;
let mut events = Vec::new();
if self.thinking_enabled && self.in_thinking_block && !self.thinking_buffer.is_empty() {
if let Some(end_pos) =
find_kiro_real_thinking_end_tag_at_buffer_end(&self.thinking_buffer)
{
let thinking_text = self.thinking_buffer[..end_pos].to_string();
if !thinking_text.is_empty() {
events.extend(self.emit_thinking_delta(&thinking_text));
}
events.extend(self.close_thinking_block());
let remaining = self.thinking_buffer[end_pos + "</thinking>".len()..].to_string();
self.thinking_buffer.clear();
self.in_thinking_block = false;
self.thinking_extracted = true;
if !remaining.is_empty() {
events.extend(self.emit_text_delta(&remaining));
}
} else {
let thinking = std::mem::take(&mut self.thinking_buffer);
events.extend(self.emit_thinking_delta(&thinking));
events.extend(self.close_thinking_block());
self.in_thinking_block = false;
self.thinking_extracted = true;
}
}
if self.thinking_enabled
&& !self.in_thinking_block
&& !self.thinking_extracted
&& !self.thinking_buffer.is_empty()
{
let buffered = std::mem::take(&mut self.thinking_buffer);
events.extend(self.emit_text_delta(&buffered));
}
if let Some(idx) = self.text_block_index.take() {
events.extend(self.close_block(idx));
}
let block_index = if let Some(block_index) = self.tool_block_indices.get(tool_use_id) {
*block_index
} else {
let block_index = self.next_block_index;
self.next_block_index += 1;
self.tool_block_indices
.insert(tool_use_id.to_string(), block_index);
block_index
};
if let std::collections::btree_map::Entry::Vacant(e) = self.open_blocks.entry(block_index) {
e.insert("tool_use".to_string());
events.push(json!({
"type": "content_block_start",
"index": block_index,
"content_block": {
"type": "tool_use",
"id": tool_use_id,
"name": name,
"input": {},
}
}));
}
if !input_json.is_empty() {
self.output_tokens += estimate_kiro_tokens(input_json);
events.push(json!({
"type": "content_block_delta",
"index": block_index,
"delta": {
"type": "input_json_delta",
"partial_json": input_json,
}
}));
}
if stop {
events.extend(self.close_block(block_index));
}
events
}
}

View File

@@ -0,0 +1,96 @@
use crate::provider_compat::kiro_stream::{
build_kiro_final_message_sse_events, encode_kiro_sse_events,
find_kiro_real_thinking_end_tag_at_buffer_end,
};
use crate::response::AiSurfaceFinalizeError;
use super::super::KiroClaudeStreamState;
impl KiroClaudeStreamState {
pub(super) fn finalize(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.thinking_enabled && !self.thinking_buffer.is_empty() {
let flush_events = if self.in_thinking_block {
if let Some(end_pos) =
find_kiro_real_thinking_end_tag_at_buffer_end(&self.thinking_buffer)
{
let thinking_text = self.thinking_buffer[..end_pos].to_string();
let mut events = Vec::new();
if !thinking_text.is_empty() {
events.extend(self.emit_thinking_delta(&thinking_text));
}
events.extend(self.close_thinking_block());
let remaining =
self.thinking_buffer[end_pos + "</thinking>".len()..].to_string();
if !remaining.is_empty() {
events.extend(self.emit_text_delta(&remaining));
}
events
} else {
let mut events = self.emit_thinking_delta(&self.thinking_buffer.clone());
events.extend(self.close_thinking_block());
events
}
} else {
self.emit_text_delta(&self.thinking_buffer.clone())
};
self.thinking_buffer.clear();
self.in_thinking_block = false;
self.thinking_extracted = true;
let mut output =
encode_kiro_sse_events(flush_events).map_err(AiSurfaceFinalizeError::from)?;
for idx in self
.open_blocks
.keys()
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
{
output.extend(
encode_kiro_sse_events(self.close_block(idx))
.map_err(AiSurfaceFinalizeError::from)?,
);
}
output.extend(self.final_message_bytes()?);
return Ok(output);
}
let mut output = Vec::new();
for idx in self
.open_blocks
.keys()
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
{
output.extend(
encode_kiro_sse_events(self.close_block(idx))
.map_err(AiSurfaceFinalizeError::from)?,
);
}
output.extend(self.final_message_bytes()?);
Ok(output)
}
pub(super) fn final_message_bytes(&self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let stop_reason = self.stop_reason_override.clone().unwrap_or_else(|| {
if self.has_tool_use {
"tool_use"
} else {
"end_turn"
}
.to_string()
});
let input_tokens = self
.context_input_tokens
.unwrap_or(self.estimated_input_tokens) as u64;
encode_kiro_sse_events(build_kiro_final_message_sse_events(
&stop_reason,
input_tokens as usize,
self.output_tokens,
))
.map_err(AiSurfaceFinalizeError::from)
}
}

View File

@@ -0,0 +1,129 @@
use serde_json::Value;
use uuid::Uuid;
use crate::provider_compat::kiro_stream::{
build_kiro_initial_sse_events, build_kiro_stream_error_sse_events, encode_kiro_sse_events,
};
use crate::response::AiSurfaceFinalizeError;
use super::super::{EventStreamDecoder, KiroClaudeStreamState, KiroToClaudeCliStreamState};
impl KiroToClaudeCliStreamState {
pub fn new(report_context: &Value) -> Self {
Self {
decoder: EventStreamDecoder::default(),
state: KiroClaudeStreamState::new(report_context),
started: false,
}
}
pub fn push_chunk(
&mut self,
_report_context: &Value,
chunk: &[u8],
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = Vec::new();
if !self.started {
self.started = true;
output.extend(self.state.generate_initial_bytes()?);
}
if let Err(err) = self.decoder.feed(chunk) {
output.extend(
self.state
.emit_stream_error("upstream_stream_error", &err)?,
);
return Ok(output);
}
match self.decoder.decode_available() {
Ok(frames) => {
for frame in frames {
output.extend(self.state.process_frame(frame)?);
}
}
Err(err) => {
output.extend(
self.state
.emit_stream_error("upstream_stream_error", &err)?,
);
}
}
Ok(output)
}
pub fn finish(&mut self, _report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if !self.started || self.state.had_error {
return Ok(Vec::new());
}
self.state.finalize()
}
}
impl KiroClaudeStreamState {
pub(super) fn new(report_context: &Value) -> Self {
let model = report_context
.get("mapped_model")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or_else(|| {
report_context
.get("model")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
.unwrap_or("unknown")
.to_string();
let thinking_enabled = report_context
.get("original_request_body")
.and_then(Value::as_object)
.and_then(|body| body.get("thinking"))
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("type"))
.and_then(Value::as_str)
.map(|value| {
value.trim().eq_ignore_ascii_case("enabled")
|| value.trim().eq_ignore_ascii_case("adaptive")
})
.unwrap_or(false);
let estimated_input_tokens = report_context
.get("input_tokens")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
Self {
model,
thinking_enabled,
estimated_input_tokens,
message_id: format!("msg_{}", Uuid::new_v4().simple()),
..Self::default()
}
}
pub(super) fn generate_initial_bytes(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let events = build_kiro_initial_sse_events(
&self.message_id,
&self.model,
self.estimated_input_tokens,
);
let mut events = events;
if !self.thinking_enabled {
events.extend(self.ensure_text_block_open());
}
encode_kiro_sse_events(events).map_err(AiSurfaceFinalizeError::from)
}
pub(super) fn emit_stream_error(
&mut self,
error_type: &str,
message: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.had_error {
return Ok(Vec::new());
}
self.had_error = true;
encode_kiro_sse_events(build_kiro_stream_error_sse_events(error_type, message))
.map_err(AiSurfaceFinalizeError::from)
}
}

View File

@@ -0,0 +1,166 @@
use crate::provider_compat::kiro_stream::kiro_crc32 as crc32;
use serde_json::{json, Value};
use super::KiroToClaudeCliStreamState;
fn encode_string_header(name: &str, value: &str) -> Vec<u8> {
let mut out = Vec::new();
out.push(name.len() as u8);
out.extend_from_slice(name.as_bytes());
out.push(7);
out.extend_from_slice(&(value.len() as u16).to_be_bytes());
out.extend_from_slice(value.as_bytes());
out
}
fn encode_event_frame(message_type: &str, event_type: Option<&str>, payload: &Value) -> Vec<u8> {
let mut headers = encode_string_header(":message-type", message_type);
if let Some(event_type) = event_type {
headers.extend_from_slice(&encode_string_header(":event-type", event_type));
}
let payload_bytes = serde_json::to_vec(payload).expect("payload should encode");
encode_frame(headers, payload_bytes)
}
fn encode_frame(headers: Vec<u8>, payload: Vec<u8>) -> Vec<u8> {
let total_len = 12 + headers.len() + payload.len() + 4;
let header_len = headers.len();
let mut out = Vec::with_capacity(total_len);
out.extend_from_slice(&(total_len as u32).to_be_bytes());
out.extend_from_slice(&(header_len as u32).to_be_bytes());
let prelude_crc = crc32(&out[..8]);
out.extend_from_slice(&prelude_crc.to_be_bytes());
out.extend_from_slice(&headers);
out.extend_from_slice(&payload);
let message_crc = crc32(&out);
out.extend_from_slice(&message_crc.to_be_bytes());
out
}
fn kiro_report_context(thinking_enabled: bool) -> Value {
let mut context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"envelope_name": "kiro:generateAssistantResponse",
"mapped_model": "claude-sonnet-4.5"
});
if thinking_enabled {
context["original_request_body"] = json!({
"thinking": {
"type": "enabled"
}
});
}
context
}
#[test]
fn kiro_stream_rewriter_converts_text_events_to_claude_sse() {
let report_context = kiro_report_context(false);
let mut rewriter = KiroToClaudeCliStreamState::new(&report_context);
let chunk = [
encode_event_frame(
"event",
Some("assistantResponseEvent"),
&json!({"content": "Hello from Kiro"}),
),
encode_event_frame(
"event",
Some("contextUsageEvent"),
&json!({"contextUsagePercentage": 1.0}),
),
]
.concat();
let first = rewriter
.push_chunk(&report_context, &chunk)
.expect("rewrite should succeed");
let rest = rewriter
.finish(&report_context)
.expect("finish should succeed");
let text = String::from_utf8([first, rest].concat()).expect("utf8 should decode");
assert!(text.contains("event: message_start"));
assert!(text.contains("\"type\":\"content_block_delta\""));
assert!(text.contains("Hello from Kiro"));
assert!(text.contains("\"stop_reason\":\"end_turn\""));
assert!(text.contains("\"input_tokens\":2000"));
}
#[test]
fn kiro_stream_rewriter_converts_tool_use_to_claude_events() {
let report_context = kiro_report_context(false);
let mut rewriter = KiroToClaudeCliStreamState::new(&report_context);
let chunk = [
encode_event_frame(
"event",
Some("assistantResponseEvent"),
&json!({"content": "Need a tool."}),
),
encode_event_frame(
"event",
Some("toolUseEvent"),
&json!({
"name": "get_weather",
"toolUseId": "tool_123",
"input": {"city": "SF"},
"stop": true
}),
),
]
.concat();
let first = rewriter
.push_chunk(&report_context, &chunk)
.expect("rewrite should succeed");
let rest = rewriter
.finish(&report_context)
.expect("finish should succeed");
let text = String::from_utf8([first, rest].concat()).expect("utf8 should decode");
assert!(text.contains("\"type\":\"tool_use\""));
assert!(text.contains("\"id\":\"tool_123\""));
assert!(text.contains("\"name\":\"get_weather\""));
assert!(text.contains("\"partial_json\":\"{\\\"city\\\":\\\"SF\\\"}\""));
assert!(text.contains("\"stop_reason\":\"tool_use\""));
}
#[test]
fn kiro_stream_rewriter_handles_multibyte_text_without_thinking_tag() {
let report_context = kiro_report_context(true);
let mut rewriter = KiroToClaudeCliStreamState::new(&report_context);
let chunk = encode_event_frame(
"event",
Some("assistantResponseEvent"),
&json!({"content": "\n\n你好!有"}),
);
let first = rewriter
.push_chunk(&report_context, &chunk)
.expect("rewrite should succeed");
let rest = rewriter
.finish(&report_context)
.expect("finish should succeed");
let text = String::from_utf8([first, rest].concat()).expect("utf8 should decode");
assert!(text.contains("\"type\":\"text_delta\""));
assert!(text.contains("你好!有"));
}
#[test]
fn kiro_stream_rewriter_handles_multibyte_text_inside_thinking_block() {
let report_context = kiro_report_context(true);
let mut rewriter = KiroToClaudeCliStreamState::new(&report_context);
let chunk = encode_event_frame(
"event",
Some("assistantResponseEvent"),
&json!({"content": "<thinking>\n\n你好!有"}),
);
let first = rewriter
.push_chunk(&report_context, &chunk)
.expect("rewrite should succeed");
let rest = rewriter
.finish(&report_context)
.expect("finish should succeed");
let text = String::from_utf8([first, rest].concat()).expect("utf8 should decode");
assert!(text.contains("\"type\":\"thinking_delta\""));
assert!(text.contains("你好!有"));
}

View File

@@ -0,0 +1,4 @@
pub mod kiro_stream;
pub mod private_envelope;
pub mod proxy;
pub mod surfaces;

View File

@@ -0,0 +1,529 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
use crate::response::AiSurfaceFinalizeError;
use super::surfaces::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
};
pub fn provider_private_response_allows_sync_finalize(report_context: &Value) -> bool {
let has_envelope = report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false);
if !has_envelope {
return true;
}
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
provider_adaptation_allows_sync_finalize_envelope(envelope_name, provider_api_format)
}
pub fn normalize_provider_private_report_context(report_context: Option<&Value>) -> Option<Value> {
let report_context = report_context?;
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Some(report_context.clone());
}
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
if provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format).is_none() {
return Some(report_context.clone());
}
Some(clear_private_envelope_context(report_context))
}
pub fn normalize_provider_private_response_value(
data: Value,
report_context: &Value,
) -> Option<Value> {
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Some(data);
}
let mut unwrapped = match report_context.get("envelope_name").and_then(Value::as_str) {
Some(KIRO_ENVELOPE_NAME) => data,
Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME) => {
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
Value::Object(response.clone())
} else {
data
}
}
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME) => {
if let Some(response) = data
.get("response")
.and_then(Value::as_object)
.filter(|response| !response.contains_key("response"))
{
let mut unwrapped = response.clone();
if let Some(response_id) = data.get("responseId").cloned() {
unwrapped.insert("_v1internal_response_id".to_string(), response_id);
}
Value::Object(unwrapped)
} else {
data
}
}
_ => return None,
};
postprocess_private_response_value(&mut unwrapped, report_context);
Some(unwrapped)
}
pub fn transform_provider_private_stream_line(
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, serde_json::Error> {
let Ok(text) = std::str::from_utf8(&line) else {
return Ok(line);
};
let trimmed = text.trim_matches('\r').trim();
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
return Ok(Vec::new());
}
let Some(data_line) = trimmed.strip_prefix("data:") else {
return Ok(line);
};
let data_line = data_line.trim();
if data_line.is_empty() || data_line == "[DONE]" {
return Ok(line);
}
let body: Value = match serde_json::from_str(data_line) {
Ok(value) => value,
Err(_) => return Ok(line),
};
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
return Ok(line);
}
let unwrapped = match envelope_name {
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME => body.get("response").cloned().unwrap_or(body),
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME => {
let mut response = body.get("response").cloned().unwrap_or(body.clone());
if let Some(response_id) = body.get("responseId").cloned() {
if let Some(object) = response.as_object_mut() {
object
.entry("_v1internal_response_id".to_string())
.or_insert(response_id);
}
}
inject_antigravity_stream_tool_ids(&mut response);
response
}
_ => body,
};
let mut out = b"data: ".to_vec();
out.extend(serde_json::to_vec(&unwrapped)?);
out.extend_from_slice(b"\n\n");
Ok(out)
}
enum ProviderPrivateStreamNormalizeMode {
EnvelopeUnwrap,
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
}
pub struct ProviderPrivateStreamNormalizer<'a> {
report_context: &'a Value,
buffered: Vec<u8>,
mode: ProviderPrivateStreamNormalizeMode,
}
pub fn maybe_build_provider_private_stream_normalizer<'a>(
report_context: Option<&'a Value>,
) -> Option<ProviderPrivateStreamNormalizer<'a>> {
let report_context = report_context?;
if !report_context
.get("has_envelope")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return None;
}
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
let descriptor =
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)?;
let mode = if descriptor
.envelope_name
.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME)
{
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(Box::new(
KiroToClaudeCliStreamState::new(report_context),
))
} else if descriptor.unwraps_response_envelope {
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap
} else {
return None;
};
Some(ProviderPrivateStreamNormalizer {
report_context,
buffered: Vec::new(),
mode,
})
}
impl ProviderPrivateStreamNormalizer<'_> {
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.mode {
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(state) => {
state.push_chunk(self.report_context, chunk)
}
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
output.extend(
transform_provider_private_stream_line(self.report_context, line)
.map_err(AiSurfaceFinalizeError::from)?,
);
}
Ok(output)
}
}
}
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.mode {
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(state) => {
state.finish(self.report_context)
}
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
if self.buffered.is_empty() {
return Ok(Vec::new());
}
let line = std::mem::take(&mut self.buffered);
transform_provider_private_stream_line(self.report_context, line)
.map_err(AiSurfaceFinalizeError::from)
}
}
}
}
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
let Ok(text) = std::str::from_utf8(body) else {
return false;
};
let mut current_event_type: Option<String> = None;
for raw_line in text.lines() {
let line = raw_line.trim_matches('\r').trim();
if line.is_empty() || line.starts_with(':') {
continue;
}
if let Some(event_name) = line.strip_prefix("event:") {
current_event_type = Some(event_name.trim().to_string());
continue;
}
let data_line = if let Some(rest) = line.strip_prefix("data:") {
rest.trim()
} else {
line
};
if data_line.is_empty() || data_line == "[DONE]" {
continue;
}
let Ok(mut event) = serde_json::from_str::<Value>(data_line) else {
continue;
};
if let Some(event_object) = event.as_object_mut() {
if !event_object.contains_key("type") {
if let Some(event_name) = current_event_type.take() {
event_object.insert("type".to_string(), Value::String(event_name));
}
}
}
if event
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
{
return true;
}
current_event_type = None;
}
false
}
fn clear_private_envelope_context(report_context: &Value) -> Value {
let mut normalized = report_context.clone();
if let Some(object) = normalized.as_object_mut() {
object.insert("has_envelope".to_string(), Value::Bool(false));
object.remove("envelope_name");
}
normalized
}
fn local_finalize_response_model(report_context: &Value) -> &str {
report_context
.get("mapped_model")
.and_then(Value::as_str)
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or_default()
}
fn inject_antigravity_stream_tool_ids(value: &mut Value) {
let Some(candidates) = value.get_mut("candidates").and_then(Value::as_array_mut) else {
return;
};
for candidate in candidates {
let Some(parts) = candidate
.get_mut("content")
.and_then(Value::as_object_mut)
.and_then(|content| content.get_mut("parts"))
.and_then(Value::as_array_mut)
else {
continue;
};
let mut counters: BTreeMap<String, usize> = BTreeMap::new();
for part in parts {
let Some(function_call) = part.get_mut("functionCall").and_then(Value::as_object_mut)
else {
continue;
};
let has_id = function_call
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_id {
continue;
}
let name = function_call
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string();
let index = counters.entry(name.clone()).or_insert(0);
function_call.insert(
"id".to_string(),
Value::String(format!("call_{name}_{index}")),
);
*index += 1;
}
}
}
fn inject_antigravity_sync_tool_ids(response: &mut Value, model: &str) {
if !model.to_ascii_lowercase().contains("claude") {
return;
}
let Some(candidates) = response.get_mut("candidates").and_then(Value::as_array_mut) else {
return;
};
for candidate in candidates {
let Some(parts) = candidate
.get_mut("content")
.and_then(Value::as_object_mut)
.and_then(|content| content.get_mut("parts"))
.and_then(Value::as_array_mut)
else {
continue;
};
let mut name_counters: BTreeMap<String, usize> = BTreeMap::new();
for part in parts {
let function_call = if let Some(function_call) =
part.get_mut("functionCall").and_then(Value::as_object_mut)
{
function_call
} else if let Some(function_call) =
part.get_mut("function_call").and_then(Value::as_object_mut)
{
function_call
} else {
continue;
};
let has_id = function_call
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_id {
continue;
}
let function_name = function_call
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string();
let count = name_counters.entry(function_name.clone()).or_insert(0);
function_call.insert(
"id".to_string(),
Value::String(format!("call_{function_name}_{count}")),
);
*count += 1;
}
}
}
fn postprocess_private_response_value(data: &mut Value, report_context: &Value) {
if !matches!(
report_context.get("envelope_name").and_then(Value::as_str),
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME)
) {
return;
}
if let Some(object) = data.as_object_mut() {
if !object.contains_key("_v1internal_response_id") {
if let Some(response_id) = object.remove("responseId") {
object.insert("_v1internal_response_id".to_string(), response_id);
}
}
}
inject_antigravity_sync_tool_ids(data, local_finalize_response_model(report_context));
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
normalize_provider_private_response_value, stream_body_contains_error_event,
transform_provider_private_stream_line,
};
#[test]
fn normalizes_supported_private_report_context() {
let report_context = json!({
"has_envelope": true,
"envelope_name": "antigravity:v1internal",
"provider_api_format": "gemini:generate_content",
});
let normalized = normalize_provider_private_report_context(Some(&report_context))
.expect("context should normalize");
assert_eq!(normalized["has_envelope"], json!(false));
assert!(normalized.get("envelope_name").is_none());
}
#[test]
fn unwraps_antigravity_sync_response_and_injects_ids() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"mapped_model": "claude-sonnet-4-5",
});
let body = json!({
"response": {
"candidates": [{
"content": {
"parts": [{
"functionCall": {
"name": "get_weather",
"args": {"city": "SF"}
}
}]
}
}]
},
"responseId": "resp_123"
});
let normalized = normalize_provider_private_response_value(body, &report_context)
.expect("body should normalize");
assert_eq!(normalized["_v1internal_response_id"], json!("resp_123"));
assert_eq!(
normalized["candidates"][0]["content"]["parts"][0]["functionCall"]["id"],
json!("call_get_weather_0")
);
}
#[test]
fn unwraps_antigravity_stream_line_and_injects_ids() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:generate_content",
"client_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"mapped_model": "claude-sonnet-4-5",
});
let output = transform_provider_private_stream_line(
&report_context,
b"data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_123\"}\n\n".to_vec(),
)
.expect("unwrap should succeed");
let output_text = String::from_utf8(output).expect("text should decode");
assert!(output_text.contains("\"_v1internal_response_id\":\"resp_123\""));
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
}
#[test]
fn private_stream_normalizer_unwraps_antigravity_stream() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:generate_content",
"client_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"mapped_model": "claude-sonnet-4-5",
});
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
.expect("normalizer should exist");
let output = normalizer
.push_chunk(
b"data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\"},\"responseId\":\"resp_123\"}\n\n",
)
.expect("unwrap should succeed");
let output_text = String::from_utf8(output).expect("text should decode");
assert!(output_text.contains("\"_v1internal_response_id\":\"resp_123\""));
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
}
#[test]
fn detects_sse_error_events_without_explicit_type_field() {
let body = br#"event: error
data: {"message":"bad"}
"#;
assert!(stream_body_contains_error_event(body));
}
}

View File

@@ -0,0 +1,188 @@
pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
pub const KIRO_ENVELOPE_NAME: &str = "kiro:generateAssistantResponse";
pub const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
pub const GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME: &str = "gemini_cli:v1internal";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAdaptationSurface {
AntigravityGeminiChat,
AntigravityGeminiCli,
GeminiCliV1Internal,
KiroClaudeCli,
}
#[derive(Debug, Clone, Copy)]
pub struct ProviderAdaptationDescriptor {
pub surface: ProviderAdaptationSurface,
pub provider_type: Option<&'static str>,
pub envelope_name: &'static str,
pub anchor_api_format: &'static str,
pub supports_request_bridge: bool,
pub supports_sync_finalize_bridge: bool,
pub supports_stream_bridge: bool,
pub requires_eventstream_accept: bool,
pub unwraps_response_envelope: bool,
}
const PROVIDER_ADAPTATION_SURFACES: &[ProviderAdaptationDescriptor] = &[
ProviderAdaptationDescriptor {
surface: ProviderAdaptationSurface::AntigravityGeminiChat,
provider_type: Some(ANTIGRAVITY_PROVIDER_TYPE),
envelope_name: ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
anchor_api_format: "gemini:generate_content",
supports_request_bridge: true,
supports_sync_finalize_bridge: true,
supports_stream_bridge: true,
requires_eventstream_accept: false,
unwraps_response_envelope: true,
},
ProviderAdaptationDescriptor {
surface: ProviderAdaptationSurface::AntigravityGeminiCli,
provider_type: Some(ANTIGRAVITY_PROVIDER_TYPE),
envelope_name: ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
anchor_api_format: "gemini:generate_content",
supports_request_bridge: true,
supports_sync_finalize_bridge: true,
supports_stream_bridge: true,
requires_eventstream_accept: false,
unwraps_response_envelope: true,
},
ProviderAdaptationDescriptor {
surface: ProviderAdaptationSurface::GeminiCliV1Internal,
provider_type: None,
envelope_name: GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
anchor_api_format: "gemini:generate_content",
supports_request_bridge: false,
supports_sync_finalize_bridge: true,
supports_stream_bridge: true,
requires_eventstream_accept: false,
unwraps_response_envelope: true,
},
ProviderAdaptationDescriptor {
surface: ProviderAdaptationSurface::KiroClaudeCli,
provider_type: Some(KIRO_PROVIDER_TYPE),
envelope_name: KIRO_ENVELOPE_NAME,
anchor_api_format: "claude:messages",
supports_request_bridge: true,
supports_sync_finalize_bridge: true,
supports_stream_bridge: true,
requires_eventstream_accept: true,
unwraps_response_envelope: false,
},
];
pub fn provider_adaptation_descriptor_for_envelope(
envelope_name: &str,
provider_api_format: &str,
) -> Option<&'static ProviderAdaptationDescriptor> {
let envelope_name = envelope_name.trim();
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
PROVIDER_ADAPTATION_SURFACES.iter().find(|descriptor| {
descriptor.envelope_name.eq_ignore_ascii_case(envelope_name)
&& descriptor
.anchor_api_format
.eq_ignore_ascii_case(provider_api_format.as_str())
})
}
pub fn provider_adaptation_descriptor_for_provider_type(
provider_type: &str,
provider_api_format: &str,
) -> Option<&'static ProviderAdaptationDescriptor> {
let provider_type = provider_type.trim();
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
PROVIDER_ADAPTATION_SURFACES.iter().find(|descriptor| {
descriptor
.provider_type
.is_some_and(|value| value.eq_ignore_ascii_case(provider_type))
&& descriptor
.anchor_api_format
.eq_ignore_ascii_case(provider_api_format.as_str())
})
}
pub fn provider_adaptation_anchor_api_format(
envelope_name: &str,
provider_api_format: &str,
) -> Option<&'static str> {
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
.map(|descriptor| descriptor.anchor_api_format)
}
pub fn provider_adaptation_allows_sync_finalize_envelope(
envelope_name: &str,
provider_api_format: &str,
) -> bool {
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
.is_some_and(|descriptor| descriptor.supports_sync_finalize_bridge)
}
pub fn provider_adaptation_requires_eventstream_accept(
envelope_name: Option<&str>,
provider_api_format: &str,
) -> bool {
envelope_name
.and_then(|value| provider_adaptation_descriptor_for_envelope(value, provider_api_format))
.is_some_and(|descriptor| descriptor.requires_eventstream_accept)
}
pub fn provider_adaptation_should_unwrap_stream_envelope(
envelope_name: &str,
provider_api_format: &str,
) -> bool {
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
.is_some_and(|descriptor| descriptor.unwraps_response_envelope)
}
#[cfg(test)]
mod tests {
use super::{
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
};
#[test]
fn resolves_private_surface_anchor_contracts() {
assert_eq!(
provider_adaptation_anchor_api_format(
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
"gemini:generate_content"
),
Some("gemini:generate_content")
);
assert_eq!(
provider_adaptation_anchor_api_format(
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
"gemini:generate_content"
),
Some("gemini:generate_content")
);
assert_eq!(
provider_adaptation_anchor_api_format(KIRO_ENVELOPE_NAME, "claude:messages"),
Some("claude:messages")
);
}
#[test]
fn exposes_private_surface_capabilities() {
assert!(provider_adaptation_allows_sync_finalize_envelope(
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
"gemini:generate_content"
));
assert!(provider_adaptation_should_unwrap_stream_envelope(
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
"gemini:generate_content"
));
assert!(provider_adaptation_requires_eventstream_accept(
Some(KIRO_ENVELOPE_NAME),
"claude:messages"
));
assert!(!provider_adaptation_requires_eventstream_accept(
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
"gemini:generate_content"
));
}
}

View File

@@ -0,0 +1,80 @@
use base64::Engine as _;
pub fn parse_direct_request_body(
is_json_request: bool,
body_bytes: &[u8],
) -> Option<(serde_json::Value, Option<String>)> {
if is_json_request {
if body_bytes.is_empty() {
Some((serde_json::json!({}), None))
} else {
serde_json::from_slice::<serde_json::Value>(body_bytes)
.ok()
.map(|value| (value, None))
}
} else {
Some((
serde_json::json!({}),
(!body_bytes.is_empty())
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
))
}
}
pub fn force_upstream_streaming_for_provider(
provider_type: &str,
provider_api_format: &str,
) -> bool {
provider_type.trim().eq_ignore_ascii_case("codex")
&& aether_ai_formats::is_openai_responses_format(provider_api_format)
}
#[cfg(test)]
mod tests {
use super::{force_upstream_streaming_for_provider, parse_direct_request_body};
#[test]
fn parses_empty_json_body_as_empty_object() {
assert_eq!(
parse_direct_request_body(true, b""),
Some((serde_json::json!({}), None))
);
}
#[test]
fn rejects_invalid_json_body() {
assert_eq!(parse_direct_request_body(true, b"{invalid"), None);
}
#[test]
fn encodes_non_json_body_as_base64() {
assert_eq!(
parse_direct_request_body(false, b"hello"),
Some((serde_json::json!({}), Some("aGVsbG8=".to_string())))
);
}
#[test]
fn forces_streaming_for_codex_openai_responses() {
assert!(force_upstream_streaming_for_provider(
"codex",
"openai:responses"
));
assert!(!force_upstream_streaming_for_provider(
"codex",
"openai:responses:compact"
));
}
#[test]
fn does_not_force_streaming_for_compact_or_other_provider_types() {
assert!(!force_upstream_streaming_for_provider(
"codex",
"openai:responses:compact"
));
assert!(!force_upstream_streaming_for_provider(
"openai",
"openai:responses"
));
}
}

View File

@@ -0,0 +1 @@
pub use crate::request::standard::matrix::build_standard_request_body_from_canonical;

View File

@@ -0,0 +1,7 @@
pub mod common;
pub mod matrix;
pub mod openai;
pub mod passthrough;
pub mod route;
pub mod specialized;
pub mod standard;

View File

@@ -0,0 +1 @@
pub mod provider;

View File

@@ -0,0 +1,109 @@
use crate::contracts::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalSameFormatProviderFamily {
Standard,
Gemini,
}
#[derive(Debug, Clone, Copy)]
pub struct LocalSameFormatProviderSpec {
pub api_format: &'static str,
pub decision_kind: &'static str,
pub report_kind: &'static str,
pub family: LocalSameFormatProviderFamily,
pub require_streaming: bool,
}
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
match plan_kind {
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
report_kind: "claude_chat_sync_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: false,
}),
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
report_kind: "claude_cli_sync_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: false,
}),
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
report_kind: "gemini_chat_sync_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: false,
}),
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
report_kind: "gemini_cli_sync_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
match plan_kind {
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
report_kind: "claude_chat_stream_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: true,
}),
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
report_kind: "claude_cli_stream_success",
family: LocalSameFormatProviderFamily::Standard,
require_streaming: true,
}),
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
report_kind: "gemini_chat_stream_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: true,
}),
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
report_kind: "gemini_cli_stream_success",
family: LocalSameFormatProviderFamily::Gemini,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_claude_sync_same_format_spec() {
let spec = resolve_sync_spec("claude_chat_sync").expect("spec");
assert_eq!(spec.api_format, "claude:messages");
assert_eq!(spec.report_kind, "claude_chat_sync_success");
assert!(!spec.require_streaming);
}
#[test]
fn resolves_gemini_stream_same_format_spec() {
let spec = resolve_stream_spec("gemini_cli_stream").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,602 @@
use http::Method;
use crate::contracts::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
use crate::request::specialized::image::is_openai_image_stream_request;
pub fn resolve_execution_runtime_stream_plan_kind(
route_class: Option<&str>,
route_family: Option<&str>,
route_kind: Option<&str>,
method: &Method,
path: &str,
) -> Option<&'static str> {
if route_class != Some("ai_public") {
return None;
}
if route_family == Some("gemini")
&& route_kind == Some("files")
&& *method == Method::GET
&& path.ends_with(":download")
{
return Some(GEMINI_FILES_DOWNLOAD_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("chat")
&& *method == Method::POST
&& path == "/v1/chat/completions"
{
return Some(OPENAI_CHAT_STREAM_PLAN_KIND);
}
if route_family == Some("claude")
&& is_claude_messages_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/messages"
{
return Some(CLAUDE_CHAT_STREAM_PLAN_KIND);
}
if route_family == Some("claude")
&& route_kind == Some("cli")
&& *method == Method::POST
&& path == "/v1/messages"
{
return Some(CLAUDE_CLI_STREAM_PLAN_KIND);
}
if route_family == Some("gemini")
&& is_gemini_generate_content_route_kind(route_kind)
&& *method == Method::POST
&& path.ends_with(":streamGenerateContent")
{
return Some(GEMINI_CHAT_STREAM_PLAN_KIND);
}
if route_family == Some("gemini")
&& route_kind == Some("cli")
&& *method == Method::POST
&& path.ends_with(":streamGenerateContent")
{
return Some(GEMINI_CLI_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& is_openai_responses_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses"
{
return Some(OPENAI_RESPONSES_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& is_openai_responses_compact_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses/compact"
{
return Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("image")
&& *method == Method::POST
&& matches!(path, "/v1/images/generations" | "/v1/images/edits")
{
return Some(OPENAI_IMAGE_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("video")
&& *method == Method::GET
&& path.ends_with("/content")
{
return Some(OPENAI_VIDEO_CONTENT_PLAN_KIND);
}
None
}
pub fn resolve_execution_runtime_sync_plan_kind(
route_class: Option<&str>,
route_family: Option<&str>,
route_kind: Option<&str>,
method: &Method,
path: &str,
) -> Option<&'static str> {
if route_class != Some("ai_public") {
return None;
}
if route_family == Some("openai")
&& route_kind == Some("video")
&& *method == Method::POST
&& path.starts_with("/v1/videos/")
&& path.ends_with("/cancel")
{
return Some(OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("video")
&& *method == Method::POST
&& path.starts_with("/v1/videos/")
&& path.ends_with("/remix")
{
return Some(OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("video")
&& *method == Method::POST
&& path == "/v1/videos"
{
return Some(OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("video")
&& *method == Method::DELETE
&& path.starts_with("/v1/videos/")
{
return Some(OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND);
}
if route_family == Some("gemini")
&& route_kind == Some("video")
&& *method == Method::POST
&& path.ends_with(":cancel")
{
return Some(GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND);
}
if route_family == Some("gemini")
&& route_kind == Some("video")
&& *method == Method::POST
&& path.ends_with(":predictLongRunning")
{
return Some(GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("chat")
&& *method == Method::POST
&& path == "/v1/chat/completions"
{
return Some(OPENAI_CHAT_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("image")
&& *method == Method::POST
&& matches!(
path,
"/v1/images/generations" | "/v1/images/edits" | "/v1/images/variations"
)
{
return Some(OPENAI_IMAGE_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& is_openai_responses_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses"
{
return Some(OPENAI_RESPONSES_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& is_openai_responses_compact_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses/compact"
{
return Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND);
}
if route_family == Some("claude")
&& is_claude_messages_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/messages"
{
return Some(CLAUDE_CHAT_SYNC_PLAN_KIND);
}
if route_family == Some("claude")
&& route_kind == Some("cli")
&& *method == Method::POST
&& path == "/v1/messages"
{
return Some(CLAUDE_CLI_SYNC_PLAN_KIND);
}
if route_family == Some("gemini")
&& is_gemini_generate_content_route_kind(route_kind)
&& *method == Method::POST
&& path.ends_with(":generateContent")
{
return Some(GEMINI_CHAT_SYNC_PLAN_KIND);
}
if route_family == Some("gemini")
&& route_kind == Some("cli")
&& *method == Method::POST
&& path.ends_with(":generateContent")
{
return Some(GEMINI_CLI_SYNC_PLAN_KIND);
}
if route_family == Some("gemini") && route_kind == Some("files") {
if *method == Method::POST && path == "/upload/v1beta/files" {
return Some(GEMINI_FILES_UPLOAD_PLAN_KIND);
}
if *method == Method::GET && path == "/v1beta/files" {
return Some(GEMINI_FILES_LIST_PLAN_KIND);
}
if *method == Method::GET
&& path.starts_with("/v1beta/files/")
&& !path.ends_with(":download")
{
return Some(GEMINI_FILES_GET_PLAN_KIND);
}
if *method == Method::DELETE
&& path.starts_with("/v1beta/files/")
&& !path.ends_with(":download")
{
return Some(GEMINI_FILES_DELETE_PLAN_KIND);
}
}
None
}
fn is_openai_responses_route_kind(route_kind: Option<&str>) -> bool {
matches!(route_kind, Some("responses") | Some("cli"))
}
fn is_openai_responses_compact_route_kind(route_kind: Option<&str>) -> bool {
matches!(route_kind, Some("responses:compact") | Some("compact"))
}
fn is_claude_messages_route_kind(route_kind: Option<&str>) -> bool {
matches!(route_kind, Some("messages") | Some("chat"))
}
fn is_gemini_generate_content_route_kind(route_kind: Option<&str>) -> bool {
matches!(route_kind, Some("generate_content") | Some("chat"))
}
pub fn is_matching_stream_request(
plan_kind: &str,
path: &str,
body_json: &serde_json::Value,
) -> bool {
match plan_kind {
OPENAI_CHAT_STREAM_PLAN_KIND
| CLAUDE_CHAT_STREAM_PLAN_KIND
| OPENAI_RESPONSES_STREAM_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND
| OPENAI_IMAGE_STREAM_PLAN_KIND => body_json
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false),
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
path.ends_with(":streamGenerateContent")
}
_ => true,
}
}
pub fn is_matching_stream_http_request(
plan_kind: &str,
parts: &http::request::Parts,
body_json: &serde_json::Value,
body_base64: Option<&str>,
) -> bool {
if plan_kind == OPENAI_IMAGE_STREAM_PLAN_KIND {
return is_openai_image_stream_request(parts, body_json, body_base64);
}
is_matching_stream_request(plan_kind, parts.uri.path(), body_json)
}
pub fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool {
matches!(
plan_kind,
OPENAI_CHAT_SYNC_PLAN_KIND
| OPENAI_IMAGE_SYNC_PLAN_KIND
| OPENAI_RESPONSES_SYNC_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
| CLAUDE_CHAT_SYNC_PLAN_KIND
| CLAUDE_CLI_SYNC_PLAN_KIND
| GEMINI_CHAT_SYNC_PLAN_KIND
| GEMINI_CLI_SYNC_PLAN_KIND
| GEMINI_FILES_UPLOAD_PLAN_KIND
| OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
| GEMINI_FILES_GET_PLAN_KIND
| GEMINI_FILES_LIST_PLAN_KIND
| GEMINI_FILES_DELETE_PLAN_KIND
)
}
pub fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool {
matches!(
plan_kind,
OPENAI_CHAT_STREAM_PLAN_KIND
| CLAUDE_CHAT_STREAM_PLAN_KIND
| GEMINI_CHAT_STREAM_PLAN_KIND
| OPENAI_RESPONSES_STREAM_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
| OPENAI_IMAGE_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND
| GEMINI_CLI_STREAM_PLAN_KIND
| GEMINI_FILES_DOWNLOAD_PLAN_KIND
| OPENAI_VIDEO_CONTENT_PLAN_KIND
)
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use http::Method;
use super::{
is_matching_stream_http_request, is_matching_stream_request,
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
supports_stream_execution_decision_kind, supports_sync_execution_decision_kind,
};
use crate::contracts::{
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
OPENAI_RESPONSES_SYNC_PLAN_KIND,
};
#[test]
fn resolves_openai_chat_plan_kinds() {
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("chat"),
&Method::POST,
"/v1/chat/completions",
),
Some(OPENAI_CHAT_SYNC_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_stream_plan_kind(
Some("ai_public"),
Some("openai"),
Some("chat"),
&Method::POST,
"/v1/chat/completions",
),
Some(OPENAI_CHAT_STREAM_PLAN_KIND)
);
}
#[test]
fn resolves_openai_responses_plan_kinds() {
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("responses"),
&Method::POST,
"/v1/responses",
),
Some(OPENAI_RESPONSES_SYNC_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_stream_plan_kind(
Some("ai_public"),
Some("openai"),
Some("responses"),
&Method::POST,
"/v1/responses",
),
Some(OPENAI_RESPONSES_STREAM_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("cli"),
&Method::POST,
"/v1/responses",
),
Some(OPENAI_RESPONSES_SYNC_PLAN_KIND)
);
assert!(supports_sync_execution_decision_kind(
OPENAI_RESPONSES_SYNC_PLAN_KIND
));
assert!(supports_stream_execution_decision_kind(
OPENAI_RESPONSES_STREAM_PLAN_KIND
));
}
#[test]
fn resolves_openai_responses_compact_plan_kinds() {
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("responses:compact"),
&Method::POST,
"/v1/responses/compact",
),
Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_stream_plan_kind(
Some("ai_public"),
Some("openai"),
Some("responses:compact"),
&Method::POST,
"/v1/responses/compact",
),
Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("compact"),
&Method::POST,
"/v1/responses/compact",
),
Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND)
);
assert!(supports_sync_execution_decision_kind(
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
));
assert!(supports_stream_execution_decision_kind(
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
));
}
#[test]
fn stream_matching_requires_openai_stream_flag() {
assert!(!is_matching_stream_request(
OPENAI_CHAT_STREAM_PLAN_KIND,
"/v1/chat/completions",
&serde_json::json!({"stream": false}),
));
assert!(is_matching_stream_request(
OPENAI_CHAT_STREAM_PLAN_KIND,
"/v1/chat/completions",
&serde_json::json!({"stream": true}),
));
assert!(supports_sync_execution_decision_kind(
OPENAI_CHAT_SYNC_PLAN_KIND
));
assert!(supports_stream_execution_decision_kind(
OPENAI_CHAT_STREAM_PLAN_KIND
));
}
#[test]
fn resolves_openai_image_sync_plan_kind() {
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("image"),
&Method::POST,
"/v1/images/generations",
),
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("image"),
&Method::POST,
"/v1/images/edits",
),
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("image"),
&Method::POST,
"/v1/images/variations",
),
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
);
assert!(supports_sync_execution_decision_kind(
OPENAI_IMAGE_SYNC_PLAN_KIND
));
}
#[test]
fn resolves_openai_image_stream_plan_kind() {
assert_eq!(
resolve_execution_runtime_stream_plan_kind(
Some("ai_public"),
Some("openai"),
Some("image"),
&Method::POST,
"/v1/images/generations",
),
Some(OPENAI_IMAGE_STREAM_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_stream_plan_kind(
Some("ai_public"),
Some("openai"),
Some("image"),
&Method::POST,
"/v1/images/edits",
),
Some(OPENAI_IMAGE_STREAM_PLAN_KIND)
);
assert!(supports_stream_execution_decision_kind(
OPENAI_IMAGE_STREAM_PLAN_KIND
));
}
#[test]
fn stream_matching_requires_openai_image_stream_flag() {
assert!(!is_matching_stream_request(
OPENAI_IMAGE_STREAM_PLAN_KIND,
"/v1/images/generations",
&serde_json::json!({"stream": false}),
));
assert!(is_matching_stream_request(
OPENAI_IMAGE_STREAM_PLAN_KIND,
"/v1/images/generations",
&serde_json::json!({"stream": true}),
));
}
#[test]
fn http_stream_matching_detects_openai_image_multipart_stream_flag() {
let request = http::Request::builder()
.method(Method::POST)
.uri("/v1/images/edits")
.header(
http::header::CONTENT_TYPE,
"multipart/form-data; boundary=image-stream-boundary",
)
.body(())
.expect("request should build");
let (parts, _) = request.into_parts();
let body = concat!(
"--image-stream-boundary\r\n",
"Content-Disposition: form-data; name=\"stream\"\r\n\r\n",
"true\r\n",
"--image-stream-boundary--\r\n"
);
let body_base64 = base64::engine::general_purpose::STANDARD.encode(body.as_bytes());
assert!(is_matching_stream_http_request(
OPENAI_IMAGE_STREAM_PLAN_KIND,
&parts,
&serde_json::json!({}),
Some(body_base64.as_str()),
));
}
}

View File

@@ -0,0 +1,69 @@
use crate::contracts::{
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
};
#[derive(Debug, Clone, Copy)]
pub struct LocalGeminiFilesSpec {
pub decision_kind: &'static str,
pub report_kind: Option<&'static str>,
pub require_streaming: bool,
}
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
match plan_kind {
GEMINI_FILES_UPLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_UPLOAD_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_LIST_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_LIST_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_GET_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_GET_PLAN_KIND,
report_kind: Some("gemini_files_store_mapping"),
require_streaming: false,
}),
GEMINI_FILES_DELETE_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_DELETE_PLAN_KIND,
report_kind: Some("gemini_files_delete_mapping"),
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
match plan_kind {
GEMINI_FILES_DOWNLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
decision_kind: GEMINI_FILES_DOWNLOAD_PLAN_KIND,
report_kind: None,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_sync_gemini_files_specs() {
let spec = resolve_sync_spec("gemini_files_upload").expect("spec");
assert_eq!(spec.decision_kind, "gemini_files_upload");
assert_eq!(spec.report_kind, Some("gemini_files_store_mapping"));
assert!(!spec.require_streaming);
}
#[test]
fn resolves_stream_gemini_files_spec() {
let spec = resolve_stream_spec("gemini_files_download").expect("spec");
assert_eq!(spec.decision_kind, "gemini_files_download");
assert_eq!(spec.report_kind, None);
assert!(spec.require_streaming);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
pub mod files;
pub mod image;
pub mod video;

View File

@@ -0,0 +1,54 @@
use crate::contracts::{GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalVideoCreateFamily {
OpenAi,
Gemini,
}
#[derive(Debug, Clone, Copy)]
pub struct LocalVideoCreateSpec {
pub api_format: &'static str,
pub decision_kind: &'static str,
pub report_kind: &'static str,
pub family: LocalVideoCreateFamily,
}
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
match plan_kind {
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
api_format: "openai:video",
decision_kind: OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
report_kind: "openai_video_create_sync_finalize",
family: LocalVideoCreateFamily::OpenAi,
}),
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
api_format: "gemini:video",
decision_kind: GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
report_kind: "gemini_video_create_sync_finalize",
family: LocalVideoCreateFamily::Gemini,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_sync_spec, LocalVideoCreateFamily};
#[test]
fn resolves_openai_video_create_spec() {
let spec = resolve_sync_spec("openai_video_create_sync").expect("spec");
assert_eq!(spec.api_format, "openai:video");
assert_eq!(spec.family, LocalVideoCreateFamily::OpenAi);
assert_eq!(spec.report_kind, "openai_video_create_sync_finalize");
}
#[test]
fn resolves_gemini_video_create_spec() {
let spec = resolve_sync_spec("gemini_video_create_sync").expect("spec");
assert_eq!(spec.api_format, "gemini:video");
assert_eq!(spec.family, LocalVideoCreateFamily::Gemini);
assert_eq!(spec.report_kind, "gemini_video_create_sync_finalize");
}
}

View File

@@ -0,0 +1,53 @@
use crate::contracts::{CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND};
use crate::request::standard::family::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
report_kind: "claude_chat_sync_finalize",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Chat,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
report_kind: "claude_chat_stream_success",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Chat,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_claude_chat_sync_spec() {
let spec = resolve_sync_spec("claude_chat_sync").expect("spec");
assert_eq!(spec.api_format, "claude:messages");
assert_eq!(spec.report_kind, "claude_chat_sync_finalize");
assert!(!spec.require_streaming);
}
#[test]
fn resolves_claude_chat_stream_spec() {
let spec = resolve_stream_spec("claude_chat_stream").expect("spec");
assert_eq!(spec.api_format, "claude:messages");
assert_eq!(spec.report_kind, "claude_chat_stream_success");
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,53 @@
use crate::contracts::{CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND};
use crate::request::standard::family::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
report_kind: "claude_cli_sync_finalize",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Cli,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "claude:messages",
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
report_kind: "claude_cli_stream_success",
family: LocalStandardSourceFamily::Standard,
mode: LocalStandardSourceMode::Cli,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_claude_cli_sync_spec() {
let spec = resolve_sync_spec("claude_cli_sync").expect("spec");
assert_eq!(spec.api_format, "claude:messages");
assert_eq!(spec.report_kind, "claude_cli_sync_finalize");
assert!(!spec.require_streaming);
}
#[test]
fn resolves_claude_cli_stream_spec() {
let spec = resolve_stream_spec("claude_cli_stream").expect("spec");
assert_eq!(spec.api_format, "claude:messages");
assert_eq!(spec.report_kind, "claude_cli_stream_success");
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,12 @@
pub mod chat;
pub mod cli;
use crate::request::standard::LocalStandardSpec;
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
}

View File

@@ -0,0 +1,519 @@
use std::collections::BTreeMap;
use std::fmt::Write;
use aether_ai_formats::provider_compat::proxy::rules::body_rules_handle_path;
use serde_json::{json, Value};
use sha1::{Digest as Sha1Digest, Sha1};
use sha2::Sha256;
use uuid::Uuid;
const CODEX_PROMPT_CACHE_NAMESPACE_VERSION: &str = "v3";
const CODEX_DEFAULT_INSTRUCTIONS: &str = "You are ChatGPT.";
const CODEX_DEFAULT_USER_AGENT: &str =
"codex-tui/0.122.0 (Mac OS 15.2.0; arm64) vscode/2.6.11 (codex-tui; 0.122.0)";
const CODEX_DEFAULT_ORIGINATOR: &str = "codex-tui";
pub const CODEX_OPENAI_IMAGE_INTERNAL_MODEL: &str = "gpt-5.4-mini";
pub const CODEX_OPENAI_IMAGE_DEFAULT_MODEL: &str = "gpt-image-2";
pub const CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL: &str = "dall-e-2";
pub const CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT: &str = "png";
pub const CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT: &str =
"Create a faithful variation of the provided image.";
const CODEX_IMAGE_TOOL_DEFAULT_SIZE: &str = "1024x1024";
const CODEX_IMAGE_TOOL_DEFAULT_QUALITY: &str = "high";
const CODEX_IMAGE_TOOL_DEFAULT_BACKGROUND: &str = "auto";
const UUID_NAMESPACE_OID_BYTES: [u8; 16] = [
0x6b, 0xa7, 0xb8, 0x12, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
];
fn is_codex_openai_responses_request(provider_type: &str, provider_api_format: &str) -> bool {
provider_type.trim().eq_ignore_ascii_case("codex")
&& (aether_ai_formats::is_openai_responses_family_format(provider_api_format)
|| is_openai_image_request(provider_api_format))
}
fn is_openai_responses_compact_request(provider_api_format: &str) -> bool {
aether_ai_formats::is_openai_responses_compact_format(provider_api_format)
}
fn is_openai_image_request(provider_api_format: &str) -> bool {
provider_api_format
.trim()
.eq_ignore_ascii_case("openai:image")
}
fn apply_codex_openai_image_tool_overrides(body_object: &mut serde_json::Map<String, Value>) {
let mut tool = body_object
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| tools.first())
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
tool.insert("type".to_string(), json!("image_generation"));
tool.entry("output_format".to_string())
.or_insert_with(|| json!(CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT));
let action = tool
.get("action")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("generate")
.to_string();
if !tool.contains_key("action") {
tool.insert("action".to_string(), json!("generate"));
}
if action == "generate" {
tool.entry("size".to_string())
.or_insert_with(|| json!(CODEX_IMAGE_TOOL_DEFAULT_SIZE));
tool.entry("quality".to_string())
.or_insert_with(|| json!(CODEX_IMAGE_TOOL_DEFAULT_QUALITY));
tool.entry("background".to_string())
.or_insert_with(|| json!(CODEX_IMAGE_TOOL_DEFAULT_BACKGROUND));
}
body_object.insert("tools".to_string(), json!([tool]));
body_object.insert(
"tool_choice".to_string(),
json!({
"type": "image_generation"
}),
);
}
fn codex_openai_image_has_prompt(body_object: &serde_json::Map<String, Value>) -> bool {
body_object
.get("input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.filter_map(|item| item.get("content"))
.any(|content| match content {
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => items.iter().any(|item| {
item.as_object()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("input_text"))
.and_then(|item| item.get("text").and_then(Value::as_str))
.map(str::trim)
.is_some_and(|text| !text.is_empty())
}),
_ => false,
})
}
fn inject_codex_default_variation_prompt(body_object: &mut serde_json::Map<String, Value>) {
let Some(action) = body_object
.get("tools")
.and_then(Value::as_array)
.and_then(|tools| tools.first())
.and_then(Value::as_object)
.and_then(|tool| tool.get("action"))
.and_then(Value::as_str)
else {
return;
};
if action != "edit" || codex_openai_image_has_prompt(body_object) {
return;
}
let Some(input) = body_object.get_mut("input").and_then(Value::as_array_mut) else {
return;
};
let Some(first_message) = input.first_mut().and_then(Value::as_object_mut) else {
return;
};
let Some(content) = first_message
.get_mut("content")
.and_then(Value::as_array_mut)
else {
return;
};
content.insert(
0,
json!({
"type": "input_text",
"text": CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT,
}),
);
}
fn build_stable_codex_prompt_cache_key(user_api_key_id: &str) -> Option<String> {
let normalized = user_api_key_id.trim();
if normalized.is_empty() {
return None;
}
let namespace = format!(
"aether:codex:prompt-cache:{CODEX_PROMPT_CACHE_NAMESPACE_VERSION}:user:{normalized}"
);
let mut hasher = Sha1::new();
hasher.update(UUID_NAMESPACE_OID_BYTES);
hasher.update(namespace.as_bytes());
let digest = hasher.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x50;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Some(Uuid::from_bytes(bytes).to_string())
}
fn build_short_codex_header_id(seed: &str) -> Option<String> {
let normalized = seed.trim();
if normalized.is_empty() {
return None;
}
let digest = Sha256::digest(normalized.as_bytes());
let mut short_id = String::with_capacity(16);
for byte in digest.iter().take(8) {
let _ = write!(&mut short_id, "{byte:02x}");
}
Some(short_id)
}
fn header_map_has_non_empty_value(headers: &http::HeaderMap, header_name: &str) -> bool {
let target = header_name.trim().to_ascii_lowercase();
if target.is_empty() {
return false;
}
headers.iter().any(|(name, value)| {
if name.as_str().trim().to_ascii_lowercase() != target {
return false;
}
value
.to_str()
.ok()
.map(str::trim)
.map(|value| !value.is_empty())
.unwrap_or(false)
})
}
fn btree_map_has_non_empty_value(headers: &BTreeMap<String, String>, header_name: &str) -> bool {
let target = header_name.trim().to_ascii_lowercase();
if target.is_empty() {
return false;
}
headers
.iter()
.any(|(name, value)| name.trim().eq_ignore_ascii_case(&target) && !value.trim().is_empty())
}
fn extract_codex_account_id(decrypted_auth_config_raw: Option<&str>) -> Option<String> {
let raw = decrypted_auth_config_raw?.trim();
if raw.is_empty() {
return None;
}
serde_json::from_str::<Value>(raw).ok().and_then(|value| {
value
.get("account_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn maybe_insert_default_codex_header(
provider_request_headers: &mut BTreeMap<String, String>,
original_headers: &http::HeaderMap,
header_name: &str,
header_value: &str,
) {
if header_map_has_non_empty_value(original_headers, header_name)
|| btree_map_has_non_empty_value(provider_request_headers, header_name)
{
return;
}
provider_request_headers.insert(header_name.to_string(), header_value.to_string());
}
fn maybe_inject_codex_prompt_cache_key(
provider_request_body: &mut Value,
provider_type: &str,
provider_api_format: &str,
user_api_key_id: Option<&str>,
) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
let existing = body_object
.get("prompt_cache_key")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if !existing.is_empty() {
return;
}
let Some(prompt_cache_key) = user_api_key_id.and_then(build_stable_codex_prompt_cache_key)
else {
return;
};
body_object.insert(
"prompt_cache_key".to_string(),
Value::String(prompt_cache_key),
);
}
pub fn apply_openai_responses_compact_special_body_edits(
provider_request_body: &mut Value,
provider_api_format: &str,
) {
if !is_openai_responses_compact_request(provider_api_format) {
return;
}
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
// `/v1/responses/compact` does not accept `store`.
body_object.remove("store");
}
pub fn apply_codex_openai_responses_special_body_edits(
provider_request_body: &mut Value,
provider_type: &str,
provider_api_format: &str,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
if !body_rules_handle_path(body_rules, "max_output_tokens") {
body_object.remove("max_output_tokens");
}
if !body_rules_handle_path(body_rules, "temperature") {
body_object.remove("temperature");
}
if !body_rules_handle_path(body_rules, "top_p") {
body_object.remove("top_p");
}
if !body_rules_handle_path(body_rules, "metadata") {
body_object.remove("metadata");
}
if is_openai_responses_compact_request(provider_api_format) {
body_object.remove("store");
} else if !body_rules_handle_path(body_rules, "store") {
body_object.insert("store".to_string(), json!(false));
}
if !body_rules_handle_path(body_rules, "instructions")
&& !body_object.contains_key("instructions")
{
body_object.insert(
"instructions".to_string(),
json!(CODEX_DEFAULT_INSTRUCTIONS),
);
}
if is_openai_image_request(provider_api_format) {
body_object.insert(
"model".to_string(),
json!(CODEX_OPENAI_IMAGE_INTERNAL_MODEL),
);
body_object.insert("stream".to_string(), json!(true));
apply_codex_openai_image_tool_overrides(body_object);
inject_codex_default_variation_prompt(body_object);
}
maybe_inject_codex_prompt_cache_key(
provider_request_body,
provider_type,
provider_api_format,
user_api_key_id,
);
}
pub fn apply_codex_openai_responses_special_headers(
provider_request_headers: &mut BTreeMap<String, String>,
provider_request_body: &Value,
original_headers: &http::HeaderMap,
provider_type: &str,
provider_api_format: &str,
request_id: Option<&str>,
decrypted_auth_config_raw: Option<&str>,
) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
let prompt_cache_key = provider_request_body
.get("prompt_cache_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if !header_map_has_non_empty_value(original_headers, "chatgpt-account-id")
&& !btree_map_has_non_empty_value(provider_request_headers, "chatgpt-account-id")
{
if let Some(account_id) = extract_codex_account_id(decrypted_auth_config_raw) {
provider_request_headers.insert("chatgpt-account-id".to_string(), account_id);
}
}
if !header_map_has_non_empty_value(original_headers, "x-client-request-id")
&& !btree_map_has_non_empty_value(provider_request_headers, "x-client-request-id")
{
if let Some(request_id) = request_id.map(str::trim).filter(|value| !value.is_empty()) {
provider_request_headers
.insert("x-client-request-id".to_string(), request_id.to_string());
}
}
if !is_openai_image_request(provider_api_format) {
maybe_insert_default_codex_header(
provider_request_headers,
original_headers,
"user-agent",
CODEX_DEFAULT_USER_AGENT,
);
maybe_insert_default_codex_header(
provider_request_headers,
original_headers,
"originator",
CODEX_DEFAULT_ORIGINATOR,
);
}
let short_session_id = prompt_cache_key.and_then(build_short_codex_header_id);
if !header_map_has_non_empty_value(original_headers, "session_id")
&& !btree_map_has_non_empty_value(provider_request_headers, "session_id")
{
if let Some(short_session_id) = short_session_id.as_deref() {
provider_request_headers.insert("session_id".to_string(), short_session_id.to_string());
}
}
if aether_ai_formats::is_openai_responses_format(provider_api_format)
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
&& !btree_map_has_non_empty_value(provider_request_headers, "conversation_id")
{
if let Some(short_session_id) = short_session_id.as_deref() {
provider_request_headers
.insert("conversation_id".to_string(), short_session_id.to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::{
apply_codex_openai_responses_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
};
use serde_json::json;
#[test]
fn codex_image_body_edits_force_tool_choice_and_default_generate_tool_fields() {
let mut provider_request_body = json!({
"input": [{
"role": "user",
"content": "generate image"
}],
"tools": [{
"type": "image_generation"
}],
"tool_choice": "auto"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:image",
None,
None,
);
assert_eq!(
provider_request_body["tools"][0]["size"],
json!("1024x1024")
);
assert_eq!(provider_request_body["tools"][0]["quality"], json!("high"));
assert_eq!(
provider_request_body["tools"][0]["background"],
json!("auto")
);
assert_eq!(
provider_request_body["tools"][0]["output_format"],
json!("png")
);
assert_eq!(
provider_request_body["tools"][0]["action"],
json!("generate")
);
assert_eq!(
provider_request_body["model"],
json!(CODEX_OPENAI_IMAGE_INTERNAL_MODEL)
);
assert_eq!(provider_request_body["stream"], json!(true));
assert_eq!(
provider_request_body["tool_choice"]["type"],
json!("image_generation")
);
}
#[test]
fn codex_image_body_edits_preserve_edit_action_without_generate_defaults() {
let mut provider_request_body = json!({
"tools": [{
"type": "image_generation",
"action": "edit",
"input_image_mask": { "image_url": "data:image/png;base64,mask" }
}],
"input": [{
"role": "user",
"content": [{
"type": "input_image",
"image_url": "data:image/png;base64,image"
}]
}],
"tool_choice": "auto"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:image",
None,
None,
);
assert_eq!(provider_request_body["tools"][0]["action"], json!("edit"));
assert!(provider_request_body["tools"][0].get("size").is_none());
assert!(provider_request_body["tools"][0].get("quality").is_none());
assert!(provider_request_body["tools"][0]
.get("background")
.is_none());
assert_eq!(
provider_request_body["tools"][0]["output_format"],
json!("png")
);
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
json!("Create a faithful variation of the provided image.")
);
assert_eq!(
provider_request_body["tool_choice"]["type"],
json!("image_generation")
);
}
}

View File

@@ -0,0 +1,21 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalStandardSourceFamily {
Standard,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalStandardSourceMode {
Chat,
Cli,
}
#[derive(Debug, Clone, Copy)]
pub struct LocalStandardSpec {
pub api_format: &'static str,
pub decision_kind: &'static str,
pub report_kind: &'static str,
pub family: LocalStandardSourceFamily,
pub mode: LocalStandardSourceMode,
pub require_streaming: bool,
}

View File

@@ -0,0 +1,53 @@
use crate::contracts::{GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND};
use crate::request::standard::family::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
report_kind: "gemini_chat_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
report_kind: "gemini_chat_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Chat,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_gemini_chat_sync_spec() {
let spec = resolve_sync_spec("gemini_chat_sync").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_chat_sync_finalize");
assert!(!spec.require_streaming);
}
#[test]
fn resolves_gemini_chat_stream_spec() {
let spec = resolve_stream_spec("gemini_chat_stream").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_chat_stream_success");
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,53 @@
use crate::contracts::{GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND};
use crate::request::standard::family::{
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
};
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
report_kind: "gemini_cli_sync_finalize",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
match plan_kind {
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
api_format: "gemini:generate_content",
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
report_kind: "gemini_cli_stream_success",
family: LocalStandardSourceFamily::Gemini,
mode: LocalStandardSourceMode::Cli,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_gemini_cli_sync_spec() {
let spec = resolve_sync_spec("gemini_cli_sync").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_cli_sync_finalize");
assert!(!spec.require_streaming);
}
#[test]
fn resolves_gemini_cli_stream_spec() {
let spec = resolve_stream_spec("gemini_cli_stream").expect("spec");
assert_eq!(spec.api_format, "gemini:generate_content");
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,12 @@
pub mod chat;
pub mod cli;
use crate::request::standard::LocalStandardSpec;
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
pub mod claude;
pub mod codex;
pub mod family;
pub mod gemini;
pub mod matrix;
pub mod normalize;
pub mod openai_responses;
pub use codex::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
apply_openai_responses_compact_special_body_edits, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
};
pub use family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub use matrix::{build_standard_request_body, normalize_standard_request_to_openai_chat_request};
pub use normalize::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_responses_request_body,
build_local_openai_chat_request_body, build_local_openai_responses_request_body,
};

View File

@@ -0,0 +1,232 @@
use aether_ai_formats::protocol::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request,
normalize_openai_responses_request_to_openai_chat_request,
};
use aether_ai_formats::{request_conversion_kind, RequestConversionKind};
use serde_json::{json, Value};
pub fn build_local_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let request_body_object = body_json.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
if upstream_is_stream {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
match provider_request_body.get_mut("stream_options") {
Some(Value::Object(stream_options)) => {
stream_options.insert("include_usage".to_string(), Value::Bool(true));
}
_ => {
provider_request_body.insert(
"stream_options".to_string(),
json!({
"include_usage": true,
}),
);
}
}
}
Some(Value::Object(provider_request_body))
}
pub fn build_cross_format_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
match conversion_kind {
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
body_json,
mapped_model,
upstream_is_stream,
),
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
body_json,
mapped_model,
upstream_is_stream,
),
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
body_json,
mapped_model,
upstream_is_stream,
false,
)
}
_ => None,
}
}
pub fn build_local_openai_responses_request_body(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
) -> Option<Value> {
let request_body_object = body_json.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
.map(|(key, value)| (key.clone(), value.clone())),
);
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
if require_streaming {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
}
Some(Value::Object(provider_request_body))
}
pub fn build_cross_format_openai_responses_request_body(
body_json: &Value,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let chat_like_request = normalize_openai_responses_request_to_openai_chat_request(body_json)?;
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
match conversion_kind {
RequestConversionKind::ToOpenAIChat => build_local_openai_chat_request_body(
&chat_like_request,
mapped_model,
upstream_is_stream,
),
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
false,
)
}
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
),
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
),
}
}
#[cfg(test)]
mod tests {
use super::build_local_openai_responses_request_body;
use super::{
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
};
use serde_json::{json, Value};
fn object_keys(value: &Value) -> Vec<&str> {
value
.as_object()
.expect("json object")
.keys()
.map(String::as_str)
.collect()
}
#[test]
fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
let body_json = json!({
"model": "gpt-5",
"input": "hello",
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5-upstream",
"openai:responses",
"openai:chat",
false,
)
.expect("openai responses to openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["messages"][0]["role"], "user");
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
}
#[test]
fn local_openai_responses_request_body_preserves_original_field_order() {
let body_json: Value = serde_json::from_str(
r#"{
"model": "gpt-5",
"include": ["reasoning.encrypted_content"],
"input": [],
"instructions": "Keep order"
}"#,
)
.expect("request json should parse");
let provider_request_body =
build_local_openai_responses_request_body(&body_json, "gpt-5-upstream", false)
.expect("openai responses body should build");
assert_eq!(
object_keys(&provider_request_body),
vec!["model", "include", "input", "instructions"]
);
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
}
#[test]
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
let body_json = json!({
"model": "gpt-5",
"messages": [{
"role": "user",
"content": "hello"
}]
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
.expect("openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["stream"], true);
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
}
#[test]
fn streaming_local_openai_chat_request_body_preserves_stream_options_while_forcing_include_usage(
) {
let body_json = json!({
"model": "gpt-5",
"messages": [{
"role": "user",
"content": "hello"
}],
"stream_options": {
"include_usage": false,
"extra": "keep-me"
}
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
.expect("openai chat body should build");
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
assert_eq!(provider_request_body["stream_options"]["extra"], "keep-me");
}
}

View File

@@ -0,0 +1,78 @@
use crate::contracts::{
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
};
#[derive(Debug, Clone, Copy)]
pub struct LocalOpenAiResponsesSpec {
pub api_format: &'static str,
pub decision_kind: &'static str,
pub report_kind: &'static str,
pub compact: bool,
pub require_streaming: bool,
}
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiResponsesSpec> {
match plan_kind {
OPENAI_RESPONSES_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses",
decision_kind: OPENAI_RESPONSES_SYNC_PLAN_KIND,
report_kind: OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
compact: false,
require_streaming: false,
}),
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses:compact",
decision_kind: OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
report_kind: OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND,
compact: true,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiResponsesSpec> {
match plan_kind {
OPENAI_RESPONSES_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses",
decision_kind: OPENAI_RESPONSES_STREAM_PLAN_KIND,
report_kind: OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
compact: false,
require_streaming: true,
}),
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses:compact",
decision_kind: OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
report_kind: OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
compact: true,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_openai_responses_sync_spec() {
let spec = resolve_sync_spec("openai_responses_sync").expect("spec");
assert_eq!(spec.api_format, "openai:responses");
assert_eq!(spec.report_kind, "openai_responses_sync_success");
assert!(!spec.compact);
assert!(!spec.require_streaming);
}
#[test]
fn resolves_openai_responses_compact_stream_spec() {
let spec = resolve_stream_spec("openai_responses_compact_stream").expect("spec");
assert_eq!(spec.api_format, "openai:responses:compact");
assert_eq!(spec.report_kind, "openai_responses_compact_stream_success");
assert!(spec.compact);
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,326 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::contracts::core_success_background_report_kind;
#[derive(Debug, Clone, PartialEq)]
pub struct LocalSyncReportParts {
pub trace_id: String,
pub report_kind: String,
pub report_context: Option<Value>,
pub status_code: u16,
pub headers: BTreeMap<String, String>,
pub body_json: Option<Value>,
pub client_body_json: Option<Value>,
pub body_base64: Option<String>,
}
pub fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub fn canonicalize_tool_arguments(value: Option<Value>) -> String {
match value {
Some(Value::String(text)) => text,
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
None => "{}".to_string(),
}
}
pub fn remove_empty_pages_from_tool_arguments(arguments: &str) -> String {
let Ok(mut value) = serde_json::from_str::<Value>(arguments) else {
return arguments.to_string();
};
let Some(object) = value.as_object_mut() else {
return arguments.to_string();
};
if object.get("pages").and_then(Value::as_str) != Some("") {
return arguments.to_string();
}
object.remove("pages");
serde_json::to_string(&value).unwrap_or_else(|_| arguments.to_string())
}
pub fn prepare_local_success_response_parts(
headers: &BTreeMap<String, String>,
body_json: &Value,
) -> serde_json::Result<(Vec<u8>, BTreeMap<String, String>)> {
prepare_local_success_response_parts_owned(headers.clone(), body_json)
}
pub fn prepare_local_success_response_parts_owned(
mut headers: BTreeMap<String, String>,
body_json: &Value,
) -> serde_json::Result<(Vec<u8>, BTreeMap<String, String>)> {
headers.remove("content-encoding");
headers.remove("content-length");
headers.insert("content-type".to_string(), "application/json".to_string());
let body_bytes = serde_json::to_vec(body_json)?;
headers.insert("content-length".to_string(), body_bytes.len().to_string());
Ok((body_bytes, headers))
}
fn should_capture_client_sync_success_body(payload: &LocalSyncReportParts) -> bool {
payload
.report_context
.as_ref()
.and_then(Value::as_object)
.and_then(|context| context.get("upstream_is_stream"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub fn build_local_success_background_report(
payload: &LocalSyncReportParts,
body_json: Value,
headers: BTreeMap<String, String>,
) -> Option<LocalSyncReportParts> {
let report_kind = core_success_background_report_kind(payload.report_kind.as_str())?;
let upstream_is_stream = should_capture_client_sync_success_body(payload);
let client_body_json = upstream_is_stream.then(|| body_json.clone());
let provider_body_json = if upstream_is_stream {
payload.body_json.clone()
} else {
Some(body_json)
};
let provider_body_base64 = if upstream_is_stream {
payload.body_base64.clone()
} else {
None
};
Some(LocalSyncReportParts {
trace_id: payload.trace_id.clone(),
report_kind: report_kind.to_string(),
report_context: payload.report_context.clone(),
status_code: payload.status_code,
headers,
body_json: provider_body_json,
client_body_json,
body_base64: provider_body_base64,
})
}
pub fn build_local_success_conversion_background_report(
payload: &LocalSyncReportParts,
client_body_json: Value,
provider_body_json: Value,
) -> Option<LocalSyncReportParts> {
let report_kind = core_success_background_report_kind(payload.report_kind.as_str())?;
Some(LocalSyncReportParts {
trace_id: payload.trace_id.clone(),
report_kind: report_kind.to_string(),
report_context: payload.report_context.clone(),
status_code: payload.status_code,
headers: payload.headers.clone(),
body_json: Some(provider_body_json),
client_body_json: Some(client_body_json),
body_base64: None,
})
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use serde_json::Value;
use super::{
build_generated_tool_call_id, build_local_success_background_report,
build_local_success_conversion_background_report, canonicalize_tool_arguments,
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
remove_empty_pages_from_tool_arguments, LocalSyncReportParts,
};
use std::collections::BTreeMap;
#[test]
fn generated_tool_call_ids_are_stable() {
assert_eq!(build_generated_tool_call_id(3), "call_auto_3");
}
#[test]
fn canonicalizes_tool_arguments() {
assert_eq!(
canonicalize_tool_arguments(Some(serde_json::json!({"x": 1}))),
"{\"x\":1}"
);
assert_eq!(canonicalize_tool_arguments(None), "{}");
}
#[test]
fn removes_empty_pages_from_tool_arguments() {
assert_eq!(
remove_empty_pages_from_tool_arguments(
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20,"pages":""}"#
),
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments(r#"{"pages":"1-2"}"#),
r#"{"pages":"1-2"}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments(r#"{"pages":"#),
r#"{"pages":"#
);
}
#[test]
fn prepare_local_success_response_parts_normalizes_headers() {
let headers = BTreeMap::from([
("content-encoding".to_string(), "gzip".to_string()),
("content-length".to_string(), "999".to_string()),
("x-test".to_string(), "1".to_string()),
]);
let (body_bytes, normalized_headers) =
prepare_local_success_response_parts(&headers, &serde_json::json!({"ok": true}))
.expect("response parts should serialize");
assert_eq!(
serde_json::from_slice::<Value>(&body_bytes).expect("json body"),
serde_json::json!({"ok": true})
);
assert_eq!(
normalized_headers.get("content-type").map(String::as_str),
Some("application/json")
);
assert!(!normalized_headers.contains_key("content-encoding"));
let expected_length = body_bytes.len().to_string();
assert_eq!(
normalized_headers.get("content-length").map(String::as_str),
Some(expected_length.as_str())
);
assert_eq!(
normalized_headers.get("x-test").map(String::as_str),
Some("1")
);
}
#[test]
fn prepare_local_success_response_parts_owned_normalizes_headers() {
let headers = BTreeMap::from([
("content-encoding".to_string(), "gzip".to_string()),
("content-length".to_string(), "999".to_string()),
("x-test".to_string(), "1".to_string()),
]);
let (body_bytes, normalized_headers) =
prepare_local_success_response_parts_owned(headers, &serde_json::json!({"ok": true}))
.expect("response parts should serialize");
assert_eq!(
serde_json::from_slice::<Value>(&body_bytes).expect("json body"),
serde_json::json!({"ok": true})
);
assert_eq!(
normalized_headers.get("content-type").map(String::as_str),
Some("application/json")
);
assert!(!normalized_headers.contains_key("content-encoding"));
let expected_length = body_bytes.len().to_string();
assert_eq!(
normalized_headers.get("content-length").map(String::as_str),
Some(expected_length.as_str())
);
assert_eq!(
normalized_headers.get("x-test").map(String::as_str),
Some("1")
);
}
#[test]
fn build_local_success_background_report_maps_finalize_kind() {
let payload = LocalSyncReportParts {
trace_id: "trace-1".to_string(),
report_kind: "openai_chat_sync_finalize".to_string(),
report_context: Some(serde_json::json!({"request_id": "req-1"})),
status_code: 200,
headers: BTreeMap::from([("x-test".to_string(), "1".to_string())]),
body_json: None,
client_body_json: None,
body_base64: None,
};
let report = build_local_success_background_report(
&payload,
serde_json::json!({"id": "resp-1"}),
payload.headers.clone(),
)
.expect("success report should be built");
assert_eq!(report.report_kind, "openai_chat_sync_success");
assert_eq!(report.body_json, Some(serde_json::json!({"id": "resp-1"})));
assert_eq!(report.client_body_json, None);
}
#[test]
fn build_local_success_background_report_preserves_provider_stream_for_upstream_stream_sync() {
let payload = LocalSyncReportParts {
trace_id: "trace-1b".to_string(),
report_kind: "openai_chat_sync_finalize".to_string(),
report_context: Some(serde_json::json!({
"request_id": "req-1b",
"upstream_is_stream": true
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
body_json: None,
client_body_json: None,
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1b\",\"object\":\"response\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1b\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n",
)
)),
};
let report = build_local_success_background_report(
&payload,
serde_json::json!({"id": "resp-1b"}),
payload.headers.clone(),
)
.expect("success report should be built");
assert_eq!(report.body_json, None);
assert_eq!(
report.client_body_json,
Some(serde_json::json!({"id": "resp-1b"}))
);
assert_eq!(report.body_base64, payload.body_base64);
}
#[test]
fn build_local_success_conversion_background_report_maps_provider_body() {
let payload = LocalSyncReportParts {
trace_id: "trace-2".to_string(),
report_kind: "openai_chat_sync_finalize".to_string(),
report_context: Some(serde_json::json!({"request_id": "req-2"})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
body_json: None,
client_body_json: None,
body_base64: None,
};
let report = build_local_success_conversion_background_report(
&payload,
serde_json::json!({"client": true}),
serde_json::json!({"provider": true}),
)
.expect("conversion success report should be built");
assert_eq!(report.report_kind, "openai_chat_sync_success");
assert_eq!(
report.body_json,
Some(serde_json::json!({"provider": true}))
);
assert_eq!(
report.client_body_json,
Some(serde_json::json!({"client": true}))
);
}
}

View File

@@ -0,0 +1,168 @@
use serde_json::{Map, Value};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LocalCoreSyncErrorKind {
InvalidRequest,
Authentication,
PermissionDenied,
NotFound,
RateLimit,
ContextLengthExceeded,
Overloaded,
ServerError,
}
pub fn is_core_error_finalize_kind(report_kind: &str) -> bool {
core_error_default_client_api_format(report_kind).is_some()
}
pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
crate::contracts::core_error_default_client_api_format(report_kind)
}
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
crate::contracts::core_error_background_report_kind(report_kind)
}
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
crate::contracts::core_success_background_report_kind(report_kind)
}
pub fn build_core_error_body_for_client_format(
client_api_format: &str,
message: &str,
code: Option<&str>,
kind: LocalCoreSyncErrorKind,
) -> Option<Value> {
let mut error_object = Map::new();
error_object.insert("message".to_string(), Value::String(message.to_string()));
match aether_ai_formats::normalize_api_format_alias(client_api_format).as_str() {
"openai:chat" | "openai:responses" | "openai:responses:compact" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(error_object),
)])))
}
"claude:messages" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_claude_type(kind).to_string()),
);
if let Some(code) = code.filter(|value| !value.is_empty()) {
error_object.insert("code".to_string(), Value::String(code.to_string()));
}
Some(Value::Object(Map::from_iter([
("type".to_string(), Value::String("error".to_string())),
("error".to_string(), Value::Object(error_object)),
])))
}
"gemini:generate_content" => Some(Value::Object(Map::from_iter([(
"error".to_string(),
Value::Object(Map::from_iter([
(
"code".to_string(),
Value::from(map_local_sync_error_kind_to_gemini_code(kind)),
),
("message".to_string(), Value::String(message.to_string())),
(
"status".to_string(),
Value::String(map_local_sync_error_kind_to_gemini_status(kind).to_string()),
),
])),
)]))),
_ => None,
}
}
fn map_local_sync_error_kind_to_openai_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest => "invalid_request_error",
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::ContextLengthExceeded => "context_length_exceeded",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "server_error",
}
}
fn map_local_sync_error_kind_to_claude_type(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"invalid_request_error"
}
LocalCoreSyncErrorKind::Authentication => "authentication_error",
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
LocalCoreSyncErrorKind::NotFound => "not_found_error",
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "api_error",
}
}
fn map_local_sync_error_kind_to_gemini_code(kind: LocalCoreSyncErrorKind) -> u16 {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
400
}
LocalCoreSyncErrorKind::Authentication => 401,
LocalCoreSyncErrorKind::PermissionDenied => 403,
LocalCoreSyncErrorKind::NotFound => 404,
LocalCoreSyncErrorKind::RateLimit => 429,
LocalCoreSyncErrorKind::Overloaded => 503,
LocalCoreSyncErrorKind::ServerError => 500,
}
}
fn map_local_sync_error_kind_to_gemini_status(kind: LocalCoreSyncErrorKind) -> &'static str {
match kind {
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
"INVALID_ARGUMENT"
}
LocalCoreSyncErrorKind::Authentication => "UNAUTHENTICATED",
LocalCoreSyncErrorKind::PermissionDenied => "PERMISSION_DENIED",
LocalCoreSyncErrorKind::NotFound => "NOT_FOUND",
LocalCoreSyncErrorKind::RateLimit => "RESOURCE_EXHAUSTED",
LocalCoreSyncErrorKind::Overloaded => "UNAVAILABLE",
LocalCoreSyncErrorKind::ServerError => "INTERNAL",
}
}
#[cfg(test)]
mod tests {
use super::{
build_core_error_body_for_client_format, core_success_background_report_kind,
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
#[test]
fn builds_openai_core_error_body() {
let body = build_core_error_body_for_client_format(
"openai:chat",
"bad request",
Some("invalid_request"),
LocalCoreSyncErrorKind::InvalidRequest,
)
.expect("body should build");
assert_eq!(body["error"]["message"], "bad request");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "invalid_request");
}
#[test]
fn recognizes_finalize_kind_and_success_mapping() {
assert!(is_core_error_finalize_kind("openai_chat_sync_finalize"));
assert_eq!(
core_success_background_report_kind("openai_chat_sync_finalize"),
Some("openai_chat_sync_success")
);
}
}

View File

@@ -0,0 +1,47 @@
use std::fmt;
pub use self::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
pub use self::standard::stream_core::CanonicalStreamEvent;
pub use self::standard::stream_core::CanonicalStreamFrame;
pub use self::stream_rewrite::{
maybe_build_ai_surface_stream_rewriter, resolve_finalize_stream_rewrite_mode,
AiSurfaceStreamRewriter, FinalizeStreamRewriteMode,
};
pub mod common;
pub mod error_body;
pub mod openai_image_stream;
pub mod sse;
pub mod standard;
pub mod stream_rewrite;
pub mod sync_products;
pub mod sync_to_stream;
#[derive(Debug)]
pub struct AiSurfaceFinalizeError(pub String);
impl AiSurfaceFinalizeError {
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl fmt::Display for AiSurfaceFinalizeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AI surface finalize error: {}", self.0)
}
}
impl std::error::Error for AiSurfaceFinalizeError {}
impl From<serde_json::Error> for AiSurfaceFinalizeError {
fn from(source: serde_json::Error) -> Self {
Self(source.to_string())
}
}
impl From<base64::DecodeError> for AiSurfaceFinalizeError {
fn from(source: base64::DecodeError) -> Self {
Self(source.to_string())
}
}

View File

@@ -0,0 +1,714 @@
use base64::Engine as _;
use serde_json::Value;
use crate::contracts::OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND;
use crate::request::standard::CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT;
use crate::response::sse::encode_json_sse;
use crate::response::AiSurfaceFinalizeError;
#[derive(Default)]
pub struct OpenAiImageStreamState {
buffered: Vec<u8>,
latest_image: Option<OpenAiImageFrame>,
emitted_partial_count: u64,
saw_upstream_partial: bool,
emitted_failure: bool,
}
#[derive(Clone)]
struct OpenAiImageFrame {
b64_json: String,
}
impl OpenAiImageStreamState {
pub fn push_chunk(
&mut self,
report_context: &Value,
chunk: &[u8],
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(block_end) = find_sse_block_end(&self.buffered) {
let block = self.buffered.drain(..block_end).collect::<Vec<_>>();
output.extend(self.transform_block(report_context, &block)?);
drain_sse_separator(&mut self.buffered);
}
Ok(output)
}
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.buffered.is_empty() {
return Ok(Vec::new());
}
let block = std::mem::take(&mut self.buffered);
self.transform_block(report_context, &block)
}
fn transform_block(
&mut self,
report_context: &Value,
block: &[u8],
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let text = std::str::from_utf8(block)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
let mut event_name = None::<String>;
let mut data_lines = Vec::new();
for raw_line in text.lines() {
let line = raw_line.trim_end_matches('\r');
if let Some(value) = line.strip_prefix("event:") {
event_name = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
}
let data = data_lines.join("\n");
if data.is_empty() || data == "[DONE]" {
return Ok(Vec::new());
}
let event: Value = serde_json::from_str(&data)?;
let event_type = event
.get("type")
.and_then(Value::as_str)
.or(event_name.as_deref())
.unwrap_or_default();
match event_type {
"error" | "response.failed" => self.handle_failed(report_context, &event),
"response.image_generation_call.partial_image" => {
self.handle_image_generation_partial(report_context, &event)
}
"response.output_item.done" => self.handle_output_item_done(report_context, &event),
"response.completed" => self.handle_completed(report_context, &event),
_ => Ok(Vec::new()),
}
}
fn handle_image_generation_partial(
&mut self,
report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.emitted_failure {
return Ok(Vec::new());
}
if requested_partial_images(report_context) == 0 {
return Ok(Vec::new());
}
let Some(result) = event
.get("partial_image_b64")
.or_else(|| event.get("b64_json"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(Vec::new());
};
let partial_image_index = event
.get("partial_image_index")
.or_else(|| event.get("output_index"))
.and_then(Value::as_u64)
.unwrap_or(self.emitted_partial_count);
self.emitted_partial_count = self
.emitted_partial_count
.max(partial_image_index.saturating_add(1));
self.saw_upstream_partial = true;
self.latest_image = Some(OpenAiImageFrame {
b64_json: result.to_string(),
});
encode_json_sse(
Some(image_partial_event_name(report_context)),
&serde_json::json!({
"type": image_partial_event_name(report_context),
"b64_json": result,
"partial_image_index": partial_image_index,
}),
)
}
fn handle_output_item_done(
&mut self,
report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.emitted_failure {
return Ok(Vec::new());
}
let Some(item) = event.get("item").and_then(Value::as_object) else {
return Ok(Vec::new());
};
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
return Ok(Vec::new());
}
let Some(result) = item.get("result").and_then(Value::as_str).map(str::trim) else {
return Ok(Vec::new());
};
if result.is_empty() {
return Ok(Vec::new());
}
self.latest_image = Some(OpenAiImageFrame {
b64_json: result.to_string(),
});
if requested_partial_images(report_context) == 0 || self.saw_upstream_partial {
return Ok(Vec::new());
}
let partial_image_index = event
.get("output_index")
.and_then(Value::as_u64)
.unwrap_or(self.emitted_partial_count);
self.emitted_partial_count = partial_image_index.saturating_add(1);
encode_json_sse(
Some(image_partial_event_name(report_context)),
&serde_json::json!({
"type": image_partial_event_name(report_context),
"b64_json": result,
"partial_image_index": partial_image_index,
}),
)
}
fn handle_completed(
&mut self,
report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.emitted_failure {
return Ok(Vec::new());
}
if self.latest_image.is_none() {
if let Some(result) = completed_response_image_result(event) {
self.latest_image = Some(OpenAiImageFrame {
b64_json: result.to_string(),
});
}
}
let Some(latest_image) = self.latest_image.clone() else {
return Ok(Vec::new());
};
let usage = event
.get("response")
.and_then(Value::as_object)
.and_then(|response| {
response
.get("tool_usage")
.and_then(|value| value.get("image_gen"))
.cloned()
.or_else(|| response.get("usage").cloned())
})
.unwrap_or(Value::Null);
encode_json_sse(
Some(image_completed_event_name(report_context)),
&serde_json::json!({
"type": image_completed_event_name(report_context),
"b64_json": latest_image.b64_json,
"usage": usage,
}),
)
}
fn handle_failed(
&mut self,
report_context: &Value,
event: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.emitted_failure {
return Ok(Vec::new());
}
self.emitted_failure = true;
let error = image_failure_error(event);
encode_json_sse(
Some(image_failed_event_name(report_context)),
&serde_json::json!({
"type": image_failed_event_name(report_context),
"error": error,
}),
)
}
}
fn image_failure_error(event: &Value) -> Value {
let mut error = event
.get("error")
.or_else(|| event.get("response").and_then(|value| value.get("error")))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if !error.contains_key("message") {
if let Some(message) = event
.get("message")
.and_then(Value::as_str)
.or_else(|| {
event
.get("response")
.and_then(|value| value.get("error"))
.and_then(|value| value.get("message"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
{
error.insert("message".to_string(), Value::String(message.to_string()));
}
}
if !error.contains_key("code") {
if let Some(code) = event
.get("code")
.or_else(|| {
event
.get("response")
.and_then(|value| value.get("error"))
.and_then(|value| value.get("code"))
})
.cloned()
{
error.insert("code".to_string(), code);
}
}
if !error.contains_key("type") {
let inferred_type = error
.get("code")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.unwrap_or("upstream_error");
error.insert("type".to_string(), Value::String(inferred_type.to_string()));
}
if !error.contains_key("message") {
error.insert(
"message".to_string(),
Value::String("Image generation failed".to_string()),
);
}
Value::Object(error)
}
fn completed_response_image_result(event: &Value) -> Option<&str> {
event
.get("response")
.and_then(|value| value.get("output"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("image_generation_call"))
.filter_map(|item| item.get("result").and_then(Value::as_str))
.map(str::trim)
.find(|value| !value.is_empty())
}
fn requested_partial_images(report_context: &Value) -> u64 {
report_context
.get("image_request")
.and_then(|value| value.get("partial_images"))
.and_then(Value::as_u64)
.unwrap_or(0)
}
fn image_partial_event_name(report_context: &Value) -> &'static str {
if image_request_operation(report_context) == Some("edit") {
"image_edit.partial_image"
} else {
"image_generation.partial_image"
}
}
fn image_completed_event_name(report_context: &Value) -> &'static str {
if image_request_operation(report_context) == Some("edit") {
"image_edit.completed"
} else {
"image_generation.completed"
}
}
fn image_failed_event_name(report_context: &Value) -> &'static str {
if image_request_operation(report_context) == Some("edit") {
"image_edit.failed"
} else {
"image_generation.failed"
}
}
fn image_request_operation(report_context: &Value) -> Option<&str> {
report_context
.get("image_request")
.and_then(|value| value.get("operation"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn find_sse_block_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(2)
.position(|window| window == b"\n\n")
.map(|index| index + 2)
.or_else(|| {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
})
}
fn drain_sse_separator(buffer: &mut Vec<u8>) {
while matches!(buffer.first(), Some(b'\n' | b'\r')) {
buffer.remove(0);
}
}
pub struct OpenAiImageSyncFinalizeProduct {
pub client_body_json: Value,
pub provider_body_json: Value,
}
pub fn maybe_build_openai_image_sync_finalize_product(
report_kind: &str,
status_code: u16,
report_context: Option<&Value>,
body_base64: Option<&str>,
) -> Result<Option<OpenAiImageSyncFinalizeProduct>, AiSurfaceFinalizeError> {
if report_kind != OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND || status_code >= 400 {
return Ok(None);
}
let Some(report_context) = report_context else {
return Ok(None);
};
if report_context
.get("client_api_format")
.and_then(Value::as_str)
.map(str::trim)
!= Some("openai:image")
{
return Ok(None);
}
let Some(body_base64) = body_base64 else {
return Ok(None);
};
let default_output_format = report_context
.get("image_request")
.and_then(|value| value.get("output_format"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT);
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
let text = std::str::from_utf8(&body_bytes)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
let mut created = None;
let mut completed_response = None;
let mut images = Vec::new();
for raw_block in text.split("\n\n") {
let block = raw_block.trim();
if block.is_empty() {
continue;
}
let data_line = block
.lines()
.find_map(|line| line.trim().strip_prefix("data:").map(str::trim));
let Some(data_line) = data_line else {
continue;
};
if data_line.is_empty() || data_line == "[DONE]" {
continue;
}
let event: Value = serde_json::from_str(data_line)?;
match event
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"response.created" => {
created = event
.get("response")
.and_then(|value| value.get("created_at"))
.and_then(Value::as_i64)
.or(created);
}
"response.output_item.done" => {
let Some(item) = event.get("item").and_then(Value::as_object) else {
continue;
};
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
continue;
}
let Some(result) = item.get("result").and_then(Value::as_str) else {
continue;
};
images.push(serde_json::json!({
"b64_json": result,
"output_format": item.get("output_format").cloned().unwrap_or(Value::String(default_output_format.to_string())),
"revised_prompt": item.get("revised_prompt").cloned().unwrap_or(Value::Null),
}));
}
"response.completed" => {
completed_response = event.get("response").and_then(Value::as_object).cloned();
}
_ => {}
}
}
if images.is_empty() {
return Ok(None);
}
let completed_response = completed_response.unwrap_or_default();
let provider_usage = completed_response
.get("tool_usage")
.and_then(|value| value.get("image_gen"))
.cloned()
.or_else(|| completed_response.get("usage").cloned());
let provider_body_json = serde_json::json!({
"id": completed_response.get("id").cloned().unwrap_or(Value::Null),
"object": "response",
"model": completed_response.get("model").cloned().unwrap_or(Value::Null),
"status": completed_response.get("status").cloned().unwrap_or(Value::String("completed".to_string())),
"usage": provider_usage,
"tool_usage": completed_response.get("tool_usage").cloned().unwrap_or(Value::Null),
"output": images
.iter()
.map(|image| serde_json::json!({
"type": "image_generation_call",
"output_format": image.get("output_format").cloned().unwrap_or(Value::Null),
"revised_prompt": image.get("revised_prompt").cloned().unwrap_or(Value::Null),
}))
.collect::<Vec<_>>(),
});
let client_images = images
.iter()
.map(|image| {
let revised_prompt = image.get("revised_prompt").cloned().unwrap_or(Value::Null);
let b64_json = image
.get("b64_json")
.and_then(Value::as_str)
.unwrap_or_default();
serde_json::json!({
"b64_json": b64_json,
"revised_prompt": revised_prompt,
})
})
.collect::<Vec<_>>();
let client_body_json = serde_json::json!({
"created": created.unwrap_or_default(),
"data": client_images,
"usage": provider_body_json.get("usage").cloned().unwrap_or(Value::Null),
});
Ok(Some(OpenAiImageSyncFinalizeProduct {
client_body_json,
provider_body_json,
}))
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use serde_json::json;
use super::{maybe_build_openai_image_sync_finalize_product, OpenAiImageStreamState};
fn utf8(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("utf8 should decode")
}
#[test]
fn emits_completed_event_for_generate() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:image",
"needs_conversion": false,
"image_request": {
"operation": "generate"
}
});
let mut rewriter = OpenAiImageStreamState::default();
let first = rewriter
.push_chunk(
&report_context,
concat!(
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\"}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(first.is_empty());
let second = rewriter
.push_chunk(
&report_context,
concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"tool_usage\":{\"image_gen\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let output_text = utf8(second);
assert!(output_text.contains("event: image_generation.completed"));
assert!(output_text.contains("\"type\":\"image_generation.completed\""));
assert!(output_text.contains("\"b64_json\":\"aGVsbG8=\""));
assert!(output_text.contains("\"input_tokens\":1"));
assert!(!output_text.contains("data: [DONE]"));
assert!(rewriter
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
#[test]
fn maps_responses_partial_image_events() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:image",
"needs_conversion": false,
"image_request": {
"operation": "generate",
"partial_images": 1
}
});
let mut rewriter = OpenAiImageStreamState::default();
let partial = rewriter
.push_chunk(
&report_context,
concat!(
"event: response.image_generation_call.partial_image\n",
"data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_index\":0,\"partial_image_b64\":\"cGFydGlhbA==\"}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let partial_text = utf8(partial);
assert!(partial_text.contains("event: image_generation.partial_image"));
assert!(partial_text.contains("\"type\":\"image_generation.partial_image\""));
assert!(partial_text.contains("\"b64_json\":\"cGFydGlhbA==\""));
assert!(partial_text.contains("\"partial_image_index\":0"));
assert!(!partial_text.contains("response.image_generation_call.partial_image"));
let done = rewriter
.push_chunk(
&report_context,
concat!(
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\"}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
assert!(done.is_empty());
let completed = rewriter
.push_chunk(
&report_context,
concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":4,\"output_tokens\":5,\"total_tokens\":9}}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let completed_text = utf8(completed);
assert!(completed_text.contains("event: image_generation.completed"));
assert!(completed_text.contains("\"type\":\"image_generation.completed\""));
assert!(completed_text.contains("\"b64_json\":\"ZmluYWw=\""));
assert!(completed_text.contains("\"total_tokens\":9"));
}
#[test]
fn maps_upstream_error_to_generation_failed_once() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:image",
"needs_conversion": false,
"image_request": {
"operation": "generate"
}
});
let mut rewriter = OpenAiImageStreamState::default();
let output = rewriter
.push_chunk(
&report_context,
concat!(
"event: error\n",
"data: {\"type\":\"error\",\"error\":{\"type\":\"input-images\",\"code\":\"rate_limit_exceeded\",\"message\":\"Rate limit reached for gpt-image-2\",\"param\":null}}\n\n",
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Rate limit reached for gpt-image-2\"}}}\n\n"
)
.as_bytes(),
)
.expect("rewrite should succeed");
let output_text = utf8(output);
assert!(output_text.contains("event: image_generation.failed"));
assert_eq!(
output_text
.matches("event: image_generation.failed")
.count(),
1
);
assert!(output_text.contains("\"type\":\"image_generation.failed\""));
assert!(output_text.contains("\"type\":\"input-images\""));
assert!(output_text.contains("\"code\":\"rate_limit_exceeded\""));
assert!(output_text.contains("\"message\":\"Rate limit reached for gpt-image-2\""));
assert!(!output_text.contains("response.failed"));
assert!(rewriter
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
#[test]
fn sync_finalize_product_maps_stream_response_to_client_and_provider_bodies() {
let report_context = json!({
"client_api_format": "openai:image",
"provider_api_format": "openai:image",
"image_request": {
"operation": "generate",
"output_format": "png"
}
});
let body_base64 = base64::engine::general_purpose::STANDARD.encode(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1776839946}}\n\n",
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"image_generation_call\",\"output_format\":\"png\",\"revised_prompt\":\"revised history prompt\",\"result\":\"aGVsbG8=\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img_123\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":171,\"output_tokens\":1372,\"total_tokens\":1543}}}}\n\n"
)
.as_bytes(),
);
let product = maybe_build_openai_image_sync_finalize_product(
"openai_image_sync_finalize",
200,
Some(&report_context),
Some(&body_base64),
)
.expect("finalize should succeed")
.expect("finalize should match");
assert_eq!(product.client_body_json["created"], 1776839946);
assert_eq!(product.client_body_json["data"][0]["b64_json"], "aGVsbG8=");
assert_eq!(
product.client_body_json["data"][0]["revised_prompt"],
"revised history prompt"
);
assert_eq!(product.client_body_json["usage"]["input_tokens"], 171);
assert_eq!(product.provider_body_json["id"], "resp_img_123");
assert_eq!(
product.provider_body_json["output"][0]["output_format"],
"png"
);
assert_eq!(
product.provider_body_json["output"][0]["revised_prompt"],
"revised history prompt"
);
}
}

View File

@@ -0,0 +1,41 @@
use serde_json::Value;
use crate::response::AiSurfaceFinalizeError;
pub fn map_claude_stop_reason(
stop_reason: Option<&str>,
has_tool_calls: bool,
) -> Option<&'static str> {
let mapped = match stop_reason {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some("pause_turn") => Some("stop"),
_ => None,
};
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
Some("tool_calls")
} else {
mapped
}
}
pub fn encode_done_sse() -> Vec<u8> {
b"data: [DONE]\n\n".to_vec()
}
pub fn encode_json_sse(
event: Option<&str>,
value: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut out = Vec::new();
if let Some(event) = event.filter(|value| !value.trim().is_empty()) {
out.extend_from_slice(b"event: ");
out.extend_from_slice(event.as_bytes());
out.push(b'\n');
}
out.extend_from_slice(b"data: ");
out.extend(serde_json::to_vec(value).map_err(AiSurfaceFinalizeError::from)?);
out.extend_from_slice(b"\n\n");
Ok(out)
}

View File

@@ -0,0 +1 @@
pub mod stream;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
pub mod stream;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
pub mod claude;
pub mod gemini;
pub mod openai;
pub mod stream_core;

View File

@@ -0,0 +1 @@
pub mod stream;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
use serde_json::{json, Map, Value};
pub use aether_ai_formats::protocol::stream::{
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
};
pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(line).ok()?;
let trimmed = text.trim_matches('\r').trim();
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
return None;
}
let data_line = trimmed.strip_prefix("data:")?.trim();
if data_line.is_empty() || data_line == "[DONE]" {
return None;
}
serde_json::from_str(data_line).ok()
}
pub fn resolve_identity(
response_id: Option<&str>,
model: Option<&str>,
report_context: &Value,
default_id: &str,
) -> (String, String) {
let id = response_id
.filter(|value| !value.is_empty())
.unwrap_or(default_id)
.to_string();
let model = model
.filter(|value| !value.is_empty())
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown")
.to_string();
(id, model)
}
pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let mut input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.get("output_tokens")
.or_else(|| usage.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_creation_tokens = usage
.get("cache_creation_input_tokens")
.and_then(Value::as_u64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_creation_tokens"))
.and_then(Value::as_u64)
})
.unwrap_or(0);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(Value::as_u64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_u64)
})
.unwrap_or(0);
let reasoning_tokens = usage
.get("reasoning_tokens")
.and_then(Value::as_u64)
.or_else(|| {
usage
.get("output_tokens_details")
.or_else(|| usage.get("completion_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("reasoning_tokens"))
.and_then(Value::as_u64)
})
.unwrap_or(0);
let total_tokens = usage.get("total_tokens").and_then(Value::as_u64).unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
);
if input_tokens == 0 && total_tokens > output_tokens {
input_tokens = total_tokens.saturating_sub(output_tokens);
}
Some(CanonicalUsage {
input_tokens,
output_tokens,
total_tokens,
cache_creation_tokens,
cache_read_tokens,
reasoning_tokens,
..CanonicalUsage::default()
})
}
pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let input_tokens = usage
.get("input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_creation_ephemeral_5m_tokens = usage
.get("cache_creation")
.and_then(Value::as_object)
.and_then(|value| value.get("ephemeral_5m_input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_creation_ephemeral_1h_tokens = usage
.get("cache_creation")
.and_then(Value::as_object)
.and_then(|value| value.get("ephemeral_1h_input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_creation_tokens = usage
.get("cache_creation_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(
cache_creation_ephemeral_5m_tokens.saturating_add(cache_creation_ephemeral_1h_tokens),
);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
let reasoning_tokens = usage
.get("reasoning_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
Some(CanonicalUsage {
input_tokens,
output_tokens,
total_tokens: input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
cache_creation_tokens,
cache_creation_ephemeral_5m_tokens,
cache_creation_ephemeral_1h_tokens,
cache_read_tokens,
reasoning_tokens,
})
}
pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let input_tokens = usage
.get("promptTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.get("candidatesTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0);
let reasoning_tokens = usage
.get("thoughtsTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0);
let cache_read_tokens = usage
.get("cachedContentTokenCount")
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.get("totalTokenCount")
.and_then(Value::as_u64)
.unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_read_tokens),
);
Some(CanonicalUsage {
input_tokens,
output_tokens: output_tokens.saturating_add(reasoning_tokens),
total_tokens,
cache_read_tokens,
reasoning_tokens,
..CanonicalUsage::default()
})
}
pub fn normalize_openai_finish_reason(value: Option<&str>) -> Option<String> {
match value {
Some("function_call") => Some("tool_calls".to_string()),
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
_ => None,
}
}
pub fn map_openai_finish_reason_to_claude(value: Option<&str>) -> &'static str {
match value {
Some("length") => "max_tokens",
Some("tool_calls") | Some("function_call") => "tool_use",
Some("content_filter") => "content_filtered",
_ => "end_turn",
}
}
pub fn map_openai_finish_reason_to_gemini(value: Option<&str>) -> &'static str {
match value {
Some("length") => "MAX_TOKENS",
Some("content_filter") => "SAFETY",
_ => "STOP",
}
}
pub fn parse_json_arguments_value(arguments: &str) -> Option<Value> {
let trimmed = arguments.trim();
if trimmed.is_empty() {
return Some(Value::Object(Map::new()));
}
serde_json::from_str(trimmed).ok()
}
pub fn build_openai_chat_chunk(
id: &str,
model: &str,
text: String,
tool_calls: Option<Vec<Value>>,
finish_reason: Option<&str>,
) -> Value {
let mut delta = Map::new();
delta.insert("role".to_string(), Value::String("assistant".to_string()));
if !text.is_empty() {
delta.insert("content".to_string(), Value::String(text));
} else if tool_calls.is_none() {
delta.insert("content".to_string(), Value::String(String::new()));
}
if let Some(tool_calls) = tool_calls {
delta.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": Value::Object(delta),
"finish_reason": finish_reason,
}]
})
}
pub fn build_openai_chat_role_chunk(id: &str, model: &str) -> Value {
json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {
"role": "assistant"
},
"finish_reason": Value::Null
}]
})
}
pub fn build_openai_chat_finish_chunk(id: &str, model: &str, finish_reason: Option<&str>) -> Value {
json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [{
"index": 0,
"delta": {},
"finish_reason": finish_reason,
}]
})
}
pub fn build_openai_chat_usage_chunk(
id: &str,
model: &str,
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
reasoning_tokens: u64,
) -> Value {
let mut usage = Map::new();
usage.insert("prompt_tokens".to_string(), Value::from(prompt_tokens));
usage.insert(
"completion_tokens".to_string(),
Value::from(completion_tokens),
);
usage.insert("total_tokens".to_string(), Value::from(total_tokens));
if reasoning_tokens > 0 {
usage.insert(
"completion_tokens_details".to_string(),
json!({ "reasoning_tokens": reasoning_tokens }),
);
}
json!({
"id": id,
"object": "chat.completion.chunk",
"model": model,
"choices": [],
"usage": usage,
})
}

View File

@@ -0,0 +1,921 @@
use aether_ai_formats::FormatId;
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
use serde_json::Value;
use crate::response::error_body::{
build_core_error_body_for_client_format, LocalCoreSyncErrorKind,
};
use crate::response::sse::encode_json_sse;
use crate::response::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
use crate::response::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
use crate::response::standard::openai::stream::{
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
OpenAIResponsesProviderState,
};
use crate::response::standard::stream_core::common::{
decode_json_data_line, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
};
use crate::response::AiSurfaceFinalizeError;
#[derive(Default)]
pub struct StreamingStandardFormatMatrix {
provider: Option<ProviderStreamParser>,
client: Option<ClientStreamEmitter>,
terminated: bool,
}
impl StreamingStandardFormatMatrix {
pub fn transform_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.terminated {
return Ok(Vec::new());
}
self.ensure_initialized(report_context);
if let Some(error_body) = build_client_error_body_for_line(report_context, &line) {
self.terminated = true;
return self.emit_error(error_body);
}
let Some(provider) = self.provider.as_mut() else {
return Ok(Vec::new());
};
let frames = provider.push_line(report_context, line)?;
self.emit_frames(frames)
}
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.terminated {
return Ok(Vec::new());
}
self.ensure_initialized(report_context);
let Some(provider) = self.provider.as_mut() else {
return Ok(Vec::new());
};
let frames = provider.finish(report_context)?;
let mut out = self.emit_frames(frames)?;
if let Some(client) = self.client.as_mut() {
out.extend(client.finish()?);
}
Ok(out)
}
fn ensure_initialized(&mut self, report_context: &Value) {
if self.provider.is_some() && self.client.is_some() {
return;
}
let provider_api_format = provider_api_format_for_context(report_context);
let client_api_format = client_api_format_for_context(report_context);
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
self.client = ClientStreamEmitter::for_api_format(client_api_format.as_str());
}
fn emit_frames(
&mut self,
frames: Vec<CanonicalStreamFrame>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(client) = self.client.as_mut() else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for frame in frames {
out.extend(client.emit(frame)?);
}
Ok(out)
}
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(client) = self.client.as_mut() else {
return Ok(Vec::new());
};
client.emit_error(error_body)
}
}
#[derive(Default)]
pub struct StreamingStandardTerminalObserver {
provider: Option<ProviderStreamParser>,
latest_summary: Option<ExecutionStreamTerminalSummary>,
}
impl StreamingStandardTerminalObserver {
pub fn push_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<(), AiSurfaceFinalizeError> {
self.ensure_initialized(report_context);
let Some(provider) = self.provider.as_mut() else {
return Ok(());
};
let frames = provider.push_line(report_context, line)?;
self.observe_frames(frames);
Ok(())
}
pub fn finish(
&mut self,
report_context: &Value,
) -> Result<Option<ExecutionStreamTerminalSummary>, AiSurfaceFinalizeError> {
self.ensure_initialized(report_context);
let Some(provider) = self.provider.as_mut() else {
return Ok(self.latest_summary.clone());
};
let frames = provider.finish(report_context)?;
self.observe_frames(frames);
Ok(self.latest_summary.clone())
}
pub fn disable_with_error(&mut self, parser_error: impl Into<String>) {
let parser_error = parser_error.into();
if let Some(summary) = self.latest_summary.as_mut() {
if summary.parser_error.is_none() {
summary.parser_error = Some(parser_error);
}
} else {
self.latest_summary = Some(ExecutionStreamTerminalSummary {
parser_error: Some(parser_error),
..ExecutionStreamTerminalSummary::default()
});
}
self.provider = None;
}
pub fn latest_summary(&self) -> Option<&ExecutionStreamTerminalSummary> {
self.latest_summary.as_ref()
}
fn ensure_initialized(&mut self, report_context: &Value) {
if self.provider.is_some() || self.latest_summary.is_some() {
return;
}
let provider_api_format = provider_api_format_for_context(report_context);
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
}
fn observe_frames(&mut self, frames: Vec<CanonicalStreamFrame>) {
for frame in frames {
self.observe_frame(frame);
}
}
fn observe_frame(&mut self, frame: CanonicalStreamFrame) {
let CanonicalStreamFrame { id, model, event } = frame;
let summary = self
.latest_summary
.get_or_insert_with(|| ExecutionStreamTerminalSummary {
response_id: Some(id.clone()),
model: Some(model.clone()),
..ExecutionStreamTerminalSummary::default()
});
if summary.response_id.is_none() {
summary.response_id = Some(id);
}
if summary.model.is_none() {
summary.model = Some(model);
}
match event {
CanonicalStreamEvent::UnknownEvent(_) => {
summary.unknown_event_count = summary.unknown_event_count.saturating_add(1);
}
CanonicalStreamEvent::Finish {
finish_reason,
usage,
} => {
summary.finish_reason = finish_reason;
summary.standardized_usage = usage.map(standardized_usage_from_canonical);
summary.observed_finish = true;
}
_ => {}
}
}
}
enum ProviderStreamParser {
OpenAIChat(OpenAIChatProviderState),
OpenAIResponses(OpenAIResponsesProviderState),
Claude(ClaudeProviderState),
Gemini(GeminiProviderState),
}
impl ProviderStreamParser {
fn for_api_format(provider_api_format: &str) -> Option<Self> {
Some(match FormatId::parse(provider_api_format)? {
FormatId::OpenAiChat => Self::OpenAIChat(OpenAIChatProviderState::default()),
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
Self::OpenAIResponses(OpenAIResponsesProviderState::default())
}
FormatId::ClaudeMessages => Self::Claude(ClaudeProviderState::default()),
FormatId::GeminiGenerateContent => Self::Gemini(GeminiProviderState::default()),
})
}
fn push_line(
&mut self,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
match self {
ProviderStreamParser::OpenAIChat(state) => state.push_line(report_context, line),
ProviderStreamParser::OpenAIResponses(state) => state.push_line(report_context, line),
ProviderStreamParser::Claude(state) => state.push_line(report_context, line),
ProviderStreamParser::Gemini(state) => state.push_line(report_context, line),
}
}
fn finish(
&mut self,
report_context: &Value,
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
match self {
ProviderStreamParser::OpenAIChat(state) => state.finish(report_context),
ProviderStreamParser::OpenAIResponses(state) => state.finish(report_context),
ProviderStreamParser::Claude(state) => state.finish(report_context),
ProviderStreamParser::Gemini(state) => state.finish(report_context),
}
}
}
enum ClientStreamEmitter {
OpenAIChat(OpenAIChatClientEmitter),
OpenAIResponses(OpenAIResponsesClientEmitter),
Claude(ClaudeClientEmitter),
Gemini(GeminiClientEmitter),
}
fn provider_api_format_for_context(report_context: &Value) -> String {
string_context_field(report_context, "provider_stream_event_api_format")
.or_else(|| string_context_field(report_context, "provider_stream_api_format"))
.or_else(|| string_context_field(report_context, "provider_api_format"))
.unwrap_or_default()
}
fn string_context_field(report_context: &Value, key: &str) -> Option<String> {
let value = report_context.get(key)?.as_str()?.trim();
(!value.is_empty()).then(|| value.to_ascii_lowercase())
}
fn client_api_format_for_context(report_context: &Value) -> String {
report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
}
fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage {
let mut standardized = StandardizedUsage::new();
standardized.input_tokens = usage.input_tokens as i64;
standardized.output_tokens = usage.output_tokens as i64;
standardized.cache_creation_tokens = usage.cache_creation_tokens as i64;
standardized.cache_creation_ephemeral_5m_tokens =
usage.cache_creation_ephemeral_5m_tokens as i64;
standardized.cache_creation_ephemeral_1h_tokens =
usage.cache_creation_ephemeral_1h_tokens as i64;
standardized.cache_read_tokens = usage.cache_read_tokens as i64;
standardized.reasoning_tokens = usage.reasoning_tokens as i64;
standardized.dimensions.insert(
"total_tokens".to_string(),
serde_json::json!(usage.total_tokens),
);
standardized.normalize_cache_creation_breakdown()
}
impl ClientStreamEmitter {
fn for_api_format(client_api_format: &str) -> Option<Self> {
Some(match FormatId::parse(client_api_format)? {
FormatId::OpenAiChat => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
Self::OpenAIResponses(OpenAIResponsesClientEmitter::default())
}
FormatId::ClaudeMessages => Self::Claude(ClaudeClientEmitter::default()),
FormatId::GeminiGenerateContent => Self::Gemini(GeminiClientEmitter::default()),
})
}
fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match self {
ClientStreamEmitter::OpenAIChat(state) => state.emit(frame),
ClientStreamEmitter::OpenAIResponses(state) => state.emit(frame),
ClientStreamEmitter::Claude(state) => state.emit(frame),
ClientStreamEmitter::Gemini(state) => state.emit(frame),
}
}
fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match self {
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
ClientStreamEmitter::OpenAIResponses(state) => state.finish(),
ClientStreamEmitter::Claude(state) => state.finish(),
ClientStreamEmitter::Gemini(state) => state.finish(),
}
}
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match self {
ClientStreamEmitter::OpenAIResponses(state) => state.emit_error(error_body),
ClientStreamEmitter::Claude(_) => {
let event = error_body.get("type").and_then(Value::as_str);
encode_json_sse(event, &error_body)
}
ClientStreamEmitter::OpenAIChat(_) | ClientStreamEmitter::Gemini(_) => {
encode_json_sse(None, &error_body)
}
}
}
}
fn build_client_error_body_for_line(report_context: &Value, line: &[u8]) -> Option<Value> {
let value = decode_json_data_line(line)?;
let provider_api_format = provider_api_format_for_context(report_context);
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let (message, code, kind) = parse_provider_error(&provider_api_format, &value)?;
build_core_error_body_for_client_format(&client_api_format, &message, code.as_deref(), kind)
}
fn parse_provider_error(
provider_api_format: &str,
payload: &Value,
) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
match FormatId::parse(provider_api_format)? {
FormatId::OpenAiChat | FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
parse_openai_error(payload)
}
FormatId::ClaudeMessages => parse_claude_error(payload),
FormatId::GeminiGenerateContent => parse_gemini_error(payload),
}
}
fn parse_openai_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
let error = payload.get("error")?.as_object()?;
let message = error.get("message").and_then(Value::as_str)?.to_string();
let code = error
.get("code")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let kind = match error
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"invalid_request_error" => LocalCoreSyncErrorKind::InvalidRequest,
"authentication_error" => LocalCoreSyncErrorKind::Authentication,
"permission_error" => LocalCoreSyncErrorKind::PermissionDenied,
"not_found_error" => LocalCoreSyncErrorKind::NotFound,
"rate_limit_error" => LocalCoreSyncErrorKind::RateLimit,
"context_length_exceeded" => LocalCoreSyncErrorKind::ContextLengthExceeded,
"overloaded_error" => LocalCoreSyncErrorKind::Overloaded,
_ => LocalCoreSyncErrorKind::ServerError,
};
Some((message, code, kind))
}
fn parse_claude_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
let error = payload.get("error")?.as_object()?;
let message = error.get("message").and_then(Value::as_str)?.to_string();
let code = error
.get("code")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let kind = match error
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"invalid_request_error" => LocalCoreSyncErrorKind::InvalidRequest,
"authentication_error" => LocalCoreSyncErrorKind::Authentication,
"permission_error" => LocalCoreSyncErrorKind::PermissionDenied,
"not_found_error" => LocalCoreSyncErrorKind::NotFound,
"rate_limit_error" => LocalCoreSyncErrorKind::RateLimit,
"overloaded_error" => LocalCoreSyncErrorKind::Overloaded,
_ => LocalCoreSyncErrorKind::ServerError,
};
Some((message, code, kind))
}
fn parse_gemini_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
let error = payload.get("error")?.as_object()?;
let message = error.get("message").and_then(Value::as_str)?.to_string();
let code = error.get("code").map(|value| match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
_ => String::new(),
});
let kind = match error
.get("status")
.and_then(Value::as_str)
.unwrap_or_default()
{
"INVALID_ARGUMENT" => LocalCoreSyncErrorKind::InvalidRequest,
"UNAUTHENTICATED" => LocalCoreSyncErrorKind::Authentication,
"PERMISSION_DENIED" => LocalCoreSyncErrorKind::PermissionDenied,
"NOT_FOUND" => LocalCoreSyncErrorKind::NotFound,
"RESOURCE_EXHAUSTED" => LocalCoreSyncErrorKind::RateLimit,
"UNAVAILABLE" => LocalCoreSyncErrorKind::Overloaded,
_ => LocalCoreSyncErrorKind::ServerError,
};
let code = code.filter(|value| !value.is_empty());
Some((message, code, kind))
}
#[cfg(test)]
mod tests {
use super::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
use serde_json::{json, Value};
fn report_context(provider_api_format: &str, client_api_format: &str) -> Value {
json!({
"provider_api_format": provider_api_format,
"client_api_format": client_api_format,
"mapped_model": "test-model",
})
}
fn data_line(value: Value) -> Vec<u8> {
format!("data: {}\n", value).into_bytes()
}
#[test]
fn transforms_provider_errors_to_openai_chat_error_bodies() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"type\":\"invalid_request_error\"",
"\"code\":\"invalid_request\"",
),
(
"claude:messages",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"rate_limit\"",
),
(
"gemini:generate_content",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"429\"",
),
];
for (provider_api_format, line, message, err_type, code) in cases {
let report_context = report_context(provider_api_format, "openai:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("data: {\"error\":"));
assert!(!sse.contains("event: "));
assert!(sse.contains(message));
assert!(sse.contains(err_type));
assert!(sse.contains(code));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_provider_errors_to_claude_error_events() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"type\":\"invalid_request_error\"",
"\"code\":\"invalid_request\"",
),
(
"claude:messages",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"rate_limit\"",
),
(
"gemini:generate_content",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"429\"",
),
];
for (provider_api_format, line, message, err_type, code) in cases {
let report_context = report_context(provider_api_format, "claude:messages");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("event: error\n"));
assert!(sse.contains("data: {"));
assert!(sse.contains("\"type\":\"error\""));
assert!(sse.contains("\"error\":{"));
assert!(sse.contains(message));
assert!(sse.contains(err_type));
assert!(sse.contains(code));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_provider_errors_to_gemini_error_bodies() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"code\":400",
"\"status\":\"INVALID_ARGUMENT\"",
),
(
"claude:messages",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"code\":429",
"\"status\":\"RESOURCE_EXHAUSTED\"",
),
(
"gemini:generate_content",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"code\":429",
"\"status\":\"RESOURCE_EXHAUSTED\"",
),
];
for (provider_api_format, line, message, code, status) in cases {
let report_context = report_context(provider_api_format, "gemini:generate_content");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("data: {\"error\":"));
assert!(!sse.contains("event: "));
assert!(sse.contains(message));
assert!(sse.contains(code));
assert!(sse.contains(status));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_provider_errors_to_openai_responses_failed_events() {
let cases = [
(
"openai:chat",
data_line(json!({
"error": {
"message": "bad request",
"type": "invalid_request_error",
"code": "invalid_request",
}
})),
"\"message\":\"bad request\"",
"\"type\":\"invalid_request_error\"",
"\"code\":\"invalid_request\"",
),
(
"claude:messages",
data_line(json!({
"type": "error",
"error": {
"message": "slow down",
"type": "rate_limit_error",
"code": "rate_limit",
}
})),
"\"message\":\"slow down\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"rate_limit\"",
),
(
"gemini:generate_content",
data_line(json!({
"error": {
"code": 429,
"message": "quota exceeded",
"status": "RESOURCE_EXHAUSTED",
}
})),
"\"message\":\"quota exceeded\"",
"\"type\":\"rate_limit_error\"",
"\"code\":\"429\"",
),
];
for (provider_api_format, line, message, err_type, code) in cases {
let report_context = report_context(provider_api_format, "openai:responses");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
.expect("error should convert");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.starts_with("event: response.failed\n"));
assert!(sse.contains("\"sequence_number\":1"));
assert!(sse.contains(message));
assert!(sse.contains(err_type));
assert!(sse.contains(code));
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn rewrites_gemini_inline_image_streams_to_claude_image_blocks() {
let report_context = report_context("gemini:generate_content", "claude:messages");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"responseId": "resp_media_123",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"content": {
"parts": [
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } }
]
}
}]
})),
)
.expect("image chunk should rewrite");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains("event: message_start"));
assert!(sse.contains("\"type\":\"image\""));
assert!(sse.contains("\"media_type\":\"image/png\""));
assert!(sse.contains("\"data\":\"iVBORw0KGgo=\""));
}
#[test]
fn rewrites_claude_image_blocks_to_gemini_inline_image_streams() {
let report_context = report_context("claude:messages", "gemini:generate_content");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo="
}
}
})),
)
.expect("image chunk should rewrite");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(
sse.contains("\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgo=\"}")
);
}
#[test]
fn terminal_observer_preserves_claude_cache_usage() {
let report_context = report_context("claude:messages", "openai:chat");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "message_start",
"message": {
"id": "msg_cache_123",
"model": "claude-sonnet-4-5"
}
})),
)
.expect("message_start should parse");
observer
.push_line(
&report_context,
data_line(json!({
"type": "message_delta",
"delta": {
"stop_reason": "end_turn"
},
"usage": {
"input_tokens": 6,
"output_tokens": 20,
"cache_creation_input_tokens": 42262,
"cache_read_input_tokens": 0
}
})),
)
.expect("message_delta should parse");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
let usage = summary
.standardized_usage
.expect("standardized usage should exist");
assert_eq!(usage.input_tokens, 6);
assert_eq!(usage.output_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 42_262);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn terminal_observer_uses_explicit_provider_stream_event_api_format() {
let mut report_context = report_context("openai:chat", "openai:responses");
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_codex_123",
"object": "response",
"model": "gpt-5.5",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 26,
"input_tokens_details": {
"cached_tokens": 0,
},
"output_tokens": 137,
"output_tokens_details": {
"reasoning_tokens": 10,
},
"total_tokens": 163,
},
},
"sequence_number": 139,
})),
)
.expect("response.completed should parse");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
let usage = summary
.standardized_usage
.expect("standardized usage should exist");
assert_eq!(usage.input_tokens, 26);
assert_eq!(usage.output_tokens, 137);
assert_eq!(usage.reasoning_tokens, 10);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn terminal_observer_does_not_infer_provider_stream_event_api_format() {
let report_context = report_context("openai:chat", "openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.completed",
"response": {
"usage": {
"input_tokens": 26,
"output_tokens": 137,
"total_tokens": 163,
},
},
})),
)
.expect("line should be ignored by explicitly selected chat parser");
assert!(
observer.latest_summary().is_none(),
"provider stream parser selection must come from report context, not event sniffing"
);
}
#[test]
fn terminal_observer_counts_unknown_provider_stream_events() {
let mut report_context = report_context("openai:chat", "openai:responses");
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.future.delta",
"response": {
"id": "resp_unknown_123",
"model": "gpt-5.4",
},
"payload": {
"kept": true,
},
})),
)
.expect("unknown stream event should be observed");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
assert_eq!(summary.response_id.as_deref(), Some("resp_unknown_123"));
assert_eq!(summary.model.as_deref(), Some("gpt-5.4"));
assert_eq!(summary.unknown_event_count, 1);
assert!(!summary.observed_finish);
}
}

View File

@@ -0,0 +1,5 @@
pub mod common;
pub mod format_matrix;
pub use common::{CanonicalStreamEvent, CanonicalStreamFrame};
pub use format_matrix::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};

View File

@@ -0,0 +1,356 @@
use serde_json::Value;
use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
use crate::provider_compat::private_envelope::transform_provider_private_stream_line;
use crate::provider_compat::surfaces::{
provider_adaptation_should_unwrap_stream_envelope, KIRO_ENVELOPE_NAME,
};
use crate::response::openai_image_stream::OpenAiImageStreamState;
use crate::response::standard::stream_core::StreamingStandardFormatMatrix;
use crate::response::AiSurfaceFinalizeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FinalizeStreamRewriteMode {
EnvelopeUnwrap,
OpenAiImage,
Standard,
KiroToClaudeCli,
KiroToClaudeCliThenStandard,
}
pub fn resolve_finalize_stream_rewrite_mode(
report_context: &Value,
) -> Option<FinalizeStreamRewriteMode> {
let needs_conversion = report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if needs_conversion
&& envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME)
&& provider_api_format == "claude:messages"
{
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(),
client_api_format.as_str(),
)
.then_some(FinalizeStreamRewriteMode::Standard);
}
if provider_api_format == "openai:image" && client_api_format == "openai:image" {
return Some(FinalizeStreamRewriteMode::OpenAiImage);
}
if envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME) {
return (provider_api_format == "claude:messages"
&& client_api_format == "claude:messages")
.then_some(FinalizeStreamRewriteMode::KiroToClaudeCli);
}
(provider_api_format == client_api_format
&& provider_adaptation_should_unwrap_stream_envelope(
envelope_name.as_str(),
provider_api_format.as_str(),
))
.then_some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
}
enum AiSurfaceStreamRewriteState {
EnvelopeUnwrap,
OpenAiImage(Box<OpenAiImageStreamState>),
Standard(Box<StreamingStandardFormatMatrix>),
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
KiroToClaudeCliThenStandard {
kiro: Box<KiroToClaudeCliStreamState>,
standard: Box<StreamingStandardFormatMatrix>,
},
}
pub struct AiSurfaceStreamRewriter<'a> {
report_context: &'a Value,
buffered: Vec<u8>,
state: AiSurfaceStreamRewriteState,
}
pub fn maybe_build_ai_surface_stream_rewriter<'a>(
report_context: Option<&'a Value>,
) -> Option<AiSurfaceStreamRewriter<'a>> {
let report_context = report_context?;
let state = match resolve_finalize_stream_rewrite_mode(report_context)? {
FinalizeStreamRewriteMode::EnvelopeUnwrap => AiSurfaceStreamRewriteState::EnvelopeUnwrap,
FinalizeStreamRewriteMode::OpenAiImage => {
AiSurfaceStreamRewriteState::OpenAiImage(Box::<OpenAiImageStreamState>::default())
}
FinalizeStreamRewriteMode::Standard => {
AiSurfaceStreamRewriteState::Standard(Box::<StreamingStandardFormatMatrix>::default())
}
FinalizeStreamRewriteMode::KiroToClaudeCli => AiSurfaceStreamRewriteState::KiroToClaudeCli(
Box::new(KiroToClaudeCliStreamState::new(report_context)),
),
FinalizeStreamRewriteMode::KiroToClaudeCliThenStandard => {
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard {
kiro: Box::new(KiroToClaudeCliStreamState::new(report_context)),
standard: Box::<StreamingStandardFormatMatrix>::default(),
}
}
};
Some(AiSurfaceStreamRewriter {
report_context,
buffered: Vec::new(),
state,
})
}
impl AiSurfaceStreamRewriter<'_> {
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::OpenAiImage(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { kiro, standard } => {
let claude_bytes = kiro.push_chunk(self.report_context, chunk)?;
transform_standard_bytes(standard, self.report_context, claude_bytes)
}
AiSurfaceStreamRewriteState::EnvelopeUnwrap
| AiSurfaceStreamRewriteState::Standard(_) => {
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
output.extend(self.transform_line(line)?);
}
Ok(output)
}
}
}
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::OpenAiImage(state) => state.finish(self.report_context),
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.finish(self.report_context)
}
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { kiro, standard } => {
let mut output = transform_standard_bytes(
standard,
self.report_context,
kiro.finish(self.report_context)?,
)?;
output.extend(standard.finish(self.report_context)?);
Ok(output)
}
AiSurfaceStreamRewriteState::EnvelopeUnwrap
| AiSurfaceStreamRewriteState::Standard(_) => {
if self.buffered.is_empty() {
if let AiSurfaceStreamRewriteState::Standard(state) = &mut self.state {
return state.finish(self.report_context);
}
return Ok(Vec::new());
}
let line = std::mem::take(&mut self.buffered);
let mut output = self.transform_line(line)?;
if let AiSurfaceStreamRewriteState::Standard(state) = &mut self.state {
output.extend(state.finish(self.report_context)?);
}
Ok(output)
}
}
}
fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::EnvelopeUnwrap => {
transform_provider_private_stream_line(self.report_context, line)
.map_err(AiSurfaceFinalizeError::from)
}
AiSurfaceStreamRewriteState::Standard(state) => {
transform_standard_line(state, self.report_context, line)
}
AiSurfaceStreamRewriteState::OpenAiImage(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCli(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { .. } => Ok(Vec::new()),
}
}
}
fn transform_standard_bytes(
standard: &mut StreamingStandardFormatMatrix,
report_context: &Value,
bytes: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
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(transform_standard_line(
standard,
report_context,
line.to_vec(),
)?);
}
Ok(output)
}
fn transform_standard_line(
standard: &mut StreamingStandardFormatMatrix,
report_context: &Value,
line: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let line = if should_unwrap_envelope(report_context) {
transform_provider_private_stream_line(report_context, line)?
} else {
line
};
if line.is_empty() {
return Ok(Vec::new());
}
standard.transform_line(report_context, line)
}
fn should_unwrap_envelope(report_context: &Value) -> bool {
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format)
}
fn supports_standard_stream_rewrite(provider_api_format: &str, client_api_format: &str) -> bool {
is_standard_provider_api_format(provider_api_format)
&& (is_standard_chat_client_api_format(client_api_format)
|| is_standard_cli_client_api_format(client_api_format))
}
fn is_standard_provider_api_format(api_format: &str) -> bool {
matches!(
aether_ai_formats::normalize_api_format_alias(api_format).as_str(),
"openai:chat"
| "openai:responses"
| "openai:responses:compact"
| "claude:messages"
| "gemini:generate_content"
)
}
fn is_standard_chat_client_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat" | "claude:messages" | "gemini:generate_content"
)
}
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
matches!(
aether_ai_formats::normalize_api_format_alias(api_format).as_str(),
"openai:responses"
| "openai:responses:compact"
| "claude:messages"
| "gemini:generate_content"
)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{resolve_finalize_stream_rewrite_mode, FinalizeStreamRewriteMode};
#[test]
fn resolves_standard_mode_for_cross_format_standard_streams() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "openai:chat",
"needs_conversion": true,
});
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::Standard)
);
}
#[test]
fn resolves_envelope_unwrap_for_same_format_private_envelopes() {
let report_context = json!({
"provider_api_format": "gemini:generate_content",
"client_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"needs_conversion": false,
});
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
);
}
#[test]
fn resolves_kiro_same_format_streams_to_kiro_mode() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"envelope_name": "kiro:generateAssistantResponse",
"needs_conversion": false,
});
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::KiroToClaudeCli)
);
}
#[test]
fn rejects_unsupported_non_conversion_streams() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "openai:chat",
"needs_conversion": false,
});
assert_eq!(resolve_finalize_stream_rewrite_mode(&report_context), None);
}
#[test]
fn resolves_openai_image_mode_for_same_format_image_streams() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:image",
"needs_conversion": false,
});
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::OpenAiImage)
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,569 @@
use aether_ai_formats::protocol::conversion::response::{
convert_claude_response_to_openai_responses, convert_gemini_response_to_openai_responses,
convert_openai_chat_response_to_openai_responses,
};
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
use serde_json::{json, Value};
use crate::response::sse::encode_json_sse;
use crate::response::standard::claude::stream::ClaudeClientEmitter;
use crate::response::standard::gemini::stream::GeminiClientEmitter;
use crate::response::standard::openai::stream::{
OpenAIChatClientEmitter, OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
};
use crate::response::standard::stream_core::CanonicalStreamFrame;
use crate::response::AiSurfaceFinalizeError;
pub struct SyncToStreamBridgeOutcome {
pub sse_body: Vec<u8>,
pub terminal_summary: Option<ExecutionStreamTerminalSummary>,
}
pub fn maybe_bridge_standard_sync_json_to_stream(
provider_body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: Option<&Value>,
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
let provider_api_format = normalize_api_format(provider_api_format);
let client_api_format = normalize_api_format(client_api_format);
if provider_api_format == "openai:image" && client_api_format == "openai:image" {
return maybe_bridge_openai_image_sync_json_to_stream(provider_body_json, report_context);
}
if !is_standard_api_format(provider_api_format.as_str())
|| !is_standard_api_format(client_api_format.as_str())
{
return Ok(None);
}
let bridge_context = build_bridge_report_context(
report_context,
provider_api_format.as_str(),
client_api_format.as_str(),
);
let Some(openai_responses_response) = convert_provider_sync_response_to_openai_responses(
provider_body_json,
provider_api_format.as_str(),
&bridge_context,
) else {
return Ok(None);
};
let terminal_summary =
build_terminal_summary_from_openai_responses_response(&openai_responses_response);
let canonical_frames = build_canonical_frames_from_openai_responses_response(
&openai_responses_response,
&bridge_context,
)?;
let sse_body =
emit_client_stream_from_canonical_frames(canonical_frames, client_api_format.as_str())?;
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary,
}))
}
fn maybe_bridge_openai_image_sync_json_to_stream(
provider_body_json: &Value,
report_context: Option<&Value>,
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
let Some(response) = provider_body_json.as_object() else {
return Ok(None);
};
let Some(image) = response
.get("data")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.find_map(extract_openai_image_sync_b64_json)
else {
return Ok(None);
};
let usage = response.get("usage").cloned().unwrap_or(Value::Null);
let event_name = openai_image_completed_event_name(report_context);
let sse_body = encode_json_sse(
Some(event_name),
&json!({
"type": event_name,
"b64_json": image,
"usage": usage,
}),
)?;
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary: Some(ExecutionStreamTerminalSummary {
standardized_usage: response
.get("usage")
.and_then(standardized_usage_from_openai_usage),
finish_reason: Some("stop".to_string()),
response_id: response
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
model: response
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| image_bridge_model(report_context)),
observed_finish: true,
unknown_event_count: 0,
parser_error: None,
}),
}))
}
fn normalize_api_format(value: &str) -> String {
aether_ai_formats::normalize_api_format_alias(value)
}
fn is_standard_api_format(value: &str) -> bool {
matches!(
value,
"openai:chat"
| "openai:responses"
| "openai:responses:compact"
| "claude:messages"
| "gemini:generate_content"
)
}
fn extract_openai_image_sync_b64_json(item: &serde_json::Map<String, Value>) -> Option<String> {
item.get("b64_json")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
item.get("url")
.and_then(Value::as_str)
.and_then(extract_base64_from_data_url)
})
}
fn extract_base64_from_data_url(value: &str) -> Option<String> {
let trimmed = value.trim();
let (metadata, payload) = trimmed.split_once(',')?;
if !metadata.starts_with("data:") || !metadata.ends_with(";base64") {
return None;
}
(!payload.trim().is_empty()).then(|| payload.trim().to_string())
}
fn openai_image_completed_event_name(report_context: Option<&Value>) -> &'static str {
if openai_image_request_operation(report_context) == Some("edit") {
"image_edit.completed"
} else {
"image_generation.completed"
}
}
fn openai_image_request_operation(report_context: Option<&Value>) -> Option<&str> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get("operation"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn image_bridge_model(report_context: Option<&Value>) -> Option<String> {
report_context.and_then(|context| {
context
.get("mapped_model")
.or_else(|| context.get("model"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn build_bridge_report_context(
report_context: Option<&Value>,
provider_api_format: &str,
client_api_format: &str,
) -> Value {
let mut context = report_context
.cloned()
.filter(Value::is_object)
.unwrap_or_else(|| json!({}));
let object = context
.as_object_mut()
.expect("bridge report context should stay object");
object
.entry("provider_api_format".to_string())
.or_insert_with(|| Value::String(provider_api_format.to_string()));
object
.entry("client_api_format".to_string())
.or_insert_with(|| Value::String(client_api_format.to_string()));
context
}
fn convert_provider_sync_response_to_openai_responses(
provider_body_json: &Value,
provider_api_format: &str,
report_context: &Value,
) -> Option<Value> {
match provider_api_format {
"openai:responses" | "openai:responses:compact" => Some(provider_body_json.clone()),
"openai:chat" => convert_openai_chat_response_to_openai_responses(
provider_body_json,
report_context,
false,
),
"claude:messages" => {
convert_claude_response_to_openai_responses(provider_body_json, report_context)
}
"gemini:generate_content" => {
convert_gemini_response_to_openai_responses(provider_body_json, report_context)
}
_ => None,
}
}
fn build_canonical_frames_from_openai_responses_response(
openai_responses_response: &Value,
report_context: &Value,
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
let mut state = OpenAIResponsesProviderState::default();
let line = format!(
"data: {}\n",
serde_json::to_string(&json!({
"type": "response.completed",
"response": openai_responses_response,
}))
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?
);
let mut frames = state
.push_line(report_context, line.into_bytes())
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?;
frames.extend(
state
.finish(report_context)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
Ok(frames)
}
fn emit_client_stream_from_canonical_frames(
canonical_frames: Vec<CanonicalStreamFrame>,
client_api_format: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match client_api_format {
"openai:chat" => {
let mut emitter = OpenAIChatClientEmitter::default();
emit_with_openai_chat_emitter(&mut emitter, canonical_frames)
}
"openai:responses" | "openai:responses:compact" => {
let mut emitter = OpenAIResponsesClientEmitter::default();
emit_with_openai_responses_emitter(&mut emitter, canonical_frames)
}
"claude:messages" => {
let mut emitter = ClaudeClientEmitter::default();
emit_with_claude_emitter(&mut emitter, canonical_frames)
}
"gemini:generate_content" => {
let mut emitter = GeminiClientEmitter::default();
emit_with_gemini_emitter(&mut emitter, canonical_frames)
}
_ => Ok(Vec::new()),
}
}
fn emit_with_openai_chat_emitter(
emitter: &mut OpenAIChatClientEmitter,
canonical_frames: Vec<CanonicalStreamFrame>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
Ok(output)
}
fn emit_with_openai_responses_emitter(
emitter: &mut OpenAIResponsesClientEmitter,
canonical_frames: Vec<CanonicalStreamFrame>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
Ok(output)
}
fn emit_with_claude_emitter(
emitter: &mut ClaudeClientEmitter,
canonical_frames: Vec<CanonicalStreamFrame>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
Ok(output)
}
fn emit_with_gemini_emitter(
emitter: &mut GeminiClientEmitter,
canonical_frames: Vec<CanonicalStreamFrame>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
Ok(output)
}
fn build_terminal_summary_from_openai_responses_response(
openai_responses_response: &Value,
) -> Option<ExecutionStreamTerminalSummary> {
let response = openai_responses_response.as_object()?;
let response_id = response
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let model = response
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let finish_reason = response
.get("output")
.and_then(Value::as_array)
.map(|output| resolve_openai_responses_finish_reason(output))
.filter(|value| !value.trim().is_empty());
let standardized_usage = response
.get("usage")
.and_then(standardized_usage_from_openai_usage);
Some(ExecutionStreamTerminalSummary {
standardized_usage,
finish_reason,
response_id,
model,
observed_finish: true,
unknown_event_count: 0,
parser_error: None,
})
}
fn resolve_openai_responses_finish_reason(output: &[Value]) -> String {
let has_tool_calls = output.iter().filter_map(Value::as_object).any(|item| {
item.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "function_call")
});
if has_tool_calls {
"tool_calls".to_string()
} else {
"stop".to_string()
}
}
fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsage> {
let usage = value.as_object()?;
let mut input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_i64)
.unwrap_or(0);
let output_tokens = usage
.get("output_tokens")
.or_else(|| usage.get("completion_tokens"))
.and_then(Value::as_i64)
.unwrap_or(0);
let cache_creation_tokens = usage
.get("cache_creation_input_tokens")
.and_then(Value::as_i64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_creation_tokens"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(Value::as_i64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let total_tokens = usage.get("total_tokens").and_then(Value::as_i64).unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
);
if input_tokens == 0 && total_tokens > output_tokens {
input_tokens = total_tokens.saturating_sub(output_tokens);
}
let mut standardized_usage = StandardizedUsage::new();
standardized_usage.input_tokens = input_tokens;
standardized_usage.output_tokens = output_tokens;
standardized_usage.cache_creation_tokens = cache_creation_tokens;
standardized_usage.cache_read_tokens = cache_read_tokens;
standardized_usage
.dimensions
.insert("total_tokens".to_string(), json!(total_tokens));
Some(standardized_usage.normalize_cache_creation_breakdown())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{maybe_bridge_standard_sync_json_to_stream, standardized_usage_from_openai_usage};
fn utf8(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("utf8 should decode")
}
#[test]
fn openai_sync_usage_derives_missing_input_tokens_from_total() {
let usage = standardized_usage_from_openai_usage(&json!({
"output_tokens": 177,
"total_tokens": 20_612,
"input_tokens_details": {
"cached_tokens": 19_840,
},
}))
.expect("usage should parse");
assert_eq!(usage.input_tokens, 20_435);
assert_eq!(usage.output_tokens, 177);
assert_eq!(usage.cache_read_tokens, 19_840);
}
#[test]
fn bridges_openai_image_sync_json_to_generation_completed_sse() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:image",
"mapped_model": "gpt-image-1",
"image_request": {
"operation": "generate"
}
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"created": 1776971267,
"data": [{
"b64_json": "aGVsbG8="
}],
"usage": {
"total_tokens": 100,
"input_tokens": 50,
"output_tokens": 50,
"input_tokens_details": {
"text_tokens": 10,
"image_tokens": 40
}
}
}),
"openai:image",
"openai:image",
Some(&report_context),
)
.expect("bridge should succeed")
.expect("bridge should produce sse");
let output = utf8(outcome.sse_body);
assert!(output.contains("event: image_generation.completed"));
assert!(output.contains("\"type\":\"image_generation.completed\""));
assert!(output.contains("\"b64_json\":\"aGVsbG8=\""));
assert!(output.contains("\"total_tokens\":100"));
let summary = outcome
.terminal_summary
.expect("terminal summary should exist");
assert_eq!(summary.model.as_deref(), Some("gpt-image-1"));
assert_eq!(summary.finish_reason.as_deref(), Some("stop"));
assert_eq!(
summary
.standardized_usage
.as_ref()
.and_then(|usage| usage.dimensions.get("total_tokens"))
.cloned(),
Some(json!(100))
);
}
#[test]
fn bridges_openai_image_sync_data_url_to_edit_completed_sse() {
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:image",
"image_request": {
"operation": "edit"
}
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"created": 1776971267,
"data": [{
"url": "data:image/webp;base64,d29ybGQ="
}],
"usage": {
"total_tokens": 9,
"input_tokens": 4,
"output_tokens": 5
}
}),
"openai:image",
"openai:image",
Some(&report_context),
)
.expect("bridge should succeed")
.expect("bridge should produce sse");
let output = utf8(outcome.sse_body);
assert!(output.contains("event: image_edit.completed"));
assert!(output.contains("\"type\":\"image_edit.completed\""));
assert!(output.contains("\"b64_json\":\"d29ybGQ=\""));
assert!(output.contains("\"total_tokens\":9"));
}
}