mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 14:40:20 +08:00
Merge remote-tracking branch 'origin/main' into codex/usage-pending-reasoning-reset-expiry-20260712
# Conflicts: # crates/aether-ai-formats/src/formats/openai/responses/mod.rs # crates/aether-usage/runtime/src/runtime.rs # frontend/src/features/usage/components/UsageRecordsTable.vue # frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "aether-ai-formats"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Pure AI API format, surface, planning, and finalize logic for Aether"
|
||||
|
||||
[dependencies]
|
||||
aether-contracts.workspace = true
|
||||
base64.workspace = true
|
||||
http.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha1 = "0.10"
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,307 @@
|
||||
pub use crate::contracts::{
|
||||
core_error_background_report_kind, core_error_default_client_api_format,
|
||||
core_success_background_report_kind, implicit_sync_finalize_report_kind,
|
||||
is_openai_responses_stream_plan_kind, is_openai_responses_sync_plan_kind, AiControlPlanRequest,
|
||||
ExecutionRuntimeAuthContext, CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND,
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
|
||||
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
|
||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND, GEMINI_CHAT_SYNC_ERROR_REPORT_KIND,
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND,
|
||||
GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_ERROR_REPORT_KIND,
|
||||
GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND,
|
||||
GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_ERROR_REPORT_KIND, OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_IMAGE_SYNC_ERROR_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RERANK_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_SEARCH_SYNC_PLAN_KIND,
|
||||
OPENAI_SEARCH_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::formats::claude::messages::stream::{ClaudeClientEmitter, ClaudeProviderState};
|
||||
pub use crate::formats::gemini::generate_content::stream::{
|
||||
GeminiClientEmitter, GeminiProviderState,
|
||||
};
|
||||
pub use crate::formats::openai::chat::stream::{
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
|
||||
OpenAIResponsesProviderState,
|
||||
};
|
||||
pub use crate::formats::openai::image::stream::{
|
||||
maybe_build_openai_image_sync_finalize_product, OpenAiImageStreamState,
|
||||
OpenAiImageSyncFinalizeProduct,
|
||||
};
|
||||
pub use crate::formats::openai::prompt_cache::resolve_openai_prompt_cache_ttl_minutes;
|
||||
pub use crate::formats::openai::shared::{
|
||||
copy_request_number_field, copy_request_number_field_as,
|
||||
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
|
||||
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
|
||||
};
|
||||
pub use crate::formats::openai::{
|
||||
reasoning::{
|
||||
validate_openai_reasoning_request, OpenAiReasoningContractViolation,
|
||||
OpenAiReasoningViolationKind,
|
||||
},
|
||||
request_contract::{
|
||||
finalize_openai_provider_request,
|
||||
finalize_openai_provider_request_with_codex_model_capabilities,
|
||||
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
|
||||
OpenAiProviderRequestFinalization,
|
||||
},
|
||||
};
|
||||
pub use crate::formats::shared::error_body::{
|
||||
build_core_error_body_for_client_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||
};
|
||||
pub use crate::formats::shared::image_bridge::{
|
||||
build_gemini_image_request_body_from_openai_image_request,
|
||||
build_gemini_image_response_from_openai_image_response,
|
||||
build_gemini_image_response_from_openai_responses_image_response,
|
||||
build_openai_image_provider_body_from_response_stream_sync_body,
|
||||
build_openai_image_request_body_from_gemini_image_request,
|
||||
build_openai_image_response_from_gemini_response,
|
||||
build_openai_image_response_from_response_stream_sync_body, gemini_request_is_image_generation,
|
||||
resolve_requested_gemini_image_model_for_request, GeminiImageRequestForOpenAi,
|
||||
OpenAiImageRequestForGemini,
|
||||
};
|
||||
pub use crate::formats::shared::model_directives::{
|
||||
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
|
||||
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
|
||||
default_model_directive_mapping_patch, default_model_directive_suffixes,
|
||||
default_model_directives_config, extract_gemini_model_from_path,
|
||||
gemini_model_uses_thinking_level, model_directive_base_model,
|
||||
model_directive_builtin_suffix_supported_for_source_model,
|
||||
model_directive_suffix_has_builtin_mapping, normalize_model_directive_model,
|
||||
openai_model_supports_prompt_cache_options, parse_model_directive,
|
||||
parse_model_directive_with_suffixes, reasoning_effort_supported_for_model, ModelDirective,
|
||||
ModelDirectiveSuffixResolution, ModelOverride, ReasoningEffort, ServiceTier,
|
||||
CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES, MODEL_DIRECTIVE_API_FORMATS,
|
||||
OPENAI_MODEL_DIRECTIVE_SUFFIXES,
|
||||
};
|
||||
pub use crate::formats::shared::passthrough::{
|
||||
resolve_stream_spec as resolve_local_same_format_stream_spec,
|
||||
resolve_sync_spec as resolve_local_same_format_sync_spec, LocalSameFormatProviderFamily,
|
||||
LocalSameFormatProviderSpec,
|
||||
};
|
||||
pub use crate::formats::shared::request::{
|
||||
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
|
||||
forbid_upstream_streaming_for_provider, force_upstream_streaming_for_provider,
|
||||
parse_direct_request_body, resolve_upstream_is_stream_for_provider,
|
||||
resolve_upstream_is_stream_from_endpoint_config,
|
||||
};
|
||||
pub use crate::formats::shared::request_matrix::{
|
||||
build_standard_request_body_from_canonical,
|
||||
build_standard_request_body_from_canonical_with_model_directives,
|
||||
};
|
||||
pub use crate::formats::shared::response::{
|
||||
build_generated_tool_call_id, build_local_success_background_report,
|
||||
build_local_success_conversion_background_report, canonicalize_tool_arguments,
|
||||
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
|
||||
LocalSyncReportParts,
|
||||
};
|
||||
pub use crate::formats::shared::routing::{
|
||||
is_matching_stream_http_request, is_matching_stream_request,
|
||||
request_path_implies_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind, sanitize_request_path,
|
||||
sanitize_request_path_and_query, sanitize_request_query_string,
|
||||
supports_stream_execution_decision_kind, supports_sync_execution_decision_kind,
|
||||
};
|
||||
pub use crate::formats::shared::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
|
||||
pub use crate::formats::shared::standard_matrix::normalize_standard_request_to_openai_chat_request;
|
||||
pub use crate::formats::shared::stream_core::common::*;
|
||||
pub use crate::formats::shared::stream_core::{
|
||||
CanonicalStreamFrame, StreamingStandardFormatMatrix, StreamingStandardTerminalObserver,
|
||||
};
|
||||
pub use crate::formats::shared::sync_products::{
|
||||
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
|
||||
aggregate_openai_chat_stream_sync_response, aggregate_openai_responses_stream_sync_response,
|
||||
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
|
||||
convert_standard_chat_response, convert_standard_cli_response,
|
||||
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload,
|
||||
maybe_build_standard_cross_format_sync_product,
|
||||
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_standard_same_format_sync_body_from_normalized_payload,
|
||||
maybe_build_standard_sync_finalize_product_from_normalized_payload,
|
||||
StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
|
||||
};
|
||||
pub use crate::formats::shared::sync_to_stream::{
|
||||
maybe_bridge_standard_sync_json_to_stream, SyncToStreamBridgeOutcome,
|
||||
};
|
||||
pub use crate::formats::shared::{
|
||||
maybe_build_ai_surface_stream_rewriter, resolve_finalize_stream_rewrite_mode,
|
||||
AiSurfaceFinalizeError, AiSurfaceStreamRewriter, FinalizeStreamRewriteMode,
|
||||
};
|
||||
pub use crate::formats::{
|
||||
claude::messages::{
|
||||
resolve_stream_spec as resolve_claude_stream_spec,
|
||||
resolve_sync_spec as resolve_claude_sync_spec,
|
||||
},
|
||||
gemini::generate_content::{
|
||||
resolve_stream_spec as resolve_gemini_stream_spec,
|
||||
resolve_sync_spec as resolve_gemini_sync_spec,
|
||||
},
|
||||
openai::{
|
||||
embedding::spec::resolve_sync_spec as resolve_openai_embedding_sync_spec,
|
||||
responses::{
|
||||
codex::{
|
||||
apply_codex_openai_compact_terminal_headers,
|
||||
apply_codex_openai_responses_chat_body_edits,
|
||||
apply_codex_openai_responses_lite_header_for_request_body_with_capabilities,
|
||||
apply_codex_openai_responses_lite_header_with_capabilities,
|
||||
apply_codex_openai_responses_special_body_edits,
|
||||
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities,
|
||||
apply_codex_openai_special_headers,
|
||||
apply_openai_responses_compact_special_body_edits,
|
||||
build_codex_model_catalog_metadata, parse_codex_auth_identity,
|
||||
resolve_codex_responses_model_capabilities, CodexAuthIdentity,
|
||||
CodexResponsesModelCapabilities, 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,
|
||||
},
|
||||
spec::{
|
||||
resolve_stream_spec as resolve_openai_responses_stream_spec,
|
||||
resolve_sync_spec as resolve_openai_responses_sync_spec, LocalOpenAiResponsesSpec,
|
||||
},
|
||||
},
|
||||
},
|
||||
shared::{
|
||||
family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec},
|
||||
standard_matrix::{
|
||||
build_standard_request_body, build_standard_request_body_with_model_directives,
|
||||
build_standard_request_body_with_model_directives_and_request_headers,
|
||||
},
|
||||
standard_normalize::{
|
||||
build_cross_format_openai_chat_request_body,
|
||||
build_cross_format_openai_chat_request_body_with_model_directives,
|
||||
build_cross_format_openai_responses_request_body,
|
||||
build_cross_format_openai_responses_request_body_with_model_directives,
|
||||
build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body_with_model_directives,
|
||||
is_claude_messages_shaped_body_on_openai_chat_endpoint,
|
||||
},
|
||||
},
|
||||
};
|
||||
pub use crate::formats::{
|
||||
gemini::files::spec::{
|
||||
resolve_stream_spec as resolve_gemini_files_stream_spec,
|
||||
resolve_sync_spec as resolve_gemini_files_sync_spec, LocalGeminiFilesSpec,
|
||||
},
|
||||
openai::image::{
|
||||
request::{
|
||||
build_chatgpt_web_image_request_body,
|
||||
build_codex_openai_image_api_provider_request_body,
|
||||
build_openai_image_api_provider_request_body, build_openai_image_provider_request_body,
|
||||
default_model_for_openai_image_operation, is_openai_image_stream_request,
|
||||
normalize_openai_image_quality, normalize_openai_image_request,
|
||||
normalize_openai_image_request_with_options, openai_image_operation_from_path,
|
||||
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
|
||||
resolve_requested_openai_image_model_for_request, ChatGptWebImageRequestError,
|
||||
NormalizedOpenAiImageRequest, OpenAiImageNormalizeOptions, OpenAiImageOperation,
|
||||
OpenAiImageResponseFormat,
|
||||
},
|
||||
spec::{
|
||||
resolve_stream_spec as resolve_local_image_stream_spec,
|
||||
resolve_sync_spec as resolve_local_image_sync_spec, LocalOpenAiImageSpec,
|
||||
},
|
||||
},
|
||||
shared::video::{
|
||||
resolve_sync_spec as resolve_local_video_sync_spec, LocalVideoCreateFamily,
|
||||
LocalVideoCreateSpec,
|
||||
},
|
||||
};
|
||||
pub use crate::provider_compat::kiro_stream::{
|
||||
build_kiro_final_message_sse_events, build_kiro_initial_sse_events,
|
||||
build_kiro_stream_error_sse_events, calculate_kiro_context_input_tokens,
|
||||
encode_kiro_sse_events, estimate_kiro_tokens, find_kiro_real_thinking_end_tag,
|
||||
find_kiro_real_thinking_end_tag_at_buffer_end, find_kiro_real_thinking_start_tag, kiro_crc32,
|
||||
KiroToClaudeCliStreamState, KIRO_MAX_THINKING_BUFFER,
|
||||
};
|
||||
pub use crate::provider_compat::private_envelope::{
|
||||
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
provider_private_response_allows_sync_finalize, stream_body_contains_error_event,
|
||||
transform_provider_private_stream_line, ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
pub use crate::provider_compat::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ProviderAdaptationDescriptor,
|
||||
ProviderAdaptationSurface, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
pub use aether_ai_formats::formats::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_responses_request, extract_openai_text_content,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||
};
|
||||
pub use aether_ai_formats::formats::conversion::response::{
|
||||
build_openai_responses_response, build_openai_responses_response_with_content,
|
||||
build_openai_responses_response_with_reasoning, convert_claude_chat_response_to_openai_chat,
|
||||
convert_claude_response_to_openai_responses, convert_gemini_chat_response_to_openai_chat,
|
||||
convert_gemini_response_to_openai_responses, convert_openai_chat_response_to_claude_chat,
|
||||
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_responses,
|
||||
convert_openai_responses_response_to_openai_chat, OpenAiResponsesResponseUsage,
|
||||
};
|
||||
pub use aether_ai_formats::{
|
||||
api_format_alias_matches, api_format_permission_covers, api_format_permission_storage_aliases,
|
||||
api_format_storage_aliases, intersect_api_format_allowed_lists,
|
||||
is_openai_responses_compact_format, is_openai_responses_family_format,
|
||||
is_openai_responses_format, normalize_api_format_alias,
|
||||
};
|
||||
pub use aether_ai_formats::{
|
||||
canonical_request_unknown_block_count, canonical_response_unknown_block_count,
|
||||
canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request,
|
||||
canonical_to_gemini_response, canonical_to_openai_chat_request,
|
||||
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
|
||||
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
|
||||
canonical_to_openai_responses_response, canonical_unknown_block_count, convert_request,
|
||||
convert_request_pure, convert_request_pure_with_context, convert_response,
|
||||
convert_response_pure, emit_request_pure, emit_response_pure, 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_gemini_interactions_api_format,
|
||||
parse_request_pure, parse_response_pure, request_candidate_api_format_preference,
|
||||
request_candidate_api_formats, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, CanonicalContentBlock, CanonicalGenerationConfig,
|
||||
CanonicalInstruction, CanonicalMessage, CanonicalRequest, CanonicalResponse,
|
||||
CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
|
||||
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage,
|
||||
ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted, FormatContext,
|
||||
FormatError, FormatFamily, FormatId, FormatProfile, RequestConversionKind,
|
||||
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
pub const EXECUTION_RUNTIME_SYNC_ACTION: &str = "execution_runtime_sync";
|
||||
pub const EXECUTION_RUNTIME_SYNC_DECISION_ACTION: &str = "execution_runtime_sync_decision";
|
||||
pub const EXECUTION_RUNTIME_STREAM_ACTION: &str = "execution_runtime_stream";
|
||||
pub const EXECUTION_RUNTIME_STREAM_DECISION_ACTION: &str = "execution_runtime_stream_decision";
|
||||
@@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ExecutionRuntimeAuthContext {
|
||||
pub user_id: String,
|
||||
pub api_key_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_name: Option<String>,
|
||||
pub balance_remaining: Option<f64>,
|
||||
pub access_allowed: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub api_key_is_standalone: bool,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::contracts::ExecutionRuntimeAuthContext;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AiControlPlanRequest {
|
||||
pub trace_id: String,
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub query_string: Option<String>,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body_json: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body_base64: Option<String>,
|
||||
pub auth_context: Option<ExecutionRuntimeAuthContext>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_ai_control_plan_request(
|
||||
trace_id: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
query_string: Option<&str>,
|
||||
headers: BTreeMap<String, String>,
|
||||
body_json: serde_json::Value,
|
||||
body_base64: Option<String>,
|
||||
auth_context: Option<ExecutionRuntimeAuthContext>,
|
||||
) -> AiControlPlanRequest {
|
||||
AiControlPlanRequest {
|
||||
trace_id: trace_id.to_string(),
|
||||
method: method.to_string(),
|
||||
path: path.to_string(),
|
||||
query_string: query_string.map(ToOwned::to_owned),
|
||||
headers,
|
||||
body_json,
|
||||
body_base64,
|
||||
auth_context,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{build_ai_control_plan_request, ExecutionRuntimeAuthContext};
|
||||
|
||||
#[test]
|
||||
fn build_ai_control_plan_request_preserves_request_shape() {
|
||||
let payload = build_ai_control_plan_request(
|
||||
"trace-123",
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
Some("stream=true"),
|
||||
BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
serde_json::json!({"model": "gpt-5"}),
|
||||
Some("eyJmb28iOiJiYXIifQ==".to_string()),
|
||||
Some(ExecutionRuntimeAuthContext {
|
||||
user_id: "user-1".to_string(),
|
||||
api_key_id: "key-1".to_string(),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
balance_remaining: Some(12.5),
|
||||
access_allowed: true,
|
||||
api_key_is_standalone: false,
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(payload.trace_id, "trace-123");
|
||||
assert_eq!(payload.method, "POST");
|
||||
assert_eq!(payload.path, "/v1/chat/completions");
|
||||
assert_eq!(payload.query_string.as_deref(), Some("stream=true"));
|
||||
assert_eq!(
|
||||
payload.headers.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(payload.body_json, serde_json::json!({"model": "gpt-5"}));
|
||||
assert_eq!(payload.body_base64.as_deref(), Some("eyJmb28iOiJiYXIifQ=="));
|
||||
assert_eq!(
|
||||
payload
|
||||
.auth_context
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.user_id.as_str()),
|
||||
Some("user-1")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
mod actions;
|
||||
mod auth_context;
|
||||
mod control_payloads;
|
||||
mod plan_kinds;
|
||||
mod report_kinds;
|
||||
|
||||
pub use actions::{
|
||||
EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
|
||||
EXECUTION_RUNTIME_SYNC_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
||||
};
|
||||
pub use auth_context::ExecutionRuntimeAuthContext;
|
||||
pub use control_payloads::{build_ai_control_plan_request, AiControlPlanRequest};
|
||||
pub use plan_kinds::{
|
||||
is_openai_responses_stream_plan_kind, is_openai_responses_sync_plan_kind,
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
|
||||
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
GEMINI_INTERACTIONS_STREAM_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND,
|
||||
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_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_SEARCH_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
pub use report_kinds::{
|
||||
core_error_background_report_kind, core_error_default_client_api_format,
|
||||
core_success_background_report_kind, implicit_stream_success_report_kind,
|
||||
implicit_sync_finalize_report_kind, CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||
CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND, CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND,
|
||||
CLAUDE_CLI_SYNC_ERROR_REPORT_KIND, CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND,
|
||||
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||
GEMINI_CHAT_SYNC_ERROR_REPORT_KIND, GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||
GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND,
|
||||
GEMINI_CLI_SYNC_ERROR_REPORT_KIND, GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND,
|
||||
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND,
|
||||
GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_ERROR_REPORT_KIND,
|
||||
GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND,
|
||||
GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND, OPENAI_EMBEDDING_SYNC_ERROR_REPORT_KIND,
|
||||
OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND, OPENAI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_ERROR_REPORT_KIND,
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND, OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND, OPENAI_SEARCH_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
pub const GEMINI_FILES_GET_PLAN_KIND: &str = "gemini_files_get";
|
||||
pub const GEMINI_FILES_UPLOAD_PLAN_KIND: &str = "gemini_files_upload";
|
||||
pub const GEMINI_FILES_LIST_PLAN_KIND: &str = "gemini_files_list";
|
||||
pub const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete";
|
||||
pub const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download";
|
||||
pub const OPENAI_IMAGE_STREAM_PLAN_KIND: &str = "openai_image_stream";
|
||||
pub const OPENAI_IMAGE_SYNC_PLAN_KIND: &str = "openai_image_sync";
|
||||
pub const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
|
||||
pub const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
|
||||
pub const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";
|
||||
pub const OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND: &str = "openai_video_delete_sync";
|
||||
pub const GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "gemini_video_create_sync";
|
||||
pub const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
|
||||
pub const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
|
||||
pub const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
|
||||
pub const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
|
||||
pub const GEMINI_INTERACTIONS_STREAM_PLAN_KIND: &str = "gemini_interactions_stream";
|
||||
pub const OPENAI_RESPONSES_STREAM_PLAN_KIND: &str = "openai_responses_stream";
|
||||
pub const OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND: &str = "openai_responses_compact_stream";
|
||||
pub const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
|
||||
pub const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
|
||||
pub const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
|
||||
pub const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
|
||||
pub const OPENAI_EMBEDDING_SYNC_PLAN_KIND: &str = "openai_embedding_sync";
|
||||
pub const OPENAI_RERANK_SYNC_PLAN_KIND: &str = "openai_rerank_sync";
|
||||
pub const OPENAI_SEARCH_SYNC_PLAN_KIND: &str = "openai_search_sync";
|
||||
pub const GEMINI_EMBEDDING_SYNC_PLAN_KIND: &str = "gemini_embedding_sync";
|
||||
pub const OPENAI_RESPONSES_SYNC_PLAN_KIND: &str = "openai_responses_sync";
|
||||
pub const OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND: &str = "openai_responses_compact_sync";
|
||||
pub const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync";
|
||||
pub const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync";
|
||||
pub const GEMINI_INTERACTIONS_SYNC_PLAN_KIND: &str = "gemini_interactions_sync";
|
||||
pub const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync";
|
||||
pub const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync";
|
||||
|
||||
pub fn is_openai_responses_stream_plan_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND | OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_sync_plan_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND | OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::contracts::{
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
OPENAI_EMBEDDING_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 GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_interactions_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_EMBEDDING_SYNC_FINALIZE_REPORT_KIND: &str = "openai_embedding_sync_finalize";
|
||||
pub const OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_image_sync_finalize";
|
||||
pub const CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "claude_cli_sync_finalize";
|
||||
pub const GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_cli_sync_finalize";
|
||||
pub const OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_video_create_sync_finalize";
|
||||
pub const GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_video_create_sync_finalize";
|
||||
const LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "openai_cli_sync_finalize";
|
||||
const LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sync_finalize";
|
||||
|
||||
pub const OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_chat_sync_success";
|
||||
pub const CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "claude_chat_sync_success";
|
||||
pub const GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_chat_sync_success";
|
||||
pub const GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_interactions_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_EMBEDDING_SYNC_SUCCESS_REPORT_KIND: &str = "openai_embedding_sync_success";
|
||||
pub const OPENAI_SEARCH_SYNC_SUCCESS_REPORT_KIND: &str = "openai_search_sync_success";
|
||||
pub const GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_embedding_sync_success";
|
||||
pub const OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND: &str = "openai_image_sync_success";
|
||||
pub const CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "claude_cli_sync_success";
|
||||
pub const GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_cli_sync_success";
|
||||
|
||||
pub const OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "openai_chat_stream_success";
|
||||
pub const CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "claude_chat_stream_success";
|
||||
pub const GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_chat_stream_success";
|
||||
pub const GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND: &str =
|
||||
"gemini_interactions_stream_success";
|
||||
pub const OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND: &str = "openai_responses_stream_success";
|
||||
pub const OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND: &str =
|
||||
"openai_responses_compact_stream_success";
|
||||
pub const OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND: &str = "openai_image_stream_success";
|
||||
pub const CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "claude_cli_stream_success";
|
||||
pub const GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_cli_stream_success";
|
||||
|
||||
pub const OPENAI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "openai_chat_sync_error";
|
||||
pub const CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND: &str = "claude_chat_sync_error";
|
||||
pub const GEMINI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "gemini_chat_sync_error";
|
||||
pub const GEMINI_INTERACTIONS_SYNC_ERROR_REPORT_KIND: &str = "gemini_interactions_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_EMBEDDING_SYNC_ERROR_REPORT_KIND: &str = "openai_embedding_sync_error";
|
||||
pub const OPENAI_IMAGE_SYNC_ERROR_REPORT_KIND: &str = "openai_image_sync_error";
|
||||
pub const CLAUDE_CLI_SYNC_ERROR_REPORT_KIND: &str = "claude_cli_sync_error";
|
||||
pub const GEMINI_CLI_SYNC_ERROR_REPORT_KIND: &str = "gemini_cli_sync_error";
|
||||
|
||||
pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static str> {
|
||||
match plan_kind {
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND => Some(OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
GEMINI_INTERACTIONS_SYNC_PLAN_KIND => Some(GEMINI_INTERACTIONS_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_EMBEDDING_SYNC_PLAN_KIND => Some(OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND),
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND),
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND),
|
||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
|
||||
match report_kind {
|
||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("openai:chat"),
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("claude:messages"),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:generate_content"),
|
||||
GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND => Some("gemini:interactions"),
|
||||
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"),
|
||||
OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND => Some("openai:embedding"),
|
||||
LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"),
|
||||
LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"),
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some("openai:image"),
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some("claude:messages"),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("gemini:generate_content"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
match report_kind {
|
||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(GEMINI_INTERACTIONS_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_EMBEDDING_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_EMBEDDING_SYNC_ERROR_REPORT_KIND),
|
||||
LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND)
|
||||
}
|
||||
LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND)
|
||||
}
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_IMAGE_SYNC_ERROR_REPORT_KIND),
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
match report_kind {
|
||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND),
|
||||
OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
LEGACY_OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND)
|
||||
}
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn implicit_stream_success_report_kind(plan_kind: &str) -> Option<&'static str> {
|
||||
match plan_kind {
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND => Some(OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -0,0 +1,364 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
|
||||
use crate::protocol::canonical::{
|
||||
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalRequest,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let model = request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let contents = request
|
||||
.get("input")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|input| input.get("contents"))
|
||||
.and_then(Value::as_array)?;
|
||||
let input = contents_to_embedding_input(contents)?;
|
||||
let mut parameters = request
|
||||
.get("parameters")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let dimensions = parameters
|
||||
.remove("dimension")
|
||||
.or_else(|| request.get("dimensions").cloned())
|
||||
.and_then(|value| value.as_u64());
|
||||
let parameters = (!parameters.is_empty()).then_some(parameters);
|
||||
Some(CanonicalRequest {
|
||||
model,
|
||||
embedding: Some(crate::protocol::canonical::CanonicalEmbeddingRequest {
|
||||
input,
|
||||
encoding_format: None,
|
||||
dimensions,
|
||||
task: None,
|
||||
user: None,
|
||||
parameters,
|
||||
extensions: namespace_extensions(
|
||||
"aliyun",
|
||||
request,
|
||||
&["model", "input", "parameters", "dimensions"],
|
||||
),
|
||||
}),
|
||||
..CanonicalRequest::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
let embedding = request.embedding.as_ref()?;
|
||||
let contents = embedding_input_to_contents(&embedding.input)?;
|
||||
if contents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_embedding_model(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
)),
|
||||
);
|
||||
output.insert(
|
||||
"input".to_string(),
|
||||
Value::Object(Map::from_iter([(
|
||||
"contents".to_string(),
|
||||
Value::Array(contents),
|
||||
)])),
|
||||
);
|
||||
|
||||
let mut parameters = embedding.parameters.clone().unwrap_or_default();
|
||||
if let Some(dimensions) = embedding.dimensions {
|
||||
parameters
|
||||
.entry("dimension".to_string())
|
||||
.or_insert_with(|| Value::from(dimensions));
|
||||
}
|
||||
if !parameters.is_empty() {
|
||||
output.insert("parameters".to_string(), Value::Object(parameters));
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn contents_to_embedding_input(contents: &[Value]) -> Option<CanonicalEmbeddingInput> {
|
||||
if contents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parsed = contents
|
||||
.iter()
|
||||
.map(embedding_content_from_value)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if parsed.iter().all(|content| {
|
||||
content.image.is_none() && content.video.is_none() && content.multi_images.is_none()
|
||||
}) {
|
||||
return Some(CanonicalEmbeddingInput::StringArray(
|
||||
parsed
|
||||
.into_iter()
|
||||
.map(|content| content.text)
|
||||
.collect::<Option<Vec<_>>>()?,
|
||||
));
|
||||
}
|
||||
Some(CanonicalEmbeddingInput::Multimodal(parsed))
|
||||
}
|
||||
|
||||
fn embedding_content_from_value(value: &Value) -> Option<CanonicalEmbeddingContent> {
|
||||
let object = value.as_object()?;
|
||||
let content = CanonicalEmbeddingContent {
|
||||
text: object
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
image: object
|
||||
.get("image")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
video: object
|
||||
.get("video")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
multi_images: match object.get("multi_images").and_then(Value::as_array) {
|
||||
Some(values) => Some(
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?,
|
||||
),
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
(!content.is_empty()).then_some(content)
|
||||
}
|
||||
|
||||
fn embedding_input_to_contents(input: &CanonicalEmbeddingInput) -> Option<Vec<Value>> {
|
||||
match input {
|
||||
CanonicalEmbeddingInput::String(text) => {
|
||||
non_empty_text_content(text).map(|content| vec![content])
|
||||
}
|
||||
CanonicalEmbeddingInput::StringArray(items) => items
|
||||
.iter()
|
||||
.map(|text| non_empty_text_content(text))
|
||||
.collect(),
|
||||
CanonicalEmbeddingInput::Multimodal(items) => {
|
||||
items.iter().map(multimodal_content_to_value).collect()
|
||||
}
|
||||
CanonicalEmbeddingInput::TokenArray(_) | CanonicalEmbeddingInput::TokenArrayArray(_) => {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_text_content(text: &str) -> Option<Value> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(Map::from_iter([(
|
||||
"text".to_string(),
|
||||
Value::String(text.to_string()),
|
||||
)])))
|
||||
}
|
||||
}
|
||||
|
||||
fn multimodal_content_to_value(content: &CanonicalEmbeddingContent) -> Option<Value> {
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut object = Map::new();
|
||||
if let Some(text) = content
|
||||
.text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
object.insert("text".to_string(), Value::String(text.to_string()));
|
||||
}
|
||||
if let Some(image) = content
|
||||
.image
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
object.insert("image".to_string(), Value::String(image.to_string()));
|
||||
}
|
||||
if let Some(video) = content
|
||||
.video
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
object.insert("video".to_string(), Value::String(video.to_string()));
|
||||
}
|
||||
if let Some(multi_images) = content
|
||||
.multi_images
|
||||
.as_ref()
|
||||
.filter(|values| !values.is_empty() && values.iter().all(|value| !value.trim().is_empty()))
|
||||
{
|
||||
object.insert(
|
||||
"multi_images".to_string(),
|
||||
Value::Array(
|
||||
multi_images
|
||||
.iter()
|
||||
.map(|value| Value::String(value.trim().to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if object.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(object))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::to;
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::{
|
||||
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalEmbeddingRequest,
|
||||
CanonicalRequest,
|
||||
};
|
||||
|
||||
fn canonical_embedding(input: CanonicalEmbeddingInput) -> CanonicalRequest {
|
||||
CanonicalRequest {
|
||||
model: "text-embedding-3-small".to_string(),
|
||||
embedding: Some(CanonicalEmbeddingRequest {
|
||||
input,
|
||||
encoding_format: None,
|
||||
dimensions: None,
|
||||
task: None,
|
||||
user: None,
|
||||
parameters: None,
|
||||
extensions: BTreeMap::new(),
|
||||
}),
|
||||
..CanonicalRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_input_uses_dashscope_contents() {
|
||||
let request = canonical_embedding(CanonicalEmbeddingInput::StringArray(vec![
|
||||
"alpha".to_string(),
|
||||
"beta".to_string(),
|
||||
]));
|
||||
|
||||
let body = to(
|
||||
&request,
|
||||
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
|
||||
)
|
||||
.expect("aliyun request");
|
||||
|
||||
assert_eq!(body["model"], "qwen3-vl-embedding");
|
||||
assert_eq!(
|
||||
body["input"]["contents"],
|
||||
json!([{ "text": "alpha" }, { "text": "beta" }])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimodal_input_and_parameters_use_dashscope_contract() {
|
||||
let mut request = canonical_embedding(CanonicalEmbeddingInput::Multimodal(vec![
|
||||
CanonicalEmbeddingContent {
|
||||
text: Some("white running shoes".to_string()),
|
||||
image: None,
|
||||
video: None,
|
||||
multi_images: None,
|
||||
},
|
||||
CanonicalEmbeddingContent {
|
||||
text: None,
|
||||
image: Some("https://example.com/shoe.png".to_string()),
|
||||
video: None,
|
||||
multi_images: None,
|
||||
},
|
||||
CanonicalEmbeddingContent {
|
||||
text: None,
|
||||
image: None,
|
||||
video: None,
|
||||
multi_images: Some(vec![
|
||||
"https://example.com/a.png".to_string(),
|
||||
"https://example.com/b.png".to_string(),
|
||||
]),
|
||||
},
|
||||
]));
|
||||
let embedding = request.embedding.as_mut().expect("embedding request");
|
||||
embedding.dimensions = Some(1024);
|
||||
embedding.parameters = Some(Map::from_iter([
|
||||
("enable_fusion".to_string(), Value::Bool(true)),
|
||||
("res_level".to_string(), Value::from(2_u64)),
|
||||
("max_video_frames".to_string(), Value::from(64_u64)),
|
||||
]));
|
||||
|
||||
let body = to(
|
||||
&request,
|
||||
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
|
||||
)
|
||||
.expect("aliyun request");
|
||||
|
||||
assert_eq!(
|
||||
body["input"]["contents"],
|
||||
json!([
|
||||
{ "text": "white running shoes" },
|
||||
{ "image": "https://example.com/shoe.png" },
|
||||
{ "multi_images": ["https://example.com/a.png", "https://example.com/b.png"] }
|
||||
])
|
||||
);
|
||||
assert_eq!(body["parameters"]["dimension"], 1024);
|
||||
assert_eq!(body["parameters"]["enable_fusion"], true);
|
||||
assert_eq!(body["parameters"]["res_level"], 2);
|
||||
assert_eq!(body["parameters"]["max_video_frames"], 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_dimension_wins_over_openai_dimensions() {
|
||||
let mut request = canonical_embedding(CanonicalEmbeddingInput::String("alpha".to_string()));
|
||||
let embedding = request.embedding.as_mut().expect("embedding request");
|
||||
embedding.dimensions = Some(1024);
|
||||
embedding.parameters = Some(Map::from_iter([(
|
||||
"dimension".to_string(),
|
||||
Value::from(512_u64),
|
||||
)]));
|
||||
|
||||
let body = to(
|
||||
&request,
|
||||
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
|
||||
)
|
||||
.expect("aliyun request");
|
||||
|
||||
assert_eq!(body["parameters"]["dimension"], 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_arrays_are_not_convertible() {
|
||||
let request = canonical_embedding(CanonicalEmbeddingInput::TokenArray(vec![1, 2, 3]));
|
||||
assert!(to(
|
||||
&request,
|
||||
&FormatContext::default().with_mapped_model("qwen3-vl-embedding"),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::formats::openai::embedding::request::namespace_extensions;
|
||||
use crate::protocol::canonical::{CanonicalEmbedding, CanonicalEmbeddingResponse, CanonicalUsage};
|
||||
|
||||
pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") || body.contains_key("code") && body.contains_key("message") {
|
||||
return None;
|
||||
}
|
||||
let data = body
|
||||
.get("output")?
|
||||
.as_object()?
|
||||
.get("embeddings")?
|
||||
.as_array()?;
|
||||
let mut embeddings = Vec::new();
|
||||
for (fallback_index, item) in data.iter().enumerate() {
|
||||
let item_object = item.as_object()?;
|
||||
let values = item_object.get("embedding")?.as_array()?;
|
||||
let embedding = values
|
||||
.iter()
|
||||
.map(Value::as_f64)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let mut extensions =
|
||||
namespace_extensions("aliyun", item_object, &["index", "embedding", "type"]);
|
||||
if let Some(value) = item_object.get("type").cloned() {
|
||||
extensions.insert(
|
||||
"openai".to_string(),
|
||||
Value::Object(Map::from_iter([("type".to_string(), value)])),
|
||||
);
|
||||
}
|
||||
embeddings.push(CanonicalEmbedding {
|
||||
index: item_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.unwrap_or(fallback_index),
|
||||
embedding,
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
|
||||
let request_id = body.get("request_id").and_then(Value::as_str);
|
||||
let mut extensions =
|
||||
namespace_extensions("aliyun", body, &["output", "usage", "request_id", "model"]);
|
||||
if let Some(request_id) = request_id {
|
||||
extensions.insert(
|
||||
"openai".to_string(),
|
||||
Value::Object(Map::from_iter([(
|
||||
"request_id".to_string(),
|
||||
Value::String(request_id.to_string()),
|
||||
)])),
|
||||
);
|
||||
}
|
||||
|
||||
Some(CanonicalEmbeddingResponse {
|
||||
id: request_id.unwrap_or("aliyun-request-unknown").to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
embeddings,
|
||||
usage: aliyun_usage_to_canonical(body.get("usage")),
|
||||
extensions,
|
||||
})
|
||||
}
|
||||
|
||||
fn aliyun_usage_to_canonical(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens: usage
|
||||
.get("total_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(input_tokens.saturating_add(output_tokens)),
|
||||
extensions: BTreeMap::from([("aliyun".to_string(), Value::Object(usage.clone()))]),
|
||||
..CanonicalUsage::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::from;
|
||||
use crate::formats::openai::embedding::response::to as to_openai;
|
||||
|
||||
#[test]
|
||||
fn parses_dashscope_embeddings_to_openai_compatible_shape() {
|
||||
let body = json!({
|
||||
"output": {
|
||||
"embeddings": [
|
||||
{
|
||||
"index": 0,
|
||||
"embedding": [0.1, 0.2, 0.3],
|
||||
"type": "fused"
|
||||
}
|
||||
]
|
||||
},
|
||||
"usage": {
|
||||
"input_tokens": 432,
|
||||
"input_tokens_details": {
|
||||
"image_tokens": 402,
|
||||
"text_tokens": 30
|
||||
},
|
||||
"output_tokens": 1,
|
||||
"total_tokens": 433
|
||||
},
|
||||
"request_id": "aliyun-request-1"
|
||||
});
|
||||
|
||||
let canonical = from(&body).expect("aliyun response");
|
||||
let emitted = to_openai(&canonical).expect("openai response");
|
||||
|
||||
assert_eq!(emitted["request_id"], "aliyun-request-1");
|
||||
assert_eq!(emitted["data"][0]["embedding"], json!([0.1, 0.2, 0.3]));
|
||||
assert_eq!(emitted["data"][0]["type"], "fused");
|
||||
assert_eq!(emitted["usage"]["prompt_tokens"], 432);
|
||||
assert_eq!(emitted["usage"]["completion_tokens"], 1);
|
||||
assert_eq!(emitted["usage"]["total_tokens"], 433);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod embedding;
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_chat_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_chat_stream_success",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_chat_sync_spec() {
|
||||
let spec = resolve_sync_spec("claude_chat_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_chat_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_chat_stream_spec() {
|
||||
let spec = resolve_stream_spec("claude_chat_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_chat_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_cli_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_cli_stream_success",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_cli_sync_spec() {
|
||||
let spec = resolve_sync_spec("claude_cli_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_cli_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_cli_stream_spec() {
|
||||
let spec = resolve_stream_spec("claude_cli_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub mod chat_spec;
|
||||
pub mod cli_spec;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
|
||||
use crate::formats::shared::family::LocalStandardSpec;
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_sync_spec(plan_kind).or_else(|| cli_spec::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_stream_spec(plan_kind).or_else(|| cli_spec::resolve_stream_spec(plan_kind))
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::{
|
||||
context::FormatContext,
|
||||
openai::shared::{
|
||||
map_openai_reasoning_effort_to_claude_output,
|
||||
map_openai_reasoning_effort_to_thinking_budget,
|
||||
},
|
||||
shared::model_directives::claude_model_uses_adaptive_effort,
|
||||
},
|
||||
protocol::canonical::{
|
||||
canonical_extension_object_mut, canonical_instructions_to_claude_system,
|
||||
canonical_messages_to_claude, canonical_openai_reasoning_effort,
|
||||
canonical_tool_choice_to_claude, canonical_tools_to_claude, claude_extensions,
|
||||
claude_generation_config, claude_messages_to_canonical, claude_parallel_tool_calls,
|
||||
claude_system_to_canonical_instructions, claude_thinking_to_canonical,
|
||||
claude_tool_choice_to_canonical, claude_tools_to_canonical,
|
||||
compact_canonical_claude_messages, insert_f64, mark_claude_messages_request_source,
|
||||
namespace_extension_object, CanonicalRequest,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
to_raw(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
ctx.upstream_is_stream,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut canonical = CanonicalRequest {
|
||||
model: request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
canonical.instructions = claude_system_to_canonical_instructions(request.get("system"))?;
|
||||
let system_text = canonical
|
||||
.instructions
|
||||
.iter()
|
||||
.map(|instruction| instruction.text.as_str())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !system_text.is_empty() {
|
||||
canonical.system = Some(system_text);
|
||||
}
|
||||
canonical.messages = claude_messages_to_canonical(request.get("messages"))?;
|
||||
canonical.generation = claude_generation_config(request);
|
||||
let (tools, builtin_tools, web_search_options) =
|
||||
claude_tools_to_canonical(request.get("tools"))?;
|
||||
canonical.tools = tools;
|
||||
canonical.tool_choice = claude_tool_choice_to_canonical(request.get("tool_choice"));
|
||||
canonical.parallel_tool_calls = claude_parallel_tool_calls(request.get("tool_choice"));
|
||||
canonical.metadata = request.get("metadata").cloned();
|
||||
canonical.thinking = claude_thinking_to_canonical(request);
|
||||
|
||||
canonical.extensions = claude_extensions(
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"system",
|
||||
"messages",
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"stop",
|
||||
"stop_sequences",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"metadata",
|
||||
"thinking",
|
||||
"output_config",
|
||||
],
|
||||
);
|
||||
mark_claude_messages_request_source(&mut canonical.extensions);
|
||||
if !builtin_tools.is_empty() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "claude")
|
||||
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
|
||||
}
|
||||
if let Some(web_search_options) = web_search_options {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "openai")
|
||||
.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
if let Some(output_config) = request.get("output_config").cloned() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "claude")
|
||||
.insert("output_config".to_string(), output_config);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
pub fn to_raw(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mut output = Map::new();
|
||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
output.insert(
|
||||
"messages".to_string(),
|
||||
Value::Array(compact_canonical_claude_messages(
|
||||
canonical_messages_to_claude(canonical)?,
|
||||
)),
|
||||
);
|
||||
output.insert(
|
||||
"max_tokens".to_string(),
|
||||
Value::from(canonical.generation.max_tokens.unwrap_or(8192)),
|
||||
);
|
||||
if let Some(system) = canonical_instructions_to_claude_system(&canonical.instructions) {
|
||||
output.insert("system".to_string(), system);
|
||||
} else if let Some(system) = canonical
|
||||
.system
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
output.insert("system".to_string(), Value::String(system.clone()));
|
||||
}
|
||||
if upstream_is_stream {
|
||||
output.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
insert_f64(&mut output, "temperature", canonical.generation.temperature);
|
||||
insert_f64(&mut output, "top_p", canonical.generation.top_p);
|
||||
if let Some(top_k) = canonical.generation.top_k {
|
||||
output.insert("top_k".to_string(), Value::from(top_k));
|
||||
}
|
||||
if let Some(stop_sequences) = &canonical.generation.stop_sequences {
|
||||
output.insert(
|
||||
"stop_sequences".to_string(),
|
||||
Value::Array(stop_sequences.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
}
|
||||
let tools = canonical_tools_to_claude(canonical);
|
||||
if !tools.is_empty() {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = canonical_tool_choice_to_claude(
|
||||
canonical.tool_choice.as_ref(),
|
||||
canonical.parallel_tool_calls,
|
||||
) {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
if let Some(metadata) = canonical.metadata.clone() {
|
||||
output.insert("metadata".to_string(), metadata);
|
||||
}
|
||||
if let Some(thinking) = canonical.thinking.as_ref() {
|
||||
let openai_effort = canonical_openai_reasoning_effort(thinking);
|
||||
let budget_tokens = thinking
|
||||
.budget_tokens
|
||||
.or_else(|| openai_effort.and_then(map_openai_reasoning_effort_to_thinking_budget));
|
||||
let uses_adaptive = claude_model_uses_adaptive_effort(mapped_model)
|
||||
|| claude_model_uses_adaptive_effort(canonical.model.as_str());
|
||||
if thinking.enabled || budget_tokens.is_some() {
|
||||
let thinking_config = if uses_adaptive {
|
||||
json!({"type": "adaptive"})
|
||||
} else {
|
||||
json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens.unwrap_or(1024),
|
||||
})
|
||||
};
|
||||
output.insert("thinking".to_string(), thinking_config);
|
||||
}
|
||||
if let Some(output_effort) =
|
||||
openai_effort.and_then(map_openai_reasoning_effort_to_claude_output)
|
||||
{
|
||||
output.insert(
|
||||
"output_config".to_string(),
|
||||
json!({
|
||||
"effort": output_effort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
"claude",
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_blocks_to_claude, canonical_extension_object_mut,
|
||||
canonical_stop_reason_to_claude, canonical_usage_to_claude,
|
||||
claude_content_to_canonical_blocks, claude_extensions, claude_stop_reason_to_canonical,
|
||||
claude_usage_to_canonical, namespace_extension_object, CanonicalResponse,
|
||||
CanonicalResponseOutput, CanonicalRole,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalResponse, _ctx: &FormatContext) -> Option<Value> {
|
||||
Some(to_raw(response))
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") || body.get("type").and_then(Value::as_str) == Some("error") {
|
||||
return None;
|
||||
}
|
||||
let content = claude_content_to_canonical_blocks(body.get("content"))?;
|
||||
let stop_reason =
|
||||
claude_stop_reason_to_canonical(body.get("stop_reason").and_then(Value::as_str));
|
||||
let mut extensions = claude_extensions(
|
||||
body,
|
||||
&[
|
||||
"id",
|
||||
"type",
|
||||
"role",
|
||||
"model",
|
||||
"content",
|
||||
"stop_reason",
|
||||
"stop_sequence",
|
||||
"usage",
|
||||
],
|
||||
);
|
||||
if let Some(raw_stop_reason) = body.get("stop_reason").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, "claude")
|
||||
.insert("raw_stop_reason".to_string(), raw_stop_reason);
|
||||
}
|
||||
if let Some(raw_stop_sequence) = body.get("stop_sequence").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, "claude")
|
||||
.insert("raw_stop_sequence".to_string(), raw_stop_sequence);
|
||||
}
|
||||
Some(CanonicalResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("msg-unknown")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
outputs: vec![CanonicalResponseOutput {
|
||||
index: 0,
|
||||
role: CanonicalRole::Assistant,
|
||||
content: content.clone(),
|
||||
stop_reason: stop_reason.clone(),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
content,
|
||||
stop_reason,
|
||||
usage: claude_usage_to_canonical(body.get("usage")),
|
||||
extensions,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse) -> Value {
|
||||
let mut content = canonical_blocks_to_claude(&canonical.content, CanonicalRole::Assistant)
|
||||
.unwrap_or_default();
|
||||
if content.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "text",
|
||||
"text": "",
|
||||
}));
|
||||
}
|
||||
let mut response = json!({
|
||||
"id": canonical.id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": canonical.model,
|
||||
"content": content,
|
||||
"stop_reason": canonical_stop_reason_to_claude(canonical.stop_reason.as_ref()),
|
||||
"usage": canonical.usage.as_ref().map(canonical_usage_to_claude).unwrap_or_else(|| json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
})),
|
||||
});
|
||||
if let Some(claude) = canonical
|
||||
.extensions
|
||||
.get("claude")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
if let Some(raw_stop_reason) = claude.get("raw_stop_reason").cloned() {
|
||||
response["stop_reason"] = raw_stop_reason;
|
||||
}
|
||||
if let Some(raw_stop_sequence) = claude.get("raw_stop_sequence").cloned() {
|
||||
response["stop_sequence"] = raw_stop_sequence;
|
||||
}
|
||||
}
|
||||
if let Some(object) = response.as_object_mut() {
|
||||
let mut extra = namespace_extension_object(&canonical.extensions, "claude", object);
|
||||
extra.remove("raw_stop_reason");
|
||||
extra.remove("raw_stop_sequence");
|
||||
object.extend(extra);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use super::{chat_spec, cli_spec, resolve_stream_spec, resolve_sync_spec};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
pub mod messages;
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FormatContext {
|
||||
pub mapped_model: Option<String>,
|
||||
pub request_path: Option<String>,
|
||||
pub upstream_is_stream: bool,
|
||||
pub report_context: Option<Value>,
|
||||
}
|
||||
|
||||
impl FormatContext {
|
||||
pub fn with_mapped_model(mut self, mapped_model: impl Into<String>) -> Self {
|
||||
self.mapped_model = Some(mapped_model.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_request_path(mut self, request_path: impl Into<String>) -> Self {
|
||||
self.request_path = Some(request_path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_upstream_stream(mut self, upstream_is_stream: bool) -> Self {
|
||||
self.upstream_is_stream = upstream_is_stream;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_report_context(mut self, report_context: Value) -> Self {
|
||||
self.report_context = Some(report_context);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn without_runtime_request_edits(&self) -> Self {
|
||||
Self {
|
||||
mapped_model: None,
|
||||
request_path: self.request_path.clone(),
|
||||
upstream_is_stream: false,
|
||||
report_context: self.report_context.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mapped_model_or<'a>(&'a self, fallback: &'a str) -> &'a str {
|
||||
self.mapped_model
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub(crate) fn report_context_value(&self) -> Value {
|
||||
self.report_context.clone().unwrap_or_else(|| {
|
||||
json!({
|
||||
"mapped_model": self.mapped_model,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ConversionFieldStatus {
|
||||
Native,
|
||||
Mapped,
|
||||
ExtensionPreserved,
|
||||
Unaudited,
|
||||
Unsupported,
|
||||
InvalidEnum,
|
||||
LossyBlocked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConversionFieldRecord {
|
||||
pub field: String,
|
||||
pub status: ConversionFieldStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl ConversionFieldRecord {
|
||||
pub fn new(
|
||||
field: impl Into<String>,
|
||||
status: ConversionFieldStatus,
|
||||
detail: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
field: field.into(),
|
||||
status,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConversionReport {
|
||||
pub source_format: String,
|
||||
pub target_format: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub fields: Vec<ConversionFieldRecord>,
|
||||
}
|
||||
|
||||
impl ConversionReport {
|
||||
pub fn new(source_format: impl Into<String>, target_format: impl Into<String>) -> Self {
|
||||
Self {
|
||||
source_format: source_format.into(),
|
||||
target_format: target_format.into(),
|
||||
fields: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(
|
||||
&mut self,
|
||||
field: impl Into<String>,
|
||||
status: ConversionFieldStatus,
|
||||
detail: Option<String>,
|
||||
) {
|
||||
self.fields
|
||||
.push(ConversionFieldRecord::new(field, status, detail));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Converted<T> {
|
||||
pub value: T,
|
||||
pub report: ConversionReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FormatError {
|
||||
UnsupportedFormat(String),
|
||||
RequestParseFailed {
|
||||
format: String,
|
||||
},
|
||||
RequestEmitFailed {
|
||||
format: String,
|
||||
},
|
||||
ResponseParseFailed {
|
||||
format: String,
|
||||
},
|
||||
ResponseEmitFailed {
|
||||
format: String,
|
||||
},
|
||||
UnsupportedField {
|
||||
format: String,
|
||||
field: String,
|
||||
reason: String,
|
||||
},
|
||||
UnauditedField {
|
||||
source_format: String,
|
||||
target_format: String,
|
||||
field: String,
|
||||
reason: String,
|
||||
},
|
||||
InvalidEnumValue {
|
||||
format: String,
|
||||
field: String,
|
||||
value: String,
|
||||
},
|
||||
LossyConversionBlocked {
|
||||
source_format: String,
|
||||
target_format: String,
|
||||
field: String,
|
||||
reason: String,
|
||||
},
|
||||
InvalidTargetField {
|
||||
format: String,
|
||||
field: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for FormatError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedFormat(format) => write!(f, "unsupported AI format: {format}"),
|
||||
Self::RequestParseFailed { format } => {
|
||||
write!(f, "failed to parse {format} request")
|
||||
}
|
||||
Self::RequestEmitFailed { format } => write!(f, "failed to emit {format} request"),
|
||||
Self::ResponseParseFailed { format } => {
|
||||
write!(f, "failed to parse {format} response")
|
||||
}
|
||||
Self::ResponseEmitFailed { format } => {
|
||||
write!(f, "failed to emit {format} response")
|
||||
}
|
||||
Self::UnsupportedField {
|
||||
format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
write!(f, "unsupported field {field} in {format}: {reason}")
|
||||
}
|
||||
Self::UnauditedField {
|
||||
source_format,
|
||||
target_format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"unaudited field {field} in {source_format} cannot be converted to {target_format}: {reason}"
|
||||
)
|
||||
}
|
||||
Self::InvalidEnumValue {
|
||||
format,
|
||||
field,
|
||||
value,
|
||||
} => {
|
||||
write!(f, "invalid enum value {value:?} for {format}.{field}")
|
||||
}
|
||||
Self::LossyConversionBlocked {
|
||||
source_format,
|
||||
target_format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"lossy conversion blocked from {source_format} to {target_format} at {field}: {reason}"
|
||||
)
|
||||
}
|
||||
Self::InvalidTargetField {
|
||||
format,
|
||||
field,
|
||||
reason,
|
||||
} => {
|
||||
write!(f, "invalid target field {field} for {format}: {reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for FormatError {}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
//! Pairwise response conversion helpers.
|
||||
//!
|
||||
//! These helpers keep the call sites readable while delegating wire-format
|
||||
//! parsing and emitting to `formats::<format>::response` through the registry's
|
||||
//! canonical IR path.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::{
|
||||
context::FormatContext,
|
||||
openai::responses::response::ensure_modern_openai_responses_response_fields, registry,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenAiResponsesResponseUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
pub fn convert_claude_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_gemini_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_claude_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
"claude:messages",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_gemini_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
"gemini:generate_content",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_responses_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
let target_format = if compact {
|
||||
"openai:responses:compact"
|
||||
} else {
|
||||
"openai:responses"
|
||||
};
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
target_format,
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_claude_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_gemini_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:responses",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_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_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
Vec::new(),
|
||||
function_calls,
|
||||
OpenAiResponsesResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_response_with_reasoning(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
usage: OpenAiResponsesResponseUsage,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
usage,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_response_with_content(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
content: Vec<Value>,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
usage: OpenAiResponsesResponseUsage,
|
||||
) -> 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);
|
||||
let mut response = 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,
|
||||
}
|
||||
});
|
||||
if let Some(response_object) = response.as_object_mut() {
|
||||
ensure_modern_openai_responses_response_fields(response_object);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn response_context(report_context: &Value) -> FormatContext {
|
||||
let mut context = FormatContext::default().with_report_context(report_context.clone());
|
||||
if let Some(model) = report_context
|
||||
.get("mapped_model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
context = context.with_mapped_model(model);
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
convert_claude_chat_response_to_openai_chat,
|
||||
convert_openai_chat_response_to_openai_responses,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn pairwise_response_helper_routes_through_registry() {
|
||||
let body = json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"model": "gpt-source",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hello"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_response_to_openai_responses(&body, &json!({}), false)
|
||||
.expect("responses response");
|
||||
|
||||
assert_eq!(converted["object"], "response");
|
||||
assert_eq!(converted["output"][0]["type"], "message");
|
||||
assert_eq!(converted["output_text"], "hello");
|
||||
assert!(converted["created_at"].as_i64().is_some());
|
||||
assert!(converted["completed_at"].as_i64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_responses_response_builder_emits_modern_fields() {
|
||||
let response = super::build_openai_responses_response(
|
||||
"resp_manual_123",
|
||||
"gpt-5",
|
||||
"Hello manual",
|
||||
Vec::new(),
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
);
|
||||
|
||||
assert_eq!(response["output_text"], "Hello manual");
|
||||
assert!(response["created_at"].as_i64().is_some());
|
||||
assert!(response["completed_at"].as_i64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairwise_response_helper_uses_report_context_model_fallback() {
|
||||
let body = json!({
|
||||
"id": "msg-test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 2}
|
||||
});
|
||||
|
||||
let converted = convert_claude_chat_response_to_openai_chat(
|
||||
&body,
|
||||
&json!({"mapped_model": "gpt-target"}),
|
||||
)
|
||||
.expect("openai chat response");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-target");
|
||||
assert_eq!(converted["choices"][0]["message"]["content"], "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod request;
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::formats::openai::embedding::request::mapped_embedding_model;
|
||||
use crate::protocol::canonical::{namespace_extension_object, CanonicalRequest};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
crate::formats::openai::embedding::request::from_namespace(body, "doubao")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
let embedding = request.embedding.as_ref()?;
|
||||
let items = embedding.input.as_string_items()?;
|
||||
if items.is_empty() || items.iter().any(|value| value.trim().is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_embedding_model(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
)),
|
||||
);
|
||||
output.insert(
|
||||
"input".to_string(),
|
||||
Value::Array(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|text| Value::String(text.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
if let Some(dimensions) = embedding.dimensions {
|
||||
output.insert("dimensions".to_string(), Value::from(dimensions));
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&embedding.extensions,
|
||||
"doubao",
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod embedding;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -0,0 +1,310 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
|
||||
use crate::protocol::canonical::{
|
||||
CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalRequest,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
if let Some(requests) = request.get("requests").and_then(Value::as_array) {
|
||||
return from_batch_requests(request, requests);
|
||||
}
|
||||
|
||||
let item = parse_gemini_embedding_request_object(request)?;
|
||||
Some(CanonicalRequest {
|
||||
model: item.model,
|
||||
embedding: Some(CanonicalEmbeddingRequest {
|
||||
input: CanonicalEmbeddingInput::String(item.text),
|
||||
encoding_format: None,
|
||||
dimensions: item.dimensions,
|
||||
task: item.task,
|
||||
user: None,
|
||||
parameters: None,
|
||||
extensions: namespace_extensions(
|
||||
"gemini",
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"content",
|
||||
"outputDimensionality",
|
||||
"output_dimensionality",
|
||||
"taskType",
|
||||
"task_type",
|
||||
],
|
||||
),
|
||||
}),
|
||||
..CanonicalRequest::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn from_batch_requests(
|
||||
request: &Map<String, Value>,
|
||||
requests: &[Value],
|
||||
) -> Option<CanonicalRequest> {
|
||||
if requests.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let items = requests
|
||||
.iter()
|
||||
.map(|request| parse_gemini_embedding_request_object(request.as_object()?))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let first = items.first()?;
|
||||
if items.iter().any(|item| {
|
||||
item.model != first.model || item.dimensions != first.dimensions || item.task != first.task
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let model = first.model.clone();
|
||||
let dimensions = first.dimensions;
|
||||
let task = first.task.clone();
|
||||
Some(CanonicalRequest {
|
||||
model,
|
||||
embedding: Some(CanonicalEmbeddingRequest {
|
||||
input: CanonicalEmbeddingInput::StringArray(
|
||||
items.into_iter().map(|item| item.text).collect(),
|
||||
),
|
||||
encoding_format: None,
|
||||
dimensions,
|
||||
task,
|
||||
user: None,
|
||||
parameters: None,
|
||||
extensions: namespace_extensions("gemini", request, &["requests"]),
|
||||
}),
|
||||
..CanonicalRequest::default()
|
||||
})
|
||||
}
|
||||
|
||||
struct ParsedGeminiEmbeddingRequest {
|
||||
model: String,
|
||||
text: String,
|
||||
dimensions: Option<u64>,
|
||||
task: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_gemini_embedding_request_object(
|
||||
request: &Map<String, Value>,
|
||||
) -> Option<ParsedGeminiEmbeddingRequest> {
|
||||
let model = request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let parts = request
|
||||
.get("content")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|content| content.get("parts"))
|
||||
.and_then(Value::as_array)?;
|
||||
let text = parts
|
||||
.iter()
|
||||
.map(|part| {
|
||||
part.as_object()?
|
||||
.get("text")?
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
.join("\n");
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ParsedGeminiEmbeddingRequest {
|
||||
model,
|
||||
text,
|
||||
dimensions: request
|
||||
.get("outputDimensionality")
|
||||
.or_else(|| request.get("output_dimensionality"))
|
||||
.and_then(Value::as_u64),
|
||||
task: request
|
||||
.get("taskType")
|
||||
.or_else(|| request.get("task_type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
let embedding = request.embedding.as_ref()?;
|
||||
let items = embedding.input.as_string_items()?;
|
||||
if items.is_empty() || items.iter().any(|value| value.trim().is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let model = mapped_embedding_model(request, ctx.mapped_model_or(request.model.as_str()));
|
||||
if items.len() == 1 {
|
||||
return Some(Value::Object(gemini_embedding_request_object(
|
||||
&model, items[0], embedding,
|
||||
)));
|
||||
}
|
||||
let model_resource = gemini_embedding_model_resource_name(&model);
|
||||
let requests = items
|
||||
.into_iter()
|
||||
.map(|text| {
|
||||
Value::Object(gemini_embedding_request_object(
|
||||
&model_resource,
|
||||
text,
|
||||
embedding,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Some(json!({ "requests": requests }))
|
||||
}
|
||||
|
||||
fn gemini_embedding_request_object(
|
||||
model: &str,
|
||||
text: &str,
|
||||
embedding: &CanonicalEmbeddingRequest,
|
||||
) -> Map<String, Value> {
|
||||
let mut object = Map::new();
|
||||
object.insert("model".to_string(), Value::String(model.to_string()));
|
||||
object.insert(
|
||||
"content".to_string(),
|
||||
json!({
|
||||
"parts": [{"text": text}]
|
||||
}),
|
||||
);
|
||||
insert_gemini_embedding_options(&mut object, embedding);
|
||||
object
|
||||
}
|
||||
|
||||
fn gemini_embedding_model_resource_name(model: &str) -> String {
|
||||
let trimmed = model.trim();
|
||||
if trimmed.starts_with("models/") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("models/{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_gemini_embedding_options(
|
||||
object: &mut Map<String, Value>,
|
||||
embedding: &CanonicalEmbeddingRequest,
|
||||
) {
|
||||
if let Some(dimensions) = embedding.dimensions {
|
||||
object.insert("outputDimensionality".to_string(), Value::from(dimensions));
|
||||
}
|
||||
if let Some(task_type) = embedding
|
||||
.task
|
||||
.as_deref()
|
||||
.and_then(normalize_gemini_embedding_task_type)
|
||||
{
|
||||
object.insert("taskType".to_string(), Value::String(task_type));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_gemini_embedding_task_type(value: &str) -> Option<String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let key = normalized.replace(['-', ' '], "_").to_ascii_uppercase();
|
||||
let task_type = match key.as_str() {
|
||||
"QUERY" | "RETRIEVAL_QUERY" => "RETRIEVAL_QUERY",
|
||||
"DOCUMENT" | "RETRIEVAL_DOCUMENT" => "RETRIEVAL_DOCUMENT",
|
||||
"TEXT_MATCHING" | "SEMANTIC_SIMILARITY" => "SEMANTIC_SIMILARITY",
|
||||
"CLASSIFICATION" => "CLASSIFICATION",
|
||||
"CLUSTERING" => "CLUSTERING",
|
||||
"QUESTION_ANSWERING" => "QUESTION_ANSWERING",
|
||||
"FACT_VERIFICATION" => "FACT_VERIFICATION",
|
||||
"CODE_RETRIEVAL_QUERY" => "CODE_RETRIEVAL_QUERY",
|
||||
_ => key.as_str(),
|
||||
};
|
||||
Some(task_type.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::to;
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::{
|
||||
CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalRequest,
|
||||
};
|
||||
|
||||
fn canonical_embedding(input: CanonicalEmbeddingInput) -> CanonicalRequest {
|
||||
CanonicalRequest {
|
||||
model: "text-embedding-3-small".to_string(),
|
||||
embedding: Some(CanonicalEmbeddingRequest {
|
||||
input,
|
||||
encoding_format: None,
|
||||
dimensions: None,
|
||||
task: None,
|
||||
user: None,
|
||||
parameters: None,
|
||||
extensions: BTreeMap::new(),
|
||||
}),
|
||||
..CanonicalRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_string_array_item_uses_single_embed_content_body() {
|
||||
let request = canonical_embedding(CanonicalEmbeddingInput::StringArray(vec![
|
||||
"hello".to_string()
|
||||
]));
|
||||
let body = to(
|
||||
&request,
|
||||
&FormatContext::default().with_mapped_model("gemini-embedding-2-preview"),
|
||||
)
|
||||
.expect("gemini embedding request");
|
||||
|
||||
assert_eq!(body["model"], "gemini-embedding-2-preview");
|
||||
assert_eq!(body["content"]["parts"][0]["text"], "hello");
|
||||
assert!(body.get("requests").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_string_items_use_gemini_batch_request_body() {
|
||||
let request = canonical_embedding(CanonicalEmbeddingInput::StringArray(vec![
|
||||
"alpha".to_string(),
|
||||
"beta".to_string(),
|
||||
]));
|
||||
let body = to(
|
||||
&request,
|
||||
&FormatContext::default().with_mapped_model("gemini-embedding-2-preview"),
|
||||
)
|
||||
.expect("gemini embedding request");
|
||||
|
||||
assert!(body.get("model").is_none());
|
||||
assert_eq!(body["requests"].as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(
|
||||
body["requests"][0]["model"],
|
||||
"models/gemini-embedding-2-preview"
|
||||
);
|
||||
assert_eq!(body["requests"][0]["content"]["parts"][0]["text"], "alpha");
|
||||
assert_eq!(
|
||||
body["requests"][1]["model"],
|
||||
"models/gemini-embedding-2-preview"
|
||||
);
|
||||
assert_eq!(body["requests"][1]["content"]["parts"][0]["text"], "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_embedding_options_are_preserved_without_defaults() {
|
||||
let mut request = canonical_embedding(CanonicalEmbeddingInput::String("query".to_string()));
|
||||
let embedding = request.embedding.as_mut().expect("embedding request");
|
||||
embedding.dimensions = Some(768);
|
||||
embedding.task = Some("retrieval_query".to_string());
|
||||
|
||||
let body = to(
|
||||
&request,
|
||||
&FormatContext::default().with_mapped_model("gemini-embedding-2-preview"),
|
||||
)
|
||||
.expect("gemini embedding request");
|
||||
|
||||
assert_eq!(body["outputDimensionality"], json!(768));
|
||||
assert_eq!(body["taskType"], "RETRIEVAL_QUERY");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::openai::embedding::request::namespace_extensions;
|
||||
use crate::protocol::canonical::{
|
||||
gemini_usage_to_canonical, CanonicalEmbedding, CanonicalEmbeddingResponse,
|
||||
};
|
||||
|
||||
pub fn from(body_json: &Value) -> Option<CanonicalEmbeddingResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let embeddings = if let Some(raw_embeddings) = vertex_predict_embeddings(body) {
|
||||
raw_embeddings
|
||||
} else if let Some(values) = body
|
||||
.get("embedding")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|embedding| embedding.get("values"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
vec![CanonicalEmbedding {
|
||||
index: 0,
|
||||
embedding: embedding_values(values)?,
|
||||
extensions: Default::default(),
|
||||
}]
|
||||
} else {
|
||||
let raw_embeddings = body.get("embeddings")?.as_array()?;
|
||||
raw_embeddings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let item_object = item.as_object()?;
|
||||
let values = item_object.get("values")?.as_array()?;
|
||||
Some(CanonicalEmbedding {
|
||||
index,
|
||||
embedding: embedding_values(values)?,
|
||||
extensions: namespace_extensions("gemini", item_object, &["values"]),
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
};
|
||||
if embeddings.is_empty()
|
||||
|| embeddings
|
||||
.iter()
|
||||
.any(|embedding| embedding.embedding.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CanonicalEmbeddingResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.or_else(|| body.get("responseId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("embd-gemini-unknown")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.or_else(|| body.get("modelVersion"))
|
||||
.or_else(|| body.get("deployedModelId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
embeddings,
|
||||
usage: gemini_usage_to_canonical(body.get("usageMetadata")),
|
||||
extensions: namespace_extensions(
|
||||
"gemini",
|
||||
body,
|
||||
&[
|
||||
"id",
|
||||
"responseId",
|
||||
"model",
|
||||
"modelVersion",
|
||||
"deployedModelId",
|
||||
"embedding",
|
||||
"embeddings",
|
||||
"predictions",
|
||||
"usageMetadata",
|
||||
],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn vertex_predict_embeddings(
|
||||
body: &serde_json::Map<String, Value>,
|
||||
) -> Option<Vec<CanonicalEmbedding>> {
|
||||
let predictions = body.get("predictions")?.as_array()?;
|
||||
predictions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let item_object = item.as_object()?;
|
||||
let embedding_object = item_object.get("embeddings")?.as_object()?;
|
||||
let values = embedding_object.get("values")?.as_array()?;
|
||||
Some(CanonicalEmbedding {
|
||||
index,
|
||||
embedding: embedding_values(values)?,
|
||||
extensions: namespace_extensions("vertex", item_object, &["embeddings"]),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn embedding_values(values: &[Value]) -> Option<Vec<f64>> {
|
||||
values.iter().map(Value::as_f64).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::from;
|
||||
|
||||
#[test]
|
||||
fn parses_gemini_single_embedding_response() {
|
||||
let body = json!({
|
||||
"embedding": {"values": [0.1, 0.2, 0.3]},
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 4,
|
||||
"totalTokenCount": 4
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = from(&body).expect("response should parse");
|
||||
|
||||
assert_eq!(parsed.model, "unknown");
|
||||
assert_eq!(parsed.embeddings[0].embedding, vec![0.1, 0.2, 0.3]);
|
||||
let usage = parsed.usage.expect("usage should parse");
|
||||
assert_eq!(usage.input_tokens, 4);
|
||||
assert_eq!(usage.total_tokens, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_vertex_predict_embedding_response() {
|
||||
let body = json!({
|
||||
"predictions": [
|
||||
{
|
||||
"embeddings": {
|
||||
"values": [0.1, 0.2, 0.3]
|
||||
}
|
||||
},
|
||||
{
|
||||
"embeddings": {
|
||||
"values": [0.4, 0.5, 0.6]
|
||||
}
|
||||
}
|
||||
],
|
||||
"deployedModelId": "gemini-embedding-2"
|
||||
});
|
||||
|
||||
let parsed = from(&body).expect("response should parse");
|
||||
|
||||
assert_eq!(parsed.model, "gemini-embedding-2");
|
||||
assert_eq!(parsed.embeddings[0].embedding, vec![0.1, 0.2, 0.3]);
|
||||
assert_eq!(parsed.embeddings[1].embedding, vec![0.4, 0.5, 0.6]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod spec;
|
||||
@@ -0,0 +1,69 @@
|
||||
use crate::contracts::{
|
||||
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
|
||||
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalGeminiFilesSpec {
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: Option<&'static str>,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_LIST_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_LIST_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_GET_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_GET_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_DELETE_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_delete_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_DOWNLOAD_PLAN_KIND,
|
||||
report_kind: None,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_sync_gemini_files_specs() {
|
||||
let spec = resolve_sync_spec("gemini_files_upload").expect("spec");
|
||||
assert_eq!(spec.decision_kind, "gemini_files_upload");
|
||||
assert_eq!(spec.report_kind, Some("gemini_files_store_mapping"));
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_stream_gemini_files_spec() {
|
||||
let spec = resolve_stream_spec("gemini_files_download").expect("spec");
|
||||
assert_eq!(spec.decision_kind, "gemini_files_download");
|
||||
assert_eq!(spec.report_kind, None);
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_chat_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_chat_stream_success",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_chat_sync_spec() {
|
||||
let spec = resolve_sync_spec("gemini_chat_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_chat_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_chat_stream_spec() {
|
||||
let spec = resolve_stream_spec("gemini_chat_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_chat_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_cli_sync_finalize",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_cli_stream_success",
|
||||
family: LocalStandardSourceFamily::Gemini,
|
||||
mode: LocalStandardSourceMode::Cli,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_cli_sync_spec() {
|
||||
let spec = resolve_sync_spec("gemini_cli_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_cli_stream_spec() {
|
||||
let spec = resolve_stream_spec("gemini_cli_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub mod chat_spec;
|
||||
pub mod cli_spec;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
|
||||
use crate::formats::shared::family::LocalStandardSpec;
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_sync_spec(plan_kind).or_else(|| cli_spec::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat_spec::resolve_stream_spec(plan_kind).or_else(|| cli_spec::resolve_stream_spec(plan_kind))
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::{
|
||||
context::FormatContext,
|
||||
openai::shared::{
|
||||
map_openai_reasoning_effort_to_gemini_budget,
|
||||
map_thinking_budget_to_openai_reasoning_effort,
|
||||
},
|
||||
shared::model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
|
||||
},
|
||||
protocol::canonical::{
|
||||
apply_gemini_request_extensions, canonical_extension_object_mut,
|
||||
canonical_openai_reasoning_effort, extract_gemini_model_from_path,
|
||||
gemini_contents_to_canonical_messages, gemini_extensions, gemini_generation_config,
|
||||
gemini_generation_config_extra, gemini_google_search_grounding,
|
||||
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
|
||||
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
|
||||
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
|
||||
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_raw(body, ctx.request_path.as_deref().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
to_raw(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
ctx.upstream_is_stream,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut canonical = CanonicalRequest {
|
||||
model: request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| extract_gemini_model_from_path(request_path))
|
||||
.unwrap_or_default(),
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
canonical.instructions = gemini_system_to_canonical_instructions(
|
||||
request
|
||||
.get("systemInstruction")
|
||||
.or_else(|| request.get("system_instruction")),
|
||||
)?;
|
||||
let system_text = canonical
|
||||
.instructions
|
||||
.iter()
|
||||
.map(|instruction| instruction.text.as_str())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !system_text.is_empty() {
|
||||
canonical.system = Some(system_text);
|
||||
}
|
||||
canonical.messages = gemini_contents_to_canonical_messages(request.get("contents"))?;
|
||||
canonical.generation = gemini_generation_config(
|
||||
request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config")),
|
||||
);
|
||||
canonical.thinking = gemini_thinking_to_canonical(
|
||||
request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config")),
|
||||
);
|
||||
canonical.response_format = gemini_response_format_to_canonical(
|
||||
request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config")),
|
||||
);
|
||||
let (tools, builtin_tools, web_search_options, raw_tools, google_search_grounding) =
|
||||
gemini_tools_to_canonical(request.get("tools"))?;
|
||||
canonical.tools = tools;
|
||||
canonical.tool_choice = gemini_tool_choice_to_canonical(
|
||||
request
|
||||
.get("toolConfig")
|
||||
.or_else(|| request.get("tool_config")),
|
||||
);
|
||||
|
||||
canonical.extensions = gemini_extensions(
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
"contents",
|
||||
"generationConfig",
|
||||
"generation_config",
|
||||
"tools",
|
||||
"toolConfig",
|
||||
"tool_config",
|
||||
"safetySettings",
|
||||
"safety_settings",
|
||||
"cachedContent",
|
||||
"cached_content",
|
||||
"stream",
|
||||
],
|
||||
);
|
||||
if let Some(generation_config) = request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config"))
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
let gemini_extension = canonical_extension_object_mut(&mut canonical.extensions, "gemini");
|
||||
if let Some(thinking_config) =
|
||||
gemini_value_by_case(generation_config, "thinkingConfig", "thinking_config").cloned()
|
||||
{
|
||||
gemini_extension.insert("thinking_config".to_string(), thinking_config);
|
||||
}
|
||||
if let Some(response_modalities) = gemini_value_by_case(
|
||||
generation_config,
|
||||
"responseModalities",
|
||||
"response_modalities",
|
||||
)
|
||||
.cloned()
|
||||
{
|
||||
gemini_extension.insert("response_modalities".to_string(), response_modalities);
|
||||
}
|
||||
let extra = gemini_generation_config_extra(generation_config);
|
||||
if !extra.is_empty() {
|
||||
gemini_extension.insert("generation_config_extra".to_string(), Value::Object(extra));
|
||||
}
|
||||
}
|
||||
if let Some(value) = request
|
||||
.get("safetySettings")
|
||||
.or_else(|| request.get("safety_settings"))
|
||||
.cloned()
|
||||
{
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("safety_settings".to_string(), value);
|
||||
}
|
||||
if let Some(value) = request
|
||||
.get("cachedContent")
|
||||
.or_else(|| request.get("cached_content"))
|
||||
.cloned()
|
||||
{
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("cached_content".to_string(), value);
|
||||
}
|
||||
if let Some(raw_tools) = raw_tools {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("raw_tools".to_string(), raw_tools);
|
||||
}
|
||||
if !builtin_tools.is_empty() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
|
||||
}
|
||||
if let Some(google_search_grounding) = google_search_grounding {
|
||||
let gemini_extension = canonical_extension_object_mut(&mut canonical.extensions, "gemini");
|
||||
gemini_extension.insert(
|
||||
"grounding".to_string(),
|
||||
json!({ "google_search": google_search_grounding }),
|
||||
);
|
||||
}
|
||||
if let Some(tool_config) = request
|
||||
.get("toolConfig")
|
||||
.or_else(|| request.get("tool_config"))
|
||||
.cloned()
|
||||
{
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("raw_tool_config".to_string(), tool_config);
|
||||
}
|
||||
if let Some(web_search_options) = web_search_options {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "openai")
|
||||
.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
pub fn to_raw(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
|
||||
apply_gemini_request_extensions(&mut output, &canonical.extensions)?;
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn canonical_to_gemini_request_body(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
_upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let mut output = Map::new();
|
||||
if !mapped_model.trim().is_empty() {
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_model.trim().to_string()),
|
||||
);
|
||||
}
|
||||
output.insert(
|
||||
"contents".to_string(),
|
||||
Value::Array(compact_gemini_contents(
|
||||
canonical_messages_to_gemini_contents(&canonical.messages)?,
|
||||
)),
|
||||
);
|
||||
|
||||
if let Some(system_instruction) = canonical_system_instruction(canonical) {
|
||||
output.insert("systemInstruction".to_string(), system_instruction);
|
||||
}
|
||||
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical, mapped_model)
|
||||
{
|
||||
output.insert("generationConfig".to_string(), generation_config);
|
||||
}
|
||||
if let Some(tools) = canonical_tools_to_gemini(canonical) {
|
||||
output.insert("tools".to_string(), tools);
|
||||
}
|
||||
if let Some(tool_config) = canonical_tool_choice_to_gemini(canonical.tool_choice.as_ref()) {
|
||||
output.insert("toolConfig".to_string(), tool_config);
|
||||
}
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn canonical_system_instruction(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
let text = canonical
|
||||
.instructions
|
||||
.iter()
|
||||
.map(|instruction| instruction.text.as_str())
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
let text = if text.trim().is_empty() {
|
||||
canonical.system.as_deref().unwrap_or_default().to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
(!text.trim().is_empty()).then(|| json!({ "parts": [{ "text": text }] }))
|
||||
}
|
||||
|
||||
fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option<Vec<Value>> {
|
||||
let mut contents = Vec::new();
|
||||
let mut tool_name_by_id = BTreeMap::new();
|
||||
for message in messages {
|
||||
let role = match message.role {
|
||||
CanonicalRole::Assistant => "model",
|
||||
CanonicalRole::System | CanonicalRole::Developer => continue,
|
||||
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
|
||||
};
|
||||
let parts = canonical_blocks_to_gemini_parts(&message.content, &mut tool_name_by_id)?;
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
contents.push(json!({
|
||||
"role": role,
|
||||
"parts": parts,
|
||||
}));
|
||||
}
|
||||
Some(contents)
|
||||
}
|
||||
|
||||
fn canonical_blocks_to_gemini_parts(
|
||||
blocks: &[CanonicalContentBlock],
|
||||
tool_name_by_id: &mut BTreeMap<String, String>,
|
||||
) -> Option<Vec<Value>> {
|
||||
let mut parts = Vec::new();
|
||||
for block in blocks {
|
||||
if let Some(part) = canonical_block_to_gemini_part(block, tool_name_by_id)? {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
fn canonical_block_to_gemini_part(
|
||||
block: &CanonicalContentBlock,
|
||||
tool_name_by_id: &mut BTreeMap<String, String>,
|
||||
) -> Option<Option<Value>> {
|
||||
match block {
|
||||
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
|
||||
CanonicalContentBlock::Thinking {
|
||||
text, signature, ..
|
||||
} => {
|
||||
if text.trim().is_empty() {
|
||||
return Some(None);
|
||||
}
|
||||
let mut part = Map::new();
|
||||
part.insert("text".to_string(), Value::String(text.clone()));
|
||||
part.insert("thought".to_string(), Value::Bool(true));
|
||||
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
|
||||
part.insert(
|
||||
"thoughtSignature".to_string(),
|
||||
Value::String(signature.clone()),
|
||||
);
|
||||
}
|
||||
Some(Some(Value::Object(part)))
|
||||
}
|
||||
CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("image/png"),
|
||||
data.as_deref(),
|
||||
url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::File {
|
||||
data,
|
||||
file_url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("application/octet-stream"),
|
||||
data.as_deref(),
|
||||
file_url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::Audio {
|
||||
data, media_type, ..
|
||||
} => Some(data.as_ref().map(|data| {
|
||||
json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
})),
|
||||
CanonicalContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_name_by_id.insert(id.clone(), name.clone());
|
||||
Some(Some(json!({
|
||||
"functionCall": {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"args": gemini_function_args(input),
|
||||
}
|
||||
})))
|
||||
}
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
name,
|
||||
output,
|
||||
content_text,
|
||||
..
|
||||
} => Some(Some(json!({
|
||||
"functionResponse": {
|
||||
"id": tool_use_id,
|
||||
"name": name.clone()
|
||||
.or_else(|| tool_name_by_id.get(tool_use_id).cloned())
|
||||
.unwrap_or_else(|| tool_use_id.clone()),
|
||||
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
|
||||
}
|
||||
}))),
|
||||
CanonicalContentBlock::Unknown { .. } => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_media_to_gemini_part(
|
||||
media_type: &str,
|
||||
data: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> Value {
|
||||
if let Some(data) = data.filter(|value| !value.is_empty()) {
|
||||
return json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type,
|
||||
"data": data,
|
||||
}
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"fileData": {
|
||||
"mimeType": media_type,
|
||||
"fileUri": url.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_generation_config_to_gemini(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
) -> Option<Value> {
|
||||
let mut generation_config = Map::new();
|
||||
if let Some(value) = canonical.generation.max_tokens {
|
||||
generation_config.insert("maxOutputTokens".to_string(), Value::from(value));
|
||||
}
|
||||
insert_f64(
|
||||
&mut generation_config,
|
||||
"temperature",
|
||||
canonical.generation.temperature,
|
||||
);
|
||||
insert_f64(&mut generation_config, "topP", canonical.generation.top_p);
|
||||
if let Some(value) = canonical.generation.top_k {
|
||||
generation_config.insert("topK".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = canonical.generation.n.filter(|value| *value > 1) {
|
||||
generation_config.insert("candidateCount".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = canonical.generation.seed {
|
||||
generation_config.insert("seed".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(stop_sequences) = &canonical.generation.stop_sequences {
|
||||
generation_config.insert(
|
||||
"stopSequences".to_string(),
|
||||
Value::Array(stop_sequences.iter().cloned().map(Value::String).collect()),
|
||||
);
|
||||
}
|
||||
if let Some(response_format) = &canonical.response_format {
|
||||
apply_response_format_to_gemini_generation_config(&mut generation_config, response_format);
|
||||
}
|
||||
if let Some(thinking_config) = canonical.thinking.as_ref().and_then(|thinking| {
|
||||
thinking
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(|value| value.get("thinking_config"))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
let effort = canonical_openai_reasoning_effort(thinking);
|
||||
gemini_thinking_config_from_reasoning(mapped_model, effort, thinking.budget_tokens)
|
||||
})
|
||||
}) {
|
||||
generation_config.insert("thinkingConfig".to_string(), thinking_config);
|
||||
}
|
||||
(!generation_config.is_empty()).then_some(Value::Object(generation_config))
|
||||
}
|
||||
|
||||
fn gemini_thinking_config_from_reasoning(
|
||||
mapped_model: &str,
|
||||
effort: Option<&str>,
|
||||
budget_tokens: Option<u64>,
|
||||
) -> Option<Value> {
|
||||
if gemini_model_uses_thinking_level(mapped_model) {
|
||||
let level = effort
|
||||
.and_then(ReasoningEffort::parse)
|
||||
.or_else(|| {
|
||||
budget_tokens
|
||||
.map(map_thinking_budget_to_openai_reasoning_effort)
|
||||
.and_then(ReasoningEffort::parse)
|
||||
})
|
||||
.map(ReasoningEffort::as_gemini_level_value)?;
|
||||
return Some(json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingLevel": level,
|
||||
}));
|
||||
}
|
||||
|
||||
let budget =
|
||||
budget_tokens.or_else(|| effort.and_then(map_openai_reasoning_effort_to_gemini_budget))?;
|
||||
Some(json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": budget,
|
||||
}))
|
||||
}
|
||||
|
||||
fn apply_response_format_to_gemini_generation_config(
|
||||
generation_config: &mut Map<String, Value>,
|
||||
response_format: &CanonicalResponseFormat,
|
||||
) {
|
||||
match response_format.format_type.as_str() {
|
||||
"json_schema" => {
|
||||
generation_config.insert(
|
||||
"responseMimeType".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
);
|
||||
if let Some(schema) = response_format
|
||||
.json_schema
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("schema"))
|
||||
.cloned()
|
||||
.or_else(|| response_format.json_schema.clone())
|
||||
{
|
||||
let mut schema = schema;
|
||||
clean_gemini_schema(&mut schema);
|
||||
generation_config.insert("responseSchema".to_string(), schema);
|
||||
}
|
||||
}
|
||||
"json_object" => {
|
||||
generation_config.insert(
|
||||
"responseMimeType".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
let mut declarations = Vec::new();
|
||||
let mut tools = Vec::new();
|
||||
let mut google_search = canonical
|
||||
.extensions
|
||||
.get("openai")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|value| value.contains_key("web_search_options"));
|
||||
let mut google_search_payload = canonical_google_search_output_payload(canonical);
|
||||
if google_search_payload.is_some() {
|
||||
google_search = true;
|
||||
}
|
||||
let mut code_execution = false;
|
||||
let mut url_context = false;
|
||||
|
||||
for tool in &canonical.tools {
|
||||
match normalize_gemini_builtin_tool_name(&tool.name) {
|
||||
Some("googleSearch") => {
|
||||
google_search = true;
|
||||
continue;
|
||||
}
|
||||
Some("codeExecution") => {
|
||||
code_execution = true;
|
||||
continue;
|
||||
}
|
||||
Some("urlContext") => {
|
||||
url_context = true;
|
||||
continue;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => {}
|
||||
}
|
||||
if tool
|
||||
.extensions
|
||||
.get("openai_responses")
|
||||
.or_else(|| {
|
||||
tool.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(|value| value.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tool_type| tool_type.starts_with("web_search"))
|
||||
{
|
||||
google_search = true;
|
||||
continue;
|
||||
}
|
||||
declarations.push(canonical_tool_to_gemini_declaration(tool));
|
||||
}
|
||||
let mut emitted_google_search = false;
|
||||
let mut emitted_code_execution = false;
|
||||
let mut emitted_url_context = false;
|
||||
if let Some(builtin_tools) = canonical
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("builtin_tools"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
for builtin_tool in builtin_tools {
|
||||
let Some(tool_object) = builtin_tool.as_object() else {
|
||||
tools.push(builtin_tool.clone());
|
||||
continue;
|
||||
};
|
||||
let mut emitted_builtin_portion = false;
|
||||
if let Some(grounding) = gemini_google_search_grounding(tool_object) {
|
||||
google_search = true;
|
||||
if google_search_payload.is_none() {
|
||||
google_search_payload = Some(grounding.output_payload);
|
||||
}
|
||||
if !emitted_google_search {
|
||||
tools.push(json!({
|
||||
"googleSearch": google_search_payload.clone().unwrap_or_else(|| json!({}))
|
||||
}));
|
||||
emitted_google_search = true;
|
||||
}
|
||||
emitted_builtin_portion = true;
|
||||
}
|
||||
if let Some(tool) =
|
||||
gemini_builtin_tool_by_case(tool_object, "codeExecution", "code_execution")
|
||||
{
|
||||
if !emitted_code_execution {
|
||||
tools.push(tool);
|
||||
emitted_code_execution = true;
|
||||
}
|
||||
emitted_builtin_portion = true;
|
||||
}
|
||||
if let Some(tool) =
|
||||
gemini_builtin_tool_by_case(tool_object, "urlContext", "url_context")
|
||||
{
|
||||
if !emitted_url_context {
|
||||
tools.push(tool);
|
||||
emitted_url_context = true;
|
||||
}
|
||||
emitted_builtin_portion = true;
|
||||
}
|
||||
if let Some(tool) = gemini_unhandled_builtin_tool_portion(tool_object) {
|
||||
tools.push(tool);
|
||||
} else if !emitted_builtin_portion {
|
||||
tools.push(builtin_tool.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if code_execution && !emitted_code_execution {
|
||||
tools.push(json!({ "codeExecution": {} }));
|
||||
}
|
||||
if google_search && !emitted_google_search {
|
||||
tools.push(json!({
|
||||
"googleSearch": google_search_payload.unwrap_or_else(|| json!({}))
|
||||
}));
|
||||
}
|
||||
if url_context && !emitted_url_context {
|
||||
tools.push(json!({ "urlContext": {} }));
|
||||
}
|
||||
if !declarations.is_empty() {
|
||||
tools.push(json!({ "functionDeclarations": declarations }));
|
||||
}
|
||||
(!tools.is_empty()).then_some(Value::Array(tools))
|
||||
}
|
||||
|
||||
fn canonical_google_search_output_payload(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
let google_search = canonical
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("grounding"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("google_search"))
|
||||
.and_then(Value::as_object)?;
|
||||
google_search
|
||||
.get("legacy")
|
||||
.and_then(Value::as_bool)
|
||||
.filter(|legacy| *legacy)
|
||||
.map(|_| json!({}))
|
||||
.or_else(|| google_search.get("payload").cloned())
|
||||
}
|
||||
|
||||
fn gemini_builtin_tool_by_case(
|
||||
tool_object: &Map<String, Value>,
|
||||
camel: &str,
|
||||
snake: &str,
|
||||
) -> Option<Value> {
|
||||
let payload = tool_object
|
||||
.get(camel)
|
||||
.or_else(|| tool_object.get(snake))
|
||||
.map(gemini_builtin_tool_payload)?;
|
||||
Some(json!({ camel: payload }))
|
||||
}
|
||||
|
||||
fn gemini_builtin_tool_payload(payload: &Value) -> Value {
|
||||
match payload {
|
||||
Value::Null => json!({}),
|
||||
value => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_unhandled_builtin_tool_portion(tool_object: &Map<String, Value>) -> Option<Value> {
|
||||
let builtin = tool_object
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
!matches!(
|
||||
key.as_str(),
|
||||
"googleSearch"
|
||||
| "google_search"
|
||||
| "googleSearchRetrieval"
|
||||
| "google_search_retrieval"
|
||||
| "codeExecution"
|
||||
| "code_execution"
|
||||
| "urlContext"
|
||||
| "url_context"
|
||||
)
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect::<Map<_, _>>();
|
||||
(!builtin.is_empty()).then_some(Value::Object(builtin))
|
||||
}
|
||||
|
||||
fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value {
|
||||
let mut declaration = Map::new();
|
||||
declaration.insert("name".to_string(), Value::String(tool.name.clone()));
|
||||
if let Some(description) = &tool.description {
|
||||
declaration.insert(
|
||||
"description".to_string(),
|
||||
Value::String(description.clone()),
|
||||
);
|
||||
}
|
||||
let raw_parameters = tool
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("raw_parameters"))
|
||||
.cloned();
|
||||
declaration.insert(
|
||||
"parameters".to_string(),
|
||||
raw_parameters
|
||||
.clone()
|
||||
.or_else(|| tool.parameters.clone())
|
||||
.map(|mut schema| {
|
||||
if raw_parameters.is_none() {
|
||||
clean_gemini_schema(&mut schema);
|
||||
}
|
||||
schema
|
||||
})
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
Value::Object(declaration)
|
||||
}
|
||||
|
||||
fn canonical_tool_choice_to_gemini(choice: Option<&CanonicalToolChoice>) -> Option<Value> {
|
||||
let choice = choice?;
|
||||
let mode = match choice {
|
||||
CanonicalToolChoice::Auto => "AUTO",
|
||||
CanonicalToolChoice::None => "NONE",
|
||||
CanonicalToolChoice::Required | CanonicalToolChoice::Tool { .. } => "ANY",
|
||||
};
|
||||
let mut function_calling_config = Map::new();
|
||||
function_calling_config.insert("mode".to_string(), Value::String(mode.to_string()));
|
||||
if let CanonicalToolChoice::Tool { name } = choice {
|
||||
function_calling_config.insert(
|
||||
"allowedFunctionNames".to_string(),
|
||||
Value::Array(vec![Value::String(name.clone())]),
|
||||
);
|
||||
}
|
||||
Some(json!({
|
||||
"functionCallingConfig": Value::Object(function_calling_config),
|
||||
}))
|
||||
}
|
||||
|
||||
fn gemini_function_args(input: &Value) -> Value {
|
||||
match input {
|
||||
Value::Object(_) => input.clone(),
|
||||
Value::Null => json!({}),
|
||||
other => json!({ "value": other.clone() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
|
||||
match output {
|
||||
Some(value) => json!({ "result": value }),
|
||||
None => json!({ "result": content_text.unwrap_or_default() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
|
||||
let mut compact: Vec<Value> = Vec::new();
|
||||
for content in contents {
|
||||
let role = content
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let parts = content
|
||||
.get("parts")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(last) = compact.last_mut() {
|
||||
let last_role = last.get("role").and_then(Value::as_str).unwrap_or_default();
|
||||
if last_role == role {
|
||||
if let Some(last_parts) = last
|
||||
.as_object_mut()
|
||||
.and_then(|object| object.get_mut("parts"))
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
last_parts.extend(parts);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
compact.push(json!({
|
||||
"role": role,
|
||||
"parts": parts,
|
||||
}));
|
||||
}
|
||||
compact
|
||||
}
|
||||
|
||||
fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> {
|
||||
match name
|
||||
.trim()
|
||||
.replace(['_', '-', ' '], "")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"googlesearch" | "websearch" | "websearchpreview" => Some("googleSearch"),
|
||||
"codeexecution" => Some("codeExecution"),
|
||||
"urlcontext" => Some("urlContext"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_f64(output: &mut Map<String, Value>, key: &str, value: Option<f64>) {
|
||||
if let Some(value) = value.and_then(serde_json::Number::from_f64) {
|
||||
output.insert(key.to_string(), Value::Number(value));
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_gemini_schema(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for inner in object.values_mut() {
|
||||
clean_gemini_schema(inner);
|
||||
}
|
||||
if object.get("type").and_then(Value::as_str) == Some("object")
|
||||
&& !object.contains_key("properties")
|
||||
{
|
||||
object.insert("properties".to_string(), Value::Object(Map::new()));
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
clean_gemini_schema(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CanonicalContentBlock;
|
||||
|
||||
#[test]
|
||||
fn canonical_tool_result_to_gemini_request_preserves_function_response_id() {
|
||||
let mut tool_name_by_id = BTreeMap::new();
|
||||
tool_name_by_id.insert("call_1".to_string(), "lookup".to_string());
|
||||
|
||||
let part = canonical_block_to_gemini_part(
|
||||
&CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: "call_1".to_string(),
|
||||
name: None,
|
||||
output: Some(serde_json::json!({"ok": true})),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: BTreeMap::new(),
|
||||
},
|
||||
&mut tool_name_by_id,
|
||||
)
|
||||
.expect("part should be representable")
|
||||
.expect("part should not be omitted");
|
||||
|
||||
let function_response = part
|
||||
.get("functionResponse")
|
||||
.and_then(Value::as_object)
|
||||
.expect("functionResponse should exist");
|
||||
|
||||
assert_eq!(function_response["id"], "call_1");
|
||||
assert_eq!(function_response["name"], "lookup");
|
||||
assert_eq!(
|
||||
function_response["response"],
|
||||
serde_json::json!({"result": {"ok": true}})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_extension_object_mut, canonical_usage_total_input_tokens,
|
||||
canonical_usage_total_tokens_for_inclusive_input, gemini_extensions,
|
||||
gemini_part_to_canonical_block, gemini_stop_reason_to_canonical, gemini_usage_to_canonical,
|
||||
CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
|
||||
CanonicalStopReason, CanonicalUsage,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
|
||||
to_raw(response, &ctx.report_context_value())
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidates = body.get("candidates")?.as_array()?;
|
||||
let mut outputs = Vec::new();
|
||||
for (fallback_index, candidate) in candidates.iter().enumerate() {
|
||||
let candidate_object = candidate.as_object()?;
|
||||
let parts = candidate_object
|
||||
.get("content")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|content| content.get("parts"))
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
let content = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, part)| gemini_part_to_canonical_block(part, index))
|
||||
.collect::<Vec<_>>();
|
||||
let mut stop_reason = candidate_object
|
||||
.get("finishReason")
|
||||
.or_else(|| candidate_object.get("finish_reason"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(gemini_stop_reason_to_canonical);
|
||||
if content
|
||||
.iter()
|
||||
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }))
|
||||
&& stop_reason
|
||||
.as_ref()
|
||||
.is_none_or(|reason| matches!(reason, CanonicalStopReason::EndTurn))
|
||||
{
|
||||
stop_reason = Some(CanonicalStopReason::ToolUse);
|
||||
}
|
||||
let mut extensions = gemini_extensions(
|
||||
candidate_object,
|
||||
&["index", "content", "finishReason", "finish_reason"],
|
||||
);
|
||||
if let Some(raw_finish_reason) = candidate_object
|
||||
.get("finishReason")
|
||||
.or_else(|| candidate_object.get("finish_reason"))
|
||||
.cloned()
|
||||
{
|
||||
canonical_extension_object_mut(&mut extensions, "gemini")
|
||||
.insert("raw_finish_reason".to_string(), raw_finish_reason);
|
||||
}
|
||||
outputs.push(CanonicalResponseOutput {
|
||||
index: candidate_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.unwrap_or(fallback_index),
|
||||
role: CanonicalRole::Assistant,
|
||||
content,
|
||||
stop_reason,
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
outputs.retain(gemini_response_output_has_visible_content);
|
||||
if outputs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let content = outputs
|
||||
.first()
|
||||
.map(|output| output.content.clone())
|
||||
.unwrap_or_default();
|
||||
let stop_reason = outputs
|
||||
.first()
|
||||
.and_then(|output| output.stop_reason.clone());
|
||||
|
||||
let mut canonical = CanonicalResponse {
|
||||
id: body
|
||||
.get("responseId")
|
||||
.or_else(|| body.get("_v1internal_response_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("gemini-local-finalize")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("modelVersion")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
outputs,
|
||||
content,
|
||||
stop_reason,
|
||||
usage: gemini_usage_to_canonical(body.get("usageMetadata")),
|
||||
extensions: gemini_extensions(
|
||||
body,
|
||||
&[
|
||||
"responseId",
|
||||
"_v1internal_response_id",
|
||||
"modelVersion",
|
||||
"candidates",
|
||||
"usageMetadata",
|
||||
],
|
||||
),
|
||||
};
|
||||
if let Some(candidates) = body.get("candidates").cloned() {
|
||||
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
|
||||
.insert("raw_candidates".to_string(), candidates);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
fn gemini_response_output_has_visible_content(output: &CanonicalResponseOutput) -> bool {
|
||||
output.content.iter().any(|block| match block {
|
||||
CanonicalContentBlock::Text { text, .. } => !text.trim().is_empty(),
|
||||
CanonicalContentBlock::ToolUse { .. }
|
||||
| CanonicalContentBlock::ToolResult { .. }
|
||||
| CanonicalContentBlock::Image { .. }
|
||||
| CanonicalContentBlock::File { .. }
|
||||
| CanonicalContentBlock::Audio { .. } => true,
|
||||
CanonicalContentBlock::Thinking { .. } | CanonicalContentBlock::Unknown { .. } => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option<Value> {
|
||||
let mut response = canonical_to_gemini_response(canonical, report_context)?;
|
||||
if let Some(object) = response.as_object_mut() {
|
||||
if let Some(gemini) = canonical
|
||||
.extensions
|
||||
.get("gemini")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
for (key, value) in gemini {
|
||||
if key == "raw_candidates" || object.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(response)
|
||||
}
|
||||
|
||||
fn canonical_to_gemini_response(
|
||||
canonical: &CanonicalResponse,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let outputs = if canonical.outputs.is_empty() {
|
||||
vec![CanonicalResponseOutput {
|
||||
index: 0,
|
||||
role: crate::protocol::canonical::CanonicalRole::Assistant,
|
||||
content: canonical.content.clone(),
|
||||
stop_reason: canonical.stop_reason.clone(),
|
||||
extensions: Default::default(),
|
||||
}]
|
||||
} else {
|
||||
canonical.outputs.clone()
|
||||
};
|
||||
let mut candidates = Vec::new();
|
||||
for output in outputs {
|
||||
let parts = canonical_blocks_to_gemini_parts(&output.content)?;
|
||||
let mut candidate = json!({
|
||||
"index": output.index,
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
},
|
||||
"finishReason": canonical_stop_reason_to_gemini(
|
||||
output.stop_reason.as_ref().or(canonical.stop_reason.as_ref())
|
||||
),
|
||||
});
|
||||
if let Some(candidate_object) = candidate.as_object_mut() {
|
||||
if let Some(gemini) = output.extensions.get("gemini").and_then(Value::as_object) {
|
||||
if let Some(raw_finish_reason) = gemini.get("raw_finish_reason").cloned() {
|
||||
candidate_object.insert("finishReason".to_string(), raw_finish_reason);
|
||||
}
|
||||
for (key, value) in gemini {
|
||||
if key == "raw_finish_reason" {
|
||||
continue;
|
||||
}
|
||||
candidate_object.entry(key.clone()).or_insert(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
let mut response = Map::new();
|
||||
response.insert(
|
||||
"responseId".to_string(),
|
||||
Value::String(if canonical.id.trim().is_empty() {
|
||||
"resp-local-finalize".to_string()
|
||||
} else {
|
||||
canonical.id.clone()
|
||||
}),
|
||||
);
|
||||
response.insert(
|
||||
"modelVersion".to_string(),
|
||||
Value::String(
|
||||
if canonical.model.trim().is_empty() || canonical.model == "unknown" {
|
||||
report_context
|
||||
.get("mapped_model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
} else {
|
||||
canonical.model.clone()
|
||||
},
|
||||
),
|
||||
);
|
||||
response.insert("candidates".to_string(), Value::Array(candidates));
|
||||
if let Some(usage) = &canonical.usage {
|
||||
response.insert(
|
||||
"usageMetadata".to_string(),
|
||||
canonical_usage_to_gemini_usage_metadata(usage),
|
||||
);
|
||||
}
|
||||
Some(Value::Object(response))
|
||||
}
|
||||
|
||||
fn canonical_blocks_to_gemini_parts(blocks: &[CanonicalContentBlock]) -> Option<Vec<Value>> {
|
||||
let mut parts = Vec::new();
|
||||
for block in blocks {
|
||||
if let Some(part) = canonical_block_to_gemini_part(block)? {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
parts.push(json!({ "text": "" }));
|
||||
}
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
fn canonical_block_to_gemini_part(block: &CanonicalContentBlock) -> Option<Option<Value>> {
|
||||
match block {
|
||||
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
|
||||
CanonicalContentBlock::Thinking {
|
||||
text, signature, ..
|
||||
} => {
|
||||
if text.trim().is_empty() {
|
||||
return Some(None);
|
||||
}
|
||||
let mut part = Map::new();
|
||||
part.insert("text".to_string(), Value::String(text.clone()));
|
||||
part.insert("thought".to_string(), Value::Bool(true));
|
||||
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
|
||||
part.insert(
|
||||
"thoughtSignature".to_string(),
|
||||
Value::String(signature.clone()),
|
||||
);
|
||||
}
|
||||
Some(Some(Value::Object(part)))
|
||||
}
|
||||
CanonicalContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => Some(Some(json!({
|
||||
"functionCall": {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"args": gemini_function_args(input),
|
||||
}
|
||||
}))),
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
name,
|
||||
output,
|
||||
content_text,
|
||||
..
|
||||
} => Some(Some(json!({
|
||||
"functionResponse": {
|
||||
"id": tool_use_id,
|
||||
"name": name.clone().unwrap_or_else(|| tool_use_id.clone()),
|
||||
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
|
||||
}
|
||||
}))),
|
||||
CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("image/png"),
|
||||
data.as_deref(),
|
||||
url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::File {
|
||||
data,
|
||||
file_url,
|
||||
media_type,
|
||||
..
|
||||
} => Some(Some(canonical_media_to_gemini_part(
|
||||
media_type.as_deref().unwrap_or("application/octet-stream"),
|
||||
data.as_deref(),
|
||||
file_url.as_deref(),
|
||||
))),
|
||||
CanonicalContentBlock::Audio {
|
||||
data, media_type, ..
|
||||
} => Some(data.as_ref().map(|data| {
|
||||
json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
})),
|
||||
CanonicalContentBlock::Unknown { .. } => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_media_to_gemini_part(
|
||||
media_type: &str,
|
||||
data: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> Value {
|
||||
if let Some(data) = data.filter(|value| !value.is_empty()) {
|
||||
return json!({
|
||||
"inlineData": {
|
||||
"mimeType": media_type,
|
||||
"data": data,
|
||||
}
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"fileData": {
|
||||
"mimeType": media_type,
|
||||
"fileUri": url.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn gemini_function_args(input: &Value) -> Value {
|
||||
match input {
|
||||
Value::Object(_) => input.clone(),
|
||||
Value::Null => json!({}),
|
||||
other => json!({ "value": other.clone() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
|
||||
match output {
|
||||
Some(Value::Object(object)) => Value::Object(object.clone()),
|
||||
Some(value) => json!({ "result": value }),
|
||||
None => json!({ "result": content_text.unwrap_or_default() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_stop_reason_to_gemini(reason: Option<&CanonicalStopReason>) -> Value {
|
||||
Value::String(
|
||||
match reason {
|
||||
Some(CanonicalStopReason::MaxTokens) => "MAX_TOKENS",
|
||||
Some(CanonicalStopReason::ContentFiltered) | Some(CanonicalStopReason::Refusal) => {
|
||||
"SAFETY"
|
||||
}
|
||||
Some(CanonicalStopReason::Unknown) => "OTHER",
|
||||
_ => "STOP",
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_usage_to_gemini_usage_metadata(usage: &CanonicalUsage) -> Value {
|
||||
let input_tokens = canonical_usage_total_input_tokens(usage);
|
||||
let mut out = Map::new();
|
||||
out.insert("promptTokenCount".to_string(), Value::from(input_tokens));
|
||||
out.insert(
|
||||
"candidatesTokenCount".to_string(),
|
||||
Value::from(usage.output_tokens.saturating_sub(usage.reasoning_tokens)),
|
||||
);
|
||||
out.insert(
|
||||
"totalTokenCount".to_string(),
|
||||
Value::from(canonical_usage_total_tokens_for_inclusive_input(
|
||||
usage,
|
||||
input_tokens,
|
||||
)),
|
||||
);
|
||||
if usage.cache_read_tokens > 0 {
|
||||
out.insert(
|
||||
"cachedContentTokenCount".to_string(),
|
||||
Value::from(usage.cache_read_tokens),
|
||||
);
|
||||
}
|
||||
if usage.reasoning_tokens > 0 {
|
||||
out.insert(
|
||||
"thoughtsTokenCount".to_string(),
|
||||
Value::from(usage.reasoning_tokens),
|
||||
);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CanonicalContentBlock;
|
||||
|
||||
#[test]
|
||||
fn gemini_response_without_visible_parts_is_not_success() {
|
||||
let body = json!({
|
||||
"candidates": [{
|
||||
"content": {"role": "model"},
|
||||
"finishReason": "MAX_TOKENS"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 8,
|
||||
"candidatesTokenCount": 1,
|
||||
"thoughtsTokenCount": 25,
|
||||
"totalTokenCount": 34
|
||||
},
|
||||
"modelVersion": "gemini-3-flash-preview",
|
||||
"responseId": "resp-empty"
|
||||
});
|
||||
|
||||
assert!(from_raw(&body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_response_with_only_thought_parts_is_not_success() {
|
||||
let body = json!({
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [{"text": "hidden plan", "thought": true}]
|
||||
},
|
||||
"finishReason": "MAX_TOKENS"
|
||||
}],
|
||||
"modelVersion": "gemini-3-flash-preview",
|
||||
"responseId": "resp-thought-only"
|
||||
});
|
||||
|
||||
assert!(from_raw(&body).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_response_with_function_call_is_visible_output() {
|
||||
let body = json!({
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [{
|
||||
"functionCall": {
|
||||
"name": "lookup",
|
||||
"args": {"query": "weather"}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"modelVersion": "gemini-3-flash-preview",
|
||||
"responseId": "resp-tool"
|
||||
});
|
||||
|
||||
let canonical = from_raw(&body).expect("function call should be visible output");
|
||||
assert!(matches!(
|
||||
canonical.content.first(),
|
||||
Some(CanonicalContentBlock::ToolUse { name, .. }) if name == "lookup"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use super::{chat_spec, cli_spec, resolve_stream_spec, resolve_sync_spec};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
pub mod embedding;
|
||||
pub mod files;
|
||||
pub mod generate_content;
|
||||
pub mod video;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod spec;
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::contracts::GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND;
|
||||
use crate::formats::shared::video::{LocalVideoCreateFamily, LocalVideoCreateSpec};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
||||
api_format: "gemini:video",
|
||||
decision_kind: GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_video_create_sync_finalize",
|
||||
family: LocalVideoCreateFamily::Gemini,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_sync_spec, LocalVideoCreateFamily};
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_video_create_spec() {
|
||||
let spec = resolve_sync_spec("gemini_video_create_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:video");
|
||||
assert_eq!(spec.family, LocalVideoCreateFamily::Gemini);
|
||||
assert_eq!(spec.report_kind, "gemini_video_create_sync_finalize");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
//! Format identity and aliases.
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FormatFamily {
|
||||
OpenAi,
|
||||
Claude,
|
||||
Gemini,
|
||||
Jina,
|
||||
Doubao,
|
||||
Aliyun,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FormatProfile {
|
||||
Default,
|
||||
Compact,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum FormatId {
|
||||
OpenAiChat,
|
||||
OpenAiResponses,
|
||||
OpenAiResponsesCompact,
|
||||
OpenAiSearch,
|
||||
OpenAiEmbedding,
|
||||
OpenAiRerank,
|
||||
ClaudeMessages,
|
||||
GeminiGenerateContent,
|
||||
GeminiInteractions,
|
||||
GeminiEmbedding,
|
||||
JinaEmbedding,
|
||||
JinaRerank,
|
||||
DoubaoEmbedding,
|
||||
AliyunMultimodalEmbedding,
|
||||
}
|
||||
|
||||
impl FormatId {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
value.parse().ok()
|
||||
}
|
||||
|
||||
pub fn canonical(self) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn family(self) -> FormatFamily {
|
||||
match self {
|
||||
Self::OpenAiChat
|
||||
| Self::OpenAiResponses
|
||||
| Self::OpenAiResponsesCompact
|
||||
| Self::OpenAiSearch
|
||||
| Self::OpenAiEmbedding
|
||||
| Self::OpenAiRerank => FormatFamily::OpenAi,
|
||||
Self::ClaudeMessages => FormatFamily::Claude,
|
||||
Self::GeminiGenerateContent | Self::GeminiInteractions | Self::GeminiEmbedding => {
|
||||
FormatFamily::Gemini
|
||||
}
|
||||
Self::JinaEmbedding | Self::JinaRerank => FormatFamily::Jina,
|
||||
Self::DoubaoEmbedding => FormatFamily::Doubao,
|
||||
Self::AliyunMultimodalEmbedding => FormatFamily::Aliyun,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profile(self) -> FormatProfile {
|
||||
match self {
|
||||
Self::OpenAiResponsesCompact => FormatProfile::Compact,
|
||||
_ => FormatProfile::Default,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiChat => "openai:chat",
|
||||
Self::OpenAiResponses => "openai:responses",
|
||||
Self::OpenAiResponsesCompact => "openai:responses:compact",
|
||||
Self::OpenAiSearch => "openai:search",
|
||||
Self::OpenAiEmbedding => "openai:embedding",
|
||||
Self::OpenAiRerank => "openai:rerank",
|
||||
Self::ClaudeMessages => "claude:messages",
|
||||
Self::GeminiGenerateContent => "gemini:generate_content",
|
||||
Self::GeminiInteractions => "gemini:interactions",
|
||||
Self::GeminiEmbedding => "gemini:embedding",
|
||||
Self::JinaEmbedding => "jina:embedding",
|
||||
Self::JinaRerank => "jina:rerank",
|
||||
Self::DoubaoEmbedding => "doubao:embedding",
|
||||
Self::AliyunMultimodalEmbedding => "aliyun:multimodal_embedding",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FormatId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FormatId {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"openai" | "openai:chat" | "/v1/chat/completions" => Ok(Self::OpenAiChat),
|
||||
"openai:responses" | "/v1/responses" => Ok(Self::OpenAiResponses),
|
||||
"openai:responses:compact" | "/v1/responses/compact" => {
|
||||
Ok(Self::OpenAiResponsesCompact)
|
||||
}
|
||||
"openai:search" | "openai_search" | "search" | "/v1/alpha/search" => {
|
||||
Ok(Self::OpenAiSearch)
|
||||
}
|
||||
"openai:embedding" | "/v1/embeddings" => Ok(Self::OpenAiEmbedding),
|
||||
"openai:rerank" | "/v1/rerank" => Ok(Self::OpenAiRerank),
|
||||
"claude:messages" | "/v1/messages" => Ok(Self::ClaudeMessages),
|
||||
"gemini:generate_content" => Ok(Self::GeminiGenerateContent),
|
||||
"gemini:interactions"
|
||||
| "gemini:interaction"
|
||||
| "gemini_interactions"
|
||||
| "gemini_interaction"
|
||||
| "/v1/interactions"
|
||||
| "/v1beta/interactions" => Ok(Self::GeminiInteractions),
|
||||
"gemini:embedding" => Ok(Self::GeminiEmbedding),
|
||||
"jina:embedding" | "/jina/v1/embeddings" => Ok(Self::JinaEmbedding),
|
||||
"jina:rerank" | "/jina/v1/rerank" => Ok(Self::JinaRerank),
|
||||
"doubao:embedding" => Ok(Self::DoubaoEmbedding),
|
||||
"aliyun:multimodal_embedding"
|
||||
| "aliyun_embedding"
|
||||
| "aliyun_multimodal_embedding"
|
||||
| "dashscope:multimodal_embedding"
|
||||
| "dashscope_embedding"
|
||||
| "dashscope_multimodal_embedding" => Ok(Self::AliyunMultimodalEmbedding),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_api_format_alias(value: &str) -> String {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
FormatId::parse(&normalized)
|
||||
.map(|format| format.as_str().to_string())
|
||||
.unwrap_or(normalized)
|
||||
}
|
||||
|
||||
pub fn api_format_alias_matches(left: &str, right: &str) -> bool {
|
||||
normalize_api_format_alias(left) == normalize_api_format_alias(right)
|
||||
}
|
||||
|
||||
pub fn api_format_defaults_to_non_stream(value: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(value).as_str(),
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "openai:image"
|
||||
| "claude:messages"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn api_format_defaults_to_client_error_failover(value: &str) -> bool {
|
||||
!matches!(
|
||||
FormatId::parse(value).map(FormatId::canonical),
|
||||
Some(FormatId::OpenAiSearch)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn api_format_permission_covers(allowed_value: &str, requested_api_format: &str) -> bool {
|
||||
let allowed_value = normalize_api_format_alias(allowed_value);
|
||||
let requested_api_format = normalize_api_format_alias(requested_api_format);
|
||||
!allowed_value.is_empty()
|
||||
&& !requested_api_format.is_empty()
|
||||
&& (allowed_value == requested_api_format
|
||||
|| allowed_value == "openai:responses"
|
||||
&& matches!(
|
||||
requested_api_format.as_str(),
|
||||
"openai:responses:compact" | "openai:search"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn intersect_api_format_allowed_lists(left: &[String], right: &[String]) -> Vec<String> {
|
||||
let mut effective = Vec::new();
|
||||
for left_value in left {
|
||||
for right_value in right {
|
||||
let intersection = if api_format_permission_covers(right_value, left_value) {
|
||||
Some(left_value)
|
||||
} else if api_format_permission_covers(left_value, right_value) {
|
||||
Some(right_value)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(value) = intersection {
|
||||
let normalized = normalize_api_format_alias(value);
|
||||
if !effective.iter().any(|item| item == &normalized) {
|
||||
effective.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
effective
|
||||
}
|
||||
|
||||
pub fn api_format_storage_aliases(value: &str) -> Vec<String> {
|
||||
match FormatId::parse(value).map(FormatId::canonical) {
|
||||
Some(FormatId::AliyunMultimodalEmbedding) => vec![
|
||||
"aliyun:multimodal_embedding".to_string(),
|
||||
"dashscope:multimodal_embedding".to_string(),
|
||||
],
|
||||
_ => vec![normalize_api_format_alias(value)],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_format_permission_storage_aliases(value: &str) -> Vec<String> {
|
||||
let requested_api_format = normalize_api_format_alias(value);
|
||||
let mut aliases = api_format_storage_aliases(&requested_api_format);
|
||||
for allowed_api_format in [FormatId::OpenAiResponses.as_str()] {
|
||||
if !api_format_permission_covers(allowed_api_format, &requested_api_format) {
|
||||
continue;
|
||||
}
|
||||
for alias in api_format_storage_aliases(allowed_api_format) {
|
||||
if !aliases.iter().any(|existing| existing == &alias) {
|
||||
aliases.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
aliases
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_format(value: &str) -> bool {
|
||||
normalize_api_format_alias(value) == "openai:responses"
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_compact_format(value: &str) -> bool {
|
||||
normalize_api_format_alias(value) == "openai:responses:compact"
|
||||
}
|
||||
|
||||
pub fn is_openai_responses_family_format(value: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(value).as_str(),
|
||||
"openai:responses" | "openai:responses:compact"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn api_format_uses_body_stream_field(value: &str) -> bool {
|
||||
matches!(
|
||||
FormatId::parse(value).map(FormatId::canonical),
|
||||
Some(
|
||||
FormatId::OpenAiChat
|
||||
| FormatId::OpenAiResponses
|
||||
| FormatId::ClaudeMessages
|
||||
| FormatId::GeminiInteractions,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
api_format_alias_matches, api_format_defaults_to_client_error_failover,
|
||||
api_format_defaults_to_non_stream, api_format_permission_covers,
|
||||
api_format_permission_storage_aliases, api_format_storage_aliases,
|
||||
api_format_uses_body_stream_field, intersect_api_format_allowed_lists,
|
||||
normalize_api_format_alias, FormatId,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn retired_api_formats_do_not_parse() {
|
||||
assert_eq!(FormatId::parse("openai:cli"), None);
|
||||
assert_eq!(FormatId::parse("openai:compact"), None);
|
||||
assert_eq!(FormatId::parse("claude:chat"), None);
|
||||
assert_eq!(FormatId::parse("claude:cli"), None);
|
||||
assert_eq!(FormatId::parse("gemini:chat"), None);
|
||||
assert_eq!(FormatId::parse("gemini:cli"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_permission_covers_its_companion_endpoints() {
|
||||
assert!(api_format_permission_covers(
|
||||
"OPENAI:RESPONSES",
|
||||
"openai:search"
|
||||
));
|
||||
assert!(api_format_permission_covers(
|
||||
"OPENAI:RESPONSES",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(api_format_permission_covers(
|
||||
"openai:search",
|
||||
"openai:search"
|
||||
));
|
||||
assert!(!api_format_permission_covers(
|
||||
"openai:search",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!api_format_permission_covers(
|
||||
"openai:responses:compact",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!api_format_permission_covers(
|
||||
"openai:responses",
|
||||
"openai:chat"
|
||||
));
|
||||
assert_eq!(
|
||||
api_format_permission_storage_aliases("openai:search"),
|
||||
vec!["openai:search".to_string(), "openai:responses".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_permission_storage_aliases("openai:responses:compact"),
|
||||
vec![
|
||||
"openai:responses:compact".to_string(),
|
||||
"openai:responses".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_permission_storage_aliases("openai:responses"),
|
||||
vec!["openai:responses".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_openai_search_aliases() {
|
||||
for alias in [
|
||||
"openai:search",
|
||||
"OPENAI_SEARCH",
|
||||
"search",
|
||||
"/v1/alpha/search",
|
||||
] {
|
||||
assert_eq!(FormatId::parse(alias), Some(FormatId::OpenAiSearch));
|
||||
assert_eq!(normalize_api_format_alias(alias), "openai:search");
|
||||
}
|
||||
assert!(!api_format_uses_body_stream_field("openai:search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifies_default_non_stream_formats_from_aliases() {
|
||||
for format in [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses",
|
||||
"/v1/responses/compact",
|
||||
"/v1/alpha/search",
|
||||
"openai:image",
|
||||
"/v1/messages",
|
||||
] {
|
||||
assert!(api_format_defaults_to_non_stream(format), "{format}");
|
||||
}
|
||||
assert!(!api_format_defaults_to_non_stream("gemini:interactions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_defaults_to_passthrough_for_client_errors() {
|
||||
for format in ["openai:search", "OPENAI_SEARCH", "/v1/alpha/search"] {
|
||||
assert!(
|
||||
!api_format_defaults_to_client_error_failover(format),
|
||||
"{format}"
|
||||
);
|
||||
}
|
||||
assert!(api_format_defaults_to_client_error_failover(
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(api_format_defaults_to_client_error_failover(
|
||||
"custom:unknown"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_format_policy_intersection_keeps_the_narrowest_companion_scope() {
|
||||
assert_eq!(
|
||||
intersect_api_format_allowed_lists(
|
||||
&["openai:responses".to_string()],
|
||||
&["openai:search".to_string()],
|
||||
),
|
||||
vec!["openai:search".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_api_format_allowed_lists(
|
||||
&["openai:responses".to_string()],
|
||||
&["openai:responses:compact".to_string()],
|
||||
),
|
||||
vec!["openai:responses:compact".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_api_format_allowed_lists(
|
||||
&["openai:search".to_string()],
|
||||
&["OPENAI:RESPONSES".to_string()],
|
||||
),
|
||||
vec!["openai:search".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_api_format_allowed_lists(
|
||||
&["openai:responses".to_string()],
|
||||
&["openai:responses:compact".to_string()],
|
||||
),
|
||||
vec!["openai:responses:compact".to_string()]
|
||||
);
|
||||
assert!(intersect_api_format_allowed_lists(
|
||||
&["openai:search".to_string()],
|
||||
&["openai:chat".to_string()],
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_embedding_api_formats() {
|
||||
assert_eq!(
|
||||
FormatId::parse("openai:embedding"),
|
||||
Some(FormatId::OpenAiEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("/v1/embeddings"),
|
||||
Some(FormatId::OpenAiEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("gemini:embedding"),
|
||||
Some(FormatId::GeminiEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("jina:embedding"),
|
||||
Some(FormatId::JinaEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("/jina/v1/embeddings"),
|
||||
Some(FormatId::JinaEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("doubao:embedding"),
|
||||
Some(FormatId::DoubaoEmbedding)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("aliyun:multimodal_embedding").map(|format| format.to_string()),
|
||||
Some("aliyun:multimodal_embedding".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("dashscope:multimodal_embedding").map(|format| format.to_string()),
|
||||
Some("aliyun:multimodal_embedding".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("dashscope_embedding").map(|format| format.to_string()),
|
||||
Some("aliyun:multimodal_embedding".to_string())
|
||||
);
|
||||
assert_eq!(FormatId::OpenAiEmbedding.to_string(), "openai:embedding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_format_ids_keep_provider_family_and_default_profile() {
|
||||
use super::{FormatFamily, FormatProfile};
|
||||
|
||||
for (format, family) in [
|
||||
(FormatId::OpenAiEmbedding, FormatFamily::OpenAi),
|
||||
(FormatId::GeminiEmbedding, FormatFamily::Gemini),
|
||||
(FormatId::JinaEmbedding, FormatFamily::Jina),
|
||||
(FormatId::DoubaoEmbedding, FormatFamily::Doubao),
|
||||
(FormatId::AliyunMultimodalEmbedding, FormatFamily::Aliyun),
|
||||
] {
|
||||
assert_eq!(format.family(), family);
|
||||
assert_eq!(format.profile(), FormatProfile::Default);
|
||||
assert_eq!(FormatId::parse(format.as_str()), Some(format));
|
||||
assert_eq!(format.to_string(), format.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gemini_interactions_api_formats() {
|
||||
use super::{FormatFamily, FormatProfile};
|
||||
|
||||
assert_eq!(
|
||||
FormatId::parse("gemini:interactions"),
|
||||
Some(FormatId::GeminiInteractions)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("gemini_interactions"),
|
||||
Some(FormatId::GeminiInteractions)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::parse("/v1/interactions"),
|
||||
Some(FormatId::GeminiInteractions)
|
||||
);
|
||||
assert_eq!(
|
||||
FormatId::GeminiInteractions.to_string(),
|
||||
"gemini:interactions"
|
||||
);
|
||||
assert_eq!(FormatId::GeminiInteractions.family(), FormatFamily::Gemini);
|
||||
assert_eq!(
|
||||
FormatId::GeminiInteractions.profile(),
|
||||
FormatProfile::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rerank_api_formats() {
|
||||
assert_eq!(
|
||||
FormatId::parse("openai:rerank"),
|
||||
Some(FormatId::OpenAiRerank)
|
||||
);
|
||||
assert_eq!(FormatId::parse("/v1/rerank"), Some(FormatId::OpenAiRerank));
|
||||
assert_eq!(FormatId::parse("jina:rerank"), Some(FormatId::JinaRerank));
|
||||
assert_eq!(
|
||||
FormatId::parse("/jina/v1/rerank"),
|
||||
Some(FormatId::JinaRerank)
|
||||
);
|
||||
assert_eq!(FormatId::OpenAiRerank.to_string(), "openai:rerank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerank_format_ids_keep_provider_family_and_default_profile() {
|
||||
use super::{FormatFamily, FormatProfile};
|
||||
|
||||
for (format, family) in [
|
||||
(FormatId::OpenAiRerank, FormatFamily::OpenAi),
|
||||
(FormatId::JinaRerank, FormatFamily::Jina),
|
||||
] {
|
||||
assert_eq!(format.family(), family);
|
||||
assert_eq!(format.profile(), FormatProfile::Default);
|
||||
assert_eq!(FormatId::parse(format.as_str()), Some(format));
|
||||
assert_eq!(format.to_string(), format.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_embedding_format() {
|
||||
assert_eq!(FormatId::parse("embedding"), None);
|
||||
assert_eq!(FormatId::parse("openai:embeddings"), None);
|
||||
assert_eq!(FormatId::parse("claude:embedding"), None);
|
||||
assert_eq!(FormatId::parse("gemini:embed_content"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_api_format_aliases() {
|
||||
assert_eq!(
|
||||
normalize_api_format_alias(" OPENAI:RESPONSES "),
|
||||
"openai:responses"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("OPENAI:RESPONSES:COMPACT"),
|
||||
"openai:responses:compact"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("CLAUDE:MESSAGES"),
|
||||
"claude:messages"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("GEMINI:GENERATE_CONTENT"),
|
||||
"gemini:generate_content"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("GEMINI_INTERACTIONS"),
|
||||
"gemini:interactions"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("OPENAI:EMBEDDING"),
|
||||
"openai:embedding"
|
||||
);
|
||||
assert_eq!(normalize_api_format_alias("openai:image"), "openai:image");
|
||||
assert_eq!(normalize_api_format_alias("openai:video"), "openai:video");
|
||||
assert_eq!(normalize_api_format_alias("gemini:video"), "gemini:video");
|
||||
assert_eq!(normalize_api_format_alias("gemini:files"), "gemini:files");
|
||||
assert!(!api_format_alias_matches("claude:cli", "claude:messages"));
|
||||
assert!(!api_format_alias_matches(
|
||||
"gemini:chat",
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(!api_format_alias_matches("openai:cli", "openai:responses"));
|
||||
assert_eq!(
|
||||
normalize_api_format_alias("openai:compact"),
|
||||
"openai:compact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_aliases_only_include_normalized_value() {
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("openai:responses"),
|
||||
vec!["openai:responses".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("openai:responses:compact"),
|
||||
vec!["openai:responses:compact".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("claude:messages"),
|
||||
vec!["claude:messages".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("gemini:generate_content"),
|
||||
vec!["gemini:generate_content".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("gemini:interactions"),
|
||||
vec!["gemini:interactions".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("openai:embedding"),
|
||||
vec!["openai:embedding".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("gemini:embedding"),
|
||||
vec!["gemini:embedding".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("jina:embedding"),
|
||||
vec!["jina:embedding".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("doubao:embedding"),
|
||||
vec!["doubao:embedding".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
api_format_storage_aliases("dashscope:multimodal_embedding"),
|
||||
vec![
|
||||
"aliyun:multimodal_embedding".to_string(),
|
||||
"dashscope:multimodal_embedding".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_stream_field_support_matches_provider_wire_formats() {
|
||||
assert!(api_format_uses_body_stream_field("openai:chat"));
|
||||
assert!(api_format_uses_body_stream_field("/v1/chat/completions"));
|
||||
assert!(api_format_uses_body_stream_field("openai:responses"));
|
||||
assert!(api_format_uses_body_stream_field("/v1/responses"));
|
||||
assert!(api_format_uses_body_stream_field("claude:messages"));
|
||||
assert!(api_format_uses_body_stream_field("/v1/messages"));
|
||||
assert!(api_format_uses_body_stream_field("gemini:interactions"));
|
||||
assert!(api_format_uses_body_stream_field("/v1/interactions"));
|
||||
assert!(!api_format_uses_body_stream_field(
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(!api_format_uses_body_stream_field("/v1/responses/compact"));
|
||||
assert!(!api_format_uses_body_stream_field(
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(!api_format_uses_body_stream_field("openai:embedding"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::CanonicalRequest;
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
crate::formats::openai::embedding::request::from_namespace(body, "jina")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
crate::formats::openai::embedding::request::to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"jina",
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::protocol::canonical::CanonicalEmbeddingResponse;
|
||||
|
||||
pub fn from(body: &Value) -> Option<CanonicalEmbeddingResponse> {
|
||||
crate::formats::openai::embedding::response::from_namespace(body, "jina")
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalEmbeddingResponse) -> Option<Value> {
|
||||
Some(crate::formats::openai::embedding::response::to_openai_like(
|
||||
response, "jina",
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod embedding;
|
||||
pub mod rerank;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod request;
|
||||
@@ -0,0 +1,16 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::CanonicalRequest;
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
crate::formats::openai::rerank::request::from_namespace(body, "jina")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
crate::formats::openai::rerank::request::to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"jina",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
use crate::{
|
||||
api_format_alias_matches,
|
||||
formats::id::{is_openai_responses_compact_format, normalize_api_format_alias},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToOpenAiResponses,
|
||||
ToClaudeStandard,
|
||||
ToGeminiStandard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncChatResponseConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToClaudeChat,
|
||||
ToGeminiChat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncCliResponseConversionKind {
|
||||
ToOpenAiResponses,
|
||||
ToClaudeCli,
|
||||
ToGeminiCli,
|
||||
}
|
||||
|
||||
const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
const STANDARD_API_FORMAT_ORDER: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
const EMBEDDING_CANDIDATE_API_FORMATS: &[&str] = &[
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
];
|
||||
const RERANK_CANDIDATE_API_FORMATS: &[&str] = &["openai:rerank", "jina:rerank"];
|
||||
const GEMINI_INTERACTIONS_CANDIDATE_API_FORMATS: &[&str] = &["gemini:interactions"];
|
||||
|
||||
pub fn request_candidate_api_format_preference(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<(u8, u8)> {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
|
||||
if client_api_format == "openai:responses:compact" {
|
||||
return (provider_api_format == "openai:responses:compact").then_some((0, 0));
|
||||
}
|
||||
if client_api_format == "openai:search" {
|
||||
return (provider_api_format == "openai:search").then_some((0, 0));
|
||||
}
|
||||
if is_gemini_interactions_api_format(client_api_format.as_str()) {
|
||||
return (provider_api_format == "gemini:interactions").then_some((0, 0));
|
||||
}
|
||||
if is_embedding_api_format(client_api_format.as_str()) {
|
||||
return is_embedding_api_format(provider_api_format.as_str()).then_some((
|
||||
if client_api_format == provider_api_format {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
},
|
||||
embedding_api_format_priority(provider_api_format.as_str()),
|
||||
));
|
||||
}
|
||||
if is_rerank_api_format(client_api_format.as_str()) {
|
||||
return is_rerank_api_format(provider_api_format.as_str()).then_some((
|
||||
if client_api_format == provider_api_format {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
},
|
||||
rerank_api_format_priority(provider_api_format.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
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_api_format == provider_api_format {
|
||||
0
|
||||
} else if client_kind == provider_kind {
|
||||
1
|
||||
} else if client_family == provider_family {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
|
||||
Some((
|
||||
preference_bucket,
|
||||
standard_api_format_priority(provider_api_format.as_str()),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn request_candidate_api_formats(
|
||||
client_api_format: &str,
|
||||
_require_streaming: bool,
|
||||
) -> Vec<&'static str> {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
if client_api_format == "openai:responses:compact" {
|
||||
return vec!["openai:responses:compact"];
|
||||
}
|
||||
if client_api_format == "openai:search" {
|
||||
return vec!["openai:search"];
|
||||
}
|
||||
if is_gemini_interactions_api_format(client_api_format.as_str()) {
|
||||
return GEMINI_INTERACTIONS_CANDIDATE_API_FORMATS.to_vec();
|
||||
}
|
||||
if is_embedding_api_format(client_api_format.as_str()) {
|
||||
let mut candidate_api_formats = EMBEDDING_CANDIDATE_API_FORMATS.to_vec();
|
||||
candidate_api_formats.sort_by_key(|provider_api_format| {
|
||||
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
|
||||
.unwrap_or((u8::MAX, u8::MAX))
|
||||
});
|
||||
return candidate_api_formats;
|
||||
}
|
||||
if is_rerank_api_format(client_api_format.as_str()) {
|
||||
let mut candidate_api_formats = RERANK_CANDIDATE_API_FORMATS.to_vec();
|
||||
candidate_api_formats.sort_by_key(|provider_api_format| {
|
||||
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
|
||||
.unwrap_or((u8::MAX, u8::MAX))
|
||||
});
|
||||
return candidate_api_formats;
|
||||
}
|
||||
if parse_non_compact_standard_api_format(client_api_format.as_str()).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut candidate_api_formats = NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS.to_vec();
|
||||
candidate_api_formats.sort_by_key(|provider_api_format| {
|
||||
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
|
||||
.unwrap_or((u8::MAX, u8::MAX))
|
||||
});
|
||||
candidate_api_formats
|
||||
}
|
||||
|
||||
pub fn request_conversion_kind(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<RequestConversionKind> {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
if client_api_format == provider_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(client_api_format.as_str())
|
||||
|| !is_standard_api_format(provider_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if is_openai_responses_compact_format(client_api_format.as_str())
|
||||
|| is_openai_responses_compact_format(provider_api_format.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
match provider_api_format.as_str() {
|
||||
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
|
||||
"openai:responses" => Some(RequestConversionKind::ToOpenAiResponses),
|
||||
"claude:messages" => Some(RequestConversionKind::ToClaudeStandard),
|
||||
"gemini:generate_content" => Some(RequestConversionKind::ToGeminiStandard),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_chat_response_conversion_kind(
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
) -> Option<SyncChatResponseConversionKind> {
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
|
||||
match client_api_format.as_str() {
|
||||
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
|
||||
"claude:messages" => Some(SyncChatResponseConversionKind::ToClaudeChat),
|
||||
"gemini:generate_content" => Some(SyncChatResponseConversionKind::ToGeminiChat),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_cli_response_conversion_kind(
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
) -> Option<SyncCliResponseConversionKind> {
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
if !is_openai_responses_compact_format(client_api_format.as_str()) {
|
||||
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
|
||||
}
|
||||
match client_api_format.as_str() {
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
}
|
||||
"claude:messages" => Some(SyncCliResponseConversionKind::ToClaudeCli),
|
||||
"gemini:generate_content" => Some(SyncCliResponseConversionKind::ToGeminiCli),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_conversion_requires_enable_flag(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
let client_api_format = normalize_api_format_alias(client_api_format);
|
||||
let provider_api_format = normalize_api_format_alias(provider_api_format);
|
||||
match (
|
||||
api_data_format_id(client_api_format.as_str()),
|
||||
api_data_format_id(provider_api_format.as_str()),
|
||||
) {
|
||||
(Some(client_data_format), Some(provider_data_format)) => {
|
||||
client_data_format != provider_data_format
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_standard_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "claude:messages"
|
||||
| "gemini:generate_content"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_embedding_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:embedding"
|
||||
| "gemini:embedding"
|
||||
| "jina:embedding"
|
||||
| "doubao:embedding"
|
||||
| "aliyun:multimodal_embedding"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_rerank_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
normalize_api_format_alias(api_format).as_str(),
|
||||
"openai:rerank" | "jina:rerank"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_gemini_interactions_api_format(api_format: &str) -> bool {
|
||||
normalize_api_format_alias(api_format) == "gemini:interactions"
|
||||
}
|
||||
|
||||
pub fn parse_non_compact_standard_api_format(
|
||||
api_format: &str,
|
||||
) -> Option<(&'static str, &'static str)> {
|
||||
match normalize_api_format_alias(api_format).as_str() {
|
||||
"openai:chat" => Some(("openai", "chat")),
|
||||
"openai:responses" => Some(("openai", "responses")),
|
||||
"claude:messages" => Some(("claude", "messages")),
|
||||
"gemini:generate_content" => Some(("gemini", "generate_content")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_data_format_id(api_format: &str) -> Option<&'static str> {
|
||||
match normalize_api_format_alias(api_format).as_str() {
|
||||
"claude:messages" => Some("claude"),
|
||||
"gemini:generate_content" => Some("gemini"),
|
||||
"gemini:interactions" => Some("gemini_interactions"),
|
||||
"openai:chat" => Some("openai_chat"),
|
||||
"openai:responses" | "openai:responses:compact" => Some("openai_responses"),
|
||||
"openai:embedding"
|
||||
| "gemini:embedding"
|
||||
| "jina:embedding"
|
||||
| "doubao:embedding"
|
||||
| "aliyun:multimodal_embedding" => Some("embedding"),
|
||||
"openai:rerank" | "jina:rerank" => Some("rerank"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized_same_standard_api_format(left: &str, right: &str) -> bool {
|
||||
api_format_alias_matches(left, right)
|
||||
}
|
||||
|
||||
fn standard_api_format_priority(api_format: &str) -> u8 {
|
||||
let api_format = normalize_api_format_alias(api_format);
|
||||
STANDARD_API_FORMAT_ORDER
|
||||
.iter()
|
||||
.position(|candidate| *candidate == api_format)
|
||||
.unwrap_or(STANDARD_API_FORMAT_ORDER.len()) as u8
|
||||
}
|
||||
|
||||
fn embedding_api_format_priority(api_format: &str) -> u8 {
|
||||
let api_format = normalize_api_format_alias(api_format);
|
||||
EMBEDDING_CANDIDATE_API_FORMATS
|
||||
.iter()
|
||||
.position(|candidate| *candidate == api_format)
|
||||
.unwrap_or(EMBEDDING_CANDIDATE_API_FORMATS.len()) as u8
|
||||
}
|
||||
|
||||
fn rerank_api_format_priority(api_format: &str) -> u8 {
|
||||
let api_format = normalize_api_format_alias(api_format);
|
||||
RERANK_CANDIDATE_API_FORMATS
|
||||
.iter()
|
||||
.position(|candidate| *candidate == api_format)
|
||||
.unwrap_or(RERANK_CANDIDATE_API_FORMATS.len()) as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
api_data_format_id, is_embedding_api_format, is_gemini_interactions_api_format,
|
||||
is_rerank_api_format, request_candidate_api_format_preference,
|
||||
request_candidate_api_formats, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
};
|
||||
|
||||
fn expected_request_conversion_kind(provider_api_format: &str) -> RequestConversionKind {
|
||||
match provider_api_format {
|
||||
"openai:chat" => RequestConversionKind::ToOpenAIChat,
|
||||
"openai:responses" => RequestConversionKind::ToOpenAiResponses,
|
||||
"claude:messages" => RequestConversionKind::ToClaudeStandard,
|
||||
"gemini:generate_content" => RequestConversionKind::ToGeminiStandard,
|
||||
other => panic!("unexpected provider format {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "openai:responses"),
|
||||
Some(RequestConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "claude:messages"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:responses", "openai:chat"),
|
||||
Some(RequestConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:responses:compact", "gemini:generate_content"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:generate_content", "openai:responses:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "openai:responses: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("openai:search", "openai:responses"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:generate_content", "claude:messages"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(request_conversion_kind("claude:chat", "claude:cli"), None);
|
||||
assert_eq!(
|
||||
request_conversion_kind("claude:messages", "claude:messages"),
|
||||
None
|
||||
);
|
||||
|
||||
let formats = [
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
for client_api_format in formats {
|
||||
for provider_api_format in formats {
|
||||
let actual = request_conversion_kind(client_api_format, provider_api_format);
|
||||
if client_api_format == provider_api_format {
|
||||
assert_eq!(actual, None, "{client_api_format} -> {provider_api_format}");
|
||||
} else {
|
||||
assert_eq!(
|
||||
actual,
|
||||
Some(expected_request_conversion_kind(provider_api_format)),
|
||||
"{client_api_format} -> {provider_api_format}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_candidate_registry_keeps_exact_protocol_identity() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:search", false),
|
||||
vec!["openai:search"]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("/v1/alpha/search", true),
|
||||
vec!["openai:search"]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("openai:search", "openai:search"),
|
||||
Some((0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("openai:search", "openai:responses"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_response_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("openai:chat", "claude:messages"),
|
||||
Some(SyncChatResponseConversionKind::ToClaudeChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("claude:messages", "gemini:generate_content"),
|
||||
Some(SyncChatResponseConversionKind::ToGeminiChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("gemini:generate_content", "openai:chat"),
|
||||
Some(SyncChatResponseConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:responses", "gemini:generate_content"),
|
||||
Some(SyncCliResponseConversionKind::ToGeminiCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:messages", "openai:responses"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:messages", "openai:responses:compact"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:responses:compact", "claude:messages"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("gemini:generate_content", "claude:messages"),
|
||||
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]
|
||||
fn embedding_candidate_registry_excludes_chat_generation_formats() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:embedding", false),
|
||||
vec![
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("jina:embedding", false),
|
||||
vec![
|
||||
"jina:embedding",
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"doubao:embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
]
|
||||
);
|
||||
assert!(!request_candidate_api_formats("openai:embedding", false).contains(&"openai:chat"));
|
||||
assert!(!request_candidate_api_formats("openai:embedding", false)
|
||||
.contains(&"gemini:generate_content"));
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:embedding", "jina:embedding"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:embedding", "openai:chat"),
|
||||
None
|
||||
);
|
||||
assert!(!request_conversion_requires_enable_flag(
|
||||
"openai:embedding",
|
||||
"jina:embedding"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_candidate_registry_covers_all_provider_orderings() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("gemini:embedding", true),
|
||||
vec![
|
||||
"gemini:embedding",
|
||||
"openai:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("doubao:embedding", false),
|
||||
vec![
|
||||
"doubao:embedding",
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("aliyun:multimodal_embedding", false),
|
||||
vec![
|
||||
"aliyun:multimodal_embedding",
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
]
|
||||
);
|
||||
|
||||
let embedding_formats = [
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
];
|
||||
for client_api_format in embedding_formats {
|
||||
for provider_api_format in embedding_formats {
|
||||
assert!(
|
||||
request_candidate_api_format_preference(client_api_format, provider_api_format)
|
||||
.is_some(),
|
||||
"{client_api_format} should consider {provider_api_format} as embedding candidate"
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind(client_api_format, provider_api_format),
|
||||
None,
|
||||
"embedding pair should not use chat/generation conversion kind"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_candidate_registry_never_crosses_chat_generation_boundary() {
|
||||
let embedding_formats = [
|
||||
"openai:embedding",
|
||||
"gemini:embedding",
|
||||
"jina:embedding",
|
||||
"doubao:embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
];
|
||||
let standard_formats = [
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content",
|
||||
];
|
||||
|
||||
for embedding_api_format in embedding_formats {
|
||||
assert!(is_embedding_api_format(embedding_api_format));
|
||||
assert_eq!(api_data_format_id(embedding_api_format), Some("embedding"));
|
||||
for standard_api_format in standard_formats {
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference(
|
||||
embedding_api_format,
|
||||
standard_api_format
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference(
|
||||
standard_api_format,
|
||||
embedding_api_format
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind(embedding_api_format, standard_api_format),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind(standard_api_format, embedding_api_format),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_interactions_stays_same_format_only() {
|
||||
assert!(is_gemini_interactions_api_format("gemini:interactions"));
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("gemini:interactions", false),
|
||||
vec!["gemini:interactions"]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("gemini:interactions", "gemini:interactions"),
|
||||
Some((0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference(
|
||||
"gemini:interactions",
|
||||
"gemini:generate_content"
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:interactions", "gemini:generate_content"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:generate_content", "gemini:interactions"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
api_data_format_id("gemini:interactions"),
|
||||
Some("gemini_interactions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerank_candidate_registry_excludes_chat_and_embedding_formats() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:rerank", false),
|
||||
vec!["openai:rerank", "jina:rerank"]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("jina:rerank", false),
|
||||
vec!["jina:rerank", "openai:rerank"]
|
||||
);
|
||||
assert_eq!(api_data_format_id("openai:rerank"), Some("rerank"));
|
||||
assert!(is_rerank_api_format("jina:rerank"));
|
||||
assert!(!is_embedding_api_format("openai:rerank"));
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("openai:rerank", "openai:embedding"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("openai:rerank", "openai:chat"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:rerank", "jina:rerank"),
|
||||
None
|
||||
);
|
||||
assert!(!request_conversion_requires_enable_flag(
|
||||
"openai:rerank",
|
||||
"jina:rerank"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_candidate_registry_prefers_same_kind_before_same_family_fallbacks() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:chat", false),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
"gemini:generate_content"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:responses", false),
|
||||
vec![
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
"claude:messages",
|
||||
"gemini:generate_content"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("claude:messages", false),
|
||||
vec![
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"gemini:generate_content"
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
request_candidate_api_format_preference("claude:messages", "openai:chat")
|
||||
< request_candidate_api_format_preference("claude:messages", "openai:responses")
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:cli", false),
|
||||
Vec::<&'static str>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("claude:cli", false),
|
||||
Vec::<&'static str>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:compact", false),
|
||||
Vec::<&'static str>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("claude:cli", "openai:responses"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("claude:cli", "claude:chat"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_format_preference("claude:cli", "openai:chat"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversion_enable_flag_only_applies_to_real_data_format_conversions() {
|
||||
assert!(!request_conversion_requires_enable_flag(
|
||||
"claude:messages",
|
||||
"claude:messages"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"claude:chat",
|
||||
"claude:cli"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"openai:chat",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"claude:messages",
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(request_conversion_requires_enable_flag(
|
||||
"openai:compact",
|
||||
"claude:cli"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
pub mod aliyun;
|
||||
pub mod claude;
|
||||
pub mod context;
|
||||
pub mod conversion;
|
||||
pub mod doubao;
|
||||
pub mod gemini;
|
||||
pub mod id;
|
||||
pub mod jina;
|
||||
pub mod matrix;
|
||||
pub mod openai;
|
||||
pub mod registry;
|
||||
pub mod shared;
|
||||
|
||||
pub use context::{FormatContext, FormatError};
|
||||
pub use id::{
|
||||
api_format_alias_matches, api_format_defaults_to_client_error_failover,
|
||||
api_format_defaults_to_non_stream, api_format_permission_covers,
|
||||
api_format_permission_storage_aliases, api_format_storage_aliases,
|
||||
intersect_api_format_allowed_lists, is_openai_responses_compact_format,
|
||||
is_openai_responses_family_format, is_openai_responses_format, normalize_api_format_alias,
|
||||
FormatFamily, FormatId, FormatProfile,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod stream;
|
||||
@@ -0,0 +1,417 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_extension_object_mut, canonical_message_to_openai_chat_messages,
|
||||
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
|
||||
canonical_tool_is_openai_custom, canonical_tool_to_openai, is_claude_tool_result,
|
||||
namespace_extension_object, openai_content_text, openai_extensions,
|
||||
openai_generation_config, openai_message_content_blocks,
|
||||
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
|
||||
openai_tool_choice_raw_to_chat, openai_tool_choice_to_canonical, openai_tools_to_canonical,
|
||||
write_openai_generation_config, CanonicalContentBlock, CanonicalInstruction,
|
||||
CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice,
|
||||
CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(request) {
|
||||
return None;
|
||||
}
|
||||
let mut body = to_raw(request);
|
||||
force_stream_options(&mut body, ctx.upstream_is_stream);
|
||||
Some(body)
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut canonical = CanonicalRequest {
|
||||
model: request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
if let Some(messages) = request.get("messages").and_then(Value::as_array) {
|
||||
for message in messages {
|
||||
let message_object = message.as_object()?;
|
||||
let role = openai_role_to_canonical(
|
||||
message_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if matches!(role, CanonicalRole::System | CanonicalRole::Developer) {
|
||||
let text = openai_content_text(message_object.get("content"));
|
||||
canonical.instructions.push(CanonicalInstruction {
|
||||
role,
|
||||
text: text.clone(),
|
||||
extensions: openai_extensions(message_object, &["role", "content"]),
|
||||
});
|
||||
if !text.trim().is_empty() {
|
||||
canonical.system = Some(match canonical.system.take() {
|
||||
Some(existing) if !existing.trim().is_empty() => {
|
||||
format!("{existing}\n\n{text}")
|
||||
}
|
||||
_ => text,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
canonical
|
||||
.messages
|
||||
.push(crate::protocol::canonical::CanonicalMessage {
|
||||
role,
|
||||
content: openai_message_content_blocks(message_object)?,
|
||||
extensions: openai_extensions(
|
||||
message_object,
|
||||
&["role", "content", "tool_calls", "tool_call_id"],
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
canonical.generation = openai_generation_config(request);
|
||||
canonical.tools = openai_tools_to_canonical(request.get("tools"))?;
|
||||
canonical.tool_choice = openai_tool_choice_to_canonical(request.get("tool_choice"));
|
||||
canonical.parallel_tool_calls = request.get("parallel_tool_calls").and_then(Value::as_bool);
|
||||
canonical.metadata = request.get("metadata").cloned();
|
||||
canonical.response_format = openai_response_format_to_canonical(request.get("response_format"));
|
||||
if let Some(reasoning_effort) = request.get("reasoning_effort").and_then(Value::as_str) {
|
||||
let mut extensions = std::collections::BTreeMap::new();
|
||||
extensions.insert(
|
||||
"openai".to_string(),
|
||||
json!({ "reasoning_effort": reasoning_effort }),
|
||||
);
|
||||
canonical.thinking = Some(CanonicalThinkingConfig {
|
||||
enabled: true,
|
||||
budget_tokens: None,
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
canonical.extensions = openai_extensions(
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"messages",
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"stop",
|
||||
"tools",
|
||||
"parallel_tool_calls",
|
||||
"metadata",
|
||||
"response_format",
|
||||
"reasoning_effort",
|
||||
"n",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
"seed",
|
||||
"logprobs",
|
||||
"top_logprobs",
|
||||
],
|
||||
);
|
||||
if canonical.tool_choice.is_some() {
|
||||
remove_tool_choice_extension(&mut canonical.extensions, "openai");
|
||||
}
|
||||
if let Some(verbosity) = request.get("verbosity").cloned() {
|
||||
canonical_extension_object_mut(
|
||||
&mut canonical.extensions,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
)
|
||||
.insert("verbosity".to_string(), verbosity);
|
||||
}
|
||||
Some(canonical)
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalRequest) -> Value {
|
||||
let mut output = serde_json::Map::new();
|
||||
if !canonical.model.trim().is_empty() {
|
||||
output.insert("model".to_string(), Value::String(canonical.model.clone()));
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for instruction in &canonical.instructions {
|
||||
let role = match instruction.role {
|
||||
CanonicalRole::Developer => "system",
|
||||
_ => "system",
|
||||
};
|
||||
if !instruction.text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": role,
|
||||
"content": instruction.text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
for message in &canonical.messages {
|
||||
messages.extend(canonical_message_to_openai_chat_messages(message));
|
||||
}
|
||||
output.insert("messages".to_string(), Value::Array(messages));
|
||||
|
||||
write_openai_generation_config(&mut output, &canonical.generation);
|
||||
if !canonical.tools.is_empty() {
|
||||
output.insert(
|
||||
"tools".to_string(),
|
||||
Value::Array(
|
||||
canonical
|
||||
.tools
|
||||
.iter()
|
||||
.map(canonical_tool_to_openai)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(tool_choice) = canonical_tool_choice_to_openai_for_request(canonical) {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
if let Some(value) = canonical.parallel_tool_calls {
|
||||
output.insert("parallel_tool_calls".to_string(), Value::Bool(value));
|
||||
}
|
||||
if let Some(metadata) = canonical.metadata.clone() {
|
||||
output.insert("metadata".to_string(), metadata);
|
||||
}
|
||||
if let Some(response_format) = &canonical.response_format {
|
||||
output.insert(
|
||||
"response_format".to_string(),
|
||||
canonical_response_format_to_openai(response_format),
|
||||
);
|
||||
}
|
||||
if let Some(thinking) = &canonical.thinking {
|
||||
if let Some(reasoning_effort) = thinking
|
||||
.extensions
|
||||
.get("openai")
|
||||
.and_then(|value| value.get("reasoning_effort"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
openai_responses_extension(&thinking.extensions)
|
||||
.and_then(|value| value.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.and_then(openai_chat_reasoning_effort)
|
||||
{
|
||||
output.insert(
|
||||
"reasoning_effort".to_string(),
|
||||
Value::String(reasoning_effort.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
"openai",
|
||||
&output,
|
||||
));
|
||||
output.extend(chat_compatible_openai_responses_extension_object(
|
||||
&canonical.extensions,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
&output,
|
||||
));
|
||||
output.extend(chat_compatible_openai_responses_extension_object(
|
||||
&canonical.extensions,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
&output,
|
||||
));
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
fn canonical_tool_choice_to_openai_for_request(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
canonical
|
||||
.tool_choice
|
||||
.as_ref()
|
||||
.map(|tool_choice| canonical_tool_choice_to_openai_for_tools(tool_choice, &canonical.tools))
|
||||
.or_else(|| raw_tool_choice_extension(canonical).map(openai_tool_choice_raw_to_chat))
|
||||
}
|
||||
|
||||
fn canonical_tool_choice_to_openai_for_tools(
|
||||
choice: &CanonicalToolChoice,
|
||||
tools: &[CanonicalToolDefinition],
|
||||
) -> Value {
|
||||
match choice {
|
||||
CanonicalToolChoice::Tool { name }
|
||||
if tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == *name && canonical_tool_is_openai_custom(tool)) =>
|
||||
{
|
||||
json!({
|
||||
"type": "custom",
|
||||
"custom": { "name": name },
|
||||
})
|
||||
}
|
||||
_ => canonical_tool_choice_to_openai(choice),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_tool_choice_extension(canonical: &CanonicalRequest) -> Option<&Value> {
|
||||
canonical
|
||||
.extensions
|
||||
.get("openai")
|
||||
.and_then(|value| value.get("tool_choice"))
|
||||
.or_else(|| {
|
||||
openai_responses_extension(&canonical.extensions)
|
||||
.and_then(|value| value.get("tool_choice"))
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_tool_choice_extension(
|
||||
extensions: &mut std::collections::BTreeMap<String, Value>,
|
||||
namespace: &str,
|
||||
) {
|
||||
let should_remove_namespace = extensions
|
||||
.get_mut(namespace)
|
||||
.and_then(Value::as_object_mut)
|
||||
.is_some_and(|object| {
|
||||
object.remove("tool_choice");
|
||||
object.is_empty()
|
||||
});
|
||||
if should_remove_namespace {
|
||||
extensions.remove(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(
|
||||
request: &CanonicalRequest,
|
||||
) -> bool {
|
||||
request.messages.iter().any(|message| {
|
||||
message.content.iter().any(|block| {
|
||||
let CanonicalContentBlock::ToolResult {
|
||||
output, extensions, ..
|
||||
} = block
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
is_claude_tool_result(extensions)
|
||||
&& output
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
!claude_tool_result_parts_are_openai_chat_representable(parts)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn claude_tool_result_parts_are_openai_chat_representable(parts: &[Value]) -> bool {
|
||||
parts
|
||||
.iter()
|
||||
.all(claude_tool_result_part_is_openai_chat_representable)
|
||||
}
|
||||
|
||||
fn claude_tool_result_part_is_openai_chat_representable(part: &Value) -> bool {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
return false;
|
||||
};
|
||||
match part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text" => true,
|
||||
"image" => claude_image_block_is_openai_chat_representable(part_object),
|
||||
"document" | "file" => claude_document_block_is_openai_chat_representable(part_object),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_image_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
|
||||
let Some(source) = block.get("source").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" => {
|
||||
non_empty_source_str(source, "media_type").is_some()
|
||||
&& non_empty_source_str(source, "data").is_some()
|
||||
}
|
||||
"url" => non_empty_source_str(source, "url").is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_document_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
|
||||
let Some(source) = block.get("source").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" => {
|
||||
non_empty_source_str(source, "media_type").is_some()
|
||||
&& non_empty_source_str(source, "data").is_some()
|
||||
}
|
||||
"url" => non_empty_source_str(source, "url").is_some(),
|
||||
"text" => non_empty_source_str(source, "data").is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_source_str<'a>(source: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||
source
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn openai_chat_reasoning_effort(value: &str) -> Option<&str> {
|
||||
(!value.trim().is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
fn chat_compatible_openai_responses_extension_object(
|
||||
extensions: &std::collections::BTreeMap<String, Value>,
|
||||
namespace: &str,
|
||||
existing: &Map<String, Value>,
|
||||
) -> Map<String, Value> {
|
||||
namespace_extension_object(extensions, namespace, existing)
|
||||
.into_iter()
|
||||
.filter(|(key, _)| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"verbosity"
|
||||
| "store"
|
||||
| "service_tier"
|
||||
| "prompt_cache_key"
|
||||
| "prompt_cache_options"
|
||||
| "prompt_cache_retention"
|
||||
| "safety_identifier"
|
||||
| "user"
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn force_stream_options(body: &mut Value, upstream_is_stream: bool) {
|
||||
if !upstream_is_stream {
|
||||
return;
|
||||
}
|
||||
let Some(object) = body.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
object.insert("stream".to_string(), Value::Bool(true));
|
||||
match object.get_mut("stream_options") {
|
||||
Some(Value::Object(stream_options)) => {
|
||||
stream_options.insert("include_usage".to_string(), Value::Bool(true));
|
||||
}
|
||||
_ => {
|
||||
object.insert(
|
||||
"stream_options".to_string(),
|
||||
json!({
|
||||
"include_usage": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_blocks_to_openai_chat_message, canonical_stop_reason_to_openai,
|
||||
canonical_usage_to_openai, openai_extensions, openai_finish_reason_to_canonical,
|
||||
openai_message_content_blocks, openai_service_tier_extension, openai_usage_to_canonical,
|
||||
CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalResponse, _ctx: &FormatContext) -> Option<Value> {
|
||||
Some(to_raw(response))
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
let mut outputs = Vec::new();
|
||||
for (fallback_index, choice_value) in body
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let choice = choice_value.as_object()?;
|
||||
let message = choice.get("message").and_then(Value::as_object)?;
|
||||
let mut content = openai_message_content_blocks(message)?;
|
||||
if !content
|
||||
.iter()
|
||||
.any(|block| matches!(block, CanonicalContentBlock::Thinking { .. }))
|
||||
{
|
||||
if let Some(reasoning_content) = message
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
content.insert(
|
||||
0,
|
||||
CanonicalContentBlock::Thinking {
|
||||
text: reasoning_content.to_string(),
|
||||
signature: None,
|
||||
encrypted_content: None,
|
||||
extensions: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let stop_reason =
|
||||
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
|
||||
let mut extensions = BTreeMap::new();
|
||||
if let Some(raw_finish_reason) = choice.get("finish_reason").cloned() {
|
||||
extensions.insert(
|
||||
"openai".to_string(),
|
||||
json!({ "raw_finish_reason": raw_finish_reason }),
|
||||
);
|
||||
}
|
||||
outputs.push(CanonicalResponseOutput {
|
||||
index: choice
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(fallback_index),
|
||||
role: CanonicalRole::Assistant,
|
||||
content,
|
||||
stop_reason,
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
let first_output = outputs.first()?;
|
||||
let content = first_output.content.clone();
|
||||
let stop_reason = first_output.stop_reason.clone();
|
||||
Some(CanonicalResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatcmpl-unknown")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
outputs,
|
||||
content,
|
||||
stop_reason,
|
||||
usage: openai_usage_to_canonical(body.get("usage")),
|
||||
extensions: openai_extensions(
|
||||
body,
|
||||
&["id", "object", "model", "choices", "usage", "created"],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse) -> Value {
|
||||
let outputs: Vec<CanonicalResponseOutput> = if canonical.outputs.is_empty() {
|
||||
vec![CanonicalResponseOutput {
|
||||
index: 0,
|
||||
role: CanonicalRole::Assistant,
|
||||
content: canonical.content.clone(),
|
||||
stop_reason: canonical.stop_reason.clone(),
|
||||
extensions: BTreeMap::new(),
|
||||
}]
|
||||
} else {
|
||||
canonical.outputs.clone()
|
||||
};
|
||||
let choices: Vec<Value> = outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(fallback_index, output)| {
|
||||
let finish_reason = output
|
||||
.extensions
|
||||
.get("openai")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|openai| openai.get("raw_finish_reason"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
Value::String(
|
||||
canonical_stop_reason_to_openai(output.stop_reason.as_ref()).to_string(),
|
||||
)
|
||||
});
|
||||
json!({
|
||||
"index": output.index,
|
||||
"message": canonical_blocks_to_openai_chat_message(&output.content),
|
||||
"finish_reason": finish_reason,
|
||||
})
|
||||
.as_object()
|
||||
.map(|choice| {
|
||||
let mut choice = choice.clone();
|
||||
if output.index == 0 && fallback_index != 0 {
|
||||
choice.insert("index".to_string(), Value::from(fallback_index as u64));
|
||||
}
|
||||
Value::Object(choice)
|
||||
})
|
||||
.unwrap_or_else(|| json!({}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut response = json!({
|
||||
"id": canonical.id,
|
||||
"object": "chat.completion",
|
||||
"model": canonical.model,
|
||||
"choices": choices,
|
||||
"usage": canonical.usage.as_ref().map(canonical_usage_to_openai).unwrap_or_else(|| json!({
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
})),
|
||||
});
|
||||
if let Some(created_at) = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(|value| value.get("created_at"))
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().map(|value| value as i64))
|
||||
})
|
||||
{
|
||||
response["created"] = Value::from(created_at);
|
||||
}
|
||||
if let Some(service_tier) = openai_service_tier_extension(&canonical.extensions).cloned() {
|
||||
response["service_tier"] = service_tier;
|
||||
}
|
||||
response
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
@@ -0,0 +1,161 @@
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::protocol::canonical::{
|
||||
namespace_extension_object, CanonicalEmbeddingInput, CanonicalEmbeddingRequest,
|
||||
CanonicalRequest,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_namespace(body, "openai")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"openai",
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_namespace(body_json: &Value, namespace: &str) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let model = request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let input =
|
||||
serde_json::from_value::<CanonicalEmbeddingInput>(request.get("input")?.clone()).ok()?;
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let embedding = CanonicalEmbeddingRequest {
|
||||
input,
|
||||
encoding_format: request
|
||||
.get("encoding_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
dimensions: request.get("dimensions").and_then(Value::as_u64),
|
||||
task: request
|
||||
.get("task")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
user: request
|
||||
.get("user")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
parameters: request
|
||||
.get("parameters")
|
||||
.and_then(Value::as_object)
|
||||
.cloned(),
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"input",
|
||||
"encoding_format",
|
||||
"dimensions",
|
||||
"task",
|
||||
"user",
|
||||
"parameters",
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
Some(CanonicalRequest {
|
||||
model,
|
||||
embedding: Some(embedding),
|
||||
..CanonicalRequest::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_openai_like(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
namespace: &str,
|
||||
default_task: bool,
|
||||
) -> Option<Value> {
|
||||
let embedding = canonical.embedding.as_ref()?;
|
||||
if embedding.input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if matches!(&embedding.input, CanonicalEmbeddingInput::Multimodal(_)) {
|
||||
return None;
|
||||
}
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_embedding_model(canonical, mapped_model)),
|
||||
);
|
||||
output.insert(
|
||||
"input".to_string(),
|
||||
serde_json::to_value(&embedding.input).ok()?,
|
||||
);
|
||||
if let Some(value) = &embedding.encoding_format {
|
||||
output.insert("encoding_format".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
if let Some(value) = embedding.dimensions {
|
||||
output.insert("dimensions".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = &embedding.user {
|
||||
output.insert("user".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
if let Some(value) = &embedding.parameters {
|
||||
output.insert("parameters".to_string(), Value::Object(value.clone()));
|
||||
}
|
||||
if let Some(task) = embedding
|
||||
.task
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
output.insert("task".to_string(), Value::String(task.clone()));
|
||||
} else if default_task {
|
||||
output.insert(
|
||||
"task".to_string(),
|
||||
Value::String("text-matching".to_string()),
|
||||
);
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&embedding.extensions,
|
||||
namespace,
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
pub(crate) fn mapped_embedding_model(canonical: &CanonicalRequest, mapped_model: &str) -> String {
|
||||
let mapped_model = mapped_model.trim();
|
||||
if mapped_model.is_empty() {
|
||||
canonical.model.clone()
|
||||
} else {
|
||||
mapped_model.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn namespace_extensions(
|
||||
namespace: &str,
|
||||
object: &Map<String, Value>,
|
||||
handled_keys: &[&str],
|
||||
) -> BTreeMap<String, Value> {
|
||||
let handled = handled_keys
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let raw = object
|
||||
.iter()
|
||||
.filter(|(key, _)| !handled.contains(key.as_str()))
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect::<Map<String, Value>>();
|
||||
if raw.is_empty() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
BTreeMap::from([(namespace.to_string(), Value::Object(raw))])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map};
|
||||
|
||||
use crate::formats::openai::embedding::request::namespace_extensions;
|
||||
use crate::protocol::canonical::{
|
||||
canonical_usage_to_openai, namespace_extension_object, openai_usage_to_canonical,
|
||||
CanonicalEmbedding, CanonicalEmbeddingResponse,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value) -> Option<CanonicalEmbeddingResponse> {
|
||||
from_namespace(body, "openai")
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalEmbeddingResponse) -> Option<Value> {
|
||||
Some(to_openai_like(response, "openai"))
|
||||
}
|
||||
|
||||
pub(crate) fn from_namespace(
|
||||
body_json: &Value,
|
||||
namespace: &str,
|
||||
) -> Option<CanonicalEmbeddingResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
let data = body.get("data")?.as_array()?;
|
||||
let mut embeddings = Vec::new();
|
||||
for (fallback_index, item) in data.iter().enumerate() {
|
||||
let item_object = item.as_object()?;
|
||||
let values = item_object.get("embedding")?.as_array()?;
|
||||
let embedding = values
|
||||
.iter()
|
||||
.map(Value::as_f64)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
embeddings.push(CanonicalEmbedding {
|
||||
index: item_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.unwrap_or(fallback_index),
|
||||
embedding,
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
item_object,
|
||||
&["object", "index", "embedding"],
|
||||
),
|
||||
});
|
||||
}
|
||||
Some(CanonicalEmbeddingResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("embd-unknown")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
embeddings,
|
||||
usage: openai_usage_to_canonical(body.get("usage")),
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
body,
|
||||
&["id", "object", "model", "data", "usage"],
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_openai_like(canonical: &CanonicalEmbeddingResponse, namespace: &str) -> Value {
|
||||
let mut response = Map::new();
|
||||
response.insert("object".to_string(), Value::String("list".to_string()));
|
||||
if !canonical.model.trim().is_empty() && canonical.model != "unknown" {
|
||||
response.insert("model".to_string(), Value::String(canonical.model.clone()));
|
||||
}
|
||||
response.insert(
|
||||
"data".to_string(),
|
||||
Value::Array(
|
||||
canonical
|
||||
.embeddings
|
||||
.iter()
|
||||
.map(|embedding| {
|
||||
let mut item = Map::new();
|
||||
item.insert("object".to_string(), Value::String("embedding".to_string()));
|
||||
item.insert("index".to_string(), Value::from(embedding.index as u64));
|
||||
item.insert("embedding".to_string(), json!(embedding.embedding));
|
||||
item.extend(namespace_extension_object(
|
||||
&embedding.extensions,
|
||||
namespace,
|
||||
&item,
|
||||
));
|
||||
Value::Object(item)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
if let Some(usage) = &canonical.usage {
|
||||
response.insert("usage".to_string(), canonical_usage_to_openai(usage));
|
||||
}
|
||||
response.extend(namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
namespace,
|
||||
&response,
|
||||
));
|
||||
Value::Object(response)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::contracts::{
|
||||
OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
};
|
||||
use crate::formats::shared::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "openai:embedding",
|
||||
decision_kind: OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
report_kind: OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND,
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Embedding,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_sync_spec;
|
||||
use crate::formats::shared::family::LocalStandardSourceMode;
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_embedding_sync_standard_spec() {
|
||||
let spec = resolve_sync_spec("openai_embedding_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:embedding");
|
||||
assert_eq!(spec.report_kind, "openai_embedding_sync_finalize");
|
||||
assert_eq!(spec.mode, LocalStandardSourceMode::Embedding);
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod request;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
use crate::contracts::{OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalOpenAiImageSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiImageSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(LocalOpenAiImageSpec {
|
||||
api_format: "openai:image",
|
||||
decision_kind: OPENAI_IMAGE_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_image_sync_finalize",
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiImageSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_IMAGE_STREAM_PLAN_KIND => Some(LocalOpenAiImageSpec {
|
||||
api_format: "openai:image",
|
||||
decision_kind: OPENAI_IMAGE_STREAM_PLAN_KIND,
|
||||
report_kind: "openai_image_stream_success",
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_image_sync_spec() {
|
||||
let spec = resolve_sync_spec("openai_image_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:image");
|
||||
assert_eq!(spec.report_kind, "openai_image_sync_finalize");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_image_stream_spec() {
|
||||
let spec = resolve_stream_spec("openai_image_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:image");
|
||||
assert_eq!(spec.report_kind, "openai_image_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
pub mod chat;
|
||||
pub mod embedding;
|
||||
pub mod image;
|
||||
pub mod prompt_cache;
|
||||
pub mod reasoning;
|
||||
pub mod request_contract;
|
||||
pub mod rerank;
|
||||
pub mod responses;
|
||||
pub mod search;
|
||||
pub mod shared;
|
||||
pub mod video;
|
||||
@@ -0,0 +1,663 @@
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OpenAiPromptCacheViolationKind {
|
||||
InvalidType,
|
||||
InvalidEnum,
|
||||
UnsupportedForModel,
|
||||
UnsupportedContentBlock,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenAiPromptCacheContractViolation {
|
||||
pub kind: OpenAiPromptCacheViolationKind,
|
||||
pub field: String,
|
||||
pub value: Option<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn validate_openai_prompt_cache_request(
|
||||
source_api_format: &str,
|
||||
provider_model: &str,
|
||||
body: &Value,
|
||||
) -> Result<(), OpenAiPromptCacheContractViolation> {
|
||||
let source_model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
validate_openai_prompt_cache_request_with_source_model(
|
||||
source_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_openai_prompt_cache_ttl_minutes(
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
body: &Value,
|
||||
) -> Option<i64> {
|
||||
OpenAiPromptCacheApi::parse(provider_api_format)?;
|
||||
let request = body.as_object()?;
|
||||
let explicit_ttl = request
|
||||
.get("prompt_cache_options")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|options| options.get("ttl"))
|
||||
.and_then(Value::as_str);
|
||||
if explicit_ttl == Some("30m") {
|
||||
return Some(30);
|
||||
}
|
||||
|
||||
let capability_model =
|
||||
crate::formats::shared::model_directives::openai_model_capability_identity(
|
||||
provider_model,
|
||||
source_model,
|
||||
);
|
||||
crate::openai_model_supports_prompt_cache_options(&capability_model).then_some(30)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_openai_prompt_cache_request_with_source_model(
|
||||
source_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
body: &Value,
|
||||
) -> Result<(), OpenAiPromptCacheContractViolation> {
|
||||
let Some(api) = OpenAiPromptCacheApi::parse(source_api_format) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(request) = body.as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
let capability_model =
|
||||
crate::formats::shared::model_directives::openai_model_capability_identity(
|
||||
provider_model,
|
||||
source_model,
|
||||
);
|
||||
let supports_prompt_cache_options =
|
||||
crate::formats::shared::model_directives::openai_model_capability_is_opaque(
|
||||
provider_model,
|
||||
source_model,
|
||||
) || crate::openai_model_supports_prompt_cache_options(&capability_model);
|
||||
|
||||
if let Some(options) = request
|
||||
.get("prompt_cache_options")
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
validate_prompt_cache_options(options, supports_prompt_cache_options)?;
|
||||
}
|
||||
if let Some(retention) = request
|
||||
.get("prompt_cache_retention")
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
validate_prompt_cache_retention(retention, &capability_model)?;
|
||||
}
|
||||
match api {
|
||||
OpenAiPromptCacheApi::Chat => {
|
||||
validate_chat_prompt_cache_breakpoints(request, supports_prompt_cache_options)
|
||||
}
|
||||
OpenAiPromptCacheApi::Responses => {
|
||||
validate_responses_prompt_cache_breakpoints(request, supports_prompt_cache_options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OpenAiPromptCacheApi {
|
||||
Chat,
|
||||
Responses,
|
||||
}
|
||||
|
||||
impl OpenAiPromptCacheApi {
|
||||
fn parse(api_format: &str) -> Option<Self> {
|
||||
match crate::normalize_api_format_alias(api_format).as_str() {
|
||||
"openai:chat" => Some(Self::Chat),
|
||||
"openai:responses" | "openai:responses:compact" => Some(Self::Responses),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_provider_model(model: &str) -> &str {
|
||||
model.trim().rsplit('/').next().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn validate_prompt_cache_options(
|
||||
value: &Value,
|
||||
supported_for_model: bool,
|
||||
) -> Result<(), OpenAiPromptCacheContractViolation> {
|
||||
if !supported_for_model {
|
||||
return Err(unsupported_for_model(
|
||||
"prompt_cache_options",
|
||||
"provider model does not support prompt_cache_options",
|
||||
));
|
||||
}
|
||||
let Some(options) = value.as_object() else {
|
||||
return Err(invalid_type(
|
||||
"prompt_cache_options",
|
||||
value,
|
||||
"prompt_cache_options must be an object",
|
||||
));
|
||||
};
|
||||
if let Some(mode) = options.get("mode") {
|
||||
let Some(raw) = mode.as_str() else {
|
||||
return Err(invalid_type(
|
||||
"prompt_cache_options.mode",
|
||||
mode,
|
||||
"prompt_cache_options.mode must be a string",
|
||||
));
|
||||
};
|
||||
if !matches!(raw, "implicit" | "explicit") {
|
||||
return Err(invalid_enum(
|
||||
"prompt_cache_options.mode",
|
||||
raw,
|
||||
"prompt_cache_options.mode supports implicit or explicit",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ttl) = options.get("ttl") {
|
||||
let Some(raw) = ttl.as_str() else {
|
||||
return Err(invalid_type(
|
||||
"prompt_cache_options.ttl",
|
||||
ttl,
|
||||
"prompt_cache_options.ttl must be a string",
|
||||
));
|
||||
};
|
||||
if raw != "30m" {
|
||||
return Err(invalid_enum(
|
||||
"prompt_cache_options.ttl",
|
||||
raw,
|
||||
"prompt_cache_options.ttl supports 30m",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_prompt_cache_retention(
|
||||
value: &Value,
|
||||
provider_model: &str,
|
||||
) -> Result<(), OpenAiPromptCacheContractViolation> {
|
||||
if crate::openai_model_supports_prompt_cache_options(provider_model) {
|
||||
return Err(unsupported_for_model(
|
||||
"prompt_cache_retention",
|
||||
"provider model uses prompt_cache_options.ttl",
|
||||
));
|
||||
}
|
||||
let Some(raw) = value.as_str() else {
|
||||
return Err(invalid_type(
|
||||
"prompt_cache_retention",
|
||||
value,
|
||||
"prompt_cache_retention must be a string",
|
||||
));
|
||||
};
|
||||
if super::shared::OpenAiPromptCacheRetention::parse(raw).is_none() {
|
||||
return Err(invalid_enum(
|
||||
"prompt_cache_retention",
|
||||
raw,
|
||||
"prompt_cache_retention supports in_memory or 24h",
|
||||
));
|
||||
}
|
||||
if gpt_5_5_retention_is_24h_only(provider_model) && raw != "24h" {
|
||||
return Err(invalid_enum(
|
||||
"prompt_cache_retention",
|
||||
raw,
|
||||
"GPT-5.5 family models support 24h retention",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gpt_5_5_retention_is_24h_only(model: &str) -> bool {
|
||||
let normalized = normalize_provider_model(model)
|
||||
.to_ascii_lowercase()
|
||||
.replace('_', "-");
|
||||
matches!(normalized.as_str(), "gpt-5.5" | "gpt-5.5-pro")
|
||||
}
|
||||
|
||||
fn validate_chat_prompt_cache_breakpoints(
|
||||
request: &serde_json::Map<String, Value>,
|
||||
supported_for_model: bool,
|
||||
) -> Result<(), OpenAiPromptCacheContractViolation> {
|
||||
let Some(messages) = request.get("messages").and_then(Value::as_array) else {
|
||||
return Ok(());
|
||||
};
|
||||
for (message_index, message) in messages.iter().enumerate() {
|
||||
let Some(content) = message
|
||||
.as_object()
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (content_index, part) in content.iter().enumerate() {
|
||||
let Some(part) = part.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(breakpoint) = part.get("prompt_cache_breakpoint") else {
|
||||
continue;
|
||||
};
|
||||
let block_type = part.get("type").and_then(Value::as_str);
|
||||
let supported = matches!(
|
||||
block_type,
|
||||
Some("text" | "image_url" | "input_audio" | "file" | "refusal")
|
||||
);
|
||||
validate_prompt_cache_breakpoint(
|
||||
breakpoint,
|
||||
&format!(
|
||||
"messages[{message_index}].content[{content_index}].prompt_cache_breakpoint"
|
||||
),
|
||||
supported,
|
||||
supported_for_model,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_responses_prompt_cache_breakpoints(
|
||||
request: &serde_json::Map<String, Value>,
|
||||
supported_for_model: bool,
|
||||
) -> Result<(), OpenAiPromptCacheContractViolation> {
|
||||
let Some(input) = request.get("input").and_then(Value::as_array) else {
|
||||
return Ok(());
|
||||
};
|
||||
for (item_index, item) in input.iter().enumerate() {
|
||||
let Some(item) = item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(breakpoint) = item.get("prompt_cache_breakpoint") {
|
||||
validate_prompt_cache_breakpoint(
|
||||
breakpoint,
|
||||
&format!("input[{item_index}].prompt_cache_breakpoint"),
|
||||
responses_cache_breakpoint_block_is_supported(item),
|
||||
supported_for_model,
|
||||
)?;
|
||||
}
|
||||
let Some(content) = item.get("content").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
for (content_index, part) in content.iter().enumerate() {
|
||||
let Some(part) = part.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(breakpoint) = part.get("prompt_cache_breakpoint") else {
|
||||
continue;
|
||||
};
|
||||
validate_prompt_cache_breakpoint(
|
||||
breakpoint,
|
||||
&format!("input[{item_index}].content[{content_index}].prompt_cache_breakpoint"),
|
||||
responses_cache_breakpoint_block_is_supported(part),
|
||||
supported_for_model,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn responses_cache_breakpoint_block_is_supported(block: &serde_json::Map<String, Value>) -> bool {
|
||||
matches!(
|
||||
block.get("type").and_then(Value::as_str),
|
||||
Some("input_text" | "input_image" | "input_file")
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_prompt_cache_breakpoint(
|
||||
value: &Value,
|
||||
field: &str,
|
||||
supported_content_block: bool,
|
||||
supported_for_model: bool,
|
||||
) -> Result<(), OpenAiPromptCacheContractViolation> {
|
||||
if !supported_for_model {
|
||||
return Err(unsupported_for_model(
|
||||
field,
|
||||
"provider model does not support prompt_cache_breakpoint",
|
||||
));
|
||||
}
|
||||
if !supported_content_block {
|
||||
return Err(OpenAiPromptCacheContractViolation {
|
||||
kind: OpenAiPromptCacheViolationKind::UnsupportedContentBlock,
|
||||
field: field.to_string(),
|
||||
value: None,
|
||||
reason: "content block does not support prompt_cache_breakpoint".to_string(),
|
||||
});
|
||||
}
|
||||
let Some(breakpoint) = value.as_object() else {
|
||||
return Err(invalid_type(
|
||||
field,
|
||||
value,
|
||||
"prompt_cache_breakpoint must be an object",
|
||||
));
|
||||
};
|
||||
let mode_field = format!("{field}.mode");
|
||||
let Some(mode) = breakpoint.get("mode") else {
|
||||
return Err(OpenAiPromptCacheContractViolation {
|
||||
kind: OpenAiPromptCacheViolationKind::InvalidType,
|
||||
field: mode_field,
|
||||
value: None,
|
||||
reason: "prompt_cache_breakpoint.mode is required".to_string(),
|
||||
});
|
||||
};
|
||||
let Some(raw) = mode.as_str() else {
|
||||
return Err(invalid_type(
|
||||
&mode_field,
|
||||
mode,
|
||||
"prompt_cache_breakpoint.mode must be a string",
|
||||
));
|
||||
};
|
||||
if raw != "explicit" {
|
||||
return Err(invalid_enum(
|
||||
&mode_field,
|
||||
raw,
|
||||
"prompt_cache_breakpoint.mode supports explicit",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalid_type(field: &str, value: &Value, reason: &str) -> OpenAiPromptCacheContractViolation {
|
||||
OpenAiPromptCacheContractViolation {
|
||||
kind: OpenAiPromptCacheViolationKind::InvalidType,
|
||||
field: field.to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: reason.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_enum(field: &str, value: &str, reason: &str) -> OpenAiPromptCacheContractViolation {
|
||||
OpenAiPromptCacheContractViolation {
|
||||
kind: OpenAiPromptCacheViolationKind::InvalidEnum,
|
||||
field: field.to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: reason.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_for_model(field: &str, reason: &str) -> OpenAiPromptCacheContractViolation {
|
||||
OpenAiPromptCacheContractViolation {
|
||||
kind: OpenAiPromptCacheViolationKind::UnsupportedForModel,
|
||||
field: field.to_string(),
|
||||
value: None,
|
||||
reason: reason.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
resolve_openai_prompt_cache_ttl_minutes, validate_openai_prompt_cache_request,
|
||||
OpenAiPromptCacheViolationKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn resolves_effective_prompt_cache_ttl_from_current_openai_contract() {
|
||||
for body in [
|
||||
json!({"model": "client-alias"}),
|
||||
json!({
|
||||
"model": "client-alias",
|
||||
"prompt_cache_options": {"mode": "implicit"}
|
||||
}),
|
||||
json!({
|
||||
"model": "client-alias",
|
||||
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}
|
||||
}),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_openai_prompt_cache_ttl_minutes(
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
"client-alias",
|
||||
&body,
|
||||
),
|
||||
Some(30)
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolve_openai_prompt_cache_ttl_minutes(
|
||||
"openai:chat",
|
||||
"deployment-alias",
|
||||
"gpt-5.6-terra",
|
||||
&json!({"model": "gpt-5.6-terra"}),
|
||||
),
|
||||
Some(30)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_openai_prompt_cache_ttl_minutes(
|
||||
"openai:chat",
|
||||
"gpt-5.5",
|
||||
"gpt-5.6-terra",
|
||||
&json!({"model": "gpt-5.6-terra"}),
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_openai_prompt_cache_ttl_minutes(
|
||||
"claude:messages",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"model": "gpt-5.6-sol"}),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_6_accepts_current_prompt_cache_options_and_breakpoints() {
|
||||
for (format, body) in [
|
||||
(
|
||||
"openai:chat",
|
||||
json!({
|
||||
"model": "client-alias",
|
||||
"prompt_cache_options": {"mode": "implicit", "ttl": "30m"},
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": "ZmFrZQ==", "format": "mp3"},
|
||||
"prompt_cache_breakpoint": {"mode": "explicit"}
|
||||
}]
|
||||
}]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"openai:responses",
|
||||
json!({
|
||||
"model": "client-alias",
|
||||
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"},
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_file",
|
||||
"file_id": "file_123",
|
||||
"prompt_cache_breakpoint": {"mode": "explicit"}
|
||||
}]
|
||||
}]
|
||||
}),
|
||||
),
|
||||
] {
|
||||
validate_openai_prompt_cache_request(format, "gpt-5.6-sol", &body)
|
||||
.expect("GPT-5.6 prompt cache contract should be accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_6_uses_prompt_cache_options_and_rejects_invalid_enums() {
|
||||
let retention_error = validate_openai_prompt_cache_request(
|
||||
"openai:chat",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"prompt_cache_retention": "24h"}),
|
||||
)
|
||||
.expect_err("GPT-5.6 uses prompt_cache_options.ttl");
|
||||
assert_eq!(
|
||||
retention_error.kind,
|
||||
OpenAiPromptCacheViolationKind::UnsupportedForModel
|
||||
);
|
||||
|
||||
let cases = [
|
||||
(
|
||||
json!({"prompt_cache_options": {"mode": "automatic"}}),
|
||||
"prompt_cache_options.mode",
|
||||
OpenAiPromptCacheViolationKind::InvalidEnum,
|
||||
),
|
||||
(
|
||||
json!({"prompt_cache_options": {"ttl": "1h"}}),
|
||||
"prompt_cache_options.ttl",
|
||||
OpenAiPromptCacheViolationKind::InvalidEnum,
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "stable",
|
||||
"prompt_cache_breakpoint": {"mode": "implicit"}
|
||||
}]
|
||||
}]
|
||||
}),
|
||||
"messages[0].content[0].prompt_cache_breakpoint.mode",
|
||||
OpenAiPromptCacheViolationKind::InvalidEnum,
|
||||
),
|
||||
];
|
||||
|
||||
for (body, field, kind) in cases {
|
||||
let error = validate_openai_prompt_cache_request("openai:chat", "gpt-5.6-sol", &body)
|
||||
.expect_err("invalid GPT-5.6 cache contract should fail");
|
||||
assert_eq!(error.field, field);
|
||||
assert_eq!(error.kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_capability_uses_the_provider_model() {
|
||||
let options = json!({
|
||||
"model": "gpt-5.5",
|
||||
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"},
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
validate_openai_prompt_cache_request("openai:chat", "gpt-5.6-terra", &options)
|
||||
.expect("mapped GPT-5.6 provider model should enable prompt_cache_options");
|
||||
let error = validate_openai_prompt_cache_request("openai:chat", "gpt-5.5", &options)
|
||||
.expect_err("mapped earlier provider model should reject prompt_cache_options");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
OpenAiPromptCacheViolationKind::UnsupportedForModel
|
||||
);
|
||||
let error =
|
||||
validate_openai_prompt_cache_request("openai:chat", "deployment-alias", &options)
|
||||
.expect_err("opaque provider model must not inherit source model capability");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
OpenAiPromptCacheViolationKind::UnsupportedForModel
|
||||
);
|
||||
|
||||
let retention = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"prompt_cache_retention": "24h",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
validate_openai_prompt_cache_request("openai:chat", "gpt-5.5", &retention)
|
||||
.expect("mapped earlier provider model should retain its retention contract");
|
||||
let error = validate_openai_prompt_cache_request("openai:chat", "gpt-5.6-luna", &retention)
|
||||
.expect_err("GPT-5.6 uses prompt_cache_options.ttl");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
OpenAiPromptCacheViolationKind::UnsupportedForModel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_breakpoints_validate_supported_blocks_per_api() {
|
||||
let cases = [
|
||||
(
|
||||
"openai:chat",
|
||||
json!({
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": "https://example.com/video.mp4"},
|
||||
"prompt_cache_breakpoint": {"mode": "explicit"}
|
||||
}]
|
||||
}]
|
||||
}),
|
||||
"messages[0].content[0].prompt_cache_breakpoint",
|
||||
),
|
||||
(
|
||||
"openai:responses",
|
||||
json!({
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_audio",
|
||||
"input_audio": {"data": "ZmFrZQ==", "format": "mp3"},
|
||||
"prompt_cache_breakpoint": {"mode": "explicit"}
|
||||
}]
|
||||
}]
|
||||
}),
|
||||
"input[0].content[0].prompt_cache_breakpoint",
|
||||
),
|
||||
];
|
||||
|
||||
for (format, body, field) in cases {
|
||||
let error = validate_openai_prompt_cache_request(format, "gpt-5.6-sol", &body)
|
||||
.expect_err("unsupported cache breakpoint block should fail");
|
||||
assert_eq!(error.field, field);
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
OpenAiPromptCacheViolationKind::UnsupportedContentBlock
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_models_reject_breakpoint_options_and_validate_retention() {
|
||||
let options = json!({"prompt_cache_options": {"mode": "explicit"}});
|
||||
let error = validate_openai_prompt_cache_request("openai:responses", "gpt-5.5", &options)
|
||||
.expect_err("earlier model should reject prompt_cache_options");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
OpenAiPromptCacheViolationKind::UnsupportedForModel
|
||||
);
|
||||
|
||||
validate_openai_prompt_cache_request(
|
||||
"openai:responses",
|
||||
"gpt-5.5-pro",
|
||||
&json!({"prompt_cache_retention": "24h"}),
|
||||
)
|
||||
.expect("GPT-5.5 supports 24h retention");
|
||||
let error = validate_openai_prompt_cache_request(
|
||||
"openai:responses",
|
||||
"gpt-5.5",
|
||||
&json!({"prompt_cache_retention": "in_memory"}),
|
||||
)
|
||||
.expect_err("GPT-5.5 only supports 24h retention");
|
||||
assert_eq!(error.kind, OpenAiPromptCacheViolationKind::InvalidEnum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_prompt_cache_fields_are_treated_as_unconfigured() {
|
||||
for format in [
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
"openai:responses:compact",
|
||||
] {
|
||||
validate_openai_prompt_cache_request(
|
||||
format,
|
||||
"gpt-5.6-sol",
|
||||
&json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"prompt_cache_options": null,
|
||||
"prompt_cache_retention": null
|
||||
}),
|
||||
)
|
||||
.expect("nullable prompt cache fields should be omitted semantically");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::shared::model_directives::ReasoningEffort;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OpenAiReasoningViolationKind {
|
||||
InvalidType,
|
||||
InvalidEnum,
|
||||
UnsupportedForModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenAiReasoningContractViolation {
|
||||
pub kind: OpenAiReasoningViolationKind,
|
||||
pub field: String,
|
||||
pub value: Option<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn validate_openai_reasoning_request(
|
||||
source_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
body: &Value,
|
||||
) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let source_model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
validate_openai_reasoning_request_with_source_model(
|
||||
source_api_format,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_openai_reasoning_request_with_source_model(
|
||||
source_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
body: &Value,
|
||||
) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
validate_openai_reasoning_request_with_model_profile(
|
||||
source_api_format,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_openai_reasoning_request_with_model_profile(
|
||||
source_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
body: &Value,
|
||||
model_card_reasoning_efforts: Option<&[String]>,
|
||||
supports_reasoning_mode: Option<bool>,
|
||||
) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let Some(object) = body.as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
let source_api_format = crate::normalize_api_format_alias(source_api_format);
|
||||
let reasoning = match source_api_format.as_str() {
|
||||
"openai:responses" | "openai:responses:compact" | "openai:search" => {
|
||||
match object.get("reasoning") {
|
||||
Some(Value::Object(reasoning)) => Some(reasoning),
|
||||
Some(Value::Null) => None,
|
||||
Some(value) => {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidType,
|
||||
field: "reasoning".to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: "reasoning must be an object".to_string(),
|
||||
});
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
"openai:chat" => None,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let provider_model = provider_model.trim();
|
||||
let provider_model = if provider_model.is_empty() {
|
||||
source_model
|
||||
} else {
|
||||
provider_model
|
||||
};
|
||||
|
||||
let effort = match source_api_format.as_str() {
|
||||
"openai:chat" => object.get("reasoning_effort"),
|
||||
"openai:responses" | "openai:responses:compact" | "openai:search" => {
|
||||
reasoning.and_then(|reasoning| reasoning.get("effort"))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(value) = effort.filter(|value| !value.is_null()) {
|
||||
validate_reasoning_effort(
|
||||
value,
|
||||
source_api_format.as_str(),
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
model_card_reasoning_efforts,
|
||||
)?;
|
||||
}
|
||||
|
||||
if source_api_format != "openai:search" {
|
||||
if let Some(mode) = reasoning
|
||||
.and_then(|reasoning| reasoning.get("mode"))
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
validate_reasoning_mode(mode, provider_model, source_model, supports_reasoning_mode)?;
|
||||
}
|
||||
}
|
||||
if let Some(context) = reasoning
|
||||
.and_then(|reasoning| reasoning.get("context"))
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
validate_reasoning_context(context)?;
|
||||
}
|
||||
if let Some(summary) = reasoning
|
||||
.and_then(|reasoning| reasoning.get("summary"))
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
validate_reasoning_summary(summary)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_reasoning_effort(
|
||||
value: &Value,
|
||||
source_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
model_card_reasoning_efforts: Option<&[String]>,
|
||||
) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let field = if source_api_format == "openai:chat" {
|
||||
"reasoning_effort"
|
||||
} else {
|
||||
"reasoning.effort"
|
||||
};
|
||||
let Some(raw) = value.as_str() else {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidType,
|
||||
field: field.to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: "reasoning effort must be a string".to_string(),
|
||||
});
|
||||
};
|
||||
if raw.trim().is_empty() {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidEnum,
|
||||
field: field.to_string(),
|
||||
value: Some(raw.to_string()),
|
||||
reason: "reasoning effort must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
if raw.trim() == "ultra" {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidEnum,
|
||||
field: field.to_string(),
|
||||
value: Some(raw.to_string()),
|
||||
reason: "ultra is a Codex client preset, not an OpenAI wire effort".to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(supported_efforts) =
|
||||
model_card_reasoning_efforts.filter(|values| !values.is_empty())
|
||||
{
|
||||
if supported_efforts
|
||||
.iter()
|
||||
.any(|effort| effort == raw.trim() || (raw.trim() == "max" && effort == "ultra"))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::UnsupportedForModel,
|
||||
field: field.to_string(),
|
||||
value: Some(raw.to_string()),
|
||||
reason: "provider model card does not support the requested reasoning effort"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let Some(effort) = ReasoningEffort::parse(raw) else {
|
||||
return Ok(());
|
||||
};
|
||||
if crate::reasoning_effort_supported_for_model(
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
effort,
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::UnsupportedForModel,
|
||||
field: field.to_string(),
|
||||
value: Some(raw.to_string()),
|
||||
reason: "provider model does not support the requested reasoning effort".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_reasoning_mode(
|
||||
value: &Value,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
supports_reasoning_mode: Option<bool>,
|
||||
) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let Some(mode) = value.as_str() else {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidType,
|
||||
field: "reasoning.mode".to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: "reasoning mode must be a string".to_string(),
|
||||
});
|
||||
};
|
||||
if mode.trim().is_empty() {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidEnum,
|
||||
field: "reasoning.mode".to_string(),
|
||||
value: Some(mode.to_string()),
|
||||
reason: "reasoning mode must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
if !matches!(mode, "standard" | "pro") {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidEnum,
|
||||
field: "reasoning.mode".to_string(),
|
||||
value: Some(mode.to_string()),
|
||||
reason: "reasoning mode supports standard or pro".to_string(),
|
||||
});
|
||||
}
|
||||
let supported = supports_reasoning_mode.unwrap_or_else(|| {
|
||||
crate::formats::shared::model_directives::openai_model_resolves_to_gpt_5_6(
|
||||
provider_model,
|
||||
source_model,
|
||||
)
|
||||
});
|
||||
if supported {
|
||||
return Ok(());
|
||||
}
|
||||
Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::UnsupportedForModel,
|
||||
field: "reasoning.mode".to_string(),
|
||||
value: Some(mode.to_string()),
|
||||
reason: "provider model does not support reasoning mode".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_reasoning_context(value: &Value) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let Some(context) = value.as_str() else {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidType,
|
||||
field: "reasoning.context".to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: "reasoning context must be a string".to_string(),
|
||||
});
|
||||
};
|
||||
if matches!(context, "auto" | "current_turn" | "all_turns") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidEnum,
|
||||
field: "reasoning.context".to_string(),
|
||||
value: Some(context.to_string()),
|
||||
reason: "reasoning context is not a supported wire value".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_reasoning_summary(value: &Value) -> Result<(), OpenAiReasoningContractViolation> {
|
||||
let Some(summary) = value.as_str() else {
|
||||
return Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidType,
|
||||
field: "reasoning.summary".to_string(),
|
||||
value: Some(value.to_string()),
|
||||
reason: "reasoning summary must be a string".to_string(),
|
||||
});
|
||||
};
|
||||
if matches!(summary, "auto" | "concise" | "detailed") {
|
||||
return Ok(());
|
||||
}
|
||||
Err(OpenAiReasoningContractViolation {
|
||||
kind: OpenAiReasoningViolationKind::InvalidEnum,
|
||||
field: "reasoning.summary".to_string(),
|
||||
value: Some(summary.to_string()),
|
||||
reason: "reasoning summary is not a supported wire value".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{validate_openai_reasoning_request, OpenAiReasoningViolationKind};
|
||||
|
||||
#[test]
|
||||
fn mapped_model_is_authoritative_for_openai_reasoning_effort() {
|
||||
let alias = json!({
|
||||
"model": "deployment-alias",
|
||||
"reasoning": {"effort": "max"}
|
||||
});
|
||||
validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&alias,
|
||||
)
|
||||
.expect("GPT-5.6 should accept max");
|
||||
|
||||
let error = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.4",
|
||||
&alias,
|
||||
)
|
||||
.expect_err("GPT-5.4 should reject max");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
OpenAiReasoningViolationKind::UnsupportedForModel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_6_rejects_known_unsupported_effort_and_preserves_custom_effort() {
|
||||
let unsupported = json!({
|
||||
"model": "gpt-5.6-terra",
|
||||
"reasoning_effort": "minimal"
|
||||
});
|
||||
let error = validate_openai_reasoning_request(
|
||||
"openai:chat",
|
||||
"openai:chat",
|
||||
"gpt-5.6-terra",
|
||||
&unsupported,
|
||||
)
|
||||
.expect_err("known unsupported effort should be rejected");
|
||||
assert_eq!(
|
||||
error.kind,
|
||||
OpenAiReasoningViolationKind::UnsupportedForModel
|
||||
);
|
||||
|
||||
validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-terra",
|
||||
&json!({
|
||||
"model": "gpt-5.6-terra",
|
||||
"reasoning": {"effort": "future"}
|
||||
}),
|
||||
)
|
||||
.expect("model-advertised custom effort should pass through");
|
||||
|
||||
let ultra = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-terra",
|
||||
&json!({"reasoning": {"effort": "ultra"}}),
|
||||
)
|
||||
.expect_err("Codex local ultra preset should not enter the OpenAI wire contract");
|
||||
assert_eq!(ultra.kind, OpenAiReasoningViolationKind::InvalidEnum);
|
||||
|
||||
let empty = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-terra",
|
||||
&json!({"reasoning": {"effort": " "}}),
|
||||
)
|
||||
.expect_err("empty reasoning effort should be rejected");
|
||||
assert_eq!(empty.kind, OpenAiReasoningViolationKind::InvalidEnum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_mode_is_responses_only_and_requires_gpt_5_6() {
|
||||
for mode in ["standard", "pro"] {
|
||||
validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"model": "deployment-alias", "reasoning": {"mode": mode}}),
|
||||
)
|
||||
.expect("GPT-5.6 should accept reasoning mode");
|
||||
}
|
||||
|
||||
let unsupported = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.4",
|
||||
&json!({"reasoning": {"mode": "pro"}}),
|
||||
)
|
||||
.expect_err("earlier GPT models should reject reasoning mode");
|
||||
assert_eq!(
|
||||
unsupported.kind,
|
||||
OpenAiReasoningViolationKind::UnsupportedForModel
|
||||
);
|
||||
|
||||
let invalid = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"reasoning": {"mode": "fast"}}),
|
||||
)
|
||||
.expect_err("unknown reasoning mode should be rejected");
|
||||
assert_eq!(invalid.kind, OpenAiReasoningViolationKind::InvalidEnum);
|
||||
|
||||
let empty = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"reasoning": {"mode": ""}}),
|
||||
)
|
||||
.expect_err("empty reasoning mode should be rejected");
|
||||
assert_eq!(empty.kind, OpenAiReasoningViolationKind::InvalidEnum);
|
||||
|
||||
validate_openai_reasoning_request(
|
||||
"openai:chat",
|
||||
"openai:chat",
|
||||
"gpt-5.4",
|
||||
&json!({"reasoning": {"mode": "pro"}}),
|
||||
)
|
||||
.expect("Chat Completions does not define reasoning.mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_context_validates_wire_values_without_model_gating() {
|
||||
for context in ["auto", "current_turn", "all_turns"] {
|
||||
validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.4",
|
||||
&json!({"reasoning": {"context": context}}),
|
||||
)
|
||||
.expect("reasoning context should remain available to Codex Responses models");
|
||||
}
|
||||
|
||||
let invalid = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"reasoning": {"context": "session"}}),
|
||||
)
|
||||
.expect_err("unknown reasoning context should be rejected");
|
||||
assert_eq!(invalid.kind, OpenAiReasoningViolationKind::InvalidEnum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_accepts_only_openai_wire_values() {
|
||||
for summary in ["auto", "concise", "detailed"] {
|
||||
validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"reasoning": {"summary": summary}}),
|
||||
)
|
||||
.expect("documented reasoning summary should be accepted");
|
||||
}
|
||||
|
||||
for (summary, expected_kind) in [
|
||||
(json!("none"), OpenAiReasoningViolationKind::InvalidEnum),
|
||||
(json!(true), OpenAiReasoningViolationKind::InvalidType),
|
||||
] {
|
||||
let error = validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"reasoning": {"summary": summary}}),
|
||||
)
|
||||
.expect_err("invalid reasoning summary should be rejected");
|
||||
assert_eq!(error.kind, expected_kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_reasoning_fields_are_treated_as_unconfigured() {
|
||||
for body in [
|
||||
json!({"model": "gpt-5.6-sol", "reasoning": null}),
|
||||
json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoning": {"effort": null, "mode": null, "context": null}
|
||||
}),
|
||||
] {
|
||||
validate_openai_reasoning_request(
|
||||
"openai:responses",
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
&body,
|
||||
)
|
||||
.expect("nullable Responses reasoning fields should be omitted semantically");
|
||||
}
|
||||
validate_openai_reasoning_request(
|
||||
"openai:chat",
|
||||
"openai:chat",
|
||||
"gpt-5.6-sol",
|
||||
&json!({"model": "gpt-5.6-sol", "reasoning_effort": null}),
|
||||
)
|
||||
.expect("nullable Chat reasoning effort should be omitted semantically");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::prompt_cache::OpenAiPromptCacheContractViolation;
|
||||
use super::reasoning::OpenAiReasoningContractViolation;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OpenAiProviderRequestContractViolation {
|
||||
CodexCompact(super::responses::codex::CodexOpenAiCompactRequestContractViolation),
|
||||
Responses(super::responses::request::OpenAiResponsesRequestContractViolation),
|
||||
PromptCache(OpenAiPromptCacheContractViolation),
|
||||
Reasoning(OpenAiReasoningContractViolation),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct OpenAiProviderRequestFinalization<'a> {
|
||||
pub source_api_format: &'a str,
|
||||
pub provider_api_format: &'a str,
|
||||
pub provider_type: &'a str,
|
||||
pub provider_model: &'a str,
|
||||
pub source_model: &'a str,
|
||||
pub body_rules: Option<&'a Value>,
|
||||
pub upstream_is_stream: bool,
|
||||
pub require_body_stream_field: bool,
|
||||
}
|
||||
|
||||
pub fn finalize_openai_provider_request(
|
||||
body: &mut Value,
|
||||
finalization: OpenAiProviderRequestFinalization<'_>,
|
||||
) -> Result<(), OpenAiProviderRequestContractViolation> {
|
||||
finalize_openai_provider_request_with_codex_model_capabilities(body, finalization, None)
|
||||
}
|
||||
|
||||
pub fn finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
body: &mut Value,
|
||||
finalization: OpenAiProviderRequestFinalization<'_>,
|
||||
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
|
||||
) -> Result<(), OpenAiProviderRequestContractViolation> {
|
||||
let is_codex_reasoning_endpoint = finalization
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
&& (crate::is_openai_responses_family_format(finalization.provider_api_format)
|
||||
|| crate::api_format_alias_matches(finalization.provider_api_format, "openai:search"));
|
||||
let resolved_model_capabilities = (is_codex_reasoning_endpoint && model_capabilities.is_none())
|
||||
.then(|| {
|
||||
super::responses::codex::resolve_codex_responses_model_capabilities(
|
||||
finalization.provider_model,
|
||||
finalization.source_model,
|
||||
None,
|
||||
)
|
||||
});
|
||||
let model_capabilities = is_codex_reasoning_endpoint
|
||||
.then(|| model_capabilities.or(resolved_model_capabilities.as_ref()))
|
||||
.flatten();
|
||||
match crate::normalize_api_format_alias(finalization.source_api_format).as_str() {
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
super::responses::codex::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
|
||||
body,
|
||||
finalization.provider_type,
|
||||
finalization.provider_api_format,
|
||||
finalization.provider_model,
|
||||
finalization.source_model,
|
||||
model_capabilities,
|
||||
finalization.body_rules,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
super::responses::codex::apply_codex_openai_responses_chat_body_edits_with_source_model_and_capabilities(
|
||||
body,
|
||||
finalization.provider_type,
|
||||
finalization.provider_api_format,
|
||||
finalization.provider_model,
|
||||
finalization.source_model,
|
||||
model_capabilities,
|
||||
finalization.body_rules,
|
||||
)
|
||||
}
|
||||
}
|
||||
super::responses::codex::normalize_codex_openai_reasoning_wire_effort(
|
||||
body,
|
||||
finalization.provider_type,
|
||||
finalization.provider_api_format,
|
||||
);
|
||||
super::responses::codex::apply_openai_responses_compact_special_body_edits(
|
||||
body,
|
||||
finalization.provider_api_format,
|
||||
);
|
||||
crate::enforce_request_body_stream_field(
|
||||
body,
|
||||
finalization.provider_api_format,
|
||||
finalization.upstream_is_stream,
|
||||
finalization.require_body_stream_field,
|
||||
);
|
||||
super::search::apply_openai_search_request_projection(body, finalization.provider_api_format);
|
||||
let provider_model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(finalization.provider_model);
|
||||
super::responses::codex::validate_codex_openai_responses_compact_request_contract(
|
||||
body,
|
||||
finalization.provider_type,
|
||||
finalization.provider_api_format,
|
||||
)
|
||||
.map_err(OpenAiProviderRequestContractViolation::CodexCompact)?;
|
||||
validate_openai_provider_request_contract_with_codex_model_capabilities(
|
||||
finalization.provider_api_format,
|
||||
provider_model,
|
||||
finalization.source_model,
|
||||
body,
|
||||
model_capabilities,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn validate_openai_provider_request_contract(
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
body: &Value,
|
||||
) -> Result<(), OpenAiProviderRequestContractViolation> {
|
||||
validate_openai_provider_request_contract_with_codex_model_capabilities(
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_openai_provider_request_contract_with_codex_model_capabilities(
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
body: &Value,
|
||||
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
|
||||
) -> Result<(), OpenAiProviderRequestContractViolation> {
|
||||
super::responses::request::validate_openai_responses_request_contract(
|
||||
body,
|
||||
provider_api_format,
|
||||
)
|
||||
.map_err(OpenAiProviderRequestContractViolation::Responses)?;
|
||||
super::prompt_cache::validate_openai_prompt_cache_request_with_source_model(
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
)
|
||||
.map_err(OpenAiProviderRequestContractViolation::PromptCache)?;
|
||||
super::reasoning::validate_openai_reasoning_request_with_model_profile(
|
||||
provider_api_format,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
body,
|
||||
model_capabilities.map(|capabilities| capabilities.supported_reasoning_efforts.as_slice()),
|
||||
None,
|
||||
)
|
||||
.map_err(OpenAiProviderRequestContractViolation::Reasoning)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
finalize_openai_provider_request,
|
||||
finalize_openai_provider_request_with_codex_model_capabilities,
|
||||
validate_openai_provider_request_contract, OpenAiProviderRequestFinalization,
|
||||
};
|
||||
use crate::CodexResponsesModelCapabilities;
|
||||
|
||||
#[test]
|
||||
fn validates_reasoning_and_prompt_cache_against_the_final_provider_model() {
|
||||
let body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "max"},
|
||||
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}
|
||||
});
|
||||
validate_openai_provider_request_contract(
|
||||
"openai:responses",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-sol",
|
||||
&body,
|
||||
)
|
||||
.expect("GPT-5.6 request should satisfy the final provider contract");
|
||||
|
||||
assert!(validate_openai_provider_request_contract(
|
||||
"openai:responses",
|
||||
"gpt-5.4",
|
||||
"gpt-5.6-sol",
|
||||
&body,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_provider_models_inherit_source_capabilities_but_concrete_models_do_not() {
|
||||
let body = json!({
|
||||
"model": "azure-production",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "max", "mode": "pro"},
|
||||
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}
|
||||
});
|
||||
validate_openai_provider_request_contract(
|
||||
"openai:responses",
|
||||
"azure-production",
|
||||
"gpt-5.6-sol-max",
|
||||
&body,
|
||||
)
|
||||
.expect("opaque deployments should inherit the concrete source model capability");
|
||||
assert!(validate_openai_provider_request_contract(
|
||||
"openai:responses",
|
||||
"gpt-5.4",
|
||||
"gpt-5.6-sol",
|
||||
&body,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_finalization_enforces_model_card_reasoning_efforts() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "minimal"}
|
||||
});
|
||||
|
||||
let error = finalize_openai_provider_request(
|
||||
&mut body,
|
||||
OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: false,
|
||||
require_body_stream_field: true,
|
||||
},
|
||||
)
|
||||
.expect_err("GPT-5.6 Codex model card should reject minimal");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(
|
||||
super::OpenAiReasoningContractViolation {
|
||||
kind: crate::formats::openai::reasoning::OpenAiReasoningViolationKind::UnsupportedForModel,
|
||||
..
|
||||
}
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalization_reapplies_codex_and_compact_projection_after_mutations() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [],
|
||||
"store": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"client_metadata": {"source": "mapping"},
|
||||
"stream": true,
|
||||
"stream_options": {"include_usage": true},
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.5,
|
||||
"previous_response_id": "resp_123"
|
||||
});
|
||||
finalize_openai_provider_request(
|
||||
&mut body,
|
||||
OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses:compact",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: false,
|
||||
require_body_stream_field: true,
|
||||
},
|
||||
)
|
||||
.expect("final Compact request should satisfy its provider contract");
|
||||
|
||||
for field in [
|
||||
"store",
|
||||
"include",
|
||||
"client_metadata",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"tool_choice",
|
||||
"temperature",
|
||||
"previous_response_id",
|
||||
] {
|
||||
assert!(body.get(field).is_none(), "{field} must not reach Compact");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_responses_sources_receive_codex_responses_reasoning_defaults() {
|
||||
for source_api_format in ["openai:chat", "claude:messages", "gemini:generate_content"] {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": []
|
||||
});
|
||||
finalize_openai_provider_request(
|
||||
&mut body,
|
||||
OpenAiProviderRequestFinalization {
|
||||
source_api_format,
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
require_body_stream_field: true,
|
||||
},
|
||||
)
|
||||
.expect("Codex Responses request should satisfy the final provider contract");
|
||||
|
||||
assert_eq!(body["reasoning"]["effort"], "low");
|
||||
assert!(body["reasoning"].get("summary").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_ultra_preset_uses_max_for_every_codex_model_on_the_wire() {
|
||||
let mut sol = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "ultra"}
|
||||
});
|
||||
let mut luna = json!({
|
||||
"model": "gpt-5.6-luna",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "ultra"}
|
||||
});
|
||||
let finalization_for = |model| OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: model,
|
||||
source_model: model,
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
require_body_stream_field: true,
|
||||
};
|
||||
|
||||
finalize_openai_provider_request(&mut sol, finalization_for("gpt-5.6-sol"))
|
||||
.expect("Sol ultra preset should map to the OpenAI wire contract");
|
||||
assert_eq!(sol["reasoning"]["effort"], "max");
|
||||
|
||||
finalize_openai_provider_request(&mut luna, finalization_for("gpt-5.6-luna"))
|
||||
.expect("Luna ultra preset should map to the OpenAI wire contract");
|
||||
assert_eq!(luna["reasoning"]["effort"], "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_codex_card_controls_default_effort_and_keeps_mode_model_specific() {
|
||||
let finalization = OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.7-sol",
|
||||
source_model: "gpt-5.7-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
require_body_stream_field: true,
|
||||
};
|
||||
let capabilities = CodexResponsesModelCapabilities {
|
||||
use_responses_lite: true,
|
||||
supports_reasoning_summary_parameter: true,
|
||||
default_reasoning_effort: Some("ultra".to_string()),
|
||||
default_reasoning_summary: None,
|
||||
supported_reasoning_efforts: vec!["max".to_string(), "ultra".to_string()],
|
||||
supports_parallel_tool_calls: true,
|
||||
support_verbosity: true,
|
||||
default_verbosity: Some("low".to_string()),
|
||||
supported_service_tiers: vec!["priority".to_string()],
|
||||
};
|
||||
|
||||
let mut default_body = json!({"model": "gpt-5.7-sol", "input": []});
|
||||
finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut default_body,
|
||||
finalization,
|
||||
Some(&capabilities),
|
||||
)
|
||||
.expect("card default ultra should use the max wire effort");
|
||||
assert_eq!(default_body["reasoning"]["effort"], "max");
|
||||
|
||||
let mut mode_body = json!({
|
||||
"model": "gpt-5.7-sol",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "max", "mode": "pro"}
|
||||
});
|
||||
let mode_error = finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut mode_body,
|
||||
finalization,
|
||||
Some(&capabilities),
|
||||
)
|
||||
.expect_err("Responses Lite alone must not enable GPT-5.6 reasoning modes");
|
||||
assert!(matches!(
|
||||
mode_error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(_)
|
||||
));
|
||||
|
||||
let ultra_only = CodexResponsesModelCapabilities {
|
||||
supported_reasoning_efforts: vec!["ultra".to_string()],
|
||||
..capabilities.clone()
|
||||
};
|
||||
let mut ultra_only_body = json!({
|
||||
"model": "gpt-5.7-sol",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "ultra"}
|
||||
});
|
||||
finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut ultra_only_body,
|
||||
finalization,
|
||||
Some(&ultra_only),
|
||||
)
|
||||
.expect("Codex maps the Ultra preset to max without card-list wire validation");
|
||||
assert_eq!(ultra_only_body["reasoning"]["effort"], "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_search_normalizes_reasoning_and_projects_the_typed_request() {
|
||||
let finalization = OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:search",
|
||||
provider_api_format: "openai:search",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: false,
|
||||
require_body_stream_field: false,
|
||||
};
|
||||
let mut body = json!({
|
||||
"id": "session-1",
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoning": {
|
||||
"effort": "ultra",
|
||||
"summary": "auto",
|
||||
"context": "current_turn",
|
||||
"future_reasoning_field": true
|
||||
},
|
||||
"commands": {"search_query": [{"q": "Aether"}]},
|
||||
"store": false,
|
||||
"future_request_field": {"enabled": true},
|
||||
"stream": true
|
||||
});
|
||||
|
||||
finalize_openai_provider_request(&mut body, finalization)
|
||||
.expect("Codex Search request should finalize");
|
||||
|
||||
assert_eq!(body["reasoning"]["effort"], "max");
|
||||
assert_eq!(body["reasoning"]["summary"], "auto");
|
||||
assert_eq!(body["reasoning"]["context"], "current_turn");
|
||||
assert!(body["reasoning"].get("future_reasoning_field").is_none());
|
||||
assert_eq!(body["commands"]["search_query"][0]["q"], "Aether");
|
||||
assert!(body.get("store").is_none());
|
||||
assert!(body.get("future_request_field").is_none());
|
||||
assert!(body.get("stream").is_none());
|
||||
assert!(body.get("tool_choice").is_none());
|
||||
assert!(body.get("include").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_search_validates_reasoning_effort_against_model_card() {
|
||||
let finalization = OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:search",
|
||||
provider_api_format: "openai:search",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: false,
|
||||
require_body_stream_field: false,
|
||||
};
|
||||
let mut supported = json!({
|
||||
"id": "session-1",
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoning": {"effort": "high"}
|
||||
});
|
||||
finalize_openai_provider_request(&mut supported, finalization)
|
||||
.expect("published Search effort should pass");
|
||||
|
||||
let mut unsupported = json!({
|
||||
"id": "session-1",
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoning": {"effort": "none"}
|
||||
});
|
||||
let error = finalize_openai_provider_request(&mut unsupported, finalization)
|
||||
.expect_err("unpublished Search effort should be rejected");
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_codex_card_preserves_custom_reasoning_effort_case() {
|
||||
let finalization = OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "codex-custom",
|
||||
source_model: "codex-custom",
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
require_body_stream_field: true,
|
||||
};
|
||||
let capabilities = CodexResponsesModelCapabilities {
|
||||
use_responses_lite: false,
|
||||
supports_reasoning_summary_parameter: true,
|
||||
default_reasoning_effort: Some("VendorEffortX".to_string()),
|
||||
default_reasoning_summary: None,
|
||||
supported_reasoning_efforts: vec!["VendorEffortX".to_string()],
|
||||
supports_parallel_tool_calls: true,
|
||||
support_verbosity: true,
|
||||
default_verbosity: Some("low".to_string()),
|
||||
supported_service_tiers: vec![],
|
||||
};
|
||||
|
||||
let mut body = json!({"model": "codex-custom", "input": []});
|
||||
finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut body,
|
||||
finalization,
|
||||
Some(&capabilities),
|
||||
)
|
||||
.expect("custom card effort should remain exact");
|
||||
assert_eq!(body["reasoning"]["effort"], "VendorEffortX");
|
||||
|
||||
let mut custom = json!({
|
||||
"model": "codex-custom",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "vendoreffortx"}
|
||||
});
|
||||
let error = finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut custom,
|
||||
finalization,
|
||||
Some(&capabilities),
|
||||
)
|
||||
.expect_err("custom reasoning efforts should match the model card exactly");
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(_)
|
||||
));
|
||||
|
||||
let mut ultra = json!({
|
||||
"model": "codex-custom",
|
||||
"input": [],
|
||||
"reasoning": {"effort": "ultra"}
|
||||
});
|
||||
let error = finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut ultra,
|
||||
finalization,
|
||||
Some(&capabilities),
|
||||
)
|
||||
.expect_err("ultra should require model-card support before mapping to max");
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::OpenAiProviderRequestContractViolation::Reasoning(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_6_sol_uses_the_responses_lite_request_contract() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"instructions": "Follow the project instructions.",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,aGVsbG8=",
|
||||
"detail": "original"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call-1",
|
||||
"output": [{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,ZnVuY3Rpb24=",
|
||||
"detail": "high"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call-2",
|
||||
"output": [{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,Y3VzdG9t",
|
||||
"detail": "auto"
|
||||
}]
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}],
|
||||
"parallel_tool_calls": true
|
||||
});
|
||||
|
||||
let finalization = OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
require_body_stream_field: true,
|
||||
};
|
||||
finalize_openai_provider_request(&mut body, finalization)
|
||||
.expect("GPT-5.6 Sol should satisfy the Responses Lite contract");
|
||||
let first = body.clone();
|
||||
finalize_openai_provider_request(&mut body, finalization)
|
||||
.expect("Responses Lite finalization should be idempotent");
|
||||
|
||||
assert_eq!(body, first);
|
||||
assert!(body.get("instructions").is_none());
|
||||
assert!(body.get("tools").is_none());
|
||||
assert_eq!(body["input"][0]["type"], "additional_tools");
|
||||
assert_eq!(body["input"][0]["role"], "developer");
|
||||
assert_eq!(body["input"][0]["tools"][0]["name"], "lookup");
|
||||
assert_eq!(body["input"][1]["type"], "message");
|
||||
assert_eq!(body["input"][1]["role"], "developer");
|
||||
assert_eq!(
|
||||
body["input"][1]["content"][0]["text"],
|
||||
"Follow the project instructions."
|
||||
);
|
||||
assert!(body["input"][2]["content"][0].get("detail").is_none());
|
||||
assert!(body["input"][3]["output"][0].get("detail").is_none());
|
||||
assert!(body["input"][4]["output"][0].get("detail").is_none());
|
||||
assert_eq!(
|
||||
body["input"][0]["tools"][0]["parameters"]["properties"]["detail"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert_eq!(body["parallel_tool_calls"], false);
|
||||
assert_eq!(body["reasoning"]["effort"], "low");
|
||||
assert_eq!(body["reasoning"]["context"], "all_turns");
|
||||
assert!(body["reasoning"].get("summary").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_6_sol_uses_standard_responses_contract_for_server_side_compaction() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"instructions": "Preserve the standard Responses request shape.",
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "compact"}]
|
||||
}],
|
||||
"tools": [{"type": "function", "name": "lookup"}],
|
||||
"context_management": [{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 128000
|
||||
}],
|
||||
"parallel_tool_calls": true
|
||||
});
|
||||
let finalization = OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
require_body_stream_field: true,
|
||||
};
|
||||
|
||||
finalize_openai_provider_request(&mut body, finalization)
|
||||
.expect("server-side compaction should use the standard Responses contract");
|
||||
let first = body.clone();
|
||||
finalize_openai_provider_request(&mut body, finalization)
|
||||
.expect("standard Responses finalization should be idempotent");
|
||||
|
||||
assert_eq!(body, first);
|
||||
assert_eq!(body["context_management"][0]["compact_threshold"], 128000);
|
||||
assert_eq!(
|
||||
body["instructions"],
|
||||
"Preserve the standard Responses request shape."
|
||||
);
|
||||
assert_eq!(body["tools"][0]["name"], "lookup");
|
||||
assert_eq!(body["parallel_tool_calls"], true);
|
||||
assert!(body["reasoning"].get("context").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_codex_deployments_use_the_exact_source_model_card() {
|
||||
let mut body = json!({
|
||||
"model": "azure-production",
|
||||
"instructions": "Use the configured tools.",
|
||||
"input": [],
|
||||
"tools": [],
|
||||
"parallel_tool_calls": true
|
||||
});
|
||||
|
||||
finalize_openai_provider_request(
|
||||
&mut body,
|
||||
OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses:compact",
|
||||
provider_type: "codex",
|
||||
provider_model: "azure-production",
|
||||
source_model: "gpt-5.6-terra",
|
||||
body_rules: None,
|
||||
upstream_is_stream: false,
|
||||
require_body_stream_field: true,
|
||||
},
|
||||
)
|
||||
.expect("opaque Codex deployment should use the exact source model card");
|
||||
|
||||
assert!(body.get("instructions").is_none());
|
||||
assert!(body.get("tools").is_none());
|
||||
assert_eq!(body["input"][0]["type"], "additional_tools");
|
||||
assert_eq!(body["input"][1]["role"], "developer");
|
||||
assert_eq!(body["parallel_tool_calls"], false);
|
||||
assert_eq!(body["reasoning"]["effort"], "medium");
|
||||
assert_eq!(body["reasoning"]["context"], "all_turns");
|
||||
for field in body.as_object().expect("object").keys() {
|
||||
assert!(
|
||||
[
|
||||
"model",
|
||||
"input",
|
||||
"instructions",
|
||||
"tools",
|
||||
"parallel_tool_calls",
|
||||
"reasoning",
|
||||
"service_tier",
|
||||
"prompt_cache_key",
|
||||
"text",
|
||||
]
|
||||
.contains(&field.as_str()),
|
||||
"unexpected Compact field: {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt_5_4_keeps_the_standard_codex_responses_shape() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"instructions": "Keep this top-level instruction.",
|
||||
"input": [],
|
||||
"tools": [{"type": "function", "name": "lookup", "parameters": {}}],
|
||||
"parallel_tool_calls": true
|
||||
});
|
||||
|
||||
finalize_openai_provider_request(
|
||||
&mut body,
|
||||
OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:responses",
|
||||
provider_api_format: "openai:responses",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.4",
|
||||
source_model: "gpt-5.4",
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
require_body_stream_field: true,
|
||||
},
|
||||
)
|
||||
.expect("GPT-5.4 should satisfy the standard Codex Responses contract");
|
||||
|
||||
assert_eq!(body["instructions"], "Keep this top-level instruction.");
|
||||
assert_eq!(body["tools"][0]["name"], "lookup");
|
||||
assert_eq!(body["parallel_tool_calls"], true);
|
||||
assert_eq!(body["reasoning"]["effort"], "medium");
|
||||
assert!(body["reasoning"].get("context").is_none());
|
||||
assert!(body["reasoning"].get("summary").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_compact_finalization_removes_post_conversion_fields() {
|
||||
for source_api_format in ["claude:messages", "gemini:generate_content"] {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [],
|
||||
"client_metadata": {"source": "mapping"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"store": true,
|
||||
"stream": true,
|
||||
"stream_options": {"include_usage": true},
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "max"},
|
||||
"text": {"verbosity": "medium"},
|
||||
"tools": [{"type": "function", "name": "lookup", "parameters": {}}]
|
||||
});
|
||||
finalize_openai_provider_request(
|
||||
&mut body,
|
||||
OpenAiProviderRequestFinalization {
|
||||
source_api_format,
|
||||
provider_api_format: "openai:responses:compact",
|
||||
provider_type: "codex",
|
||||
provider_model: "gpt-5.6-sol",
|
||||
source_model: "gpt-5.6-sol",
|
||||
body_rules: None,
|
||||
upstream_is_stream: false,
|
||||
require_body_stream_field: true,
|
||||
},
|
||||
)
|
||||
.expect("cross-format Compact request should satisfy its final contract");
|
||||
|
||||
for field in [
|
||||
"client_metadata",
|
||||
"include",
|
||||
"store",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"tool_choice",
|
||||
] {
|
||||
assert!(body.get(field).is_none(), "{field} must not reach Compact");
|
||||
}
|
||||
assert_eq!(body["parallel_tool_calls"], false);
|
||||
assert_eq!(body["reasoning"]["effort"], "max");
|
||||
assert_eq!(body["reasoning"]["context"], "all_turns");
|
||||
assert_eq!(body["text"]["verbosity"], "medium");
|
||||
assert!(body.get("tools").is_none());
|
||||
assert_eq!(body["input"][0]["tools"][0]["name"], "lookup");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod request;
|
||||
@@ -0,0 +1,113 @@
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::context::FormatContext;
|
||||
use crate::formats::openai::embedding::request::namespace_extensions;
|
||||
use crate::protocol::canonical::{
|
||||
namespace_extension_object, CanonicalRequest, CanonicalRerankRequest,
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
from_namespace(body, "openai")
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
to_openai_like(
|
||||
request,
|
||||
ctx.mapped_model_or(request.model.as_str()),
|
||||
"openai",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_namespace(body_json: &Value, namespace: &str) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let model = request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let query = request
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let documents = request.get("documents").and_then(Value::as_array)?.to_vec();
|
||||
let rerank = CanonicalRerankRequest {
|
||||
query,
|
||||
documents,
|
||||
top_n: request
|
||||
.get("top_n")
|
||||
.or_else(|| request.get("topN"))
|
||||
.and_then(Value::as_u64),
|
||||
return_documents: request
|
||||
.get("return_documents")
|
||||
.or_else(|| request.get("returnDocuments"))
|
||||
.and_then(Value::as_bool),
|
||||
extensions: namespace_extensions(
|
||||
namespace,
|
||||
request,
|
||||
&[
|
||||
"model",
|
||||
"query",
|
||||
"documents",
|
||||
"top_n",
|
||||
"topN",
|
||||
"return_documents",
|
||||
"returnDocuments",
|
||||
],
|
||||
),
|
||||
};
|
||||
if rerank.is_empty() || rerank.top_n == Some(0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CanonicalRequest {
|
||||
model,
|
||||
rerank: Some(rerank),
|
||||
..CanonicalRequest::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn to_openai_like(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
namespace: &str,
|
||||
) -> Option<Value> {
|
||||
let rerank = canonical.rerank.as_ref()?;
|
||||
if rerank.is_empty() || rerank.top_n == Some(0) {
|
||||
return None;
|
||||
}
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"model".to_string(),
|
||||
Value::String(mapped_rerank_model(canonical, mapped_model)),
|
||||
);
|
||||
output.insert("query".to_string(), Value::String(rerank.query.clone()));
|
||||
output.insert(
|
||||
"documents".to_string(),
|
||||
Value::Array(rerank.documents.clone()),
|
||||
);
|
||||
if let Some(value) = rerank.top_n {
|
||||
output.insert("top_n".to_string(), Value::from(value));
|
||||
}
|
||||
if let Some(value) = rerank.return_documents {
|
||||
output.insert("return_documents".to_string(), Value::Bool(value));
|
||||
}
|
||||
output.extend(namespace_extension_object(
|
||||
&rerank.extensions,
|
||||
namespace,
|
||||
&output,
|
||||
));
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn mapped_rerank_model(canonical: &CanonicalRequest, mapped_model: &str) -> String {
|
||||
let mapped_model = mapped_model.trim();
|
||||
if mapped_model.is_empty() {
|
||||
canonical.model.clone()
|
||||
} else {
|
||||
mapped_model.to_string()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod codex;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
pub mod stream;
|
||||
|
||||
const TOOL_ERROR_PREFIX: &str = "[tool error]";
|
||||
|
||||
/// Semantic operation carried by an OpenAI Responses request that asks the
|
||||
/// service to compact a thread. The request still uses the Responses wire
|
||||
/// contract and transport endpoint.
|
||||
pub const OPENAI_RESPONSES_OPERATION_COMPACT: &str = "compact";
|
||||
|
||||
/// Resolves the operation expressed by an OpenAI Responses wire request.
|
||||
///
|
||||
/// `responses_compaction_v2` is represented by a `compaction_trigger` input
|
||||
/// item on the normal Responses request. The legacy Compact API format is
|
||||
/// retained as the same operation for observability and scoped model mapping.
|
||||
pub fn openai_responses_request_operation(api_format: &str, body: &Value) -> Option<&'static str> {
|
||||
if aether_ai_formats::is_openai_responses_compact_format(api_format) {
|
||||
return Some(OPENAI_RESPONSES_OPERATION_COMPACT);
|
||||
}
|
||||
if !aether_ai_formats::is_openai_responses_format(api_format) {
|
||||
return None;
|
||||
}
|
||||
|
||||
body.get("input")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item.get("type").and_then(Value::as_str) == Some("compaction_trigger"))
|
||||
})
|
||||
.then_some(OPENAI_RESPONSES_OPERATION_COMPACT)
|
||||
}
|
||||
|
||||
fn encode_tool_result_error(output: Value, is_error: bool) -> Value {
|
||||
if !is_error {
|
||||
return output;
|
||||
}
|
||||
let detail = match output {
|
||||
Value::String(text) => text,
|
||||
Value::Null => String::new(),
|
||||
value => serde_json::to_string(&value).unwrap_or_else(|_| value.to_string()),
|
||||
};
|
||||
if detail.is_empty() {
|
||||
Value::String(TOOL_ERROR_PREFIX.to_string())
|
||||
} else {
|
||||
Value::String(format!("{TOOL_ERROR_PREFIX}\n{detail}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{openai_responses_request_operation, OPENAI_RESPONSES_OPERATION_COMPACT};
|
||||
|
||||
#[test]
|
||||
fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() {
|
||||
assert_eq!(
|
||||
openai_responses_request_operation(
|
||||
"openai:responses",
|
||||
&json!({
|
||||
"input": [
|
||||
{"role": "user", "content": "keep working"},
|
||||
{"type": "compaction_trigger"}
|
||||
]
|
||||
}),
|
||||
),
|
||||
Some(OPENAI_RESPONSES_OPERATION_COMPACT)
|
||||
);
|
||||
assert_eq!(
|
||||
openai_responses_request_operation(
|
||||
"openai:responses",
|
||||
&json!({"input": [{"role": "user", "content": "keep working"}]}),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_legacy_compact_contract_without_a_body_marker() {
|
||||
assert_eq!(
|
||||
openai_responses_request_operation("openai:responses:compact", &json!({})),
|
||||
Some(OPENAI_RESPONSES_OPERATION_COMPACT)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,771 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::encode_tool_result_error;
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
protocol::canonical::{
|
||||
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
|
||||
canonical_tool_use_to_openai_responses_item, canonical_usage_to_openai_responses_usage,
|
||||
flush_openai_responses_message_item, is_openai_responses_raw_block,
|
||||
is_openai_thinking_block, namespace_extension_object, openai_responses_extensions,
|
||||
openai_responses_item_extension_object, openai_responses_output_to_canonical,
|
||||
openai_responses_usage_to_canonical, openai_service_tier_extension, CanonicalContentBlock,
|
||||
CanonicalResponse, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
const AETHER_RESPONSES_RAW_OUTPUT_KEY: &str = "openai_responses_raw_output";
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
|
||||
from_raw(body)
|
||||
}
|
||||
|
||||
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
|
||||
Some(to_raw(response, &ctx.report_context_value(), false))
|
||||
}
|
||||
|
||||
pub fn to_compact(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
|
||||
Some(to_raw(response, &ctx.report_context_value(), true))
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
let body = body_json.as_object()?;
|
||||
if body.get("error").is_some_and(|error| !error.is_null())
|
||||
|| body.get("status").and_then(Value::as_str) == Some("failed")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (content, output_extensions) = openai_responses_output_to_canonical(body.get("output"))?;
|
||||
let has_tool_use = content
|
||||
.iter()
|
||||
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }));
|
||||
let stop_reason = if has_tool_use {
|
||||
Some(CanonicalStopReason::ToolUse)
|
||||
} else {
|
||||
match body.get("status").and_then(Value::as_str) {
|
||||
Some("incomplete") => Some(openai_responses_incomplete_stop_reason(body)),
|
||||
Some("failed") => Some(CanonicalStopReason::Unknown),
|
||||
_ => Some(CanonicalStopReason::EndTurn),
|
||||
}
|
||||
};
|
||||
let mut extensions = openai_responses_extensions(
|
||||
body,
|
||||
&[
|
||||
"id", "object", "model", "output", "usage", "status", "error",
|
||||
],
|
||||
);
|
||||
if let Some(raw_status) = body.get("status").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.insert("raw_status".to_string(), raw_status);
|
||||
}
|
||||
if let Some(raw_output) = body.get("output").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, "aether")
|
||||
.insert(AETHER_RESPONSES_RAW_OUTPUT_KEY.to_string(), raw_output);
|
||||
}
|
||||
Some(CanonicalResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("resp-unknown")
|
||||
.to_string(),
|
||||
model: body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
outputs: vec![CanonicalResponseOutput {
|
||||
index: 0,
|
||||
role: CanonicalRole::Assistant,
|
||||
content: content.clone(),
|
||||
stop_reason: stop_reason.clone(),
|
||||
extensions: output_extensions,
|
||||
}],
|
||||
content,
|
||||
stop_reason,
|
||||
usage: openai_responses_usage_to_canonical(body.get("usage")),
|
||||
extensions,
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> CanonicalStopReason {
|
||||
match body
|
||||
.get("incomplete_details")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("reason"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some("content_filter") => CanonicalStopReason::ContentFiltered,
|
||||
Some("tool_calls") | Some("function_call") => CanonicalStopReason::ToolUse,
|
||||
_ => CanonicalStopReason::MaxTokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bool) -> Value {
|
||||
let mut response = Map::new();
|
||||
let response_id = canonical.id.replace("chatcmpl", "resp");
|
||||
response.insert("id".to_string(), Value::String(response_id.clone()));
|
||||
response.insert(
|
||||
"object".to_string(),
|
||||
Value::String(if compact {
|
||||
"response.compaction".to_string()
|
||||
} else {
|
||||
"response".to_string()
|
||||
}),
|
||||
);
|
||||
if !compact {
|
||||
response.insert("status".to_string(), Value::String("completed".to_string()));
|
||||
response.insert("model".to_string(), Value::String(canonical.model.clone()));
|
||||
if let Some(raw_status) = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|openai| openai.get("raw_status"))
|
||||
.cloned()
|
||||
{
|
||||
response.insert("status".to_string(), raw_status);
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
let mut message_content = Vec::new();
|
||||
let mut message_index = 0usize;
|
||||
for block in &canonical.content {
|
||||
match block {
|
||||
CanonicalContentBlock::Text { .. }
|
||||
| CanonicalContentBlock::File { .. }
|
||||
| CanonicalContentBlock::Audio { .. } => {
|
||||
if let Some(part) = canonical_content_block_to_openai_responses_part(block) {
|
||||
message_content.push(part);
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
if image_block_is_generation_call(extensions) {
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
output.push(openai_responses_image_generation_call_item(
|
||||
&response_id,
|
||||
output.len(),
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
));
|
||||
} else if let Some(part) = canonical_content_block_to_openai_responses_part(block) {
|
||||
message_content.push(part);
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Thinking {
|
||||
text,
|
||||
encrypted_content,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
let encrypted_content = encrypted_content
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|_| is_openai_thinking_block(extensions));
|
||||
if text.trim().is_empty() && encrypted_content.is_none() {
|
||||
continue;
|
||||
}
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
let mut item = Map::new();
|
||||
item.insert("type".to_string(), Value::String("reasoning".to_string()));
|
||||
item.insert(
|
||||
"id".to_string(),
|
||||
Value::String(format!("{}_rs_{}", response_id, output.len())),
|
||||
);
|
||||
item.insert("status".to_string(), Value::String("completed".to_string()));
|
||||
if let Some(encrypted_content) = encrypted_content {
|
||||
item.insert(
|
||||
"encrypted_content".to_string(),
|
||||
Value::String(encrypted_content.clone()),
|
||||
);
|
||||
}
|
||||
if !text.trim().is_empty() {
|
||||
item.insert(
|
||||
"summary".to_string(),
|
||||
Value::Array(vec![json!({
|
||||
"type": "summary_text",
|
||||
"text": text,
|
||||
})]),
|
||||
);
|
||||
}
|
||||
output.push(Value::Object(item));
|
||||
}
|
||||
CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
extensions,
|
||||
} => {
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
if is_responses_web_search_tool(name) {
|
||||
output.push(json!({
|
||||
"type": "web_search_call",
|
||||
"id": id,
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": web_search_query_from_value(input),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
output.push(canonical_tool_use_to_openai_responses_item(
|
||||
id, name, input, extensions,
|
||||
));
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
output: result_output,
|
||||
content_text,
|
||||
is_error,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String(
|
||||
responses_tool_result_item_type(extensions)
|
||||
.unwrap_or("function_call_output")
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
item.insert("call_id".to_string(), Value::String(tool_use_id.clone()));
|
||||
item.insert(
|
||||
"output".to_string(),
|
||||
encode_tool_result_error(
|
||||
result_output.clone().unwrap_or_else(|| {
|
||||
Value::String(content_text.clone().unwrap_or_default())
|
||||
}),
|
||||
*is_error,
|
||||
),
|
||||
);
|
||||
let extension_fields = openai_responses_item_extension_object(extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
output.push(Value::Object(item));
|
||||
}
|
||||
CanonicalContentBlock::Unknown {
|
||||
raw_type, payload, ..
|
||||
} if raw_type == "refusal" => {
|
||||
if let Some(text) = payload.get("refusal").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
message_content.push(json!({
|
||||
"type": "refusal",
|
||||
"refusal": text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Unknown {
|
||||
payload,
|
||||
extensions,
|
||||
..
|
||||
} if is_openai_responses_raw_block(extensions) => {
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
output.push(payload.clone());
|
||||
}
|
||||
CanonicalContentBlock::Unknown { .. } => {}
|
||||
}
|
||||
}
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
if let Some(raw_output) = canonical
|
||||
.extensions
|
||||
.get("aether")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|aether| aether.get(AETHER_RESPONSES_RAW_OUTPUT_KEY))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
output.clone_from(raw_output);
|
||||
}
|
||||
response.insert("output".to_string(), Value::Array(output));
|
||||
if let Some(usage) = &canonical.usage {
|
||||
response.insert(
|
||||
"usage".to_string(),
|
||||
canonical_usage_to_openai_responses_usage(usage),
|
||||
);
|
||||
}
|
||||
if compact {
|
||||
let created_at = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|openai| openai.get("created_at").or_else(|| openai.get("created")))
|
||||
.and_then(openai_responses_timestamp_value)
|
||||
.unwrap_or_else(openai_responses_current_timestamp);
|
||||
response.insert("created_at".to_string(), Value::from(created_at));
|
||||
return Value::Object(response);
|
||||
}
|
||||
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.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(service_tier) = openai_service_tier_extension(&canonical.extensions).cloned() {
|
||||
response.insert("service_tier".to_string(), service_tier);
|
||||
}
|
||||
let mut extension_fields = namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
&response,
|
||||
);
|
||||
extension_fields.remove("raw_status");
|
||||
response.extend(extension_fields);
|
||||
let mut legacy_extension_fields = namespace_extension_object(
|
||||
&canonical.extensions,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
&response,
|
||||
);
|
||||
legacy_extension_fields.remove("raw_status");
|
||||
response.extend(legacy_extension_fields);
|
||||
ensure_modern_openai_responses_response_fields(&mut response);
|
||||
Value::Object(response)
|
||||
}
|
||||
|
||||
fn responses_tool_result_item_type(extensions: &BTreeMap<String, Value>) -> Option<&str> {
|
||||
let item_type = extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE))
|
||||
.and_then(|value| value.get("item_type"))
|
||||
.and_then(Value::as_str)?;
|
||||
matches!(
|
||||
item_type,
|
||||
"custom_tool_call_output"
|
||||
| "local_shell_call_output"
|
||||
| "shell_call_output"
|
||||
| "apply_patch_call_output"
|
||||
| "computer_call_output"
|
||||
)
|
||||
.then_some(item_type)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_modern_openai_responses_response_fields(
|
||||
response: &mut Map<String, Value>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
if !response
|
||||
.get("output")
|
||||
.is_some_and(|value| matches!(value, Value::Array(_)))
|
||||
{
|
||||
response.insert("output".to_string(), Value::Array(Vec::new()));
|
||||
changed = true;
|
||||
}
|
||||
if !response.contains_key("created_at") {
|
||||
let created_at = response
|
||||
.get("created")
|
||||
.and_then(openai_responses_timestamp_value)
|
||||
.unwrap_or_else(openai_responses_current_timestamp);
|
||||
response.insert("created_at".to_string(), Value::from(created_at));
|
||||
changed = true;
|
||||
}
|
||||
if response
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|status| status == "completed")
|
||||
&& !response.contains_key("completed_at")
|
||||
{
|
||||
let completed_at = response
|
||||
.get("created_at")
|
||||
.and_then(openai_responses_timestamp_value)
|
||||
.unwrap_or_else(openai_responses_current_timestamp);
|
||||
response.insert("completed_at".to_string(), Value::from(completed_at));
|
||||
changed = true;
|
||||
}
|
||||
if !response.contains_key("output_text") {
|
||||
let output_text = openai_responses_output_text_from_output(response.get("output"));
|
||||
response.insert("output_text".to_string(), Value::String(output_text));
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub(crate) fn openai_responses_output_text_from_output(output: Option<&Value>) -> String {
|
||||
output
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_object)
|
||||
.flat_map(|item| {
|
||||
item.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.filter_map(|part| {
|
||||
let part = part.as_object()?;
|
||||
matches!(
|
||||
part.get("type").and_then(Value::as_str),
|
||||
Some("output_text" | "text")
|
||||
)
|
||||
.then(|| part.get("text").and_then(Value::as_str).unwrap_or_default())
|
||||
})
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub(crate) fn openai_responses_current_timestamp() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn openai_responses_timestamp_value(value: &Value) -> Option<i64> {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
|
||||
}
|
||||
|
||||
fn image_block_is_generation_call(extensions: &BTreeMap<String, Value>) -> bool {
|
||||
extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE))
|
||||
.and_then(|value| value.get("item_type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "image_generation_call")
|
||||
}
|
||||
|
||||
fn openai_responses_image_generation_call_item(
|
||||
response_id: &str,
|
||||
index: usize,
|
||||
data: &Option<String>,
|
||||
url: &Option<String>,
|
||||
media_type: &Option<String>,
|
||||
) -> Value {
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"id".to_string(),
|
||||
Value::String(format!("{response_id}_ig_{index}")),
|
||||
);
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String("image_generation_call".to_string()),
|
||||
);
|
||||
item.insert("status".to_string(), Value::String("completed".to_string()));
|
||||
item.insert("action".to_string(), Value::String("generate".to_string()));
|
||||
item.insert(
|
||||
"output_format".to_string(),
|
||||
Value::String(openai_responses_output_format_from_mime_type(
|
||||
media_type.as_deref().unwrap_or("image/png"),
|
||||
)),
|
||||
);
|
||||
if let Some(data) = data.as_ref().filter(|value| !value.trim().is_empty()) {
|
||||
item.insert("result".to_string(), Value::String(data.clone()));
|
||||
} else if let Some(url) = url.as_ref().filter(|value| !value.trim().is_empty()) {
|
||||
item.insert("url".to_string(), Value::String(url.clone()));
|
||||
} else {
|
||||
item.insert("result".to_string(), Value::String(String::new()));
|
||||
}
|
||||
Value::Object(item)
|
||||
}
|
||||
|
||||
fn openai_responses_output_format_from_mime_type(mime_type: &str) -> String {
|
||||
match mime_type.trim().to_ascii_lowercase().as_str() {
|
||||
"image/jpeg" | "image/jpg" => "jpeg",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
_ => "png",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_responses_web_search_tool(name: &str) -> bool {
|
||||
matches!(name, "web_search" | "web_search_preview")
|
||||
}
|
||||
|
||||
fn web_search_query_from_value(input: &Value) -> String {
|
||||
input
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| input.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_emits_web_search_call_for_web_search_tool_use() {
|
||||
let response = CanonicalResponse {
|
||||
id: "resp_test".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id: "call_ws_1".to_string(),
|
||||
name: "web_search".to_string(),
|
||||
input: json!({"query": "today tech"}),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::ToolUse),
|
||||
usage: None,
|
||||
extensions: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), false);
|
||||
|
||||
assert_eq!(body["output"][0]["type"], "web_search_call");
|
||||
assert_eq!(body["output"][0]["id"], "call_ws_1");
|
||||
assert_eq!(body["output"][0]["status"], "completed");
|
||||
assert_eq!(body["output"][0]["action"]["type"], "search");
|
||||
assert_eq!(body["output"][0]["action"]["query"], "today tech");
|
||||
assert_eq!(body["output_text"], "");
|
||||
assert!(body["created_at"].as_i64().is_some());
|
||||
assert!(body["completed_at"].as_i64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_emits_modern_output_text_and_preserves_source_fields() {
|
||||
let mut extensions = BTreeMap::new();
|
||||
extensions.insert(
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
|
||||
json!({
|
||||
"created_at": 111,
|
||||
"completed_at": 222,
|
||||
"output_text": "source text",
|
||||
"conversation": {"id": "conv_123"}
|
||||
}),
|
||||
);
|
||||
let response = CanonicalResponse {
|
||||
id: "resp_text".to_string(),
|
||||
model: "gpt-5".to_string(),
|
||||
content: vec![CanonicalContentBlock::Text {
|
||||
text: "generated text".to_string(),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::EndTurn),
|
||||
usage: None,
|
||||
extensions,
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), false);
|
||||
|
||||
assert_eq!(body["output_text"], "source text");
|
||||
assert_eq!(body["created_at"], 111);
|
||||
assert_eq!(body["completed_at"], 222);
|
||||
assert_eq!(body["conversation"]["id"], "conv_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_encodes_tool_errors_in_output() {
|
||||
let response = CanonicalResponse {
|
||||
id: "resp_tool_error".to_string(),
|
||||
model: "gpt-5.6-sol".to_string(),
|
||||
content: vec![CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: "call_error".to_string(),
|
||||
name: None,
|
||||
output: Some(json!("command failed")),
|
||||
content_text: None,
|
||||
is_error: true,
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::EndTurn),
|
||||
usage: None,
|
||||
extensions: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), false);
|
||||
let item = &body["output"][0];
|
||||
|
||||
assert_eq!(item["type"], "function_call_output");
|
||||
assert_eq!(item["call_id"], "call_error");
|
||||
assert_eq!(item["output"], "[tool error]\ncommand failed");
|
||||
assert!(item.get("is_error").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_response_builder_emits_the_compaction_resource_shape() {
|
||||
let mut extensions = BTreeMap::new();
|
||||
extensions.insert(
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
|
||||
json!({"created_at": 123}),
|
||||
);
|
||||
let response = CanonicalResponse {
|
||||
id: "resp_compact".to_string(),
|
||||
model: "gpt-5.6-sol".to_string(),
|
||||
content: vec![CanonicalContentBlock::Text {
|
||||
text: "compacted context".to_string(),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::EndTurn),
|
||||
usage: None,
|
||||
extensions,
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), true);
|
||||
let keys = body
|
||||
.as_object()
|
||||
.expect("Compact response should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
|
||||
assert_eq!(body["object"], "response.compaction");
|
||||
assert_eq!(body["created_at"], 123);
|
||||
assert_eq!(
|
||||
keys,
|
||||
std::collections::BTreeSet::from(["created_at", "id", "object", "output"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
|
||||
let body = json!({
|
||||
"id": "resp_test",
|
||||
"model": "gpt-5",
|
||||
"status": "completed",
|
||||
"output": [{
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"status": "completed",
|
||||
"summary": [],
|
||||
"encrypted_content": "openai-opaque"
|
||||
}]
|
||||
});
|
||||
|
||||
let canonical = from_raw(&body).expect("response should parse");
|
||||
|
||||
assert!(matches!(
|
||||
canonical.content.first(),
|
||||
Some(CanonicalContentBlock::Thinking {
|
||||
text,
|
||||
encrypted_content,
|
||||
..
|
||||
}) if text.is_empty() && encrypted_content.as_deref() == Some("openai-opaque")
|
||||
));
|
||||
|
||||
let rebuilt = to_raw(&canonical, &json!({}), false);
|
||||
assert_eq!(rebuilt["output"][0]["type"], "reasoning");
|
||||
assert_eq!(
|
||||
rebuilt["output"][0]["encrypted_content"],
|
||||
json!("openai-opaque")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_does_not_emit_claude_redacted_as_openai_encrypted_content() {
|
||||
let mut extensions = BTreeMap::new();
|
||||
extensions.insert("aether".to_string(), json!({"source": "claude_thinking"}));
|
||||
let response = CanonicalResponse {
|
||||
id: "msg_claude".to_string(),
|
||||
model: "claude-sonnet".to_string(),
|
||||
content: vec![CanonicalContentBlock::Thinking {
|
||||
text: String::new(),
|
||||
signature: None,
|
||||
encrypted_content: Some("{\"type\":\"redacted_thinking\",\"v\":5}".to_string()),
|
||||
extensions,
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::EndTurn),
|
||||
usage: None,
|
||||
extensions: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), false);
|
||||
|
||||
assert!(body["output"].as_array().expect("output").is_empty());
|
||||
assert!(!body.to_string().contains("encrypted_content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_parser_reads_web_search_call_as_tool_use() {
|
||||
let body = json!({
|
||||
"id": "resp_test",
|
||||
"model": "gpt-5-5-low",
|
||||
"status": "incomplete",
|
||||
"output": [{
|
||||
"type": "web_search_call",
|
||||
"id": "call_ws_1",
|
||||
"status": "completed",
|
||||
"action": {"type": "search", "query": "today tech"}
|
||||
}]
|
||||
});
|
||||
|
||||
let canonical = from_raw(&body).expect("response should parse");
|
||||
|
||||
assert!(
|
||||
matches!(canonical.content.first(), Some(CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
..
|
||||
}) if id == "call_ws_1" && name == "web_search" && input["query"] == "today tech")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use crate::contracts::{
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalOpenAiResponsesSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub compact: bool,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiResponsesSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses",
|
||||
decision_kind: OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
|
||||
compact: false,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses:compact",
|
||||
decision_kind: OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND,
|
||||
compact: true,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiResponsesSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses",
|
||||
decision_kind: OPENAI_RESPONSES_STREAM_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
|
||||
compact: false,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => 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 does_not_resolve_openai_responses_compact_as_streaming() {
|
||||
assert!(resolve_stream_spec("openai_responses_compact_stream").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::formats::openai::chat::stream::{
|
||||
OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
use serde_json::Value;
|
||||
|
||||
const REQUEST_FIELDS: &[&str] = &[
|
||||
"id",
|
||||
"model",
|
||||
"reasoning",
|
||||
"input",
|
||||
"commands",
|
||||
"settings",
|
||||
"max_output_tokens",
|
||||
];
|
||||
const REASONING_FIELDS: &[&str] = &["effort", "summary", "context"];
|
||||
const COMMAND_FIELDS: &[&str] = &[
|
||||
"search_query",
|
||||
"image_query",
|
||||
"open",
|
||||
"click",
|
||||
"find",
|
||||
"screenshot",
|
||||
"finance",
|
||||
"weather",
|
||||
"sports",
|
||||
"time",
|
||||
"response_length",
|
||||
];
|
||||
const COMMAND_ITEM_FIELDS: &[(&str, &[&str])] = &[
|
||||
("search_query", &["q", "recency", "domains"]),
|
||||
("image_query", &["q", "recency", "domains"]),
|
||||
("open", &["ref_id", "lineno"]),
|
||||
("click", &["ref_id", "id"]),
|
||||
("find", &["ref_id", "pattern"]),
|
||||
("screenshot", &["ref_id", "pageno"]),
|
||||
("finance", &["ticker", "type", "market"]),
|
||||
("weather", &["location", "start", "duration"]),
|
||||
(
|
||||
"sports",
|
||||
&[
|
||||
"tool",
|
||||
"fn",
|
||||
"league",
|
||||
"team",
|
||||
"opponent",
|
||||
"date_from",
|
||||
"date_to",
|
||||
"num_games",
|
||||
"locale",
|
||||
],
|
||||
),
|
||||
("time", &["utc_offset"]),
|
||||
];
|
||||
const SETTINGS_FIELDS: &[&str] = &[
|
||||
"user_location",
|
||||
"search_context_size",
|
||||
"filters",
|
||||
"image_settings",
|
||||
"allowed_callers",
|
||||
"external_web_access",
|
||||
];
|
||||
const USER_LOCATION_FIELDS: &[&str] = &["type", "country", "region", "city", "timezone"];
|
||||
const FILTER_FIELDS: &[&str] = &["allowed_domains", "blocked_domains"];
|
||||
const IMAGE_SETTINGS_FIELDS: &[&str] = &["max_results", "caption"];
|
||||
|
||||
fn retain_object_fields(value: &mut Value, fields: &[&str]) {
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.retain(|field, _| fields.contains(&field.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_array_object_fields(
|
||||
object: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
fields: &[&str],
|
||||
) {
|
||||
if let Some(items) = object.get_mut(key).and_then(Value::as_array_mut) {
|
||||
for item in items {
|
||||
retain_object_fields(item, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_openai_search_request_projection(body: &mut Value, provider_api_format: &str) {
|
||||
if !crate::api_format_alias_matches(provider_api_format, "openai:search") {
|
||||
return;
|
||||
}
|
||||
let Some(body_object) = body.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
body_object.retain(|field, _| REQUEST_FIELDS.contains(&field.as_str()));
|
||||
|
||||
if let Some(reasoning) = body_object.get_mut("reasoning") {
|
||||
retain_object_fields(reasoning, REASONING_FIELDS);
|
||||
}
|
||||
if let Some(commands) = body_object
|
||||
.get_mut("commands")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
commands.retain(|field, _| COMMAND_FIELDS.contains(&field.as_str()));
|
||||
for (key, fields) in COMMAND_ITEM_FIELDS {
|
||||
retain_array_object_fields(commands, key, fields);
|
||||
}
|
||||
}
|
||||
if let Some(settings) = body_object
|
||||
.get_mut("settings")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
settings.retain(|field, _| SETTINGS_FIELDS.contains(&field.as_str()));
|
||||
if let Some(user_location) = settings.get_mut("user_location") {
|
||||
retain_object_fields(user_location, USER_LOCATION_FIELDS);
|
||||
}
|
||||
if let Some(filters) = settings.get_mut("filters") {
|
||||
retain_object_fields(filters, FILTER_FIELDS);
|
||||
}
|
||||
if let Some(image_settings) = settings.get_mut("image_settings") {
|
||||
retain_object_fields(image_settings, IMAGE_SETTINGS_FIELDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::apply_openai_search_request_projection;
|
||||
|
||||
#[test]
|
||||
fn projects_the_typed_search_request_contract() {
|
||||
let mut body = json!({
|
||||
"id": "session-1",
|
||||
"model": "gpt-5.6-sol",
|
||||
"reasoning": {
|
||||
"effort": "max",
|
||||
"summary": "auto",
|
||||
"context": "current_turn",
|
||||
"mode": "pro"
|
||||
},
|
||||
"input": "find documentation",
|
||||
"commands": {
|
||||
"search_query": [{"q": "Aether", "recency": 7, "unknown": true}],
|
||||
"image_query": [{"q": "Aether UI", "domains": ["example.com"], "unknown": true}],
|
||||
"open": [{"ref_id": "turn0search0", "lineno": 12, "unknown": true}],
|
||||
"click": [{"ref_id": "turn0fetch0", "id": 3, "unknown": true}],
|
||||
"find": [{"ref_id": "turn0fetch0", "pattern": "Aether", "unknown": true}],
|
||||
"screenshot": [{"ref_id": "turn0fetch0", "pageno": 2, "unknown": true}],
|
||||
"finance": [{"ticker": "OPENAI", "type": "equity", "market": "USA", "unknown": true}],
|
||||
"weather": [{"location": "US, CA, San Francisco", "duration": 3, "unknown": true}],
|
||||
"sports": [{"tool": "sports", "fn": "schedule", "league": "nba", "team": "GSW", "unknown": true}],
|
||||
"time": [{"utc_offset": "+08:00", "unknown": true}],
|
||||
"response_length": "short",
|
||||
"unknown": true
|
||||
},
|
||||
"settings": {
|
||||
"user_location": {"type": "approximate", "country": "US", "unknown": true},
|
||||
"search_context_size": "high",
|
||||
"filters": {"allowed_domains": ["openai.com"], "unknown": true},
|
||||
"image_settings": {"max_results": 3, "unknown": true},
|
||||
"allowed_callers": ["direct"],
|
||||
"external_web_access": "live",
|
||||
"unknown": true
|
||||
},
|
||||
"max_output_tokens": 1024,
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"service_tier": "priority",
|
||||
"unknown": true
|
||||
});
|
||||
|
||||
apply_openai_search_request_projection(&mut body, "/v1/alpha/search");
|
||||
|
||||
assert_eq!(
|
||||
body["reasoning"],
|
||||
json!({
|
||||
"effort": "max",
|
||||
"summary": "auto",
|
||||
"context": "current_turn"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
body["commands"],
|
||||
json!({
|
||||
"search_query": [{"q": "Aether", "recency": 7}],
|
||||
"image_query": [{"q": "Aether UI", "domains": ["example.com"]}],
|
||||
"open": [{"ref_id": "turn0search0", "lineno": 12}],
|
||||
"click": [{"ref_id": "turn0fetch0", "id": 3}],
|
||||
"find": [{"ref_id": "turn0fetch0", "pattern": "Aether"}],
|
||||
"screenshot": [{"ref_id": "turn0fetch0", "pageno": 2}],
|
||||
"finance": [{"ticker": "OPENAI", "type": "equity", "market": "USA"}],
|
||||
"weather": [{"location": "US, CA, San Francisco", "duration": 3}],
|
||||
"sports": [{"tool": "sports", "fn": "schedule", "league": "nba", "team": "GSW"}],
|
||||
"time": [{"utc_offset": "+08:00"}],
|
||||
"response_length": "short"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
body["settings"],
|
||||
json!({
|
||||
"user_location": {"type": "approximate", "country": "US"},
|
||||
"search_context_size": "high",
|
||||
"filters": {"allowed_domains": ["openai.com"]},
|
||||
"image_settings": {"max_results": 3},
|
||||
"allowed_callers": ["direct"],
|
||||
"external_web_access": "live"
|
||||
})
|
||||
);
|
||||
assert!(body.get("store").is_none());
|
||||
assert!(body.get("stream").is_none());
|
||||
assert!(body.get("service_tier").is_none());
|
||||
assert!(body.get("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_other_formats_unchanged() {
|
||||
let mut body = json!({"model": "gpt-5.6-sol", "store": true});
|
||||
let expected = body.clone();
|
||||
|
||||
apply_openai_search_request_projection(&mut body, "openai:responses");
|
||||
|
||||
assert_eq!(body, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::formats::shared::model_directives::ReasoningEffort;
|
||||
|
||||
macro_rules! define_openai_reasoning_effort {
|
||||
($name:ident) => {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum $name {
|
||||
None,
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"none" => Some(Self::None),
|
||||
"minimal" => Some(Self::Minimal),
|
||||
"low" => Some(Self::Low),
|
||||
"medium" => Some(Self::Medium),
|
||||
"high" => Some(Self::High),
|
||||
"xhigh" => Some(Self::XHigh),
|
||||
"max" => Some(Self::Max),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Minimal => "minimal",
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Max => "max",
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_openai_reasoning_effort!(OpenAiChatReasoningEffort);
|
||||
define_openai_reasoning_effort!(OpenAiResponsesReasoningEffort);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OpenAiPromptCacheRetention {
|
||||
InMemory,
|
||||
TwentyFourHours,
|
||||
}
|
||||
|
||||
impl OpenAiPromptCacheRetention {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim() {
|
||||
"in_memory" => Some(Self::InMemory),
|
||||
"24h" => Some(Self::TwentyFourHours),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::InMemory => "in_memory",
|
||||
Self::TwentyFourHours => "24h",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[deprecated(note = "use OpenAiChatReasoningEffort or OpenAiResponsesReasoningEffort")]
|
||||
pub type OpenAiReasoningEffort = OpenAiChatReasoningEffort;
|
||||
|
||||
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
|
||||
match stop {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => {
|
||||
Some(vec![Value::String(value.clone())])
|
||||
}
|
||||
Some(Value::Array(values)) => Some(
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| Value::String(value.to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.filter(|values| !values.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_openai_chat_max_tokens(request: &Map<String, Value>) -> u64 {
|
||||
request
|
||||
.get("max_completion_tokens")
|
||||
.and_then(value_as_u64)
|
||||
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
|
||||
.unwrap_or(4096)
|
||||
}
|
||||
|
||||
pub fn value_as_u64(value: &Value) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok()))
|
||||
}
|
||||
|
||||
pub fn copy_request_number_field(
|
||||
request: &Map<String, Value>,
|
||||
target: &mut Map<String, Value>,
|
||||
key: &str,
|
||||
) {
|
||||
copy_request_number_field_as(request, target, key, key);
|
||||
}
|
||||
|
||||
pub fn copy_request_number_field_as(
|
||||
request: &Map<String, Value>,
|
||||
target: &mut Map<String, Value>,
|
||||
source_key: &str,
|
||||
target_key: &str,
|
||||
) {
|
||||
if let Some(value) = request.get(source_key).cloned() {
|
||||
if value.is_number() {
|
||||
target.insert(target_key.to_string(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_claude_output(value: &str) -> Option<&'static str> {
|
||||
ReasoningEffort::parse(value).map(ReasoningEffort::as_claude_output_value)
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_thinking_budget(value: &str) -> Option<u64> {
|
||||
ReasoningEffort::parse(value).map(ReasoningEffort::thinking_budget_tokens)
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_gemini_budget(value: &str) -> Option<u64> {
|
||||
map_openai_reasoning_effort_to_thinking_budget(value)
|
||||
}
|
||||
|
||||
pub fn map_thinking_budget_to_openai_reasoning_effort(value: u64) -> &'static str {
|
||||
match value {
|
||||
0..=1664 => "low",
|
||||
1665..=3072 => "medium",
|
||||
3073..=6144 => "high",
|
||||
_ => "xhigh",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_openai_reasoning_effort(request: &Map<String, Value>) -> Option<String> {
|
||||
request
|
||||
.get("reasoning_effort")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
request
|
||||
.get("reasoning")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
OpenAiChatReasoningEffort, OpenAiPromptCacheRetention, OpenAiResponsesReasoningEffort,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn openai_reasoning_effort_contract_includes_gpt_5_6_max() {
|
||||
assert_eq!(
|
||||
OpenAiChatReasoningEffort::parse("max").map(OpenAiChatReasoningEffort::as_str),
|
||||
Some("max")
|
||||
);
|
||||
assert_eq!(
|
||||
OpenAiResponsesReasoningEffort::parse("max")
|
||||
.map(OpenAiResponsesReasoningEffort::as_str),
|
||||
Some("max")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_retention_uses_the_openai_wire_enum() {
|
||||
assert_eq!(
|
||||
OpenAiPromptCacheRetention::parse("in_memory").map(OpenAiPromptCacheRetention::as_str),
|
||||
Some("in_memory")
|
||||
);
|
||||
assert_eq!(
|
||||
OpenAiPromptCacheRetention::parse("24h").map(OpenAiPromptCacheRetention::as_str),
|
||||
Some("24h")
|
||||
);
|
||||
assert_eq!(OpenAiPromptCacheRetention::parse("in-memory"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod spec;
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::contracts::OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND;
|
||||
use crate::formats::shared::video::{LocalVideoCreateFamily, LocalVideoCreateSpec};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
||||
api_format: "openai:video",
|
||||
decision_kind: OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_video_create_sync_finalize",
|
||||
family: LocalVideoCreateFamily::OpenAi,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_sync_spec, LocalVideoCreateFamily};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_video_create_spec() {
|
||||
let spec = resolve_sync_spec("openai_video_create_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:video");
|
||||
assert_eq!(spec.family, LocalVideoCreateFamily::OpenAi);
|
||||
assert_eq!(spec.report_kind, "openai_video_create_sync_finalize");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LocalCoreSyncErrorKind {
|
||||
InvalidRequest,
|
||||
Authentication,
|
||||
PermissionDenied,
|
||||
NotFound,
|
||||
RateLimit,
|
||||
ContextLengthExceeded,
|
||||
Overloaded,
|
||||
ServerError,
|
||||
}
|
||||
|
||||
pub fn is_core_error_finalize_kind(report_kind: &str) -> bool {
|
||||
core_error_default_client_api_format(report_kind).is_some()
|
||||
}
|
||||
|
||||
pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_error_default_client_api_format(report_kind)
|
||||
}
|
||||
|
||||
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_error_background_report_kind(report_kind)
|
||||
}
|
||||
|
||||
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_success_background_report_kind(report_kind)
|
||||
}
|
||||
|
||||
pub fn build_core_error_body_for_client_format(
|
||||
client_api_format: &str,
|
||||
message: &str,
|
||||
code: Option<&str>,
|
||||
kind: LocalCoreSyncErrorKind,
|
||||
) -> Option<Value> {
|
||||
let mut error_object = Map::new();
|
||||
error_object.insert("message".to_string(), Value::String(message.to_string()));
|
||||
|
||||
match aether_ai_formats::normalize_api_format_alias(client_api_format).as_str() {
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "openai:embedding" => {
|
||||
error_object.insert(
|
||||
"type".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
|
||||
);
|
||||
if let Some(code) = code.filter(|value| !value.is_empty()) {
|
||||
error_object.insert("code".to_string(), Value::String(code.to_string()));
|
||||
}
|
||||
Some(Value::Object(Map::from_iter([(
|
||||
"error".to_string(),
|
||||
Value::Object(error_object),
|
||||
)])))
|
||||
}
|
||||
"claude:messages" => {
|
||||
error_object.insert(
|
||||
"type".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_claude_type(kind).to_string()),
|
||||
);
|
||||
if let Some(code) = code.filter(|value| !value.is_empty()) {
|
||||
error_object.insert("code".to_string(), Value::String(code.to_string()));
|
||||
}
|
||||
Some(Value::Object(Map::from_iter([
|
||||
("type".to_string(), Value::String("error".to_string())),
|
||||
("error".to_string(), Value::Object(error_object)),
|
||||
])))
|
||||
}
|
||||
"gemini:generate_content" => Some(Value::Object(Map::from_iter([(
|
||||
"error".to_string(),
|
||||
Value::Object(Map::from_iter([
|
||||
(
|
||||
"code".to_string(),
|
||||
Value::from(map_local_sync_error_kind_to_gemini_code(kind)),
|
||||
),
|
||||
("message".to_string(), Value::String(message.to_string())),
|
||||
(
|
||||
"status".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_gemini_status(kind).to_string()),
|
||||
),
|
||||
])),
|
||||
)]))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_openai_type(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest => "invalid_request_error",
|
||||
LocalCoreSyncErrorKind::Authentication => "authentication_error",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
|
||||
LocalCoreSyncErrorKind::NotFound => "not_found_error",
|
||||
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
|
||||
LocalCoreSyncErrorKind::ContextLengthExceeded => "context_length_exceeded",
|
||||
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "server_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_claude_type(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
"invalid_request_error"
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => "authentication_error",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
|
||||
LocalCoreSyncErrorKind::NotFound => "not_found_error",
|
||||
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
|
||||
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "api_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_gemini_code(kind: LocalCoreSyncErrorKind) -> u16 {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
400
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => 401,
|
||||
LocalCoreSyncErrorKind::PermissionDenied => 403,
|
||||
LocalCoreSyncErrorKind::NotFound => 404,
|
||||
LocalCoreSyncErrorKind::RateLimit => 429,
|
||||
LocalCoreSyncErrorKind::Overloaded => 503,
|
||||
LocalCoreSyncErrorKind::ServerError => 500,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_gemini_status(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
"INVALID_ARGUMENT"
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => "UNAUTHENTICATED",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "PERMISSION_DENIED",
|
||||
LocalCoreSyncErrorKind::NotFound => "NOT_FOUND",
|
||||
LocalCoreSyncErrorKind::RateLimit => "RESOURCE_EXHAUSTED",
|
||||
LocalCoreSyncErrorKind::Overloaded => "UNAVAILABLE",
|
||||
LocalCoreSyncErrorKind::ServerError => "INTERNAL",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_core_error_body_for_client_format, core_success_background_report_kind,
|
||||
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn builds_openai_core_error_body() {
|
||||
let body = build_core_error_body_for_client_format(
|
||||
"openai:chat",
|
||||
"bad request",
|
||||
Some("invalid_request"),
|
||||
LocalCoreSyncErrorKind::InvalidRequest,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["error"]["message"], "bad request");
|
||||
assert_eq!(body["error"]["type"], "invalid_request_error");
|
||||
assert_eq!(body["error"]["code"], "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_search_core_error_body() {
|
||||
let body = build_core_error_body_for_client_format(
|
||||
"openai:search",
|
||||
"search unavailable",
|
||||
Some("upstream_unavailable"),
|
||||
LocalCoreSyncErrorKind::ServerError,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["error"]["message"], "search unavailable");
|
||||
assert_eq!(body["error"]["type"], "server_error");
|
||||
assert_eq!(body["error"]["code"], "upstream_unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_finalize_kind_and_success_mapping() {
|
||||
assert!(is_core_error_finalize_kind("openai_chat_sync_finalize"));
|
||||
assert_eq!(
|
||||
core_success_background_report_kind("openai_chat_sync_finalize"),
|
||||
Some("openai_chat_sync_success")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalStandardSourceFamily {
|
||||
Standard,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalStandardSourceMode {
|
||||
Chat,
|
||||
Cli,
|
||||
Embedding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalStandardSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalStandardSourceFamily,
|
||||
pub mode: LocalStandardSourceMode,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
use std::fmt;
|
||||
|
||||
pub mod error_body;
|
||||
pub mod family;
|
||||
pub mod image_bridge;
|
||||
pub mod model_directives;
|
||||
pub mod passthrough;
|
||||
pub mod request;
|
||||
pub mod request_matrix;
|
||||
pub mod response;
|
||||
pub mod routing;
|
||||
pub mod sse;
|
||||
pub mod standard_matrix;
|
||||
pub mod standard_normalize;
|
||||
pub mod stream_core;
|
||||
pub mod stream_rewrite;
|
||||
pub mod sync_products;
|
||||
pub mod sync_to_stream;
|
||||
pub mod video;
|
||||
|
||||
pub use self::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
|
||||
pub use self::stream_core::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
pub use self::stream_rewrite::{
|
||||
maybe_build_ai_surface_stream_rewriter, resolve_finalize_stream_rewrite_mode,
|
||||
AiSurfaceStreamRewriter, FinalizeStreamRewriteMode,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AiSurfaceFinalizeError(pub String);
|
||||
|
||||
impl AiSurfaceFinalizeError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AiSurfaceFinalizeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "AI surface finalize error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AiSurfaceFinalizeError {}
|
||||
|
||||
impl From<serde_json::Error> for AiSurfaceFinalizeError {
|
||||
fn from(source: serde_json::Error) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for AiSurfaceFinalizeError {
|
||||
fn from(source: base64::DecodeError) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
use crate::contracts::{
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND,
|
||||
GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND,
|
||||
GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND,
|
||||
OPENAI_SEARCH_SYNC_SUCCESS_REPORT_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalSameFormatProviderFamily {
|
||||
Standard,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalSameFormatProviderSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalSameFormatProviderFamily,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_chat_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "claude_cli_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_chat_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_cli_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_EMBEDDING_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:embedding",
|
||||
decision_kind: GEMINI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
report_kind: GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND,
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_INTERACTIONS_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:interactions",
|
||||
decision_kind: GEMINI_INTERACTIONS_SYNC_PLAN_KIND,
|
||||
report_kind: GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND,
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "openai:embedding",
|
||||
decision_kind: OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_embedding_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_RERANK_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "openai:rerank",
|
||||
decision_kind: OPENAI_RERANK_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_rerank_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_SEARCH_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "openai:search",
|
||||
decision_kind: OPENAI_SEARCH_SYNC_PLAN_KIND,
|
||||
report_kind: OPENAI_SEARCH_SYNC_SUCCESS_REPORT_KIND,
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_chat_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: true,
|
||||
}),
|
||||
CLAUDE_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "claude_cli_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Standard,
|
||||
require_streaming: true,
|
||||
}),
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_chat_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: true,
|
||||
}),
|
||||
GEMINI_CLI_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:generate_content",
|
||||
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_cli_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: true,
|
||||
}),
|
||||
GEMINI_INTERACTIONS_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "gemini:interactions",
|
||||
decision_kind: GEMINI_INTERACTIONS_STREAM_PLAN_KIND,
|
||||
report_kind: GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND,
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("claude_chat_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:messages");
|
||||
assert_eq!(spec.report_kind, "claude_chat_sync_success");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_stream_same_format_spec() {
|
||||
let spec = resolve_stream_spec("gemini_cli_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:generate_content");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_embedding_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("openai_embedding_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:embedding");
|
||||
assert_eq!(spec.report_kind, "openai_embedding_sync_success");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_embedding_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("gemini_embedding_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:embedding");
|
||||
assert_eq!(spec.report_kind, "gemini_embedding_sync_success");
|
||||
assert_eq!(spec.family, super::LocalSameFormatProviderFamily::Gemini);
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_interactions_same_format_specs() {
|
||||
let sync = resolve_sync_spec("gemini_interactions_sync").expect("sync spec");
|
||||
assert_eq!(sync.api_format, "gemini:interactions");
|
||||
assert_eq!(sync.report_kind, "gemini_interactions_sync_success");
|
||||
assert_eq!(sync.family, super::LocalSameFormatProviderFamily::Gemini);
|
||||
assert!(!sync.require_streaming);
|
||||
|
||||
let stream = resolve_stream_spec("gemini_interactions_stream").expect("stream spec");
|
||||
assert_eq!(stream.api_format, "gemini:interactions");
|
||||
assert_eq!(stream.report_kind, "gemini_interactions_stream_success");
|
||||
assert_eq!(stream.family, super::LocalSameFormatProviderFamily::Gemini);
|
||||
assert!(stream.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_rerank_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("openai_rerank_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:rerank");
|
||||
assert_eq!(spec.report_kind, "openai_rerank_sync_success");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_search_sync_spec() {
|
||||
let spec = resolve_sync_spec("openai_search_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:search");
|
||||
assert_eq!(spec.report_kind, "openai_search_sync_success");
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
use base64::Engine as _;
|
||||
|
||||
use crate::formats::id::api_format_uses_body_stream_field;
|
||||
|
||||
/// JSON key under which `upstream_is_stream` is written into the AI execution
|
||||
/// report context and propagated into usage metadata. Shared by the producer
|
||||
/// (`aether-ai-serving::report_context`) and every downstream consumer so that
|
||||
/// renames cannot silently desync them — a string-literal mismatch here would
|
||||
/// degrade to default values (e.g. assuming streaming) without any compile-time
|
||||
/// signal.
|
||||
pub const UPSTREAM_IS_STREAM_KEY: &str = "upstream_is_stream";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UpstreamStreamPolicy {
|
||||
Auto,
|
||||
ForceStream,
|
||||
ForceNonStream,
|
||||
}
|
||||
|
||||
pub fn parse_direct_request_body(
|
||||
is_json_request: bool,
|
||||
body_bytes: &[u8],
|
||||
) -> Option<(serde_json::Value, Option<String>)> {
|
||||
if is_json_request {
|
||||
if body_bytes.is_empty() {
|
||||
Some((serde_json::json!({}), None))
|
||||
} else {
|
||||
serde_json::from_slice::<serde_json::Value>(body_bytes)
|
||||
.ok()
|
||||
.map(|value| (value, None))
|
||||
}
|
||||
} else {
|
||||
Some((
|
||||
serde_json::json!({}),
|
||||
(!body_bytes.is_empty())
|
||||
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn force_upstream_streaming_for_provider(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
provider_type.trim().eq_ignore_ascii_case("codex")
|
||||
&& aether_ai_formats::is_openai_responses_format(provider_api_format)
|
||||
}
|
||||
|
||||
pub fn forbid_upstream_streaming_for_provider(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
aether_ai_formats::api_format_alias_matches(provider_api_format, "openai:search")
|
||||
|| aether_ai_formats::is_openai_responses_compact_format(provider_api_format)
|
||||
|| (provider_type.trim().eq_ignore_ascii_case("codex")
|
||||
&& provider_api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("openai:image"))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_upstream_stream_policy(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> UpstreamStreamPolicy {
|
||||
let Some(value) = value else {
|
||||
return UpstreamStreamPolicy::Auto;
|
||||
};
|
||||
if let Some(value) = value.as_bool() {
|
||||
return if value {
|
||||
UpstreamStreamPolicy::ForceStream
|
||||
} else {
|
||||
UpstreamStreamPolicy::ForceNonStream
|
||||
};
|
||||
}
|
||||
|
||||
let serde_json::Value::String(value) = value else {
|
||||
return UpstreamStreamPolicy::Auto;
|
||||
};
|
||||
let raw = value.trim().to_ascii_lowercase();
|
||||
match raw.as_str() {
|
||||
"" | "auto" | "follow" | "client" | "default" => UpstreamStreamPolicy::Auto,
|
||||
"force_stream" | "stream" | "sse" | "true" | "1" | "yes" => {
|
||||
UpstreamStreamPolicy::ForceStream
|
||||
}
|
||||
"force_non_stream" | "force_sync" | "non_stream" | "sync" | "false" | "0" | "no" => {
|
||||
UpstreamStreamPolicy::ForceNonStream
|
||||
}
|
||||
_ => UpstreamStreamPolicy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn upstream_stream_policy_from_endpoint_config(
|
||||
endpoint_config: Option<&serde_json::Value>,
|
||||
) -> UpstreamStreamPolicy {
|
||||
let Some(config) = endpoint_config.and_then(serde_json::Value::as_object) else {
|
||||
return UpstreamStreamPolicy::Auto;
|
||||
};
|
||||
for key in [
|
||||
"upstream_stream_policy",
|
||||
"upstreamStreamPolicy",
|
||||
"upstream_stream",
|
||||
] {
|
||||
if let Some(value) = config.get(key) {
|
||||
return parse_upstream_stream_policy(Some(value));
|
||||
}
|
||||
}
|
||||
UpstreamStreamPolicy::Auto
|
||||
}
|
||||
|
||||
pub fn endpoint_config_forces_upstream_stream_policy(
|
||||
endpoint_config: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
upstream_stream_policy_from_endpoint_config(endpoint_config),
|
||||
UpstreamStreamPolicy::ForceStream | UpstreamStreamPolicy::ForceNonStream
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolves the upstream provider transport mode.
|
||||
///
|
||||
/// `client_is_stream` means the request landed on a streaming surface or should
|
||||
/// be treated as streaming; the original JSON body may not have had
|
||||
/// `"stream": true`.
|
||||
pub(crate) fn resolve_upstream_is_stream(
|
||||
client_is_stream: bool,
|
||||
hard_requires_streaming: bool,
|
||||
policy: UpstreamStreamPolicy,
|
||||
) -> bool {
|
||||
// ForceStream is unconditional, while ForceNonStream yields to hard
|
||||
// stream-only constraints such as Kiro or Codex OpenAI Responses.
|
||||
match policy {
|
||||
UpstreamStreamPolicy::ForceStream => true,
|
||||
UpstreamStreamPolicy::ForceNonStream => hard_requires_streaming,
|
||||
UpstreamStreamPolicy::Auto => hard_requires_streaming || client_is_stream,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enforce_request_body_stream_field(
|
||||
body: &mut serde_json::Value,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
require_body_stream_field: bool,
|
||||
) {
|
||||
let Some(body_object) = body.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if !api_format_uses_body_stream_field(provider_api_format) {
|
||||
body_object.remove("stream");
|
||||
return;
|
||||
}
|
||||
|
||||
// Final-body fallback catches body rules, directive patches, and other
|
||||
// provider-body mutations that introduce `stream`.
|
||||
if upstream_is_stream || require_body_stream_field || body_object.contains_key("stream") {
|
||||
body_object.insert(
|
||||
"stream".to_string(),
|
||||
serde_json::Value::Bool(upstream_is_stream),
|
||||
);
|
||||
} else {
|
||||
body_object.remove("stream");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_upstream_is_stream_from_endpoint_config(
|
||||
endpoint_config: Option<&serde_json::Value>,
|
||||
client_is_stream: bool,
|
||||
hard_requires_streaming: bool,
|
||||
) -> bool {
|
||||
resolve_upstream_is_stream(
|
||||
client_is_stream,
|
||||
hard_requires_streaming,
|
||||
upstream_stream_policy_from_endpoint_config(endpoint_config),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_upstream_is_stream_for_provider(
|
||||
endpoint_config: Option<&serde_json::Value>,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
client_is_stream: bool,
|
||||
hard_requires_streaming: bool,
|
||||
) -> bool {
|
||||
if forbid_upstream_streaming_for_provider(provider_type, provider_api_format) {
|
||||
return false;
|
||||
}
|
||||
resolve_upstream_is_stream_from_endpoint_config(
|
||||
endpoint_config,
|
||||
client_is_stream,
|
||||
hard_requires_streaming
|
||||
|| force_upstream_streaming_for_provider(provider_type, provider_api_format),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
|
||||
forbid_upstream_streaming_for_provider, force_upstream_streaming_for_provider,
|
||||
parse_direct_request_body, parse_upstream_stream_policy, resolve_upstream_is_stream,
|
||||
resolve_upstream_is_stream_for_provider, resolve_upstream_is_stream_from_endpoint_config,
|
||||
upstream_stream_policy_from_endpoint_config, UpstreamStreamPolicy,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_empty_json_body_as_empty_object() {
|
||||
assert_eq!(
|
||||
parse_direct_request_body(true, b""),
|
||||
Some((serde_json::json!({}), None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_json_body() {
|
||||
assert_eq!(parse_direct_request_body(true, b"{invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_non_json_body_as_base64() {
|
||||
assert_eq!(
|
||||
parse_direct_request_body(false, b"hello"),
|
||||
Some((serde_json::json!({}), Some("aGVsbG8=".to_string())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forces_streaming_for_codex_openai_responses() {
|
||||
assert!(force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_force_streaming_for_gemini_cli_generate_content() {
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"gemini_cli",
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"google",
|
||||
"gemini:generate_content"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_force_streaming_for_compact_or_other_provider_types() {
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(!force_upstream_streaming_for_provider(
|
||||
"openai",
|
||||
"openai:responses"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbids_streaming_for_sync_only_openai_formats() {
|
||||
assert!(forbid_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:search"
|
||||
));
|
||||
assert!(forbid_upstream_streaming_for_provider(
|
||||
"custom",
|
||||
"/v1/alpha/search"
|
||||
));
|
||||
assert!(forbid_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(forbid_upstream_streaming_for_provider(
|
||||
"openai",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(forbid_upstream_streaming_for_provider(
|
||||
"custom",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(forbid_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:image"
|
||||
));
|
||||
assert!(!forbid_upstream_streaming_for_provider(
|
||||
"codex",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!forbid_upstream_streaming_for_provider(
|
||||
"openai",
|
||||
"openai:image"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_python_compatible_upstream_stream_policy_values() {
|
||||
assert_eq!(
|
||||
parse_upstream_stream_policy(None),
|
||||
UpstreamStreamPolicy::Auto
|
||||
);
|
||||
for value in [
|
||||
json!(""),
|
||||
json!("auto"),
|
||||
json!("follow"),
|
||||
json!("client"),
|
||||
json!("default"),
|
||||
json!("unknown"),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_upstream_stream_policy(Some(&value)),
|
||||
UpstreamStreamPolicy::Auto
|
||||
);
|
||||
}
|
||||
for value in [
|
||||
json!(true),
|
||||
json!("force_stream"),
|
||||
json!("stream"),
|
||||
json!("sse"),
|
||||
json!("true"),
|
||||
json!("1"),
|
||||
json!("yes"),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_upstream_stream_policy(Some(&value)),
|
||||
UpstreamStreamPolicy::ForceStream
|
||||
);
|
||||
}
|
||||
for value in [
|
||||
json!(false),
|
||||
json!("force_non_stream"),
|
||||
json!("force_sync"),
|
||||
json!("non_stream"),
|
||||
json!("sync"),
|
||||
json!("false"),
|
||||
json!("0"),
|
||||
json!("no"),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_upstream_stream_policy(Some(&value)),
|
||||
UpstreamStreamPolicy::ForceNonStream
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_non_string_non_bool_policy_values_as_auto() {
|
||||
for value in [json!(1), json!(0), json!(null), json!({}), json!([])] {
|
||||
assert_eq!(
|
||||
parse_upstream_stream_policy(Some(&value)),
|
||||
UpstreamStreamPolicy::Auto
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_request_body_stream_field_for_stream_and_streamless_formats() {
|
||||
let mut openai_chat = json!({"stream": true});
|
||||
enforce_request_body_stream_field(&mut openai_chat, "openai:chat", false, false);
|
||||
assert_eq!(openai_chat.get("stream"), Some(&json!(false)));
|
||||
|
||||
let mut ordinary_sync = json!({"messages": []});
|
||||
enforce_request_body_stream_field(&mut ordinary_sync, "openai:chat", false, false);
|
||||
assert!(ordinary_sync.get("stream").is_none());
|
||||
|
||||
let mut forced_sync = json!({"messages": []});
|
||||
enforce_request_body_stream_field(&mut forced_sync, "openai:chat", false, true);
|
||||
assert_eq!(forced_sync.get("stream"), Some(&json!(false)));
|
||||
|
||||
let mut compact = json!({"stream": true});
|
||||
enforce_request_body_stream_field(&mut compact, "openai:responses:compact", true, true);
|
||||
assert!(compact.get("stream").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_endpoint_policy_keys_in_python_compatible_order() {
|
||||
assert_eq!(
|
||||
upstream_stream_policy_from_endpoint_config(Some(&json!({
|
||||
"upstream_stream_policy": "force_non_stream",
|
||||
"upstreamStreamPolicy": "force_stream",
|
||||
"upstream_stream": "force_stream"
|
||||
}))),
|
||||
UpstreamStreamPolicy::ForceNonStream
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_stream_policy_from_endpoint_config(Some(&json!({
|
||||
"upstreamStreamPolicy": "force_stream"
|
||||
}))),
|
||||
UpstreamStreamPolicy::ForceStream
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_stream_policy_from_endpoint_config(Some(&json!({
|
||||
"upstream_stream": false
|
||||
}))),
|
||||
UpstreamStreamPolicy::ForceNonStream
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_forced_endpoint_policy_values() {
|
||||
assert!(endpoint_config_forces_upstream_stream_policy(Some(
|
||||
&json!({"upstream_stream_policy": "force_stream"})
|
||||
)));
|
||||
assert!(endpoint_config_forces_upstream_stream_policy(Some(
|
||||
&json!({"upstream_stream_policy": "force_non_stream"})
|
||||
)));
|
||||
assert!(!endpoint_config_forces_upstream_stream_policy(Some(
|
||||
&json!({"upstream_stream_policy": "auto"})
|
||||
)));
|
||||
assert!(!endpoint_config_forces_upstream_stream_policy(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_upstream_stream_policy_against_client_mode_and_hard_constraints() {
|
||||
assert!(resolve_upstream_is_stream(
|
||||
false,
|
||||
false,
|
||||
UpstreamStreamPolicy::ForceStream
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream(
|
||||
true,
|
||||
false,
|
||||
UpstreamStreamPolicy::ForceNonStream
|
||||
));
|
||||
assert!(resolve_upstream_is_stream(
|
||||
true,
|
||||
true,
|
||||
UpstreamStreamPolicy::ForceNonStream
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream(
|
||||
false,
|
||||
false,
|
||||
UpstreamStreamPolicy::Auto
|
||||
));
|
||||
assert!(resolve_upstream_is_stream(
|
||||
true,
|
||||
false,
|
||||
UpstreamStreamPolicy::Auto
|
||||
));
|
||||
assert!(resolve_upstream_is_stream(
|
||||
false,
|
||||
true,
|
||||
UpstreamStreamPolicy::Auto
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_endpoint_policy_config_to_upstream_mode() {
|
||||
assert!(resolve_upstream_is_stream_from_endpoint_config(
|
||||
Some(&json!({"upstream_stream_policy": "force_stream"})),
|
||||
false,
|
||||
false,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_from_endpoint_config(
|
||||
Some(&json!({"upstream_stream_policy": "force_non_stream"})),
|
||||
true,
|
||||
false,
|
||||
));
|
||||
assert!(resolve_upstream_is_stream_from_endpoint_config(
|
||||
Some(&json!({"upstream_stream_policy": "auto"})),
|
||||
true,
|
||||
false,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_from_endpoint_config(
|
||||
None, false, false,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_policy_gives_non_stream_contracts_precedence() {
|
||||
let force_stream = json!({"upstream_stream_policy": "force_stream"});
|
||||
assert!(!resolve_upstream_is_stream_for_provider(
|
||||
Some(&force_stream),
|
||||
"codex",
|
||||
"openai:search",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_for_provider(
|
||||
Some(&force_stream),
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
assert!(!resolve_upstream_is_stream_for_provider(
|
||||
Some(&force_stream),
|
||||
"codex",
|
||||
"openai:image",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
assert!(resolve_upstream_is_stream_for_provider(
|
||||
Some(&json!({"upstream_stream_policy": "force_non_stream"})),
|
||||
"codex",
|
||||
"openai:responses",
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub use crate::formats::shared::standard_matrix::{
|
||||
build_standard_request_body_from_canonical,
|
||||
build_standard_request_body_from_canonical_with_model_directives,
|
||||
};
|
||||
@@ -0,0 +1,426 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::contracts::core_success_background_report_kind;
|
||||
use crate::formats::shared::request::UPSTREAM_IS_STREAM_KEY;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LocalSyncReportParts {
|
||||
pub trace_id: String,
|
||||
pub report_kind: String,
|
||||
pub report_context: Option<Value>,
|
||||
pub status_code: u16,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body_json: Option<Value>,
|
||||
pub client_body_json: Option<Value>,
|
||||
pub body_base64: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_generated_tool_call_id(index: usize) -> String {
|
||||
format!("call_auto_{index}")
|
||||
}
|
||||
|
||||
pub fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text,
|
||||
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
||||
None => "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_empty_pages_from_tool_arguments(tool_name: &str, arguments: &str) -> String {
|
||||
if tool_name != "Read" {
|
||||
return arguments.to_string();
|
||||
}
|
||||
let Ok(mut value) = serde_json::from_str::<Value>(arguments) else {
|
||||
return arguments.to_string();
|
||||
};
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return arguments.to_string();
|
||||
};
|
||||
if object.get("pages").and_then(Value::as_str) != Some("") {
|
||||
return arguments.to_string();
|
||||
}
|
||||
object.remove("pages");
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| arguments.to_string())
|
||||
}
|
||||
|
||||
pub fn remove_empty_pages_from_tool_input_value(tool_name: &str, input: &Value) -> Value {
|
||||
if tool_name != "Read" || input.get("pages").and_then(Value::as_str) != Some("") {
|
||||
return input.clone();
|
||||
}
|
||||
let Some(object) = input.as_object() else {
|
||||
return input.clone();
|
||||
};
|
||||
let mut object = object.clone();
|
||||
object.remove("pages");
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
pub fn sanitize_claude_read_tool_inputs(value: &mut Value) -> bool {
|
||||
let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
let mut changed = false;
|
||||
for block in content {
|
||||
let Some(block_object) = block.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if block_object.get("type").and_then(Value::as_str) != Some("tool_use")
|
||||
|| block_object.get("name").and_then(Value::as_str) != Some("Read")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(input) = block_object.get("input") else {
|
||||
continue;
|
||||
};
|
||||
let sanitized = remove_empty_pages_from_tool_input_value("Read", input);
|
||||
if sanitized != *input {
|
||||
block_object.insert("input".to_string(), sanitized);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn prepare_local_success_response_parts(
|
||||
headers: &BTreeMap<String, String>,
|
||||
body_json: &Value,
|
||||
) -> serde_json::Result<(Vec<u8>, BTreeMap<String, String>)> {
|
||||
prepare_local_success_response_parts_owned(headers.clone(), body_json)
|
||||
}
|
||||
|
||||
pub fn prepare_local_success_response_parts_owned(
|
||||
mut headers: BTreeMap<String, String>,
|
||||
body_json: &Value,
|
||||
) -> serde_json::Result<(Vec<u8>, BTreeMap<String, String>)> {
|
||||
headers.remove("content-encoding");
|
||||
headers.remove("content-length");
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
let body_bytes = serde_json::to_vec(body_json)?;
|
||||
headers.insert("content-length".to_string(), body_bytes.len().to_string());
|
||||
Ok((body_bytes, headers))
|
||||
}
|
||||
|
||||
fn should_capture_client_sync_success_body(payload: &LocalSyncReportParts) -> bool {
|
||||
payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|context| context.get(UPSTREAM_IS_STREAM_KEY))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn build_local_success_background_report(
|
||||
payload: &LocalSyncReportParts,
|
||||
body_json: Value,
|
||||
headers: BTreeMap<String, String>,
|
||||
) -> Option<LocalSyncReportParts> {
|
||||
let report_kind = core_success_background_report_kind(payload.report_kind.as_str())?;
|
||||
let upstream_is_stream = should_capture_client_sync_success_body(payload);
|
||||
let client_body_json = upstream_is_stream.then(|| body_json.clone());
|
||||
let provider_body_json = if upstream_is_stream {
|
||||
payload.body_json.clone()
|
||||
} else {
|
||||
Some(body_json)
|
||||
};
|
||||
let provider_body_base64 = if upstream_is_stream {
|
||||
payload.body_base64.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(LocalSyncReportParts {
|
||||
trace_id: payload.trace_id.clone(),
|
||||
report_kind: report_kind.to_string(),
|
||||
report_context: payload.report_context.clone(),
|
||||
status_code: payload.status_code,
|
||||
headers,
|
||||
body_json: provider_body_json,
|
||||
client_body_json,
|
||||
body_base64: provider_body_base64,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_local_success_conversion_background_report(
|
||||
payload: &LocalSyncReportParts,
|
||||
client_body_json: Value,
|
||||
provider_body_json: Value,
|
||||
) -> Option<LocalSyncReportParts> {
|
||||
let report_kind = core_success_background_report_kind(payload.report_kind.as_str())?;
|
||||
|
||||
Some(LocalSyncReportParts {
|
||||
trace_id: payload.trace_id.clone(),
|
||||
report_kind: report_kind.to_string(),
|
||||
report_context: payload.report_context.clone(),
|
||||
status_code: payload.status_code,
|
||||
headers: payload.headers.clone(),
|
||||
body_json: Some(provider_body_json),
|
||||
client_body_json: Some(client_body_json),
|
||||
body_base64: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
build_generated_tool_call_id, build_local_success_background_report,
|
||||
build_local_success_conversion_background_report, canonicalize_tool_arguments,
|
||||
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
|
||||
remove_empty_pages_from_tool_arguments, sanitize_claude_read_tool_inputs,
|
||||
LocalSyncReportParts,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn generated_tool_call_ids_are_stable() {
|
||||
assert_eq!(build_generated_tool_call_id(3), "call_auto_3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_tool_arguments() {
|
||||
assert_eq!(
|
||||
canonicalize_tool_arguments(Some(serde_json::json!({"x": 1}))),
|
||||
"{\"x\":1}"
|
||||
);
|
||||
assert_eq!(canonicalize_tool_arguments(None), "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_empty_pages_from_tool_arguments() {
|
||||
assert_eq!(
|
||||
remove_empty_pages_from_tool_arguments(
|
||||
"Read",
|
||||
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20,"pages":""}"#
|
||||
),
|
||||
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20}"#
|
||||
);
|
||||
assert_eq!(
|
||||
remove_empty_pages_from_tool_arguments("Search", r#"{"query":"","pages":""}"#),
|
||||
r#"{"query":"","pages":""}"#
|
||||
);
|
||||
assert_eq!(
|
||||
remove_empty_pages_from_tool_arguments("Read", r#"{"pages":"1-2"}"#),
|
||||
r#"{"pages":"1-2"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
remove_empty_pages_from_tool_arguments("Read", r#"{"pages":"#),
|
||||
r#"{"pages":"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_claude_read_tool_inputs_only() {
|
||||
let mut value = serde_json::json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/tmp/a.txt",
|
||||
"limit": 20,
|
||||
"pages": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "Search",
|
||||
"input": {
|
||||
"query": "",
|
||||
"pages": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"pages": "1-2"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert!(sanitize_claude_read_tool_inputs(&mut value));
|
||||
assert_eq!(
|
||||
value["content"][0]["input"],
|
||||
serde_json::json!({
|
||||
"file_path": "/tmp/a.txt",
|
||||
"limit": 20,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
value["content"][1]["input"],
|
||||
serde_json::json!({
|
||||
"query": "",
|
||||
"pages": "",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
value["content"][2]["input"],
|
||||
serde_json::json!({"pages": "1-2"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_local_success_response_parts_normalizes_headers() {
|
||||
let headers = BTreeMap::from([
|
||||
("content-encoding".to_string(), "gzip".to_string()),
|
||||
("content-length".to_string(), "999".to_string()),
|
||||
("x-test".to_string(), "1".to_string()),
|
||||
]);
|
||||
let (body_bytes, normalized_headers) =
|
||||
prepare_local_success_response_parts(&headers, &serde_json::json!({"ok": true}))
|
||||
.expect("response parts should serialize");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&body_bytes).expect("json body"),
|
||||
serde_json::json!({"ok": true})
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert!(!normalized_headers.contains_key("content-encoding"));
|
||||
let expected_length = body_bytes.len().to_string();
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-length").map(String::as_str),
|
||||
Some(expected_length.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("x-test").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_local_success_response_parts_owned_normalizes_headers() {
|
||||
let headers = BTreeMap::from([
|
||||
("content-encoding".to_string(), "gzip".to_string()),
|
||||
("content-length".to_string(), "999".to_string()),
|
||||
("x-test".to_string(), "1".to_string()),
|
||||
]);
|
||||
let (body_bytes, normalized_headers) =
|
||||
prepare_local_success_response_parts_owned(headers, &serde_json::json!({"ok": true}))
|
||||
.expect("response parts should serialize");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&body_bytes).expect("json body"),
|
||||
serde_json::json!({"ok": true})
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
assert!(!normalized_headers.contains_key("content-encoding"));
|
||||
let expected_length = body_bytes.len().to_string();
|
||||
assert_eq!(
|
||||
normalized_headers.get("content-length").map(String::as_str),
|
||||
Some(expected_length.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_headers.get("x-test").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_success_background_report_maps_finalize_kind() {
|
||||
let payload = LocalSyncReportParts {
|
||||
trace_id: "trace-1".to_string(),
|
||||
report_kind: "openai_chat_sync_finalize".to_string(),
|
||||
report_context: Some(serde_json::json!({"request_id": "req-1"})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([("x-test".to_string(), "1".to_string())]),
|
||||
body_json: None,
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
};
|
||||
|
||||
let report = build_local_success_background_report(
|
||||
&payload,
|
||||
serde_json::json!({"id": "resp-1"}),
|
||||
payload.headers.clone(),
|
||||
)
|
||||
.expect("success report should be built");
|
||||
|
||||
assert_eq!(report.report_kind, "openai_chat_sync_success");
|
||||
assert_eq!(report.body_json, Some(serde_json::json!({"id": "resp-1"})));
|
||||
assert_eq!(report.client_body_json, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_success_background_report_preserves_provider_stream_for_upstream_stream_sync() {
|
||||
let payload = LocalSyncReportParts {
|
||||
trace_id: "trace-1b".to_string(),
|
||||
report_kind: "openai_chat_sync_finalize".to_string(),
|
||||
report_context: Some(serde_json::json!({
|
||||
"request_id": "req-1b",
|
||||
"upstream_is_stream": true
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
|
||||
body_json: None,
|
||||
client_body_json: None,
|
||||
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(
|
||||
concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1b\",\"object\":\"response\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1b\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n",
|
||||
)
|
||||
)),
|
||||
};
|
||||
|
||||
let report = build_local_success_background_report(
|
||||
&payload,
|
||||
serde_json::json!({"id": "resp-1b"}),
|
||||
payload.headers.clone(),
|
||||
)
|
||||
.expect("success report should be built");
|
||||
|
||||
assert_eq!(report.body_json, None);
|
||||
assert_eq!(
|
||||
report.client_body_json,
|
||||
Some(serde_json::json!({"id": "resp-1b"}))
|
||||
);
|
||||
assert_eq!(report.body_base64, payload.body_base64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_success_conversion_background_report_maps_provider_body() {
|
||||
let payload = LocalSyncReportParts {
|
||||
trace_id: "trace-2".to_string(),
|
||||
report_kind: "openai_chat_sync_finalize".to_string(),
|
||||
report_context: Some(serde_json::json!({"request_id": "req-2"})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
|
||||
body_json: None,
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
};
|
||||
|
||||
let report = build_local_success_conversion_background_report(
|
||||
&payload,
|
||||
serde_json::json!({"client": true}),
|
||||
serde_json::json!({"provider": true}),
|
||||
)
|
||||
.expect("conversion success report should be built");
|
||||
|
||||
assert_eq!(report.report_kind, "openai_chat_sync_success");
|
||||
assert_eq!(
|
||||
report.body_json,
|
||||
Some(serde_json::json!({"provider": true}))
|
||||
);
|
||||
assert_eq!(
|
||||
report.client_body_json,
|
||||
Some(serde_json::json!({"client": true}))
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
pub fn map_claude_stop_reason(stop_reason: Option<&str>, has_tool_calls: bool) -> Option<String> {
|
||||
let mapped = match stop_reason {
|
||||
Some("end_turn") | Some("stop_sequence") => Some("stop".to_string()),
|
||||
Some("max_tokens") => Some("length".to_string()),
|
||||
Some("tool_use") => Some("tool_calls".to_string()),
|
||||
Some("pause_turn") => Some("stop".to_string()),
|
||||
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
if has_tool_calls && mapped.as_deref().is_none_or(|value| value == "stop") {
|
||||
Some("tool_calls".to_string())
|
||||
} else {
|
||||
mapped
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_done_sse() -> Vec<u8> {
|
||||
b"data: [DONE]\n\n".to_vec()
|
||||
}
|
||||
|
||||
pub fn encode_json_sse(
|
||||
event: Option<&str>,
|
||||
value: &Value,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(event) = event.filter(|value| !value.trim().is_empty()) {
|
||||
out.extend_from_slice(b"event: ");
|
||||
out.extend_from_slice(event.as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.extend_from_slice(b"data: ");
|
||||
out.extend(serde_json::to_vec(value).map_err(AiSurfaceFinalizeError::from)?);
|
||||
out.extend_from_slice(b"\n\n");
|
||||
Ok(out)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,904 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use aether_ai_formats::formats::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_responses_request_to_openai_chat_request,
|
||||
};
|
||||
use aether_ai_formats::{request_conversion_kind, FormatContext, RequestConversionKind};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::shared::model_directives::apply_model_directive_overrides_from_request;
|
||||
|
||||
fn is_responses_shaped_body_on_chat_endpoint(body_json: &Value) -> bool {
|
||||
body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| !object.contains_key("messages") && object.contains_key("input"))
|
||||
}
|
||||
|
||||
pub fn is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json: &Value) -> bool {
|
||||
let Some(request) = body_json.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if !request.contains_key("messages") {
|
||||
return false;
|
||||
}
|
||||
request
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tools| tools.iter().any(is_claude_native_tool_definition))
|
||||
|| request
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|messages| messages.iter().any(message_has_claude_tool_block))
|
||||
}
|
||||
|
||||
fn is_claude_native_tool_definition(tool: &Value) -> bool {
|
||||
tool.as_object().is_some_and(|tool_object| {
|
||||
tool_object.contains_key("input_schema") && !tool_object.contains_key("function")
|
||||
})
|
||||
}
|
||||
|
||||
fn message_has_claude_tool_block(message: &Value) -> bool {
|
||||
message
|
||||
.as_object()
|
||||
.and_then(|object| object.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| parts.iter().any(is_claude_tool_content_block))
|
||||
}
|
||||
|
||||
fn is_claude_tool_content_block(part: &Value) -> bool {
|
||||
part.as_object()
|
||||
.and_then(|object| object.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|block_type| matches!(block_type, "tool_use" | "tool_result"))
|
||||
}
|
||||
|
||||
fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Cow<'_, Value>> {
|
||||
if is_responses_shaped_body_on_chat_endpoint(body_json) {
|
||||
return normalize_openai_responses_request_to_openai_chat_request(body_json)
|
||||
.map(Cow::Owned);
|
||||
}
|
||||
Some(Cow::Borrowed(body_json))
|
||||
}
|
||||
|
||||
fn chat_compatible_body_for_standard_source<'a>(
|
||||
body_json: &'a Value,
|
||||
client_api_format: &str,
|
||||
) -> Option<Cow<'a, Value>> {
|
||||
match aether_ai_formats::normalize_api_format_alias(client_api_format).as_str() {
|
||||
"openai:chat" => chat_compatible_body_for_openai_chat_endpoint(body_json),
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
normalize_openai_responses_request_to_openai_chat_request(body_json).map(Cow::Owned)
|
||||
}
|
||||
"claude:messages" => {
|
||||
normalize_claude_request_to_openai_chat_request(body_json).map(Cow::Owned)
|
||||
}
|
||||
"gemini:generate_content" => {
|
||||
normalize_gemini_request_to_openai_chat_request(body_json, "").map(Cow::Owned)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_local_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_local_openai_chat_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_local_openai_chat_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
let request_body_object = chat_body.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if upstream_is_stream {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
match provider_request_body.get_mut("stream_options") {
|
||||
Some(Value::Object(stream_options)) => {
|
||||
stream_options.insert("include_usage".to_string(), Value::Bool(true));
|
||||
}
|
||||
_ => {
|
||||
provider_request_body.insert(
|
||||
"stream_options".to_string(),
|
||||
json!({
|
||||
"include_usage": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut provider_request_body = with_model_directive_overrides(
|
||||
Value::Object(provider_request_body),
|
||||
"openai:chat",
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
);
|
||||
let require_body_stream_field = body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("stream"));
|
||||
crate::formats::shared::request::enforce_request_body_stream_field(
|
||||
&mut provider_request_body,
|
||||
"openai:chat",
|
||||
upstream_is_stream,
|
||||
require_body_stream_field,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToClaudeStandard => {
|
||||
if is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json) {
|
||||
convert_claude_compatible_chat_endpoint_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)?
|
||||
} else {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
convert_openai_chat_request_to_claude_request(
|
||||
chat_body.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?
|
||||
}
|
||||
}
|
||||
RequestConversionKind::ToGeminiStandard => {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
convert_openai_chat_request_to_gemini_request(
|
||||
chat_body.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
if is_responses_shaped_body_on_chat_endpoint(body_json) {
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
enable_model_directives,
|
||||
)?
|
||||
} else {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)?
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut provider_request_body = with_model_directive_overrides(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
);
|
||||
let require_body_stream_field = body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("stream"));
|
||||
crate::formats::shared::request::enforce_request_body_stream_field(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
require_body_stream_field,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
fn convert_claude_compatible_chat_endpoint_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
aether_ai_formats::convert_request(
|
||||
"claude:messages",
|
||||
provider_api_format,
|
||||
body_json,
|
||||
&FormatContext::default()
|
||||
.with_mapped_model(mapped_model)
|
||||
.with_upstream_stream(upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn build_local_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
) -> Option<Value> {
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
require_streaming,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if require_streaming {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
let mut provider_request_body = with_model_directive_overrides(
|
||||
Value::Object(provider_request_body),
|
||||
"openai:responses",
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
);
|
||||
let require_body_stream_field = body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("stream"));
|
||||
crate::formats::shared::request::enforce_request_body_stream_field(
|
||||
&mut provider_request_body,
|
||||
"openai:responses",
|
||||
require_streaming,
|
||||
require_body_stream_field,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_cross_format_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_responses_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let chat_like_request = chat_compatible_body_for_standard_source(body_json, client_api_format)?;
|
||||
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToOpenAIChat => {
|
||||
build_local_openai_chat_request_body_with_model_directives(
|
||||
chat_like_request.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
enable_model_directives,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
chat_like_request.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
chat_like_request.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
chat_like_request.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
};
|
||||
let mut provider_request_body = with_model_directive_overrides(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
);
|
||||
let require_body_stream_field = body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("stream"));
|
||||
crate::formats::shared::request::enforce_request_body_stream_field(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
require_body_stream_field,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
fn with_model_directive_overrides(
|
||||
mut provider_request_body: Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
request_body: &Value,
|
||||
request_path: Option<&str>,
|
||||
enable_model_directives: bool,
|
||||
) -> Value {
|
||||
if enable_model_directives {
|
||||
apply_model_directive_overrides_from_request(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
request_body,
|
||||
request_path,
|
||||
);
|
||||
}
|
||||
provider_request_body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_local_openai_responses_request_body;
|
||||
use super::{
|
||||
build_cross_format_openai_chat_request_body_with_model_directives,
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body_with_model_directives,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("json object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
false,
|
||||
)
|
||||
.expect("openai responses to openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["messages"][0]["role"], "user");
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_request_body_preserves_original_field_order() {
|
||||
let body_json: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"model": "gpt-5",
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"input": [],
|
||||
"instructions": "Keep order"
|
||||
}"#,
|
||||
)
|
||||
.expect("request json should parse");
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_responses_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai responses body should build");
|
||||
|
||||
assert_eq!(
|
||||
object_keys(&provider_request_body),
|
||||
vec!["model", "include", "input", "instructions"]
|
||||
);
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_accepts_responses_shape_from_chat_endpoint() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"input": [{"role": "user", "content": "hello"}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "Shell",
|
||||
"parameters": {"type": "object"},
|
||||
"strict": false
|
||||
}],
|
||||
"reasoning": {"effort": "high"}
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
|
||||
.expect("responses-shaped chat body should build as chat");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["messages"][0]["role"], "user");
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
assert_eq!(
|
||||
provider_request_body["tools"][0]["function"]["name"],
|
||||
"Shell"
|
||||
);
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "high");
|
||||
assert_eq!(provider_request_body["stream"], true);
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_chat_request_body_preserves_responses_shape_for_responses_target() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"input": [{"role": "user", "content": "hello"}],
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"stream_options": {"include_usage": true},
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "Shell",
|
||||
"parameters": {"type": "object"},
|
||||
"strict": false
|
||||
}, {
|
||||
"type": "function",
|
||||
"parameters": {"type": "object"}
|
||||
}]
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("responses-shaped chat body should build as responses");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["input"][0]["role"], "user");
|
||||
assert_eq!(provider_request_body["input"][0]["content"], "hello");
|
||||
assert_eq!(provider_request_body["tools"][0]["name"], "Shell");
|
||||
assert_eq!(provider_request_body["tools"][0]["strict"], false);
|
||||
assert_eq!(provider_request_body["tools"][1]["type"], "function");
|
||||
assert_eq!(
|
||||
provider_request_body["include"][0],
|
||||
"reasoning.encrypted_content"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
assert_eq!(provider_request_body["stream"], false);
|
||||
assert!(provider_request_body.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_responses_body_preserves_chat_messages_for_chat_source() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.5",
|
||||
"messages": [
|
||||
{"role": "system", "content": "Return a JSON object."},
|
||||
{"role": "user", "content": "Explain why this JSON patch failed."}
|
||||
],
|
||||
"response_format": {"type": "json_object"}
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5.5-upstream",
|
||||
"openai:chat",
|
||||
"openai:responses",
|
||||
false,
|
||||
)
|
||||
.expect("openai chat to openai responses body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5.5-upstream");
|
||||
assert_eq!(
|
||||
provider_request_body["text"]["format"]["type"],
|
||||
"json_object"
|
||||
);
|
||||
assert_eq!(provider_request_body["input"][0]["role"], "user");
|
||||
assert_eq!(
|
||||
provider_request_body["input"][0]["content"][0]["text"],
|
||||
"Explain why this JSON patch failed."
|
||||
);
|
||||
assert!(provider_request_body.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_request_body_prefers_messages_when_messages_and_input_are_both_present() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "from messages"}],
|
||||
"input": [{"role": "user", "content": "from input"}]
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("normal chat body should still use messages");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["input"][0]["content"][0]["text"],
|
||||
"from messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "hello"
|
||||
}]
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["stream"], true);
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_overrides_client_stream_for_non_stream_upstream() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "hello"
|
||||
}],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["stream"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_request_body_overrides_client_stream_for_non_stream_upstream() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_responses_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai responses body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["stream"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_chat_request_body_overrides_client_stream_for_non_stream_upstream() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let claude = build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"claude:messages",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("claude body should build");
|
||||
assert_eq!(claude["stream"], false);
|
||||
|
||||
let responses = build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("responses body should build");
|
||||
assert_eq!(responses["stream"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_chat_request_body_does_not_add_stream_false_for_plain_sync_body() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
let claude = build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"claude:messages",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("claude body should build");
|
||||
assert!(claude.get("stream").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_responses_body_overrides_client_stream_for_non_stream_upstream() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"openai:responses",
|
||||
"claude:messages",
|
||||
false,
|
||||
)
|
||||
.expect("claude body should build");
|
||||
|
||||
assert_eq!(provider_request_body["stream"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-xhigh",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body = build_local_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_leaves_model_directive_disabled_by_default() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-xhigh",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.6-sol-max",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "low", "summary": "auto"}
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("openai responses body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning"]["summary"], "auto");
|
||||
assert_eq!(provider_request_body["reasoning"]["effort"], "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-high",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"claude:messages",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("claude body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(provider_request_body["output_config"]["effort"], "high");
|
||||
assert_eq!(provider_request_body["thinking"]["budget_tokens"], 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_local_openai_chat_request_body_preserves_stream_options_while_forcing_include_usage(
|
||||
) {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "hello"
|
||||
}],
|
||||
"stream_options": {
|
||||
"include_usage": false,
|
||||
"extra": "keep-me"
|
||||
}
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
assert_eq!(provider_request_body["stream_options"]["extra"], "keep-me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_chat_request_body_accepts_claude_native_messages_for_claude_target() {
|
||||
let body_json = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "lookup"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "checking"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_1",
|
||||
"name": "lookup",
|
||||
"input": {"q": "db"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": {"rows": 1}
|
||||
}]
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}
|
||||
}],
|
||||
"tool_choice": {"type": "auto"},
|
||||
"max_tokens": 128,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"claude:messages",
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.expect("claude-native chat endpoint body should build as claude messages");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(provider_request_body["tools"][0]["name"], "lookup");
|
||||
assert_eq!(
|
||||
provider_request_body["tools"][0]["input_schema"]["properties"]["q"]["type"],
|
||||
"string"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["messages"][1]["content"][1]["type"],
|
||||
"tool_use"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["messages"][2]["content"][0]["type"],
|
||||
"tool_result"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
provider_request_body["messages"][2]["content"][0]["content"]
|
||||
.as_str()
|
||||
.expect("object tool result content should be serialized for Claude")
|
||||
)
|
||||
.expect("serialized tool result content should remain JSON"),
|
||||
json!({"rows": 1})
|
||||
);
|
||||
assert_eq!(provider_request_body["tool_choice"]["type"], "auto");
|
||||
assert_eq!(provider_request_body["stream"], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
|
||||
|
||||
pub use aether_ai_formats::protocol::stream::{
|
||||
CanonicalContentPart, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
||||
};
|
||||
|
||||
pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
|
||||
let text = std::str::from_utf8(line).ok()?;
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
return None;
|
||||
}
|
||||
let data_line = trimmed.strip_prefix("data:")?.trim();
|
||||
if data_line.is_empty() || data_line == "[DONE]" {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(data_line).ok()
|
||||
}
|
||||
|
||||
pub fn unsupported_stream_event_message(payload: &Value) -> String {
|
||||
const BASE_MESSAGE: &str = "Unsupported provider stream event cannot be converted losslessly";
|
||||
match unsupported_stream_event_diagnostic(payload) {
|
||||
Some(diagnostic) if !diagnostic.is_empty() => format!("{BASE_MESSAGE}: {diagnostic}"),
|
||||
_ => BASE_MESSAGE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_stream_event_diagnostic(payload: &Value) -> Option<String> {
|
||||
let mut details = Vec::new();
|
||||
if let Some((path, value)) = unsupported_stream_event_primary_field(payload) {
|
||||
details.push(format!("field {path} = {value}"));
|
||||
} else if let Some(path) = unsupported_stream_event_single_field(payload) {
|
||||
details.push(format!("field {path} is unsupported"));
|
||||
}
|
||||
|
||||
if let Some(fields) = unsupported_stream_event_field_list(payload) {
|
||||
details.push(format!("fields: {fields}"));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(details.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_stream_event_primary_field(payload: &Value) -> Option<(&'static str, String)> {
|
||||
const STRING_FIELD_PATHS: &[(&str, &str)] = &[
|
||||
("$.item.type", "/item/type"),
|
||||
("$.content_block.type", "/content_block/type"),
|
||||
("$.delta.type", "/delta/type"),
|
||||
("$.part.type", "/part/type"),
|
||||
("$.payload.type", "/payload/type"),
|
||||
("$.type", "/type"),
|
||||
("$.event", "/event"),
|
||||
];
|
||||
|
||||
STRING_FIELD_PATHS
|
||||
.iter()
|
||||
.find_map(|(display_path, pointer)| {
|
||||
payload
|
||||
.pointer(pointer)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| (*display_path, json!(value.trim()).to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
fn unsupported_stream_event_single_field(payload: &Value) -> Option<String> {
|
||||
let object = payload.as_object()?;
|
||||
if object.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
object.keys().next().map(|key| json_path_key(key))
|
||||
}
|
||||
|
||||
fn unsupported_stream_event_field_list(payload: &Value) -> Option<String> {
|
||||
let object = payload.as_object()?;
|
||||
if object.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fields = object
|
||||
.keys()
|
||||
.take(8)
|
||||
.map(|key| key.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if object.len() > 8 {
|
||||
Some(format!("{fields}, ..."))
|
||||
} else {
|
||||
Some(fields)
|
||||
}
|
||||
}
|
||||
|
||||
fn json_path_key(key: &str) -> String {
|
||||
if !key.is_empty()
|
||||
&& key.chars().enumerate().all(|(index, ch)| {
|
||||
ch == '_' || ch.is_ascii_alphabetic() || (index > 0 && ch.is_ascii_digit())
|
||||
})
|
||||
{
|
||||
format!("$.{key}")
|
||||
} else {
|
||||
format!("$[{}]", json!(key))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_identity(
|
||||
response_id: Option<&str>,
|
||||
model: Option<&str>,
|
||||
report_context: &Value,
|
||||
default_id: &str,
|
||||
) -> (String, String) {
|
||||
let id = response_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(default_id)
|
||||
.to_string();
|
||||
if let Some(display_model) = model_directive_display_model_from_report_context(report_context) {
|
||||
return (id, display_model);
|
||||
}
|
||||
let model = model
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
(id, model)
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let mut input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.or_else(|| usage.get("prompt_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.or_else(|| usage.get("completion_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_tokens = usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.or_else(|| usage.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| {
|
||||
details
|
||||
.get("cache_write_tokens")
|
||||
.or_else(|| details.get("cached_creation_tokens"))
|
||||
.or_else(|| details.get("cache_creation_tokens"))
|
||||
})
|
||||
.and_then(Value::as_u64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let cache_read_tokens = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.or_else(|| usage.get("prompt_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("cached_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("reasoning_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("output_tokens_details")
|
||||
.or_else(|| usage.get("completion_tokens_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("reasoning_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage.get("total_tokens").and_then(Value::as_u64).unwrap_or(
|
||||
input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
.saturating_add(reasoning_tokens),
|
||||
);
|
||||
if input_tokens == 0 && total_tokens > output_tokens {
|
||||
input_tokens = total_tokens.saturating_sub(output_tokens);
|
||||
}
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
input_tokens_include_cache: cache_read_tokens > 0 || cache_creation_tokens > 0,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
..CanonicalUsage::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn openai_stream_payload_is_terminal_error(payload: &Value) -> bool {
|
||||
let response = payload.get("response").and_then(Value::as_object);
|
||||
if payload.get("error").is_some_and(|error| !error.is_null())
|
||||
|| response
|
||||
.and_then(|response| response.get("error"))
|
||||
.is_some_and(|error| !error.is_null())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let event_type = payload
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if matches!(event_type, "error" | "response.failed") {
|
||||
return true;
|
||||
}
|
||||
|
||||
response
|
||||
.and_then(|response| response.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|status| status == "failed")
|
||||
}
|
||||
|
||||
pub fn openai_stream_terminal_error_body(payload: &Value) -> Option<Value> {
|
||||
if !openai_stream_payload_is_terminal_error(payload) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let event_type = payload
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let response = payload.get("response").and_then(Value::as_object);
|
||||
let status = response
|
||||
.and_then(|response| response.get("status"))
|
||||
.and_then(Value::as_str);
|
||||
let raw_error = response
|
||||
.and_then(|response| response.get("error"))
|
||||
.or_else(|| payload.get("error"));
|
||||
|
||||
let mut error = raw_error
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| raw_error.and_then(Value::as_str).map(ToOwned::to_owned))
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.or_else(|| {
|
||||
response
|
||||
.and_then(|response| response.get("incomplete_details"))
|
||||
.and_then(|details| details.get("reason"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|reason| format!("Response incomplete: {reason}"))
|
||||
})
|
||||
.or_else(|| status.map(|status| format!("Response ended with status {status}")))
|
||||
.unwrap_or_else(|| "Upstream stream ended with an error".to_string());
|
||||
|
||||
error
|
||||
.entry("message".to_string())
|
||||
.or_insert_with(|| Value::String(message));
|
||||
error.entry("type".to_string()).or_insert_with(|| {
|
||||
if event_type == "response.incomplete" || status == Some("incomplete") {
|
||||
Value::String("incomplete".to_string())
|
||||
} else {
|
||||
Value::String("server_error".to_string())
|
||||
}
|
||||
});
|
||||
|
||||
if !error.contains_key("code") {
|
||||
if let Some(reason) = response
|
||||
.and_then(|response| response.get("incomplete_details"))
|
||||
.and_then(|details| details.get("reason"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
error.insert("code".to_string(), Value::String(reason.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Some(json!({ "error": Value::Object(error) }))
|
||||
}
|
||||
|
||||
pub fn openai_stream_terminal_error_message(payload: &Value) -> Option<String> {
|
||||
openai_stream_terminal_error_body(payload)
|
||||
.and_then(|body| body.get("error").cloned())
|
||||
.and_then(|error| {
|
||||
error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("output_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_ephemeral_5m_tokens = usage
|
||||
.get("cache_creation")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("ephemeral_5m_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_ephemeral_1h_tokens = usage
|
||||
.get("cache_creation")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("ephemeral_1h_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_creation_tokens = usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(
|
||||
cache_creation_ephemeral_5m_tokens.saturating_add(cache_creation_ephemeral_1h_tokens),
|
||||
);
|
||||
let cache_read_tokens = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("output_tokens_details")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| {
|
||||
details
|
||||
.get("thinking_tokens")
|
||||
.or_else(|| details.get("reasoning_tokens"))
|
||||
})
|
||||
.or_else(|| usage.get("reasoning_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
input_tokens_include_cache: false,
|
||||
output_tokens,
|
||||
total_tokens: input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
.saturating_add(reasoning_tokens),
|
||||
cache_creation_tokens,
|
||||
cache_creation_ephemeral_5m_tokens,
|
||||
cache_creation_ephemeral_1h_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn content_part_from_openai_image_generation_item(
|
||||
item: &Value,
|
||||
) -> Option<CanonicalContentPart> {
|
||||
let item = item.as_object()?;
|
||||
let result = item
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let url = item
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let image = if let Some(result) = result {
|
||||
if result.starts_with("data:image/")
|
||||
|| result.starts_with("http://")
|
||||
|| result.starts_with("https://")
|
||||
{
|
||||
result.to_string()
|
||||
} else {
|
||||
let mime_type = item
|
||||
.get("mime_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
item.get("output_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(openai_image_output_format_to_mime_type)
|
||||
})
|
||||
.unwrap_or_else(|| "image/png".to_string());
|
||||
format!("data:{mime_type};base64,{result}")
|
||||
}
|
||||
} else {
|
||||
url?.to_string()
|
||||
};
|
||||
Some(CanonicalContentPart::ImageUrl(image))
|
||||
}
|
||||
|
||||
fn openai_image_output_format_to_mime_type(output_format: &str) -> String {
|
||||
match output_format.trim().to_ascii_lowercase().as_str() {
|
||||
"jpeg" | "jpg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
_ => "image/png",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("promptTokenCount")
|
||||
.or_else(|| usage.get("prompt_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("candidatesTokenCount")
|
||||
.or_else(|| usage.get("candidates_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("thoughtsTokenCount")
|
||||
.or_else(|| usage.get("thoughts_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_read_tokens = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.or_else(|| usage.get("cached_content_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.get("totalTokenCount")
|
||||
.or_else(|| usage.get("total_token_count"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(
|
||||
input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
.saturating_add(reasoning_tokens),
|
||||
);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
input_tokens_include_cache: cache_read_tokens > 0,
|
||||
output_tokens: output_tokens.saturating_add(reasoning_tokens),
|
||||
total_tokens,
|
||||
cache_read_tokens,
|
||||
reasoning_tokens,
|
||||
..CanonicalUsage::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_openai_finish_reason(value: Option<&str>) -> Option<String> {
|
||||
match value {
|
||||
Some("function_call") => Some("tool_calls".to_string()),
|
||||
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_finish_reason_to_claude(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("length") => "max_tokens",
|
||||
Some("tool_calls") | Some("function_call") => "tool_use",
|
||||
Some("content_filter") => "content_filtered",
|
||||
_ => "end_turn",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_finish_reason_to_gemini(value: Option<&str>) -> &'static str {
|
||||
match value {
|
||||
Some("length") => "MAX_TOKENS",
|
||||
Some("content_filter") => "SAFETY",
|
||||
_ => "STOP",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_json_arguments_value(arguments: &str) -> Option<Value> {
|
||||
let trimmed = arguments.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some(Value::Object(Map::new()));
|
||||
}
|
||||
serde_json::from_str(trimmed).ok()
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_chunk(
|
||||
id: &str,
|
||||
model: &str,
|
||||
text: String,
|
||||
tool_calls: Option<Vec<Value>>,
|
||||
finish_reason: Option<&str>,
|
||||
) -> Value {
|
||||
let mut delta = Map::new();
|
||||
delta.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
if !text.is_empty() {
|
||||
delta.insert("content".to_string(), Value::String(text));
|
||||
} else if tool_calls.is_none() {
|
||||
delta.insert("content".to_string(), Value::String(String::new()));
|
||||
}
|
||||
if let Some(tool_calls) = tool_calls {
|
||||
delta.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": Value::Object(delta),
|
||||
"finish_reason": finish_reason,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_role_chunk(id: &str, model: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_finish_chunk(id: &str, model: &str, finish_reason: Option<&str>) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": finish_reason,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_usage_chunk(
|
||||
id: &str,
|
||||
model: &str,
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
reasoning_tokens: u64,
|
||||
) -> Value {
|
||||
build_openai_chat_usage_chunk_with_cache(
|
||||
id,
|
||||
model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
reasoning_tokens,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_openai_chat_usage_chunk_with_cache(
|
||||
id: &str,
|
||||
model: &str,
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
reasoning_tokens: u64,
|
||||
cache_creation_tokens: u64,
|
||||
cache_read_tokens: u64,
|
||||
) -> Value {
|
||||
let usage = openai_chat_usage_payload(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
reasoning_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
);
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": [],
|
||||
"usage": usage,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_openai_chat_usage_chunk_from_usage(
|
||||
id: &str,
|
||||
model: &str,
|
||||
usage: &CanonicalUsage,
|
||||
) -> Value {
|
||||
let input_tokens = inclusive_input_tokens_from_usage(usage);
|
||||
build_openai_chat_usage_chunk_with_cache(
|
||||
id,
|
||||
model,
|
||||
input_tokens,
|
||||
usage.output_tokens,
|
||||
inclusive_total_tokens_from_usage(usage, input_tokens),
|
||||
usage.reasoning_tokens,
|
||||
cache_creation_tokens_for_usage(usage),
|
||||
usage.cache_read_tokens,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn openai_responses_usage_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
let mut output = Map::new();
|
||||
let input_tokens = inclusive_input_tokens_from_usage(usage);
|
||||
output.insert("input_tokens".to_string(), Value::from(input_tokens));
|
||||
output.insert(
|
||||
"output_tokens".to_string(),
|
||||
Value::from(usage.output_tokens),
|
||||
);
|
||||
output.insert(
|
||||
"total_tokens".to_string(),
|
||||
Value::from(inclusive_total_tokens_from_usage(usage, input_tokens)),
|
||||
);
|
||||
if usage.reasoning_tokens > 0 {
|
||||
output.insert(
|
||||
"output_tokens_details".to_string(),
|
||||
json!({ "reasoning_tokens": usage.reasoning_tokens }),
|
||||
);
|
||||
}
|
||||
insert_openai_token_details(
|
||||
&mut output,
|
||||
"input_tokens_details",
|
||||
cache_creation_tokens_for_usage(usage),
|
||||
usage.cache_read_tokens,
|
||||
);
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
pub fn claude_usage_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
let mut output = Map::new();
|
||||
output.insert(
|
||||
"input_tokens".to_string(),
|
||||
Value::from(claude_input_tokens_from_usage(usage)),
|
||||
);
|
||||
output.insert(
|
||||
"output_tokens".to_string(),
|
||||
Value::from(usage.output_tokens),
|
||||
);
|
||||
if usage.cache_read_tokens > 0 {
|
||||
output.insert(
|
||||
"cache_read_input_tokens".to_string(),
|
||||
Value::from(usage.cache_read_tokens),
|
||||
);
|
||||
}
|
||||
let cache_creation_tokens = cache_creation_tokens_for_usage(usage);
|
||||
if cache_creation_tokens > 0 {
|
||||
output.insert(
|
||||
"cache_creation_input_tokens".to_string(),
|
||||
Value::from(cache_creation_tokens),
|
||||
);
|
||||
}
|
||||
if usage.cache_creation_ephemeral_5m_tokens > 0 || usage.cache_creation_ephemeral_1h_tokens > 0
|
||||
{
|
||||
output.insert(
|
||||
"cache_creation".to_string(),
|
||||
json!({
|
||||
"ephemeral_5m_input_tokens": usage.cache_creation_ephemeral_5m_tokens,
|
||||
"ephemeral_1h_input_tokens": usage.cache_creation_ephemeral_1h_tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
pub fn gemini_usage_metadata_from_usage(usage: &CanonicalUsage) -> Value {
|
||||
let visible_output_tokens = usage.output_tokens.saturating_sub(usage.reasoning_tokens);
|
||||
let input_tokens = inclusive_input_tokens_from_usage(usage);
|
||||
let mut output = Map::new();
|
||||
output.insert("promptTokenCount".to_string(), Value::from(input_tokens));
|
||||
output.insert(
|
||||
"candidatesTokenCount".to_string(),
|
||||
Value::from(visible_output_tokens),
|
||||
);
|
||||
output.insert(
|
||||
"totalTokenCount".to_string(),
|
||||
Value::from(inclusive_total_tokens_from_usage(usage, input_tokens)),
|
||||
);
|
||||
if usage.reasoning_tokens > 0 {
|
||||
output.insert(
|
||||
"thoughtsTokenCount".to_string(),
|
||||
Value::from(usage.reasoning_tokens),
|
||||
);
|
||||
}
|
||||
if usage.cache_read_tokens > 0 {
|
||||
output.insert(
|
||||
"cachedContentTokenCount".to_string(),
|
||||
Value::from(usage.cache_read_tokens),
|
||||
);
|
||||
}
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
fn openai_chat_usage_payload(
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
reasoning_tokens: u64,
|
||||
cache_creation_tokens: u64,
|
||||
cache_read_tokens: u64,
|
||||
) -> Map<String, 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 }),
|
||||
);
|
||||
}
|
||||
insert_openai_token_details(
|
||||
&mut usage,
|
||||
"prompt_tokens_details",
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
);
|
||||
usage
|
||||
}
|
||||
|
||||
fn insert_openai_token_details(
|
||||
output: &mut Map<String, Value>,
|
||||
details_key: &str,
|
||||
cache_creation_tokens: u64,
|
||||
cache_read_tokens: u64,
|
||||
) {
|
||||
if cache_creation_tokens == 0 && cache_read_tokens == 0 {
|
||||
return;
|
||||
}
|
||||
let mut details = Map::new();
|
||||
if cache_read_tokens > 0 {
|
||||
details.insert("cached_tokens".to_string(), Value::from(cache_read_tokens));
|
||||
}
|
||||
if cache_creation_tokens > 0 {
|
||||
details.insert(
|
||||
"cache_write_tokens".to_string(),
|
||||
Value::from(cache_creation_tokens),
|
||||
);
|
||||
}
|
||||
output.insert(details_key.to_string(), Value::Object(details));
|
||||
}
|
||||
|
||||
fn cache_creation_tokens_for_usage(usage: &CanonicalUsage) -> u64 {
|
||||
if usage.cache_creation_tokens > 0 {
|
||||
usage.cache_creation_tokens
|
||||
} else {
|
||||
usage
|
||||
.cache_creation_ephemeral_5m_tokens
|
||||
.saturating_add(usage.cache_creation_ephemeral_1h_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_input_tokens_for_usage(usage: &CanonicalUsage) -> u64 {
|
||||
usage
|
||||
.cache_read_tokens
|
||||
.saturating_add(cache_creation_tokens_for_usage(usage))
|
||||
}
|
||||
|
||||
fn claude_input_tokens_from_usage(usage: &CanonicalUsage) -> u64 {
|
||||
if usage.input_tokens_include_cache {
|
||||
usage
|
||||
.input_tokens
|
||||
.saturating_sub(cache_input_tokens_for_usage(usage))
|
||||
} else {
|
||||
usage.input_tokens
|
||||
}
|
||||
}
|
||||
|
||||
fn inclusive_input_tokens_from_usage(usage: &CanonicalUsage) -> u64 {
|
||||
if usage.input_tokens_include_cache {
|
||||
usage.input_tokens
|
||||
} else {
|
||||
usage
|
||||
.input_tokens
|
||||
.saturating_add(cache_input_tokens_for_usage(usage))
|
||||
}
|
||||
}
|
||||
|
||||
fn inclusive_total_tokens_from_usage(usage: &CanonicalUsage, input_tokens: u64) -> u64 {
|
||||
if usage.total_tokens > 0
|
||||
&& (usage.input_tokens_include_cache || cache_input_tokens_for_usage(usage) == 0)
|
||||
{
|
||||
usage.total_tokens
|
||||
} else {
|
||||
input_tokens.saturating_add(usage.output_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
openai_stream_payload_is_terminal_error, openai_stream_terminal_error_body,
|
||||
openai_stream_terminal_error_message,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn completed_openai_responses_payload_with_null_error_is_not_terminal_error() {
|
||||
let payload = json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"error": null,
|
||||
"incomplete_details": null,
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 2,
|
||||
"total_tokens": 3
|
||||
}
|
||||
},
|
||||
"error": null
|
||||
});
|
||||
|
||||
assert!(!openai_stream_payload_is_terminal_error(&payload));
|
||||
assert!(openai_stream_terminal_error_body(&payload).is_none());
|
||||
assert!(openai_stream_terminal_error_message(&payload).is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
pub mod common;
|
||||
pub mod format_matrix;
|
||||
|
||||
pub use common::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
pub use format_matrix::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalVideoCreateFamily {
|
||||
OpenAi,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalVideoCreateSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalVideoCreateFamily,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
||||
crate::formats::openai::video::spec::resolve_sync_spec(plan_kind)
|
||||
.or_else(|| crate::formats::gemini::video::spec::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_sync_spec, LocalVideoCreateFamily};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_and_gemini_video_create_specs() {
|
||||
let openai = resolve_sync_spec("openai_video_create_sync").expect("openai spec");
|
||||
assert_eq!(openai.api_format, "openai:video");
|
||||
assert_eq!(openai.family, LocalVideoCreateFamily::OpenAi);
|
||||
|
||||
let gemini = resolve_sync_spec("gemini_video_create_sync").expect("gemini spec");
|
||||
assert_eq!(gemini.api_format, "gemini:video");
|
||||
assert_eq!(gemini.family, LocalVideoCreateFamily::Gemini);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user