mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: fold ai surfaces into formats
This commit is contained in:
204
crates/aether-ai-formats/src/api.rs
Normal file
204
crates/aether-ai-formats/src/api.rs
Normal 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,
|
||||
};
|
||||
4
crates/aether-ai-formats/src/contracts/actions.rs
Normal file
4
crates/aether-ai-formats/src/contracts/actions.rs
Normal 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";
|
||||
19
crates/aether-ai-formats/src/contracts/auth_context.rs
Normal file
19
crates/aether-ai-formats/src/contracts/auth_context.rs
Normal 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,
|
||||
}
|
||||
88
crates/aether-ai-formats/src/contracts/control_payloads.rs
Normal file
88
crates/aether-ai-formats/src/contracts/control_payloads.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
49
crates/aether-ai-formats/src/contracts/mod.rs
Normal file
49
crates/aether-ai-formats/src/contracts/mod.rs
Normal 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,
|
||||
};
|
||||
42
crates/aether-ai-formats/src/contracts/plan_kinds.rs
Normal file
42
crates/aether-ai-formats/src/contracts/plan_kinds.rs
Normal 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
|
||||
)
|
||||
}
|
||||
133
crates/aether-ai-formats/src/contracts/report_kinds.rs
Normal file
133
crates/aether-ai-formats/src/contracts/report_kinds.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod openai;
|
||||
@@ -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 {
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
@@ -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,
|
||||
},
|
||||
@@ -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> {
|
||||
@@ -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> {
|
||||
@@ -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(),
|
||||
@@ -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"],
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
@@ -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> {
|
||||
@@ -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> {
|
||||
@@ -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)]
|
||||
7
crates/aether-ai-formats/src/protocol/mod.rs
Normal file
7
crates/aether-ai-formats/src/protocol/mod.rs
Normal 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;
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
180
crates/aether-ai-formats/src/provider_compat/kiro_stream.rs
Normal file
180
crates/aether-ai-formats/src/provider_compat/kiro_stream.rs
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("你好!有"));
|
||||
}
|
||||
4
crates/aether-ai-formats/src/provider_compat/mod.rs
Normal file
4
crates/aether-ai-formats/src/provider_compat/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod kiro_stream;
|
||||
pub mod private_envelope;
|
||||
pub mod proxy;
|
||||
pub mod surfaces;
|
||||
529
crates/aether-ai-formats/src/provider_compat/private_envelope.rs
Normal file
529
crates/aether-ai-formats/src/provider_compat/private_envelope.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
188
crates/aether-ai-formats/src/provider_compat/surfaces.rs
Normal file
188
crates/aether-ai-formats/src/provider_compat/surfaces.rs
Normal 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
80
crates/aether-ai-formats/src/request/common.rs
Normal file
80
crates/aether-ai-formats/src/request/common.rs
Normal 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
1
crates/aether-ai-formats/src/request/matrix.rs
Normal file
1
crates/aether-ai-formats/src/request/matrix.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use crate::request::standard::matrix::build_standard_request_body_from_canonical;
|
||||
7
crates/aether-ai-formats/src/request/mod.rs
Normal file
7
crates/aether-ai-formats/src/request/mod.rs
Normal 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;
|
||||
1
crates/aether-ai-formats/src/request/passthrough/mod.rs
Normal file
1
crates/aether-ai-formats/src/request/passthrough/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod provider;
|
||||
109
crates/aether-ai-formats/src/request/passthrough/provider.rs
Normal file
109
crates/aether-ai-formats/src/request/passthrough/provider.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
602
crates/aether-ai-formats/src/request/route.rs
Normal file
602
crates/aether-ai-formats/src/request/route.rs
Normal 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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
69
crates/aether-ai-formats/src/request/specialized/files.rs
Normal file
69
crates/aether-ai-formats/src/request/specialized/files.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1127
crates/aether-ai-formats/src/request/specialized/image.rs
Normal file
1127
crates/aether-ai-formats/src/request/specialized/image.rs
Normal file
File diff suppressed because it is too large
Load Diff
3
crates/aether-ai-formats/src/request/specialized/mod.rs
Normal file
3
crates/aether-ai-formats/src/request/specialized/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod files;
|
||||
pub mod image;
|
||||
pub mod video;
|
||||
54
crates/aether-ai-formats/src/request/specialized/video.rs
Normal file
54
crates/aether-ai-formats/src/request/specialized/video.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
53
crates/aether-ai-formats/src/request/standard/claude/chat.rs
Normal file
53
crates/aether-ai-formats/src/request/standard/claude/chat.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
53
crates/aether-ai-formats/src/request/standard/claude/cli.rs
Normal file
53
crates/aether-ai-formats/src/request/standard/claude/cli.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
12
crates/aether-ai-formats/src/request/standard/claude/mod.rs
Normal file
12
crates/aether-ai-formats/src/request/standard/claude/mod.rs
Normal 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))
|
||||
}
|
||||
519
crates/aether-ai-formats/src/request/standard/codex.rs
Normal file
519
crates/aether-ai-formats/src/request/standard/codex.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
21
crates/aether-ai-formats/src/request/standard/family.rs
Normal file
21
crates/aether-ai-formats/src/request/standard/family.rs
Normal 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,
|
||||
}
|
||||
53
crates/aether-ai-formats/src/request/standard/gemini/chat.rs
Normal file
53
crates/aether-ai-formats/src/request/standard/gemini/chat.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
53
crates/aether-ai-formats/src/request/standard/gemini/cli.rs
Normal file
53
crates/aether-ai-formats/src/request/standard/gemini/cli.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
12
crates/aether-ai-formats/src/request/standard/gemini/mod.rs
Normal file
12
crates/aether-ai-formats/src/request/standard/gemini/mod.rs
Normal 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))
|
||||
}
|
||||
1095
crates/aether-ai-formats/src/request/standard/matrix.rs
Normal file
1095
crates/aether-ai-formats/src/request/standard/matrix.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
crates/aether-ai-formats/src/request/standard/mod.rs
Normal file
20
crates/aether-ai-formats/src/request/standard/mod.rs
Normal 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,
|
||||
};
|
||||
232
crates/aether-ai-formats/src/request/standard/normalize.rs
Normal file
232
crates/aether-ai-formats/src/request/standard/normalize.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
326
crates/aether-ai-formats/src/response/common.rs
Normal file
326
crates/aether-ai-formats/src/response/common.rs
Normal 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}))
|
||||
);
|
||||
}
|
||||
}
|
||||
168
crates/aether-ai-formats/src/response/error_body.rs
Normal file
168
crates/aether-ai-formats/src/response/error_body.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
47
crates/aether-ai-formats/src/response/mod.rs
Normal file
47
crates/aether-ai-formats/src/response/mod.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
714
crates/aether-ai-formats/src/response/openai_image_stream.rs
Normal file
714
crates/aether-ai-formats/src/response/openai_image_stream.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
41
crates/aether-ai-formats/src/response/sse.rs
Normal file
41
crates/aether-ai-formats/src/response/sse.rs
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
1302
crates/aether-ai-formats/src/response/standard/claude/stream.rs
Normal file
1302
crates/aether-ai-formats/src/response/standard/claude/stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
1077
crates/aether-ai-formats/src/response/standard/gemini/stream.rs
Normal file
1077
crates/aether-ai-formats/src/response/standard/gemini/stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
4
crates/aether-ai-formats/src/response/standard/mod.rs
Normal file
4
crates/aether-ai-formats/src/response/standard/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod claude;
|
||||
pub mod gemini;
|
||||
pub mod openai;
|
||||
pub mod stream_core;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
3099
crates/aether-ai-formats/src/response/standard/openai/stream.rs
Normal file
3099
crates/aether-ai-formats/src/response/standard/openai/stream.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod common;
|
||||
pub mod format_matrix;
|
||||
|
||||
pub use common::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
pub use format_matrix::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
|
||||
356
crates/aether-ai-formats/src/response/stream_rewrite.rs
Normal file
356
crates/aether-ai-formats/src/response/stream_rewrite.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
4446
crates/aether-ai-formats/src/response/sync_products.rs
Normal file
4446
crates/aether-ai-formats/src/response/sync_products.rs
Normal file
File diff suppressed because it is too large
Load Diff
569
crates/aether-ai-formats/src/response/sync_to_stream.rs
Normal file
569
crates/aether-ai-formats/src/response/sync_to_stream.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user