migrate ai format conversion to responses adapters

This commit is contained in:
fawney19
2026-04-26 20:32:55 +08:00
parent e36fb8c07a
commit 5b914aa78c
183 changed files with 13539 additions and 3401 deletions

View File

@@ -22,7 +22,8 @@ pub use crate::contracts::augment_sync_report_context;
pub use crate::contracts::{
core_error_background_report_kind, core_error_default_client_api_format,
core_success_background_report_kind, generic_decision_missing_exact_provider_request,
implicit_sync_finalize_report_kind, ExecutionRuntimeAuthContext, GatewayControlPlanRequest,
implicit_sync_finalize_report_kind, is_openai_responses_stream_plan_kind,
is_openai_responses_sync_plan_kind, ExecutionRuntimeAuthContext, GatewayControlPlanRequest,
GatewayControlPlanResponse, GatewayControlSyncDecisionResponse, LocalStreamPlanAndReport,
LocalSyncPlanAndReport, CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND,
CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND, CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND,
@@ -51,33 +52,56 @@ pub use crate::contracts::{
OPENAI_COMPACT_SYNC_PLAN_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_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,
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::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request, extract_openai_text_content,
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_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub use crate::conversion::response::{
build_openai_cli_response, convert_claude_chat_response_to_openai_chat,
convert_claude_cli_response_to_openai_cli, convert_gemini_chat_response_to_openai_chat,
convert_gemini_cli_response_to_openai_cli, convert_openai_chat_response_to_claude_chat,
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
convert_openai_cli_response_to_openai_chat,
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 crate::conversion::{
build_core_error_body_for_client_format, is_core_error_finalize_kind,
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_direct_auth, request_conversion_enabled_for_transport,
request_conversion_kind, request_conversion_requires_enable_flag,
request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, LocalCoreSyncErrorKind, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
build_core_error_body_for_client_format, 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,
is_core_error_finalize_kind, request_candidate_api_format_preference,
request_candidate_api_formats, request_conversion_direct_auth,
request_conversion_enabled_for_transport, request_conversion_kind,
request_conversion_requires_enable_flag, request_conversion_transport_supported,
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
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,
LocalCoreSyncErrorKind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};
pub use crate::finalize::common::{
build_generated_tool_call_id, build_local_success_background_report,
@@ -88,8 +112,8 @@ pub use crate::finalize::sse::{encode_done_sse, encode_json_sse, map_claude_stop
pub use crate::finalize::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
pub use crate::finalize::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
pub use crate::finalize::standard::openai::stream::{
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
OpenAICliProviderState,
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
OpenAIResponsesProviderState,
};
pub use crate::finalize::standard::stream_core::common::*;
pub use crate::finalize::standard::stream_core::{
@@ -97,12 +121,12 @@ pub use crate::finalize::standard::stream_core::{
};
pub use crate::finalize::sync_products::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_chat_stream_sync_response, aggregate_openai_cli_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_cli_cross_format_sync_product_from_normalized_payload,
maybe_build_openai_cli_same_family_sync_body_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,
@@ -145,11 +169,18 @@ pub use crate::planner::specialized::{
LocalVideoCreateSpec,
},
};
#[allow(deprecated)]
pub use crate::planner::standard::apply_openai_compact_special_body_edits;
#[allow(deprecated)]
pub use crate::planner::standard::{
apply_codex_openai_cli_special_body_edits, apply_codex_openai_cli_special_headers,
apply_openai_compact_special_body_edits, build_cross_format_openai_chat_request_body,
build_cross_format_openai_cli_request_body, build_local_openai_chat_request_body,
build_local_openai_cli_request_body, build_standard_request_body, build_standard_upstream_url,
};
pub use crate::planner::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,
build_standard_upstream_url,
claude::{
resolve_stream_spec as resolve_claude_stream_spec,
resolve_sync_spec as resolve_claude_sync_spec,
@@ -159,9 +190,9 @@ pub use crate::planner::standard::{
resolve_sync_spec as resolve_gemini_sync_spec,
},
normalize_standard_request_to_openai_chat_request,
openai_cli::{
resolve_stream_spec as resolve_openai_cli_stream_spec,
resolve_sync_spec as resolve_openai_cli_sync_spec, LocalOpenAiCliSpec,
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,

View File

@@ -16,6 +16,7 @@ pub use control_payloads::{
LocalSyncPlanAndReport,
};
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,
@@ -24,6 +25,8 @@ pub use plan_kinds::{
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_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,
@@ -46,5 +49,10 @@ pub use report_kinds::{
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND, OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND,
OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND, OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND, OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND,
OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
};

View File

@@ -14,15 +14,39 @@ 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 OPENAI_CLI_STREAM_PLAN_KIND: &str = "openai_cli_stream";
pub const OPENAI_COMPACT_STREAM_PLAN_KIND: &str = "openai_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 OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync";
pub const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_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
| OPENAI_CLI_STREAM_PLAN_KIND
| OPENAI_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
| OPENAI_CLI_SYNC_PLAN_KIND
| OPENAI_COMPACT_SYNC_PLAN_KIND
)
}

View File

@@ -2,11 +2,15 @@ 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_CLI_SYNC_PLAN_KIND,
OPENAI_COMPACT_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_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "openai_cli_sync_finalize";
pub const OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sync_finalize";
pub const OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_image_sync_finalize";
@@ -18,6 +22,9 @@ pub const GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_video_cr
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_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "openai_cli_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";
@@ -26,6 +33,9 @@ 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_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "openai_cli_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";
@@ -34,6 +44,9 @@ pub const GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_cli_stream_succe
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 OPENAI_CLI_SYNC_ERROR_REPORT_KIND: &str = "openai_cli_sync_error";
pub const OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND: &str = "openai_compact_sync_error";
pub const CLAUDE_CLI_SYNC_ERROR_REPORT_KIND: &str = "claude_cli_sync_error";
@@ -44,6 +57,10 @@ pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static st
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_CLI_SYNC_PLAN_KIND => Some(OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND),
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND),
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND),
@@ -58,8 +75,10 @@ pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'stati
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("openai:chat"),
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("claude:chat"),
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:chat"),
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:cli"),
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:compact"),
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"),
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
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:cli"),
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("gemini:cli"),
@@ -72,8 +91,13 @@ pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static s
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_CLI_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CLI_SYNC_ERROR_REPORT_KIND),
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_COMPACT_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)
}
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND | OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_RESPONSES_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,
@@ -86,9 +110,14 @@ pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static
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_CLI_SYNC_FINALIZE_REPORT_KIND | OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_CLI_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)
}
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND),
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_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,

View File

@@ -0,0 +1,3 @@
#![allow(deprecated)]
pub use aether_ai_formats::canonical::*;

View File

@@ -38,7 +38,11 @@ pub fn build_core_error_body_for_client_format(
error_object.insert("message".to_string(), Value::String(message.to_string()));
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" | "openai:cli" | "openai:compact" => {
"openai:chat"
| "openai:responses"
| "openai:cli"
| "openai:compact"
| "openai:responses:compact" => {
error_object.insert(
"type".to_string(),
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),

View File

@@ -1,8 +1,29 @@
pub mod canonical;
mod error;
mod registry;
pub mod request;
pub mod response;
pub use aether_ai_formats::{
convert_request, convert_response, FormatContext, FormatError, FormatFamily, FormatId,
FormatProfile,
};
pub use 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,
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,
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,
CanonicalContentBlock, CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage,
CanonicalRequest, CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput,
CanonicalRole, CanonicalStopReason, CanonicalStreamEvent, CanonicalStreamFrame,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
};
pub use error::{
build_core_error_body_for_client_format, core_error_background_report_kind,
core_error_default_client_api_format, core_success_background_report_kind,

View File

@@ -19,7 +19,7 @@ use aether_provider_transport::GatewayProviderTransportSnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestConversionKind {
ToOpenAIChat,
ToOpenAIFamilyCli,
ToOpenAiResponses,
ToClaudeStandard,
ToGeminiStandard,
}
@@ -33,13 +33,14 @@ pub enum SyncChatResponseConversionKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncCliResponseConversionKind {
ToOpenAIFamilyCli,
ToOpenAiResponses,
ToClaudeCli,
ToGeminiCli,
}
const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
"openai:chat",
"openai:responses",
"openai:cli",
"claude:chat",
"claude:cli",
@@ -55,15 +56,24 @@ pub fn request_candidate_api_format_preference(
let client_api_format = client_api_format.trim().to_ascii_lowercase();
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
if client_api_format == "openai:compact" {
return (provider_api_format == "openai:compact").then_some((0, 0));
if matches!(
client_api_format.as_str(),
"openai:compact" | "openai:responses:compact"
) {
return matches!(
provider_api_format.as_str(),
"openai:compact" | "openai:responses:compact"
)
.then_some((0, 0));
}
let (client_family, client_kind) =
parse_non_compact_standard_api_format(client_api_format.as_str())?;
let (provider_family, provider_kind) =
parse_non_compact_standard_api_format(provider_api_format.as_str())?;
let preference_bucket = if client_family == provider_family && client_kind == provider_kind {
let preference_bucket = if canonical_standard_api_format(client_api_format.as_str())
== canonical_standard_api_format(provider_api_format.as_str())
{
0
} else if client_kind == provider_kind {
1
@@ -84,8 +94,11 @@ pub fn request_candidate_api_formats(
_require_streaming: bool,
) -> Vec<&'static str> {
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if client_api_format == "openai:compact" {
return vec!["openai:compact"];
if matches!(
client_api_format.as_str(),
"openai:compact" | "openai:responses:compact"
) {
return vec!["openai:responses:compact", "openai:compact"];
}
if parse_non_compact_standard_api_format(client_api_format.as_str()).is_none() {
return Vec::new();
@@ -108,18 +121,28 @@ pub fn request_conversion_kind(
if client_api_format == provider_api_format {
return None;
}
if normalized_same_standard_api_format(client_api_format.as_str(), provider_api_format.as_str())
{
return None;
}
if !is_standard_api_format(client_api_format.as_str())
|| !is_standard_api_format(provider_api_format.as_str())
{
return None;
}
if client_api_format == "openai:compact" || provider_api_format == "openai:compact" {
if matches!(
client_api_format.as_str(),
"openai:compact" | "openai:responses:compact"
) || matches!(
provider_api_format.as_str(),
"openai:compact" | "openai:responses:compact"
) {
return None;
}
match provider_api_format.as_str() {
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
"openai:cli" => Some(RequestConversionKind::ToOpenAIFamilyCli),
"openai:responses" | "openai:cli" => Some(RequestConversionKind::ToOpenAiResponses),
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
_ => None,
@@ -135,6 +158,10 @@ pub fn sync_chat_response_conversion_kind(
if provider_api_format == client_api_format {
return None;
}
if normalized_same_standard_api_format(provider_api_format.as_str(), client_api_format.as_str())
{
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
@@ -156,14 +183,23 @@ pub fn sync_cli_response_conversion_kind(
if provider_api_format == client_api_format {
return None;
}
if normalized_same_standard_api_format(provider_api_format.as_str(), client_api_format.as_str())
{
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
if client_api_format != "openai:compact" {
if !matches!(
client_api_format.as_str(),
"openai:compact" | "openai:responses:compact"
) {
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
}
match client_api_format.as_str() {
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
"openai:responses" | "openai:cli" | "openai:compact" | "openai:responses:compact" => {
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
}
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
_ => None,
@@ -277,11 +313,11 @@ pub fn request_conversion_transport_unsupported_reason(
.as_str()
{
"openai:chat" => local_openai_chat_transport_unsupported_reason(transport),
"openai:cli" => {
local_standard_transport_unsupported_reason_with_network(transport, "openai:cli")
}
"openai:compact" => {
local_standard_transport_unsupported_reason_with_network(transport, "openai:compact")
"openai:responses" | "openai:cli" | "openai:responses:compact" | "openai:compact" => {
local_standard_transport_unsupported_reason_with_network(
transport,
transport.endpoint.api_format.trim(),
)
}
"claude:chat" => {
local_standard_transport_unsupported_reason_with_network(transport, "claude:chat")
@@ -313,9 +349,11 @@ pub fn request_conversion_direct_auth(
.to_ascii_lowercase()
.as_str()
{
"openai:chat" | "openai:cli" | "openai:compact" => {
resolve_local_openai_bearer_auth(transport)
}
"openai:chat"
| "openai:responses"
| "openai:cli"
| "openai:compact"
| "openai:responses:compact" => resolve_local_openai_bearer_auth(transport),
"gemini:chat" | "gemini:cli" => {
if is_vertex_api_key_transport_context(transport) {
resolve_local_vertex_api_key_query_auth(transport)
@@ -333,8 +371,10 @@ fn is_standard_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat"
| "openai:responses"
| "openai:cli"
| "openai:compact"
| "openai:responses:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
@@ -344,6 +384,9 @@ fn is_standard_api_format(api_format: &str) -> bool {
fn parse_non_compact_standard_api_format(api_format: &str) -> Option<(&str, &str)> {
let (family, kind) = api_format.split_once(':')?;
if family == "openai" && kind == "responses" {
return Some((family, "cli"));
}
if !STANDARD_API_FAMILY_ORDER.contains(&family) || !matches!(kind, "chat" | "cli") {
return None;
}
@@ -362,11 +405,31 @@ fn api_data_format_id(api_format: &str) -> Option<&'static str> {
"claude:chat" | "claude:cli" => Some("claude"),
"gemini:chat" | "gemini:cli" => Some("gemini"),
"openai:chat" => Some("openai_chat"),
"openai:cli" | "openai:compact" => Some("openai_responses"),
"openai:responses" | "openai:cli" | "openai:compact" | "openai:responses:compact" => {
Some("openai_responses")
}
_ => None,
}
}
fn normalized_same_standard_api_format(left: &str, right: &str) -> bool {
matches!(
(left, right),
("openai:responses", "openai:cli")
| ("openai:cli", "openai:responses")
| ("openai:responses:compact", "openai:compact")
| ("openai:compact", "openai:responses:compact")
)
}
fn canonical_standard_api_format(api_format: &str) -> &str {
match api_format {
"openai:cli" => "openai:responses",
"openai:compact" => "openai:responses:compact",
other => other,
}
}
fn endpoint_accepts_client_api_format(
transport: &GatewayProviderTransportSnapshot,
client_api_format: &str,
@@ -437,7 +500,7 @@ mod tests {
fn expected_request_conversion_kind(provider_api_format: &str) -> RequestConversionKind {
match provider_api_format {
"openai:chat" => RequestConversionKind::ToOpenAIChat,
"openai:cli" => RequestConversionKind::ToOpenAIFamilyCli,
"openai:cli" => RequestConversionKind::ToOpenAiResponses,
"claude:chat" | "claude:cli" => RequestConversionKind::ToClaudeStandard,
"gemini:chat" | "gemini:cli" => RequestConversionKind::ToGeminiStandard,
other => panic!("unexpected provider api format: {other}"),
@@ -448,7 +511,7 @@ mod tests {
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
request_conversion_kind("openai:chat", "openai:cli"),
Some(RequestConversionKind::ToOpenAIFamilyCli)
Some(RequestConversionKind::ToOpenAiResponses)
);
assert_eq!(
request_conversion_kind("openai:chat", "claude:cli"),
@@ -474,6 +537,14 @@ mod tests {
request_conversion_kind("openai:chat", "openai:compact"),
None
);
assert_eq!(
request_conversion_kind("openai:responses", "openai:cli"),
None
);
assert_eq!(
request_conversion_kind("openai:compact", "openai:responses:compact"),
None
);
assert_eq!(
request_conversion_kind("claude:chat", "claude:cli"),
Some(RequestConversionKind::ToClaudeStandard)
@@ -522,11 +593,11 @@ mod tests {
);
assert_eq!(
sync_cli_response_conversion_kind("claude:chat", "openai:cli"),
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
);
assert_eq!(
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:compact", "claude:cli"),
@@ -536,6 +607,14 @@ mod tests {
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
Some(SyncCliResponseConversionKind::ToClaudeCli)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:responses", "openai:cli"),
None
);
assert_eq!(
sync_cli_response_conversion_kind("openai:compact", "openai:responses:compact"),
None
);
}
#[test]
@@ -574,7 +653,7 @@ mod tests {
);
} else {
let expected = match client_api_format {
"openai:cli" => SyncCliResponseConversionKind::ToOpenAIFamilyCli,
"openai:cli" => SyncCliResponseConversionKind::ToOpenAiResponses,
"claude:cli" => SyncCliResponseConversionKind::ToClaudeCli,
"gemini:cli" => SyncCliResponseConversionKind::ToGeminiCli,
other => panic!("unexpected cli client api format: {other}"),
@@ -597,6 +676,7 @@ mod tests {
"openai:chat",
"claude:chat",
"gemini:chat",
"openai:responses",
"openai:cli",
"claude:cli",
"gemini:cli",
@@ -605,6 +685,19 @@ mod tests {
assert_eq!(
request_candidate_api_formats("openai:cli", false),
vec![
"openai:responses",
"openai:cli",
"claude:cli",
"gemini:cli",
"openai:chat",
"claude:chat",
"gemini:chat",
]
);
assert_eq!(
request_candidate_api_formats("openai:responses", false),
vec![
"openai:responses",
"openai:cli",
"claude:cli",
"gemini:cli",
@@ -617,6 +710,7 @@ mod tests {
request_candidate_api_formats("claude:cli", false),
vec![
"claude:cli",
"openai:responses",
"openai:cli",
"gemini:cli",
"claude:chat",
@@ -626,7 +720,7 @@ mod tests {
);
assert_eq!(
request_candidate_api_formats("openai:compact", false),
vec!["openai:compact"]
vec!["openai:responses:compact", "openai:compact"]
);
}
@@ -750,9 +844,9 @@ mod tests {
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:cli".to_string(),
api_format: "openai:responses".to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
endpoint_kind: Some("responses".to_string()),
is_active: true,
base_url: "https://right.codes/codex".to_string(),
header_rules: None,
@@ -772,7 +866,7 @@ mod tests {
name: "key".to_string(),
auth_type: "bearer".to_string(),
is_active: true,
api_formats: Some(vec!["openai:cli".to_string()]),
api_formats: Some(vec!["openai:responses".to_string()]),
allowed_models: None,
capabilities: None,
rate_multipliers: None,
@@ -788,17 +882,17 @@ mod tests {
assert!(request_conversion_enabled_for_transport(
&transport,
"claude:cli",
"openai:cli"
"openai:responses"
));
assert!(request_pair_allowed_for_transport(
&transport,
"claude:cli",
"openai:cli"
"openai:responses"
));
assert!(!request_pair_allowed_for_transport(
&transport,
"gemini:cli",
"openai:cli"
"openai:responses"
));
}
@@ -823,9 +917,9 @@ mod tests {
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:cli".to_string(),
api_format: "openai:responses".to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
endpoint_kind: Some("responses".to_string()),
is_active: true,
base_url: "https://right.codes/codex".to_string(),
header_rules: None,
@@ -845,7 +939,7 @@ mod tests {
name: "key".to_string(),
auth_type: "bearer".to_string(),
is_active: true,
api_formats: Some(vec!["openai:cli".to_string()]),
api_formats: Some(vec!["openai:responses".to_string()]),
allowed_models: None,
capabilities: None,
rate_multipliers: None,
@@ -861,7 +955,7 @@ mod tests {
assert!(!request_conversion_enabled_for_transport(
&transport,
"claude:cli",
"openai:cli"
"openai:responses"
));
}

View File

@@ -1,814 +0,0 @@
use serde_json::{json, Map, Value};
use uuid::Uuid;
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
use super::shared::parse_openai_tool_arguments;
use crate::planner::openai::{
copy_request_number_field, extract_openai_reasoning_effort,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_thinking_budget,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens,
};
pub fn convert_openai_chat_request_to_claude_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut system_segments = Vec::new();
let mut messages = Vec::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
system_segments.push(text);
}
}
"user" => {
let blocks = convert_openai_content_to_claude_blocks(
message_object.get("content"),
ClaudeMessageRole::User,
)?;
if !blocks.is_empty() {
messages.push(build_claude_message("user", blocks));
}
}
"assistant" => {
let mut blocks = extract_openai_reasoning_to_claude_blocks(message_object);
blocks.extend(convert_openai_content_to_claude_blocks(
message_object.get("content"),
ClaudeMessageRole::Assistant,
)?);
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
let tool_input =
parse_openai_tool_arguments(function.get("arguments"))?;
blocks.push(json!({
"type": "tool_use",
"id": tool_call_id,
"name": tool_name,
"input": tool_input,
}));
}
}
if !blocks.is_empty() {
messages.push(build_claude_message("assistant", blocks));
}
}
"tool" => {
let tool_use_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let tool_result =
parse_openai_tool_result_content(message_object.get("content"));
messages.push(json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": tool_result,
"is_error": false,
}],
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
output.insert(
"messages".to_string(),
Value::Array(compact_claude_messages(messages)),
);
output.insert(
"max_tokens".to_string(),
Value::from(resolve_openai_chat_max_tokens(request)),
);
let system_text = system_segments
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
output.insert("system".to_string(), Value::String(system_text));
}
if upstream_is_stream {
output.insert("stream".to_string(), Value::Bool(true));
}
copy_request_number_field(request, &mut output, "temperature");
copy_request_number_field(request, &mut output, "top_p");
copy_request_number_field(request, &mut output, "top_k");
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
output.insert("stop_sequences".to_string(), Value::Array(stop_sequences));
}
if let Some(tools) =
convert_openai_tools_to_claude(request.get("tools"), request.get("web_search_options"))
{
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(
request.get("tool_choice"),
request.get("parallel_tool_calls"),
) {
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(metadata) = request.get("metadata").cloned() {
output.insert("metadata".to_string(), metadata);
}
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
if let Some(thinking_budget) =
map_openai_reasoning_effort_to_thinking_budget(reasoning_effort.as_str())
{
output.insert(
"thinking".to_string(),
json!({
"type": "enabled",
"budget_tokens": thinking_budget,
}),
);
}
if let Some(output_effort) =
map_openai_reasoning_effort_to_claude_output(reasoning_effort.as_str())
{
output.insert(
"output_config".to_string(),
json!({
"effort": output_effort,
}),
);
}
}
Some(Value::Object(output))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClaudeMessageRole {
User,
Assistant,
}
fn convert_openai_content_to_claude_blocks(
content: Option<&Value>,
role: ClaudeMessageRole,
) -> Option<Vec<Value>> {
match content {
None | Some(Value::Null) => Some(Vec::new()),
Some(Value::String(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({ "type": "text", "text": text })])
}
}
Some(Value::Array(parts)) => {
let mut blocks = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match part_type {
"text" | "input_text" | "output_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
blocks.push(json!({ "type": "text", "text": text }));
}
}
}
"image_url" | "input_image" | "output_image" => {
let url = part_object
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part_object
.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.filter(|value| !value.trim().is_empty())?;
if role == ClaudeMessageRole::User {
if let Some((media_type, data)) = parse_data_url(url.as_str()) {
blocks.push(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
}
}));
} else {
blocks.push(json!({
"type": "image",
"source": {
"type": "url",
"url": url,
}
}));
}
} else {
blocks.push(json!({
"type": "text",
"text": assistant_image_placeholder(url.as_str()),
}));
}
}
"file" | "input_file" => {
let file_object = part_object
.get("file")
.and_then(Value::as_object)
.unwrap_or(part_object);
if let Some(file_data) =
file_object.get("file_data").and_then(Value::as_str)
{
if let Some((media_type, data)) = parse_data_url(file_data) {
blocks.push(json!({
"type": "document",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
}
}));
}
} else if let Some(file_id) = file_object
.get("file_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
blocks.push(json!({
"type": "text",
"text": format!("[File: {file_id}]"),
}));
}
}
"input_audio" => {
let audio_object = part_object
.get("input_audio")
.and_then(Value::as_object)
.unwrap_or(part_object);
let data = audio_object
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let format = audio_object
.get("format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let (Some(data), Some(format)) = (data, format) {
blocks.push(json!({
"type": "document",
"source": {
"type": "base64",
"media_type": format!("audio/{format}"),
"data": data,
}
}));
}
}
_ => {}
}
}
Some(blocks)
}
_ => None,
}
}
fn extract_openai_reasoning_to_claude_blocks(message: &Map<String, Value>) -> Vec<Value> {
let mut blocks = Vec::new();
if let Some(reasoning_parts) = message.get("reasoning_parts").and_then(Value::as_array) {
for reasoning_part in reasoning_parts {
let Some(reasoning_object) = reasoning_part.as_object() else {
continue;
};
match reasoning_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("thinking")
{
"thinking" => {
let thinking = reasoning_object
.get("thinking")
.or_else(|| reasoning_object.get("text"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if thinking.is_empty() {
continue;
}
let mut block = Map::new();
block.insert("type".to_string(), Value::String("thinking".to_string()));
block.insert("thinking".to_string(), Value::String(thinking.to_string()));
if let Some(signature) = reasoning_object
.get("signature")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
block.insert(
"signature".to_string(),
Value::String(signature.to_string()),
);
}
blocks.push(Value::Object(block));
}
"redacted_thinking" => {
let data = reasoning_object
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if !data.is_empty() {
blocks.push(json!({
"type": "redacted_thinking",
"data": data,
}));
}
}
_ => {}
}
}
}
if !blocks.is_empty() {
return blocks;
}
if let Some(reasoning_content) = message
.get("reasoning_content")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
blocks.push(json!({
"type": "thinking",
"thinking": reasoning_content,
}));
}
blocks
}
fn convert_openai_tools_to_claude(
tools: Option<&Value>,
web_search_options: Option<&Value>,
) -> Option<Vec<Value>> {
let mut converted = Vec::new();
if let Some(tool_values) = tools.and_then(Value::as_array) {
for tool in tool_values {
let tool_object = tool.as_object()?;
if tool_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value != "function")
{
continue;
}
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut converted_tool = Map::new();
converted_tool.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description").cloned() {
converted_tool.insert("description".to_string(), description);
}
converted_tool.insert(
"input_schema".to_string(),
function
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({})),
);
converted.push(Value::Object(converted_tool));
}
}
if let Some(web_search_tool) =
convert_openai_web_search_options_to_claude_tool(web_search_options)
{
converted.push(web_search_tool);
}
(!converted.is_empty()).then_some(converted)
}
fn convert_openai_web_search_options_to_claude_tool(
web_search_options: Option<&Value>,
) -> Option<Value> {
let web_search_options = web_search_options?.as_object()?;
let mut tool = Map::new();
tool.insert(
"type".to_string(),
Value::String("web_search_20250305".to_string()),
);
tool.insert("name".to_string(), Value::String("web_search".to_string()));
if let Some(user_location) = web_search_options
.get("user_location")
.and_then(Value::as_object)
{
let approximate = user_location
.get("approximate")
.and_then(Value::as_object)
.unwrap_or(user_location);
let mut location = Map::new();
location.insert("type".to_string(), Value::String("approximate".to_string()));
for field in ["city", "country", "region", "timezone"] {
if let Some(value) = approximate.get(field).cloned() {
location.insert(field.to_string(), value);
}
}
if location.len() > 1 {
tool.insert("user_location".to_string(), Value::Object(location));
}
}
if let Some(max_uses) = web_search_options
.get("search_context_size")
.and_then(Value::as_str)
.and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
"low" => Some(1u64),
"medium" => Some(5u64),
"high" => Some(10u64),
_ => None,
})
{
tool.insert("max_uses".to_string(), Value::from(max_uses));
}
Some(Value::Object(tool))
}
fn convert_openai_tool_choice_to_claude(
tool_choice: Option<&Value>,
parallel_tool_calls: Option<&Value>,
) -> Option<Value> {
let mut converted = match tool_choice {
Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(json!({ "type": "none" })),
"required" => Some(json!({ "type": "any" })),
"auto" => Some(json!({ "type": "auto" })),
_ => None,
},
Some(Value::Object(object)) => {
let function_name = object
.get("function")
.and_then(Value::as_object)
.and_then(|function| function.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"type": "tool",
"name": function_name,
}))
}
Some(_) => None,
None => None,
};
if let Some(parallel_tool_calls) = parallel_tool_calls.and_then(Value::as_bool) {
if converted.is_none() {
converted = Some(json!({ "type": "auto" }));
}
if let Some(object) = converted.as_mut().and_then(Value::as_object_mut) {
let choice_type = object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if choice_type != "none" {
object.insert(
"disable_parallel_tool_use".to_string(),
Value::Bool(!parallel_tool_calls),
);
}
}
}
converted
}
fn compact_claude_messages(messages: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for message in messages {
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Some(last) = compact.last_mut() {
let last_role = last
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if last_role == role {
merge_claude_message_content(last, message);
continue;
}
}
compact.push(message);
}
if compact
.first()
.and_then(|value| value.get("role"))
.and_then(Value::as_str)
.is_some_and(|value| value == "assistant")
{
compact.insert(0, json!({ "role": "user", "content": "" }));
}
compact
}
fn merge_claude_message_content(target: &mut Value, message: Value) {
let Some(target_object) = target.as_object_mut() else {
return;
};
let incoming_content = message.get("content").cloned().unwrap_or(Value::Null);
let merged_blocks = extract_claude_content_blocks(target_object.get("content"))
.into_iter()
.chain(extract_claude_content_blocks(Some(&incoming_content)))
.collect::<Vec<_>>();
target_object.insert(
"content".to_string(),
simplify_claude_content(merged_blocks),
);
}
fn build_claude_message(role: &str, blocks: Vec<Value>) -> Value {
json!({
"role": role,
"content": simplify_claude_content(blocks),
})
}
fn simplify_claude_content(blocks: Vec<Value>) -> Value {
if blocks.is_empty() {
return Value::String(String::new());
}
let mut text_values = Vec::new();
for block in &blocks {
let Some(block_object) = block.as_object() else {
return Value::Array(blocks);
};
if block_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "text")
{
if let Some(text) = block_object.get("text").and_then(Value::as_str) {
text_values.push(text.to_string());
continue;
}
}
return Value::Array(blocks);
}
Value::String(text_values.join("\n"))
}
fn extract_claude_content_blocks(content: Option<&Value>) -> Vec<Value> {
match content {
Some(Value::String(text)) if !text.is_empty() => vec![json!({
"type": "text",
"text": text,
})],
Some(Value::Array(blocks)) => blocks.clone(),
_ => Vec::new(),
}
}
fn parse_data_url(value: &str) -> Option<(String, String)> {
let rest = value.strip_prefix("data:")?;
let (meta, data) = rest.split_once(",")?;
let media_type = meta.strip_suffix(";base64")?;
if media_type.trim().is_empty() || data.trim().is_empty() {
return None;
}
Some((media_type.to_string(), data.to_string()))
}
fn assistant_image_placeholder(url: &str) -> String {
if url.starts_with("data:") {
"[Image]".to_string()
} else {
format!("[Image: {url}]")
}
}
#[cfg(test)]
mod tests {
use super::convert_openai_chat_request_to_claude_request;
use serde_json::json;
#[test]
fn maps_openai_web_search_options_to_claude_builtin_tool() {
let request = json!({
"model": "gpt-5.4",
"messages": [
{ "role": "user", "content": "weather in shanghai" }
],
"web_search_options": {
"search_context_size": "medium",
"user_location": {
"approximate": {
"city": "Shanghai",
"country": "CN",
"timezone": "Asia/Shanghai"
}
}
}
});
let converted =
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
.expect("request should convert");
assert_eq!(converted["tools"][0]["type"], "web_search_20250305");
assert_eq!(converted["tools"][0]["name"], "web_search");
assert_eq!(converted["tools"][0]["max_uses"], 5);
assert_eq!(
converted["tools"][0]["user_location"],
json!({
"type": "approximate",
"city": "Shanghai",
"country": "CN",
"timezone": "Asia/Shanghai",
})
);
}
#[test]
fn maps_parallel_tool_calls_to_disable_parallel_tool_use() {
let request = json!({
"model": "gpt-5.4",
"messages": [
{ "role": "user", "content": "call tools if needed" }
],
"parallel_tool_calls": true
});
let converted =
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
.expect("request should convert");
assert_eq!(
converted["tool_choice"],
json!({
"type": "auto",
"disable_parallel_tool_use": false,
})
);
}
#[test]
fn converts_openai_multipart_reasoning_and_file_id_to_claude_request() {
let request = json!({
"model": "gpt-5.4",
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "Read this" },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgo="
}
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x"
}
}
]
},
{
"role": "assistant",
"reasoning_content": "step by step",
"reasoning_parts": [
{
"type": "thinking",
"thinking": "step by step",
"signature": "sig_123"
},
{
"type": "redacted_thinking",
"data": "redacted_blob"
}
],
"content": [
{
"type": "image_url",
"image_url": {
"url": "https://example.com/diagram.png"
}
},
{ "type": "file", "file": { "file_id": "file_123" } },
{ "type": "text", "text": "done" }
],
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "lookup",
"arguments": "\"tokyo\""
}
}]
}
],
"reasoning_effort": "xhigh"
});
let converted =
convert_openai_chat_request_to_claude_request(&request, "claude-sonnet-4-5", false)
.expect("request should convert");
assert_eq!(
converted["messages"][0]["content"],
json!([
{ "type": "text", "text": "Read this" },
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo="
}
},
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0x"
}
}
])
);
assert_eq!(converted["messages"][1]["content"][0]["type"], "thinking");
assert_eq!(
converted["messages"][1]["content"][0]["signature"],
"sig_123"
);
assert_eq!(
converted["messages"][1]["content"][1]["type"],
"redacted_thinking"
);
assert_eq!(
converted["messages"][1]["content"][2]["text"],
"[Image: https://example.com/diagram.png]"
);
assert_eq!(
converted["messages"][1]["content"][3]["text"],
"[File: file_123]"
);
assert_eq!(
converted["messages"][1]["content"][5]["input"],
json!({"raw": "tokyo"})
);
assert_eq!(converted["thinking"]["budget_tokens"], 8192);
assert_eq!(converted["output_config"]["effort"], "max");
}
}

View File

@@ -1,8 +0,0 @@
mod claude;
mod gemini;
mod openai_cli;
mod shared;
pub use claude::convert_openai_chat_request_to_claude_request;
pub use gemini::convert_openai_chat_request_to_gemini_request;
pub use openai_cli::convert_openai_chat_request_to_openai_cli_request;

View File

@@ -1,571 +0,0 @@
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use super::super::to_openai_chat::extract_openai_text_content;
use crate::planner::openai::{
copy_request_number_field, extract_openai_reasoning_effort, value_as_u64,
};
pub fn convert_openai_chat_request_to_openai_cli_request(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
compact: bool,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut instructions = Vec::new();
let mut input_items = Vec::new();
let mut next_generated_tool_call_index = 0usize;
let mut tool_call_id_aliases = BTreeMap::new();
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
let text = extract_openai_text_content(message_object.get("content"))?;
if !text.trim().is_empty() {
instructions.push(text);
}
}
"user" | "assistant" => {
let mut content_items = convert_openai_content_to_openai_cli_items(
message_object.get("content"),
role.as_str(),
)?;
if role == "assistant" {
if let Some(refusal) = message_object.get("refusal").and_then(Value::as_str)
{
if !refusal.trim().is_empty() {
content_items.push(json!({
"type": "refusal",
"refusal": refusal,
}));
}
}
}
if !content_items.is_empty() {
input_items.push(json!({
"type": "message",
"role": role,
"content": content_items,
}));
}
if role == "assistant" {
if let Some(tool_calls) =
message_object.get("tool_calls").and_then(Value::as_array)
{
for tool_call in tool_calls {
let tool_call_object = tool_call.as_object()?;
let function = tool_call_object.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let raw_call_id = tool_call_object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let call_id = if raw_call_id.is_empty() {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
} else {
raw_call_id.to_string()
};
if !raw_call_id.is_empty() && raw_call_id != call_id {
tool_call_id_aliases
.insert(raw_call_id.to_string(), call_id.clone());
}
let arguments = function
.get("arguments")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "{}".to_string());
input_items.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": arguments,
}));
}
}
}
}
"tool" => {
let raw_tool_call_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let tool_call_id = if raw_tool_call_id.is_empty() {
let generated = format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
} else {
tool_call_id_aliases
.get(raw_tool_call_id)
.cloned()
.unwrap_or_else(|| raw_tool_call_id.to_string())
};
let output = match message_object.get("content") {
Some(Value::String(text)) => text.clone(),
Some(other) => serde_json::to_string(other).ok()?,
None => String::new(),
};
input_items.push(json!({
"type": "function_call_output",
"call_id": tool_call_id,
"output": output,
}));
}
_ => {}
}
}
}
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
if !instructions.is_empty() {
output.insert(
"instructions".to_string(),
Value::String(
instructions
.into_iter()
.filter(|value: &String| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n"),
),
);
}
output.insert("input".to_string(), Value::Array(input_items));
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
}
if let Some(max_tokens) = request
.get("max_completion_tokens")
.and_then(value_as_u64)
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
{
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
}
copy_request_number_field(request, &mut output, "temperature");
copy_request_number_field(request, &mut output, "top_p");
copy_request_integer_field(request, &mut output, "top_logprobs");
copy_request_bool_field(request, &mut output, "parallel_tool_calls");
for passthrough_key in [
"prompt_cache_key",
"prompt_cache_retention",
"service_tier",
"metadata",
"store",
"user",
"safety_identifier",
"previous_response_id",
"truncation",
"stop",
] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if !output.contains_key("reasoning") {
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
output.insert(
"reasoning".to_string(),
json!({
"effort": if reasoning_effort == "xhigh" {
"high"
} else {
reasoning_effort.as_str()
}
}),
);
}
}
if let Some(text) = build_openai_cli_text_config_from_openai_chat_request(request) {
output.insert("text".to_string(), Value::Object(text));
}
if let Some(tools) = build_openai_cli_tools_from_openai_chat_request(request) {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = build_openai_cli_tool_choice_from_openai_chat_request(request) {
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn convert_openai_content_to_openai_cli_items(
content: Option<&Value>,
role: &str,
) -> Option<Vec<Value>> {
let Some(content) = content else {
return Some(Vec::new());
};
match content {
Value::String(text) => {
if text.is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
})])
}
}
Value::Array(parts) => {
let mut items = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("text")
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"text" | "input_text" | "output_text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.is_empty() {
items.push(json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
}));
}
}
}
"image_url" => {
let image_url = part_object
.get("image_url")
.and_then(Value::as_object)
.and_then(|value| value.get("url"))
.and_then(Value::as_str)
.or_else(|| part_object.get("image_url").and_then(Value::as_str))?;
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String(
if role == "assistant" {
"output_image"
} else {
"input_image"
}
.to_string(),
),
);
item.insert(
"image_url".to_string(),
Value::String(image_url.to_string()),
);
if let Some(detail) = part_object
.get("image_url")
.and_then(Value::as_object)
.and_then(|value| value.get("detail"))
.cloned()
{
item.insert("detail".to_string(), detail);
}
items.push(Value::Object(item));
}
"input_image" | "output_image" => {
let image_url = part_object
.get("image_url")
.and_then(Value::as_str)
.or_else(|| part_object.get("url").and_then(Value::as_str))?;
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String(
if role == "assistant" {
"output_image"
} else {
"input_image"
}
.to_string(),
),
);
item.insert(
"image_url".to_string(),
Value::String(image_url.to_string()),
);
if let Some(detail) = part_object.get("detail").cloned() {
item.insert("detail".to_string(), detail);
}
items.push(Value::Object(item));
}
"file" | "input_file" => {
let file_object = part_object
.get("file")
.and_then(Value::as_object)
.unwrap_or(part_object);
let mut item = Map::new();
item.insert("type".to_string(), Value::String("input_file".to_string()));
if let Some(file_data) = file_object.get("file_data").cloned() {
item.insert("file_data".to_string(), file_data);
}
if let Some(file_id) = file_object.get("file_id").cloned() {
item.insert("file_id".to_string(), file_id);
}
if let Some(filename) = file_object.get("filename").cloned() {
item.insert("filename".to_string(), filename);
}
if item.len() > 1 {
items.push(Value::Object(item));
}
}
_ => {}
}
}
Some(items)
}
_ => None,
}
}
fn build_openai_cli_text_config_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Map<String, Value>> {
let mut text = Map::new();
if let Some(response_format) = request.get("response_format") {
text.insert("format".to_string(), response_format.clone());
}
if let Some(verbosity) = request.get("verbosity") {
text.insert("verbosity".to_string(), verbosity.clone());
}
(!text.is_empty()).then_some(text)
}
fn build_openai_cli_tools_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Vec<Value>> {
let mut tools = Vec::new();
if let Some(tool_values) = request.get("tools").and_then(Value::as_array) {
for tool in tool_values {
let tool_object = tool.as_object()?;
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
match tool_type.as_str() {
"function" => {
let function = tool_object.get("function")?.as_object()?;
let name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut rebuilt = Map::new();
rebuilt.insert("type".to_string(), Value::String("function".to_string()));
rebuilt.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = function.get("description") {
rebuilt.insert("description".to_string(), description.clone());
}
if let Some(parameters) = function.get("parameters") {
rebuilt.insert("parameters".to_string(), parameters.clone());
}
tools.push(Value::Object(rebuilt));
}
"custom" => {
let custom = tool_object.get("custom").and_then(Value::as_object)?;
let mut rebuilt = Map::new();
rebuilt.insert("type".to_string(), Value::String("custom".to_string()));
if let Some(name) = custom.get("name") {
rebuilt.insert("name".to_string(), name.clone());
}
if let Some(description) = custom.get("description") {
rebuilt.insert("description".to_string(), description.clone());
}
if let Some(format) = custom.get("format") {
rebuilt.insert("format".to_string(), format.clone());
}
tools.push(Value::Object(rebuilt));
}
_ => tools.push(tool.clone()),
}
}
}
if let Some(web_search_options) = request.get("web_search_options").and_then(Value::as_object) {
let mut tool = Map::new();
tool.insert("type".to_string(), Value::String("web_search".to_string()));
if let Some(user_location) = web_search_options
.get("user_location")
.and_then(Value::as_object)
{
if user_location.get("type").and_then(Value::as_str) == Some("approximate") {
if let Some(approximate) =
user_location.get("approximate").and_then(Value::as_object)
{
let mut flattened = Map::new();
flattened.insert("type".to_string(), Value::String("approximate".to_string()));
if let Some(country) = approximate.get("country") {
flattened.insert("country".to_string(), country.clone());
}
if let Some(city) = approximate.get("city") {
flattened.insert("city".to_string(), city.clone());
}
tool.insert("user_location".to_string(), Value::Object(flattened));
}
}
}
if let Some(search_context_size) = web_search_options.get("search_context_size") {
tool.insert(
"search_context_size".to_string(),
search_context_size.clone(),
);
}
tools.push(Value::Object(tool));
}
(!tools.is_empty()).then_some(tools)
}
fn build_openai_cli_tool_choice_from_openai_chat_request(
request: &Map<String, Value>,
) -> Option<Value> {
let tool_choice = request.get("tool_choice")?;
match tool_choice {
Value::String(value) => Some(Value::String(value.clone())),
Value::Object(object) => {
let choice_type = object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match choice_type.as_str() {
"function" => {
let function = object.get("function").and_then(Value::as_object)?;
let name = function.get("name")?.as_str()?;
Some(json!({
"type": "function",
"name": name,
}))
}
"custom" => {
let custom = object.get("custom").and_then(Value::as_object)?;
let name = custom.get("name")?.as_str()?;
Some(json!({
"type": "custom",
"name": name,
}))
}
"allowed_tools" => {
let allowed_tools = object.get("allowed_tools").and_then(Value::as_object)?;
Some(json!({
"type": "allowed_tools",
"mode": allowed_tools.get("mode").cloned().unwrap_or_else(|| Value::String("auto".to_string())),
"tools": allowed_tools.get("tools").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
}))
}
_ => Some(tool_choice.clone()),
}
}
_ => Some(tool_choice.clone()),
}
}
fn copy_request_integer_field(
request: &Map<String, Value>,
output: &mut Map<String, Value>,
field: &str,
) {
if let Some(value) = request.get(field).and_then(Value::as_i64) {
output.insert(field.to_string(), Value::from(value));
}
}
fn copy_request_bool_field(
request: &Map<String, Value>,
output: &mut Map<String, Value>,
field: &str,
) {
if let Some(value) = request.get(field).and_then(Value::as_bool) {
output.insert(field.to_string(), Value::Bool(value));
}
}
#[cfg(test)]
mod tests {
use super::convert_openai_chat_request_to_openai_cli_request;
use serde_json::json;
#[test]
fn preserves_shared_openai_chat_controls_when_converting_to_openai_cli() {
let request = json!({
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}],
"max_completion_tokens": 256,
"verbosity": "low",
"prompt_cache_key": "cache-key-123",
"prompt_cache_retention": "persist",
"service_tier": "priority",
"user": "user-123",
"safety_identifier": "safe-123",
"top_logprobs": 3,
});
let converted = convert_openai_chat_request_to_openai_cli_request(
&request,
"gpt-5-upstream",
false,
false,
)
.expect("chat request should convert to responses");
assert_eq!(converted["model"], "gpt-5-upstream");
assert_eq!(converted["max_output_tokens"], 256);
assert_eq!(converted["prompt_cache_key"], "cache-key-123");
assert_eq!(converted["prompt_cache_retention"], "persist");
assert_eq!(converted["service_tier"], "priority");
assert_eq!(converted["user"], "user-123");
assert_eq!(converted["safety_identifier"], "safe-123");
assert_eq!(converted["top_logprobs"], 3);
assert_eq!(converted["text"]["verbosity"], "low");
}
#[test]
fn preserves_assistant_refusal_when_converting_to_openai_cli() {
let request = json!({
"model": "gpt-5",
"messages": [{
"role": "assistant",
"content": "",
"refusal": "cannot comply"
}]
});
let converted = convert_openai_chat_request_to_openai_cli_request(
&request,
"gpt-5-upstream",
false,
false,
)
.expect("chat request should convert to responses");
assert_eq!(
converted["input"][0]["content"],
json!([{
"type": "refusal",
"refusal": "cannot comply"
}])
);
}
}

View File

@@ -1,21 +0,0 @@
use serde_json::{json, Value};
pub(super) fn parse_openai_tool_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments {
Some(Value::Object(object)) => Some(Value::Object(object.clone())),
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
Some(json!({}))
} else {
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Object(object)) => Some(Value::Object(object)),
Ok(other) => Some(json!({ "raw": other })),
Err(_) => Some(json!({ "raw": trimmed })),
}
}
}
Some(other) => Some(json!({ "raw": other })),
None => Some(json!({})),
}
}

View File

@@ -1,12 +1 @@
pub mod from_openai_chat;
pub mod to_openai_chat;
pub use from_openai_chat::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request,
};
pub use to_openai_chat::{
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
};
pub use aether_ai_formats::conversion::request::*;

View File

@@ -1,906 +0,0 @@
use serde_json::{json, Map, Value};
use super::shared::canonical_json_string;
use crate::planner::openai::map_thinking_budget_to_openai_reasoning_effort;
pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
let mut next_generated_tool_use_index = 0usize;
if let Some(model) = request.get("model") {
output.insert("model".to_string(), model.clone());
}
let mut messages = Vec::new();
if let Some(system_text) = extract_claude_system_text(request.get("system")) {
if !system_text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": system_text,
}));
}
}
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
for message in message_values {
let message_object = message.as_object()?;
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"user" => {
append_claude_user_message_to_openai_messages(
message_object.get("content"),
&mut messages,
)?;
}
"assistant" => {
messages.push(normalize_claude_assistant_message_to_openai_message(
message_object.get("content"),
&mut next_generated_tool_use_index,
)?);
}
_ => {}
}
}
}
output.insert("messages".to_string(), Value::Array(messages));
if let Some(max_tokens) = request.get("max_tokens").cloned() {
output.insert("max_completion_tokens".to_string(), max_tokens);
}
for passthrough_key in ["temperature", "top_p", "metadata", "stop", "stream"] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if output.get("stop").is_none() {
if let Some(stop_sequences) = request
.get("stop_sequences")
.cloned()
.filter(|value| !value.is_null())
{
output.insert("stop".to_string(), stop_sequences);
}
}
if output.get("reasoning_effort").is_none() {
if let Some(reasoning_effort) = extract_claude_output_reasoning_effort(request) {
output.insert(
"reasoning_effort".to_string(),
Value::String(reasoning_effort.to_string()),
);
} else if let Some(thinking_budget) = request
.get("thinking")
.and_then(Value::as_object)
.and_then(|thinking| thinking.get("budget_tokens"))
.and_then(Value::as_u64)
{
output.insert(
"reasoning_effort".to_string(),
Value::String(
map_thinking_budget_to_openai_reasoning_effort(thinking_budget).to_string(),
),
);
}
}
if let Some(tools) = normalize_claude_tools_to_openai(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(web_search_options) = extract_claude_web_search_options(request.get("tools")) {
output.insert("web_search_options".to_string(), web_search_options);
}
if let Some(tool_choice) = normalize_claude_tool_choice_to_openai(request.get("tool_choice"))? {
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(parallel_tool_calls) =
extract_claude_parallel_tool_calls(request.get("tool_choice"))
{
output.insert(
"parallel_tool_calls".to_string(),
Value::Bool(parallel_tool_calls),
);
}
Some(Value::Object(output))
}
#[derive(Debug)]
enum ClaudeNormalizedBlock {
Text(String),
Thinking {
text: String,
signature: Option<String>,
},
RedactedThinking {
data: String,
},
ImageUrl(String),
FileData(String),
FileUrl(String),
ToolUse {
id: Option<String>,
name: String,
input: Option<Value>,
},
ToolResult {
tool_use_id: String,
content: Value,
},
}
fn normalize_claude_content_blocks(content: &Value) -> Option<Vec<ClaudeNormalizedBlock>> {
match content {
Value::String(text) => Some(vec![ClaudeNormalizedBlock::Text(text.clone())]),
Value::Array(blocks) => {
let mut normalized = Vec::new();
for block in blocks {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
normalized.push(ClaudeNormalizedBlock::Text(text.to_string()));
}
"thinking" => {
let thinking = block
.get("thinking")
.and_then(Value::as_str)
.or_else(|| block.get("text").and_then(Value::as_str))
.unwrap_or_default();
normalized.push(ClaudeNormalizedBlock::Thinking {
text: thinking.to_string(),
signature: block
.get("signature")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
});
}
"redacted_thinking" => {
let data = block
.get("data")
.and_then(Value::as_str)
.unwrap_or_default();
normalized.push(ClaudeNormalizedBlock::RedactedThinking {
data: data.to_string(),
});
}
"image" => {
let source = block.get("source")?.as_object()?;
match source.get("type")?.as_str()? {
"base64" => {
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let data = source
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
normalized.push(ClaudeNormalizedBlock::ImageUrl(build_data_url(
media_type, data,
)));
}
"url" => {
let url = source
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
normalized.push(ClaudeNormalizedBlock::ImageUrl(url));
}
_ => {}
}
}
"document" => {
let source = block.get("source")?.as_object()?;
match source.get("type")?.as_str()? {
"base64" => {
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let data = source
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
normalized.push(ClaudeNormalizedBlock::FileData(build_data_url(
media_type, data,
)));
}
"url" => {
let url = source
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
normalized.push(ClaudeNormalizedBlock::FileUrl(url));
}
_ => {}
}
}
"tool_use" => {
let name = block
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
normalized.push(ClaudeNormalizedBlock::ToolUse {
id: block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
name,
input: block.get("input").cloned(),
});
}
"tool_result" => {
let tool_use_id = block
.get("tool_use_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let content = block.get("content").cloned().unwrap_or(Value::Null);
normalized.push(ClaudeNormalizedBlock::ToolResult {
tool_use_id,
content,
});
}
_ => {}
}
}
Some(normalized)
}
_ => None,
}
}
fn append_claude_user_message_to_openai_messages(
content: Option<&Value>,
messages: &mut Vec<Value>,
) -> Option<()> {
let Some(content) = content else {
return Some(());
};
let mut pending_parts = Vec::new();
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) | ClaudeNormalizedBlock::Thinking { text, .. } => {
push_openai_text_part(&mut pending_parts, text);
}
ClaudeNormalizedBlock::RedactedThinking { .. } => {}
ClaudeNormalizedBlock::ImageUrl(url) => {
pending_parts.push(build_openai_image_part(url));
}
ClaudeNormalizedBlock::FileData(file_data) => {
pending_parts.push(build_openai_file_part(file_data));
}
ClaudeNormalizedBlock::FileUrl(url) => {
push_openai_text_part(&mut pending_parts, format!("[File: {url}]"));
}
ClaudeNormalizedBlock::ToolResult {
tool_use_id,
content,
} => {
flush_openai_user_content_parts(&mut pending_parts, messages);
messages.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
}));
}
ClaudeNormalizedBlock::ToolUse { .. } => {}
}
}
flush_openai_user_content_parts(&mut pending_parts, messages);
Some(())
}
fn normalize_claude_assistant_message_to_openai_message(
content: Option<&Value>,
next_generated_tool_use_index: &mut usize,
) -> Option<Value> {
let mut reasoning_segments = Vec::new();
let mut reasoning_parts = Vec::new();
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
if let Some(content) = content {
for block in normalize_claude_content_blocks(content)? {
match block {
ClaudeNormalizedBlock::Text(text) => {
push_openai_text_part(&mut content_parts, text);
}
ClaudeNormalizedBlock::Thinking { text, signature } => {
if !text.trim().is_empty() {
reasoning_segments.push(text.clone());
}
let mut reasoning_part = Map::new();
reasoning_part
.insert("type".to_string(), Value::String("thinking".to_string()));
reasoning_part.insert("thinking".to_string(), Value::String(text));
if let Some(signature) = signature {
reasoning_part.insert("signature".to_string(), Value::String(signature));
}
reasoning_parts.push(Value::Object(reasoning_part));
}
ClaudeNormalizedBlock::RedactedThinking { data } => {
if data.trim().is_empty() {
continue;
}
reasoning_parts.push(json!({
"type": "redacted_thinking",
"data": data,
}));
}
ClaudeNormalizedBlock::ImageUrl(url) => {
content_parts.push(build_openai_image_part(url));
}
ClaudeNormalizedBlock::FileData(file_data) => {
content_parts.push(build_openai_file_part(file_data));
}
ClaudeNormalizedBlock::FileUrl(url) => {
push_openai_text_part(&mut content_parts, format!("[File: {url}]"));
}
ClaudeNormalizedBlock::ToolUse { id, name, input } => {
let tool_use_id = id.unwrap_or_else(|| {
let generated = format!("toolu_auto_{next_generated_tool_use_index}");
*next_generated_tool_use_index += 1;
generated
});
tool_calls.push(json!({
"id": tool_use_id,
"type": "function",
"function": {
"name": name,
"arguments": canonical_json_string(input.unwrap_or(Value::Object(Map::new()))),
}
}));
}
ClaudeNormalizedBlock::ToolResult { .. } => {}
}
}
}
let mut assistant = Map::new();
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
assistant.insert(
"content".to_string(),
match build_openai_content_value(content_parts) {
Some(content) => content,
None if !tool_calls.is_empty() => Value::Null,
None => Value::String(String::new()),
},
);
if !reasoning_segments.is_empty() {
assistant.insert(
"reasoning_content".to_string(),
Value::String(reasoning_segments.join("")),
);
}
if !reasoning_parts.is_empty() {
assistant.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
}
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(Value::Object(assistant))
}
fn flush_openai_user_content_parts(pending_parts: &mut Vec<Value>, messages: &mut Vec<Value>) {
let parts = std::mem::take(pending_parts);
let Some(content) = build_openai_content_value(parts) else {
return;
};
messages.push(json!({
"role": "user",
"content": content,
}));
}
fn build_openai_content_value(parts: Vec<Value>) -> Option<Value> {
if parts.is_empty() {
return None;
}
if parts
.iter()
.all(|part| part.get("type").and_then(Value::as_str) == Some("text"))
{
let text = parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n\n");
return Some(Value::String(text));
}
Some(Value::Array(parts))
}
fn push_openai_text_part(parts: &mut Vec<Value>, text: String) {
if text.trim().is_empty() {
return;
}
parts.push(json!({
"type": "text",
"text": text,
}));
}
fn build_openai_image_part(url: String) -> Value {
json!({
"type": "image_url",
"image_url": {
"url": url,
}
})
}
fn build_openai_file_part(file_data: String) -> Value {
json!({
"type": "file",
"file": {
"file_data": file_data,
}
})
}
fn build_data_url(media_type: &str, data: &str) -> String {
format!("data:{media_type};base64,{data}")
}
fn extract_claude_output_reasoning_effort(request: &Map<String, Value>) -> Option<&'static str> {
match request
.get("output_config")
.and_then(Value::as_object)
.and_then(|config| config.get("effort"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_ascii_lowercase()
.as_str()
{
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"max" | "xhigh" => Some("xhigh"),
_ => None,
}
}
fn extract_claude_system_text(system: Option<&Value>) -> Option<String> {
let system = system?;
let text = match system {
Value::String(text) => text.clone(),
Value::Array(blocks) => {
let mut segments = Vec::new();
for block in blocks {
let block = block.as_object()?;
if block.get("type").and_then(Value::as_str).unwrap_or("text") == "text" {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if !text.trim().is_empty() {
segments.push(text.to_string());
}
}
}
segments.join("\n\n")
}
_ => return None,
};
Some(strip_claude_billing_header(&text))
}
fn strip_claude_billing_header(text: &str) -> String {
let trimmed = text.trim();
let prefix = "x-anthropic-billing-header:";
if !trimmed.to_ascii_lowercase().starts_with(prefix) {
return trimmed.to_string();
}
let remainder = trimmed
.split_once('\n')
.map(|(_, rest)| rest.trim_start())
.unwrap_or_default();
remainder.trim_start_matches('\n').trim().to_string()
}
fn normalize_claude_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(tools) = tools else {
return Some(None);
};
let tools = tools.as_array()?;
let mut normalized = Vec::new();
for tool in tools {
let tool = tool.as_object()?;
if tool
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.starts_with("web_search"))
{
continue;
}
let name = tool
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = tool.get("description").and_then(Value::as_str) {
if !description.trim().is_empty() {
function.insert(
"description".to_string(),
Value::String(description.trim().to_string()),
);
}
}
function.insert(
"parameters".to_string(),
tool.get("input_schema")
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
);
normalized.push(json!({
"type": "function",
"function": Value::Object(function),
}));
}
if normalized.is_empty() {
Some(None)
} else {
Some(Some(normalized))
}
}
fn extract_claude_web_search_options(tools: Option<&Value>) -> Option<Value> {
let tools = tools?.as_array()?;
for tool in tools {
let tool = tool.as_object()?;
let tool_type = tool
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !tool_type.starts_with("web_search") {
continue;
}
let found = true;
let mut options = Map::new();
if let Some(max_uses) = tool.get("max_uses").and_then(Value::as_u64) {
let search_context_size = if max_uses <= 1 {
"low"
} else if max_uses <= 5 {
"medium"
} else {
"high"
};
options.insert(
"search_context_size".to_string(),
Value::String(search_context_size.to_string()),
);
}
if let Some(user_location) = tool.get("user_location").and_then(Value::as_object) {
let mut approximate = Map::new();
for field in ["city", "country", "region", "timezone"] {
if let Some(value) = user_location.get(field).cloned() {
approximate.insert(field.to_string(), value);
}
}
if !approximate.is_empty() {
options.insert(
"user_location".to_string(),
json!({
"type": "approximate",
"approximate": approximate,
}),
);
}
}
if found {
return Some(Value::Object(options));
}
}
None
}
fn normalize_claude_tool_choice_to_openai(tool_choice: Option<&Value>) -> Option<Option<Value>> {
let Some(tool_choice) = tool_choice else {
return Some(None);
};
match tool_choice {
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Some(Value::String("auto".to_string()))),
"any" => Some(Some(Value::String("required".to_string()))),
"none" => Some(Some(Value::String("none".to_string()))),
_ => Some(None),
},
Value::Object(value) => {
if let Some(name) = value.get("name").and_then(Value::as_str) {
return Some(Some(json!({
"type": "function",
"function": { "name": name }
})));
}
let kind = value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match kind.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Some(Value::String("auto".to_string()))),
"any" => Some(Some(Value::String("required".to_string()))),
"none" => Some(Some(Value::String("none".to_string()))),
"tool" => value
.get("name")
.and_then(Value::as_str)
.map(|name| {
Some(json!({
"type": "function",
"function": { "name": name }
}))
})
.or(Some(None)),
_ => Some(None),
}
}
_ => Some(None),
}
}
fn extract_claude_parallel_tool_calls(tool_choice: Option<&Value>) -> Option<bool> {
let tool_choice = tool_choice?.as_object()?;
let choice_type = tool_choice
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if choice_type == "none" {
return None;
}
tool_choice
.get("disable_parallel_tool_use")
.and_then(Value::as_bool)
.map(|value| !value)
}
#[cfg(test)]
mod tests {
use super::normalize_claude_request_to_openai_chat_request;
use serde_json::json;
#[test]
fn assigns_deterministic_tool_use_ids_when_claude_blocks_omit_ids() {
let request = json!({
"model": "claude-sonnet-4-5",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"name": "search",
"input": {"query": "alpha"}
},
{
"type": "tool_use",
"name": "search",
"input": {"query": "beta"}
}
]
}
]
});
let first = normalize_claude_request_to_openai_chat_request(&request)
.expect("request should convert");
let second = normalize_claude_request_to_openai_chat_request(&request)
.expect("request should convert");
assert_eq!(first, second);
assert_eq!(first["messages"][0]["tool_calls"][0]["id"], "toolu_auto_0");
assert_eq!(first["messages"][0]["tool_calls"][1]["id"], "toolu_auto_1");
}
#[test]
fn preserves_explicit_claude_tool_use_ids() {
let request = json!({
"model": "claude-sonnet-4-5",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_explicit_1",
"name": "search",
"input": {"query": "alpha"}
}
]
}
]
});
let normalized = normalize_claude_request_to_openai_chat_request(&request)
.expect("request should convert");
assert_eq!(
normalized["messages"][0]["tool_calls"][0]["id"],
"toolu_explicit_1"
);
}
#[test]
fn extracts_claude_web_search_and_parallel_settings() {
let request = json!({
"model": "claude-sonnet-4-5",
"tools": [
{
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 10,
"user_location": {
"type": "approximate",
"city": "Shanghai",
"country": "CN",
"timezone": "Asia/Shanghai"
}
}
],
"tool_choice": {
"type": "auto",
"disable_parallel_tool_use": true
},
"messages": [
{
"role": "user",
"content": "find something"
}
]
});
let normalized = normalize_claude_request_to_openai_chat_request(&request)
.expect("request should convert");
assert_eq!(
normalized["web_search_options"]["search_context_size"],
"high"
);
assert_eq!(
normalized["web_search_options"]["user_location"],
json!({
"type": "approximate",
"approximate": {
"city": "Shanghai",
"country": "CN",
"timezone": "Asia/Shanghai"
}
})
);
assert_eq!(normalized["parallel_tool_calls"], false);
assert!(normalized.get("tools").is_none());
}
#[test]
fn normalizes_claude_media_thinking_and_stop_sequences() {
let request = json!({
"model": "claude-sonnet-4-5",
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "See attachment" },
{
"type": "image",
"source": {
"type": "url",
"url": "https://example.com/cat.png"
}
},
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0x"
}
}
]
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "need context first",
"signature": "sig_123"
},
{ "type": "redacted_thinking", "data": "redacted_blob" },
{ "type": "text", "text": "Working on it" }
]
}
],
"stop_sequences": ["END"],
"output_config": { "effort": "max" }
});
let normalized = normalize_claude_request_to_openai_chat_request(&request)
.expect("request should convert");
assert_eq!(normalized["stop"], json!(["END"]));
assert_eq!(normalized["reasoning_effort"], "xhigh");
assert_eq!(
normalized["messages"][0]["content"],
json!([
{ "type": "text", "text": "See attachment" },
{
"type": "image_url",
"image_url": { "url": "https://example.com/cat.png" }
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x"
}
}
])
);
assert_eq!(
normalized["messages"][1]["reasoning_content"],
"need context first"
);
assert_eq!(
normalized["messages"][1]["reasoning_parts"],
json!([
{
"type": "thinking",
"thinking": "need context first",
"signature": "sig_123"
},
{
"type": "redacted_thinking",
"data": "redacted_blob"
}
])
);
assert_eq!(normalized["messages"][1]["content"], "Working on it");
}
#[test]
fn preserves_default_claude_web_search_tool_as_empty_openai_options() {
let request = json!({
"model": "claude-sonnet-4-5",
"tools": [
{
"type": "web_search_20250305",
"name": "web_search"
}
],
"messages": [
{
"role": "user",
"content": "find something"
}
]
});
let normalized = normalize_claude_request_to_openai_chat_request(&request)
.expect("request should convert");
assert_eq!(normalized["web_search_options"], json!({}));
assert!(normalized.get("tools").is_none());
}
}

View File

@@ -1,962 +0,0 @@
use serde_json::{json, Map, Value};
use super::shared::canonical_json_string;
use crate::planner::openai::map_thinking_budget_to_openai_reasoning_effort;
const GEMINI_MAPPED_GENERATION_CONFIG_KEYS: &[&str] = &[
"maxOutputTokens",
"max_output_tokens",
"temperature",
"topP",
"top_p",
"topK",
"top_k",
"candidateCount",
"candidate_count",
"seed",
"stopSequences",
"stop_sequences",
"thinkingConfig",
"thinking_config",
"responseMimeType",
"response_mime_type",
"responseSchema",
"response_schema",
"responseModalities",
"response_modalities",
];
pub fn normalize_gemini_request_to_openai_chat_request(
body_json: &Value,
request_path: &str,
) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
output.insert("model".to_string(), Value::String(model.to_string()));
} else if let Some(model) = extract_gemini_model_from_path(request_path) {
output.insert("model".to_string(), Value::String(model));
}
let mut messages = Vec::new();
if let Some(system_text) = extract_gemini_system_text(
request
.get("systemInstruction")
.or_else(|| request.get("system_instruction")),
) {
if !system_text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": system_text,
}));
}
}
if let Some(contents) = request.get("contents").and_then(Value::as_array) {
for content in contents {
let content_object = content.as_object()?;
let role = content_object
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
let parts = content_object.get("parts").and_then(Value::as_array)?;
match role.as_str() {
"model" => messages.push(normalize_gemini_model_parts_to_openai_message(parts)?),
_ => append_gemini_user_parts_to_openai_messages(parts, &mut messages)?,
}
}
}
output.insert("messages".to_string(), Value::Array(messages));
let generation_config = request
.get("generationConfig")
.or_else(|| request.get("generation_config"))
.and_then(Value::as_object);
let mut google_extra = Map::new();
let mut gemini_extra = Map::new();
if let Some(generation_config) = generation_config {
if let Some(value) =
generation_config_value(generation_config, "maxOutputTokens", "max_output_tokens")
.cloned()
{
output.insert("max_completion_tokens".to_string(), value);
}
if let Some(value) = generation_config.get("temperature").cloned() {
output.insert("temperature".to_string(), value);
}
if let Some(value) = generation_config_value(generation_config, "topP", "top_p").cloned() {
output.insert("top_p".to_string(), value);
}
if let Some(value) = generation_config_value(generation_config, "topK", "top_k").cloned() {
output.insert("top_k".to_string(), value);
}
if let Some(value) =
generation_config_value(generation_config, "candidateCount", "candidate_count").cloned()
{
output.insert("n".to_string(), value);
}
if let Some(value) = generation_config.get("seed").cloned() {
output.insert("seed".to_string(), value);
}
if let Some(value) =
generation_config_value(generation_config, "stopSequences", "stop_sequences").cloned()
{
output.insert("stop".to_string(), value);
}
if let Some(thinking_config) =
generation_config_value(generation_config, "thinkingConfig", "thinking_config")
.and_then(Value::as_object)
{
google_extra.insert(
"thinking_config".to_string(),
Value::Object(thinking_config.clone()),
);
}
if let Some(response_modalities) = generation_config_value(
generation_config,
"responseModalities",
"response_modalities",
)
.cloned()
{
google_extra.insert("response_modalities".to_string(), response_modalities);
}
if let Some(thinking_budget) =
generation_config_value(generation_config, "thinkingConfig", "thinking_config")
.and_then(Value::as_object)
.and_then(|thinking| {
thinking
.get("thinkingBudget")
.or_else(|| thinking.get("thinking_budget"))
})
.and_then(Value::as_u64)
{
output.insert(
"reasoning_effort".to_string(),
Value::String(
map_thinking_budget_to_openai_reasoning_effort(thinking_budget).to_string(),
),
);
}
if generation_config_value(generation_config, "responseMimeType", "response_mime_type")
.and_then(Value::as_str)
.is_some_and(|value| value == "application/json")
{
let response_format = if let Some(schema) =
generation_config_value(generation_config, "responseSchema", "response_schema")
{
json!({
"type": "json_schema",
"json_schema": {
"name": "response_schema",
"schema": schema,
}
})
} else {
json!({ "type": "json_object" })
};
output.insert("response_format".to_string(), response_format);
}
let mut generation_config_extra = Map::new();
for (key, value) in generation_config {
if GEMINI_MAPPED_GENERATION_CONFIG_KEYS
.iter()
.any(|candidate| candidate == &key.as_str())
{
continue;
}
generation_config_extra.insert(key.clone(), value.clone());
}
if !generation_config_extra.is_empty() {
gemini_extra.insert(
"generation_config_extra".to_string(),
Value::Object(generation_config_extra),
);
}
}
if let Some(value) = request.get("stream").cloned() {
output.insert("stream".to_string(), value);
}
if let Some(value) = request
.get("safetySettings")
.or_else(|| request.get("safety_settings"))
.cloned()
{
gemini_extra.insert("safety_settings".to_string(), value);
}
if let Some(value) = request
.get("cachedContent")
.or_else(|| request.get("cached_content"))
.cloned()
{
gemini_extra.insert("cached_content".to_string(), value);
}
if let Some(tools) = normalize_gemini_tools_to_openai(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(web_search_options) = extract_gemini_web_search_options(request.get("tools")) {
output.insert("web_search_options".to_string(), web_search_options);
}
if let Some(tool_choice) = normalize_gemini_tool_choice_to_openai(
request
.get("toolConfig")
.or_else(|| request.get("tool_config")),
)? {
output.insert("tool_choice".to_string(), tool_choice);
}
if !google_extra.is_empty() || !gemini_extra.is_empty() {
let mut extra_body = Map::new();
if !google_extra.is_empty() {
extra_body.insert("google".to_string(), Value::Object(google_extra));
}
if !gemini_extra.is_empty() {
extra_body.insert("gemini".to_string(), Value::Object(gemini_extra));
}
output.insert("extra_body".to_string(), Value::Object(extra_body));
}
Some(Value::Object(output))
}
#[derive(Debug)]
enum GeminiNormalizedPart {
Text(String),
Thinking {
text: String,
signature: Option<String>,
},
ImageUrl(String),
FileData(String),
FileUrl(String),
AudioData {
data: String,
format: String,
},
ToolUse {
id: Option<String>,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: Value,
},
}
fn append_gemini_user_parts_to_openai_messages(
parts: &[Value],
messages: &mut Vec<Value>,
) -> Option<()> {
let mut pending_parts = Vec::new();
for part in normalize_gemini_parts(parts)? {
match part {
GeminiNormalizedPart::Text(text) | GeminiNormalizedPart::Thinking { text, .. } => {
push_openai_text_part(&mut pending_parts, text);
}
GeminiNormalizedPart::ImageUrl(url) => {
pending_parts.push(build_openai_image_part(url));
}
GeminiNormalizedPart::FileData(file_data) => {
pending_parts.push(build_openai_file_part(file_data));
}
GeminiNormalizedPart::FileUrl(url) => {
push_openai_text_part(&mut pending_parts, format!("[File: {url}]"));
}
GeminiNormalizedPart::AudioData { data, format } => {
pending_parts.push(build_openai_audio_part(data, format));
}
GeminiNormalizedPart::ToolResult {
tool_use_id,
content,
} => {
flush_openai_user_content_parts(&mut pending_parts, messages);
messages.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
}));
}
GeminiNormalizedPart::ToolUse { .. } => {}
}
}
flush_openai_user_content_parts(&mut pending_parts, messages);
Some(())
}
fn normalize_gemini_model_parts_to_openai_message(parts: &[Value]) -> Option<Value> {
let mut reasoning_segments = Vec::new();
let mut reasoning_parts = Vec::new();
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
for (index, part) in normalize_gemini_parts(parts)?.into_iter().enumerate() {
match part {
GeminiNormalizedPart::Text(text) => {
push_openai_text_part(&mut content_parts, text);
}
GeminiNormalizedPart::Thinking { text, signature } => {
if !text.trim().is_empty() {
reasoning_segments.push(text.clone());
}
let mut reasoning_part = Map::new();
reasoning_part.insert("type".to_string(), Value::String("thinking".to_string()));
reasoning_part.insert("thinking".to_string(), Value::String(text));
if let Some(signature) = signature {
reasoning_part.insert("signature".to_string(), Value::String(signature));
}
reasoning_parts.push(Value::Object(reasoning_part));
}
GeminiNormalizedPart::ImageUrl(url) => {
content_parts.push(build_openai_image_part(url));
}
GeminiNormalizedPart::FileData(file_data) => {
content_parts.push(build_openai_file_part(file_data));
}
GeminiNormalizedPart::FileUrl(url) => {
push_openai_text_part(&mut content_parts, format!("[File: {url}]"));
}
GeminiNormalizedPart::AudioData { data, format } => {
content_parts.push(build_openai_audio_part(data, format));
}
GeminiNormalizedPart::ToolUse { id, name, input } => {
let tool_id = id.unwrap_or_else(|| format!("toolu_{}_{}", name, index));
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": name,
"arguments": canonical_json_string(input),
}
}));
}
GeminiNormalizedPart::ToolResult { .. } => {}
}
}
let mut assistant = Map::new();
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
assistant.insert(
"content".to_string(),
match build_openai_content_value(content_parts) {
Some(content) => content,
None if !tool_calls.is_empty() => Value::Null,
None => Value::String(String::new()),
},
);
if !reasoning_segments.is_empty() {
assistant.insert(
"reasoning_content".to_string(),
Value::String(reasoning_segments.join("")),
);
}
if !reasoning_parts.is_empty() {
assistant.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
}
if !tool_calls.is_empty() {
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(Value::Object(assistant))
}
fn normalize_gemini_parts(parts: &[Value]) -> Option<Vec<GeminiNormalizedPart>> {
let mut normalized = Vec::new();
for part in parts {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if part
.get("thought")
.and_then(Value::as_bool)
.unwrap_or(false)
{
normalized.push(GeminiNormalizedPart::Thinking {
text: text.to_string(),
signature: part
.get("thoughtSignature")
.or_else(|| part.get("thought_signature"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
});
} else {
normalized.push(GeminiNormalizedPart::Text(text.to_string()));
}
continue;
}
if let Some(inline_data) = part
.get("inlineData")
.or_else(|| part.get("inline_data"))
.and_then(Value::as_object)
{
let mime_type = inline_data
.get("mimeType")
.or_else(|| inline_data.get("mime_type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let data = inline_data
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
if mime_type.starts_with("image/") {
normalized.push(GeminiNormalizedPart::ImageUrl(build_data_url(
mime_type, data,
)));
} else if let Some(format) = mime_type.strip_prefix("audio/") {
normalized.push(GeminiNormalizedPart::AudioData {
data: data.to_string(),
format: format.to_string(),
});
} else {
normalized.push(GeminiNormalizedPart::FileData(build_data_url(
mime_type, data,
)));
}
continue;
}
if let Some(file_data) = part
.get("fileData")
.or_else(|| part.get("file_data"))
.and_then(Value::as_object)
{
let file_uri = file_data
.get("fileUri")
.or_else(|| file_data.get("file_uri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mime_type = file_data
.get("mimeType")
.or_else(|| file_data.get("mime_type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if mime_type.is_some_and(|value| value.starts_with("image/")) {
normalized.push(GeminiNormalizedPart::ImageUrl(file_uri.to_string()));
} else {
normalized.push(GeminiNormalizedPart::FileUrl(file_uri.to_string()));
}
continue;
}
if let Some(function_call) = part
.get("functionCall")
.or_else(|| part.get("function_call"))
.and_then(Value::as_object)
{
let name = function_call
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
normalized.push(GeminiNormalizedPart::ToolUse {
id: function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
name,
input: function_call
.get("args")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new())),
});
continue;
}
if let Some(function_response) = part
.get("functionResponse")
.or_else(|| part.get("function_response"))
.and_then(Value::as_object)
{
let tool_use_id = function_response
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
function_response
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})?;
let response_value = function_response
.get("response")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
let content = match response_value {
Value::Object(mut object) => object
.remove("result")
.unwrap_or_else(|| Value::Object(object)),
other => other,
};
normalized.push(GeminiNormalizedPart::ToolResult {
tool_use_id,
content,
});
}
}
Some(normalized)
}
fn flush_openai_user_content_parts(pending_parts: &mut Vec<Value>, messages: &mut Vec<Value>) {
let parts = std::mem::take(pending_parts);
let Some(content) = build_openai_content_value(parts) else {
return;
};
messages.push(json!({
"role": "user",
"content": content,
}));
}
fn build_openai_content_value(parts: Vec<Value>) -> Option<Value> {
if parts.is_empty() {
return None;
}
if parts
.iter()
.all(|part| part.get("type").and_then(Value::as_str) == Some("text"))
{
let text = parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n\n");
return Some(Value::String(text));
}
Some(Value::Array(parts))
}
fn push_openai_text_part(parts: &mut Vec<Value>, text: String) {
if text.trim().is_empty() {
return;
}
parts.push(json!({
"type": "text",
"text": text,
}));
}
fn build_openai_image_part(url: String) -> Value {
json!({
"type": "image_url",
"image_url": {
"url": url,
}
})
}
fn build_openai_file_part(file_data: String) -> Value {
json!({
"type": "file",
"file": {
"file_data": file_data,
}
})
}
fn build_openai_audio_part(data: String, format: String) -> Value {
json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": format,
}
})
}
fn build_data_url(mime_type: &str, data: &str) -> String {
format!("data:{mime_type};base64,{data}")
}
fn generation_config_value<'a>(
generation_config: &'a Map<String, Value>,
camel: &str,
snake: &str,
) -> Option<&'a Value> {
generation_config
.get(camel)
.or_else(|| generation_config.get(snake))
}
fn extract_gemini_system_text(system_instruction: Option<&Value>) -> Option<String> {
let system_instruction = system_instruction?;
match system_instruction {
Value::String(text) => Some(text.trim().to_string()),
Value::Object(object) => {
let parts = object.get("parts")?.as_array()?;
let mut segments = Vec::new();
for part in parts {
let part = part.as_object()?;
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
segments.push(text.to_string());
}
}
}
Some(segments.join("\n\n"))
}
_ => None,
}
}
fn normalize_gemini_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(tools) = tools else {
return Some(None);
};
let tools = tools.as_array()?;
let mut normalized = Vec::new();
let mut has_code_execution = false;
let mut has_url_context = false;
for tool in tools {
let tool = tool.as_object()?;
if tool.get("codeExecution").is_some() || tool.get("code_execution").is_some() {
has_code_execution = true;
}
if tool.get("urlContext").is_some() || tool.get("url_context").is_some() {
has_url_context = true;
}
let declarations = tool
.get("functionDeclarations")
.or_else(|| tool.get("function_declarations"))
.and_then(Value::as_array);
let Some(declarations) = declarations else {
continue;
};
for declaration in declarations {
let declaration = declaration.as_object()?;
let name = declaration
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = declaration.get("description").and_then(Value::as_str) {
if !description.trim().is_empty() {
function.insert(
"description".to_string(),
Value::String(description.trim().to_string()),
);
}
}
function.insert(
"parameters".to_string(),
declaration
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({"type": "object"})),
);
normalized.push(json!({
"type": "function",
"function": Value::Object(function),
}));
}
}
if has_code_execution {
normalized.push(build_openai_builtin_gemini_tool("codeExecution"));
}
if has_url_context {
normalized.push(build_openai_builtin_gemini_tool("urlContext"));
}
if normalized.is_empty() {
Some(None)
} else {
Some(Some(normalized))
}
}
fn normalize_gemini_tool_choice_to_openai(tool_config: Option<&Value>) -> Option<Option<Value>> {
let Some(tool_config) = tool_config else {
return Some(None);
};
let tool_config = tool_config.as_object()?;
let function_config = tool_config
.get("functionCallingConfig")
.or_else(|| tool_config.get("function_calling_config"))
.and_then(Value::as_object)?;
let mode = function_config
.get("mode")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_uppercase();
if let Some(name) = function_config
.get("allowedFunctionNames")
.or_else(|| function_config.get("allowed_function_names"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(Some(json!({
"type": "function",
"function": { "name": name }
})));
}
match mode.as_str() {
"NONE" => Some(Some(Value::String("none".to_string()))),
"AUTO" => Some(Some(Value::String("auto".to_string()))),
"ANY" | "REQUIRED" => Some(Some(Value::String("required".to_string()))),
_ => Some(None),
}
}
fn extract_gemini_web_search_options(tools: Option<&Value>) -> Option<Value> {
let tools = tools?.as_array()?;
for tool in tools {
let tool = tool.as_object()?;
if tool.get("googleSearch").is_some() || tool.get("google_search").is_some() {
return Some(json!({}));
}
}
None
}
fn build_openai_builtin_gemini_tool(name: &str) -> Value {
json!({
"type": "function",
"function": {
"name": name,
"parameters": {
"type": "object",
"properties": {}
}
}
})
}
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
let tail = &path[start..];
let end = tail.find(':').unwrap_or(tail.len());
let model = tail[..end].trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}
#[cfg(test)]
mod tests {
use super::normalize_gemini_request_to_openai_chat_request;
use serde_json::json;
#[test]
fn normalizes_gemini_seed_builtin_tools_and_specific_tool_choice() {
let request = json!({
"model": "gemini-2.5-pro",
"contents": [
{
"role": "user",
"parts": [{ "text": "use tools" }]
}
],
"generationConfig": {
"maxOutputTokens": 256,
"topK": 20,
"seed": 7
},
"tools": [
{ "googleSearch": {} },
{ "codeExecution": {} },
{ "urlContext": {} },
{
"functionDeclarations": [
{
"name": "lookupWeather",
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
}
]
}
],
"toolConfig": {
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": ["lookupWeather"]
}
}
});
let normalized = normalize_gemini_request_to_openai_chat_request(
&request,
"/v1beta/models/gemini:generateContent",
)
.expect("request should convert");
assert_eq!(normalized["max_completion_tokens"], 256);
assert_eq!(normalized["top_k"], 20);
assert_eq!(normalized["seed"], 7);
assert_eq!(normalized["web_search_options"], json!({}));
assert_eq!(
normalized["tool_choice"],
json!({
"type": "function",
"function": { "name": "lookupWeather" }
})
);
assert_eq!(
normalized["tools"],
json!([
{
"type": "function",
"function": {
"name": "lookupWeather",
"parameters": { "type": "object", "properties": { "city": { "type": "string" } } }
}
},
{
"type": "function",
"function": {
"name": "codeExecution",
"parameters": { "type": "object", "properties": {} }
}
},
{
"type": "function",
"function": {
"name": "urlContext",
"parameters": { "type": "object", "properties": {} }
}
}
])
);
}
#[test]
fn normalizes_gemini_multimodal_thought_and_passthrough_config() {
let request = json!({
"contents": [
{
"role": "user",
"parts": [
{ "text": "Look at these" },
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } },
{ "inline_data": { "mime_type": "application/pdf", "data": "JVBERi0x" } },
{ "inlineData": { "mimeType": "audio/mp3", "data": "SUQz" } },
{
"functionResponse": {
"name": "lookup",
"id": "call_1",
"response": { "result": { "city": "Shanghai" } }
}
}
]
},
{
"role": "model",
"parts": [
{ "text": "reasoning", "thought": true, "thoughtSignature": "sig_123" },
{ "text": "done" },
{ "fileData": { "fileUri": "https://example.com/cat.png", "mimeType": "image/png" } },
{ "fileData": { "fileUri": "https://example.com/report.pdf", "mimeType": "application/pdf" } },
{
"functionCall": {
"name": "lookup",
"id": "call_1",
"args": { "city": "Shanghai" }
}
}
]
}
],
"generation_config": {
"max_output_tokens": 128,
"stop_sequences": ["END"],
"thinking_config": {
"includeThoughts": true,
"thinkingBudget": 4096
},
"responseModalities": ["TEXT", "IMAGE"],
"candidate_count": 2,
"presencePenalty": 0.5
},
"safetySettings": [{ "category": "HARM_CATEGORY_HATE_SPEECH" }],
"cachedContent": "cached/123"
});
let normalized = normalize_gemini_request_to_openai_chat_request(
&request,
"/v1beta/models/gemini-2.5-pro:generateContent",
)
.expect("request should convert");
assert_eq!(normalized["model"], "gemini-2.5-pro");
assert_eq!(
normalized["messages"][2]["reasoning_parts"],
json!([
{
"type": "thinking",
"thinking": "reasoning",
"signature": "sig_123"
}
])
);
assert_eq!(
normalized["messages"][0]["content"],
json!([
{ "type": "text", "text": "Look at these" },
{
"type": "image_url",
"image_url": { "url": "data:image/png;base64,iVBORw0KGgo=" }
},
{
"type": "file",
"file": { "file_data": "data:application/pdf;base64,JVBERi0x" }
},
{
"type": "input_audio",
"input_audio": { "data": "SUQz", "format": "mp3" }
}
])
);
assert_eq!(normalized["messages"][1]["role"], "tool");
assert_eq!(normalized["messages"][1]["tool_call_id"], "call_1");
assert_eq!(
normalized["messages"][1]["content"],
json!({ "city": "Shanghai" })
);
assert_eq!(normalized["messages"][2]["reasoning_content"], "reasoning");
assert_eq!(
normalized["messages"][2]["content"],
json!([
{ "type": "text", "text": "done" },
{
"type": "image_url",
"image_url": { "url": "https://example.com/cat.png" }
},
{ "type": "text", "text": "[File: https://example.com/report.pdf]" }
])
);
assert_eq!(normalized["messages"][2]["tool_calls"][0]["id"], "call_1");
assert_eq!(normalized["max_completion_tokens"], 128);
assert_eq!(normalized["n"], 2);
assert_eq!(normalized["stop"], json!(["END"]));
assert_eq!(normalized["reasoning_effort"], "high");
assert_eq!(
normalized["extra_body"]["google"]["response_modalities"],
json!(["TEXT", "IMAGE"])
);
assert_eq!(
normalized["extra_body"]["gemini"]["generation_config_extra"]["presencePenalty"],
0.5
);
assert_eq!(
normalized["extra_body"]["gemini"]["cached_content"],
"cached/123"
);
}
}

View File

@@ -1,9 +0,0 @@
mod claude;
mod gemini;
mod openai_cli;
mod shared;
pub use claude::normalize_claude_request_to_openai_chat_request;
pub use gemini::normalize_gemini_request_to_openai_chat_request;
pub use openai_cli::normalize_openai_cli_request_to_openai_chat_request;
pub use shared::{extract_openai_text_content, parse_openai_tool_result_content};

View File

@@ -1,565 +0,0 @@
use serde_json::{json, Map, Value};
use super::shared::{extract_openai_text_content, parse_openai_tool_result_content};
use crate::planner::openai::extract_openai_reasoning_effort;
pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
let request = body_json.as_object()?;
let mut output = Map::new();
if let Some(model) = request.get("model") {
output.insert("model".to_string(), model.clone());
}
let mut messages = Vec::new();
if let Some(instructions) = request.get("instructions") {
let text = extract_openai_text_content(Some(instructions))?;
if !text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": text,
}));
}
}
messages.extend(normalize_openai_cli_input_to_openai_chat_messages(
request.get("input"),
)?);
output.insert("messages".to_string(), Value::Array(messages));
if let Some(max_output_tokens) = request.get("max_output_tokens").cloned() {
output.insert("max_completion_tokens".to_string(), max_output_tokens);
}
for passthrough_key in [
"temperature",
"top_p",
"metadata",
"store",
"service_tier",
"prompt_cache_key",
"prompt_cache_retention",
"parallel_tool_calls",
"stop",
"stream",
"stream_options",
"user",
"safety_identifier",
"top_logprobs",
] {
if let Some(value) = request.get(passthrough_key) {
output.insert(passthrough_key.to_string(), value.clone());
}
}
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
output.insert(
"reasoning_effort".to_string(),
Value::String(reasoning_effort),
);
}
if let Some(response_format) = request
.get("text")
.and_then(Value::as_object)
.and_then(|text| text.get("format"))
.cloned()
{
output.insert("response_format".to_string(), response_format);
}
if let Some(verbosity) = request
.get("text")
.and_then(Value::as_object)
.and_then(|text| text.get("verbosity"))
.cloned()
{
output.insert("verbosity".to_string(), verbosity);
}
if let Some(tools) = normalize_openai_cli_tools_to_openai_chat(request.get("tools"))? {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(web_search_options) =
extract_openai_cli_web_search_options(request.get("tools").and_then(Value::as_array))
{
output.insert("web_search_options".to_string(), web_search_options);
}
if let Some(tool_choice) =
normalize_openai_cli_tool_choice_to_openai_chat(request.get("tool_choice"))?
{
output.insert("tool_choice".to_string(), tool_choice);
}
Some(Value::Object(output))
}
fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) -> Option<Vec<Value>> {
let Some(input) = input else {
return Some(Vec::new());
};
match input {
Value::Null => Some(Vec::new()),
Value::String(text) => {
if text.trim().is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({
"role": "user",
"content": text,
})])
}
}
Value::Array(items) => {
let mut messages = Vec::new();
let mut next_generated_tool_call_index = 0usize;
for item in items {
if let Some(item_text) = item.as_str() {
if !item_text.trim().is_empty() {
messages.push(json!({
"role": "user",
"content": item_text,
}));
}
continue;
}
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("message")
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
let role = item_object
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
if role == "system" || role == "developer" {
let text = extract_openai_text_content(item_object.get("content"))?;
if !text.trim().is_empty() {
messages.push(json!({
"role": "system",
"content": text,
}));
}
continue;
}
let normalized_content =
normalize_openai_cli_message_content(item_object.get("content"))?;
let mut message = serde_json::Map::new();
message.insert("role".to_string(), Value::String(role.clone()));
message.insert("content".to_string(), normalized_content);
if role == "assistant" {
if let Some(refusal) =
extract_openai_cli_message_refusal(item_object.get("content"))?
{
message.insert("refusal".to_string(), Value::String(refusal));
}
}
messages.push(Value::Object(message));
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = item_object
.get("call_id")
.or_else(|| item_object.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
});
let arguments = item_object
.get("arguments")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_else(|| "{}".to_string());
messages.push(json!({
"role": "assistant",
"content": Value::Array(Vec::new()),
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}]
}));
}
"function_call_output" => {
let tool_call_id = item_object
.get("call_id")
.or_else(|| item_object.get("tool_call_id"))
.or_else(|| item_object.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let generated =
format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
generated
});
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": parse_openai_tool_result_content(item_object.get("output")),
}));
}
_ => {}
}
}
Some(messages)
}
_ => None,
}
}
fn normalize_openai_cli_message_content(content: Option<&Value>) -> Option<Value> {
let Some(content) = content else {
return Some(Value::Array(Vec::new()));
};
match content {
Value::String(text) => Some(Value::String(text.clone())),
Value::Array(parts) => {
let mut normalized = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"input_text" | "output_text" | "text" => {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
normalized.push(json!({
"type": "text",
"text": text,
}));
}
}
"input_image" | "output_image" | "image_url" => {
let image_url = part_object
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part_object
.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})?;
let detail = part_object
.get("detail")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
part_object
.get("image_url")
.and_then(Value::as_object)
.and_then(|image| image.get("detail"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
});
let mut image = Map::new();
image.insert("url".to_string(), Value::String(image_url));
if let Some(detail) = detail {
image.insert("detail".to_string(), Value::String(detail));
}
normalized.push(json!({
"type": "image_url",
"image_url": image,
}));
}
"input_file" => {
let mut file = Map::new();
if let Some(file_data) = part_object.get("file_data").cloned() {
file.insert("file_data".to_string(), file_data);
}
if let Some(file_id) = part_object.get("file_id").cloned() {
file.insert("file_id".to_string(), file_id);
}
if let Some(filename) = part_object.get("filename").cloned() {
file.insert("filename".to_string(), filename);
}
if !file.is_empty() {
normalized.push(json!({
"type": "file",
"file": file,
}));
}
}
_ => {}
}
}
Some(Value::Array(normalized))
}
_ => Some(content.clone()),
}
}
fn extract_openai_cli_message_refusal(content: Option<&Value>) -> Option<Option<String>> {
let Some(content) = content else {
return Some(None);
};
match content {
Value::Array(parts) => {
let mut refusals = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if part_type == "refusal" {
if let Some(refusal) = part_object.get("refusal").and_then(Value::as_str) {
if !refusal.trim().is_empty() {
refusals.push(refusal.to_string());
}
}
}
}
if refusals.is_empty() {
Some(None)
} else {
Some(Some(refusals.join("\n")))
}
}
_ => Some(None),
}
}
fn normalize_openai_cli_tools_to_openai_chat(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
let Some(Value::Array(tool_values)) = tools else {
return Some(None);
};
let mut normalized = Vec::new();
for tool in tool_values {
let tool_object = tool.as_object()?;
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
if tool_type.starts_with("web_search") {
continue;
}
if tool_object.get("function").is_some() || tool_type != "function" {
continue;
}
let name = tool_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut function = Map::new();
function.insert("name".to_string(), Value::String(name.to_string()));
if let Some(description) = tool_object.get("description") {
function.insert("description".to_string(), description.clone());
}
if let Some(parameters) = tool_object.get("parameters") {
function.insert("parameters".to_string(), parameters.clone());
}
normalized.push(json!({
"type": "function",
"function": function,
}));
}
Some((!normalized.is_empty()).then_some(normalized))
}
fn extract_openai_cli_web_search_options(tools: Option<&Vec<Value>>) -> Option<Value> {
let tool_values = tools?;
for tool in tool_values {
let tool_object = tool.as_object()?;
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if !tool_type.starts_with("web_search") {
continue;
}
let mut options = Map::new();
if let Some(search_context_size) = tool_object.get("search_context_size").cloned() {
options.insert("search_context_size".to_string(), search_context_size);
}
if let Some(user_location) = tool_object.get("user_location").and_then(Value::as_object) {
let mut approximate = Map::new();
for field in ["city", "country", "region", "timezone"] {
if let Some(value) = user_location.get(field).cloned() {
approximate.insert(field.to_string(), value);
}
}
if !approximate.is_empty() {
options.insert(
"user_location".to_string(),
json!({
"type": "approximate",
"approximate": approximate,
}),
);
}
}
if !options.is_empty() {
return Some(Value::Object(options));
}
}
None
}
fn normalize_openai_cli_tool_choice_to_openai_chat(
tool_choice: Option<&Value>,
) -> Option<Option<Value>> {
let Some(tool_choice) = tool_choice else {
return Some(None);
};
match tool_choice {
Value::Object(object)
if object.get("function").is_none()
&& object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("function")) =>
{
let name = object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(Some(json!({
"type": "function",
"function": {
"name": name,
}
})))
}
_ => Some(Some(tool_choice.clone())),
}
}
#[cfg(test)]
mod tests {
use super::normalize_openai_cli_request_to_openai_chat_request;
use serde_json::json;
#[test]
fn preserves_openai_cli_text_and_passthrough_fields_when_normalizing_to_chat() {
let request = json!({
"model": "gpt-5",
"max_output_tokens": 128,
"input": [{
"role": "user",
"content": [{"type": "input_text", "text": "hi"}]
}],
"text": {
"format": {
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}}
},
"verbosity": "high"
},
"prompt_cache_key": "cache-key-456",
"prompt_cache_retention": "persist",
"service_tier": "flex",
"user": "user-456",
"safety_identifier": "safe-456",
"top_logprobs": 4
});
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
.expect("responses request should normalize to chat");
assert_eq!(converted["max_completion_tokens"], 128);
assert_eq!(
converted["response_format"],
json!({
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}}
})
);
assert_eq!(converted["verbosity"], "high");
assert_eq!(converted["prompt_cache_key"], "cache-key-456");
assert_eq!(converted["prompt_cache_retention"], "persist");
assert_eq!(converted["service_tier"], "flex");
assert_eq!(converted["user"], "user-456");
assert_eq!(converted["safety_identifier"], "safe-456");
assert_eq!(converted["top_logprobs"], 4);
}
#[test]
fn preserves_assistant_refusal_when_normalizing_to_chat() {
let request = json!({
"model": "gpt-5",
"input": [{
"type": "message",
"role": "assistant",
"content": [{"type": "refusal", "refusal": "cannot comply"}]
}]
});
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
.expect("responses request should normalize to chat");
assert_eq!(converted["messages"][0]["role"], "assistant");
assert_eq!(converted["messages"][0]["refusal"], "cannot comply");
assert_eq!(converted["messages"][0]["content"], json!([]));
}
#[test]
fn passes_through_stream_options() {
let request = json!({
"model": "gpt-5",
"stream": true,
"stream_options": {
"include_usage": true
},
"input": "hello"
});
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
.expect("responses request should normalize to chat");
assert_eq!(converted["stream"], true);
assert_eq!(converted["stream_options"]["include_usage"], true);
}
#[test]
fn preserves_stream_options_without_forcing_include_usage_during_normalization() {
let request = json!({
"model": "gpt-5",
"stream": true,
"stream_options": {
"include_usage": false,
"extra": "keep-me"
},
"input": "hello"
});
let converted = normalize_openai_cli_request_to_openai_chat_request(&request)
.expect("responses request should normalize to chat");
assert_eq!(converted["stream_options"]["include_usage"], false);
assert_eq!(converted["stream_options"]["extra"], "keep-me");
}
}

View File

@@ -1,66 +0,0 @@
use serde_json::Value;
pub fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
match content {
None | Some(Value::Null) => Some(String::new()),
Some(Value::String(text)) => Some(text.clone()),
Some(Value::Array(parts)) => {
let mut collected = Vec::new();
for part in parts {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if matches!(part_type, "text" | "input_text") {
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
collected.push(text.to_string());
}
}
}
}
Some(collected.join("\n"))
}
_ => None,
}
}
pub fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
match content {
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
Value::String(String::new())
} else {
serde_json::from_str::<Value>(trimmed)
.unwrap_or_else(|_| Value::String(raw.clone()))
}
}
Some(Value::Array(parts)) => {
let texts = parts
.iter()
.filter_map(|part| {
part.as_object()
.and_then(|object| object.get("text"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.collect::<Vec<_>>();
if texts.is_empty() {
Value::Array(parts.clone())
} else {
Value::String(texts.join("\n"))
}
}
Some(value) => value.clone(),
None => Value::String(String::new()),
}
}
pub(super) fn canonical_json_string(value: Value) -> String {
match value {
Value::String(text) => text,
other => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
}
}

View File

@@ -1,419 +0,0 @@
use serde_json::{json, Value};
use super::shared::{build_generated_tool_call_id, parse_openai_function_arguments};
pub fn convert_openai_chat_response_to_claude_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut content = extract_openai_reasoning_to_claude_blocks(message);
content.extend(convert_openai_assistant_content_to_claude_blocks(
message.get("content"),
)?);
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let input = parse_openai_function_arguments(function.get("arguments"))?;
content.push(json!({
"type": "tool_use",
"id": tool_id,
"name": tool_name,
"input": input,
}));
}
}
if content.is_empty() {
content.push(json!({
"type": "text",
"text": "",
}));
}
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "end_turn",
Some("length") => "max_tokens",
Some("tool_calls") | Some("function_call") => "tool_use",
Some("content_filter") => "content_filtered",
Some(other) => other,
};
let usage = body.get("usage").and_then(Value::as_object);
let input_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let model = body
.get("model")
.and_then(Value::as_str)
.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");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("msg-local-finalize");
Some(json!({
"id": id,
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stop_reason,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
}))
.map(|mut response| {
if let Some(cached_tokens) = usage
.and_then(|value| value.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_u64)
{
response["usage"]["cache_read_input_tokens"] = Value::from(cached_tokens);
}
if let Some(cached_creation_tokens) = usage
.and_then(|value| value.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_creation_tokens"))
.and_then(Value::as_u64)
{
response["usage"]["cache_creation_input_tokens"] = Value::from(cached_creation_tokens);
}
response
})
}
fn extract_openai_reasoning_to_claude_blocks(
message: &serde_json::Map<String, Value>,
) -> Vec<Value> {
let mut blocks = Vec::new();
if let Some(reasoning_parts) = message.get("reasoning_parts").and_then(Value::as_array) {
for reasoning_part in reasoning_parts {
let Some(reasoning_object) = reasoning_part.as_object() else {
continue;
};
match reasoning_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("thinking")
{
"thinking" => {
let thinking = reasoning_object
.get("thinking")
.or_else(|| reasoning_object.get("text"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if thinking.is_empty() {
continue;
}
let mut block = serde_json::Map::new();
block.insert("type".to_string(), Value::String("thinking".to_string()));
block.insert("thinking".to_string(), Value::String(thinking.to_string()));
if let Some(signature) = reasoning_object
.get("signature")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
block.insert(
"signature".to_string(),
Value::String(signature.to_string()),
);
}
blocks.push(Value::Object(block));
}
"redacted_thinking" => {
let data = reasoning_object
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if !data.is_empty() {
blocks.push(json!({
"type": "redacted_thinking",
"data": data,
}));
}
}
_ => {}
}
}
}
if !blocks.is_empty() {
return blocks;
}
if let Some(reasoning_content) = message
.get("reasoning_content")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
blocks.push(json!({
"type": "thinking",
"thinking": reasoning_content,
}));
}
blocks
}
fn convert_openai_assistant_content_to_claude_blocks(
content: Option<&Value>,
) -> Option<Vec<Value>> {
match content {
None | Some(Value::Null) => Some(Vec::new()),
Some(Value::String(text)) => {
if text.trim().is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({
"type": "text",
"text": text,
})])
}
}
Some(Value::Array(parts)) => {
let mut blocks = Vec::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"text" | "output_text" => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
blocks.push(json!({
"type": "text",
"text": text,
}));
}
}
}
"image_url" | "output_image" => {
if let Some(url) = extract_openai_image_url(part) {
if let Some((media_type, data)) = parse_data_url(url.as_str()) {
blocks.push(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
}
}));
} else {
blocks.push(json!({
"type": "image",
"source": {
"type": "url",
"url": url,
}
}));
}
}
}
"file" => {
let file = part.get("file").and_then(Value::as_object).unwrap_or(part);
if let Some(file_data) = file.get("file_data").and_then(Value::as_str) {
if let Some((media_type, data)) = parse_data_url(file_data) {
blocks.push(json!({
"type": "document",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
}
}));
}
} else if let Some(file_id) = file
.get("file_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
blocks.push(json!({
"type": "text",
"text": format!("[File: {file_id}]"),
}));
}
}
"input_audio" => {
let audio = part
.get("input_audio")
.and_then(Value::as_object)
.unwrap_or(part);
let data = audio
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let format = audio
.get("format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let (Some(data), Some(format)) = (data, format) {
blocks.push(json!({
"type": "document",
"source": {
"type": "base64",
"media_type": format!("audio/{format}"),
"data": data,
}
}));
}
}
_ => {}
}
}
Some(blocks)
}
_ => None,
}
}
fn extract_openai_image_url(part: &serde_json::Map<String, Value>) -> Option<String> {
part.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
}
fn parse_data_url(value: &str) -> Option<(String, String)> {
let rest = value.strip_prefix("data:")?;
let (meta, data) = rest.split_once(",")?;
let media_type = meta.strip_suffix(";base64")?;
if media_type.trim().is_empty() || data.trim().is_empty() {
return None;
}
Some((media_type.to_string(), data.to_string()))
}
#[cfg(test)]
mod tests {
use super::convert_openai_chat_response_to_claude_chat;
use serde_json::json;
#[test]
fn preserves_openai_reasoning_and_cache_usage_in_claude_response() {
let response = json!({
"id": "chatcmpl_123",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "hello",
"reasoning_content": "step by step",
"reasoning_parts": [
{
"type": "thinking",
"thinking": "step by step",
"signature": "sig_123"
},
{
"type": "redacted_thinking",
"data": "redacted_blob"
}
]
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 11,
"completion_tokens": 7,
"prompt_tokens_details": {
"cached_tokens": 3,
"cached_creation_tokens": 2
}
}
});
let converted = convert_openai_chat_response_to_claude_chat(&response, &json!({}))
.expect("response should convert");
assert_eq!(converted["content"][0]["type"], "thinking");
assert_eq!(converted["content"][0]["thinking"], "step by step");
assert_eq!(converted["content"][0]["signature"], "sig_123");
assert_eq!(converted["content"][1]["type"], "redacted_thinking");
assert_eq!(converted["usage"]["cache_read_input_tokens"], 3);
assert_eq!(converted["usage"]["cache_creation_input_tokens"], 2);
}
#[test]
fn converts_openai_multipart_content_into_claude_blocks() {
let response = json!({
"id": "chatcmpl_img_123",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": [
{ "type": "text", "text": "See attached." },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgo="
}
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x"
}
}
]
},
"finish_reason": "stop"
}]
});
let converted = convert_openai_chat_response_to_claude_chat(&response, &json!({}))
.expect("response should convert");
assert_eq!(converted["content"][0]["type"], "text");
assert_eq!(converted["content"][1]["type"], "image");
assert_eq!(converted["content"][1]["source"]["media_type"], "image/png");
assert_eq!(converted["content"][2]["type"], "document");
assert_eq!(
converted["content"][2]["source"]["media_type"],
"application/pdf"
);
}
}

View File

@@ -1,490 +0,0 @@
use serde_json::{json, Value};
use super::shared::{build_generated_tool_call_id, parse_openai_function_arguments};
pub fn convert_openai_chat_response_to_gemini_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let mut candidates = Vec::new();
for choice in choices {
let choice = choice.as_object()?;
let message = choice.get("message")?.as_object()?;
let mut parts = extract_openai_reasoning_to_gemini_parts(message);
parts.extend(convert_openai_assistant_content_to_gemini_parts(
message.get("content"),
)?);
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for (index, tool_call) in tool_call_values.iter().enumerate() {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let call_id = tool_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
parts.push(json!({
"functionCall": {
"id": call_id,
"name": tool_name,
"args": parse_openai_function_arguments(function.get("arguments"))?,
}
}));
}
}
if parts.is_empty() {
parts.push(json!({ "text": "" }));
}
let mut finish_reason = match choice.get("finish_reason").and_then(Value::as_str) {
Some("stop") | None => "STOP",
Some("length") => "MAX_TOKENS",
Some("content_filter") => "SAFETY",
Some("tool_calls") | Some("function_call") => "STOP",
Some(other) => other,
};
if parts.iter().any(|part| part.get("functionCall").is_some()) {
finish_reason = "STOP";
}
candidates.push(json!({
"content": {
"role": "model",
"parts": parts,
},
"finishReason": finish_reason,
"index": choice.get("index").and_then(Value::as_u64).unwrap_or(0),
}));
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let reasoning_tokens = usage
.and_then(|value| value.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 visible_completion_tokens = completion_tokens.saturating_sub(reasoning_tokens);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let model = body
.get("model")
.and_then(Value::as_str)
.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");
let response_id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-local-finalize");
Some(json!({
"responseId": response_id,
"modelVersion": model,
"candidates": candidates,
"usageMetadata": {
"promptTokenCount": prompt_tokens,
"candidatesTokenCount": visible_completion_tokens,
"thoughtsTokenCount": reasoning_tokens,
"totalTokenCount": total_tokens,
}
}))
}
fn extract_openai_reasoning_to_gemini_parts(
message: &serde_json::Map<String, Value>,
) -> Vec<Value> {
let mut parts = Vec::new();
if let Some(reasoning_parts) = message.get("reasoning_parts").and_then(Value::as_array) {
for reasoning_part in reasoning_parts {
let Some(reasoning_object) = reasoning_part.as_object() else {
continue;
};
if reasoning_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value != "thinking")
{
continue;
}
let thinking = reasoning_object
.get("thinking")
.or_else(|| reasoning_object.get("text"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if thinking.is_empty() {
continue;
}
let mut part = serde_json::Map::new();
part.insert("text".to_string(), Value::String(thinking.to_string()));
part.insert("thought".to_string(), Value::Bool(true));
if let Some(signature) = reasoning_object
.get("signature")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
part.insert(
"thoughtSignature".to_string(),
Value::String(signature.to_string()),
);
}
parts.push(Value::Object(part));
}
}
if !parts.is_empty() {
return parts;
}
if let Some(reasoning_content) = message
.get("reasoning_content")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(json!({
"text": reasoning_content,
"thought": true,
}));
}
parts
}
fn convert_openai_assistant_content_to_gemini_parts(content: Option<&Value>) -> Option<Vec<Value>> {
match content {
None | Some(Value::Null) => Some(Vec::new()),
Some(Value::String(text)) => {
if text.trim().is_empty() {
Some(Vec::new())
} else {
Some(vec![json!({ "text": text })])
}
}
Some(Value::Array(parts)) => {
let mut converted = Vec::new();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match part_type.as_str() {
"text" | "output_text" => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
converted.push(json!({ "text": text }));
}
}
}
"image_url" | "output_image" => {
let image_url = part
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})?;
if let Some((mime_type, data)) = parse_data_url(image_url.as_str()) {
converted.push(json!({
"inlineData": {
"mimeType": mime_type,
"data": data,
}
}));
} else {
converted.push(json!({
"fileData": {
"fileUri": image_url,
"mimeType": guess_media_type_from_reference(image_url.as_str(), "image/jpeg"),
}
}));
}
}
"file" | "input_file" => {
let file_object =
part.get("file").and_then(Value::as_object).unwrap_or(part);
if let Some(file_data) =
file_object.get("file_data").and_then(Value::as_str)
{
if let Some((mime_type, data)) = parse_data_url(file_data) {
converted.push(json!({
"inlineData": {
"mimeType": mime_type,
"data": data,
}
}));
}
} else if let Some(file_id) = file_object
.get("file_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
converted.push(json!({
"text": format!("[File: {file_id}]"),
}));
}
}
"input_audio" => {
let audio_object = part
.get("input_audio")
.and_then(Value::as_object)
.unwrap_or(part);
let data = audio_object
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let format = audio_object
.get("format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let (Some(data), Some(format)) = (data, format) {
converted.push(json!({
"inlineData": {
"mimeType": format!("audio/{format}"),
"data": data,
}
}));
}
}
_ => {}
}
}
Some(converted)
}
_ => None,
}
}
fn parse_data_url(value: &str) -> Option<(String, String)> {
let rest = value.strip_prefix("data:")?;
let (meta, data) = rest.split_once(",")?;
let mime_type = meta.strip_suffix(";base64")?;
if mime_type.trim().is_empty() || data.trim().is_empty() {
return None;
}
Some((mime_type.to_string(), data.to_string()))
}
fn guess_media_type_from_reference(reference: &str, default_mime: &str) -> String {
let normalized = reference
.split('?')
.next()
.unwrap_or(reference)
.to_ascii_lowercase();
if normalized.ends_with(".png") {
"image/png".to_string()
} else if normalized.ends_with(".gif") {
"image/gif".to_string()
} else if normalized.ends_with(".webp") {
"image/webp".to_string()
} else if normalized.ends_with(".jpg") || normalized.ends_with(".jpeg") {
"image/jpeg".to_string()
} else if normalized.ends_with(".pdf") {
"application/pdf".to_string()
} else {
default_mime.to_string()
}
}
#[cfg(test)]
mod tests {
use super::convert_openai_chat_response_to_gemini_chat;
use serde_json::json;
#[test]
fn preserves_multiple_openai_choices_and_reasoning_tokens_for_gemini() {
let response = json!({
"id": "chatcmpl_123",
"model": "gpt-5.4",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "hello",
"reasoning_content": "step by step",
"reasoning_parts": [
{
"type": "thinking",
"thinking": "step by step",
"signature": "sig_123"
}
]
},
"finish_reason": "stop"
},
{
"index": 1,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "lookup",
"arguments": "{\"city\":\"Shanghai\"}"
}
}]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 7,
"total_tokens": 17,
"completion_tokens_details": {
"reasoning_tokens": 2
}
}
});
let converted = convert_openai_chat_response_to_gemini_chat(&response, &json!({}))
.expect("response should convert");
assert_eq!(
converted["candidates"]
.as_array()
.expect("candidates")
.len(),
2
);
assert_eq!(
converted["candidates"][0]["content"]["parts"][0]["thought"],
true
);
assert_eq!(
converted["candidates"][0]["content"]["parts"][0]["thoughtSignature"],
"sig_123"
);
assert_eq!(
converted["candidates"][1]["content"]["parts"][0]["functionCall"]["name"],
"lookup"
);
assert_eq!(converted["usageMetadata"]["candidatesTokenCount"], 5);
assert_eq!(converted["usageMetadata"]["thoughtsTokenCount"], 2);
}
#[test]
fn preserves_multimodal_openai_content_in_gemini_response() {
let response = json!({
"id": "chatcmpl_mm_123",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"reasoning_content": "step by step",
"content": [
{ "type": "text", "text": "Attached." },
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgo="
}
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x"
}
},
{
"type": "input_audio",
"input_audio": {
"data": "SUQz",
"format": "mp3"
}
},
{
"type": "file",
"file": { "file_id": "file_123" }
}
]
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 3,
"completion_tokens_details": { "reasoning_tokens": 1 },
"total_tokens": 7
}
});
let converted = convert_openai_chat_response_to_gemini_chat(&response, &json!({}))
.expect("response should convert");
assert_eq!(
converted["candidates"][0]["content"]["parts"][0]["thought"],
true
);
assert_eq!(
converted["candidates"][0]["content"]["parts"][1],
json!({ "text": "Attached." })
);
assert_eq!(
converted["candidates"][0]["content"]["parts"][2],
json!({
"inlineData": {
"mimeType": "image/png",
"data": "iVBORw0KGgo="
}
})
);
assert_eq!(
converted["candidates"][0]["content"]["parts"][3],
json!({
"inlineData": {
"mimeType": "application/pdf",
"data": "JVBERi0x"
}
})
);
assert_eq!(
converted["candidates"][0]["content"]["parts"][4],
json!({
"inlineData": {
"mimeType": "audio/mp3",
"data": "SUQz"
}
})
);
assert_eq!(
converted["candidates"][0]["content"]["parts"][5],
json!({ "text": "[File: file_123]" })
);
assert_eq!(converted["usageMetadata"]["candidatesTokenCount"], 2);
assert_eq!(converted["usageMetadata"]["thoughtsTokenCount"], 1);
}
}

View File

@@ -1,12 +0,0 @@
mod claude_chat;
mod gemini_chat;
mod openai_cli;
mod shared;
pub use claude_chat::convert_openai_chat_response_to_claude_chat;
pub use gemini_chat::convert_openai_chat_response_to_gemini_chat;
pub use openai_cli::convert_openai_chat_response_to_openai_cli;
pub use shared::{
build_openai_cli_response, build_openai_cli_response_with_content,
build_openai_cli_response_with_reasoning, OpenAiCliResponseUsage,
};

View File

@@ -1,441 +0,0 @@
use serde_json::{json, Value};
use super::shared::{
build_openai_cli_response_with_content, canonicalize_tool_arguments, OpenAiCliResponseUsage,
};
pub fn convert_openai_chat_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
compact: bool,
) -> Option<Value> {
let body = body_json.as_object()?;
let choices = body.get("choices")?.as_array()?;
let first_choice = choices.first()?.as_object()?;
let message = first_choice.get("message")?.as_object()?;
let mut message_content = Vec::new();
let mut reasoning_summaries = Vec::new();
let message_annotations = message.get("annotations").cloned();
match message.get("content") {
Some(Value::String(value)) => {
if !value.is_empty() {
let mut item = json!({
"type": "output_text",
"text": value,
"annotations": []
});
if let Some(annotations) = message_annotations.clone() {
item["annotations"] = annotations;
}
message_content.push(item);
}
}
Some(Value::Array(parts)) => {
let text_part_count = parts
.iter()
.filter_map(Value::as_object)
.filter(|part| {
matches!(
part.get("type").and_then(Value::as_str).unwrap_or_default(),
"text" | "output_text"
)
})
.count();
for part in parts {
let part = part.as_object()?;
let part_type = part
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "text" | "output_text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
let mut item = json!({
"type": "output_text",
"text": piece,
"annotations": []
});
if text_part_count == 1 {
if let Some(annotations) = message_annotations.clone() {
item["annotations"] = annotations;
}
}
message_content.push(item);
}
} else if matches!(part_type.as_str(), "image_url" | "output_image") {
if let Some(image_url) = part
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
{
let mut image_part = json!({
"type": "output_image",
"image_url": image_url,
});
if let Some(detail) =
part.get("detail").and_then(Value::as_str).or_else(|| {
part.get("image_url")
.and_then(Value::as_object)
.and_then(|image| image.get("detail"))
.and_then(Value::as_str)
})
{
image_part["detail"] = Value::String(detail.to_string());
}
message_content.push(image_part);
}
} else if matches!(part_type.as_str(), "file" | "input_file") {
if let Some(file_part) = build_openai_cli_file_part(part) {
message_content.push(file_part);
}
} else if part_type == "input_audio" {
if let Some(audio_part) = build_openai_cli_input_audio_part(part) {
message_content.push(audio_part);
}
}
}
}
Some(Value::Null) | None => {}
_ => return None,
}
if let Some(refusal) = message.get("refusal").and_then(Value::as_str) {
if !refusal.trim().is_empty() {
message_content.push(json!({
"type": "refusal",
"refusal": refusal,
}));
}
}
if let Some(reasoning_content) = message.get("reasoning_content").and_then(Value::as_str) {
if !reasoning_content.trim().is_empty() {
reasoning_summaries.push(reasoning_content.to_string());
}
}
let mut function_calls = Vec::new();
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_call_values {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function")?.as_object()?;
let tool_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
function_calls.push(json!({
"type": "function_call",
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
"name": tool_name,
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
}));
}
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("prompt_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
.and_then(|value| value.get("completion_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + output_tokens);
let response_id = if compact {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
} else {
body.get("id")
.and_then(Value::as_str)
.map(|value| value.replace("chatcmpl", "resp"))
.unwrap_or_else(|| "resp-local-finalize".to_string())
};
let model = body
.get("model")
.and_then(Value::as_str)
.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");
let mut response = build_openai_cli_response_with_content(
&response_id,
model,
message_content,
reasoning_summaries,
function_calls,
OpenAiCliResponseUsage {
prompt_tokens,
output_tokens,
total_tokens,
},
);
if let Some(created) = body.get("created").and_then(Value::as_i64).or_else(|| {
body.get("created")
.and_then(Value::as_u64)
.map(|value| value as i64)
}) {
response["created_at"] = Value::from(created);
}
if let Some(service_tier) = body.get("service_tier").cloned().or_else(|| {
report_context
.get("original_request_body")
.and_then(Value::as_object)
.and_then(|request| request.get("service_tier"))
.cloned()
}) {
response["service_tier"] = service_tier;
}
if let Some(request_object) = report_context
.get("original_request_body")
.and_then(Value::as_object)
{
for key in [
"instructions",
"max_output_tokens",
"parallel_tool_calls",
"previous_response_id",
"reasoning",
"store",
"temperature",
"text",
"tool_choice",
"tools",
"top_p",
"truncation",
"user",
"metadata",
] {
if let Some(value) = request_object.get(key) {
response[key] = value.clone();
}
}
}
if let Some(prompt_details) = usage
.and_then(|value| value.get("prompt_tokens_details"))
.cloned()
{
response["usage"]["input_tokens_details"] = prompt_details;
}
if let Some(completion_details) = usage
.and_then(|value| value.get("completion_tokens_details"))
.cloned()
{
response["usage"]["output_tokens_details"] = completion_details;
}
Some(response)
}
fn build_openai_cli_file_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
let file_object = part.get("file").and_then(Value::as_object).unwrap_or(part);
let mut file = serde_json::Map::new();
for key in ["file_data", "file_id", "filename"] {
if let Some(value) = file_object
.get(key)
.cloned()
.filter(|value| !value.is_null())
{
file.insert(key.to_string(), value);
}
}
if file.is_empty() {
return None;
}
Some(json!({
"type": "file",
"file": Value::Object(file),
}))
}
fn build_openai_cli_input_audio_part(part: &serde_json::Map<String, Value>) -> Option<Value> {
let audio_object = part
.get("input_audio")
.and_then(Value::as_object)
.unwrap_or(part);
let data = audio_object
.get("data")
.cloned()
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
let format = audio_object
.get("format")
.cloned()
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
Some(json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": format,
}
}))
}
#[cfg(test)]
mod tests {
use super::convert_openai_chat_response_to_openai_cli;
use serde_json::json;
#[test]
fn preserves_created_refusal_request_echo_and_usage_details_when_converting_to_responses() {
let response = json!({
"id": "chatcmpl_123",
"object": "chat.completion",
"created": 1741569952i64,
"model": "gpt-5",
"service_tier": "default",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello",
"refusal": "partial refusal",
"annotations": [{"type": "url_citation", "start_index": 0, "end_index": 5}]
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 19,
"completion_tokens": 10,
"total_tokens": 29,
"prompt_tokens_details": {"cached_tokens": 0},
"completion_tokens_details": {"reasoning_tokens": 0}
}
});
let report_context = json!({
"original_request_body": {
"instructions": "Be concise.",
"max_output_tokens": 32,
"parallel_tool_calls": true,
"reasoning": {"effort": "medium"},
"store": true,
"temperature": 1.0,
"text": {"format": {"type": "text"}},
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"truncation": "disabled",
"user": null,
"metadata": {}
}
});
let converted =
convert_openai_chat_response_to_openai_cli(&response, &report_context, false)
.expect("chat response should convert to responses");
assert_eq!(converted["created_at"], 1741569952i64);
assert_eq!(converted["service_tier"], "default");
assert_eq!(converted["instructions"], "Be concise.");
assert_eq!(converted["max_output_tokens"], 32);
assert_eq!(converted["parallel_tool_calls"], true);
assert_eq!(converted["text"], json!({"format": {"type": "text"}}));
assert_eq!(converted["top_p"], 1.0);
assert_eq!(
converted["output"][0]["content"],
json!([
{
"type": "output_text",
"text": "Hello",
"annotations": [{"type": "url_citation", "start_index": 0, "end_index": 5}]
},
{
"type": "refusal",
"refusal": "partial refusal"
}
])
);
assert_eq!(
converted["usage"]["input_tokens_details"],
json!({"cached_tokens": 0})
);
assert_eq!(
converted["usage"]["output_tokens_details"],
json!({"reasoning_tokens": 0})
);
}
#[test]
fn preserves_file_and_audio_parts_when_converting_to_responses() {
let response = json!({
"id": "chatcmpl_mm_123",
"object": "chat.completion",
"model": "gpt-5",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": [
{ "type": "text", "text": "Attached." },
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x",
"filename": "report.pdf"
}
},
{
"type": "input_audio",
"input_audio": {
"data": "SUQz",
"format": "mp3"
}
}
]
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6
}
});
let converted = convert_openai_chat_response_to_openai_cli(&response, &json!({}), false)
.expect("chat response should convert to responses");
assert_eq!(
converted["output"][0]["content"],
json!([
{
"type": "output_text",
"text": "Attached.",
"annotations": []
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x",
"filename": "report.pdf"
}
},
{
"type": "input_audio",
"input_audio": {
"data": "SUQz",
"format": "mp3"
}
}
])
);
}
}

View File

@@ -1,146 +0,0 @@
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenAiCliResponseUsage {
pub prompt_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
}
pub fn build_openai_cli_response(
response_id: &str,
model: &str,
text: &str,
function_calls: Vec<Value>,
prompt_tokens: u64,
output_tokens: u64,
total_tokens: u64,
) -> Value {
let content = if text.is_empty() {
Vec::new()
} else {
vec![json!({
"type": "output_text",
"text": text,
"annotations": []
})]
};
build_openai_cli_response_with_content(
response_id,
model,
content,
Vec::new(),
function_calls,
OpenAiCliResponseUsage {
prompt_tokens,
output_tokens,
total_tokens,
},
)
}
pub fn build_openai_cli_response_with_reasoning(
response_id: &str,
model: &str,
text: &str,
reasoning_summaries: Vec<String>,
function_calls: Vec<Value>,
usage: OpenAiCliResponseUsage,
) -> Value {
let content = if text.is_empty() {
Vec::new()
} else {
vec![json!({
"type": "output_text",
"text": text,
"annotations": []
})]
};
build_openai_cli_response_with_content(
response_id,
model,
content,
reasoning_summaries,
function_calls,
usage,
)
}
pub fn build_openai_cli_response_with_content(
response_id: &str,
model: &str,
content: Vec<Value>,
reasoning_summaries: Vec<String>,
function_calls: Vec<Value>,
usage: OpenAiCliResponseUsage,
) -> Value {
let mut output = Vec::new();
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
let trimmed = summary.trim();
if trimmed.is_empty() {
continue;
}
output.push(json!({
"type": "reasoning",
"id": format!("{response_id}_rs_{index}"),
"status": "completed",
"summary": [{
"type": "summary_text",
"text": trimmed,
}]
}));
}
if !content.is_empty() {
output.push(json!({
"type": "message",
"id": format!("{response_id}_msg"),
"role": "assistant",
"status": "completed",
"content": content
}));
}
output.extend(function_calls);
json!({
"id": response_id,
"object": "response",
"status": "completed",
"model": model,
"output": output,
"usage": {
"input_tokens": usage.prompt_tokens,
"output_tokens": usage.output_tokens,
"total_tokens": usage.total_tokens,
}
})
}
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
Value::Object(object) => Some(Value::Object(object)),
Value::String(text) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Some(Value::Object(Map::new()))
} else {
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Object(object)) => Some(Value::Object(object)),
Ok(other) => Some(json!({ "raw": other })),
Err(_) => Some(json!({ "raw": text })),
}
}
}
other => Some(json!({ "raw": other })),
}
}
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub(super) 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(),
}
}

View File

@@ -1,13 +1 @@
pub mod from_openai_chat;
pub mod to_openai_chat;
pub use from_openai_chat::{
build_openai_cli_response, build_openai_cli_response_with_reasoning,
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
convert_openai_chat_response_to_openai_cli, OpenAiCliResponseUsage,
};
pub use to_openai_chat::{
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
convert_openai_cli_response_to_openai_chat,
};
pub use aether_ai_formats::conversion::response::*;

View File

@@ -1,436 +0,0 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub fn convert_claude_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let content = body.get("content")?.as_array()?;
let mut text = String::new();
let mut content_parts = Vec::new();
let mut reasoning_content = String::new();
let mut reasoning_parts = Vec::new();
let mut tool_calls = Vec::new();
let mut has_non_text_content = false;
for (index, block) in content.iter().enumerate() {
let block = block.as_object()?;
match block.get("type")?.as_str()? {
"text" => {
let piece = block.get("text")?.as_str()?;
push_openai_text_part(&mut text, &mut content_parts, piece);
}
"thinking" => {
let piece = block
.get("thinking")
.and_then(Value::as_str)
.or_else(|| block.get("text").and_then(Value::as_str))
.unwrap_or_default();
if !piece.is_empty() {
reasoning_content.push_str(piece);
}
let mut reasoning_part = Map::new();
reasoning_part.insert("type".to_string(), Value::String("thinking".to_string()));
reasoning_part.insert("thinking".to_string(), Value::String(piece.to_string()));
if let Some(signature) = block
.get("signature")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
reasoning_part.insert(
"signature".to_string(),
Value::String(signature.to_string()),
);
}
reasoning_parts.push(Value::Object(reasoning_part));
}
"redacted_thinking" => {
let data = block
.get("data")
.and_then(Value::as_str)
.unwrap_or_default();
if !data.is_empty() {
reasoning_parts.push(json!({
"type": "redacted_thinking",
"data": data,
}));
}
}
"tool_use" => {
let tool_name = block.get("name")?.as_str()?;
let tool_id = block
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
}
"image" => {
content_parts.push(convert_claude_image_block_to_openai_part(block)?);
has_non_text_content = true;
}
"document" => {
let part = convert_claude_document_block_to_openai_part(block)?;
if part.get("type").and_then(Value::as_str) == Some("text") {
if let Some(piece) = part.get("text").and_then(Value::as_str) {
push_openai_text_part(&mut text, &mut content_parts, piece);
}
} else {
content_parts.push(part);
has_non_text_content = true;
}
}
_ => continue,
}
}
let mut finish_reason = match body.get("stop_reason").and_then(Value::as_str) {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = prompt_tokens + completion_tokens;
let model = body
.get("model")
.and_then(Value::as_str)
.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");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
let message_content = if content_parts.is_empty() && !tool_calls.is_empty() {
Value::Null
} else if has_non_text_content {
Value::Array(content_parts)
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !reasoning_content.trim().is_empty() {
message.insert(
"reasoning_content".to_string(),
Value::String(reasoning_content),
);
}
if !reasoning_parts.is_empty() {
message.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
}
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
.map(|mut response| {
if let Some(service_tier) = report_context
.get("original_request_body")
.and_then(Value::as_object)
.and_then(|request| request.get("service_tier"))
.cloned()
{
response["service_tier"] = service_tier;
}
let mut prompt_details = Map::new();
if let Some(cached_tokens) = usage
.and_then(|value| value.get("cache_read_input_tokens"))
.and_then(Value::as_u64)
{
prompt_details.insert("cached_tokens".to_string(), Value::from(cached_tokens));
}
if let Some(cached_creation_tokens) = usage
.and_then(|value| value.get("cache_creation_input_tokens"))
.and_then(Value::as_u64)
{
prompt_details.insert(
"cached_creation_tokens".to_string(),
Value::from(cached_creation_tokens),
);
}
if !prompt_details.is_empty() {
response["usage"]["prompt_tokens_details"] = Value::Object(prompt_details);
}
response
})
}
fn push_openai_text_part(text: &mut String, content_parts: &mut Vec<Value>, piece: &str) {
if piece.is_empty() {
return;
}
text.push_str(piece);
content_parts.push(json!({
"type": "text",
"text": piece,
}));
}
fn convert_claude_image_block_to_openai_part(
block: &serde_json::Map<String, Value>,
) -> Option<Value> {
let source = block.get("source")?.as_object()?;
match source.get("type")?.as_str()? {
"base64" => {
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let data = source
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"type": "image_url",
"image_url": {
"url": build_data_url(media_type, data),
}
}))
}
"url" => {
let url = source
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"type": "image_url",
"image_url": {
"url": url,
}
}))
}
_ => None,
}
}
fn convert_claude_document_block_to_openai_part(
block: &serde_json::Map<String, Value>,
) -> Option<Value> {
let source = block.get("source")?.as_object()?;
match source.get("type")?.as_str()? {
"base64" => {
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let data = source
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
if let Some(format) = media_type.strip_prefix("audio/") {
return Some(json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": format,
}
}));
}
Some(json!({
"type": "file",
"file": {
"file_data": build_data_url(media_type, data),
}
}))
}
"url" => {
let url = source
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({
"type": "text",
"text": format!("[File: {url}]"),
}))
}
_ => None,
}
}
fn build_data_url(media_type: &str, data: &str) -> String {
format!("data:{media_type};base64,{data}")
}
#[cfg(test)]
mod tests {
use super::convert_claude_chat_response_to_openai_chat;
use serde_json::json;
#[test]
fn preserves_claude_reasoning_cache_usage_and_service_tier() {
let response = json!({
"id": "msg_123",
"model": "claude-sonnet-4-5",
"content": [
{ "type": "thinking", "thinking": "step by step", "signature": "sig_123" },
{ "type": "text", "text": "hello" }
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 11,
"output_tokens": 7,
"cache_read_input_tokens": 3,
"cache_creation_input_tokens": 2
}
});
let report_context = json!({
"original_request_body": {
"service_tier": "default"
}
});
let converted = convert_claude_chat_response_to_openai_chat(&response, &report_context)
.expect("response should convert");
assert_eq!(
converted["choices"][0]["message"]["reasoning_content"],
"step by step"
);
assert_eq!(
converted["choices"][0]["message"]["reasoning_parts"],
json!([
{
"type": "thinking",
"thinking": "step by step",
"signature": "sig_123"
}
])
);
assert_eq!(
converted["usage"]["prompt_tokens_details"]["cached_tokens"],
3
);
assert_eq!(
converted["usage"]["prompt_tokens_details"]["cached_creation_tokens"],
2
);
assert_eq!(converted["service_tier"], "default");
}
#[test]
fn converts_claude_multimodal_content_into_openai_chat_parts() {
let response = json!({
"id": "msg_multimodal_123",
"model": "claude-sonnet-4-5",
"content": [
{ "type": "thinking", "thinking": "step by step" },
{ "type": "text", "text": "See attached." },
{
"type": "image",
"source": {
"type": "url",
"url": "https://example.com/cat.png"
}
},
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0x"
}
},
{
"type": "document",
"source": {
"type": "base64",
"media_type": "audio/mp3",
"data": "SUQz"
}
},
{
"type": "tool_use",
"id": "call_1",
"name": "lookup",
"input": { "city": "Shanghai" }
}
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 9,
"output_tokens": 4
}
});
let converted = convert_claude_chat_response_to_openai_chat(&response, &json!({}))
.expect("response should convert");
assert_eq!(
converted["choices"][0]["message"]["reasoning_content"],
"step by step"
);
assert_eq!(
converted["choices"][0]["message"]["content"],
json!([
{ "type": "text", "text": "See attached." },
{
"type": "image_url",
"image_url": {
"url": "https://example.com/cat.png"
}
},
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x"
}
},
{
"type": "input_audio",
"input_audio": {
"data": "SUQz",
"format": "mp3"
}
}
])
);
assert_eq!(converted["choices"][0]["finish_reason"], "tool_calls");
assert_eq!(
converted["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
"{\"city\":\"Shanghai\"}"
);
}
}

View File

@@ -1,12 +0,0 @@
use serde_json::Value;
use super::super::from_openai_chat::convert_openai_chat_response_to_openai_cli;
use super::claude_chat::convert_claude_chat_response_to_openai_chat;
pub fn convert_claude_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let canonical = convert_claude_chat_response_to_openai_chat(body_json, report_context)?;
convert_openai_chat_response_to_openai_cli(&canonical, report_context, false)
}

View File

@@ -1,384 +0,0 @@
use serde_json::{json, Map, Value};
use super::shared::{
build_generated_tool_call_id, canonicalize_tool_arguments, extract_gemini_image_url,
};
pub fn convert_gemini_chat_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let candidates = body.get("candidates")?.as_array()?;
let mut choices = Vec::new();
for candidate in candidates {
let candidate = candidate.as_object()?;
let content = candidate.get("content")?.as_object()?;
let parts = content.get("parts")?.as_array()?;
let mut text = String::new();
let mut content_parts = Vec::new();
let mut reasoning_content = String::new();
let mut reasoning_parts = Vec::new();
let mut tool_calls = Vec::new();
let mut has_non_text_content = false;
for (index, part) in parts.iter().enumerate() {
let part = part.as_object()?;
if let Some(piece) = part.get("text").and_then(Value::as_str) {
if part
.get("thought")
.and_then(Value::as_bool)
.unwrap_or(false)
{
reasoning_content.push_str(piece);
let mut reasoning_part = Map::new();
reasoning_part
.insert("type".to_string(), Value::String("thinking".to_string()));
reasoning_part.insert("thinking".to_string(), Value::String(piece.to_string()));
if let Some(signature) = part
.get("thoughtSignature")
.or_else(|| part.get("thought_signature"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
reasoning_part.insert(
"signature".to_string(),
Value::String(signature.to_string()),
);
}
reasoning_parts.push(Value::Object(reasoning_part));
} else {
text.push_str(piece);
content_parts.push(json!({
"type": "text",
"text": piece,
}));
}
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object)
{
let tool_name = function_call.get("name")?.as_str()?;
let tool_id = function_call
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments,
}
}));
} else if let Some(rendered_text) = render_gemini_textual_part(part) {
text.push_str(rendered_text.as_str());
content_parts.push(json!({
"type": "text",
"text": rendered_text,
}));
} else if let Some(content_part) = convert_gemini_part_to_openai_content_part(part) {
if content_part.get("type").and_then(Value::as_str) == Some("text") {
if let Some(piece) = content_part.get("text").and_then(Value::as_str) {
text.push_str(piece);
}
} else {
has_non_text_content = true;
}
content_parts.push(content_part);
} else {
continue;
}
}
let mut finish_reason = match candidate.get("finishReason").and_then(Value::as_str) {
Some("STOP") => Some("stop"),
Some("MAX_TOKENS") => Some("length"),
Some(
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" | "OTHER",
) => Some("content_filter"),
Some(other) if !other.is_empty() => Some(other),
_ => None,
};
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
finish_reason = Some("tool_calls");
}
let message_content = if content_parts.is_empty() && !tool_calls.is_empty() {
Value::Null
} else if has_non_text_content {
Value::Array(content_parts)
} else {
Value::String(text)
};
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
message.insert("content".to_string(), message_content);
if !reasoning_content.trim().is_empty() {
message.insert(
"reasoning_content".to_string(),
Value::String(reasoning_content),
);
}
if !reasoning_parts.is_empty() {
message.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
}
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
choices.push(json!({
"index": candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
"message": Value::Object(message),
"finish_reason": finish_reason,
}));
}
let usage = body.get("usageMetadata").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("promptTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let reasoning_tokens = usage
.and_then(|value| value.get("thoughtsTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("candidatesTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(0)
+ reasoning_tokens;
let total_tokens = usage
.and_then(|value| value.get("totalTokenCount"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let model = body
.get("modelVersion")
.and_then(Value::as_str)
.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");
let id = body
.get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-finalize");
Some(json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": choices,
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
}))
.map(|mut response| {
if reasoning_tokens > 0 {
response["usage"]["completion_tokens_details"] =
json!({ "reasoning_tokens": reasoning_tokens });
}
response
})
}
fn render_gemini_textual_part(part: &Map<String, Value>) -> Option<String> {
if let Some(code) = part.get("executableCode").and_then(Value::as_object) {
let language = code
.get("language")
.and_then(Value::as_str)
.unwrap_or_default();
let source = code.get("code").and_then(Value::as_str).unwrap_or_default();
return Some(format!("```{language}\n{source}\n```"));
}
if let Some(result) = part.get("codeExecutionResult").and_then(Value::as_object) {
let output = result
.get("output")
.and_then(Value::as_str)
.unwrap_or_default();
return Some(format!("```output\n{output}\n```"));
}
None
}
fn convert_gemini_part_to_openai_content_part(part: &Map<String, Value>) -> Option<Value> {
if let Some(image_url) = extract_gemini_image_url(part) {
return Some(json!({
"type": "image_url",
"image_url": {
"url": image_url,
}
}));
}
if let Some(inline_data) = part
.get("inlineData")
.or_else(|| part.get("inline_data"))
.and_then(Value::as_object)
{
let mime_type = inline_data
.get("mimeType")
.or_else(|| inline_data.get("mime_type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let data = inline_data
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
if let Some(format) = mime_type.strip_prefix("audio/") {
return Some(json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": format,
}
}));
}
return Some(json!({
"type": "file",
"file": {
"file_data": format!("data:{mime_type};base64,{data}"),
}
}));
}
if let Some(file_data) = part
.get("fileData")
.or_else(|| part.get("file_data"))
.and_then(Value::as_object)
{
let file_uri = file_data
.get("fileUri")
.or_else(|| file_data.get("file_uri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
return Some(json!({
"type": "text",
"text": format!("[File: {file_uri}]"),
}));
}
None
}
#[cfg(test)]
mod tests {
use super::convert_gemini_chat_response_to_openai_chat;
use serde_json::json;
#[test]
fn preserves_gemini_candidates_reasoning_and_code_execution() {
let response = json!({
"responseId": "resp_123",
"modelVersion": "gemini-2.5-pro",
"candidates": [
{
"index": 0,
"finishReason": "RECITATION",
"content": {
"parts": [
{ "text": "thinking", "thought": true, "thoughtSignature": "sig_123" },
{ "executableCode": { "language": "python", "code": "print(1)" } },
{ "codeExecutionResult": { "output": "1" } }
]
}
},
{
"index": 1,
"finishReason": "STOP",
"content": {
"parts": [
{ "functionCall": { "id": "call_1", "name": "lookup", "args": { "city": "Shanghai" } } }
]
}
}
],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5,
"thoughtsTokenCount": 2,
"totalTokenCount": 17
}
});
let converted = convert_gemini_chat_response_to_openai_chat(&response, &json!({}))
.expect("response should convert");
assert_eq!(converted["choices"].as_array().expect("choices").len(), 2);
assert_eq!(converted["choices"][0]["finish_reason"], "content_filter");
assert_eq!(
converted["choices"][0]["message"]["reasoning_content"],
"thinking"
);
assert_eq!(
converted["choices"][0]["message"]["reasoning_parts"],
json!([
{
"type": "thinking",
"thinking": "thinking",
"signature": "sig_123"
}
])
);
let content = converted["choices"][0]["message"]["content"]
.as_str()
.expect("content should be string");
assert!(content.contains("print(1)"));
assert!(content.contains("```output\n1\n```"));
assert_eq!(converted["choices"][1]["finish_reason"], "tool_calls");
assert_eq!(
converted["usage"]["completion_tokens_details"]["reasoning_tokens"],
2
);
assert_eq!(converted["usage"]["completion_tokens"], 7);
}
#[test]
fn preserves_gemini_multimodal_parts_in_openai_chat_response() {
let response = json!({
"responseId": "resp_mm_123",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"finishReason": "STOP",
"content": {
"parts": [
{ "text": "Attached." },
{ "inlineData": { "mimeType": "image/png", "data": "iVBORw0KGgo=" } },
{ "inlineData": { "mimeType": "application/pdf", "data": "JVBERi0x" } },
{ "inline_data": { "mime_type": "audio/mp3", "data": "SUQz" } },
{ "fileData": { "fileUri": "https://example.com/report.pdf", "mimeType": "application/pdf" } }
]
}
}],
"usageMetadata": {
"promptTokenCount": 4,
"candidatesTokenCount": 2,
"totalTokenCount": 6
}
});
let converted = convert_gemini_chat_response_to_openai_chat(&response, &json!({}))
.expect("response should convert");
assert_eq!(
converted["choices"][0]["message"]["content"],
json!([
{ "type": "text", "text": "Attached." },
{
"type": "image_url",
"image_url": { "url": "data:image/png;base64,iVBORw0KGgo=" }
},
{
"type": "file",
"file": { "file_data": "data:application/pdf;base64,JVBERi0x" }
},
{
"type": "input_audio",
"input_audio": { "data": "SUQz", "format": "mp3" }
},
{ "type": "text", "text": "[File: https://example.com/report.pdf]" }
])
);
}
}

View File

@@ -1,12 +0,0 @@
use serde_json::Value;
use super::super::from_openai_chat::convert_openai_chat_response_to_openai_cli;
use super::gemini_chat::convert_gemini_chat_response_to_openai_chat;
pub fn convert_gemini_cli_response_to_openai_cli(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let canonical = convert_gemini_chat_response_to_openai_chat(body_json, report_context)?;
convert_openai_chat_response_to_openai_cli(&canonical, report_context, false)
}

View File

@@ -1,12 +0,0 @@
mod claude_chat;
mod claude_cli;
mod gemini_chat;
mod gemini_cli;
mod openai_cli;
mod shared;
pub use claude_chat::convert_claude_chat_response_to_openai_chat;
pub use claude_cli::convert_claude_cli_response_to_openai_cli;
pub use gemini_chat::convert_gemini_chat_response_to_openai_chat;
pub use gemini_cli::convert_gemini_cli_response_to_openai_cli;
pub use openai_cli::convert_openai_cli_response_to_openai_chat;

View File

@@ -1,494 +0,0 @@
use serde_json::{json, Map, Value};
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
pub fn convert_openai_cli_response_to_openai_chat(
body_json: &Value,
report_context: &Value,
) -> Option<Value> {
let body = body_json.as_object()?;
let mut text = String::new();
let mut content_parts = Vec::new();
let mut reasoning_content = String::new();
let mut tool_calls = Vec::new();
let mut annotations = Vec::new();
let mut refusal = Vec::new();
let mut has_non_text_content = false;
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
for (index, item) in output_items.iter().enumerate() {
let item_object = item.as_object()?;
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
for part in content {
let part_object = part.as_object()?;
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(part_type.as_str(), "output_text" | "text") {
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
{
let annotation_offset = text.chars().count() as i64;
if let Some(raw_annotations) =
part_object.get("annotations").and_then(Value::as_array)
{
annotations.extend(raw_annotations.iter().map(
|annotation| {
offset_annotation_indices(
annotation,
annotation_offset,
)
},
));
}
text.push_str(piece);
content_parts.push(json!({
"type": "text",
"text": piece,
}));
}
} else if part_type == "refusal" {
if let Some(piece) =
part_object.get("refusal").and_then(Value::as_str)
{
if !piece.trim().is_empty() {
refusal.push(piece.to_string());
}
}
} else if matches!(part_type.as_str(), "output_image" | "image_url") {
if let Some((image_url, detail)) =
extract_openai_response_image(part_object)
{
let mut image = Map::new();
image.insert("url".to_string(), Value::String(image_url));
if let Some(detail) = detail {
image.insert("detail".to_string(), Value::String(detail));
}
content_parts.push(json!({
"type": "image_url",
"image_url": image,
}));
has_non_text_content = true;
}
} else if part_type == "file" {
if let Some(file_part) = extract_openai_response_file(part_object) {
content_parts.push(file_part);
has_non_text_content = true;
}
} else if part_type == "input_audio" {
if let Some(audio_part) =
extract_openai_response_input_audio(part_object)
{
content_parts.push(audio_part);
has_non_text_content = true;
}
}
}
}
}
"reasoning" => {
if let Some(summary_items) =
item_object.get("summary").and_then(Value::as_array)
{
for summary in summary_items {
let summary_object = summary.as_object()?;
if summary_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "summary_text")
{
if let Some(piece) =
summary_object.get("text").and_then(Value::as_str)
{
reasoning_content.push_str(piece);
}
}
}
}
}
"function_call" => {
let tool_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let tool_id = item_object
.get("call_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or_else(|| {
item_object
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
})
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
tool_calls.push(json!({
"id": tool_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
}
}));
}
"output_text" | "text" => {
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
text.push_str(piece);
content_parts.push(json!({
"type": "text",
"text": piece,
}));
}
}
"output_image" | "image_url" => {
if let Some((image_url, detail)) = extract_openai_response_image(item_object) {
let mut image = Map::new();
image.insert("url".to_string(), Value::String(image_url));
if let Some(detail) = detail {
image.insert("detail".to_string(), Value::String(detail));
}
content_parts.push(json!({
"type": "image_url",
"image_url": image,
}));
has_non_text_content = true;
}
}
_ => {}
}
}
}
let finish_reason = if tool_calls.is_empty() {
Some("stop")
} else {
Some("tool_calls")
};
let model = body
.get("model")
.and_then(Value::as_str)
.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");
let id = body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-local-openai-cli");
let created = body.get("created_at").and_then(Value::as_i64).or_else(|| {
body.get("created_at")
.and_then(Value::as_u64)
.map(|value| value as i64)
});
let service_tier = body.get("service_tier").cloned().or_else(|| {
report_context
.get("original_request_body")
.and_then(Value::as_object)
.and_then(|request| request.get("service_tier"))
.cloned()
});
let usage = body.get("usage").and_then(Value::as_object);
let prompt_tokens = usage
.and_then(|value| value.get("input_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let completion_tokens = usage
.and_then(|value| value.get("output_tokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let total_tokens = usage
.and_then(|value| value.get("total_tokens"))
.and_then(Value::as_u64)
.unwrap_or(prompt_tokens + completion_tokens);
let mut message = Map::new();
message.insert("role".to_string(), Value::String("assistant".to_string()));
if content_parts.is_empty() && !tool_calls.is_empty() {
message.insert("content".to_string(), Value::Null);
} else if has_non_text_content {
message.insert("content".to_string(), Value::Array(content_parts));
} else {
message.insert("content".to_string(), Value::String(text));
}
if !reasoning_content.trim().is_empty() {
message.insert(
"reasoning_content".to_string(),
Value::String(reasoning_content),
);
}
if !refusal.is_empty() {
message.insert("refusal".to_string(), Value::String(refusal.join("\n")));
}
if !annotations.is_empty() {
message.insert("annotations".to_string(), Value::Array(annotations));
}
if !tool_calls.is_empty() {
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
}
let mut response = json!({
"id": id,
"object": "chat.completion",
"model": model,
"choices": [{
"index": 0,
"message": Value::Object(message),
"finish_reason": finish_reason,
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
});
if let Some(created) = created {
response["created"] = Value::from(created);
}
if let Some(service_tier) = service_tier {
response["service_tier"] = service_tier;
}
if let Some(input_details) = usage
.and_then(|value| value.get("input_tokens_details"))
.cloned()
{
response["usage"]["prompt_tokens_details"] = input_details;
}
if let Some(output_details) = usage
.and_then(|value| value.get("output_tokens_details"))
.cloned()
{
response["usage"]["completion_tokens_details"] = output_details;
}
Some(response)
}
fn extract_openai_response_image(
part_object: &Map<String, Value>,
) -> Option<(String, Option<String>)> {
let image_url = part_object
.get("image_url")
.and_then(|value| {
value.as_str().map(ToOwned::to_owned).or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
})
.or_else(|| {
part_object
.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})?;
let detail = part_object
.get("detail")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
part_object
.get("image_url")
.and_then(Value::as_object)
.and_then(|image| image.get("detail"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
});
Some((image_url, detail))
}
fn extract_openai_response_file(part_object: &Map<String, Value>) -> Option<Value> {
let file_object = part_object
.get("file")
.and_then(Value::as_object)
.unwrap_or(part_object);
let mut file = Map::new();
for key in ["file_data", "file_id", "filename"] {
if let Some(value) = file_object
.get(key)
.cloned()
.filter(|value| !value.is_null())
{
file.insert(key.to_string(), value);
}
}
if file.is_empty() {
return None;
}
Some(json!({
"type": "file",
"file": Value::Object(file),
}))
}
fn extract_openai_response_input_audio(part_object: &Map<String, Value>) -> Option<Value> {
let audio_object = part_object
.get("input_audio")
.and_then(Value::as_object)
.unwrap_or(part_object);
let data = audio_object
.get("data")
.cloned()
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
let format = audio_object
.get("format")
.cloned()
.filter(|value| value.as_str().is_some_and(|value| !value.trim().is_empty()))?;
Some(json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": format,
}
}))
}
fn offset_annotation_indices(annotation: &Value, offset: i64) -> Value {
let Some(object) = annotation.as_object() else {
return annotation.clone();
};
let mut adjusted = object.clone();
for key in [
"start_index",
"end_index",
"start_char",
"end_char",
"index",
] {
if let Some(value) = adjusted.get(key).and_then(Value::as_i64) {
adjusted.insert(key.to_string(), Value::from(value + offset));
}
}
Value::Object(adjusted)
}
#[cfg(test)]
mod tests {
use super::convert_openai_cli_response_to_openai_chat;
use serde_json::json;
#[test]
fn preserves_created_refusal_annotations_and_usage_details_when_converting_to_chat() {
let response = json!({
"id": "resp_123",
"object": "response",
"created_at": 1741476542i64,
"model": "gpt-5",
"service_tier": "flex",
"output": [{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Hello",
"annotations": [{"type": "file_citation", "start_index": 0, "end_index": 5}]
},
{"type": "refusal", "refusal": "partial refusal"}
]
}],
"usage": {
"input_tokens": 10,
"input_tokens_details": {"cached_tokens": 2},
"output_tokens": 4,
"output_tokens_details": {"reasoning_tokens": 1},
"total_tokens": 14
}
});
let converted = convert_openai_cli_response_to_openai_chat(&response, &json!({}))
.expect("responses response should convert to chat");
assert_eq!(converted["created"], 1741476542i64);
assert_eq!(converted["service_tier"], "flex");
assert_eq!(converted["choices"][0]["message"]["content"], "Hello");
assert_eq!(
converted["choices"][0]["message"]["refusal"],
"partial refusal"
);
assert_eq!(
converted["choices"][0]["message"]["annotations"],
json!([{"type": "file_citation", "start_index": 0, "end_index": 5}])
);
assert_eq!(
converted["usage"]["prompt_tokens_details"],
json!({"cached_tokens": 2})
);
assert_eq!(
converted["usage"]["completion_tokens_details"],
json!({"reasoning_tokens": 1})
);
}
#[test]
fn preserves_file_and_audio_parts_when_converting_to_chat() {
let response = json!({
"id": "resp_mm_123",
"object": "response",
"model": "gpt-5",
"output": [{
"type": "message",
"role": "assistant",
"content": [
{ "type": "output_text", "text": "Attached." },
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x",
"filename": "report.pdf"
}
},
{
"type": "input_audio",
"input_audio": {
"data": "SUQz",
"format": "mp3"
}
}
]
}],
"usage": {
"input_tokens": 4,
"output_tokens": 2,
"total_tokens": 6
}
});
let converted = convert_openai_cli_response_to_openai_chat(&response, &json!({}))
.expect("responses response should convert to chat");
assert_eq!(
converted["choices"][0]["message"]["content"],
json!([
{ "type": "text", "text": "Attached." },
{
"type": "file",
"file": {
"file_data": "data:application/pdf;base64,JVBERi0x",
"filename": "report.pdf"
}
},
{
"type": "input_audio",
"input_audio": {
"data": "SUQz",
"format": "mp3"
}
}
])
);
}
}

View File

@@ -1,48 +0,0 @@
use serde_json::{Map, Value};
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
format!("call_auto_{index}")
}
pub(super) 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(super) fn extract_gemini_image_url(part: &Map<String, Value>) -> Option<String> {
if let Some(inline_data) = part
.get("inlineData")
.or_else(|| part.get("inline_data"))
.and_then(Value::as_object)
{
let mime_type = inline_data
.get("mimeType")
.or_else(|| inline_data.get("mime_type"))
.and_then(Value::as_str)?;
if !mime_type.starts_with("image/") {
return None;
}
let data = inline_data.get("data").and_then(Value::as_str)?;
return Some(format!("data:{mime_type};base64,{data}"));
}
let file_data = part
.get("fileData")
.or_else(|| part.get("file_data"))
.and_then(Value::as_object)?;
if file_data
.get("mimeType")
.or_else(|| file_data.get("mime_type"))
.and_then(Value::as_str)
.is_some_and(|mime_type| !mime_type.starts_with("image/"))
{
return None;
}
file_data
.get("fileUri")
.or_else(|| file_data.get("file_uri"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}

View File

@@ -47,6 +47,15 @@ impl ClaudeProviderState {
self.started = true;
}
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
let (id, model) = self.identity(report_context);
CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::UnknownEvent(payload),
}
}
pub fn push_line(
&mut self,
report_context: &Value,
@@ -181,7 +190,11 @@ impl ClaudeProviderState {
event: CanonicalStreamEvent::ReasoningSignature(signature.to_string()),
});
}
_ => {}
_ => {
out.push(
self.unknown_frame(report_context, Value::Object(event_object.clone())),
);
}
}
}
"content_block_start" => {
@@ -245,6 +258,9 @@ impl ClaudeProviderState {
return Ok(out);
}
if block_type != "tool_use" {
out.push(
self.unknown_frame(report_context, Value::Object(event_object.clone())),
);
return Ok(out);
}
self.ensure_started(report_context, &mut out);
@@ -312,7 +328,10 @@ impl ClaudeProviderState {
});
self.finished = true;
}
_ => {}
"content_block_stop" | "message_stop" | "ping" => {}
_ => {
out.push(self.unknown_frame(report_context, value.clone()));
}
}
Ok(out)
}
@@ -524,6 +543,43 @@ impl ClaudeClientEmitter {
Ok(out)
}
fn emit_tool_result_block(
&mut self,
index: usize,
tool_use_id: String,
name: Option<String>,
content: String,
) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = self.ensure_started()?;
out.extend(self.close_open_block()?);
let block_index = self.next_block_index;
self.next_block_index += 1;
let mut content_block = Map::new();
content_block.insert("type".to_string(), Value::String("tool_result".to_string()));
content_block.insert("tool_use_id".to_string(), Value::String(tool_use_id));
if let Some(name) = name.filter(|value| !value.trim().is_empty()) {
content_block.insert("name".to_string(), Value::String(name));
}
content_block.insert("content".to_string(), Value::String(content));
out.extend(encode_json_sse(
Some("content_block_start"),
&json!({
"type": "content_block_start",
"index": block_index,
"content_block": Value::Object(content_block),
}),
)?);
out.extend(encode_json_sse(
Some("content_block_stop"),
&json!({
"type": "content_block_stop",
"index": block_index,
"canonical_index": index,
}),
)?);
Ok(out)
}
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
self.update_identity(&frame);
match frame.event {
@@ -632,6 +688,13 @@ impl ClaudeClientEmitter {
)?);
Ok(out)
}
CanonicalStreamEvent::ToolResultDelta {
index,
tool_use_id,
name,
content,
} => self.emit_tool_result_block(index, tool_use_id, name, content),
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish {
finish_reason,
usage,
@@ -809,6 +872,9 @@ fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> Cano
if next.cache_read_tokens > 0 {
current.cache_read_tokens = next.cache_read_tokens;
}
if next.reasoning_tokens > 0 {
current.reasoning_tokens = next.reasoning_tokens;
}
current.total_tokens = current
.input_tokens
.saturating_add(current.output_tokens)
@@ -917,6 +983,29 @@ mod tests {
format!("data: {}\n", value).into_bytes()
}
#[test]
fn claude_provider_state_emits_unknown_events_for_unknown_stream_types() {
let mut state = ClaudeProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "future_event",
"payload": {
"kept": true
}
})),
)
.expect("unknown stream event should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::UnknownEvent(ref payload)
if payload.get("type").and_then(Value::as_str) == Some("future_event")
)));
}
#[test]
fn claude_provider_state_parses_thinking_deltas() {
let mut state = ClaudeProviderState::default();
@@ -1146,4 +1235,28 @@ mod tests {
assert!(sse.contains("\"data\":\"iVBORw0KGgo=\""));
assert!(sse.contains("event: content_block_stop"));
}
#[test]
fn claude_client_emitter_emits_tool_result_blocks() {
let mut emitter = ClaudeClientEmitter::default();
let bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_tool_result_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ToolResultDelta {
index: 2,
tool_use_id: "toolu_1".to_string(),
name: Some("lookup".to_string()),
content: "{\"ok\":true}".to_string(),
},
})
.expect("tool result should encode");
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"type\":\"tool_result\""));
assert!(sse.contains("\"tool_use_id\":\"toolu_1\""));
assert!(sse.contains("\"name\":\"lookup\""));
assert!(sse.contains("\"content\":\"{\\\"ok\\\":true}\""));
assert!(sse.contains("\"canonical_index\":2"));
}
}

View File

@@ -15,6 +15,12 @@ struct GeminiProviderToolState {
started_emitted: bool,
}
#[derive(Default)]
struct GeminiProviderToolResultState {
content: String,
emitted: bool,
}
#[derive(Default)]
pub struct GeminiProviderState {
response_id: Option<String>,
@@ -26,6 +32,7 @@ pub struct GeminiProviderState {
reasoning_signatures: BTreeMap<usize, String>,
content_parts: BTreeMap<usize, CanonicalContentPart>,
tool_calls: BTreeMap<usize, GeminiProviderToolState>,
tool_results: BTreeMap<usize, GeminiProviderToolResultState>,
}
impl GeminiProviderState {
@@ -51,6 +58,15 @@ impl GeminiProviderState {
self.started = true;
}
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
let (id, model) = self.identity(report_context);
CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::UnknownEvent(payload),
}
}
pub fn push_line(
&mut self,
report_context: &Value,
@@ -79,6 +95,7 @@ impl GeminiProviderState {
let mut out = Vec::new();
let Some(candidates) = event_object.get("candidates").and_then(Value::as_array) else {
out.push(self.unknown_frame(report_context, value.clone()));
return Ok(out);
};
@@ -154,6 +171,53 @@ impl GeminiProviderState {
}
continue;
}
if let Some(function_response) = part_object
.get("functionResponse")
.or_else(|| part_object.get("function_response"))
.and_then(Value::as_object)
{
let tool_use_id = function_response
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| build_generated_tool_call_id(index));
let name = function_response
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let content = gemini_function_response_content(
function_response.get("response").unwrap_or(&Value::Null),
);
let state = self.tool_results.entry(index).or_default();
let delta = if !state.emitted {
content.clone()
} else if content.starts_with(&state.content) {
content[state.content.len()..].to_string()
} else if state.content == content {
String::new()
} else {
content.clone()
};
if !delta.is_empty() || !state.emitted {
state.emitted = true;
state.content.push_str(&delta);
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolResultDelta {
index,
tool_use_id,
name,
content: delta,
},
});
}
continue;
}
let Some(function_call) =
part_object.get("functionCall").and_then(Value::as_object)
else {
@@ -172,6 +236,10 @@ impl GeminiProviderState {
event: CanonicalStreamEvent::ContentPart(content_part),
});
}
} else {
out.push(
self.unknown_frame(report_context, Value::Object(part_object.clone())),
);
}
continue;
};
@@ -336,14 +404,27 @@ impl GeminiClientEmitter {
Value::Array(vec![Value::Object(candidate)]),
);
if let Some(usage) = usage {
response.insert(
"usageMetadata".to_string(),
json!({
"promptTokenCount": usage.input_tokens,
"candidatesTokenCount": usage.output_tokens,
"totalTokenCount": usage.total_tokens,
}),
let visible_output_tokens = usage.output_tokens.saturating_sub(usage.reasoning_tokens);
let mut usage_metadata = Map::new();
usage_metadata.insert(
"promptTokenCount".to_string(),
Value::from(usage.input_tokens),
);
usage_metadata.insert(
"candidatesTokenCount".to_string(),
Value::from(visible_output_tokens),
);
usage_metadata.insert(
"totalTokenCount".to_string(),
Value::from(usage.total_tokens),
);
if usage.reasoning_tokens > 0 {
usage_metadata.insert(
"thoughtsTokenCount".to_string(),
Value::from(usage.reasoning_tokens),
);
}
response.insert("usageMetadata".to_string(), Value::Object(usage_metadata));
}
encode_json_sse(None, &Value::Object(response))
}
@@ -447,6 +528,17 @@ impl GeminiClientEmitter {
};
self.emit_candidate(vec![part], None, None)
}
CanonicalStreamEvent::ToolResultDelta {
tool_use_id,
name,
content,
..
} => self.emit_candidate(
vec![gemini_function_response_part(tool_use_id, name, content)],
None,
None,
),
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish {
finish_reason,
usage,
@@ -472,6 +564,45 @@ impl GeminiClientEmitter {
}
}
fn gemini_function_response_part(
tool_use_id: String,
name: Option<String>,
content: String,
) -> Value {
json!({
"functionResponse": {
"id": tool_use_id,
"name": name
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "unknown".to_string()),
"response": gemini_function_response_value(&content),
}
})
}
fn gemini_function_response_value(content: &str) -> Value {
if content.trim().is_empty() {
return Value::Object(Map::new());
}
match serde_json::from_str::<Value>(content) {
Ok(Value::Object(map)) => Value::Object(map),
Ok(value) => json!({ "output": value }),
Err(_) => json!({ "output": content }),
}
}
fn gemini_function_response_content(response: &Value) -> String {
match response {
Value::Object(object) => object
.get("result")
.cloned()
.unwrap_or_else(|| Value::Object(object.clone()))
.to_string(),
Value::Null => String::new(),
value => value.to_string(),
}
}
fn render_gemini_part_as_text(part: &Map<String, Value>) -> Option<String> {
if let Some(text) = part.get("text").and_then(Value::as_str) {
return Some(text.to_string());
@@ -694,6 +825,39 @@ mod tests {
format!("data: {}\n", value).into_bytes()
}
#[test]
fn gemini_provider_state_emits_unknown_events_for_unknown_parts() {
let mut state = GeminiProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"responseId": "resp_unknown_123",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"content": {
"parts": [
{
"futurePart": {
"kept": true
}
}
]
}
}]
})),
)
.expect("unknown part should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::UnknownEvent(ref payload)
if payload.get("futurePart").is_some()
)));
}
#[test]
fn gemini_provider_state_parses_thoughts_code_and_content_filter_finish() {
let mut state = GeminiProviderState::default();
@@ -717,7 +881,8 @@ mod tests {
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3
"thoughtsTokenCount": 4,
"totalTokenCount": 7
}
})),
)
@@ -741,6 +906,56 @@ mod tests {
CanonicalStreamEvent::Finish { ref finish_reason, .. }
if finish_reason.as_deref() == Some("content_filter")
)));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::Finish {
usage: Some(CanonicalUsage {
input_tokens: 1,
output_tokens: 6,
reasoning_tokens: 4,
total_tokens: 7,
..
}),
..
}
)));
}
#[test]
fn gemini_provider_state_parses_function_response_as_tool_result() {
let mut state = GeminiProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"responseId": "resp_tool_result_123",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"content": {
"parts": [{
"functionResponse": {
"id": "call_123",
"name": "lookup",
"response": {"ok": true}
}
}]
}
}]
})),
)
.expect("function response should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolResultDelta {
index: 0,
ref tool_use_id,
name: Some(ref name),
ref content,
} if tool_use_id == "call_123" && name == "lookup" && content == "{\"ok\":true}"
)));
}
#[test]
@@ -771,8 +986,9 @@ mod tests {
finish_reason: Some("stop".to_string()),
usage: Some(CanonicalUsage {
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
output_tokens: 3,
reasoning_tokens: 1,
total_tokens: 4,
..CanonicalUsage::default()
}),
},
@@ -783,6 +999,8 @@ mod tests {
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"thought\":true"));
assert!(sse.contains("\"thoughtSignature\":\"sig_123\""));
assert!(sse.contains("\"thoughtsTokenCount\":1"));
assert!(sse.contains("\"candidatesTokenCount\":2"));
assert!(sse.contains("\"finishReason\":\"STOP\""));
}
@@ -833,4 +1051,27 @@ mod tests {
sse.contains("\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgo=\"}")
);
}
#[test]
fn gemini_client_emitter_emits_function_response_for_tool_results() {
let mut emitter = GeminiClientEmitter::default();
let bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_tool_result_123".to_string(),
model: "gemini-2.5-pro".to_string(),
event: CanonicalStreamEvent::ToolResultDelta {
index: 2,
tool_use_id: "call_123".to_string(),
name: Some("lookup".to_string()),
content: "{\"ok\":true}".to_string(),
},
})
.expect("tool result should encode");
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"functionResponse\""));
assert!(sse.contains("\"id\":\"call_123\""));
assert!(sse.contains("\"name\":\"lookup\""));
assert!(sse.contains("\"response\":{\"ok\":true}"));
}
}

View File

@@ -2,7 +2,6 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::conversion::response::OpenAiCliResponseUsage;
use crate::finalize::common::build_generated_tool_call_id;
use crate::finalize::sse::{encode_done_sse, encode_json_sse};
use crate::finalize::standard::stream_core::common::*;
@@ -26,7 +25,7 @@ pub struct OpenAIChatProviderState {
}
#[derive(Default)]
struct OpenAICliProviderToolState {
struct OpenAIResponsesProviderToolState {
call_id: String,
name: String,
arguments: String,
@@ -34,14 +33,21 @@ struct OpenAICliProviderToolState {
}
#[derive(Default)]
pub struct OpenAICliProviderState {
struct OpenAIResponsesProviderToolResultState {
content: String,
emitted: bool,
}
#[derive(Default)]
pub struct OpenAIResponsesProviderState {
response_id: Option<String>,
model: Option<String>,
started: bool,
finished: bool,
text: String,
reasoning: String,
tool_calls: BTreeMap<usize, OpenAICliProviderToolState>,
tool_calls: BTreeMap<usize, OpenAIResponsesProviderToolState>,
tool_results: BTreeMap<usize, OpenAIResponsesProviderToolResultState>,
tool_index_by_key: BTreeMap<String, usize>,
last_tool_index: Option<usize>,
}
@@ -86,6 +92,15 @@ impl OpenAIChatProviderState {
self.started = true;
}
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
let (id, model) = self.identity(report_context);
CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::UnknownEvent(payload),
}
}
pub fn push_line(
&mut self,
report_context: &Value,
@@ -122,6 +137,13 @@ impl OpenAIChatProviderState {
},
});
self.finished = true;
} else if chunk_object.contains_key("choices")
|| chunk_object
.get("object")
.and_then(Value::as_str)
.is_some_and(|object| object.contains("chat.completion"))
{
out.push(self.unknown_frame(report_context, value.clone()));
}
return Ok(out);
};
@@ -146,8 +168,10 @@ impl OpenAIChatProviderState {
}
for chunk_choice in chunk_choices {
let Some(choice_object) = chunk_choice.as_object() else {
out.push(self.unknown_frame(report_context, chunk_choice.clone()));
continue;
};
let finish_reason_key_present = choice_object.contains_key("finish_reason");
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
if let Some(finish_reason) = normalize_openai_finish_reason(
choice_object.get("finish_reason").and_then(Value::as_str),
@@ -167,15 +191,24 @@ impl OpenAIChatProviderState {
} else {
self.pending_finish_reason = Some(finish_reason);
}
} else if !finish_reason_key_present {
out.push(
self.unknown_frame(report_context, Value::Object(choice_object.clone())),
);
}
continue;
};
let mut recognized_delta = false;
if delta.get("role").and_then(Value::as_str) == Some("assistant") {
recognized_delta = true;
self.ensure_started(report_context, &mut out);
} else if delta.contains_key("role") {
recognized_delta = true;
}
if let Some(content) = delta.get("content").and_then(Value::as_str) {
recognized_delta = true;
if !content.is_empty() {
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
@@ -185,9 +218,12 @@ impl OpenAIChatProviderState {
event: CanonicalStreamEvent::TextDelta(content.to_string()),
});
}
} else if delta.contains_key("content") {
recognized_delta = true;
}
if let Some(reasoning_content) = delta.get("reasoning_content").and_then(Value::as_str)
{
recognized_delta = true;
if !reasoning_content.is_empty() {
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
@@ -197,9 +233,12 @@ impl OpenAIChatProviderState {
event: CanonicalStreamEvent::ReasoningDelta(reasoning_content.to_string()),
});
}
} else if delta.contains_key("reasoning_content") {
recognized_delta = true;
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
recognized_delta = true;
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
for tool_call in tool_calls {
@@ -270,11 +309,14 @@ impl OpenAIChatProviderState {
}
}
}
} else if delta.contains_key("tool_calls") {
recognized_delta = true;
}
if let Some(finish_reason) = normalize_openai_finish_reason(
choice_object.get("finish_reason").and_then(Value::as_str),
) {
recognized_delta = true;
if let Some(usage) = Self::finish_usage(chunk_object.get("usage")) {
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
@@ -291,6 +333,9 @@ impl OpenAIChatProviderState {
self.pending_finish_reason = Some(finish_reason);
}
}
if !recognized_delta && !finish_reason_key_present {
out.push(self.unknown_frame(report_context, Value::Object(choice_object.clone())));
}
}
Ok(out)
@@ -316,7 +361,7 @@ impl OpenAIChatProviderState {
}
}
impl OpenAICliProviderState {
impl OpenAIResponsesProviderState {
fn identity(&self, report_context: &Value) -> (String, String) {
resolve_identity(
self.response_id.as_deref(),
@@ -339,6 +384,15 @@ impl OpenAICliProviderState {
self.started = true;
}
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
let (id, model) = self.identity(report_context);
CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::UnknownEvent(payload),
}
}
fn tool_index_for_key(&mut self, key: Option<String>, output_index: Option<usize>) -> usize {
if let Some(output_index) = output_index {
if let Some(key) = key.as_ref() {
@@ -491,6 +545,79 @@ impl OpenAICliProviderState {
});
}
fn emit_missing_tool_result(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
index: usize,
tool_use_id: String,
name: Option<String>,
content: &str,
) {
self.ensure_started(report_context, out);
let state = self.tool_results.entry(index).or_default();
let missing = if !state.emitted {
content.to_string()
} else if content.starts_with(&state.content) {
content[state.content.len()..].to_string()
} else if state.content == content {
String::new()
} else {
content.to_string()
};
if missing.is_empty() && state.emitted {
return;
}
state.emitted = true;
state.content.push_str(&missing);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolResultDelta {
index,
tool_use_id,
name,
content: missing,
},
});
}
fn emit_tool_result_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
if item.get("type").and_then(Value::as_str) != Some("function_call_output") {
return;
}
let tool_use_id = item
.get("call_id")
.or_else(|| item.get("tool_call_id"))
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("call_auto_0")
.to_string();
let index = self.tool_index_for_key(
Some(format!("function_call_output:{tool_use_id}")),
output_index,
);
let content = openai_tool_result_content_from_value(
item.get("output")
.or_else(|| item.get("content"))
.or_else(|| item.get("delta")),
);
let name = item
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned);
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
}
fn emit_message_item(
&mut self,
report_context: &Value,
@@ -684,13 +811,18 @@ impl OpenAICliProviderState {
"function_call" => {
self.emit_tool_call_item(report_context, &mut out, item, output_index);
}
"function_call_output" => {
self.emit_tool_result_item(report_context, &mut out, item, output_index);
}
"message" => {
self.emit_message_item(report_context, &mut out, item);
}
"reasoning" => {
self.emit_reasoning_item(report_context, &mut out, item);
}
_ => {}
_ => {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
}
}
}
"response.function_call_arguments.delta" => {
@@ -842,6 +974,44 @@ impl OpenAICliProviderState {
});
}
}
"response.function_call_output.delta" | "response.function_call_output.done" => {
let tool_use_id = value
.get("call_id")
.or_else(|| value.get("tool_call_id"))
.or_else(|| value.get("item_id"))
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("call_auto_0")
.to_string();
let output_index = value
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(
Some(format!("function_call_output:{tool_use_id}")),
output_index,
);
let content = openai_tool_result_content_from_value(
value
.get("delta")
.or_else(|| value.get("output"))
.or_else(|| value.get("content")),
);
let name = value
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned);
self.emit_missing_tool_result(
report_context,
&mut out,
index,
tool_use_id,
name,
&content,
);
}
"response.output_item.done" => {
let Some(item) = value.get("item").and_then(Value::as_object) else {
return Ok(out);
@@ -854,13 +1024,18 @@ impl OpenAICliProviderState {
"function_call" => {
self.emit_tool_call_item(report_context, &mut out, item, output_index);
}
"function_call_output" => {
self.emit_tool_result_item(report_context, &mut out, item, output_index);
}
"message" => {
self.emit_message_item(report_context, &mut out, item);
}
"reasoning" => {
self.emit_reasoning_item(report_context, &mut out, item);
}
_ => {}
_ => {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
}
}
}
"response.completed" => {
@@ -870,11 +1045,12 @@ impl OpenAICliProviderState {
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
for raw_item in response
for (output_index, raw_item) in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let Some(item) = raw_item.as_object() else {
continue;
@@ -884,12 +1060,29 @@ impl OpenAICliProviderState {
self.emit_message_item(report_context, &mut out, item);
}
"function_call" => {
self.emit_tool_call_item(report_context, &mut out, item, None);
self.emit_tool_call_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"function_call_output" => {
self.emit_tool_result_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"reasoning" => {
self.emit_reasoning_item(report_context, &mut out, item);
}
_ => {}
_ => {
out.push(
self.unknown_frame(report_context, Value::Object(item.clone())),
);
}
}
}
@@ -908,7 +1101,9 @@ impl OpenAICliProviderState {
});
self.finished = true;
}
_ => {}
_ => {
out.push(self.unknown_frame(report_context, value.clone()));
}
}
Ok(out)
@@ -948,15 +1143,24 @@ pub struct OpenAIChatClientEmitter {
}
#[derive(Clone, Default)]
struct OpenAICliClientToolState {
struct OpenAIResponsesClientToolState {
call_id: String,
name: String,
arguments: String,
output_index: Option<usize>,
}
#[derive(Clone, Default)]
struct OpenAIResponsesClientToolResultState {
tool_use_id: String,
name: Option<String>,
content: String,
output_index: Option<usize>,
item_started: bool,
}
#[derive(Default)]
pub struct OpenAICliClientEmitter {
pub struct OpenAIResponsesClientEmitter {
response_id: Option<String>,
model: Option<String>,
message_item_id: Option<String>,
@@ -973,7 +1177,8 @@ pub struct OpenAICliClientEmitter {
message_output_index: Option<usize>,
text: String,
reasoning: String,
tool_calls: BTreeMap<usize, OpenAICliClientToolState>,
tool_calls: BTreeMap<usize, OpenAIResponsesClientToolState>,
tool_results: BTreeMap<usize, OpenAIResponsesClientToolResultState>,
}
impl OpenAIChatClientEmitter {
@@ -1111,6 +1316,38 @@ impl OpenAIChatClientEmitter {
)?);
Ok(out)
}
CanonicalStreamEvent::ToolResultDelta {
tool_use_id,
name,
content,
..
} => {
let mut out = self.ensure_started()?;
let mut delta = Map::new();
delta.insert("role".to_string(), Value::String("tool".to_string()));
delta.insert("tool_call_id".to_string(), Value::String(tool_use_id));
if let Some(name) = name.filter(|value| !value.trim().is_empty()) {
delta.insert("name".to_string(), Value::String(name));
}
delta.insert("content".to_string(), Value::String(content));
out.extend(encode_json_sse(
None,
&json!({
"id": self.response_id
.as_deref()
.unwrap_or("chatcmpl-local-stream"),
"object": "chat.completion.chunk",
"model": self.model.as_deref().unwrap_or("unknown"),
"choices": [{
"index": 0,
"delta": Value::Object(delta),
"finish_reason": Value::Null
}]
}),
)?);
Ok(out)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish {
finish_reason,
usage,
@@ -1140,6 +1377,7 @@ impl OpenAIChatClientEmitter {
usage.input_tokens,
usage.output_tokens,
usage.total_tokens,
usage.reasoning_tokens,
),
)?);
}
@@ -1171,7 +1409,7 @@ impl OpenAIChatClientEmitter {
}
}
impl OpenAICliClientEmitter {
impl OpenAIResponsesClientEmitter {
fn response_id(&self) -> &str {
self.response_id.as_deref().unwrap_or("resp-local-stream")
}
@@ -1299,6 +1537,19 @@ impl OpenAICliClientEmitter {
output_index
}
fn ensure_tool_result_output_index(&mut self, index: usize) -> usize {
if let Some(output_index) = self
.tool_results
.get(&index)
.and_then(|state| state.output_index)
{
return output_index;
}
let output_index = self.allocate_output_index();
self.tool_results.entry(index).or_default().output_index = Some(output_index);
output_index
}
fn ensure_reasoning_item_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = self.ensure_started()?;
let output_index = self.ensure_reasoning_output_index();
@@ -1539,7 +1790,42 @@ impl OpenAICliClientEmitter {
Ok(out)
}
fn completed_response(&self, usage: OpenAiCliResponseUsage) -> Value {
fn finish_tool_result_items(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
let mut out = Vec::new();
let indices = self.tool_results.keys().copied().collect::<Vec<_>>();
for index in indices {
let output_index = self.ensure_tool_result_output_index(index);
let state = self.tool_results.get(&index).cloned().unwrap_or_default();
let item_id = if state.tool_use_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.tool_use_id.clone()
};
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("function_call_output".to_string()),
);
item.insert("id".to_string(), Value::String(format!("{item_id}_output")));
item.insert("call_id".to_string(), Value::String(item_id));
if let Some(name) = state.name.filter(|value| !value.trim().is_empty()) {
item.insert("name".to_string(), Value::String(name));
}
item.insert("output".to_string(), Value::String(state.content));
out.extend(self.encode_response_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"response_id": self.response_id(),
"output_index": output_index,
"item": Value::Object(item),
}),
)?);
}
Ok(out)
}
fn completed_response(&self, usage: CanonicalUsage) -> Value {
let mut ordered_output = Vec::new();
if !self.reasoning.trim().is_empty() {
ordered_output.push((
@@ -1598,8 +1884,48 @@ impl OpenAICliClientEmitter {
));
}
}
for (index, state) in &self.tool_results {
if let Some(output_index) = state.output_index {
let item_id = if state.tool_use_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.tool_use_id.clone()
};
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("function_call_output".to_string()),
);
item.insert("id".to_string(), Value::String(format!("{item_id}_output")));
item.insert("call_id".to_string(), Value::String(item_id));
if let Some(name) = state
.name
.as_ref()
.filter(|value| !value.trim().is_empty())
.cloned()
{
item.insert("name".to_string(), Value::String(name));
}
item.insert("output".to_string(), Value::String(state.content.clone()));
ordered_output.push((output_index, Value::Object(item)));
}
}
ordered_output.sort_by_key(|(output_index, _)| *output_index);
let mut usage_payload = Map::new();
usage_payload.insert("input_tokens".to_string(), Value::from(usage.input_tokens));
usage_payload.insert(
"output_tokens".to_string(),
Value::from(usage.output_tokens),
);
usage_payload.insert("total_tokens".to_string(), Value::from(usage.total_tokens));
if usage.reasoning_tokens > 0 {
usage_payload.insert(
"output_tokens_details".to_string(),
json!({ "reasoning_tokens": usage.reasoning_tokens }),
);
}
json!({
"id": self.response_id(),
"object": "response",
@@ -1609,11 +1935,7 @@ impl OpenAICliClientEmitter {
.into_iter()
.map(|(_, item)| item)
.collect::<Vec<_>>(),
"usage": {
"input_tokens": usage.prompt_tokens,
"output_tokens": usage.output_tokens,
"total_tokens": usage.total_tokens,
}
"usage": usage_payload,
})
}
@@ -1726,6 +2048,82 @@ impl OpenAICliClientEmitter {
)?);
Ok(out)
}
CanonicalStreamEvent::ToolResultDelta {
index,
tool_use_id,
name,
content,
} => {
let mut out = self.ensure_started()?;
let output_index = self.ensure_tool_result_output_index(index);
let response_id = self.response_id().to_string();
let state = self.tool_results.entry(index).or_default();
if state.tool_use_id.is_empty() {
state.tool_use_id = tool_use_id.clone();
}
if name.is_some() {
state.name = name.clone();
}
let emitted_tool_use_id = if state.tool_use_id.is_empty() {
tool_use_id
} else {
state.tool_use_id.clone()
};
if !state.item_started {
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("function_call_output".to_string()),
);
item.insert(
"id".to_string(),
Value::String(format!("{emitted_tool_use_id}_output")),
);
item.insert(
"call_id".to_string(),
Value::String(emitted_tool_use_id.clone()),
);
if let Some(name) = state
.name
.as_ref()
.filter(|value| !value.trim().is_empty())
.cloned()
{
item.insert("name".to_string(), Value::String(name));
}
item.insert("output".to_string(), Value::String(String::new()));
out.extend(self.encode_response_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"response_id": response_id,
"output_index": output_index,
"item": Value::Object(item),
}),
)?);
self.tool_results.entry(index).or_default().item_started = true;
}
self.tool_results
.entry(index)
.or_default()
.content
.push_str(&content);
if !content.is_empty() {
out.extend(self.encode_response_event(
"response.function_call_output.delta",
json!({
"type": "response.function_call_output.delta",
"response_id": self.response_id(),
"output_index": output_index,
"item_id": format!("{emitted_tool_use_id}_output"),
"call_id": emitted_tool_use_id,
"delta": content,
}),
)?);
}
Ok(out)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish { usage, .. } => {
if self.finished {
return Ok(Vec::new());
@@ -1734,16 +2132,13 @@ impl OpenAICliClientEmitter {
out.extend(self.finish_reasoning_item()?);
out.extend(self.finish_text_item()?);
out.extend(self.finish_tool_items()?);
out.extend(self.finish_tool_result_items()?);
let usage = usage.unwrap_or_default();
out.extend(self.encode_response_event(
"response.completed",
json!({
"type": "response.completed",
"response": self.completed_response(OpenAiCliResponseUsage {
prompt_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
}),
"response": self.completed_response(usage),
}),
)?);
self.finished = true;
@@ -1808,6 +2203,14 @@ fn openai_stream_placeholder_for_content_part(part: &CanonicalContentPart) -> St
}
}
fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
match value {
Some(Value::String(text)) => text.clone(),
Some(Value::Null) | None => String::new(),
Some(value) => value.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1835,10 +2238,71 @@ mod tests {
sequence_numbers
}
#[test]
fn openai_chat_provider_state_emits_unknown_events_for_unrecognized_deltas() {
let mut state = OpenAIChatProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"id": "chatcmpl_unknown_123",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"delta": {
"future_delta_type": {
"payload": true
}
}
}]
})),
)
.expect("unknown delta should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::UnknownEvent(ref payload)
if payload.get("delta")
.and_then(|delta| delta.get("future_delta_type"))
.is_some()
)));
}
#[test]
fn openai_responses_provider_state_emits_unknown_events_for_unknown_response_types() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.future.delta",
"response": {
"id": "resp_unknown_123",
"model": "gpt-5.4"
},
"delta": {
"payload": true
}
})),
)
.expect("unknown response event should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::UnknownEvent(ref payload)
if payload.get("type").and_then(Value::as_str) == Some("response.future.delta")
)));
}
#[test]
fn openai_usage_derives_missing_input_tokens_from_total() {
let usage = canonical_usage_from_openai_usage(Some(&json!({
"output_tokens": 177,
"output_tokens_details": {
"reasoning_tokens": 7,
},
"total_tokens": 20_612,
"input_tokens_details": {
"cached_tokens": 19_840,
@@ -1849,6 +2313,7 @@ mod tests {
assert_eq!(usage.input_tokens, 20_435);
assert_eq!(usage.output_tokens, 177);
assert_eq!(usage.cache_read_tokens, 19_840);
assert_eq!(usage.reasoning_tokens, 7);
}
#[test]
@@ -1897,6 +2362,7 @@ mod tests {
input_tokens: 26,
output_tokens: 144,
cache_read_tokens: 0,
reasoning_tokens: 10,
..
}),
} if reason == "stop"
@@ -1904,8 +2370,8 @@ mod tests {
}
#[test]
fn openai_cli_provider_state_extracts_response_completed_usage() {
let mut state = OpenAICliProviderState::default();
fn openai_responses_provider_state_extracts_response_completed_usage() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
@@ -1950,8 +2416,8 @@ mod tests {
}
#[test]
fn openai_cli_client_emitter_emits_doc_like_text_events() {
let mut emitter = OpenAICliClientEmitter::default();
fn openai_responses_client_emitter_emits_doc_like_text_events() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let start = CanonicalStreamFrame {
id: "chatcmpl_stream_123".to_string(),
model: "gpt-5.4".to_string(),
@@ -1997,8 +2463,8 @@ mod tests {
}
#[test]
fn openai_cli_client_emitter_keeps_text_item_id_stable_after_text_started() {
let mut emitter = OpenAICliClientEmitter::default();
fn openai_responses_client_emitter_keeps_text_item_id_stable_after_text_started() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_first".to_string(),
@@ -2022,8 +2488,8 @@ mod tests {
}
#[test]
fn openai_cli_provider_state_accepts_done_events_without_deltas() {
let mut state = OpenAICliProviderState::default();
fn openai_responses_provider_state_accepts_done_events_without_deltas() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let mut frames = Vec::new();
@@ -2142,8 +2608,90 @@ mod tests {
}
#[test]
fn openai_cli_provider_state_accepts_legacy_outtext_delta_alias() {
let mut state = OpenAICliProviderState::default();
fn openai_responses_provider_state_parses_function_call_output_as_tool_result() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let mut frames = Vec::new();
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"type": "response.created",
"response": {
"id": "resp_123",
"model": "gpt-5.4",
}
})),
)
.expect("created should parse"),
);
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_output.done",
"response_id": "resp_123",
"output_index": 2,
"call_id": "call_123",
"name": "lookup",
"output": {"ok": true},
})),
)
.expect("tool result should parse"),
);
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolResultDelta {
index: 2,
ref tool_use_id,
name: Some(ref name),
ref content,
} if tool_use_id == "call_123" && name == "lookup" && content == "{\"ok\":true}"
)));
}
#[test]
fn openai_responses_client_emitter_emits_function_call_output_events() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::ToolResultDelta {
index: 1,
tool_use_id: "call_123".to_string(),
name: Some("lookup".to_string()),
content: "{\"ok\":true}".to_string(),
},
})
.expect("tool result should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("stop".to_string()),
usage: None,
},
})
.expect("finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.function_call_output.delta\n"));
assert!(sse.contains("\"type\":\"function_call_output\""));
assert!(sse.contains("\"call_id\":\"call_123\""));
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
}
#[test]
fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let mut frames = Vec::new();
@@ -2254,6 +2802,7 @@ mod tests {
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
reasoning_tokens: 1,
..CanonicalUsage::default()
}),
},
@@ -2266,6 +2815,7 @@ mod tests {
assert!(sse.contains("\"choices\":[]"));
assert!(sse.contains("\"prompt_tokens\":1"));
assert!(sse.contains("\"completion_tokens\":2"));
assert!(sse.contains("\"completion_tokens_details\":{\"reasoning_tokens\":1}"));
assert!(sse.contains("\"total_tokens\":3"));
assert!(sse.contains("data: [DONE]\n\n"));
}
@@ -2344,8 +2894,8 @@ mod tests {
}
#[test]
fn openai_cli_client_emitter_includes_reasoning_in_completed_response() {
let mut emitter = OpenAICliClientEmitter::default();
fn openai_responses_client_emitter_includes_reasoning_in_completed_response() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
@@ -2373,6 +2923,7 @@ mod tests {
input_tokens: 1,
output_tokens: 2,
total_tokens: 3,
reasoning_tokens: 1,
..CanonicalUsage::default()
}),
},
@@ -2383,11 +2934,12 @@ mod tests {
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"type\":\"reasoning\""));
assert!(sse.contains("\"text\":\"because\""));
assert!(sse.contains("\"output_tokens_details\":{\"reasoning_tokens\":1}"));
}
#[test]
fn openai_cli_client_emitter_emits_doc_like_reasoning_events() {
let mut emitter = OpenAICliClientEmitter::default();
fn openai_responses_client_emitter_emits_doc_like_reasoning_events() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_456".to_string(),
@@ -2428,8 +2980,8 @@ mod tests {
}
#[test]
fn openai_cli_client_emitter_emits_failed_event_with_sequence_number() {
let mut emitter = OpenAICliClientEmitter::default();
fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_err_123".to_string(),
@@ -2466,8 +3018,8 @@ mod tests {
}
#[test]
fn openai_cli_provider_state_accepts_reasoning_summary_events() {
let mut state = OpenAICliProviderState::default();
fn openai_responses_provider_state_accepts_reasoning_summary_events() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let mut frames = Vec::new();

View File

@@ -1,59 +1,8 @@
use serde_json::{json, Map, Value};
#[derive(Clone, Debug, Default)]
pub struct CanonicalUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_creation_ephemeral_5m_tokens: u64,
pub cache_creation_ephemeral_1h_tokens: u64,
pub cache_read_tokens: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CanonicalContentPart {
ImageUrl(String),
File {
file_data: Option<String>,
reference: Option<String>,
mime_type: Option<String>,
filename: Option<String>,
},
Audio {
data: String,
format: String,
},
}
#[derive(Clone, Debug)]
pub enum CanonicalStreamEvent {
Start,
TextDelta(String),
ReasoningDelta(String),
ReasoningSignature(String),
ContentPart(CanonicalContentPart),
ToolCallStart {
index: usize,
call_id: String,
name: String,
},
ToolCallArgumentsDelta {
index: usize,
arguments: String,
},
Finish {
finish_reason: Option<String>,
usage: Option<CanonicalUsage>,
},
}
#[derive(Clone, Debug)]
pub struct CanonicalStreamFrame {
pub id: String,
pub model: String,
pub event: CanonicalStreamEvent,
}
pub use aether_ai_formats::stream::{
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
};
pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(line).ok()?;
@@ -123,6 +72,18 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
.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)
@@ -138,6 +99,7 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
total_tokens,
cache_creation_tokens,
cache_read_tokens,
reasoning_tokens,
..CanonicalUsage::default()
})
}
@@ -174,6 +136,10 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
.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,
@@ -185,6 +151,7 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
cache_creation_ephemeral_5m_tokens,
cache_creation_ephemeral_1h_tokens,
cache_read_tokens,
reasoning_tokens,
})
}
@@ -198,6 +165,10 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
.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)
@@ -212,9 +183,10 @@ pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<Canoni
);
Some(CanonicalUsage {
input_tokens,
output_tokens,
output_tokens: output_tokens.saturating_add(reasoning_tokens),
total_tokens,
cache_read_tokens,
reasoning_tokens,
..CanonicalUsage::default()
})
}
@@ -316,16 +288,26 @@ pub fn build_openai_chat_usage_chunk(
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": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
"usage": usage,
})
}

View File

@@ -1,3 +1,4 @@
use aether_ai_formats::FormatId;
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
use serde_json::Value;
@@ -6,8 +7,8 @@ use crate::finalize::sse::encode_json_sse;
use crate::finalize::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
use crate::finalize::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
use crate::finalize::standard::openai::stream::{
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
OpenAICliProviderState,
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
OpenAIResponsesProviderState,
};
use crate::finalize::standard::stream_core::common::{
decode_json_data_line, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
@@ -174,33 +175,39 @@ impl StreamingStandardTerminalObserver {
if summary.model.is_none() {
summary.model = Some(model);
}
if let CanonicalStreamEvent::Finish {
finish_reason,
usage,
} = event
{
summary.finish_reason = finish_reason;
summary.standardized_usage = usage.map(standardized_usage_from_canonical);
summary.observed_finish = true;
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),
OpenAICli(OpenAICliProviderState),
OpenAIResponses(OpenAIResponsesProviderState),
Claude(ClaudeProviderState),
Gemini(GeminiProviderState),
}
impl ProviderStreamParser {
fn for_api_format(provider_api_format: &str) -> Option<Self> {
Some(match provider_api_format {
"openai:chat" => Self::OpenAIChat(OpenAIChatProviderState::default()),
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliProviderState::default()),
"claude:chat" | "claude:cli" => Self::Claude(ClaudeProviderState::default()),
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiProviderState::default()),
_ => return None,
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()),
})
}
@@ -211,7 +218,7 @@ impl ProviderStreamParser {
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
match self {
ProviderStreamParser::OpenAIChat(state) => state.push_line(report_context, line),
ProviderStreamParser::OpenAICli(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),
}
@@ -223,7 +230,7 @@ impl ProviderStreamParser {
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
match self {
ProviderStreamParser::OpenAIChat(state) => state.finish(report_context),
ProviderStreamParser::OpenAICli(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),
}
@@ -232,7 +239,7 @@ impl ProviderStreamParser {
enum ClientStreamEmitter {
OpenAIChat(OpenAIChatClientEmitter),
OpenAICli(OpenAICliClientEmitter),
OpenAIResponses(OpenAIResponsesClientEmitter),
Claude(ClaudeClientEmitter),
Gemini(GeminiClientEmitter),
}
@@ -268,6 +275,7 @@ fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage
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),
@@ -277,19 +285,20 @@ fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage
impl ClientStreamEmitter {
fn for_api_format(client_api_format: &str) -> Option<Self> {
Some(match client_api_format {
"openai:chat" => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliClientEmitter::default()),
"claude:chat" | "claude:cli" => Self::Claude(ClaudeClientEmitter::default()),
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiClientEmitter::default()),
_ => return None,
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>, PipelineFinalizeError> {
match self {
ClientStreamEmitter::OpenAIChat(state) => state.emit(frame),
ClientStreamEmitter::OpenAICli(state) => state.emit(frame),
ClientStreamEmitter::OpenAIResponses(state) => state.emit(frame),
ClientStreamEmitter::Claude(state) => state.emit(frame),
ClientStreamEmitter::Gemini(state) => state.emit(frame),
}
@@ -298,7 +307,7 @@ impl ClientStreamEmitter {
fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
match self {
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
ClientStreamEmitter::OpenAICli(state) => state.finish(),
ClientStreamEmitter::OpenAIResponses(state) => state.finish(),
ClientStreamEmitter::Claude(state) => state.finish(),
ClientStreamEmitter::Gemini(state) => state.finish(),
}
@@ -306,7 +315,7 @@ impl ClientStreamEmitter {
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, PipelineFinalizeError> {
match self {
ClientStreamEmitter::OpenAICli(state) => state.emit_error(error_body),
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)
@@ -335,11 +344,12 @@ fn parse_provider_error(
provider_api_format: &str,
payload: &Value,
) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
match provider_api_format {
"openai:chat" | "openai:cli" | "openai:compact" => parse_openai_error(payload),
"claude:chat" | "claude:cli" => parse_claude_error(payload),
"gemini:chat" | "gemini:cli" => parse_gemini_error(payload),
_ => None,
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),
}
}
@@ -630,7 +640,7 @@ mod tests {
}
#[test]
fn transforms_provider_errors_to_openai_cli_failed_events() {
fn transforms_provider_errors_to_openai_responses_failed_events() {
let cases = [
(
"openai:chat",
@@ -675,7 +685,7 @@ mod tests {
];
for (provider_api_format, line, message, err_type, code) in cases {
let report_context = report_context(provider_api_format, "openai:cli");
let report_context = report_context(provider_api_format, "openai:responses");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(&report_context, line)
@@ -802,8 +812,8 @@ mod tests {
#[test]
fn terminal_observer_uses_explicit_provider_stream_event_api_format() {
let mut report_context = report_context("openai:chat", "openai:cli");
report_context["provider_stream_event_api_format"] = json!("openai:cli");
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
@@ -824,7 +834,7 @@ mod tests {
},
"output_tokens": 137,
"output_tokens_details": {
"reasoning_tokens": 0,
"reasoning_tokens": 10,
},
"total_tokens": 163,
},
@@ -844,12 +854,13 @@ mod tests {
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:cli");
let report_context = report_context("openai:chat", "openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
@@ -873,4 +884,36 @@ mod tests {
"provider stream parser selection must come from report context, not event sniffing"
);
}
#[test]
fn terminal_observer_counts_unknown_provider_stream_events() {
let mut report_context = report_context("openai:chat", "openai:responses");
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.future.delta",
"response": {
"id": "resp_unknown_123",
"model": "gpt-5.4",
},
"payload": {
"kept": true,
},
})),
)
.expect("unknown stream event should be observed");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
assert_eq!(summary.response_id.as_deref(), Some("resp_unknown_123"));
assert_eq!(summary.model.as_deref(), Some("gpt-5.4"));
assert_eq!(summary.unknown_event_count, 1);
assert!(!summary.observed_finish);
}
}

View File

@@ -85,6 +85,8 @@ fn is_standard_provider_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:chat"
| "openai:responses"
| "openai:responses:compact"
| "openai:cli"
| "openai:compact"
| "claude:chat"
@@ -101,7 +103,12 @@ fn is_standard_chat_client_api_format(api_format: &str) -> bool {
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
matches!(
api_format,
"openai:cli" | "openai:compact" | "claude:cli" | "gemini:cli"
"openai:responses"
| "openai:responses:compact"
| "openai:cli"
| "openai:compact"
| "claude:cli"
| "gemini:cli"
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,9 +26,10 @@ pub fn force_upstream_streaming_for_provider(
provider_api_format: &str,
) -> bool {
provider_type.trim().eq_ignore_ascii_case("codex")
&& provider_api_format
.trim()
.eq_ignore_ascii_case("openai:cli")
&& matches!(
provider_api_format.trim().to_ascii_lowercase().as_str(),
"openai:responses" | "openai:cli"
)
}
#[cfg(test)]
@@ -57,7 +58,11 @@ mod tests {
}
#[test]
fn forces_streaming_for_codex_openai_cli() {
fn forces_streaming_for_codex_openai_responses() {
assert!(force_upstream_streaming_for_provider(
"codex",
"openai:responses"
));
assert!(force_upstream_streaming_for_provider("codex", "openai:cli"));
}

View File

@@ -9,6 +9,8 @@ use crate::contracts::{
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_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,
@@ -74,19 +76,19 @@ pub fn resolve_execution_runtime_stream_plan_kind(
}
if route_family == Some("openai")
&& route_kind == Some("cli")
&& is_openai_responses_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses"
{
return Some(OPENAI_CLI_STREAM_PLAN_KIND);
return Some(OPENAI_RESPONSES_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("compact")
&& is_openai_responses_compact_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses/compact"
{
return Some(OPENAI_COMPACT_STREAM_PLAN_KIND);
return Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
@@ -189,19 +191,19 @@ pub fn resolve_execution_runtime_sync_plan_kind(
}
if route_family == Some("openai")
&& route_kind == Some("cli")
&& is_openai_responses_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses"
{
return Some(OPENAI_CLI_SYNC_PLAN_KIND);
return Some(OPENAI_RESPONSES_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("compact")
&& is_openai_responses_compact_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses/compact"
{
return Some(OPENAI_COMPACT_SYNC_PLAN_KIND);
return Some(OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND);
}
if route_family == Some("claude")
@@ -260,6 +262,14 @@ pub fn resolve_execution_runtime_sync_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"))
}
pub fn is_matching_stream_request(
plan_kind: &str,
path: &str,
@@ -268,6 +278,8 @@ pub fn is_matching_stream_request(
match plan_kind {
OPENAI_CHAT_STREAM_PLAN_KIND
| CLAUDE_CHAT_STREAM_PLAN_KIND
| OPENAI_RESPONSES_STREAM_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
| OPENAI_CLI_STREAM_PLAN_KIND
| OPENAI_COMPACT_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND
@@ -287,6 +299,8 @@ pub fn supports_sync_scheduler_decision_kind(plan_kind: &str) -> bool {
plan_kind,
OPENAI_CHAT_SYNC_PLAN_KIND
| OPENAI_IMAGE_SYNC_PLAN_KIND
| OPENAI_RESPONSES_SYNC_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
| OPENAI_CLI_SYNC_PLAN_KIND
| OPENAI_COMPACT_SYNC_PLAN_KIND
| CLAUDE_CHAT_SYNC_PLAN_KIND
@@ -312,6 +326,8 @@ pub fn supports_stream_scheduler_decision_kind(plan_kind: &str) -> bool {
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_CLI_STREAM_PLAN_KIND
| OPENAI_IMAGE_STREAM_PLAN_KIND
| OPENAI_COMPACT_STREAM_PLAN_KIND
@@ -333,7 +349,9 @@ mod tests {
};
use crate::contracts::{
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
OPENAI_IMAGE_SYNC_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]
@@ -360,6 +378,86 @@ mod tests {
);
}
#[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_scheduler_decision_kind(
OPENAI_RESPONSES_SYNC_PLAN_KIND
));
assert!(supports_stream_scheduler_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_scheduler_decision_kind(
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
));
assert!(supports_stream_scheduler_decision_kind(
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
));
}
#[test]
fn stream_matching_requires_openai_stream_flag() {
assert!(!is_matching_stream_request(

View File

@@ -25,18 +25,23 @@ 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_cli_request(provider_type: &str, provider_api_format: &str) -> bool {
fn is_codex_openai_responses_request(provider_type: &str, provider_api_format: &str) -> bool {
provider_type.trim().eq_ignore_ascii_case("codex")
&& matches!(
provider_api_format.trim().to_ascii_lowercase().as_str(),
"openai:cli" | "openai:compact" | "openai:image"
"openai:responses"
| "openai:responses:compact"
| "openai:cli"
| "openai:compact"
| "openai:image"
)
}
fn is_openai_compact_request(provider_api_format: &str) -> bool {
provider_api_format
.trim()
.eq_ignore_ascii_case("openai:compact")
matches!(
provider_api_format.trim().to_ascii_lowercase().as_str(),
"openai:responses:compact" | "openai:compact"
)
}
fn is_openai_image_request(provider_api_format: &str) -> bool {
@@ -245,7 +250,7 @@ fn maybe_inject_codex_prompt_cache_key(
provider_api_format: &str,
user_api_key_id: Option<&str>,
) {
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
@@ -273,7 +278,7 @@ fn maybe_inject_codex_prompt_cache_key(
);
}
pub fn apply_openai_compact_special_body_edits(
pub fn apply_openai_responses_compact_special_body_edits(
provider_request_body: &mut Value,
provider_api_format: &str,
) {
@@ -289,14 +294,25 @@ pub fn apply_openai_compact_special_body_edits(
body_object.remove("store");
}
pub fn apply_codex_openai_cli_special_body_edits(
#[deprecated(
since = "0.1.0",
note = "use apply_openai_responses_compact_special_body_edits"
)]
pub fn apply_openai_compact_special_body_edits(
provider_request_body: &mut Value,
provider_api_format: &str,
) {
apply_openai_responses_compact_special_body_edits(provider_request_body, provider_api_format);
}
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_cli_request(provider_type, provider_api_format) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
@@ -347,7 +363,27 @@ pub fn apply_codex_openai_cli_special_body_edits(
);
}
pub fn apply_codex_openai_cli_special_headers(
#[deprecated(
since = "0.1.0",
note = "use apply_codex_openai_responses_special_body_edits"
)]
pub fn apply_codex_openai_cli_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>,
) {
apply_codex_openai_responses_special_body_edits(
provider_request_body,
provider_type,
provider_api_format,
body_rules,
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,
@@ -356,7 +392,7 @@ pub fn apply_codex_openai_cli_special_headers(
request_id: Option<&str>,
decrypted_auth_config_raw: Option<&str>,
) {
if !is_codex_openai_cli_request(provider_type, provider_api_format) {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
}
@@ -408,10 +444,10 @@ pub fn apply_codex_openai_cli_special_headers(
}
}
if provider_api_format
.trim()
.eq_ignore_ascii_case("openai:cli")
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
if matches!(
provider_api_format.trim().to_ascii_lowercase().as_str(),
"openai:responses" | "openai:cli"
) && !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() {
@@ -421,9 +457,35 @@ pub fn apply_codex_openai_cli_special_headers(
}
}
#[deprecated(
since = "0.1.0",
note = "use apply_codex_openai_responses_special_headers"
)]
pub fn apply_codex_openai_cli_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>,
) {
apply_codex_openai_responses_special_headers(
provider_request_headers,
provider_request_body,
original_headers,
provider_type,
provider_api_format,
request_id,
decrypted_auth_config_raw,
);
}
#[cfg(test)]
mod tests {
use super::{apply_codex_openai_cli_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL};
use super::{
apply_codex_openai_responses_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
};
use serde_json::json;
#[test]
@@ -439,7 +501,7 @@ mod tests {
"tool_choice": "auto"
});
apply_codex_openai_cli_special_body_edits(
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:image",
@@ -493,7 +555,7 @@ mod tests {
"tool_choice": "auto"
});
apply_codex_openai_cli_special_body_edits(
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:image",

View File

@@ -1,22 +1,23 @@
use std::borrow::Cow;
use aether_ai_formats::registry::{convert_request, FormatContext};
use aether_provider_transport::{
apply_local_body_rules, build_transport_request_url, GatewayProviderTransportSnapshot,
TransportRequestUrlParams,
};
use serde_json::Value;
use super::{
apply_openai_responses_compact_special_body_edits,
codex::apply_codex_openai_responses_special_body_edits,
normalize::build_local_openai_chat_request_body,
};
use crate::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request,
convert_openai_chat_request_to_openai_responses_request,
normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_cli_request_to_openai_chat_request,
};
use super::{
apply_openai_compact_special_body_edits, codex::apply_codex_openai_cli_special_body_edits,
normalize::build_local_openai_chat_request_body,
normalize_openai_responses_request_to_openai_chat_request,
};
#[allow(clippy::too_many_arguments)]
@@ -31,29 +32,32 @@ pub fn build_standard_request_body(
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
) -> Option<Value> {
let canonical_request = normalize_standard_request_to_openai_chat_request_cow(
body_json,
let format_context = FormatContext::default()
.with_mapped_model(mapped_model)
.with_request_path(request_path)
.with_upstream_stream(upstream_is_stream);
let mut provider_request_body = convert_request(
client_api_format,
request_path,
)?;
let mut provider_request_body = build_standard_request_body_from_canonical(
canonical_request.as_ref(),
mapped_model,
provider_api_format,
upstream_is_stream,
)?;
body_json,
&format_context,
)
.ok()?;
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
apply_codex_openai_cli_special_body_edits(
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
provider_type,
provider_api_format,
body_rules,
user_api_key_id,
);
apply_openai_compact_special_body_edits(&mut provider_request_body, provider_api_format);
apply_openai_responses_compact_special_body_edits(
&mut provider_request_body,
provider_api_format,
);
Some(provider_request_body)
}
@@ -69,18 +73,22 @@ pub fn build_standard_request_body_from_canonical(
mapped_model,
upstream_is_stream,
),
"openai:cli" => convert_openai_chat_request_to_openai_cli_request(
canonical_request,
mapped_model,
upstream_is_stream,
false,
),
"openai:compact" => convert_openai_chat_request_to_openai_cli_request(
canonical_request,
mapped_model,
false,
true,
),
"openai:responses" | "openai:cli" => {
convert_openai_chat_request_to_openai_responses_request(
canonical_request,
mapped_model,
upstream_is_stream,
false,
)
}
"openai:responses:compact" | "openai:compact" => {
convert_openai_chat_request_to_openai_responses_request(
canonical_request,
mapped_model,
false,
true,
)
}
"claude:chat" | "claude:cli" => convert_openai_chat_request_to_claude_request(
canonical_request,
mapped_model,
@@ -115,8 +123,8 @@ fn normalize_standard_request_to_openai_chat_request_cow<'a>(
) -> Option<Cow<'a, Value>> {
match client_api_format.trim().to_ascii_lowercase().as_str() {
"openai:chat" => Some(Cow::Borrowed(body_json)),
"openai:cli" | "openai:compact" => {
normalize_openai_cli_request_to_openai_chat_request(body_json).map(Cow::Owned)
"openai:responses" | "openai:cli" | "openai:responses:compact" | "openai:compact" => {
normalize_openai_responses_request_to_openai_chat_request(body_json).map(Cow::Owned)
}
"claude:chat" | "claude:cli" => {
normalize_claude_request_to_openai_chat_request(body_json).map(Cow::Owned)
@@ -149,7 +157,10 @@ pub fn build_standard_upstream_url(
#[cfg(test)]
mod tests {
use super::build_standard_request_body;
use super::{
build_standard_request_body, build_standard_request_body_from_canonical,
normalize_standard_request_to_openai_chat_request,
};
use serde_json::{json, Value};
const STANDARD_SURFACES: &[&str] = &[
@@ -251,6 +262,80 @@ mod tests {
])
}
fn legacy_openai_responses_alias_request_body(
request: &Value,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Value {
let chat_canonical = normalize_standard_request_to_openai_chat_request(
request,
"openai:cli",
"/v1/responses",
)
.expect("legacy openai responses alias normalization should succeed");
build_standard_request_body_from_canonical(
&chat_canonical,
"mapped-model",
provider_api_format,
upstream_is_stream,
)
.expect("legacy openai responses alias target conversion should succeed")
}
fn legacy_openai_chat_request_body(
request: &Value,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Value {
build_standard_request_body_from_canonical(
request,
"mapped-model",
provider_api_format,
upstream_is_stream,
)
.expect("legacy openai chat target conversion should succeed")
}
fn legacy_claude_request_body(
request: &Value,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Value {
let chat_canonical = normalize_standard_request_to_openai_chat_request(
request,
"claude:chat",
"/v1/messages",
)
.expect("legacy claude normalization should succeed");
build_standard_request_body_from_canonical(
&chat_canonical,
"mapped-model",
provider_api_format,
upstream_is_stream,
)
.expect("legacy claude target conversion should succeed")
}
fn legacy_gemini_request_body(
request: &Value,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Value {
let chat_canonical = normalize_standard_request_to_openai_chat_request(
request,
"gemini:chat",
"/v1beta/models/source-model:generateContent",
)
.expect("legacy gemini normalization should succeed");
build_standard_request_body_from_canonical(
&chat_canonical,
"mapped-model",
provider_api_format,
upstream_is_stream,
)
.expect("legacy gemini target conversion should succeed")
}
#[test]
fn builds_request_body_for_all_standard_surface_pairs_in_sync_and_stream_modes() {
for client_api_format in STANDARD_SURFACES {
@@ -285,7 +370,357 @@ mod tests {
}
#[test]
fn applies_codex_body_rules_for_all_standard_sources_to_openai_cli() {
fn openai_responses_request_uses_typed_canonical_without_changing_target_payloads() {
let request = json!({
"model": "gpt-5",
"instructions": "Be exact.",
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Inspect this"},
{
"type": "input_image",
"image_url": "data:image/png;base64,iVBORw0KGgo=",
"detail": "high"
},
{
"type": "input_file",
"file_data": "data:application/pdf;base64,JVBERi0x",
"filename": "spec.pdf"
}
]
},
{
"type": "function_call",
"call_id": "call_123",
"name": "lookup",
"arguments": "{\"q\":\"rust\"}"
},
{
"type": "function_call_output",
"call_id": "call_123",
"output": "{\"ok\":true}"
}
],
"max_output_tokens": 64,
"temperature": 0.2,
"top_p": 0.9,
"parallel_tool_calls": true,
"tools": [{
"type": "function",
"name": "lookup",
"description": "Lookup data",
"parameters": {"type": "object"}
}],
"tool_choice": {"type": "function", "name": "lookup"},
"reasoning": {"effort": "high"},
"text": {
"format": {
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}}
},
"verbosity": "low"
},
"metadata": {"trace": "abc"}
});
for provider_api_format in STANDARD_SURFACES {
for upstream_is_stream in [false, true] {
let converted = build_standard_request_body(
&request,
"openai:cli",
"mapped-model",
"custom",
provider_api_format,
"/v1/responses",
upstream_is_stream,
None,
None,
)
.expect("typed canonical route should build");
let legacy = legacy_openai_responses_alias_request_body(
&request,
provider_api_format,
upstream_is_stream,
);
assert_eq!(
converted, legacy,
"typed canonical openai:cli -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
);
}
}
}
#[test]
fn openai_chat_request_uses_typed_canonical_without_changing_target_payloads() {
let request = json!({
"model": "gpt-5",
"messages": [
{"role": "system", "content": "Be exact."},
{
"role": "user",
"content": [
{"type": "text", "text": "Inspect this"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}
}
]
},
{
"role": "assistant",
"content": null,
"reasoning_parts": [{
"type": "thinking",
"thinking": "plan",
"signature": "sig_123"
}],
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {
"name": "lookup",
"arguments": "{\"q\":\"rust\"}"
}
}]
},
{
"role": "tool",
"tool_call_id": "call_123",
"content": {"ok": true}
}
],
"max_completion_tokens": 64,
"temperature": 0.2,
"tools": [{
"type": "function",
"function": {
"name": "lookup",
"description": "Lookup data",
"parameters": {"type": "object"}
}
}],
"tool_choice": {"type": "function", "function": {"name": "lookup"}},
"reasoning_effort": "medium",
"response_format": {
"type": "json_schema",
"json_schema": {"name": "answer", "schema": {"type": "object"}}
}
});
for provider_api_format in STANDARD_SURFACES {
for upstream_is_stream in [false, true] {
let converted = build_standard_request_body(
&request,
"openai:chat",
"mapped-model",
"custom",
provider_api_format,
"/v1/chat/completions",
upstream_is_stream,
None,
None,
)
.expect("typed canonical openai chat route should build");
let legacy = legacy_openai_chat_request_body(
&request,
provider_api_format,
upstream_is_stream,
);
assert_eq!(
converted, legacy,
"typed canonical openai:chat -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
);
}
}
}
#[test]
fn claude_request_uses_typed_canonical_without_changing_non_claude_target_payloads() {
let request = json!({
"model": "claude-sonnet-4-5",
"system": "Be exact.",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Inspect this"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo="
}
},
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0x"
}
}
]
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "plan",
"signature": "sig_123"
},
{
"type": "tool_use",
"id": "toolu_123",
"name": "lookup",
"input": {"q": "rust"}
}
]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_123",
"content": {"ok": true}
}]
}
],
"max_tokens": 64,
"temperature": 0.2,
"top_p": 0.9,
"tools": [{
"name": "lookup",
"description": "Lookup data",
"input_schema": {"type": "object"}
}],
"tool_choice": {
"type": "tool",
"name": "lookup",
"disable_parallel_tool_use": false
},
"metadata": {"trace": "abc"},
"thinking": {"type": "enabled", "budget_tokens": 2048}
});
for provider_api_format in [
"openai:chat",
"openai:cli",
"openai:compact",
"gemini:chat",
"gemini:cli",
] {
for upstream_is_stream in [false, true] {
let converted = build_standard_request_body(
&request,
"claude:chat",
"mapped-model",
"custom",
provider_api_format,
"/v1/messages",
upstream_is_stream,
None,
None,
)
.expect("typed canonical claude route should build");
let legacy =
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
assert_eq!(
converted, legacy,
"typed canonical claude:chat -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
);
}
}
}
#[test]
fn gemini_request_uses_typed_canonical_without_changing_non_gemini_target_payloads() {
let request = json!({
"systemInstruction": {
"parts": [{"text": "Be exact."}]
},
"contents": [
{
"role": "user",
"parts": [
{"text": "Inspect this"},
{"inlineData": {"mimeType": "image/png", "data": "iVBORw0KGgo="}}
]
},
{
"role": "model",
"parts": [
{"text": "plan", "thought": true, "thoughtSignature": "sig_123"},
{"functionCall": {"id": "call_123", "name": "lookup", "args": {"q": "rust"}}}
]
},
{
"role": "user",
"parts": [{
"functionResponse": {
"id": "call_123",
"name": "lookup",
"response": {"result": {"ok": true}}
}
}]
}
],
"generationConfig": {
"maxOutputTokens": 64,
"temperature": 0.2,
"thinkingConfig": {"includeThoughts": true, "thinkingBudget": 2048}
},
"tools": [{
"functionDeclarations": [{
"name": "lookup",
"description": "Lookup data",
"parameters": {"type": "object"}
}]
}],
"toolConfig": {
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": ["lookup"]
}
}
});
for provider_api_format in [
"openai:chat",
"openai:cli",
"openai:compact",
"claude:chat",
"claude:cli",
] {
for upstream_is_stream in [false, true] {
let converted = build_standard_request_body(
&request,
"gemini:chat",
"mapped-model",
"custom",
provider_api_format,
"/v1beta/models/source-model:generateContent",
upstream_is_stream,
None,
None,
)
.expect("typed canonical gemini route should build");
let legacy =
legacy_gemini_request_body(&request, provider_api_format, upstream_is_stream);
assert_eq!(
converted, legacy,
"typed canonical gemini:chat -> {provider_api_format} changed payload with upstream_is_stream={upstream_is_stream}"
);
}
}
}
#[test]
fn applies_codex_body_rules_for_all_standard_sources_to_openai_responses() {
let body_rules = codex_default_body_rules();
for client_api_format in STANDARD_SURFACES {

View File

@@ -4,11 +4,17 @@ pub mod family;
pub mod gemini;
pub mod matrix;
pub mod normalize;
pub mod openai_cli;
pub mod openai_responses;
#[allow(deprecated)]
pub use codex::apply_openai_compact_special_body_edits;
#[allow(deprecated)]
pub use codex::{
apply_codex_openai_cli_special_body_edits, apply_codex_openai_cli_special_headers,
apply_openai_compact_special_body_edits, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
};
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,
};
@@ -18,6 +24,6 @@ pub use matrix::{
normalize_standard_request_to_openai_chat_request,
};
pub use normalize::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_cli_request_body,
build_local_openai_chat_request_body, build_local_openai_cli_request_body,
build_cross_format_openai_chat_request_body, build_cross_format_openai_responses_request_body,
build_local_openai_chat_request_body, build_local_openai_responses_request_body,
};

View File

@@ -2,8 +2,8 @@ use serde_json::{json, Value};
use crate::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_cli_request,
normalize_openai_cli_request_to_openai_chat_request,
convert_openai_chat_request_to_openai_responses_request,
normalize_openai_responses_request_to_openai_chat_request,
};
use crate::conversion::{request_conversion_kind, RequestConversionKind};
@@ -56,8 +56,8 @@ pub fn build_cross_format_openai_chat_request_body(
mapped_model,
upstream_is_stream,
),
RequestConversionKind::ToOpenAIFamilyCli => {
convert_openai_chat_request_to_openai_cli_request(
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
body_json,
mapped_model,
upstream_is_stream,
@@ -68,7 +68,7 @@ pub fn build_cross_format_openai_chat_request_body(
}
}
pub fn build_local_openai_cli_request_body(
pub fn build_local_openai_responses_request_body(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
@@ -86,14 +86,14 @@ pub fn build_local_openai_cli_request_body(
Some(Value::Object(provider_request_body))
}
pub fn build_cross_format_openai_cli_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_cli_request_to_openai_chat_request(body_json)?;
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(
@@ -101,8 +101,8 @@ pub fn build_cross_format_openai_cli_request_body(
mapped_model,
upstream_is_stream,
),
RequestConversionKind::ToOpenAIFamilyCli => {
convert_openai_chat_request_to_openai_cli_request(
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
@@ -124,8 +124,10 @@ pub fn build_cross_format_openai_cli_request_body(
#[cfg(test)]
mod tests {
use super::build_local_openai_cli_request_body;
use super::{build_cross_format_openai_cli_request_body, build_local_openai_chat_request_body};
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> {
@@ -138,20 +140,20 @@ mod tests {
}
#[test]
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
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_cli_request_body(
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5-upstream",
"openai:cli",
"openai:responses",
"openai:chat",
false,
)
.expect("openai cli to openai chat body should build");
.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");
@@ -159,7 +161,7 @@ mod tests {
}
#[test]
fn local_openai_cli_request_body_preserves_original_field_order() {
fn local_openai_responses_request_body_preserves_original_field_order() {
let body_json: Value = serde_json::from_str(
r#"{
"model": "gpt-5",
@@ -171,8 +173,8 @@ mod tests {
.expect("request json should parse");
let provider_request_body =
build_local_openai_cli_request_body(&body_json, "gpt-5-upstream", false)
.expect("openai cli body should build");
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),

View File

@@ -1,76 +0,0 @@
use crate::contracts::{
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND,
};
#[derive(Debug, Clone, Copy)]
pub struct LocalOpenAiCliSpec {
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<LocalOpenAiCliSpec> {
match plan_kind {
OPENAI_CLI_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:cli",
decision_kind: OPENAI_CLI_SYNC_PLAN_KIND,
report_kind: "openai_cli_sync_success",
compact: false,
require_streaming: false,
}),
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:compact",
decision_kind: OPENAI_COMPACT_SYNC_PLAN_KIND,
report_kind: "openai_cli_sync_success",
compact: true,
require_streaming: false,
}),
_ => None,
}
}
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
match plan_kind {
OPENAI_CLI_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:cli",
decision_kind: OPENAI_CLI_STREAM_PLAN_KIND,
report_kind: "openai_cli_stream_success",
compact: false,
require_streaming: true,
}),
OPENAI_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
api_format: "openai:compact",
decision_kind: OPENAI_COMPACT_STREAM_PLAN_KIND,
report_kind: "openai_cli_stream_success",
compact: true,
require_streaming: true,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{resolve_stream_spec, resolve_sync_spec};
#[test]
fn resolves_openai_cli_sync_spec() {
let spec = resolve_sync_spec("openai_cli_sync").expect("spec");
assert_eq!(spec.api_format, "openai:cli");
assert_eq!(spec.report_kind, "openai_cli_sync_success");
assert!(!spec.compact);
assert!(!spec.require_streaming);
}
#[test]
fn resolves_openai_compact_stream_spec() {
let spec = resolve_stream_spec("openai_compact_stream").expect("spec");
assert_eq!(spec.api_format, "openai:compact");
assert_eq!(spec.report_kind, "openai_cli_stream_success");
assert!(spec.compact);
assert!(spec.require_streaming);
}
}

View File

@@ -0,0 +1,126 @@
use crate::contracts::{
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, 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,
}),
OPENAI_CLI_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses",
decision_kind: OPENAI_CLI_SYNC_PLAN_KIND,
report_kind: "openai_cli_sync_success",
compact: false,
require_streaming: false,
}),
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses:compact",
decision_kind: OPENAI_COMPACT_SYNC_PLAN_KIND,
report_kind: OPENAI_RESPONSES_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,
}),
OPENAI_CLI_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses",
decision_kind: OPENAI_CLI_STREAM_PLAN_KIND,
report_kind: "openai_cli_stream_success",
compact: false,
require_streaming: true,
}),
OPENAI_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
api_format: "openai:responses:compact",
decision_kind: OPENAI_COMPACT_STREAM_PLAN_KIND,
report_kind: OPENAI_RESPONSES_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_legacy_openai_cli_sync_spec() {
let spec = resolve_sync_spec("openai_cli_sync").expect("spec");
assert_eq!(spec.api_format, "openai:responses");
assert_eq!(spec.report_kind, "openai_cli_sync_success");
assert!(!spec.compact);
assert!(!spec.require_streaming);
}
#[test]
fn resolves_openai_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);
}
#[test]
fn resolves_legacy_openai_compact_stream_spec() {
let spec = resolve_stream_spec("openai_compact_stream").expect("spec");
assert_eq!(spec.api_format, "openai:responses:compact");
assert_eq!(spec.report_kind, "openai_responses_stream_success");
assert!(spec.compact);
assert!(spec.require_streaming);
}
}