mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate
- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦 - 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块 - 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支 - 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合 - 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor - 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
14
crates/aether-ai-pipeline/Cargo.toml
Normal file
14
crates/aether-ai-pipeline/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "aether-ai-pipeline"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Shared AI pipeline contracts and planner logic for Aether"
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
http.workspace = true
|
||||
serde_json.workspace = true
|
||||
aether-provider-transport.workspace = true
|
||||
uuid.workspace = true
|
||||
1
crates/aether-ai-pipeline/src/adaptation/mod.rs
Normal file
1
crates/aether-ai-pipeline/src/adaptation/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod surfaces;
|
||||
188
crates/aether-ai-pipeline/src/adaptation/surfaces.rs
Normal file
188
crates/aether-ai-pipeline/src/adaptation/surfaces.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
|
||||
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
|
||||
pub const KIRO_ENVELOPE_NAME: &str = "kiro:generateAssistantResponse";
|
||||
pub const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||
pub const GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME: &str = "gemini_cli:v1internal";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdaptationSurface {
|
||||
AntigravityGeminiChat,
|
||||
AntigravityGeminiCli,
|
||||
GeminiCliV1Internal,
|
||||
KiroClaudeCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProviderAdaptationDescriptor {
|
||||
pub surface: ProviderAdaptationSurface,
|
||||
pub provider_type: Option<&'static str>,
|
||||
pub envelope_name: &'static str,
|
||||
pub anchor_api_format: &'static str,
|
||||
pub supports_request_bridge: bool,
|
||||
pub supports_sync_finalize_bridge: bool,
|
||||
pub supports_stream_bridge: bool,
|
||||
pub requires_eventstream_accept: bool,
|
||||
pub unwraps_response_envelope: bool,
|
||||
}
|
||||
|
||||
const PROVIDER_ADAPTATION_SURFACES: &[ProviderAdaptationDescriptor] = &[
|
||||
ProviderAdaptationDescriptor {
|
||||
surface: ProviderAdaptationSurface::AntigravityGeminiChat,
|
||||
provider_type: Some(ANTIGRAVITY_PROVIDER_TYPE),
|
||||
envelope_name: ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
anchor_api_format: "gemini:chat",
|
||||
supports_request_bridge: true,
|
||||
supports_sync_finalize_bridge: true,
|
||||
supports_stream_bridge: true,
|
||||
requires_eventstream_accept: false,
|
||||
unwraps_response_envelope: true,
|
||||
},
|
||||
ProviderAdaptationDescriptor {
|
||||
surface: ProviderAdaptationSurface::AntigravityGeminiCli,
|
||||
provider_type: Some(ANTIGRAVITY_PROVIDER_TYPE),
|
||||
envelope_name: ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
anchor_api_format: "gemini:cli",
|
||||
supports_request_bridge: true,
|
||||
supports_sync_finalize_bridge: true,
|
||||
supports_stream_bridge: true,
|
||||
requires_eventstream_accept: false,
|
||||
unwraps_response_envelope: true,
|
||||
},
|
||||
ProviderAdaptationDescriptor {
|
||||
surface: ProviderAdaptationSurface::GeminiCliV1Internal,
|
||||
provider_type: None,
|
||||
envelope_name: GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||
anchor_api_format: "gemini:cli",
|
||||
supports_request_bridge: false,
|
||||
supports_sync_finalize_bridge: true,
|
||||
supports_stream_bridge: true,
|
||||
requires_eventstream_accept: false,
|
||||
unwraps_response_envelope: true,
|
||||
},
|
||||
ProviderAdaptationDescriptor {
|
||||
surface: ProviderAdaptationSurface::KiroClaudeCli,
|
||||
provider_type: Some(KIRO_PROVIDER_TYPE),
|
||||
envelope_name: KIRO_ENVELOPE_NAME,
|
||||
anchor_api_format: "claude:cli",
|
||||
supports_request_bridge: true,
|
||||
supports_sync_finalize_bridge: true,
|
||||
supports_stream_bridge: true,
|
||||
requires_eventstream_accept: true,
|
||||
unwraps_response_envelope: false,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn provider_adaptation_descriptor_for_envelope(
|
||||
envelope_name: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<&'static ProviderAdaptationDescriptor> {
|
||||
let envelope_name = envelope_name.trim();
|
||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
||||
PROVIDER_ADAPTATION_SURFACES.iter().find(|descriptor| {
|
||||
descriptor.envelope_name.eq_ignore_ascii_case(envelope_name)
|
||||
&& descriptor
|
||||
.anchor_api_format
|
||||
.eq_ignore_ascii_case(provider_api_format.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provider_adaptation_descriptor_for_provider_type(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<&'static ProviderAdaptationDescriptor> {
|
||||
let provider_type = provider_type.trim();
|
||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
||||
PROVIDER_ADAPTATION_SURFACES.iter().find(|descriptor| {
|
||||
descriptor
|
||||
.provider_type
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(provider_type))
|
||||
&& descriptor
|
||||
.anchor_api_format
|
||||
.eq_ignore_ascii_case(provider_api_format.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provider_adaptation_anchor_api_format(
|
||||
envelope_name: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<&'static str> {
|
||||
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
|
||||
.map(|descriptor| descriptor.anchor_api_format)
|
||||
}
|
||||
|
||||
pub fn provider_adaptation_allows_sync_finalize_envelope(
|
||||
envelope_name: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
|
||||
.is_some_and(|descriptor| descriptor.supports_sync_finalize_bridge)
|
||||
}
|
||||
|
||||
pub fn provider_adaptation_requires_eventstream_accept(
|
||||
envelope_name: Option<&str>,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
envelope_name
|
||||
.and_then(|value| provider_adaptation_descriptor_for_envelope(value, provider_api_format))
|
||||
.is_some_and(|descriptor| descriptor.requires_eventstream_accept)
|
||||
}
|
||||
|
||||
pub fn provider_adaptation_should_unwrap_stream_envelope(
|
||||
envelope_name: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)
|
||||
.is_some_and(|descriptor| descriptor.unwraps_response_envelope)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn resolves_private_surface_anchor_contracts() {
|
||||
assert_eq!(
|
||||
provider_adaptation_anchor_api_format(
|
||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
"gemini:cli"
|
||||
),
|
||||
Some("gemini:cli")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_adaptation_anchor_api_format(
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||
"gemini:cli"
|
||||
),
|
||||
Some("gemini:cli")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_adaptation_anchor_api_format(KIRO_ENVELOPE_NAME, "claude:cli"),
|
||||
Some("claude:cli")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_private_surface_capabilities() {
|
||||
assert!(provider_adaptation_allows_sync_finalize_envelope(
|
||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
"gemini:chat"
|
||||
));
|
||||
assert!(provider_adaptation_should_unwrap_stream_envelope(
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||
"gemini:cli"
|
||||
));
|
||||
assert!(provider_adaptation_requires_eventstream_accept(
|
||||
Some(KIRO_ENVELOPE_NAME),
|
||||
"claude:cli"
|
||||
));
|
||||
assert!(!provider_adaptation_requires_eventstream_accept(
|
||||
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
|
||||
"gemini:cli"
|
||||
));
|
||||
}
|
||||
}
|
||||
4
crates/aether-ai-pipeline/src/contracts/actions.rs
Normal file
4
crates/aether-ai-pipeline/src/contracts/actions.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub const EXECUTION_RUNTIME_SYNC_ACTION: &str = "execution_runtime_sync";
|
||||
pub const EXECUTION_RUNTIME_SYNC_DECISION_ACTION: &str = "execution_runtime_sync_decision";
|
||||
pub const EXECUTION_RUNTIME_STREAM_ACTION: &str = "execution_runtime_stream";
|
||||
pub const EXECUTION_RUNTIME_STREAM_DECISION_ACTION: &str = "execution_runtime_stream_decision";
|
||||
38
crates/aether-ai-pipeline/src/contracts/mod.rs
Normal file
38
crates/aether-ai-pipeline/src/contracts/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod actions;
|
||||
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 plan_kinds::{
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_COMPACT_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_sync_finalize_report_kind,
|
||||
CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND,
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND,
|
||||
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND,
|
||||
GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND, GEMINI_CHAT_SYNC_ERROR_REPORT_KIND,
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND, GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND,
|
||||
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND,
|
||||
GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND, OPENAI_CLI_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_CLI_SYNC_ERROR_REPORT_KIND, OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND,
|
||||
OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND, OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND,
|
||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
|
||||
};
|
||||
26
crates/aether-ai-pipeline/src/contracts/plan_kinds.rs
Normal file
26
crates/aether-ai-pipeline/src/contracts/plan_kinds.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
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_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
|
||||
pub const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
|
||||
pub const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";
|
||||
pub const OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND: &str = "openai_video_delete_sync";
|
||||
pub const GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "gemini_video_create_sync";
|
||||
pub const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync";
|
||||
pub const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream";
|
||||
pub const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream";
|
||||
pub const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream";
|
||||
pub const OPENAI_CLI_STREAM_PLAN_KIND: &str = "openai_cli_stream";
|
||||
pub const OPENAI_COMPACT_STREAM_PLAN_KIND: &str = "openai_compact_stream";
|
||||
pub const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream";
|
||||
pub const GEMINI_CLI_STREAM_PLAN_KIND: &str = "gemini_cli_stream";
|
||||
pub const OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND: &str = "openai_video_create_sync";
|
||||
pub const OPENAI_CHAT_SYNC_PLAN_KIND: &str = "openai_chat_sync";
|
||||
pub const OPENAI_CLI_SYNC_PLAN_KIND: &str = "openai_cli_sync";
|
||||
pub const OPENAI_COMPACT_SYNC_PLAN_KIND: &str = "openai_compact_sync";
|
||||
pub const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync";
|
||||
pub const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync";
|
||||
pub const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync";
|
||||
pub const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync";
|
||||
90
crates/aether-ai-pipeline/src/contracts/report_kinds.rs
Normal file
90
crates/aether-ai-pipeline/src/contracts/report_kinds.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use crate::contracts::{
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND,
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
pub const OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_chat_sync_finalize";
|
||||
pub const CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "claude_chat_sync_finalize";
|
||||
pub const GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_chat_sync_finalize";
|
||||
pub const OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "openai_cli_sync_finalize";
|
||||
pub const OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sync_finalize";
|
||||
pub const 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";
|
||||
|
||||
pub const OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_chat_sync_success";
|
||||
pub const CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "claude_chat_sync_success";
|
||||
pub const GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_chat_sync_success";
|
||||
pub const OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "openai_cli_sync_success";
|
||||
pub const CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "claude_cli_sync_success";
|
||||
pub const GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_cli_sync_success";
|
||||
|
||||
pub const OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "openai_chat_stream_success";
|
||||
pub const CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "claude_chat_stream_success";
|
||||
pub const GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_chat_stream_success";
|
||||
pub const OPENAI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "openai_cli_stream_success";
|
||||
pub const CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "claude_cli_stream_success";
|
||||
pub const GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_cli_stream_success";
|
||||
|
||||
pub const OPENAI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "openai_chat_sync_error";
|
||||
pub const CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND: &str = "claude_chat_sync_error";
|
||||
pub const GEMINI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "gemini_chat_sync_error";
|
||||
pub const OPENAI_CLI_SYNC_ERROR_REPORT_KIND: &str = "openai_cli_sync_error";
|
||||
pub const OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND: &str = "openai_compact_sync_error";
|
||||
pub const CLAUDE_CLI_SYNC_ERROR_REPORT_KIND: &str = "claude_cli_sync_error";
|
||||
pub const GEMINI_CLI_SYNC_ERROR_REPORT_KIND: &str = "gemini_cli_sync_error";
|
||||
|
||||
pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static str> {
|
||||
match plan_kind {
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND => Some(OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND),
|
||||
OPENAI_CLI_SYNC_PLAN_KIND => Some(OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND),
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(OPENAI_COMPACT_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:chat"),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:chat"),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:cli"),
|
||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:compact"),
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some("claude:cli"),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("gemini:cli"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
match report_kind {
|
||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_ERROR_REPORT_KIND),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND),
|
||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CLI_SYNC_ERROR_REPORT_KIND),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
match report_kind {
|
||||
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND),
|
||||
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND | OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
|
||||
Some(OPENAI_CLI_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,
|
||||
}
|
||||
}
|
||||
168
crates/aether-ai-pipeline/src/conversion/error.rs
Normal file
168
crates/aether-ai-pipeline/src/conversion/error.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LocalCoreSyncErrorKind {
|
||||
InvalidRequest,
|
||||
Authentication,
|
||||
PermissionDenied,
|
||||
NotFound,
|
||||
RateLimit,
|
||||
ContextLengthExceeded,
|
||||
Overloaded,
|
||||
ServerError,
|
||||
}
|
||||
|
||||
pub fn is_core_error_finalize_kind(report_kind: &str) -> bool {
|
||||
core_error_default_client_api_format(report_kind).is_some()
|
||||
}
|
||||
|
||||
pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_error_default_client_api_format(report_kind)
|
||||
}
|
||||
|
||||
pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_error_background_report_kind(report_kind)
|
||||
}
|
||||
|
||||
pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static str> {
|
||||
crate::contracts::core_success_background_report_kind(report_kind)
|
||||
}
|
||||
|
||||
pub fn build_core_error_body_for_client_format(
|
||||
client_api_format: &str,
|
||||
message: &str,
|
||||
code: Option<&str>,
|
||||
kind: LocalCoreSyncErrorKind,
|
||||
) -> Option<Value> {
|
||||
let mut error_object = Map::new();
|
||||
error_object.insert("message".to_string(), Value::String(message.to_string()));
|
||||
|
||||
match client_api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" | "openai:cli" | "openai:compact" => {
|
||||
error_object.insert(
|
||||
"type".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_openai_type(kind).to_string()),
|
||||
);
|
||||
if let Some(code) = code.filter(|value| !value.is_empty()) {
|
||||
error_object.insert("code".to_string(), Value::String(code.to_string()));
|
||||
}
|
||||
Some(Value::Object(Map::from_iter([(
|
||||
"error".to_string(),
|
||||
Value::Object(error_object),
|
||||
)])))
|
||||
}
|
||||
"claude:chat" | "claude:cli" => {
|
||||
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:chat" | "gemini:cli" => Some(Value::Object(Map::from_iter([(
|
||||
"error".to_string(),
|
||||
Value::Object(Map::from_iter([
|
||||
(
|
||||
"code".to_string(),
|
||||
Value::from(map_local_sync_error_kind_to_gemini_code(kind)),
|
||||
),
|
||||
("message".to_string(), Value::String(message.to_string())),
|
||||
(
|
||||
"status".to_string(),
|
||||
Value::String(map_local_sync_error_kind_to_gemini_status(kind).to_string()),
|
||||
),
|
||||
])),
|
||||
)]))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_openai_type(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest => "invalid_request_error",
|
||||
LocalCoreSyncErrorKind::Authentication => "authentication_error",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
|
||||
LocalCoreSyncErrorKind::NotFound => "not_found_error",
|
||||
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
|
||||
LocalCoreSyncErrorKind::ContextLengthExceeded => "context_length_exceeded",
|
||||
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "server_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_claude_type(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
"invalid_request_error"
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => "authentication_error",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "permission_error",
|
||||
LocalCoreSyncErrorKind::NotFound => "not_found_error",
|
||||
LocalCoreSyncErrorKind::RateLimit => "rate_limit_error",
|
||||
LocalCoreSyncErrorKind::Overloaded | LocalCoreSyncErrorKind::ServerError => "api_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_gemini_code(kind: LocalCoreSyncErrorKind) -> u16 {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
400
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => 401,
|
||||
LocalCoreSyncErrorKind::PermissionDenied => 403,
|
||||
LocalCoreSyncErrorKind::NotFound => 404,
|
||||
LocalCoreSyncErrorKind::RateLimit => 429,
|
||||
LocalCoreSyncErrorKind::Overloaded => 503,
|
||||
LocalCoreSyncErrorKind::ServerError => 500,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_local_sync_error_kind_to_gemini_status(kind: LocalCoreSyncErrorKind) -> &'static str {
|
||||
match kind {
|
||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||
"INVALID_ARGUMENT"
|
||||
}
|
||||
LocalCoreSyncErrorKind::Authentication => "UNAUTHENTICATED",
|
||||
LocalCoreSyncErrorKind::PermissionDenied => "PERMISSION_DENIED",
|
||||
LocalCoreSyncErrorKind::NotFound => "NOT_FOUND",
|
||||
LocalCoreSyncErrorKind::RateLimit => "RESOURCE_EXHAUSTED",
|
||||
LocalCoreSyncErrorKind::Overloaded => "UNAVAILABLE",
|
||||
LocalCoreSyncErrorKind::ServerError => "INTERNAL",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_core_error_body_for_client_format, core_success_background_report_kind,
|
||||
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn builds_openai_core_error_body() {
|
||||
let body = build_core_error_body_for_client_format(
|
||||
"openai:chat",
|
||||
"bad request",
|
||||
Some("invalid_request"),
|
||||
LocalCoreSyncErrorKind::InvalidRequest,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["error"]["message"], "bad request");
|
||||
assert_eq!(body["error"]["type"], "invalid_request_error");
|
||||
assert_eq!(body["error"]["code"], "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_finalize_kind_and_success_mapping() {
|
||||
assert!(is_core_error_finalize_kind("openai_chat_sync_finalize"));
|
||||
assert_eq!(
|
||||
core_success_background_report_kind("openai_chat_sync_finalize"),
|
||||
Some("openai_chat_sync_success")
|
||||
);
|
||||
}
|
||||
}
|
||||
14
crates/aether-ai-pipeline/src/conversion/mod.rs
Normal file
14
crates/aether-ai-pipeline/src/conversion/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
mod error;
|
||||
mod registry;
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
|
||||
pub use error::{
|
||||
build_core_error_body_for_client_format, core_error_background_report_kind,
|
||||
core_error_default_client_api_format, core_success_background_report_kind,
|
||||
is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||
};
|
||||
pub use registry::{
|
||||
request_conversion_kind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
|
||||
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
|
||||
};
|
||||
160
crates/aether-ai-pipeline/src/conversion/registry.rs
Normal file
160
crates/aether-ai-pipeline/src/conversion/registry.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToOpenAIFamilyCli,
|
||||
ToOpenAICompact,
|
||||
ToClaudeStandard,
|
||||
ToGeminiStandard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncChatResponseConversionKind {
|
||||
ToOpenAIChat,
|
||||
ToClaudeChat,
|
||||
ToGeminiChat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncCliResponseConversionKind {
|
||||
ToOpenAIFamilyCli,
|
||||
ToClaudeCli,
|
||||
ToGeminiCli,
|
||||
}
|
||||
|
||||
pub fn request_conversion_kind(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Option<RequestConversionKind> {
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
||||
if client_api_format == 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;
|
||||
}
|
||||
|
||||
match provider_api_format.as_str() {
|
||||
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
|
||||
"openai:cli" => Some(RequestConversionKind::ToOpenAIFamilyCli),
|
||||
"openai:compact" => Some(RequestConversionKind::ToOpenAICompact),
|
||||
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
|
||||
"gemini:chat" | "gemini:cli" => 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 = provider_api_format.trim().to_ascii_lowercase();
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
match client_api_format.as_str() {
|
||||
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
|
||||
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
|
||||
"gemini:chat" => 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 = provider_api_format.trim().to_ascii_lowercase();
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
if provider_api_format == client_api_format {
|
||||
return None;
|
||||
}
|
||||
if !is_standard_api_format(provider_api_format.as_str()) {
|
||||
return None;
|
||||
}
|
||||
match client_api_format.as_str() {
|
||||
"openai:cli" | "openai:compact" => Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli),
|
||||
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
|
||||
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_standard_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:chat"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "claude:chat"
|
||||
| "claude:cli"
|
||||
| "gemini:chat"
|
||||
| "gemini:cli"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
request_conversion_kind, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
request_conversion_kind("claude:chat", "openai:chat"),
|
||||
Some(RequestConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:chat", "claude:chat"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:cli", "openai:compact"),
|
||||
Some(RequestConversionKind::ToOpenAICompact)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:compact", "gemini:cli"),
|
||||
Some(RequestConversionKind::ToGeminiStandard)
|
||||
);
|
||||
assert_eq!(request_conversion_kind("claude:chat", "claude:chat"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_response_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("openai:chat", "claude:chat"),
|
||||
Some(SyncChatResponseConversionKind::ToClaudeChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("claude:chat", "gemini:chat"),
|
||||
Some(SyncChatResponseConversionKind::ToGeminiChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_chat_response_conversion_kind("gemini:chat", "openai:chat"),
|
||||
Some(SyncChatResponseConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:cli", "gemini:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToGeminiCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToClaudeCli)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
|
||||
use super::shared::parse_openai_tool_arguments;
|
||||
use crate::planner::openai::{
|
||||
copy_request_number_field, extract_openai_reasoning_effort,
|
||||
map_openai_reasoning_effort_to_thinking_budget, parse_openai_stop_sequences,
|
||||
resolve_openai_chat_max_tokens,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_request_to_claude_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut system_segments = Vec::new();
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
|
||||
for message in message_values {
|
||||
let message_object = message.as_object()?;
|
||||
let role = message_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match role.as_str() {
|
||||
"system" | "developer" => {
|
||||
let text = extract_openai_text_content(message_object.get("content"))?;
|
||||
if !text.trim().is_empty() {
|
||||
system_segments.push(text);
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
let blocks = convert_openai_content_to_claude_blocks(
|
||||
message_object.get("content"),
|
||||
true,
|
||||
)?;
|
||||
if !blocks.is_empty() {
|
||||
messages.push(build_claude_message("user", blocks));
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
let mut blocks = convert_openai_content_to_claude_blocks(
|
||||
message_object.get("content"),
|
||||
false,
|
||||
)?;
|
||||
if let Some(tool_calls) =
|
||||
message_object.get("tool_calls").and_then(Value::as_array)
|
||||
{
|
||||
for tool_call in tool_calls {
|
||||
let tool_call_object = tool_call.as_object()?;
|
||||
let function = tool_call_object.get("function")?.as_object()?;
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let tool_call_id = tool_call_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
|
||||
let tool_input =
|
||||
parse_openai_tool_arguments(function.get("arguments"))?;
|
||||
blocks.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"input": tool_input,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if !blocks.is_empty() {
|
||||
messages.push(build_claude_message("assistant", blocks));
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
let tool_use_id = message_object
|
||||
.get("tool_call_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let tool_result =
|
||||
parse_openai_tool_result_content(message_object.get("content"));
|
||||
messages.push(json!({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": tool_result,
|
||||
"is_error": false,
|
||||
}],
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Map::new();
|
||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
output.insert(
|
||||
"messages".to_string(),
|
||||
Value::Array(compact_claude_messages(messages)),
|
||||
);
|
||||
output.insert(
|
||||
"max_tokens".to_string(),
|
||||
Value::from(resolve_openai_chat_max_tokens(request)),
|
||||
);
|
||||
|
||||
let system_text = system_segments
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !system_text.is_empty() {
|
||||
output.insert("system".to_string(), Value::String(system_text));
|
||||
}
|
||||
if upstream_is_stream {
|
||||
output.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
copy_request_number_field(request, &mut output, "temperature");
|
||||
copy_request_number_field(request, &mut output, "top_p");
|
||||
copy_request_number_field(request, &mut output, "top_k");
|
||||
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
|
||||
output.insert("stop_sequences".to_string(), Value::Array(stop_sequences));
|
||||
}
|
||||
if let Some(tools) = convert_openai_tools_to_claude(request.get("tools")) {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = convert_openai_tool_choice_to_claude(request.get("tool_choice")) {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
if let Some(metadata) = request.get("metadata").cloned() {
|
||||
output.insert("metadata".to_string(), metadata);
|
||||
}
|
||||
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
|
||||
if let Some(thinking_budget) =
|
||||
map_openai_reasoning_effort_to_thinking_budget(reasoning_effort.as_str())
|
||||
{
|
||||
output.insert(
|
||||
"thinking".to_string(),
|
||||
json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": thinking_budget,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn convert_openai_content_to_claude_blocks(
|
||||
content: Option<&Value>,
|
||||
allow_images: bool,
|
||||
) -> Option<Vec<Value>> {
|
||||
match content {
|
||||
None | Some(Value::Null) => Some(Vec::new()),
|
||||
Some(Value::String(text)) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
Some(Vec::new())
|
||||
} else {
|
||||
Some(vec![json!({ "type": "text", "text": text })])
|
||||
}
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut blocks = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
match part_type {
|
||||
"text" | "input_text" => {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
blocks.push(json!({ "type": "text", "text": text }));
|
||||
}
|
||||
}
|
||||
}
|
||||
"image_url" | "input_image" if allow_images => {
|
||||
let url = part_object
|
||||
.get("image_url")
|
||||
.and_then(|value| {
|
||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())?;
|
||||
if let Some((media_type, data)) = parse_data_url(url.as_str()) {
|
||||
blocks.push(json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": data,
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
blocks.push(json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": url,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
"file" | "input_file" => {
|
||||
let file_object = part_object
|
||||
.get("file")
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or(part_object);
|
||||
if let Some(file_data) =
|
||||
file_object.get("file_data").and_then(Value::as_str)
|
||||
{
|
||||
if let Some((media_type, data)) = parse_data_url(file_data) {
|
||||
blocks.push(json!({
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": data,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(blocks)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_openai_tools_to_claude(tools: Option<&Value>) -> Option<Vec<Value>> {
|
||||
let tool_values = tools?.as_array()?;
|
||||
let mut converted = Vec::new();
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
if tool_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value != "function")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let function = tool_object.get("function")?.as_object()?;
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut converted_tool = Map::new();
|
||||
converted_tool.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = function.get("description").cloned() {
|
||||
converted_tool.insert("description".to_string(), description);
|
||||
}
|
||||
converted_tool.insert(
|
||||
"input_schema".to_string(),
|
||||
function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
converted.push(Value::Object(converted_tool));
|
||||
}
|
||||
(!converted.is_empty()).then_some(converted)
|
||||
}
|
||||
|
||||
fn convert_openai_tool_choice_to_claude(tool_choice: Option<&Value>) -> Option<Value> {
|
||||
let tool_choice = tool_choice?;
|
||||
match tool_choice {
|
||||
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"none" => Some(json!({ "type": "none" })),
|
||||
"required" => Some(json!({ "type": "any" })),
|
||||
"auto" => Some(json!({ "type": "auto" })),
|
||||
_ => None,
|
||||
},
|
||||
Value::Object(object) => {
|
||||
let function_name = object
|
||||
.get("function")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|function| function.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
Some(json!({
|
||||
"type": "tool",
|
||||
"name": function_name,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_claude_messages(messages: Vec<Value>) -> Vec<Value> {
|
||||
let mut compact: Vec<Value> = Vec::new();
|
||||
for message in messages {
|
||||
let role = message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if let Some(last) = compact.last_mut() {
|
||||
let last_role = last
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if last_role == role {
|
||||
merge_claude_message_content(last, message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
compact.push(message);
|
||||
}
|
||||
if compact
|
||||
.first()
|
||||
.and_then(|value| value.get("role"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "assistant")
|
||||
{
|
||||
compact.insert(0, json!({ "role": "user", "content": "" }));
|
||||
}
|
||||
compact
|
||||
}
|
||||
|
||||
fn merge_claude_message_content(target: &mut Value, message: Value) {
|
||||
let Some(target_object) = target.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let incoming_content = message.get("content").cloned().unwrap_or(Value::Null);
|
||||
let merged_blocks = extract_claude_content_blocks(target_object.get("content"))
|
||||
.into_iter()
|
||||
.chain(extract_claude_content_blocks(Some(&incoming_content)))
|
||||
.collect::<Vec<_>>();
|
||||
target_object.insert(
|
||||
"content".to_string(),
|
||||
simplify_claude_content(merged_blocks),
|
||||
);
|
||||
}
|
||||
|
||||
fn build_claude_message(role: &str, blocks: Vec<Value>) -> Value {
|
||||
json!({
|
||||
"role": role,
|
||||
"content": simplify_claude_content(blocks),
|
||||
})
|
||||
}
|
||||
|
||||
fn simplify_claude_content(blocks: Vec<Value>) -> Value {
|
||||
if blocks.is_empty() {
|
||||
return Value::String(String::new());
|
||||
}
|
||||
let mut text_values = Vec::new();
|
||||
for block in &blocks {
|
||||
let Some(block_object) = block.as_object() else {
|
||||
return Value::Array(blocks);
|
||||
};
|
||||
if block_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "text")
|
||||
{
|
||||
if let Some(text) = block_object.get("text").and_then(Value::as_str) {
|
||||
text_values.push(text.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Value::Array(blocks);
|
||||
}
|
||||
Value::String(text_values.join("\n"))
|
||||
}
|
||||
|
||||
fn extract_claude_content_blocks(content: Option<&Value>) -> Vec<Value> {
|
||||
match content {
|
||||
Some(Value::String(text)) if !text.is_empty() => vec![json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
})],
|
||||
Some(Value::Array(blocks)) => blocks.clone(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
||||
let rest = value.strip_prefix("data:")?;
|
||||
let (meta, data) = rest.split_once(",")?;
|
||||
let media_type = meta.strip_suffix(";base64")?;
|
||||
if media_type.trim().is_empty() || data.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((media_type.to_string(), data.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::to_openai_chat::{extract_openai_text_content, parse_openai_tool_result_content};
|
||||
use super::shared::parse_openai_tool_arguments;
|
||||
use crate::planner::openai::{
|
||||
copy_request_number_field_as, extract_openai_reasoning_effort,
|
||||
map_openai_reasoning_effort_to_gemini_budget, parse_openai_stop_sequences, value_as_u64,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_request_to_gemini_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut system_segments = Vec::new();
|
||||
let mut tool_name_by_id = BTreeMap::new();
|
||||
let mut contents = Vec::new();
|
||||
|
||||
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
|
||||
for message in message_values {
|
||||
let message_object = message.as_object()?;
|
||||
let role = message_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match role.as_str() {
|
||||
"system" | "developer" => {
|
||||
let text = extract_openai_text_content(message_object.get("content"))?;
|
||||
if !text.trim().is_empty() {
|
||||
system_segments.push(text);
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
let parts = convert_openai_content_to_gemini_parts(
|
||||
message_object.get("content"),
|
||||
true,
|
||||
)?;
|
||||
if !parts.is_empty() {
|
||||
contents.push(json!({
|
||||
"role": "user",
|
||||
"parts": parts,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
let mut parts = convert_openai_content_to_gemini_parts(
|
||||
message_object.get("content"),
|
||||
false,
|
||||
)?;
|
||||
if let Some(tool_calls) =
|
||||
message_object.get("tool_calls").and_then(Value::as_array)
|
||||
{
|
||||
for tool_call in tool_calls {
|
||||
let tool_call_object = tool_call.as_object()?;
|
||||
let function = tool_call_object.get("function")?.as_object()?;
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let tool_call_id = tool_call_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("toolu_{}", Uuid::new_v4().simple()));
|
||||
let tool_input =
|
||||
parse_openai_tool_arguments(function.get("arguments"))?;
|
||||
tool_name_by_id.insert(tool_call_id.clone(), tool_name.clone());
|
||||
parts.push(json!({
|
||||
"functionCall": {
|
||||
"name": tool_name,
|
||||
"args": tool_input,
|
||||
"id": tool_call_id,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
contents.push(json!({
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
let tool_use_id = message_object
|
||||
.get("tool_call_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let tool_name = tool_name_by_id
|
||||
.get(&tool_use_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tool_use_id.clone());
|
||||
let tool_result =
|
||||
parse_openai_tool_result_content(message_object.get("content"));
|
||||
contents.push(json!({
|
||||
"role": "user",
|
||||
"parts": [{
|
||||
"functionResponse": {
|
||||
"name": tool_name,
|
||||
"id": tool_use_id,
|
||||
"response": {
|
||||
"result": tool_result,
|
||||
},
|
||||
}
|
||||
}],
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Map::new();
|
||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
output.insert(
|
||||
"contents".to_string(),
|
||||
Value::Array(compact_gemini_contents(contents)),
|
||||
);
|
||||
if upstream_is_stream {
|
||||
output.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
let system_text = system_segments
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !system_text.is_empty() {
|
||||
output.insert(
|
||||
"systemInstruction".to_string(),
|
||||
json!({ "parts": [{ "text": system_text }] }),
|
||||
);
|
||||
}
|
||||
|
||||
let mut generation_config = Map::new();
|
||||
if let Some(max_tokens) = request
|
||||
.get("max_completion_tokens")
|
||||
.and_then(value_as_u64)
|
||||
.or_else(|| request.get("max_tokens").and_then(value_as_u64))
|
||||
{
|
||||
generation_config.insert("maxOutputTokens".to_string(), Value::from(max_tokens));
|
||||
}
|
||||
copy_request_number_field_as(
|
||||
request,
|
||||
&mut generation_config,
|
||||
"temperature",
|
||||
"temperature",
|
||||
);
|
||||
copy_request_number_field_as(request, &mut generation_config, "top_p", "topP");
|
||||
copy_request_number_field_as(request, &mut generation_config, "top_k", "topK");
|
||||
if let Some(candidate_count) = request
|
||||
.get("n")
|
||||
.and_then(value_as_u64)
|
||||
.filter(|value| *value > 1)
|
||||
{
|
||||
generation_config.insert("candidateCount".to_string(), Value::from(candidate_count));
|
||||
}
|
||||
if let Some(stop_sequences) = parse_openai_stop_sequences(request.get("stop")) {
|
||||
generation_config.insert("stopSequences".to_string(), Value::Array(stop_sequences));
|
||||
}
|
||||
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
|
||||
if let Some(thinking_budget) =
|
||||
map_openai_reasoning_effort_to_gemini_budget(reasoning_effort.as_str())
|
||||
{
|
||||
generation_config.insert(
|
||||
"thinkingConfig".to_string(),
|
||||
json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": thinking_budget,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(response_format) = request.get("response_format").and_then(Value::as_object) {
|
||||
if let Some(format_type) = response_format.get("type").and_then(Value::as_str) {
|
||||
match format_type {
|
||||
"json_schema" => {
|
||||
generation_config.insert(
|
||||
"responseMimeType".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
);
|
||||
if let Some(schema) = response_format
|
||||
.get("json_schema")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|json_schema| json_schema.get("schema"))
|
||||
.cloned()
|
||||
{
|
||||
generation_config.insert("responseSchema".to_string(), schema);
|
||||
}
|
||||
}
|
||||
"json_object" => {
|
||||
generation_config.insert(
|
||||
"responseMimeType".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !generation_config.is_empty() {
|
||||
output.insert(
|
||||
"generationConfig".to_string(),
|
||||
Value::Object(generation_config),
|
||||
);
|
||||
}
|
||||
if let Some(tools) =
|
||||
convert_openai_tools_to_gemini(request.get("tools"), request.get("web_search_options"))
|
||||
{
|
||||
output.insert("tools".to_string(), tools);
|
||||
}
|
||||
if let Some(tool_config) = convert_openai_tool_choice_to_gemini(request.get("tool_choice")) {
|
||||
output.insert("toolConfig".to_string(), tool_config);
|
||||
}
|
||||
if let Some(extra_body) = request.get("extra_body").and_then(Value::as_object) {
|
||||
if let Some(google) = extra_body.get("google").and_then(Value::as_object) {
|
||||
if let Some(existing) = output
|
||||
.get_mut("generationConfig")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
if let Some(response_modalities) = google.get("response_modalities").cloned() {
|
||||
existing.insert("responseModalities".to_string(), response_modalities);
|
||||
}
|
||||
if let Some(thinking_config) = google.get("thinking_config").cloned() {
|
||||
existing
|
||||
.entry("thinkingConfig".to_string())
|
||||
.or_insert(thinking_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn convert_openai_content_to_gemini_parts(
|
||||
content: Option<&Value>,
|
||||
allow_images: bool,
|
||||
) -> Option<Vec<Value>> {
|
||||
match content {
|
||||
None | Some(Value::Null) => Some(Vec::new()),
|
||||
Some(Value::String(text)) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
Some(Vec::new())
|
||||
} else {
|
||||
Some(vec![json!({ "text": text })])
|
||||
}
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut converted = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
match part_type {
|
||||
"text" | "input_text" => {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
converted.push(json!({ "text": text }));
|
||||
}
|
||||
}
|
||||
}
|
||||
"image_url" | "input_image" if allow_images => {
|
||||
let image = part_object
|
||||
.get("image_url")
|
||||
.and_then(|value| {
|
||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
part_object
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})?;
|
||||
if let Some((mime_type, data)) = parse_data_url(image.as_str()) {
|
||||
converted.push(json!({
|
||||
"inlineData": {
|
||||
"mimeType": mime_type,
|
||||
"data": data,
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
converted.push(json!({
|
||||
"fileData": {
|
||||
"fileUri": image,
|
||||
"mimeType": guess_media_type_from_reference(image.as_str(), "image/jpeg"),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
"file" | "input_file" => {
|
||||
let file_object = part_object
|
||||
.get("file")
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or(part_object);
|
||||
if let Some(file_data) =
|
||||
file_object.get("file_data").and_then(Value::as_str)
|
||||
{
|
||||
if let Some((mime_type, data)) = parse_data_url(file_data) {
|
||||
converted.push(json!({
|
||||
"inlineData": {
|
||||
"mimeType": mime_type,
|
||||
"data": data,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(converted)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_openai_tools_to_gemini(
|
||||
tools: Option<&Value>,
|
||||
web_search_options: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let mut result_tools = Vec::new();
|
||||
let tool_values = tools.and_then(Value::as_array);
|
||||
let mut declarations = Vec::new();
|
||||
if let Some(tool_values) = tool_values {
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
if tool_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value != "function")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let function = tool_object.get("function")?.as_object()?;
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut declaration = Map::new();
|
||||
declaration.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = function.get("description").cloned() {
|
||||
declaration.insert("description".to_string(), description);
|
||||
}
|
||||
declaration.insert(
|
||||
"parameters".to_string(),
|
||||
function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({})),
|
||||
);
|
||||
declarations.push(Value::Object(declaration));
|
||||
}
|
||||
}
|
||||
if !declarations.is_empty() {
|
||||
result_tools.push(json!({ "functionDeclarations": declarations }));
|
||||
}
|
||||
if web_search_options.is_some() {
|
||||
result_tools.push(json!({ "googleSearch": {} }));
|
||||
}
|
||||
(!result_tools.is_empty()).then_some(Value::Array(result_tools))
|
||||
}
|
||||
|
||||
fn convert_openai_tool_choice_to_gemini(tool_choice: Option<&Value>) -> Option<Value> {
|
||||
let tool_choice = tool_choice?;
|
||||
match tool_choice {
|
||||
Value::String(value) => {
|
||||
let mode = match value.trim().to_ascii_lowercase().as_str() {
|
||||
"none" => "NONE",
|
||||
"required" => "ANY",
|
||||
"auto" => "AUTO",
|
||||
_ => return None,
|
||||
};
|
||||
Some(json!({
|
||||
"functionCallingConfig": {
|
||||
"mode": mode,
|
||||
}
|
||||
}))
|
||||
}
|
||||
Value::Object(object) => {
|
||||
let function_name = object
|
||||
.get("function")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|function| function.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
Some(json!({
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": [function_name],
|
||||
}
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.to_string();
|
||||
if last_role == role {
|
||||
if let Some(last_parts) = last.get_mut("parts").and_then(Value::as_array_mut) {
|
||||
last_parts.extend(parts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
compact.push(content);
|
||||
}
|
||||
compact
|
||||
}
|
||||
|
||||
fn parse_data_url(value: &str) -> Option<(String, String)> {
|
||||
let rest = value.strip_prefix("data:")?;
|
||||
let (meta, data) = rest.split_once(",")?;
|
||||
let mime_type = meta.strip_suffix(";base64")?;
|
||||
if mime_type.trim().is_empty() || data.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((mime_type.to_string(), data.to_string()))
|
||||
}
|
||||
|
||||
fn guess_media_type_from_reference(reference: &str, default_mime: &str) -> String {
|
||||
let normalized = reference
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(reference)
|
||||
.to_ascii_lowercase();
|
||||
if normalized.ends_with(".png") {
|
||||
"image/png".to_string()
|
||||
} else if normalized.ends_with(".gif") {
|
||||
"image/gif".to_string()
|
||||
} else if normalized.ends_with(".webp") {
|
||||
"image/webp".to_string()
|
||||
} else if normalized.ends_with(".jpg") || normalized.ends_with(".jpeg") {
|
||||
"image/jpeg".to_string()
|
||||
} else if normalized.ends_with(".pdf") {
|
||||
"application/pdf".to_string()
|
||||
} else {
|
||||
default_mime.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod claude;
|
||||
mod gemini;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude::convert_openai_chat_request_to_claude_request;
|
||||
pub use gemini::convert_openai_chat_request_to_gemini_request;
|
||||
pub use openai_cli::convert_openai_chat_request_to_openai_cli_request;
|
||||
@@ -0,0 +1,483 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::super::to_openai_chat::extract_openai_text_content;
|
||||
use crate::planner::openai::{copy_request_number_field, extract_openai_reasoning_effort};
|
||||
|
||||
pub fn convert_openai_chat_request_to_openai_cli_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut instructions = Vec::new();
|
||||
let mut input_items = Vec::new();
|
||||
let mut next_generated_tool_call_index = 0usize;
|
||||
let mut tool_call_id_aliases = BTreeMap::new();
|
||||
|
||||
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
|
||||
for message in message_values {
|
||||
let message_object = message.as_object()?;
|
||||
let role = message_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match role.as_str() {
|
||||
"system" | "developer" => {
|
||||
let text = extract_openai_text_content(message_object.get("content"))?;
|
||||
if !text.trim().is_empty() {
|
||||
instructions.push(text);
|
||||
}
|
||||
}
|
||||
"user" | "assistant" => {
|
||||
let content_items = convert_openai_content_to_openai_cli_items(
|
||||
message_object.get("content"),
|
||||
role.as_str(),
|
||||
)?;
|
||||
if !content_items.is_empty() {
|
||||
input_items.push(json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": content_items,
|
||||
}));
|
||||
}
|
||||
|
||||
if role == "assistant" {
|
||||
if let Some(tool_calls) =
|
||||
message_object.get("tool_calls").and_then(Value::as_array)
|
||||
{
|
||||
for tool_call in tool_calls {
|
||||
let tool_call_object = tool_call.as_object()?;
|
||||
let function = tool_call_object.get("function")?.as_object()?;
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let raw_call_id = tool_call_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let call_id = if raw_call_id.is_empty() {
|
||||
let generated =
|
||||
format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
} else {
|
||||
raw_call_id.to_string()
|
||||
};
|
||||
if !raw_call_id.is_empty() && raw_call_id != call_id {
|
||||
tool_call_id_aliases
|
||||
.insert(raw_call_id.to_string(), call_id.clone());
|
||||
}
|
||||
let arguments = function
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
input_items.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
let raw_tool_call_id = message_object
|
||||
.get("tool_call_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let tool_call_id = if raw_tool_call_id.is_empty() {
|
||||
let generated = format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
} else {
|
||||
tool_call_id_aliases
|
||||
.get(raw_tool_call_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| raw_tool_call_id.to_string())
|
||||
};
|
||||
let output = match message_object.get("content") {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(other) => serde_json::to_string(other).ok()?,
|
||||
None => String::new(),
|
||||
};
|
||||
input_items.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_call_id,
|
||||
"output": output,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Map::new();
|
||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if !instructions.is_empty() {
|
||||
output.insert(
|
||||
"instructions".to_string(),
|
||||
Value::String(
|
||||
instructions
|
||||
.into_iter()
|
||||
.filter(|value: &String| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
),
|
||||
);
|
||||
}
|
||||
output.insert("input".to_string(), Value::Array(input_items));
|
||||
|
||||
if upstream_is_stream && !compact {
|
||||
output.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
if let Some(max_tokens) = request.get("max_tokens").and_then(Value::as_u64) {
|
||||
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
|
||||
}
|
||||
copy_request_number_field(request, &mut output, "temperature");
|
||||
copy_request_number_field(request, &mut output, "top_p");
|
||||
copy_request_integer_field(request, &mut output, "top_logprobs");
|
||||
copy_request_bool_field(request, &mut output, "parallel_tool_calls");
|
||||
|
||||
for passthrough_key in [
|
||||
"prompt_cache_key",
|
||||
"service_tier",
|
||||
"metadata",
|
||||
"store",
|
||||
"previous_response_id",
|
||||
"truncation",
|
||||
"stop",
|
||||
] {
|
||||
if let Some(value) = request.get(passthrough_key) {
|
||||
output.insert(passthrough_key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if !output.contains_key("reasoning") {
|
||||
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
|
||||
output.insert(
|
||||
"reasoning".to_string(),
|
||||
json!({
|
||||
"effort": if reasoning_effort == "xhigh" {
|
||||
"high"
|
||||
} else {
|
||||
reasoning_effort.as_str()
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text) = build_openai_cli_text_config_from_openai_chat_request(request) {
|
||||
output.insert("text".to_string(), Value::Object(text));
|
||||
}
|
||||
if let Some(tools) = build_openai_cli_tools_from_openai_chat_request(request) {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = build_openai_cli_tool_choice_from_openai_chat_request(request) {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn convert_openai_content_to_openai_cli_items(
|
||||
content: Option<&Value>,
|
||||
role: &str,
|
||||
) -> Option<Vec<Value>> {
|
||||
let Some(content) = content else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
match content {
|
||||
Value::String(text) => {
|
||||
if text.is_empty() {
|
||||
Some(Vec::new())
|
||||
} else {
|
||||
Some(vec![json!({
|
||||
"type": if role == "assistant" { "output_text" } else { "input_text" },
|
||||
"text": text,
|
||||
})])
|
||||
}
|
||||
}
|
||||
Value::Array(parts) => {
|
||||
let mut items = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("text")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match part_type.as_str() {
|
||||
"text" | "input_text" | "output_text" => {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
if !text.is_empty() {
|
||||
items.push(json!({
|
||||
"type": if role == "assistant" { "output_text" } else { "input_text" },
|
||||
"text": text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"image_url" => {
|
||||
let image_url = part_object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| part_object.get("image_url").and_then(Value::as_str))?;
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String(
|
||||
if role == "assistant" {
|
||||
"output_image"
|
||||
} else {
|
||||
"input_image"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
item.insert(
|
||||
"image_url".to_string(),
|
||||
Value::String(image_url.to_string()),
|
||||
);
|
||||
if let Some(detail) = part_object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("detail"))
|
||||
.cloned()
|
||||
{
|
||||
item.insert("detail".to_string(), detail);
|
||||
}
|
||||
items.push(Value::Object(item));
|
||||
}
|
||||
"input_image" | "output_image" => {
|
||||
let image_url = part_object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| part_object.get("url").and_then(Value::as_str))?;
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String(
|
||||
if role == "assistant" {
|
||||
"output_image"
|
||||
} else {
|
||||
"input_image"
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
item.insert(
|
||||
"image_url".to_string(),
|
||||
Value::String(image_url.to_string()),
|
||||
);
|
||||
if let Some(detail) = part_object.get("detail").cloned() {
|
||||
item.insert("detail".to_string(), detail);
|
||||
}
|
||||
items.push(Value::Object(item));
|
||||
}
|
||||
"file" | "input_file" => {
|
||||
let file_object = part_object
|
||||
.get("file")
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or(part_object);
|
||||
let mut item = Map::new();
|
||||
item.insert("type".to_string(), Value::String("input_file".to_string()));
|
||||
if let Some(file_data) = file_object.get("file_data").cloned() {
|
||||
item.insert("file_data".to_string(), file_data);
|
||||
}
|
||||
if let Some(file_id) = file_object.get("file_id").cloned() {
|
||||
item.insert("file_id".to_string(), file_id);
|
||||
}
|
||||
if let Some(filename) = file_object.get("filename").cloned() {
|
||||
item.insert("filename".to_string(), filename);
|
||||
}
|
||||
if item.len() > 1 {
|
||||
items.push(Value::Object(item));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(items)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_openai_cli_text_config_from_openai_chat_request(
|
||||
request: &Map<String, Value>,
|
||||
) -> Option<Map<String, Value>> {
|
||||
let mut text = Map::new();
|
||||
if let Some(response_format) = request.get("response_format") {
|
||||
text.insert("format".to_string(), response_format.clone());
|
||||
}
|
||||
if let Some(verbosity) = request.get("verbosity") {
|
||||
text.insert("verbosity".to_string(), verbosity.clone());
|
||||
}
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn build_openai_cli_tools_from_openai_chat_request(
|
||||
request: &Map<String, Value>,
|
||||
) -> Option<Vec<Value>> {
|
||||
let mut tools = Vec::new();
|
||||
if let Some(tool_values) = request.get("tools").and_then(Value::as_array) {
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
let tool_type = tool_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("function")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match tool_type.as_str() {
|
||||
"function" => {
|
||||
let function = tool_object.get("function")?.as_object()?;
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut rebuilt = Map::new();
|
||||
rebuilt.insert("type".to_string(), Value::String("function".to_string()));
|
||||
rebuilt.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = function.get("description") {
|
||||
rebuilt.insert("description".to_string(), description.clone());
|
||||
}
|
||||
if let Some(parameters) = function.get("parameters") {
|
||||
rebuilt.insert("parameters".to_string(), parameters.clone());
|
||||
}
|
||||
tools.push(Value::Object(rebuilt));
|
||||
}
|
||||
"custom" => {
|
||||
let custom = tool_object.get("custom").and_then(Value::as_object)?;
|
||||
let mut rebuilt = Map::new();
|
||||
rebuilt.insert("type".to_string(), Value::String("custom".to_string()));
|
||||
if let Some(name) = custom.get("name") {
|
||||
rebuilt.insert("name".to_string(), name.clone());
|
||||
}
|
||||
if let Some(description) = custom.get("description") {
|
||||
rebuilt.insert("description".to_string(), description.clone());
|
||||
}
|
||||
if let Some(format) = custom.get("format") {
|
||||
rebuilt.insert("format".to_string(), format.clone());
|
||||
}
|
||||
tools.push(Value::Object(rebuilt));
|
||||
}
|
||||
_ => tools.push(tool.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(web_search_options) = request.get("web_search_options").and_then(Value::as_object) {
|
||||
let mut tool = Map::new();
|
||||
tool.insert("type".to_string(), Value::String("web_search".to_string()));
|
||||
if let Some(user_location) = web_search_options
|
||||
.get("user_location")
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
if user_location.get("type").and_then(Value::as_str) == Some("approximate") {
|
||||
if let Some(approximate) =
|
||||
user_location.get("approximate").and_then(Value::as_object)
|
||||
{
|
||||
let mut flattened = Map::new();
|
||||
flattened.insert("type".to_string(), Value::String("approximate".to_string()));
|
||||
if let Some(country) = approximate.get("country") {
|
||||
flattened.insert("country".to_string(), country.clone());
|
||||
}
|
||||
if let Some(city) = approximate.get("city") {
|
||||
flattened.insert("city".to_string(), city.clone());
|
||||
}
|
||||
tool.insert("user_location".to_string(), Value::Object(flattened));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(search_context_size) = web_search_options.get("search_context_size") {
|
||||
tool.insert(
|
||||
"search_context_size".to_string(),
|
||||
search_context_size.clone(),
|
||||
);
|
||||
}
|
||||
tools.push(Value::Object(tool));
|
||||
}
|
||||
|
||||
(!tools.is_empty()).then_some(tools)
|
||||
}
|
||||
|
||||
fn build_openai_cli_tool_choice_from_openai_chat_request(
|
||||
request: &Map<String, Value>,
|
||||
) -> Option<Value> {
|
||||
let tool_choice = request.get("tool_choice")?;
|
||||
match tool_choice {
|
||||
Value::String(value) => Some(Value::String(value.clone())),
|
||||
Value::Object(object) => {
|
||||
let choice_type = object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match choice_type.as_str() {
|
||||
"function" => {
|
||||
let function = object.get("function").and_then(Value::as_object)?;
|
||||
let name = function.get("name")?.as_str()?;
|
||||
Some(json!({
|
||||
"type": "function",
|
||||
"name": name,
|
||||
}))
|
||||
}
|
||||
"custom" => {
|
||||
let custom = object.get("custom").and_then(Value::as_object)?;
|
||||
let name = custom.get("name")?.as_str()?;
|
||||
Some(json!({
|
||||
"type": "custom",
|
||||
"name": name,
|
||||
}))
|
||||
}
|
||||
"allowed_tools" => {
|
||||
let allowed_tools = object.get("allowed_tools").and_then(Value::as_object)?;
|
||||
Some(json!({
|
||||
"type": "allowed_tools",
|
||||
"mode": allowed_tools.get("mode").cloned().unwrap_or_else(|| Value::String("auto".to_string())),
|
||||
"tools": allowed_tools.get("tools").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
|
||||
}))
|
||||
}
|
||||
_ => Some(tool_choice.clone()),
|
||||
}
|
||||
}
|
||||
_ => Some(tool_choice.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_request_integer_field(
|
||||
request: &Map<String, Value>,
|
||||
output: &mut Map<String, Value>,
|
||||
field: &str,
|
||||
) {
|
||||
if let Some(value) = request.get(field).and_then(Value::as_i64) {
|
||||
output.insert(field.to_string(), Value::from(value));
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_request_bool_field(
|
||||
request: &Map<String, Value>,
|
||||
output: &mut Map<String, Value>,
|
||||
field: &str,
|
||||
) {
|
||||
if let Some(value) = request.get(field).and_then(Value::as_bool) {
|
||||
output.insert(field.to_string(), Value::Bool(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(super) fn parse_openai_tool_arguments(arguments: Option<&Value>) -> Option<Value> {
|
||||
match arguments {
|
||||
Some(Value::Object(object)) => Some(Value::Object(object.clone())),
|
||||
Some(Value::String(raw)) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
Some(json!({}))
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(Value::Object(object)) => Some(Value::Object(object)),
|
||||
Ok(other) => Some(json!({ "input": other })),
|
||||
Err(_) => Some(json!({ "input": trimmed })),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(other) => Some(json!({ "input": other })),
|
||||
None => Some(json!({})),
|
||||
}
|
||||
}
|
||||
12
crates/aether-ai-pipeline/src/conversion/request/mod.rs
Normal file
12
crates/aether-ai-pipeline/src/conversion/request/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod from_openai_chat;
|
||||
pub mod to_openai_chat;
|
||||
|
||||
pub use from_openai_chat::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_cli_request,
|
||||
};
|
||||
pub use to_openai_chat::{
|
||||
extract_openai_text_content, normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_cli_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||
};
|
||||
@@ -0,0 +1,402 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::canonical_json_string;
|
||||
use crate::planner::openai::map_thinking_budget_to_openai_reasoning_effort;
|
||||
|
||||
pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut output = Map::new();
|
||||
let mut next_generated_tool_use_index = 0usize;
|
||||
if let Some(model) = request.get("model") {
|
||||
output.insert("model".to_string(), model.clone());
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(system_text) = extract_claude_system_text(request.get("system")) {
|
||||
if !system_text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "system",
|
||||
"content": system_text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(message_values) = request.get("messages").and_then(Value::as_array) {
|
||||
for message in message_values {
|
||||
let message_object = message.as_object()?;
|
||||
let role = message_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match role.as_str() {
|
||||
"user" => {
|
||||
let mut text_segments = Vec::new();
|
||||
if let Some(content) = message_object.get("content") {
|
||||
for block in normalize_claude_content_blocks(content)? {
|
||||
match block {
|
||||
ClaudeNormalizedBlock::Text(text) => {
|
||||
if !text.trim().is_empty() {
|
||||
text_segments.push(text);
|
||||
}
|
||||
}
|
||||
ClaudeNormalizedBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
} => {
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_use_id,
|
||||
"content": content,
|
||||
}));
|
||||
}
|
||||
ClaudeNormalizedBlock::ToolUse { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = text_segments.join("\n\n");
|
||||
if !text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "user",
|
||||
"content": text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
let mut text_segments = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
if let Some(content) = message_object.get("content") {
|
||||
for block in normalize_claude_content_blocks(content)? {
|
||||
match block {
|
||||
ClaudeNormalizedBlock::Text(text) => {
|
||||
if !text.trim().is_empty() {
|
||||
text_segments.push(text);
|
||||
}
|
||||
}
|
||||
ClaudeNormalizedBlock::ToolUse { id, name, input } => {
|
||||
let tool_use_id = id.unwrap_or_else(|| {
|
||||
let generated =
|
||||
format!("toolu_auto_{next_generated_tool_use_index}");
|
||||
next_generated_tool_use_index += 1;
|
||||
generated
|
||||
});
|
||||
tool_calls.push(json!({
|
||||
"id": tool_use_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": canonical_json_string(input.unwrap_or(Value::Object(Map::new()))),
|
||||
}
|
||||
}));
|
||||
}
|
||||
ClaudeNormalizedBlock::ToolResult { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut assistant = Map::new();
|
||||
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
assistant.insert(
|
||||
"content".to_string(),
|
||||
if text_segments.is_empty() && !tool_calls.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::String(text_segments.join("\n\n"))
|
||||
},
|
||||
);
|
||||
if !tool_calls.is_empty() {
|
||||
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
messages.push(Value::Object(assistant));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
output.insert("messages".to_string(), Value::Array(messages));
|
||||
|
||||
if let Some(max_tokens) = request.get("max_tokens").cloned() {
|
||||
output.insert("max_completion_tokens".to_string(), max_tokens);
|
||||
}
|
||||
for passthrough_key in ["temperature", "top_p", "metadata", "stop", "stream"] {
|
||||
if let Some(value) = request.get(passthrough_key) {
|
||||
output.insert(passthrough_key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if output.get("reasoning_effort").is_none() {
|
||||
if let Some(thinking_budget) = request
|
||||
.get("thinking")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|thinking| thinking.get("budget_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
output.insert(
|
||||
"reasoning_effort".to_string(),
|
||||
Value::String(
|
||||
map_thinking_budget_to_openai_reasoning_effort(thinking_budget).to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(tools) = normalize_claude_tools_to_openai(request.get("tools"))? {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = normalize_claude_tool_choice_to_openai(request.get("tool_choice"))? {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ClaudeNormalizedBlock {
|
||||
Text(String),
|
||||
ToolUse {
|
||||
id: Option<String>,
|
||||
name: String,
|
||||
input: Option<Value>,
|
||||
},
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: Value,
|
||||
},
|
||||
}
|
||||
|
||||
fn normalize_claude_content_blocks(content: &Value) -> Option<Vec<ClaudeNormalizedBlock>> {
|
||||
match content {
|
||||
Value::String(text) => Some(vec![ClaudeNormalizedBlock::Text(text.clone())]),
|
||||
Value::Array(blocks) => {
|
||||
let mut normalized = Vec::new();
|
||||
for block in blocks {
|
||||
let block = block.as_object()?;
|
||||
match block.get("type")?.as_str()? {
|
||||
"text" | "thinking" => {
|
||||
let text = block
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
normalized.push(ClaudeNormalizedBlock::Text(text.to_string()));
|
||||
}
|
||||
"tool_use" => {
|
||||
let name = block
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
normalized.push(ClaudeNormalizedBlock::ToolUse {
|
||||
id: block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
name,
|
||||
input: block.get("input").cloned(),
|
||||
});
|
||||
}
|
||||
"tool_result" => {
|
||||
let tool_use_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let content = block.get("content").cloned().unwrap_or(Value::Null);
|
||||
normalized.push(ClaudeNormalizedBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(normalized)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_claude_system_text(system: Option<&Value>) -> Option<String> {
|
||||
let system = system?;
|
||||
let text = match system {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Array(blocks) => {
|
||||
let mut segments = Vec::new();
|
||||
for block in blocks {
|
||||
let block = block.as_object()?;
|
||||
if block.get("type").and_then(Value::as_str).unwrap_or("text") == "text" {
|
||||
let text = block
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
segments.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
segments.join("\n\n")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(strip_claude_billing_header(&text))
|
||||
}
|
||||
|
||||
fn strip_claude_billing_header(text: &str) -> String {
|
||||
let trimmed = text.trim();
|
||||
let prefix = "x-anthropic-billing-header:";
|
||||
if !trimmed.to_ascii_lowercase().starts_with(prefix) {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let remainder = trimmed
|
||||
.split_once('\n')
|
||||
.map(|(_, rest)| rest.trim_start())
|
||||
.unwrap_or_default();
|
||||
remainder.trim_start_matches('\n').trim().to_string()
|
||||
}
|
||||
|
||||
fn normalize_claude_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
|
||||
let Some(tools) = tools else {
|
||||
return Some(None);
|
||||
};
|
||||
let tools = tools.as_array()?;
|
||||
let mut normalized = Vec::new();
|
||||
for tool in tools {
|
||||
let tool = tool.as_object()?;
|
||||
let name = tool
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut function = Map::new();
|
||||
function.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = tool.get("description").and_then(Value::as_str) {
|
||||
if !description.trim().is_empty() {
|
||||
function.insert(
|
||||
"description".to_string(),
|
||||
Value::String(description.trim().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
function.insert(
|
||||
"parameters".to_string(),
|
||||
tool.get("input_schema")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({"type": "object"})),
|
||||
);
|
||||
normalized.push(json!({
|
||||
"type": "function",
|
||||
"function": Value::Object(function),
|
||||
}));
|
||||
}
|
||||
Some(Some(normalized))
|
||||
}
|
||||
|
||||
fn normalize_claude_tool_choice_to_openai(tool_choice: Option<&Value>) -> Option<Option<Value>> {
|
||||
let Some(tool_choice) = tool_choice else {
|
||||
return Some(None);
|
||||
};
|
||||
match tool_choice {
|
||||
Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Some(Some(Value::String("auto".to_string()))),
|
||||
"any" => Some(Some(Value::String("required".to_string()))),
|
||||
"none" => Some(Some(Value::String("none".to_string()))),
|
||||
_ => Some(None),
|
||||
},
|
||||
Value::Object(value) => {
|
||||
if let Some(name) = value.get("name").and_then(Value::as_str) {
|
||||
return Some(Some(json!({
|
||||
"type": "function",
|
||||
"function": { "name": name }
|
||||
})));
|
||||
}
|
||||
let kind = value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
match kind.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Some(Some(Value::String("auto".to_string()))),
|
||||
"any" => Some(Some(Value::String("required".to_string()))),
|
||||
"none" => Some(Some(Value::String("none".to_string()))),
|
||||
"tool" => value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|name| {
|
||||
Some(json!({
|
||||
"type": "function",
|
||||
"function": { "name": name }
|
||||
}))
|
||||
})
|
||||
.or(Some(None)),
|
||||
_ => Some(None),
|
||||
}
|
||||
}
|
||||
_ => Some(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_claude_request_to_openai_chat_request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn assigns_deterministic_tool_use_ids_when_claude_blocks_omit_ids() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "search",
|
||||
"input": {"query": "alpha"}
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "search",
|
||||
"input": {"query": "beta"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let first = normalize_claude_request_to_openai_chat_request(&request)
|
||||
.expect("request should convert");
|
||||
let second = normalize_claude_request_to_openai_chat_request(&request)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first["messages"][0]["tool_calls"][0]["id"], "toolu_auto_0");
|
||||
assert_eq!(first["messages"][0]["tool_calls"][1]["id"], "toolu_auto_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_explicit_claude_tool_use_ids() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_explicit_1",
|
||||
"name": "search",
|
||||
"input": {"query": "alpha"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let normalized = normalize_claude_request_to_openai_chat_request(&request)
|
||||
.expect("request should convert");
|
||||
|
||||
assert_eq!(
|
||||
normalized["messages"][0]["tool_calls"][0]["id"],
|
||||
"toolu_explicit_1"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::canonical_json_string;
|
||||
use crate::planner::openai::map_thinking_budget_to_openai_reasoning_effort;
|
||||
|
||||
pub fn normalize_gemini_request_to_openai_chat_request(
|
||||
body_json: &Value,
|
||||
request_path: &str,
|
||||
) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut output = Map::new();
|
||||
if let Some(model) = request
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
output.insert("model".to_string(), Value::String(model.to_string()));
|
||||
} else if let Some(model) = extract_gemini_model_from_path(request_path) {
|
||||
output.insert("model".to_string(), Value::String(model));
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(system_text) = extract_gemini_system_text(
|
||||
request
|
||||
.get("systemInstruction")
|
||||
.or_else(|| request.get("system_instruction")),
|
||||
) {
|
||||
if !system_text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "system",
|
||||
"content": system_text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(contents) = request.get("contents").and_then(Value::as_array) {
|
||||
for content in contents {
|
||||
let content_object = content.as_object()?;
|
||||
let role = content_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("user")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let parts = content_object.get("parts").and_then(Value::as_array)?;
|
||||
match role.as_str() {
|
||||
"model" => {
|
||||
let mut text_segments = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part = part.as_object()?;
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
text_segments.push(text.to_string());
|
||||
}
|
||||
} else if let Some(function_call) =
|
||||
part.get("functionCall").and_then(Value::as_object)
|
||||
{
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("toolu_{}_{}", name, index));
|
||||
tool_calls.push(json!({
|
||||
"id": id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": canonical_json_string(function_call.get("args").cloned().unwrap_or(Value::Object(Map::new()))),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
let mut assistant = Map::new();
|
||||
assistant.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
assistant.insert(
|
||||
"content".to_string(),
|
||||
if text_segments.is_empty() && !tool_calls.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::String(text_segments.join("\n\n"))
|
||||
},
|
||||
);
|
||||
if !tool_calls.is_empty() {
|
||||
assistant.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
messages.push(Value::Object(assistant));
|
||||
}
|
||||
_ => {
|
||||
let mut text_segments = Vec::new();
|
||||
for part in parts {
|
||||
let part = part.as_object()?;
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
text_segments.push(text.to_string());
|
||||
}
|
||||
} else if let Some(function_response) =
|
||||
part.get("functionResponse").and_then(Value::as_object)
|
||||
{
|
||||
let name = function_response
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool");
|
||||
let response_value = function_response
|
||||
.get("response")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Object(Map::new()));
|
||||
let tool_call_id = function_response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("toolu_{}", name));
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": response_value,
|
||||
}));
|
||||
}
|
||||
}
|
||||
let text = text_segments.join("\n\n");
|
||||
if !text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "user",
|
||||
"content": text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
output.insert("messages".to_string(), Value::Array(messages));
|
||||
|
||||
let generation_config = request
|
||||
.get("generationConfig")
|
||||
.or_else(|| request.get("generation_config"))
|
||||
.and_then(Value::as_object);
|
||||
if let Some(generation_config) = generation_config {
|
||||
if let Some(value) = generation_config.get("maxOutputTokens").cloned() {
|
||||
output.insert("max_completion_tokens".to_string(), value);
|
||||
}
|
||||
if let Some(value) = generation_config.get("temperature").cloned() {
|
||||
output.insert("temperature".to_string(), value);
|
||||
}
|
||||
if let Some(value) = generation_config.get("topP").cloned() {
|
||||
output.insert("top_p".to_string(), value);
|
||||
}
|
||||
if let Some(value) = generation_config.get("candidateCount").cloned() {
|
||||
output.insert("n".to_string(), value);
|
||||
}
|
||||
if let Some(value) = generation_config.get("stopSequences").cloned() {
|
||||
output.insert("stop".to_string(), value);
|
||||
}
|
||||
if let Some(thinking_budget) = generation_config
|
||||
.get("thinkingConfig")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|thinking| thinking.get("thinkingBudget"))
|
||||
.and_then(Value::as_u64)
|
||||
{
|
||||
output.insert(
|
||||
"reasoning_effort".to_string(),
|
||||
Value::String(
|
||||
map_thinking_budget_to_openai_reasoning_effort(thinking_budget).to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if generation_config
|
||||
.get("responseMimeType")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "application/json")
|
||||
{
|
||||
let response_format = if let Some(schema) = generation_config.get("responseSchema") {
|
||||
json!({
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "response_schema",
|
||||
"schema": schema,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
json!({ "type": "json_object" })
|
||||
};
|
||||
output.insert("response_format".to_string(), response_format);
|
||||
}
|
||||
}
|
||||
if let Some(value) = request.get("stream").cloned() {
|
||||
output.insert("stream".to_string(), value);
|
||||
}
|
||||
if let Some(tools) = normalize_gemini_tools_to_openai(request.get("tools"))? {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(tool_choice) = normalize_gemini_tool_choice_to_openai(request.get("toolConfig"))? {
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn extract_gemini_system_text(system_instruction: Option<&Value>) -> Option<String> {
|
||||
let system_instruction = system_instruction?;
|
||||
match system_instruction {
|
||||
Value::String(text) => Some(text.trim().to_string()),
|
||||
Value::Object(object) => {
|
||||
let parts = object.get("parts")?.as_array()?;
|
||||
let mut segments = Vec::new();
|
||||
for part in parts {
|
||||
let part = part.as_object()?;
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
segments.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(segments.join("\n\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_gemini_tools_to_openai(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
|
||||
let Some(tools) = tools else {
|
||||
return Some(None);
|
||||
};
|
||||
let tools = tools.as_array()?;
|
||||
let mut normalized = Vec::new();
|
||||
for tool in tools {
|
||||
let tool = tool.as_object()?;
|
||||
let declarations = tool
|
||||
.get("functionDeclarations")
|
||||
.or_else(|| tool.get("function_declarations"))
|
||||
.and_then(Value::as_array);
|
||||
let Some(declarations) = declarations else {
|
||||
continue;
|
||||
};
|
||||
for declaration in declarations {
|
||||
let declaration = declaration.as_object()?;
|
||||
let name = declaration
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut function = Map::new();
|
||||
function.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = declaration.get("description").and_then(Value::as_str) {
|
||||
if !description.trim().is_empty() {
|
||||
function.insert(
|
||||
"description".to_string(),
|
||||
Value::String(description.trim().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
function.insert(
|
||||
"parameters".to_string(),
|
||||
declaration
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({"type": "object"})),
|
||||
);
|
||||
normalized.push(json!({
|
||||
"type": "function",
|
||||
"function": Value::Object(function),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Some(Some(normalized))
|
||||
}
|
||||
|
||||
fn normalize_gemini_tool_choice_to_openai(tool_config: Option<&Value>) -> Option<Option<Value>> {
|
||||
let Some(tool_config) = tool_config else {
|
||||
return Some(None);
|
||||
};
|
||||
let tool_config = tool_config.as_object()?;
|
||||
let function_config = tool_config
|
||||
.get("functionCallingConfig")
|
||||
.or_else(|| tool_config.get("function_calling_config"))
|
||||
.and_then(Value::as_object)?;
|
||||
let mode = function_config
|
||||
.get("mode")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_uppercase();
|
||||
match mode.as_str() {
|
||||
"NONE" => Some(Some(Value::String("none".to_string()))),
|
||||
"AUTO" => Some(Some(Value::String("auto".to_string()))),
|
||||
"ANY" | "REQUIRED" => Some(Some(Value::String("required".to_string()))),
|
||||
_ => {
|
||||
if let Some(name) = function_config
|
||||
.get("allowedFunctionNames")
|
||||
.or_else(|| function_config.get("allowed_function_names"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|values| values.first())
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some(Some(json!({
|
||||
"type": "function",
|
||||
"function": { "name": name }
|
||||
})))
|
||||
} else {
|
||||
Some(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
|
||||
let marker = "/models/";
|
||||
let start = path.find(marker)? + marker.len();
|
||||
let tail = &path[start..];
|
||||
let end = tail.find(':').unwrap_or(tail.len());
|
||||
let model = tail[..end].trim();
|
||||
if model.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(model.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod claude;
|
||||
mod gemini;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude::normalize_claude_request_to_openai_chat_request;
|
||||
pub use gemini::normalize_gemini_request_to_openai_chat_request;
|
||||
pub use openai_cli::normalize_openai_cli_request_to_openai_chat_request;
|
||||
pub use shared::{extract_openai_text_content, parse_openai_tool_result_content};
|
||||
@@ -0,0 +1,405 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::{extract_openai_text_content, parse_openai_tool_result_content};
|
||||
use crate::planner::openai::extract_openai_reasoning_effort;
|
||||
|
||||
pub fn normalize_openai_cli_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut output = Map::new();
|
||||
if let Some(model) = request.get("model") {
|
||||
output.insert("model".to_string(), model.clone());
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
if let Some(instructions) = request.get("instructions") {
|
||||
let text = extract_openai_text_content(Some(instructions))?;
|
||||
if !text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "system",
|
||||
"content": text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
messages.extend(normalize_openai_cli_input_to_openai_chat_messages(
|
||||
request.get("input"),
|
||||
)?);
|
||||
output.insert("messages".to_string(), Value::Array(messages));
|
||||
|
||||
if let Some(max_output_tokens) = request.get("max_output_tokens").cloned() {
|
||||
output.insert("max_completion_tokens".to_string(), max_output_tokens);
|
||||
}
|
||||
for passthrough_key in [
|
||||
"temperature",
|
||||
"top_p",
|
||||
"metadata",
|
||||
"store",
|
||||
"service_tier",
|
||||
"parallel_tool_calls",
|
||||
"stop",
|
||||
"stream",
|
||||
] {
|
||||
if let Some(value) = request.get(passthrough_key) {
|
||||
output.insert(passthrough_key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(reasoning_effort) = extract_openai_reasoning_effort(request) {
|
||||
output.insert(
|
||||
"reasoning_effort".to_string(),
|
||||
Value::String(reasoning_effort),
|
||||
);
|
||||
}
|
||||
if let Some(response_format) = request
|
||||
.get("text")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|text| text.get("format"))
|
||||
.cloned()
|
||||
{
|
||||
output.insert("response_format".to_string(), response_format);
|
||||
}
|
||||
if let Some(tools) = normalize_openai_cli_tools_to_openai_chat(request.get("tools"))? {
|
||||
output.insert("tools".to_string(), Value::Array(tools));
|
||||
}
|
||||
if let Some(web_search_options) =
|
||||
extract_openai_cli_web_search_options(request.get("tools").and_then(Value::as_array))
|
||||
{
|
||||
output.insert("web_search_options".to_string(), web_search_options);
|
||||
}
|
||||
if let Some(tool_choice) =
|
||||
normalize_openai_cli_tool_choice_to_openai_chat(request.get("tool_choice"))?
|
||||
{
|
||||
output.insert("tool_choice".to_string(), tool_choice);
|
||||
}
|
||||
|
||||
Some(Value::Object(output))
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_input_to_openai_chat_messages(input: Option<&Value>) -> Option<Vec<Value>> {
|
||||
let Some(input) = input else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
match input {
|
||||
Value::Null => Some(Vec::new()),
|
||||
Value::String(text) => {
|
||||
if text.trim().is_empty() {
|
||||
Some(Vec::new())
|
||||
} else {
|
||||
Some(vec![json!({
|
||||
"role": "user",
|
||||
"content": text,
|
||||
})])
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let mut messages = Vec::new();
|
||||
let mut next_generated_tool_call_index = 0usize;
|
||||
for item in items {
|
||||
if let Some(item_text) = item.as_str() {
|
||||
if !item_text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "user",
|
||||
"content": item_text,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let item_object = item.as_object()?;
|
||||
let item_type = item_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("message")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match item_type.as_str() {
|
||||
"message" => {
|
||||
let role = item_object
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("user")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if role == "system" || role == "developer" {
|
||||
let text = extract_openai_text_content(item_object.get("content"))?;
|
||||
if !text.trim().is_empty() {
|
||||
messages.push(json!({
|
||||
"role": "system",
|
||||
"content": text,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let normalized_content =
|
||||
normalize_openai_cli_message_content(item_object.get("content"))?;
|
||||
messages.push(json!({
|
||||
"role": role,
|
||||
"content": normalized_content,
|
||||
}));
|
||||
}
|
||||
"function_call" => {
|
||||
let tool_name = item_object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let call_id = item_object
|
||||
.get("call_id")
|
||||
.or_else(|| item_object.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
let generated =
|
||||
format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
});
|
||||
let arguments = item_object
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
messages.push(json!({
|
||||
"role": "assistant",
|
||||
"content": Value::Array(Vec::new()),
|
||||
"tool_calls": [{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
}]
|
||||
}));
|
||||
}
|
||||
"function_call_output" => {
|
||||
let tool_call_id = item_object
|
||||
.get("call_id")
|
||||
.or_else(|| item_object.get("tool_call_id"))
|
||||
.or_else(|| item_object.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
let generated =
|
||||
format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
});
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": parse_openai_tool_result_content(item_object.get("output")),
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(messages)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_message_content(content: Option<&Value>) -> Option<Value> {
|
||||
let Some(content) = content else {
|
||||
return Some(Value::Array(Vec::new()));
|
||||
};
|
||||
match content {
|
||||
Value::String(text) => Some(Value::String(text.clone())),
|
||||
Value::Array(parts) => {
|
||||
let mut normalized = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match part_type.as_str() {
|
||||
"input_text" | "output_text" | "text" => {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
normalized.push(json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"input_image" | "output_image" | "image_url" => {
|
||||
let image_url = part_object
|
||||
.get("image_url")
|
||||
.and_then(|value| {
|
||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
part_object
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})?;
|
||||
let detail = part_object
|
||||
.get("detail")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
part_object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|image| image.get("detail"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
let mut image = Map::new();
|
||||
image.insert("url".to_string(), Value::String(image_url));
|
||||
if let Some(detail) = detail {
|
||||
image.insert("detail".to_string(), Value::String(detail));
|
||||
}
|
||||
normalized.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": image,
|
||||
}));
|
||||
}
|
||||
"input_file" => {
|
||||
let mut file = Map::new();
|
||||
if let Some(file_data) = part_object.get("file_data").cloned() {
|
||||
file.insert("file_data".to_string(), file_data);
|
||||
}
|
||||
if let Some(file_id) = part_object.get("file_id").cloned() {
|
||||
file.insert("file_id".to_string(), file_id);
|
||||
}
|
||||
if let Some(filename) = part_object.get("filename").cloned() {
|
||||
file.insert("filename".to_string(), filename);
|
||||
}
|
||||
if !file.is_empty() {
|
||||
normalized.push(json!({
|
||||
"type": "file",
|
||||
"file": file,
|
||||
}));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Value::Array(normalized))
|
||||
}
|
||||
_ => Some(content.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_tools_to_openai_chat(tools: Option<&Value>) -> Option<Option<Vec<Value>>> {
|
||||
let Some(Value::Array(tool_values)) = tools else {
|
||||
return Some(None);
|
||||
};
|
||||
let mut normalized = Vec::new();
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
let tool_type = tool_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("function")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if tool_type.starts_with("web_search") {
|
||||
continue;
|
||||
}
|
||||
if tool_object.get("function").is_some() || tool_type != "function" {
|
||||
continue;
|
||||
}
|
||||
let name = tool_object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let mut function = Map::new();
|
||||
function.insert("name".to_string(), Value::String(name.to_string()));
|
||||
if let Some(description) = tool_object.get("description") {
|
||||
function.insert("description".to_string(), description.clone());
|
||||
}
|
||||
if let Some(parameters) = tool_object.get("parameters") {
|
||||
function.insert("parameters".to_string(), parameters.clone());
|
||||
}
|
||||
normalized.push(json!({
|
||||
"type": "function",
|
||||
"function": function,
|
||||
}));
|
||||
}
|
||||
Some((!normalized.is_empty()).then_some(normalized))
|
||||
}
|
||||
|
||||
fn extract_openai_cli_web_search_options(tools: Option<&Vec<Value>>) -> Option<Value> {
|
||||
let tool_values = tools?;
|
||||
for tool in tool_values {
|
||||
let tool_object = tool.as_object()?;
|
||||
let tool_type = tool_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !tool_type.starts_with("web_search") {
|
||||
continue;
|
||||
}
|
||||
let mut options = Map::new();
|
||||
if let Some(search_context_size) = tool_object.get("search_context_size").cloned() {
|
||||
options.insert("search_context_size".to_string(), search_context_size);
|
||||
}
|
||||
if let Some(user_location) = tool_object.get("user_location").and_then(Value::as_object) {
|
||||
let mut approximate = Map::new();
|
||||
for field in ["city", "country", "region", "timezone"] {
|
||||
if let Some(value) = user_location.get(field).cloned() {
|
||||
approximate.insert(field.to_string(), value);
|
||||
}
|
||||
}
|
||||
if !approximate.is_empty() {
|
||||
options.insert(
|
||||
"user_location".to_string(),
|
||||
json!({
|
||||
"type": "approximate",
|
||||
"approximate": approximate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !options.is_empty() {
|
||||
return Some(Value::Object(options));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_openai_cli_tool_choice_to_openai_chat(
|
||||
tool_choice: Option<&Value>,
|
||||
) -> Option<Option<Value>> {
|
||||
let Some(tool_choice) = tool_choice else {
|
||||
return Some(None);
|
||||
};
|
||||
match tool_choice {
|
||||
Value::Object(object)
|
||||
if object.get("function").is_none()
|
||||
&& object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("function")) =>
|
||||
{
|
||||
let name = object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
Some(Some(json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
}
|
||||
})))
|
||||
}
|
||||
_ => Some(Some(tool_choice.clone())),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
|
||||
match content {
|
||||
None | Some(Value::Null) => Some(String::new()),
|
||||
Some(Value::String(text)) => Some(text.clone()),
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut collected = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if matches!(part_type, "text" | "input_text") {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
collected.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(collected.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
|
||||
match content {
|
||||
Some(Value::String(raw)) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
Value::String(String::new())
|
||||
} else {
|
||||
serde_json::from_str::<Value>(trimmed)
|
||||
.unwrap_or_else(|_| Value::String(raw.clone()))
|
||||
}
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
let texts = parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
part.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if texts.is_empty() {
|
||||
Value::Array(parts.clone())
|
||||
} else {
|
||||
Value::String(texts.join("\n"))
|
||||
}
|
||||
}
|
||||
Some(value) => value.clone(),
|
||||
None => Value::String(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn canonical_json_string(value: Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text,
|
||||
other => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::shared::{
|
||||
build_generated_tool_call_id, extract_openai_assistant_text, parse_openai_function_arguments,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_response_to_claude_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let choices = body.get("choices")?.as_array()?;
|
||||
let first_choice = choices.first()?.as_object()?;
|
||||
let message = first_choice.get("message")?.as_object()?;
|
||||
let mut content = Vec::new();
|
||||
|
||||
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
|
||||
if !text.trim().is_empty() {
|
||||
content.push(json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
||||
for (index, tool_call) in tool_call_values.iter().enumerate() {
|
||||
let tool_call = tool_call.as_object()?;
|
||||
let function = tool_call.get("function")?.as_object()?;
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let tool_id = tool_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
let input = parse_openai_function_arguments(function.get("arguments"))?;
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": tool_id,
|
||||
"name": tool_name,
|
||||
"input": input,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if content.is_empty() {
|
||||
content.push(json!({
|
||||
"type": "text",
|
||||
"text": "",
|
||||
}));
|
||||
}
|
||||
|
||||
let stop_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
|
||||
Some("stop") | None => "end_turn",
|
||||
Some("length") => "max_tokens",
|
||||
Some("tool_calls") | Some("function_call") => "tool_use",
|
||||
Some("content_filter") => "content_filtered",
|
||||
Some(other) => other,
|
||||
};
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let input_tokens = usage
|
||||
.and_then(|value| value.get("prompt_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.and_then(|value| value.get("completion_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
let id = body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("msg-local-finalize");
|
||||
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": content,
|
||||
"stop_reason": stop_reason,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::shared::{
|
||||
build_generated_tool_call_id, extract_openai_assistant_text, parse_openai_function_arguments,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_response_to_gemini_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let choices = body.get("choices")?.as_array()?;
|
||||
let first_choice = choices.first()?.as_object()?;
|
||||
let message = first_choice.get("message")?.as_object()?;
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(text) = extract_openai_assistant_text(message.get("content")) {
|
||||
if !text.trim().is_empty() {
|
||||
parts.push(json!({ "text": text }));
|
||||
}
|
||||
}
|
||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
||||
for (index, tool_call) in tool_call_values.iter().enumerate() {
|
||||
let tool_call = tool_call.as_object()?;
|
||||
let function = tool_call.get("function")?.as_object()?;
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let call_id = tool_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
parts.push(json!({
|
||||
"functionCall": {
|
||||
"id": call_id,
|
||||
"name": tool_name,
|
||||
"args": parse_openai_function_arguments(function.get("arguments"))?,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
parts.push(json!({ "text": "" }));
|
||||
}
|
||||
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("prompt_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let completion_tokens = usage
|
||||
.and_then(|value| value.get("completion_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.and_then(|value| value.get("total_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(prompt_tokens + completion_tokens);
|
||||
let mut finish_reason = match first_choice.get("finish_reason").and_then(Value::as_str) {
|
||||
Some("stop") | None => "STOP",
|
||||
Some("length") => "MAX_TOKENS",
|
||||
Some("content_filter") => "SAFETY",
|
||||
Some("tool_calls") | Some("function_call") => "STOP",
|
||||
Some(other) => other,
|
||||
};
|
||||
if parts.iter().any(|part| part.get("functionCall").is_some()) {
|
||||
finish_reason = "STOP";
|
||||
}
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
let response_id = body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("resp-local-finalize");
|
||||
|
||||
Some(json!({
|
||||
"responseId": response_id,
|
||||
"modelVersion": model,
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
},
|
||||
"finishReason": finish_reason,
|
||||
"index": 0,
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": prompt_tokens,
|
||||
"candidatesTokenCount": completion_tokens,
|
||||
"totalTokenCount": total_tokens,
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod claude_chat;
|
||||
mod gemini_chat;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude_chat::convert_openai_chat_response_to_claude_chat;
|
||||
pub use gemini_chat::convert_openai_chat_response_to_gemini_chat;
|
||||
pub use openai_cli::convert_openai_chat_response_to_openai_cli;
|
||||
pub use shared::{
|
||||
build_openai_cli_response, build_openai_cli_response_with_content,
|
||||
build_openai_cli_response_with_reasoning,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::shared::{build_openai_cli_response_with_content, canonicalize_tool_arguments};
|
||||
|
||||
pub fn convert_openai_chat_response_to_openai_cli(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let choices = body.get("choices")?.as_array()?;
|
||||
let first_choice = choices.first()?.as_object()?;
|
||||
let message = first_choice.get("message")?.as_object()?;
|
||||
let mut message_content = Vec::new();
|
||||
let mut reasoning_summaries = Vec::new();
|
||||
match message.get("content") {
|
||||
Some(Value::String(value)) => {
|
||||
if !value.is_empty() {
|
||||
message_content.push(json!({
|
||||
"type": "output_text",
|
||||
"text": value,
|
||||
"annotations": []
|
||||
}));
|
||||
}
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
for part in parts {
|
||||
let part = part.as_object()?;
|
||||
let part_type = part
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(part_type.as_str(), "text" | "output_text") {
|
||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
||||
message_content.push(json!({
|
||||
"type": "output_text",
|
||||
"text": piece,
|
||||
"annotations": []
|
||||
}));
|
||||
}
|
||||
} else if matches!(part_type.as_str(), "image_url" | "output_image") {
|
||||
if let Some(image_url) = part
|
||||
.get("image_url")
|
||||
.and_then(|value| {
|
||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
part.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
{
|
||||
let mut image_part = json!({
|
||||
"type": "output_image",
|
||||
"image_url": image_url,
|
||||
});
|
||||
if let Some(detail) =
|
||||
part.get("detail").and_then(Value::as_str).or_else(|| {
|
||||
part.get("image_url")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|image| image.get("detail"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
{
|
||||
image_part["detail"] = Value::String(detail.to_string());
|
||||
}
|
||||
message_content.push(image_part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Value::Null) | None => {}
|
||||
_ => return None,
|
||||
}
|
||||
if let Some(reasoning_content) = message.get("reasoning_content").and_then(Value::as_str) {
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
reasoning_summaries.push(reasoning_content.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut function_calls = Vec::new();
|
||||
if let Some(tool_call_values) = message.get("tool_calls").and_then(Value::as_array) {
|
||||
for tool_call in tool_call_values {
|
||||
let tool_call = tool_call.as_object()?;
|
||||
let function = tool_call.get("function")?.as_object()?;
|
||||
let tool_name = function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
function_calls.push(json!({
|
||||
"type": "function_call",
|
||||
"id": tool_call.get("id").cloned().unwrap_or(Value::Null),
|
||||
"call_id": tool_call.get("id").cloned().unwrap_or(Value::Null),
|
||||
"name": tool_name,
|
||||
"arguments": canonicalize_tool_arguments(function.get("arguments").cloned()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("prompt_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.and_then(|value| value.get("completion_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.and_then(|value| value.get("total_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(prompt_tokens + output_tokens);
|
||||
let response_id = if compact {
|
||||
body.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.replace("chatcmpl", "resp"))
|
||||
.unwrap_or_else(|| "resp-local-finalize".to_string())
|
||||
} else {
|
||||
body.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.replace("chatcmpl", "resp"))
|
||||
.unwrap_or_else(|| "resp-local-finalize".to_string())
|
||||
};
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
|
||||
Some(build_openai_cli_response_with_content(
|
||||
&response_id,
|
||||
model,
|
||||
message_content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub fn build_openai_cli_response(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
function_calls: Vec<Value>,
|
||||
prompt_tokens: u64,
|
||||
output_tokens: u64,
|
||||
total_tokens: u64,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_cli_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
Vec::new(),
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_cli_response_with_reasoning(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
prompt_tokens: u64,
|
||||
output_tokens: u64,
|
||||
total_tokens: u64,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_cli_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_cli_response_with_content(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
content: Vec<Value>,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
prompt_tokens: u64,
|
||||
output_tokens: u64,
|
||||
total_tokens: u64,
|
||||
) -> Value {
|
||||
let mut output = Vec::new();
|
||||
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
|
||||
let trimmed = summary.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
output.push(json!({
|
||||
"type": "reasoning",
|
||||
"id": format!("{response_id}_rs_{index}"),
|
||||
"status": "completed",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": trimmed,
|
||||
}]
|
||||
}));
|
||||
}
|
||||
if !content.is_empty() {
|
||||
output.push(json!({
|
||||
"type": "message",
|
||||
"id": format!("{response_id}_msg"),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
output.extend(function_calls);
|
||||
json!({
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": model,
|
||||
"output": output,
|
||||
"usage": {
|
||||
"input_tokens": prompt_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn extract_openai_assistant_text(content: Option<&Value>) -> Option<String> {
|
||||
match content? {
|
||||
Value::Null => Some(String::new()),
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Array(parts) => {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
let part = part.as_object()?;
|
||||
let part_type = part
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(part_type.as_str(), "text" | "output_text") {
|
||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
||||
text.push_str(piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_openai_function_arguments(arguments: Option<&Value>) -> Option<Value> {
|
||||
match arguments.cloned().unwrap_or(Value::Object(Map::new())) {
|
||||
Value::String(text) => serde_json::from_str(&text)
|
||||
.ok()
|
||||
.or(Some(Value::String(text))),
|
||||
other => Some(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
|
||||
format!("call_auto_{index}")
|
||||
}
|
||||
|
||||
pub(super) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text,
|
||||
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
||||
None => "{}".to_string(),
|
||||
}
|
||||
}
|
||||
12
crates/aether-ai-pipeline/src/conversion/response/mod.rs
Normal file
12
crates/aether-ai-pipeline/src/conversion/response/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod from_openai_chat;
|
||||
pub mod to_openai_chat;
|
||||
|
||||
pub use from_openai_chat::{
|
||||
build_openai_cli_response, convert_openai_chat_response_to_claude_chat,
|
||||
convert_openai_chat_response_to_gemini_chat, convert_openai_chat_response_to_openai_cli,
|
||||
};
|
||||
pub use to_openai_chat::{
|
||||
convert_claude_chat_response_to_openai_chat, convert_claude_cli_response_to_openai_cli,
|
||||
convert_gemini_chat_response_to_openai_chat, convert_gemini_cli_response_to_openai_cli,
|
||||
convert_openai_cli_response_to_openai_chat,
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
|
||||
pub fn convert_claude_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let content = body.get("content")?.as_array()?;
|
||||
let mut text = String::new();
|
||||
let mut reasoning_content = String::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
for (index, block) in content.iter().enumerate() {
|
||||
let block = block.as_object()?;
|
||||
match block.get("type")?.as_str()? {
|
||||
"text" => {
|
||||
text.push_str(block.get("text")?.as_str()?);
|
||||
}
|
||||
"thinking" => {
|
||||
if let Some(piece) = block
|
||||
.get("thinking")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| block.get("text").and_then(Value::as_str))
|
||||
{
|
||||
reasoning_content.push_str(piece);
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let tool_name = block.get("name")?.as_str()?;
|
||||
let tool_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
||||
tool_calls.push(json!({
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
}));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
let mut finish_reason = match body.get("stop_reason").and_then(Value::as_str) {
|
||||
Some("end_turn") | Some("stop_sequence") => Some("stop"),
|
||||
Some("max_tokens") => Some("length"),
|
||||
Some("tool_use") => Some("tool_calls"),
|
||||
Some(other) if !other.is_empty() => Some(other),
|
||||
_ => None,
|
||||
};
|
||||
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
|
||||
finish_reason = Some("tool_calls");
|
||||
}
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let completion_tokens = usage
|
||||
.and_then(|value| value.get("output_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = prompt_tokens + completion_tokens;
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
let id = body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatcmpl-local-finalize");
|
||||
let message_content = if text.is_empty() && !tool_calls.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::String(text)
|
||||
};
|
||||
let mut message = Map::new();
|
||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
message.insert("content".to_string(), message_content);
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
message.insert(
|
||||
"reasoning_content".to_string(),
|
||||
Value::String(reasoning_content),
|
||||
);
|
||||
}
|
||||
if !tool_calls.is_empty() {
|
||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": Value::Object(message),
|
||||
"finish_reason": finish_reason,
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::from_openai_chat::build_openai_cli_response_with_reasoning;
|
||||
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
|
||||
pub fn convert_claude_cli_response_to_openai_cli(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let content = body.get("content")?.as_array()?;
|
||||
let mut text = String::new();
|
||||
let mut reasoning_summaries = Vec::new();
|
||||
let mut function_calls = Vec::new();
|
||||
for (index, block) in content.iter().enumerate() {
|
||||
let block = block.as_object()?;
|
||||
match block.get("type")?.as_str()? {
|
||||
"text" => {
|
||||
text.push_str(block.get("text")?.as_str()?);
|
||||
}
|
||||
"thinking" => {
|
||||
if let Some(piece) = block
|
||||
.get("thinking")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| block.get("text").and_then(Value::as_str))
|
||||
{
|
||||
if !piece.trim().is_empty() {
|
||||
reasoning_summaries.push(piece.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let tool_name = block.get("name")?.as_str()?;
|
||||
let call_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
||||
function_calls.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
}));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.and_then(|value| value.get("output_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = prompt_tokens + output_tokens;
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
let response_id = body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("resp-local-finalize");
|
||||
|
||||
Some(build_openai_cli_response_with_reasoning(
|
||||
response_id,
|
||||
model,
|
||||
&text,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::{
|
||||
build_generated_tool_call_id, canonicalize_tool_arguments, extract_gemini_image_url,
|
||||
};
|
||||
|
||||
pub fn convert_gemini_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let candidates = body.get("candidates")?.as_array()?;
|
||||
let first_candidate = candidates.first()?.as_object()?;
|
||||
let content = first_candidate.get("content")?.as_object()?;
|
||||
let parts = content.get("parts")?.as_array()?;
|
||||
let mut text = String::new();
|
||||
let mut content_parts = Vec::new();
|
||||
let mut reasoning_content = String::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut has_non_text_content = false;
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part = part.as_object()?;
|
||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
||||
if part
|
||||
.get("thought")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
reasoning_content.push_str(piece);
|
||||
} else {
|
||||
text.push_str(piece);
|
||||
content_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": piece,
|
||||
}));
|
||||
}
|
||||
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
|
||||
let tool_name = function_call.get("name")?.as_str()?;
|
||||
let tool_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
||||
tool_calls.push(json!({
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
}));
|
||||
} else if let Some(image_url) = extract_gemini_image_url(part) {
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": image_url,
|
||||
}
|
||||
}));
|
||||
has_non_text_content = true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut finish_reason = match first_candidate.get("finishReason").and_then(Value::as_str) {
|
||||
Some("STOP") => Some("stop"),
|
||||
Some("MAX_TOKENS") => Some("length"),
|
||||
Some("SAFETY") => Some("content_filter"),
|
||||
Some(other) if !other.is_empty() => Some(other),
|
||||
_ => None,
|
||||
};
|
||||
if !tool_calls.is_empty() && finish_reason.is_none_or(|reason| reason == "stop") {
|
||||
finish_reason = Some("tool_calls");
|
||||
}
|
||||
let usage = body.get("usageMetadata").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("promptTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let completion_tokens = usage
|
||||
.and_then(|value| value.get("candidatesTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.and_then(|value| value.get("totalTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(prompt_tokens + completion_tokens);
|
||||
let model = body
|
||||
.get("modelVersion")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
let id = body
|
||||
.get("responseId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatcmpl-local-finalize");
|
||||
let message_content = if content_parts.is_empty() && !tool_calls.is_empty() {
|
||||
Value::Null
|
||||
} else if has_non_text_content {
|
||||
Value::Array(content_parts)
|
||||
} else {
|
||||
Value::String(text)
|
||||
};
|
||||
let mut message = Map::new();
|
||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
message.insert("content".to_string(), message_content);
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
message.insert(
|
||||
"reasoning_content".to_string(),
|
||||
Value::String(reasoning_content),
|
||||
);
|
||||
}
|
||||
if !tool_calls.is_empty() {
|
||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": first_candidate.get("index").and_then(Value::as_u64).unwrap_or(0),
|
||||
"message": Value::Object(message),
|
||||
"finish_reason": finish_reason,
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::from_openai_chat::build_openai_cli_response_with_content;
|
||||
use super::shared::{
|
||||
build_generated_tool_call_id, canonicalize_tool_arguments, extract_gemini_image_url,
|
||||
};
|
||||
|
||||
pub fn convert_gemini_cli_response_to_openai_cli(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let candidates = body.get("candidates")?.as_array()?;
|
||||
let first_candidate = candidates.first()?.as_object()?;
|
||||
let content = first_candidate.get("content")?.as_object()?;
|
||||
let parts = content.get("parts")?.as_array()?;
|
||||
let mut message_content = Vec::new();
|
||||
let mut reasoning_summaries = Vec::new();
|
||||
let mut function_calls = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part = part.as_object()?;
|
||||
if let Some(piece) = part.get("text").and_then(Value::as_str) {
|
||||
if part
|
||||
.get("thought")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if !piece.trim().is_empty() {
|
||||
reasoning_summaries.push(piece.to_string());
|
||||
}
|
||||
} else {
|
||||
message_content.push(json!({
|
||||
"type": "output_text",
|
||||
"text": piece,
|
||||
"annotations": []
|
||||
}));
|
||||
}
|
||||
} else if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
|
||||
let tool_name = function_call.get("name")?.as_str()?;
|
||||
let call_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
||||
function_calls.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
}));
|
||||
} else if let Some(image_url) = extract_gemini_image_url(part) {
|
||||
message_content.push(json!({
|
||||
"type": "output_image",
|
||||
"image_url": image_url,
|
||||
}));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let usage = body.get("usageMetadata").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("promptTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.map(|value| {
|
||||
value
|
||||
.get("candidatesTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
+ value
|
||||
.get("thoughtsTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.and_then(|value| value.get("totalTokenCount"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(prompt_tokens + output_tokens);
|
||||
let model = body
|
||||
.get("modelVersion")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
let response_id = body
|
||||
.get("responseId")
|
||||
.or_else(|| body.get("_v1internal_response_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("resp-local-finalize");
|
||||
|
||||
Some(build_openai_cli_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
message_content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod claude_chat;
|
||||
mod claude_cli;
|
||||
mod gemini_chat;
|
||||
mod gemini_cli;
|
||||
mod openai_cli;
|
||||
mod shared;
|
||||
|
||||
pub use claude_chat::convert_claude_chat_response_to_openai_chat;
|
||||
pub use claude_cli::convert_claude_cli_response_to_openai_cli;
|
||||
pub use gemini_chat::convert_gemini_chat_response_to_openai_chat;
|
||||
pub use gemini_cli::convert_gemini_cli_response_to_openai_cli;
|
||||
pub use openai_cli::convert_openai_cli_response_to_openai_chat;
|
||||
@@ -0,0 +1,238 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
|
||||
pub fn convert_openai_cli_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
let body = body_json.as_object()?;
|
||||
let mut text = String::new();
|
||||
let mut content_parts = Vec::new();
|
||||
let mut reasoning_content = String::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut has_non_text_content = false;
|
||||
|
||||
if let Some(output_items) = body.get("output").and_then(Value::as_array) {
|
||||
for (index, item) in output_items.iter().enumerate() {
|
||||
let item_object = item.as_object()?;
|
||||
let item_type = item_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match item_type.as_str() {
|
||||
"message" => {
|
||||
if let Some(content) = item_object.get("content").and_then(Value::as_array) {
|
||||
for part in content {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(part_type.as_str(), "output_text" | "text") {
|
||||
if let Some(piece) = part_object.get("text").and_then(Value::as_str)
|
||||
{
|
||||
text.push_str(piece);
|
||||
content_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": piece,
|
||||
}));
|
||||
}
|
||||
} else if matches!(part_type.as_str(), "output_image" | "image_url") {
|
||||
if let Some((image_url, detail)) =
|
||||
extract_openai_response_image(part_object)
|
||||
{
|
||||
let mut image = Map::new();
|
||||
image.insert("url".to_string(), Value::String(image_url));
|
||||
if let Some(detail) = detail {
|
||||
image.insert("detail".to_string(), Value::String(detail));
|
||||
}
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": image,
|
||||
}));
|
||||
has_non_text_content = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"reasoning" => {
|
||||
if let Some(summary_items) =
|
||||
item_object.get("summary").and_then(Value::as_array)
|
||||
{
|
||||
for summary in summary_items {
|
||||
let summary_object = summary.as_object()?;
|
||||
if summary_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "summary_text")
|
||||
{
|
||||
if let Some(piece) =
|
||||
summary_object.get("text").and_then(Value::as_str)
|
||||
{
|
||||
reasoning_content.push_str(piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let tool_name = item_object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let tool_id = item_object
|
||||
.get("call_id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index));
|
||||
tool_calls.push(json!({
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": canonicalize_tool_arguments(item_object.get("arguments").cloned()),
|
||||
}
|
||||
}));
|
||||
}
|
||||
"output_text" | "text" => {
|
||||
if let Some(piece) = item_object.get("text").and_then(Value::as_str) {
|
||||
text.push_str(piece);
|
||||
content_parts.push(json!({
|
||||
"type": "text",
|
||||
"text": piece,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"output_image" | "image_url" => {
|
||||
if let Some((image_url, detail)) = extract_openai_response_image(item_object) {
|
||||
let mut image = Map::new();
|
||||
image.insert("url".to_string(), Value::String(image_url));
|
||||
if let Some(detail) = detail {
|
||||
image.insert("detail".to_string(), Value::String(detail));
|
||||
}
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": image,
|
||||
}));
|
||||
has_non_text_content = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finish_reason = if tool_calls.is_empty() {
|
||||
Some("stop")
|
||||
} else {
|
||||
Some("tool_calls")
|
||||
};
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown");
|
||||
let id = body
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatcmpl-local-openai-cli");
|
||||
|
||||
let usage = body.get("usage").and_then(Value::as_object);
|
||||
let prompt_tokens = usage
|
||||
.and_then(|value| value.get("input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let completion_tokens = usage
|
||||
.and_then(|value| value.get("output_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.and_then(|value| value.get("total_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(prompt_tokens + completion_tokens);
|
||||
|
||||
let mut message = Map::new();
|
||||
message.insert("role".to_string(), Value::String("assistant".to_string()));
|
||||
if content_parts.is_empty() && !tool_calls.is_empty() {
|
||||
message.insert("content".to_string(), Value::Null);
|
||||
} else if has_non_text_content {
|
||||
message.insert("content".to_string(), Value::Array(content_parts));
|
||||
} else {
|
||||
message.insert("content".to_string(), Value::String(text));
|
||||
}
|
||||
if !reasoning_content.trim().is_empty() {
|
||||
message.insert(
|
||||
"reasoning_content".to_string(),
|
||||
Value::String(reasoning_content),
|
||||
);
|
||||
}
|
||||
if !tool_calls.is_empty() {
|
||||
message.insert("tool_calls".to_string(), Value::Array(tool_calls));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": Value::Object(message),
|
||||
"finish_reason": finish_reason,
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_openai_response_image(
|
||||
part_object: &Map<String, Value>,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
let image_url = part_object
|
||||
.get("image_url")
|
||||
.and_then(|value| {
|
||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
||||
value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
part_object
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})?;
|
||||
let detail = part_object
|
||||
.get("detail")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
part_object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|image| image.get("detail"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
Some((image_url, detail))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) fn build_generated_tool_call_id(index: usize) -> String {
|
||||
format!("call_auto_{index}")
|
||||
}
|
||||
|
||||
pub(super) fn canonicalize_tool_arguments(value: Option<Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text,
|
||||
Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "null".to_string()),
|
||||
None => "{}".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_gemini_image_url(part: &Map<String, Value>) -> Option<String> {
|
||||
if let Some(inline_data) = part.get("inlineData").and_then(Value::as_object) {
|
||||
let mime_type = inline_data.get("mimeType").and_then(Value::as_str)?;
|
||||
if !mime_type.starts_with("image/") {
|
||||
return None;
|
||||
}
|
||||
let data = inline_data.get("data").and_then(Value::as_str)?;
|
||||
return Some(format!("data:{mime_type};base64,{data}"));
|
||||
}
|
||||
let file_data = part.get("fileData").and_then(Value::as_object)?;
|
||||
if file_data
|
||||
.get("mimeType")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|mime_type| !mime_type.starts_with("image/"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
file_data
|
||||
.get("fileUri")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
13
crates/aether-ai-pipeline/src/finalize/common.rs
Normal file
13
crates/aether-ai-pipeline/src/finalize/common.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use serde_json::Value;
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
41
crates/aether-ai-pipeline/src/finalize/mod.rs
Normal file
41
crates/aether-ai-pipeline/src/finalize/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::fmt;
|
||||
|
||||
pub use self::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
|
||||
pub use self::standard::stream_core::CanonicalStreamEvent;
|
||||
pub use self::standard::stream_core::CanonicalStreamFrame;
|
||||
pub use self::stream_rewrite::{resolve_finalize_stream_rewrite_mode, FinalizeStreamRewriteMode};
|
||||
|
||||
pub mod common;
|
||||
pub mod sse;
|
||||
pub mod standard;
|
||||
pub mod stream_rewrite;
|
||||
pub mod sync_products;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PipelineFinalizeError(pub String);
|
||||
|
||||
impl PipelineFinalizeError {
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PipelineFinalizeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Pipeline finalize error: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PipelineFinalizeError {}
|
||||
|
||||
impl From<serde_json::Error> for PipelineFinalizeError {
|
||||
fn from(source: serde_json::Error) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for PipelineFinalizeError {
|
||||
fn from(source: base64::DecodeError) -> Self {
|
||||
Self(source.to_string())
|
||||
}
|
||||
}
|
||||
41
crates/aether-ai-pipeline/src/finalize/sse.rs
Normal file
41
crates/aether-ai-pipeline/src/finalize/sse.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
pub fn map_claude_stop_reason(
|
||||
stop_reason: Option<&str>,
|
||||
has_tool_calls: bool,
|
||||
) -> Option<&'static str> {
|
||||
let mapped = match stop_reason {
|
||||
Some("end_turn") | Some("stop_sequence") => Some("stop"),
|
||||
Some("max_tokens") => Some("length"),
|
||||
Some("tool_use") => Some("tool_calls"),
|
||||
Some("pause_turn") => Some("stop"),
|
||||
_ => None,
|
||||
};
|
||||
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
|
||||
Some("tool_calls")
|
||||
} else {
|
||||
mapped
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_done_sse() -> Vec<u8> {
|
||||
b"data: [DONE]\n\n".to_vec()
|
||||
}
|
||||
|
||||
pub fn encode_json_sse(
|
||||
event: Option<&str>,
|
||||
value: &Value,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
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(PipelineFinalizeError::from)?);
|
||||
out.extend_from_slice(b"\n\n");
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
526
crates/aether-ai-pipeline/src/finalize/standard/claude/stream.rs
Normal file
526
crates/aether-ai-pipeline/src/finalize/standard/claude/stream.rs
Normal file
@@ -0,0 +1,526 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::finalize::common::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
use crate::finalize::sse::{encode_json_sse, map_claude_stop_reason};
|
||||
use crate::finalize::standard::stream_core::common::*;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ClaudeProviderToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ClaudeProviderState {
|
||||
message_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
tool_calls: BTreeMap<usize, ClaudeProviderToolState>,
|
||||
}
|
||||
|
||||
impl ClaudeProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.message_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"msg-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let Some(event_object) = value.as_object() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
match event_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"message_start" => {
|
||||
if let Some(message) = event_object.get("message").and_then(Value::as_object) {
|
||||
self.message_id = message
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
self.model = message
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
"content_block_delta" => {
|
||||
let index = event_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
match delta
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text_delta" => {
|
||||
let Some(piece) = delta.get("text").and_then(Value::as_str) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if piece.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(piece.to_string()),
|
||||
});
|
||||
}
|
||||
"input_json_delta" => {
|
||||
let Some(partial_json) = delta.get("partial_json").and_then(Value::as_str)
|
||||
else {
|
||||
return Ok(out);
|
||||
};
|
||||
if partial_json.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if tool_state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
tool_state.call_id.clone()
|
||||
},
|
||||
name: if tool_state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
tool_state.started_emitted = true;
|
||||
}
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: partial_json.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
let index = event_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
let Some(block) = event_object.get("content_block").and_then(Value::as_object)
|
||||
else {
|
||||
return Ok(out);
|
||||
};
|
||||
let block_type = block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if block_type == "text" {
|
||||
let Some(text) = block.get("text").and_then(Value::as_str) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if text.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(text.to_string()),
|
||||
});
|
||||
return Ok(out);
|
||||
}
|
||||
if block_type != "tool_use" {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
tool_state.call_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
||||
.to_string();
|
||||
tool_state.name = block
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.name.as_str())
|
||||
.to_string();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if tool_state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
tool_state.call_id.clone()
|
||||
},
|
||||
name: if tool_state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
tool_state.started_emitted = true;
|
||||
}
|
||||
let arguments = canonicalize_tool_arguments(block.get("input").cloned());
|
||||
if !arguments.is_empty() && arguments != "{}" {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments },
|
||||
});
|
||||
}
|
||||
}
|
||||
"message_delta" => {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let Some(delta) = event_object.get("delta").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
let finish_reason = map_claude_stop_reason(
|
||||
delta.get("stop_reason").and_then(Value::as_str),
|
||||
delta.get("stop_reason").and_then(Value::as_str) == Some("tool_use"),
|
||||
)
|
||||
.map(ToOwned::to_owned);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: canonical_usage_from_claude_usage(event_object.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
enum ClaudeOpenBlock {
|
||||
Text {
|
||||
block_index: usize,
|
||||
},
|
||||
Tool {
|
||||
tool_index: usize,
|
||||
block_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ClaudeClientEmitter {
|
||||
message_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
next_block_index: usize,
|
||||
open_block: Option<ClaudeOpenBlock>,
|
||||
tool_block_indices: BTreeMap<usize, usize>,
|
||||
}
|
||||
|
||||
impl ClaudeClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.message_id = Some(frame.id.clone());
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.started {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
encode_json_sse(
|
||||
Some("message_start"),
|
||||
&json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": self.message_id.as_deref().unwrap_or("msg-local-stream"),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"content": [],
|
||||
"stop_reason": Value::Null,
|
||||
"stop_sequence": Value::Null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn close_open_block(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let Some(open_block) = self.open_block.take() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let block_index = match open_block {
|
||||
ClaudeOpenBlock::Text { block_index } => block_index,
|
||||
ClaudeOpenBlock::Tool { block_index, .. } => block_index,
|
||||
};
|
||||
encode_json_sse(
|
||||
Some("content_block_stop"),
|
||||
&json!({
|
||||
"type": "content_block_stop",
|
||||
"index": block_index,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_text_block(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ClaudeOpenBlock::Text { .. }) = self.open_block {
|
||||
return Ok(out);
|
||||
}
|
||||
out.extend(self.close_open_block()?);
|
||||
let block_index = self.next_block_index;
|
||||
self.next_block_index += 1;
|
||||
self.open_block = Some(ClaudeOpenBlock::Text { block_index });
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_start"),
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": block_index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": "",
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ensure_tool_block(
|
||||
&mut self,
|
||||
tool_index: usize,
|
||||
call_id: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ClaudeOpenBlock::Tool {
|
||||
tool_index: current_tool_index,
|
||||
..
|
||||
}) = self.open_block
|
||||
{
|
||||
if current_tool_index == tool_index {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
out.extend(self.close_open_block()?);
|
||||
let block_index = self
|
||||
.tool_block_indices
|
||||
.get(&tool_index)
|
||||
.copied()
|
||||
.unwrap_or_else(|| {
|
||||
let block_index = self.next_block_index;
|
||||
self.next_block_index += 1;
|
||||
self.tool_block_indices.insert(tool_index, block_index);
|
||||
block_index
|
||||
});
|
||||
self.open_block = Some(ClaudeOpenBlock::Tool {
|
||||
tool_index,
|
||||
block_index,
|
||||
});
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_start"),
|
||||
&json!({
|
||||
"type": "content_block_start",
|
||||
"index": block_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": call_id,
|
||||
"name": name,
|
||||
"input": {},
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(self.ensure_text_block()?);
|
||||
let block_index = match self.open_block {
|
||||
Some(ClaudeOpenBlock::Text { block_index }) => block_index,
|
||||
_ => return Ok(out),
|
||||
};
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_delta"),
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": block_index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": text,
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let call_id = format!("tool_{index}");
|
||||
out.extend(self.ensure_tool_block(index, &call_id, "unknown")?);
|
||||
let block_index = match self.open_block {
|
||||
Some(ClaudeOpenBlock::Tool { block_index, .. }) => block_index,
|
||||
_ => return Ok(out),
|
||||
};
|
||||
out.extend(encode_json_sse(
|
||||
Some("content_block_delta"),
|
||||
&json!({
|
||||
"type": "content_block_delta",
|
||||
"index": block_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": arguments,
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
} => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(self.close_open_block()?);
|
||||
let mut payload = Map::new();
|
||||
payload.insert(
|
||||
"type".to_string(),
|
||||
Value::String("message_delta".to_string()),
|
||||
);
|
||||
payload.insert(
|
||||
"delta".to_string(),
|
||||
json!({
|
||||
"stop_reason": map_openai_finish_reason_to_claude(
|
||||
finish_reason.as_deref()
|
||||
),
|
||||
"stop_sequence": Value::Null,
|
||||
}),
|
||||
);
|
||||
if let Some(usage) = usage {
|
||||
payload.insert(
|
||||
"usage".to_string(),
|
||||
json!({
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
out.extend(encode_json_sse(
|
||||
Some("message_delta"),
|
||||
&Value::Object(payload),
|
||||
)?);
|
||||
out.extend(encode_json_sse(
|
||||
Some("message_stop"),
|
||||
&json!({
|
||||
"type": "message_stop",
|
||||
}),
|
||||
)?);
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.emit(CanonicalStreamFrame {
|
||||
id: self
|
||||
.message_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "msg-local-stream".to_string()),
|
||||
model: self.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
401
crates/aether-ai-pipeline/src/finalize/standard/gemini/stream.rs
Normal file
401
crates/aether-ai-pipeline/src/finalize/standard/gemini/stream.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::finalize::common::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
use crate::finalize::sse::encode_json_sse;
|
||||
use crate::finalize::standard::stream_core::common::*;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct GeminiProviderToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GeminiProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text_parts: BTreeMap<usize, String>,
|
||||
tool_calls: BTreeMap<usize, GeminiProviderToolState>,
|
||||
}
|
||||
|
||||
impl GeminiProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"resp-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let Some(raw_event_object) = value.as_object() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if let Some(id) = raw_event_object.get("responseId").and_then(Value::as_str) {
|
||||
self.response_id = Some(id.to_string());
|
||||
}
|
||||
let event_object = raw_event_object
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
.filter(|response| response.contains_key("candidates"))
|
||||
.unwrap_or(raw_event_object);
|
||||
if let Some(id) = event_object.get("responseId").and_then(Value::as_str) {
|
||||
self.response_id = Some(id.to_string());
|
||||
}
|
||||
if let Some(version) = event_object.get("modelVersion").and_then(Value::as_str) {
|
||||
self.model = Some(version.to_string());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let Some(candidates) = event_object.get("candidates").and_then(Value::as_array) else {
|
||||
return Ok(out);
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
let Some(candidate_object) = candidate.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(content) = candidate_object.get("content").and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
let Some(parts) = content.get("parts").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
if !parts.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
let previous = self.text_parts.entry(index).or_default();
|
||||
let delta = if text.starts_with(previous.as_str()) {
|
||||
text[previous.len()..].to_string()
|
||||
} else if previous.as_str() == text {
|
||||
String::new()
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
*previous = text.to_string();
|
||||
if !delta.is_empty() {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::TextDelta(delta),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(function_call) =
|
||||
part_object.get("functionCall").and_then(Value::as_object)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
tool_state.call_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
||||
.to_string();
|
||||
tool_state.name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.name.as_str())
|
||||
.to_string();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if tool_state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
tool_state.call_id.clone()
|
||||
},
|
||||
name: if tool_state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
tool_state.started_emitted = true;
|
||||
}
|
||||
let arguments = canonicalize_tool_arguments(function_call.get("args").cloned());
|
||||
let delta = if arguments.starts_with(&tool_state.arguments) {
|
||||
arguments[tool_state.arguments.len()..].to_string()
|
||||
} else if tool_state.arguments == arguments {
|
||||
String::new()
|
||||
} else {
|
||||
arguments.clone()
|
||||
};
|
||||
tool_state.arguments = arguments;
|
||||
if !delta.is_empty() {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: delta,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(finish_reason) =
|
||||
candidate_object.get("finishReason").and_then(Value::as_str)
|
||||
{
|
||||
let has_tool_calls = !self.tool_calls.is_empty();
|
||||
let mut finish_reason = normalize_openai_finish_reason(match finish_reason {
|
||||
"STOP" => Some("stop"),
|
||||
"MAX_TOKENS" => Some("length"),
|
||||
"SAFETY" => Some("content_filter"),
|
||||
other => Some(other),
|
||||
});
|
||||
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
|
||||
finish_reason = Some("tool_calls".to_string());
|
||||
}
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: canonical_usage_from_gemini_usage(event_object.get("usageMetadata")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GeminiClientToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GeminiClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
finished: bool,
|
||||
tool_calls: BTreeMap<usize, GeminiClientToolState>,
|
||||
}
|
||||
|
||||
impl GeminiClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.response_id = Some(frame.id.clone());
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn emit_candidate(
|
||||
&self,
|
||||
parts: Vec<Value>,
|
||||
finish_reason: Option<&str>,
|
||||
usage: Option<CanonicalUsage>,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut candidate = Map::new();
|
||||
candidate.insert(
|
||||
"content".to_string(),
|
||||
json!({
|
||||
"role": "model",
|
||||
"parts": parts,
|
||||
}),
|
||||
);
|
||||
candidate.insert("index".to_string(), Value::from(0_u64));
|
||||
if let Some(finish_reason) = finish_reason {
|
||||
candidate.insert(
|
||||
"finishReason".to_string(),
|
||||
Value::String(map_openai_finish_reason_to_gemini(Some(finish_reason)).to_string()),
|
||||
);
|
||||
}
|
||||
let mut response = Map::new();
|
||||
response.insert(
|
||||
"responseId".to_string(),
|
||||
Value::String(
|
||||
self.response_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "resp-local-stream".to_string()),
|
||||
),
|
||||
);
|
||||
response.insert(
|
||||
"modelVersion".to_string(),
|
||||
Value::String(self.model.clone().unwrap_or_else(|| "unknown".to_string())),
|
||||
);
|
||||
response.insert(
|
||||
"candidates".to_string(),
|
||||
Value::Array(vec![Value::Object(candidate)]),
|
||||
);
|
||||
if let Some(usage) = usage {
|
||||
response.insert(
|
||||
"usageMetadata".to_string(),
|
||||
json!({
|
||||
"promptTokenCount": usage.input_tokens,
|
||||
"candidatesTokenCount": usage.output_tokens,
|
||||
"totalTokenCount": usage.total_tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
encode_json_sse(None, &Value::Object(response))
|
||||
}
|
||||
|
||||
fn flush_pending_tool_calls(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
for (index, tool_call) in &mut self.tool_calls {
|
||||
if tool_call.emitted {
|
||||
continue;
|
||||
}
|
||||
let args_value = parse_json_arguments_value(&tool_call.arguments)
|
||||
.unwrap_or_else(|| Value::Object(Map::new()));
|
||||
tool_call.emitted = true;
|
||||
pending.push(json!({
|
||||
"functionCall": {
|
||||
"id": if tool_call.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
tool_call.call_id.clone()
|
||||
},
|
||||
"name": if tool_call.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
tool_call.name.clone()
|
||||
},
|
||||
"args": args_value,
|
||||
}
|
||||
}));
|
||||
}
|
||||
for part in pending {
|
||||
out.extend(self.emit_candidate(vec![part], None, None)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
self.emit_candidate(vec![json!({ "text": text })], None, None)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = call_id;
|
||||
state.name = name;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let emitted_part = {
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.arguments.push_str(&arguments);
|
||||
if state.emitted {
|
||||
None
|
||||
} else {
|
||||
let args_value = parse_json_arguments_value(&state.arguments);
|
||||
args_value.map(|args_value| {
|
||||
state.emitted = true;
|
||||
json!({
|
||||
"functionCall": {
|
||||
"id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"name": if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
"args": args_value,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
let Some(part) = emitted_part else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.emit_candidate(vec![part], None, None)
|
||||
}
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage,
|
||||
} => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.flush_pending_tool_calls()?;
|
||||
out.extend(self.emit_candidate(vec![], finish_reason.as_deref(), usage)?);
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let out = self.flush_pending_tool_calls()?;
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
4
crates/aether-ai-pipeline/src/finalize/standard/mod.rs
Normal file
4
crates/aether-ai-pipeline/src/finalize/standard/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod claude;
|
||||
pub mod gemini;
|
||||
pub mod openai;
|
||||
pub mod stream_core;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod stream;
|
||||
959
crates/aether-ai-pipeline/src/finalize/standard/openai/stream.rs
Normal file
959
crates/aether-ai-pipeline/src/finalize/standard/openai/stream.rs
Normal file
@@ -0,0 +1,959 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::conversion::response::build_openai_cli_response;
|
||||
use crate::finalize::common::build_generated_tool_call_id;
|
||||
use crate::finalize::sse::{encode_done_sse, encode_json_sse};
|
||||
use crate::finalize::standard::stream_core::common::*;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAIChatProviderToolState {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIChatProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
tool_calls: BTreeMap<usize, OpenAIChatProviderToolState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAICliProviderToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
started_emitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAICliProviderState {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text: String,
|
||||
tool_calls: BTreeMap<usize, OpenAICliProviderToolState>,
|
||||
tool_index_by_key: BTreeMap<String, usize>,
|
||||
last_tool_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl OpenAIChatProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"chatcmpl-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let Some(chunk_object) = value.as_object() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.response_id = chunk_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.response_id.clone());
|
||||
self.model = chunk_object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.model.clone());
|
||||
|
||||
let mut out = Vec::new();
|
||||
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
|
||||
return Ok(out);
|
||||
};
|
||||
for chunk_choice in chunk_choices {
|
||||
let Some(choice_object) = chunk_choice.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
|
||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
||||
) {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some(finish_reason),
|
||||
usage: canonical_usage_from_openai_usage(chunk_object.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if delta.get("role").and_then(Value::as_str) == Some("assistant") {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
|
||||
if let Some(content) = delta.get("content").and_then(Value::as_str) {
|
||||
if !content.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(content.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
for tool_call in tool_calls {
|
||||
let Some(tool_call_object) = tool_call.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let index = tool_call_object
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
if let Some(call_id) = tool_call_object.get("id").and_then(Value::as_str) {
|
||||
state.id = Some(call_id.to_string());
|
||||
}
|
||||
if let Some(function) =
|
||||
tool_call_object.get("function").and_then(Value::as_object)
|
||||
{
|
||||
if let Some(name) = function.get("name").and_then(Value::as_str) {
|
||||
state.name = Some(name.to_string());
|
||||
}
|
||||
if !state.started_emitted && (state.id.is_some() || state.name.is_some()) {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: state
|
||||
.id
|
||||
.clone()
|
||||
.unwrap_or_else(|| build_generated_tool_call_id(index)),
|
||||
name: state
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
|
||||
if !arguments.is_empty() {
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: state.id.clone().unwrap_or_else(|| {
|
||||
build_generated_tool_call_id(index)
|
||||
}),
|
||||
name: state
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: arguments.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = normalize_openai_finish_reason(
|
||||
choice_object.get("finish_reason").and_then(Value::as_str),
|
||||
) {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some(finish_reason),
|
||||
usage: canonical_usage_from_openai_usage(chunk_object.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAICliProviderState {
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
self.model.as_deref(),
|
||||
report_context,
|
||||
"resp-local-stream",
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Start,
|
||||
});
|
||||
self.started = true;
|
||||
}
|
||||
|
||||
fn tool_index_for_key(&mut self, key: Option<String>, output_index: Option<usize>) -> usize {
|
||||
if let Some(output_index) = output_index {
|
||||
if let Some(key) = key.as_ref() {
|
||||
self.tool_index_by_key
|
||||
.entry(key.clone())
|
||||
.or_insert(output_index);
|
||||
}
|
||||
self.last_tool_index = Some(output_index);
|
||||
return output_index;
|
||||
}
|
||||
if let Some(key) = key.as_ref() {
|
||||
if let Some(index) = self.tool_index_by_key.get(key).copied() {
|
||||
self.last_tool_index = Some(index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
let index = self.last_tool_index.unwrap_or(self.tool_calls.len());
|
||||
if let Some(key) = key {
|
||||
self.tool_index_by_key.insert(key, index);
|
||||
}
|
||||
self.last_tool_index = Some(index);
|
||||
index
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
if let Some(response) = value.get("response").and_then(Value::as_object) {
|
||||
self.response_id = response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.response_id.clone());
|
||||
self.model = response
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.model.clone());
|
||||
}
|
||||
|
||||
match value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"response.created" => {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
"response.output_text.delta" => {
|
||||
let piece = match value.get("delta") {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Object(delta)) => delta
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
if !piece.is_empty() {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
self.text.push_str(&piece);
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::TextDelta(piece),
|
||||
});
|
||||
}
|
||||
}
|
||||
"response.output_item.added" => {
|
||||
let Some(item) = value.get("item").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
if item.get("type").and_then(Value::as_str) != Some("function_call") {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let key = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let output_index = value
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize);
|
||||
let index = self.tool_index_for_key(key.clone(), output_index);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.call_id.as_str())
|
||||
.to_string();
|
||||
state.name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.name.as_str())
|
||||
.to_string();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
name: if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
|
||||
if !arguments.is_empty() {
|
||||
state.arguments.push_str(arguments);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: arguments.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.delta" => {
|
||||
let delta = value
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if delta.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let key = value
|
||||
.get("item_id")
|
||||
.or_else(|| value.get("call_id"))
|
||||
.or_else(|| value.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let output_index = value
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as usize);
|
||||
let index = self.tool_index_for_key(key, output_index);
|
||||
let (id, model) = self.identity(report_context);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
name: if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
state.arguments.push_str(delta);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: delta.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
"response.completed" => {
|
||||
let Some(response) = value.get("response").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
};
|
||||
self.ensure_started(report_context, &mut out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
|
||||
for raw_item in response
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(item) = raw_item.as_object() else {
|
||||
continue;
|
||||
};
|
||||
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"message" => {
|
||||
let mut completed_text = String::new();
|
||||
for raw_content in item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(content) = raw_content.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if content.get("type").and_then(Value::as_str)
|
||||
== Some("output_text")
|
||||
{
|
||||
if let Some(text) = content.get("text").and_then(Value::as_str)
|
||||
{
|
||||
completed_text.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
let missing = if completed_text.starts_with(&self.text) {
|
||||
completed_text[self.text.len()..].to_string()
|
||||
} else if self.text == completed_text {
|
||||
String::new()
|
||||
} else {
|
||||
completed_text.clone()
|
||||
};
|
||||
if !missing.is_empty() {
|
||||
self.text.push_str(&missing);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::TextDelta(missing),
|
||||
});
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let key = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let index = self.tool_index_for_key(key, None);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.call_id.as_str())
|
||||
.to_string();
|
||||
state.name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.name.as_str())
|
||||
.to_string();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id: if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
name: if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
}
|
||||
let completed_arguments = item
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let missing = if completed_arguments.starts_with(&state.arguments) {
|
||||
completed_arguments[state.arguments.len()..].to_string()
|
||||
} else if state.arguments == completed_arguments {
|
||||
String::new()
|
||||
} else {
|
||||
completed_arguments.clone()
|
||||
};
|
||||
if !missing.is_empty() {
|
||||
state.arguments.push_str(&missing);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: missing,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let finish_reason = if self.tool_calls.is_empty() {
|
||||
Some("stop".to_string())
|
||||
} else {
|
||||
Some("tool_calls".to_string())
|
||||
};
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: canonical_usage_from_openai_usage(response.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.finished = true;
|
||||
let (id, model) = self.identity(report_context);
|
||||
let finish_reason = if self.tool_calls.is_empty() {
|
||||
Some("stop".to_string())
|
||||
} else {
|
||||
Some("tool_calls".to_string())
|
||||
};
|
||||
Ok(vec![CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
usage: None,
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIChatClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OpenAICliClientToolState {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAICliClientEmitter {
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
text: String,
|
||||
tool_calls: BTreeMap<usize, OpenAICliClientToolState>,
|
||||
}
|
||||
|
||||
impl OpenAIChatClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.response_id = Some(frame.id.clone());
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.started {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
Ok(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_role_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
text,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
String::new(),
|
||||
Some(vec![json!({
|
||||
"index": index,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": "",
|
||||
}
|
||||
})]),
|
||||
None,
|
||||
),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
"id": self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": index,
|
||||
"function": {
|
||||
"arguments": arguments,
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": Value::Null
|
||||
}]
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::Finish { finish_reason, .. } => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.ensure_started()?;
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_finish_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
finish_reason.as_deref(),
|
||||
),
|
||||
)?);
|
||||
out.extend(encode_done_sse());
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let out = encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_finish_chunk(
|
||||
self.response_id
|
||||
.as_deref()
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
self.finished = true;
|
||||
let mut bytes = out;
|
||||
bytes.extend(encode_done_sse());
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAICliClientEmitter {
|
||||
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
||||
self.response_id = Some(frame.id.clone().replace("chatcmpl", "resp"));
|
||||
self.model = Some(frame.model.clone());
|
||||
}
|
||||
|
||||
fn ensure_started(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if self.started {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
encode_json_sse(
|
||||
Some("response.created"),
|
||||
&json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": self.response_id.as_deref().unwrap_or("resp-local-stream"),
|
||||
"object": "response",
|
||||
"model": self.model.as_deref().unwrap_or("unknown"),
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn function_output_index(&self, index: usize) -> usize {
|
||||
if self.text.is_empty() {
|
||||
index
|
||||
} else {
|
||||
index + 1
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
CanonicalStreamEvent::Start => self.ensure_started(),
|
||||
CanonicalStreamEvent::TextDelta(text) => {
|
||||
let mut out = self.ensure_started()?;
|
||||
self.text.push_str(&text);
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.output_text.delta"),
|
||||
&json!({
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": text,
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.function_output_index(index);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = call_id.clone();
|
||||
state.name = name.clone();
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.output_item.added"),
|
||||
&json!({
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"id": call_id,
|
||||
"call_id": state.call_id,
|
||||
"name": state.name,
|
||||
"arguments": "",
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let output_index = self.function_output_index(index);
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.arguments.push_str(&arguments);
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.function_call_arguments.delta"),
|
||||
&json!({
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"output_index": output_index,
|
||||
"item_id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"delta": arguments,
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::Finish { usage, .. } => {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = self.ensure_started()?;
|
||||
let usage = usage.unwrap_or_default();
|
||||
let function_calls = self
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|(index, state)| {
|
||||
json!({
|
||||
"type": "function_call",
|
||||
"id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"call_id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"name": if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
},
|
||||
"arguments": state.arguments.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
out.extend(encode_json_sse(
|
||||
Some("response.completed"),
|
||||
&json!({
|
||||
"type": "response.completed",
|
||||
"response": build_openai_cli_response(
|
||||
self.response_id.as_deref().unwrap_or("resp-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
&self.text,
|
||||
function_calls,
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.total_tokens,
|
||||
),
|
||||
}),
|
||||
)?);
|
||||
self.finished = true;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
if !self.started || self.finished {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.emit(CanonicalStreamFrame {
|
||||
id: self
|
||||
.response_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "resp-local-stream".to_string()),
|
||||
model: self.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: None,
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanonicalUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CanonicalStreamEvent {
|
||||
Start,
|
||||
TextDelta(String),
|
||||
ToolCallStart {
|
||||
index: usize,
|
||||
call_id: String,
|
||||
name: String,
|
||||
},
|
||||
ToolCallArgumentsDelta {
|
||||
index: usize,
|
||||
arguments: String,
|
||||
},
|
||||
Finish {
|
||||
finish_reason: Option<String>,
|
||||
usage: Option<CanonicalUsage>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CanonicalStreamFrame {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub event: CanonicalStreamEvent,
|
||||
}
|
||||
|
||||
pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
|
||||
let text = std::str::from_utf8(line).ok()?;
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
return None;
|
||||
}
|
||||
let data_line = trimmed.strip_prefix("data:")?.trim();
|
||||
if data_line.is_empty() || data_line == "[DONE]" {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(data_line).ok()
|
||||
}
|
||||
|
||||
pub fn resolve_identity(
|
||||
response_id: Option<&str>,
|
||||
model: Option<&str>,
|
||||
report_context: &Value,
|
||||
default_id: &str,
|
||||
) -> (String, String) {
|
||||
let id = response_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(default_id)
|
||||
.to_string();
|
||||
let model = model
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| report_context.get("mapped_model").and_then(Value::as_str))
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
(id, model)
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let 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 total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(input_tokens + output_tokens);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
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);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens: input_tokens + output_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn canonical_usage_from_gemini_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||
let usage = value?.as_object()?;
|
||||
let input_tokens = usage
|
||||
.get("promptTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let output_tokens = usage
|
||||
.get("candidatesTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.get("totalTokenCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(input_tokens + output_tokens);
|
||||
Some(CanonicalUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::finalize::standard::claude::stream::{ClaudeClientEmitter, ClaudeProviderState};
|
||||
use crate::finalize::standard::gemini::stream::{GeminiClientEmitter, GeminiProviderState};
|
||||
use crate::finalize::standard::openai::stream::{
|
||||
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAICliClientEmitter,
|
||||
OpenAICliProviderState,
|
||||
};
|
||||
use crate::finalize::standard::stream_core::common::CanonicalStreamFrame;
|
||||
use crate::finalize::PipelineFinalizeError;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StreamingStandardFormatMatrix {
|
||||
provider: Option<ProviderStreamParser>,
|
||||
client: Option<ClientStreamEmitter>,
|
||||
}
|
||||
|
||||
impl StreamingStandardFormatMatrix {
|
||||
pub fn transform_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.push_line(report_context, line)?;
|
||||
self.emit_frames(frames)
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let frames = provider.finish(report_context)?;
|
||||
let mut out = self.emit_frames(frames)?;
|
||||
if let Some(client) = self.client.as_mut() {
|
||||
out.extend(client.finish()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ensure_initialized(&mut self, report_context: &Value) {
|
||||
if self.provider.is_some() && self.client.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let client_api_format = report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
|
||||
self.client = ClientStreamEmitter::for_api_format(client_api_format.as_str());
|
||||
}
|
||||
|
||||
fn emit_frames(
|
||||
&mut self,
|
||||
frames: Vec<CanonicalStreamFrame>,
|
||||
) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
let Some(client) = self.client.as_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for frame in frames {
|
||||
out.extend(client.emit(frame)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderStreamParser {
|
||||
OpenAIChat(OpenAIChatProviderState),
|
||||
OpenAICli(OpenAICliProviderState),
|
||||
Claude(ClaudeProviderState),
|
||||
Gemini(GeminiProviderState),
|
||||
}
|
||||
|
||||
impl ProviderStreamParser {
|
||||
fn for_api_format(provider_api_format: &str) -> Option<Self> {
|
||||
Some(match provider_api_format {
|
||||
"openai:chat" => Self::OpenAIChat(OpenAIChatProviderState::default()),
|
||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliProviderState::default()),
|
||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeProviderState::default()),
|
||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiProviderState::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::OpenAICli(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::Claude(state) => state.push_line(report_context, line),
|
||||
ProviderStreamParser::Gemini(state) => state.push_line(report_context, line),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIChat(state) => state.finish(report_context),
|
||||
ProviderStreamParser::OpenAICli(state) => state.finish(report_context),
|
||||
ProviderStreamParser::Claude(state) => state.finish(report_context),
|
||||
ProviderStreamParser::Gemini(state) => state.finish(report_context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientStreamEmitter {
|
||||
OpenAIChat(OpenAIChatClientEmitter),
|
||||
OpenAICli(OpenAICliClientEmitter),
|
||||
Claude(ClaudeClientEmitter),
|
||||
Gemini(GeminiClientEmitter),
|
||||
}
|
||||
|
||||
impl ClientStreamEmitter {
|
||||
fn for_api_format(client_api_format: &str) -> Option<Self> {
|
||||
Some(match client_api_format {
|
||||
"openai:chat" => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
|
||||
"openai:cli" | "openai:compact" => Self::OpenAICli(OpenAICliClientEmitter::default()),
|
||||
"claude:chat" | "claude:cli" => Self::Claude(ClaudeClientEmitter::default()),
|
||||
"gemini:chat" | "gemini:cli" => Self::Gemini(GeminiClientEmitter::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.emit(frame),
|
||||
ClientStreamEmitter::OpenAICli(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Claude(state) => state.emit(frame),
|
||||
ClientStreamEmitter::Gemini(state) => state.emit(frame),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
match self {
|
||||
ClientStreamEmitter::OpenAIChat(state) => state.finish(),
|
||||
ClientStreamEmitter::OpenAICli(state) => state.finish(),
|
||||
ClientStreamEmitter::Claude(state) => state.finish(),
|
||||
ClientStreamEmitter::Gemini(state) => state.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod common;
|
||||
pub mod format_matrix;
|
||||
|
||||
pub use common::{CanonicalStreamEvent, CanonicalStreamFrame};
|
||||
pub use format_matrix::StreamingStandardFormatMatrix;
|
||||
147
crates/aether-ai-pipeline/src/finalize/stream_rewrite.rs
Normal file
147
crates/aether-ai-pipeline/src/finalize/stream_rewrite.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::adaptation::surfaces::{
|
||||
provider_adaptation_should_unwrap_stream_envelope, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FinalizeStreamRewriteMode {
|
||||
EnvelopeUnwrap,
|
||||
Standard,
|
||||
KiroToClaudeCli,
|
||||
}
|
||||
|
||||
pub fn resolve_finalize_stream_rewrite_mode(
|
||||
report_context: &Value,
|
||||
) -> Option<FinalizeStreamRewriteMode> {
|
||||
let needs_conversion = report_context
|
||||
.get("needs_conversion")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let provider_api_format = report_context
|
||||
.get("provider_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let client_api_format = report_context
|
||||
.get("client_api_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if needs_conversion {
|
||||
return supports_standard_stream_rewrite(
|
||||
provider_api_format.as_str(),
|
||||
client_api_format.as_str(),
|
||||
)
|
||||
.then_some(FinalizeStreamRewriteMode::Standard);
|
||||
}
|
||||
|
||||
if envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME) {
|
||||
return (provider_api_format == "claude:cli" && client_api_format == "claude:cli")
|
||||
.then_some(FinalizeStreamRewriteMode::KiroToClaudeCli);
|
||||
}
|
||||
|
||||
(provider_api_format == client_api_format
|
||||
&& provider_adaptation_should_unwrap_stream_envelope(
|
||||
envelope_name.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
))
|
||||
.then_some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
|
||||
}
|
||||
|
||||
fn supports_standard_stream_rewrite(provider_api_format: &str, client_api_format: &str) -> bool {
|
||||
is_standard_provider_api_format(provider_api_format)
|
||||
&& (is_standard_chat_client_api_format(client_api_format)
|
||||
|| is_standard_cli_client_api_format(client_api_format))
|
||||
}
|
||||
|
||||
fn is_standard_provider_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:chat"
|
||||
| "openai:cli"
|
||||
| "openai:compact"
|
||||
| "claude:chat"
|
||||
| "claude:cli"
|
||||
| "gemini:chat"
|
||||
| "gemini:cli"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_standard_chat_client_api_format(api_format: &str) -> bool {
|
||||
matches!(api_format, "openai:chat" | "claude:chat" | "gemini:chat")
|
||||
}
|
||||
|
||||
fn is_standard_cli_client_api_format(api_format: &str) -> bool {
|
||||
matches!(
|
||||
api_format,
|
||||
"openai:cli" | "openai:compact" | "claude:cli" | "gemini:cli"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{resolve_finalize_stream_rewrite_mode, FinalizeStreamRewriteMode};
|
||||
|
||||
#[test]
|
||||
fn resolves_standard_mode_for_cross_format_standard_streams() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:chat",
|
||||
"client_api_format": "openai:chat",
|
||||
"needs_conversion": true,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::Standard)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_envelope_unwrap_for_same_format_private_envelopes() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "gemini:cli",
|
||||
"client_api_format": "gemini:cli",
|
||||
"envelope_name": "antigravity:v1internal",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_kiro_same_format_streams_to_kiro_mode() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:cli",
|
||||
"client_api_format": "claude:cli",
|
||||
"envelope_name": "kiro:generateAssistantResponse",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(
|
||||
resolve_finalize_stream_rewrite_mode(&report_context),
|
||||
Some(FinalizeStreamRewriteMode::KiroToClaudeCli)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_non_conversion_streams() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "openai:chat",
|
||||
"needs_conversion": false,
|
||||
});
|
||||
assert_eq!(resolve_finalize_stream_rewrite_mode(&report_context), None);
|
||||
}
|
||||
}
|
||||
1869
crates/aether-ai-pipeline/src/finalize/sync_products.rs
Normal file
1869
crates/aether-ai-pipeline/src/finalize/sync_products.rs
Normal file
File diff suppressed because it is too large
Load Diff
5
crates/aether-ai-pipeline/src/lib.rs
Normal file
5
crates/aether-ai-pipeline/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod adaptation;
|
||||
pub mod contracts;
|
||||
pub mod conversion;
|
||||
pub mod finalize;
|
||||
pub mod planner;
|
||||
48
crates/aether-ai-pipeline/src/planner/common.rs
Normal file
48
crates/aether-ai-pipeline/src/planner/common.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use base64::Engine as _;
|
||||
|
||||
pub fn parse_direct_request_body(
|
||||
is_json_request: bool,
|
||||
body_bytes: &[u8],
|
||||
) -> Option<(serde_json::Value, Option<String>)> {
|
||||
if is_json_request {
|
||||
if body_bytes.is_empty() {
|
||||
Some((serde_json::json!({}), None))
|
||||
} else {
|
||||
serde_json::from_slice::<serde_json::Value>(body_bytes)
|
||||
.ok()
|
||||
.map(|value| (value, None))
|
||||
}
|
||||
} else {
|
||||
Some((
|
||||
serde_json::json!({}),
|
||||
(!body_bytes.is_empty())
|
||||
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_direct_request_body;
|
||||
|
||||
#[test]
|
||||
fn parses_empty_json_body_as_empty_object() {
|
||||
assert_eq!(
|
||||
parse_direct_request_body(true, b""),
|
||||
Some((serde_json::json!({}), None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_json_body() {
|
||||
assert_eq!(parse_direct_request_body(true, b"{invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_non_json_body_as_base64() {
|
||||
assert_eq!(
|
||||
parse_direct_request_body(false, b"hello"),
|
||||
Some((serde_json::json!({}), Some("aGVsbG8=".to_string())))
|
||||
);
|
||||
}
|
||||
}
|
||||
1
crates/aether-ai-pipeline/src/planner/matrix.rs
Normal file
1
crates/aether-ai-pipeline/src/planner/matrix.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use crate::planner::standard::matrix::build_standard_request_body_from_canonical;
|
||||
7
crates/aether-ai-pipeline/src/planner/mod.rs
Normal file
7
crates/aether-ai-pipeline/src/planner/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod common;
|
||||
pub mod matrix;
|
||||
pub mod openai;
|
||||
pub mod passthrough;
|
||||
pub mod route;
|
||||
pub mod specialized;
|
||||
pub mod standard;
|
||||
104
crates/aether-ai-pipeline/src/planner/openai.rs
Normal file
104
crates/aether-ai-pipeline/src/planner/openai.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
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> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"xhigh" => Some("max"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_thinking_budget(value: &str) -> Option<u64> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some(1280),
|
||||
"medium" => Some(2048),
|
||||
"high" => Some(4096),
|
||||
"xhigh" => Some(8192),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
1
crates/aether-ai-pipeline/src/planner/passthrough/mod.rs
Normal file
1
crates/aether-ai-pipeline/src/planner/passthrough/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod provider;
|
||||
109
crates/aether-ai-pipeline/src/planner/passthrough/provider.rs
Normal file
109
crates/aether-ai-pipeline/src/planner/passthrough/provider.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use crate::contracts::{
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalSameFormatProviderFamily {
|
||||
Standard,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalSameFormatProviderSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalSameFormatProviderFamily,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:chat",
|
||||
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:cli",
|
||||
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:chat",
|
||||
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:cli",
|
||||
decision_kind: GEMINI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_cli_sync_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalSameFormatProviderSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND => Some(LocalSameFormatProviderSpec {
|
||||
api_format: "claude:chat",
|
||||
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:cli",
|
||||
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:chat",
|
||||
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:cli",
|
||||
decision_kind: GEMINI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "gemini_cli_stream_success",
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_sync_same_format_spec() {
|
||||
let spec = resolve_sync_spec("claude_chat_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "claude:chat");
|
||||
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:cli");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
356
crates/aether-ai-pipeline/src/planner/route.rs
Normal file
356
crates/aether-ai-pipeline/src/planner/route.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
use http::Method;
|
||||
|
||||
use crate::contracts::{
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_COMPACT_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 fn resolve_execution_runtime_stream_plan_kind(
|
||||
route_class: Option<&str>,
|
||||
route_family: Option<&str>,
|
||||
route_kind: Option<&str>,
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Option<&'static str> {
|
||||
if route_class != Some("ai_public") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("files")
|
||||
&& *method == Method::GET
|
||||
&& path.ends_with(":download")
|
||||
{
|
||||
return Some(GEMINI_FILES_DOWNLOAD_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/chat/completions"
|
||||
{
|
||||
return Some(OPENAI_CHAT_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("claude")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/messages"
|
||||
{
|
||||
return Some(CLAUDE_CHAT_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("claude")
|
||||
&& route_kind == Some("cli")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/messages"
|
||||
{
|
||||
return Some(CLAUDE_CLI_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":streamGenerateContent")
|
||||
{
|
||||
return Some(GEMINI_CHAT_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("cli")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":streamGenerateContent")
|
||||
{
|
||||
return Some(GEMINI_CLI_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("cli")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses"
|
||||
{
|
||||
return Some(OPENAI_CLI_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("compact")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses/compact"
|
||||
{
|
||||
return Some(OPENAI_COMPACT_STREAM_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::GET
|
||||
&& path.ends_with("/content")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_CONTENT_PLAN_KIND);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn resolve_execution_runtime_sync_plan_kind(
|
||||
route_class: Option<&str>,
|
||||
route_family: Option<&str>,
|
||||
route_kind: Option<&str>,
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Option<&'static str> {
|
||||
if route_class != Some("ai_public") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.starts_with("/v1/videos/")
|
||||
&& path.ends_with("/cancel")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.starts_with("/v1/videos/")
|
||||
&& path.ends_with("/remix")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/videos"
|
||||
{
|
||||
return Some(OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::DELETE
|
||||
&& path.starts_with("/v1/videos/")
|
||||
{
|
||||
return Some(OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":cancel")
|
||||
{
|
||||
return Some(GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":predictLongRunning")
|
||||
{
|
||||
return Some(GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/chat/completions"
|
||||
{
|
||||
return Some(OPENAI_CHAT_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("cli")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses"
|
||||
{
|
||||
return Some(OPENAI_CLI_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("compact")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/responses/compact"
|
||||
{
|
||||
return Some(OPENAI_COMPACT_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("claude")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/messages"
|
||||
{
|
||||
return Some(CLAUDE_CHAT_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("claude")
|
||||
&& route_kind == Some("cli")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/messages"
|
||||
{
|
||||
return Some(CLAUDE_CLI_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("chat")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":generateContent")
|
||||
{
|
||||
return Some(GEMINI_CHAT_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini")
|
||||
&& route_kind == Some("cli")
|
||||
&& *method == Method::POST
|
||||
&& path.ends_with(":generateContent")
|
||||
{
|
||||
return Some(GEMINI_CLI_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
if route_family == Some("gemini") && route_kind == Some("files") {
|
||||
if *method == Method::POST && path == "/upload/v1beta/files" {
|
||||
return Some(GEMINI_FILES_UPLOAD_PLAN_KIND);
|
||||
}
|
||||
if *method == Method::GET && path == "/v1beta/files" {
|
||||
return Some(GEMINI_FILES_LIST_PLAN_KIND);
|
||||
}
|
||||
if *method == Method::GET
|
||||
&& path.starts_with("/v1beta/files/")
|
||||
&& !path.ends_with(":download")
|
||||
{
|
||||
return Some(GEMINI_FILES_GET_PLAN_KIND);
|
||||
}
|
||||
if *method == Method::DELETE
|
||||
&& path.starts_with("/v1beta/files/")
|
||||
&& !path.ends_with(":download")
|
||||
{
|
||||
return Some(GEMINI_FILES_DELETE_PLAN_KIND);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_matching_stream_request(
|
||||
plan_kind: &str,
|
||||
path: &str,
|
||||
body_json: &serde_json::Value,
|
||||
) -> bool {
|
||||
match plan_kind {
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CHAT_STREAM_PLAN_KIND
|
||||
| OPENAI_CLI_STREAM_PLAN_KIND
|
||||
| OPENAI_COMPACT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CLI_STREAM_PLAN_KIND => body_json
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false),
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
|
||||
path.ends_with(":streamGenerateContent")
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_sync_scheduler_decision_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND
|
||||
| OPENAI_CLI_SYNC_PLAN_KIND
|
||||
| OPENAI_COMPACT_SYNC_PLAN_KIND
|
||||
| CLAUDE_CHAT_SYNC_PLAN_KIND
|
||||
| CLAUDE_CLI_SYNC_PLAN_KIND
|
||||
| GEMINI_CHAT_SYNC_PLAN_KIND
|
||||
| GEMINI_CLI_SYNC_PLAN_KIND
|
||||
| GEMINI_FILES_UPLOAD_PLAN_KIND
|
||||
| OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
| GEMINI_FILES_GET_PLAN_KIND
|
||||
| GEMINI_FILES_LIST_PLAN_KIND
|
||||
| GEMINI_FILES_DELETE_PLAN_KIND
|
||||
)
|
||||
}
|
||||
|
||||
pub fn supports_stream_scheduler_decision_kind(plan_kind: &str) -> bool {
|
||||
matches!(
|
||||
plan_kind,
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CHAT_STREAM_PLAN_KIND
|
||||
| GEMINI_CHAT_STREAM_PLAN_KIND
|
||||
| OPENAI_CLI_STREAM_PLAN_KIND
|
||||
| OPENAI_COMPACT_STREAM_PLAN_KIND
|
||||
| CLAUDE_CLI_STREAM_PLAN_KIND
|
||||
| GEMINI_CLI_STREAM_PLAN_KIND
|
||||
| GEMINI_FILES_DOWNLOAD_PLAN_KIND
|
||||
| OPENAI_VIDEO_CONTENT_PLAN_KIND
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::Method;
|
||||
|
||||
use super::{
|
||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind, supports_stream_scheduler_decision_kind,
|
||||
supports_sync_scheduler_decision_kind,
|
||||
};
|
||||
use crate::contracts::{OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_chat_plan_kinds() {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_sync_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("chat"),
|
||||
&Method::POST,
|
||||
"/v1/chat/completions",
|
||||
),
|
||||
Some(OPENAI_CHAT_SYNC_PLAN_KIND)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
Some("ai_public"),
|
||||
Some("openai"),
|
||||
Some("chat"),
|
||||
&Method::POST,
|
||||
"/v1/chat/completions",
|
||||
),
|
||||
Some(OPENAI_CHAT_STREAM_PLAN_KIND)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_matching_requires_openai_stream_flag() {
|
||||
assert!(!is_matching_stream_request(
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
"/v1/chat/completions",
|
||||
&serde_json::json!({"stream": false}),
|
||||
));
|
||||
assert!(is_matching_stream_request(
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
"/v1/chat/completions",
|
||||
&serde_json::json!({"stream": true}),
|
||||
));
|
||||
assert!(supports_sync_scheduler_decision_kind(
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND
|
||||
));
|
||||
assert!(supports_stream_scheduler_decision_kind(
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND
|
||||
));
|
||||
}
|
||||
}
|
||||
69
crates/aether-ai-pipeline/src/planner/specialized/files.rs
Normal file
69
crates/aether-ai-pipeline/src/planner/specialized/files.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use crate::contracts::{
|
||||
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
|
||||
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalGeminiFilesSpec {
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: Option<&'static str>,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_FILES_UPLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_UPLOAD_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_LIST_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_LIST_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_GET_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_GET_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_store_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
GEMINI_FILES_DELETE_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_DELETE_PLAN_KIND,
|
||||
report_kind: Some("gemini_files_delete_mapping"),
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalGeminiFilesSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_FILES_DOWNLOAD_PLAN_KIND => Some(LocalGeminiFilesSpec {
|
||||
decision_kind: GEMINI_FILES_DOWNLOAD_PLAN_KIND,
|
||||
report_kind: None,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_sync_gemini_files_specs() {
|
||||
let spec = resolve_sync_spec("gemini_files_upload").expect("spec");
|
||||
assert_eq!(spec.decision_kind, "gemini_files_upload");
|
||||
assert_eq!(spec.report_kind, Some("gemini_files_store_mapping"));
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_stream_gemini_files_spec() {
|
||||
let spec = resolve_stream_spec("gemini_files_download").expect("spec");
|
||||
assert_eq!(spec.decision_kind, "gemini_files_download");
|
||||
assert_eq!(spec.report_kind, None);
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
2
crates/aether-ai-pipeline/src/planner/specialized/mod.rs
Normal file
2
crates/aether-ai-pipeline/src/planner/specialized/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod files;
|
||||
pub mod video;
|
||||
54
crates/aether-ai-pipeline/src/planner/specialized/video.rs
Normal file
54
crates/aether-ai-pipeline/src/planner/specialized/video.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::contracts::{GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalVideoCreateFamily {
|
||||
OpenAi,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalVideoCreateSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalVideoCreateFamily,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalVideoCreateSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
||||
api_format: "openai:video",
|
||||
decision_kind: OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_video_create_sync_finalize",
|
||||
family: LocalVideoCreateFamily::OpenAi,
|
||||
}),
|
||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND => Some(LocalVideoCreateSpec {
|
||||
api_format: "gemini:video",
|
||||
decision_kind: GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||
report_kind: "gemini_video_create_sync_finalize",
|
||||
family: LocalVideoCreateFamily::Gemini,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_sync_spec, LocalVideoCreateFamily};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_video_create_spec() {
|
||||
let spec = resolve_sync_spec("openai_video_create_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:video");
|
||||
assert_eq!(spec.family, LocalVideoCreateFamily::OpenAi);
|
||||
assert_eq!(spec.report_kind, "openai_video_create_sync_finalize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gemini_video_create_spec() {
|
||||
let spec = resolve_sync_spec("gemini_video_create_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "gemini:video");
|
||||
assert_eq!(spec.family, LocalVideoCreateFamily::Gemini);
|
||||
assert_eq!(spec.report_kind, "gemini_video_create_sync_finalize");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND};
|
||||
use crate::planner::standard::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:chat",
|
||||
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:chat",
|
||||
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:chat");
|
||||
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:chat");
|
||||
assert_eq!(spec.report_kind, "claude_chat_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
53
crates/aether-ai-pipeline/src/planner/standard/claude/cli.rs
Normal file
53
crates/aether-ai-pipeline/src/planner/standard/claude/cli.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND};
|
||||
use crate::planner::standard::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
CLAUDE_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "claude:cli",
|
||||
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:cli",
|
||||
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:cli");
|
||||
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:cli");
|
||||
assert_eq!(spec.report_kind, "claude_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
12
crates/aether-ai-pipeline/src/planner/standard/claude/mod.rs
Normal file
12
crates/aether-ai-pipeline/src/planner/standard/claude/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod chat;
|
||||
pub mod cli;
|
||||
|
||||
use crate::planner::standard::LocalStandardSpec;
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
|
||||
}
|
||||
21
crates/aether-ai-pipeline/src/planner/standard/family.rs
Normal file
21
crates/aether-ai-pipeline/src/planner/standard/family.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalStandardSourceFamily {
|
||||
Standard,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LocalStandardSourceMode {
|
||||
Chat,
|
||||
Cli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalStandardSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub family: LocalStandardSourceFamily,
|
||||
pub mode: LocalStandardSourceMode,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND};
|
||||
use crate::planner::standard::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CHAT_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:chat",
|
||||
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:chat",
|
||||
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:chat");
|
||||
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:chat");
|
||||
assert_eq!(spec.report_kind, "gemini_chat_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
53
crates/aether-ai-pipeline/src/planner/standard/gemini/cli.rs
Normal file
53
crates/aether-ai-pipeline/src/planner/standard/gemini/cli.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::contracts::{GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND};
|
||||
use crate::planner::standard::family::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
match plan_kind {
|
||||
GEMINI_CLI_SYNC_PLAN_KIND => Some(LocalStandardSpec {
|
||||
api_format: "gemini:cli",
|
||||
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:cli",
|
||||
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:cli");
|
||||
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:cli");
|
||||
assert_eq!(spec.report_kind, "gemini_cli_stream_success");
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
12
crates/aether-ai-pipeline/src/planner/standard/gemini/mod.rs
Normal file
12
crates/aether-ai-pipeline/src/planner/standard/gemini/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod chat;
|
||||
pub mod cli;
|
||||
|
||||
use crate::planner::standard::LocalStandardSpec;
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat::resolve_sync_spec(plan_kind).or_else(|| cli::resolve_sync_spec(plan_kind))
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalStandardSpec> {
|
||||
chat::resolve_stream_spec(plan_kind).or_else(|| cli::resolve_stream_spec(plan_kind))
|
||||
}
|
||||
376
crates/aether-ai-pipeline/src/planner/standard/matrix.rs
Normal file
376
crates/aether-ai-pipeline/src/planner/standard/matrix.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_cli_request,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
normalize_gemini_request_to_openai_chat_request,
|
||||
normalize_openai_cli_request_to_openai_chat_request,
|
||||
};
|
||||
pub fn build_standard_request_body(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
request_path: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let canonical_request = normalize_standard_request_to_openai_chat_request(
|
||||
body_json,
|
||||
client_api_format,
|
||||
request_path,
|
||||
)?;
|
||||
build_standard_request_body_from_canonical(
|
||||
&canonical_request,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_standard_request_body_from_canonical(
|
||||
canonical_request: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
match provider_api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" => {
|
||||
build_openai_chat_request_body(canonical_request, mapped_model, upstream_is_stream)
|
||||
}
|
||||
"openai:cli" => convert_openai_chat_request_to_openai_cli_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
),
|
||||
"openai:compact" => convert_openai_chat_request_to_openai_cli_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
"claude:chat" | "claude:cli" => convert_openai_chat_request_to_claude_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
"gemini:chat" | "gemini:cli" => convert_openai_chat_request_to_gemini_request(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_standard_request_to_openai_chat_request(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
request_path: &str,
|
||||
) -> Option<Value> {
|
||||
match client_api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" => Some(body_json.clone()),
|
||||
"openai:cli" | "openai:compact" => {
|
||||
normalize_openai_cli_request_to_openai_chat_request(body_json)
|
||||
}
|
||||
"claude:chat" | "claude:cli" => normalize_claude_request_to_openai_chat_request(body_json),
|
||||
"gemini:chat" | "gemini:cli" => {
|
||||
normalize_gemini_request_to_openai_chat_request(body_json, request_path)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if upstream_is_stream {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
Some(Value::Object(provider_request_body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_standard_request_body;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_request_from_claude_chat_source() {
|
||||
let request = json!({
|
||||
"model": "claude-3-7-sonnet",
|
||||
"system": "You are concise.",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello from Claude"}]
|
||||
}
|
||||
],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:chat",
|
||||
"gpt-5",
|
||||
"openai:chat",
|
||||
"/v1/messages",
|
||||
false,
|
||||
)
|
||||
.expect("claude chat should convert to openai chat");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-5");
|
||||
assert_eq!(converted["messages"][0]["role"], "system");
|
||||
assert_eq!(converted["messages"][0]["content"], "You are concise.");
|
||||
assert_eq!(converted["messages"][1]["role"], "user");
|
||||
assert_eq!(converted["messages"][1]["content"], "Hello from Claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_claude_chat_request_from_gemini_chat_source() {
|
||||
let request = json!({
|
||||
"systemInstruction": {
|
||||
"parts": [{"text": "Be brief."}]
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello from Gemini"}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"gemini:chat",
|
||||
"claude-sonnet-4-5",
|
||||
"claude:chat",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
false,
|
||||
)
|
||||
.expect("gemini chat should convert to claude chat");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
assert!(
|
||||
converted["messages"]
|
||||
.to_string()
|
||||
.contains("Hello from Gemini"),
|
||||
"converted claude payload should retain the gemini user text: {converted}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_gemini_cli_request_from_claude_cli_source() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Need CLI output"}]
|
||||
}
|
||||
],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:cli",
|
||||
"gemini-2.5-pro",
|
||||
"gemini:cli",
|
||||
"/v1/messages",
|
||||
false,
|
||||
)
|
||||
.expect("claude cli should convert to gemini cli");
|
||||
|
||||
assert_eq!(converted["contents"][0]["role"], "user");
|
||||
assert_eq!(
|
||||
converted["contents"][0]["parts"][0]["text"],
|
||||
"Need CLI output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_request_from_openai_responses_source_with_chat_shape() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"instructions": "You are concise.",
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "https://example.com/cat.png",
|
||||
"detail": "high"
|
||||
},
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0x",
|
||||
"filename": "spec.pdf"
|
||||
},
|
||||
{"type": "input_text", "text": "Summarize this"}
|
||||
]
|
||||
}],
|
||||
"reasoning": {"effort": "high"},
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "answer_schema",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:cli",
|
||||
"gpt-5",
|
||||
"openai:chat",
|
||||
"/v1/responses",
|
||||
false,
|
||||
)
|
||||
.expect("responses request should convert to chat completions");
|
||||
|
||||
assert_eq!(converted["messages"][0]["role"], "system");
|
||||
assert_eq!(converted["messages"][0]["content"], "You are concise.");
|
||||
assert_eq!(converted["reasoning_effort"], "high");
|
||||
assert_eq!(
|
||||
converted["response_format"]["json_schema"]["name"],
|
||||
"answer_schema"
|
||||
);
|
||||
assert_eq!(converted["messages"][1]["content"][0]["type"], "image_url");
|
||||
assert_eq!(
|
||||
converted["messages"][1]["content"][0]["image_url"]["url"],
|
||||
"https://example.com/cat.png"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["messages"][1]["content"][0]["image_url"]["detail"],
|
||||
"high"
|
||||
);
|
||||
assert_eq!(converted["messages"][1]["content"][1]["type"], "file");
|
||||
assert_eq!(
|
||||
converted["messages"][1]["content"][1]["file"]["filename"],
|
||||
"spec.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_gemini_request_from_openai_chat_with_structured_output_and_images() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,iVBORw0KGgo="
|
||||
}
|
||||
},
|
||||
{"type": "text", "text": "Describe it"}
|
||||
]
|
||||
}],
|
||||
"reasoning_effort": "medium",
|
||||
"n": 2,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "answer_schema",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"web_search_options": {
|
||||
"search_context_size": "high"
|
||||
}
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:chat",
|
||||
"gemini-2.5-pro",
|
||||
"gemini:chat",
|
||||
"/v1/chat/completions",
|
||||
false,
|
||||
)
|
||||
.expect("openai chat should convert to gemini");
|
||||
|
||||
assert_eq!(
|
||||
converted["generationConfig"]["thinkingConfig"]["thinkingBudget"],
|
||||
2048
|
||||
);
|
||||
assert_eq!(converted["generationConfig"]["candidateCount"], 2);
|
||||
assert_eq!(
|
||||
converted["generationConfig"]["responseMimeType"],
|
||||
"application/json"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["generationConfig"]["responseSchema"]["type"],
|
||||
"object"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["contents"][0]["parts"][0]["inlineData"]["mimeType"],
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(converted["tools"][0]["googleSearch"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_claude_request_from_openai_chat_with_thinking_and_data_url_image() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/jpeg;base64,/9j/4AAQSk"
|
||||
}
|
||||
},
|
||||
{"type": "text", "text": "What is this?"}
|
||||
]
|
||||
}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:chat",
|
||||
"claude-sonnet-4-5",
|
||||
"claude:chat",
|
||||
"/v1/chat/completions",
|
||||
false,
|
||||
)
|
||||
.expect("openai chat should convert to claude");
|
||||
|
||||
assert_eq!(converted["thinking"]["type"], "enabled");
|
||||
assert_eq!(converted["thinking"]["budget_tokens"], 1280);
|
||||
assert_eq!(
|
||||
converted["messages"][0]["content"][0]["source"]["type"],
|
||||
"base64"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["messages"][0]["content"][0]["source"]["media_type"],
|
||||
"image/jpeg"
|
||||
);
|
||||
}
|
||||
}
|
||||
13
crates/aether-ai-pipeline/src/planner/standard/mod.rs
Normal file
13
crates/aether-ai-pipeline/src/planner/standard/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod claude;
|
||||
pub mod family;
|
||||
pub mod gemini;
|
||||
pub mod matrix;
|
||||
pub mod normalize;
|
||||
pub mod openai_cli;
|
||||
|
||||
pub use family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
|
||||
pub use matrix::{build_standard_request_body, normalize_standard_request_to_openai_chat_request};
|
||||
pub use normalize::{
|
||||
build_cross_format_openai_chat_request_body, build_cross_format_openai_cli_request_body,
|
||||
build_local_openai_chat_request_body, build_local_openai_cli_request_body,
|
||||
};
|
||||
144
crates/aether-ai-pipeline/src/planner/standard/normalize.rs
Normal file
144
crates/aether-ai-pipeline/src/planner/standard/normalize.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_cli_request,
|
||||
normalize_openai_cli_request_to_openai_chat_request,
|
||||
};
|
||||
use crate::conversion::{request_conversion_kind, RequestConversionKind};
|
||||
|
||||
pub fn build_local_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if upstream_is_stream {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
Some(Value::Object(provider_request_body))
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
|
||||
match conversion_kind {
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
RequestConversionKind::ToOpenAIFamilyCli => {
|
||||
convert_openai_chat_request_to_openai_cli_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
RequestConversionKind::ToOpenAICompact => {
|
||||
convert_openai_chat_request_to_openai_cli_request(body_json, mapped_model, false, true)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_local_openai_cli_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
provider_request_body.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
||||
if require_streaming {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
Some(Value::Object(provider_request_body))
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_cli_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
let chat_like_request = normalize_openai_cli_request_to_openai_chat_request(body_json)?;
|
||||
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
|
||||
match conversion_kind {
|
||||
RequestConversionKind::ToOpenAIFamilyCli => {
|
||||
convert_openai_chat_request_to_openai_cli_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
RequestConversionKind::ToOpenAICompact => {
|
||||
convert_openai_chat_request_to_openai_cli_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
}
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_cross_format_openai_cli_request_body;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn builds_openai_family_cross_format_request_body_from_compact_source() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_cli_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:compact",
|
||||
"openai:cli",
|
||||
false,
|
||||
)
|
||||
.expect("compact to openai cli body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["input"][0]["type"], "message");
|
||||
assert_eq!(provider_request_body["input"][0]["role"], "user");
|
||||
}
|
||||
}
|
||||
76
crates/aether-ai-pipeline/src/planner/standard/openai_cli.rs
Normal file
76
crates/aether-ai-pipeline/src/planner/standard/openai_cli.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::contracts::{
|
||||
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LocalOpenAiCliSpec {
|
||||
pub api_format: &'static str,
|
||||
pub decision_kind: &'static str,
|
||||
pub report_kind: &'static str,
|
||||
pub compact: bool,
|
||||
pub require_streaming: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_CLI_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:cli",
|
||||
decision_kind: OPENAI_CLI_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_cli_sync_success",
|
||||
compact: false,
|
||||
require_streaming: false,
|
||||
}),
|
||||
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:compact",
|
||||
decision_kind: OPENAI_COMPACT_SYNC_PLAN_KIND,
|
||||
report_kind: "openai_cli_sync_success",
|
||||
compact: true,
|
||||
require_streaming: false,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiCliSpec> {
|
||||
match plan_kind {
|
||||
OPENAI_CLI_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:cli",
|
||||
decision_kind: OPENAI_CLI_STREAM_PLAN_KIND,
|
||||
report_kind: "openai_cli_stream_success",
|
||||
compact: false,
|
||||
require_streaming: true,
|
||||
}),
|
||||
OPENAI_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiCliSpec {
|
||||
api_format: "openai:compact",
|
||||
decision_kind: OPENAI_COMPACT_STREAM_PLAN_KIND,
|
||||
report_kind: "openai_cli_stream_success",
|
||||
compact: true,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_stream_spec, resolve_sync_spec};
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_cli_sync_spec() {
|
||||
let spec = resolve_sync_spec("openai_cli_sync").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:cli");
|
||||
assert_eq!(spec.report_kind, "openai_cli_sync_success");
|
||||
assert!(!spec.compact);
|
||||
assert!(!spec.require_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_compact_stream_spec() {
|
||||
let spec = resolve_stream_spec("openai_compact_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:compact");
|
||||
assert_eq!(spec.report_kind, "openai_cli_stream_success");
|
||||
assert!(spec.compact);
|
||||
assert!(spec.require_streaming);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ repository.workspace = true
|
||||
description = "Shared billing domain core for Aether Rust migration"
|
||||
|
||||
[dependencies]
|
||||
aether-data.workspace = true
|
||||
aether-data-contracts.workspace = true
|
||||
aether-usage-runtime.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use aether_data::repository::billing::StoredBillingModelContext;
|
||||
use aether_data::DataLayerError;
|
||||
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use aether_usage_runtime::{UsageEvent, UsageEventType};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Map, Value};
|
||||
@@ -136,7 +136,7 @@ fn merge_billing_snapshot_metadata(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_data::repository::billing::StoredBillingModelContext;
|
||||
use aether_data_contracts::repository::billing::StoredBillingModelContext;
|
||||
use aether_usage_runtime::{UsageEvent, UsageEventData, UsageEventType};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -155,7 +155,8 @@ mod tests {
|
||||
_provider_id: &str,
|
||||
_provider_api_key_id: Option<&str>,
|
||||
_global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, aether_data::DataLayerError> {
|
||||
) -> Result<Option<StoredBillingModelContext>, aether_data_contracts::DataLayerError>
|
||||
{
|
||||
Ok(self.context.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ mod python_fernet;
|
||||
|
||||
pub use python_fernet::{
|
||||
decrypt_python_fernet_ciphertext, derive_python_fernet_key, encrypt_python_fernet_plaintext,
|
||||
looks_like_python_fernet_ciphertext, PythonFernetCompat, PythonFernetError, APP_SALT_HEX,
|
||||
APP_SALT_SEED, DEVELOPMENT_ENCRYPTION_KEY,
|
||||
looks_like_python_fernet_ciphertext, warm_python_fernet_secret, PythonFernetCompat,
|
||||
PythonFernetError, APP_SALT_HEX, APP_SALT_SEED, DEVELOPMENT_ENCRYPTION_KEY,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
@@ -16,11 +18,15 @@ const SIGNING_KEY_SIZE: usize = 16;
|
||||
const ENCRYPTION_KEY_SIZE: usize = 16;
|
||||
const MIN_TOKEN_SIZE: usize = 1 + 8 + IV_SIZE + HMAC_SIZE;
|
||||
const PBKDF2_ITERATIONS: u32 = 100_000;
|
||||
const MAX_CACHED_DERIVED_KEYS: usize = 16;
|
||||
|
||||
pub const APP_SALT_SEED: &[u8] = b"aether-v1";
|
||||
pub const APP_SALT_HEX: &str = "8797080a7a4b45b4810e934d1af36261";
|
||||
pub const DEVELOPMENT_ENCRYPTION_KEY: &str = "dev-encryption-key-do-not-use-in-production";
|
||||
|
||||
static RAW_FERNET_KEY_CACHE: LazyLock<Mutex<HashMap<Box<str>, [u8; 32]>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
type Aes128CbcDec = Decryptor<aes::Aes128>;
|
||||
type Aes128CbcEnc = Encryptor<aes::Aes128>;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
@@ -179,16 +185,37 @@ pub fn encrypt_python_fernet_plaintext(
|
||||
PythonFernetCompat::from_secret(secret).encrypt_plaintext(plaintext)
|
||||
}
|
||||
|
||||
pub fn warm_python_fernet_secret(secret: &str) {
|
||||
let _ = raw_fernet_key(secret);
|
||||
}
|
||||
|
||||
fn raw_fernet_key(secret: &str) -> [u8; 32] {
|
||||
if let Ok(raw_key) = decode_direct_fernet_key(secret) {
|
||||
return raw_key;
|
||||
}
|
||||
|
||||
if let Some(raw_key) = RAW_FERNET_KEY_CACHE
|
||||
.lock()
|
||||
.expect("raw fernet key cache should lock")
|
||||
.get(secret)
|
||||
.copied()
|
||||
{
|
||||
return raw_key;
|
||||
}
|
||||
|
||||
let mut salt = [0u8; 16];
|
||||
salt.copy_from_slice(&Sha256::digest(APP_SALT_SEED)[..16]);
|
||||
|
||||
let mut raw_key = [0u8; 32];
|
||||
pbkdf2_hmac::<Sha256>(secret.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut raw_key);
|
||||
|
||||
let mut cache = RAW_FERNET_KEY_CACHE
|
||||
.lock()
|
||||
.expect("raw fernet key cache should lock");
|
||||
if cache.len() >= MAX_CACHED_DERIVED_KEYS && !cache.contains_key(secret) {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(secret.into(), raw_key);
|
||||
raw_key
|
||||
}
|
||||
|
||||
|
||||
13
crates/aether-data-contracts/Cargo.toml
Normal file
13
crates/aether-data-contracts/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "aether-data-contracts"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Shared data contracts and repository traits for Aether Rust services"
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
30
crates/aether-data-contracts/src/error.rs
Normal file
30
crates/aether-data-contracts/src/error.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DataLayerError {
|
||||
#[error("invalid configuration: {0}")]
|
||||
InvalidConfiguration(String),
|
||||
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("postgres error: {0}")]
|
||||
Postgres(String),
|
||||
|
||||
#[error("redis error: {0}")]
|
||||
Redis(String),
|
||||
|
||||
#[error("operation timed out: {0}")]
|
||||
TimedOut(String),
|
||||
|
||||
#[error("unexpected database value: {0}")]
|
||||
UnexpectedValue(String),
|
||||
}
|
||||
|
||||
impl DataLayerError {
|
||||
pub fn postgres(error: impl std::fmt::Display) -> Self {
|
||||
Self::Postgres(error.to_string())
|
||||
}
|
||||
|
||||
pub fn redis(error: impl std::fmt::Display) -> Self {
|
||||
Self::Redis(error.to_string())
|
||||
}
|
||||
}
|
||||
4
crates/aether-data-contracts/src/lib.rs
Normal file
4
crates/aether-data-contracts/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod error;
|
||||
pub mod repository;
|
||||
|
||||
pub use error::DataLayerError;
|
||||
@@ -0,0 +1,7 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingPresetApplyResult,
|
||||
AdminBillingRuleRecord, AdminBillingRuleWriteInput, BillingReadRepository,
|
||||
StoredBillingModelContext,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
MinimalCandidateSelectionReadRepository, MinimalCandidateSelectionRepository,
|
||||
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
||||
};
|
||||
@@ -81,64 +81,3 @@ impl<T> MinimalCandidateSelectionRepository for T where
|
||||
T: MinimalCandidateSelectionReadRepository + Send + Sync
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StoredMinimalCandidateSelectionRow, StoredProviderModelMapping};
|
||||
|
||||
fn sample_row() -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "OpenAI".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
provider_priority: 10,
|
||||
provider_is_active: true,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "openai:chat".to_string(),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
endpoint_is_active: true,
|
||||
key_id: "key-1".to_string(),
|
||||
key_name: "prod".to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_is_active: true,
|
||||
key_api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
key_allowed_models: None,
|
||||
key_capabilities: None,
|
||||
key_internal_priority: 50,
|
||||
key_global_priority_by_format: None,
|
||||
model_id: "model-1".to_string(),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "gpt-4.1".to_string(),
|
||||
global_model_mappings: Some(vec!["gpt-4\\.1-.*".to_string()]),
|
||||
global_model_supports_streaming: Some(true),
|
||||
model_provider_model_name: "gpt-4.1-upstream".to_string(),
|
||||
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
|
||||
name: "gpt-4.1-canary".to_string(),
|
||||
priority: 1,
|
||||
api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
}]),
|
||||
model_supports_streaming: None,
|
||||
model_is_active: true,
|
||||
model_is_available: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_streaming_support_to_true() {
|
||||
let mut row = sample_row();
|
||||
row.model_supports_streaming = None;
|
||||
row.global_model_supports_streaming = None;
|
||||
|
||||
assert!(row.supports_streaming());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_api_formats_none_means_support_all_formats() {
|
||||
let mut row = sample_row();
|
||||
row.key_api_formats = None;
|
||||
|
||||
assert!(row.key_supports_api_format("openai:chat"));
|
||||
assert!(row.key_supports_api_format("openai:responses"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
build_decision_trace, derive_request_candidate_final_status, DecisionTrace,
|
||||
DecisionTraceCandidate, PublicHealthStatusCount, PublicHealthTimelineBucket,
|
||||
RequestCandidateFinalStatus, RequestCandidateReadRepository, RequestCandidateRepository,
|
||||
RequestCandidateStatus, RequestCandidateTrace, RequestCandidateWriteRepository,
|
||||
StoredRequestCandidate, UpsertRequestCandidateRecord,
|
||||
};
|
||||
@@ -520,306 +520,3 @@ impl<T> RequestCandidateRepository for T where
|
||||
T: RequestCandidateReadRepository + RequestCandidateWriteRepository + Send + Sync
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_decision_trace, derive_request_candidate_final_status, DecisionTrace,
|
||||
DecisionTraceCandidate, RequestCandidateFinalStatus, RequestCandidateStatus,
|
||||
RequestCandidateTrace, StoredRequestCandidate, UpsertRequestCandidateRecord,
|
||||
};
|
||||
use crate::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_status_from_database_text() {
|
||||
assert_eq!(
|
||||
RequestCandidateStatus::from_database("streaming").expect("status should parse"),
|
||||
RequestCandidateStatus::Streaming
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_database_status() {
|
||||
assert!(RequestCandidateStatus::from_database("mystery").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_candidate_index() {
|
||||
assert!(StoredRequestCandidate::new(
|
||||
"cand-1".to_string(),
|
||||
"req-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
-1,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
RequestCandidateStatus::Pending,
|
||||
None,
|
||||
false,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(10),
|
||||
Some(1),
|
||||
None,
|
||||
None,
|
||||
100,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
fn sample_candidate(
|
||||
id: &str,
|
||||
request_id: &str,
|
||||
candidate_index: i32,
|
||||
status: RequestCandidateStatus,
|
||||
started_at_unix_secs: Option<i64>,
|
||||
latency_ms: Option<i32>,
|
||||
status_code: Option<i32>,
|
||||
) -> StoredRequestCandidate {
|
||||
StoredRequestCandidate::new(
|
||||
id.to_string(),
|
||||
request_id.to_string(),
|
||||
Some("user-1".to_string()),
|
||||
Some("api-key-1".to_string()),
|
||||
Some("alice".to_string()),
|
||||
Some("default".to_string()),
|
||||
candidate_index,
|
||||
0,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("provider-key-1".to_string()),
|
||||
status,
|
||||
None,
|
||||
false,
|
||||
status_code,
|
||||
None,
|
||||
None,
|
||||
latency_ms,
|
||||
Some(1),
|
||||
None,
|
||||
None,
|
||||
100 + i64::from(candidate_index),
|
||||
started_at_unix_secs,
|
||||
started_at_unix_secs.map(|value| value + 1),
|
||||
)
|
||||
.expect("candidate should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_request_candidate_final_status_preferring_success() {
|
||||
let candidates = vec![sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Success,
|
||||
Some(100),
|
||||
Some(25),
|
||||
Some(200),
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
derive_request_candidate_final_status(&candidates),
|
||||
RequestCandidateFinalStatus::Success
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_candidate_trace_filters_attempted_rows() {
|
||||
let trace = RequestCandidateTrace::from_candidates(
|
||||
"req-1",
|
||||
vec![
|
||||
sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Pending,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
sample_candidate(
|
||||
"cand-2",
|
||||
"req-1",
|
||||
1,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(33),
|
||||
Some(502),
|
||||
),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.expect("trace should exist");
|
||||
|
||||
assert_eq!(trace.total_candidates, 1);
|
||||
assert_eq!(trace.candidates[0].id, "cand-2");
|
||||
assert_eq!(trace.final_status, RequestCandidateFinalStatus::Failed);
|
||||
assert_eq!(trace.total_latency_ms, 33);
|
||||
}
|
||||
|
||||
fn sample_provider() -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
"provider-1".to_string(),
|
||||
"OpenAI".to_string(),
|
||||
Some("https://openai.com".to_string()),
|
||||
"custom".to_string(),
|
||||
)
|
||||
.expect("provider should build")
|
||||
}
|
||||
|
||||
fn sample_endpoint() -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
}
|
||||
|
||||
fn sample_key() -> StoredProviderCatalogKey {
|
||||
StoredProviderCatalogKey::new(
|
||||
"provider-key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"prod-key".to_string(),
|
||||
"api_key".to_string(),
|
||||
Some(serde_json::json!({"cache_1h": true})),
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_decision_trace_enriches_candidate_with_provider_catalog_metadata() {
|
||||
let trace = RequestCandidateTrace::from_candidates(
|
||||
"req-1",
|
||||
vec![sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(37),
|
||||
Some(502),
|
||||
)],
|
||||
true,
|
||||
)
|
||||
.expect("trace should exist");
|
||||
|
||||
assert_eq!(
|
||||
build_decision_trace(
|
||||
trace,
|
||||
vec![sample_provider()],
|
||||
vec![sample_endpoint()],
|
||||
vec![sample_key()],
|
||||
),
|
||||
DecisionTrace {
|
||||
request_id: "req-1".to_string(),
|
||||
total_candidates: 1,
|
||||
final_status: RequestCandidateFinalStatus::Failed,
|
||||
total_latency_ms: 37,
|
||||
candidates: vec![DecisionTraceCandidate {
|
||||
candidate: sample_candidate(
|
||||
"cand-1",
|
||||
"req-1",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(37),
|
||||
Some(502),
|
||||
),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_website: Some("https://openai.com".to_string()),
|
||||
provider_type: Some("custom".to_string()),
|
||||
endpoint_api_format: Some("openai:chat".to_string()),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
provider_key_name: Some("prod-key".to_string()),
|
||||
provider_key_auth_type: Some("api_key".to_string()),
|
||||
provider_key_capabilities: Some(serde_json::json!({"cache_1h": true})),
|
||||
provider_key_is_active: Some(true),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_created_at() {
|
||||
assert!(StoredRequestCandidate::new(
|
||||
"cand-1".to_string(),
|
||||
"req-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
RequestCandidateStatus::Pending,
|
||||
None,
|
||||
false,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(10),
|
||||
Some(1),
|
||||
None,
|
||||
None,
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_without_started_at_is_not_attempted() {
|
||||
assert!(!RequestCandidateStatus::Pending.is_attempted(None));
|
||||
assert!(RequestCandidateStatus::Pending.is_attempted(Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_upsert_payload() {
|
||||
assert!(UpsertRequestCandidateRecord {
|
||||
id: "".to_string(),
|
||||
request_id: "".to_string(),
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
candidate_index: 0,
|
||||
retry_index: 0,
|
||||
provider_id: None,
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
status: RequestCandidateStatus::Available,
|
||||
skip_reason: None,
|
||||
is_cached: None,
|
||||
status_code: None,
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: None,
|
||||
concurrent_requests: None,
|
||||
extra_data: None,
|
||||
required_capabilities: None,
|
||||
created_at_unix_secs: None,
|
||||
started_at_unix_secs: None,
|
||||
finished_at_unix_secs: None,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
AdminGlobalModelListQuery, AdminProviderModelListQuery, CreateAdminGlobalModelRecord,
|
||||
GlobalModelReadRepository, GlobalModelWriteRepository, PublicCatalogModelListQuery,
|
||||
PublicCatalogModelSearchQuery, PublicGlobalModelQuery, StoredAdminGlobalModel,
|
||||
StoredAdminGlobalModelPage, StoredAdminProviderModel, StoredProviderActiveGlobalModel,
|
||||
StoredProviderModelStats, StoredPublicCatalogModel, StoredPublicGlobalModel,
|
||||
StoredPublicGlobalModelPage, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
|
||||
};
|
||||
9
crates/aether-data-contracts/src/repository/mod.rs
Normal file
9
crates/aether-data-contracts/src/repository/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod billing;
|
||||
pub mod candidate_selection;
|
||||
pub mod candidates;
|
||||
pub mod global_models;
|
||||
pub mod provider_catalog;
|
||||
pub mod quota;
|
||||
pub mod settlement;
|
||||
pub mod usage;
|
||||
pub mod video_tasks;
|
||||
@@ -0,0 +1,7 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogKeyPage,
|
||||
StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
|
||||
};
|
||||
@@ -593,146 +593,3 @@ pub trait ProviderCatalogWriteRepository: Send + Sync {
|
||||
circuit_breaker_by_format: Option<&serde_json::Value>,
|
||||
) -> Result<bool, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_provider_name() {
|
||||
assert!(StoredProviderCatalogProvider::new(
|
||||
"provider-1".to_string(),
|
||||
"".to_string(),
|
||||
None,
|
||||
"custom".to_string(),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_endpoint_api_format() {
|
||||
assert!(StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"".to_string(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_endpoint_base_url() {
|
||||
let endpoint = StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build");
|
||||
assert!(endpoint
|
||||
.with_transport_fields("".to_string(), None, None, None, None, None, None, None,)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_key_auth_type() {
|
||||
assert!(StoredProviderCatalogKey::new(
|
||||
"key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"default".to_string(),
|
||||
"".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_encrypted_api_key() {
|
||||
let key = StoredProviderCatalogKey::new(
|
||||
"key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"default".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build");
|
||||
assert!(key
|
||||
.with_transport_fields(
|
||||
None,
|
||||
"".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_key_rate_limit_fields() {
|
||||
let key = StoredProviderCatalogKey::new(
|
||||
"key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"default".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_rate_limit_fields(
|
||||
Some(100),
|
||||
Some(80),
|
||||
Some(2),
|
||||
Some(3),
|
||||
Some(1_700_000_000),
|
||||
Some(serde_json::json!([{"new_limit": 80}])),
|
||||
Some(120),
|
||||
Some(110),
|
||||
);
|
||||
|
||||
assert_eq!(key.rpm_limit, Some(100));
|
||||
assert_eq!(key.learned_rpm_limit, Some(80));
|
||||
assert_eq!(key.concurrent_429_count, Some(2));
|
||||
assert_eq!(key.rpm_429_count, Some(3));
|
||||
assert_eq!(key.last_429_at_unix_secs, Some(1_700_000_000));
|
||||
assert_eq!(key.request_count, Some(120));
|
||||
assert_eq!(key.success_count, Some(110));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_key_health_fields() {
|
||||
let key = StoredProviderCatalogKey::new(
|
||||
"key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"default".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_health_fields(
|
||||
Some(serde_json::json!({"openai:chat": {"health_score": 0.4}})),
|
||||
Some(serde_json::json!({"openai:chat": {"open": true}})),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
key.health_by_format,
|
||||
Some(serde_json::json!({"openai:chat": {"health_score": 0.4}}))
|
||||
);
|
||||
assert_eq!(
|
||||
key.circuit_breaker_by_format,
|
||||
Some(serde_json::json!({"openai:chat": {"open": true}}))
|
||||
);
|
||||
}
|
||||
}
|
||||
6
crates/aether-data-contracts/src/repository/quota/mod.rs
Normal file
6
crates/aether-data-contracts/src/repository/quota/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
ProviderQuotaReadRepository, ProviderQuotaRepository, ProviderQuotaWriteRepository,
|
||||
StoredProviderQuotaSnapshot,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
SettlementRepository, SettlementWriteRepository, StoredUsageSettlement, UsageSettlementInput,
|
||||
};
|
||||
7
crates/aether-data-contracts/src/repository/usage/mod.rs
Normal file
7
crates/aether-data-contracts/src/repository/usage/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
StoredProviderUsageSummary, StoredProviderUsageWindow, StoredRequestUsageAudit,
|
||||
UpsertUsageRecord, UsageAuditListQuery, UsageReadRepository, UsageRepository,
|
||||
UsageWriteRepository,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
mod types;
|
||||
|
||||
pub use types::{
|
||||
StoredVideoTask, UpsertVideoTask, VideoTaskLookupKey, VideoTaskModelCount,
|
||||
VideoTaskQueryFilter, VideoTaskReadRepository, VideoTaskRepository, VideoTaskStatus,
|
||||
VideoTaskStatusCount, VideoTaskWriteRepository,
|
||||
};
|
||||
@@ -7,6 +7,7 @@ repository.workspace = true
|
||||
description = "Shared data access contracts and config for Aether Rust services"
|
||||
|
||||
[dependencies]
|
||||
aether-data-contracts.workspace = true
|
||||
aether-cache.workspace = true
|
||||
aether-wallet.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE provider_endpoints
|
||||
ADD COLUMN IF NOT EXISTS health_score DOUBLE PRECISION NOT NULL DEFAULT 1.0;
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::postgres::{
|
||||
PostgresLeaseRunner, PostgresLeaseRunnerConfig, PostgresPool, PostgresPoolConfig,
|
||||
PostgresPoolFactory, PostgresTransactionRunner,
|
||||
@@ -304,10 +305,11 @@ impl PostgresBackend {
|
||||
let row = sqlx::query(FIND_SYSTEM_CONFIG_VALUE_SQL)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
row.map(|row| row.try_get("value"))
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
.map_postgres_err()
|
||||
}
|
||||
|
||||
pub async fn upsert_system_config_value(
|
||||
@@ -322,8 +324,9 @@ impl PostgresBackend {
|
||||
.bind(value)
|
||||
.bind(description)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
row.try_get("value").map_err(Into::into)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
row.try_get("value").map_postgres_err()
|
||||
}
|
||||
|
||||
pub async fn list_system_config_entries(
|
||||
@@ -331,19 +334,21 @@ impl PostgresBackend {
|
||||
) -> Result<Vec<StoredSystemConfigEntry>, DataLayerError> {
|
||||
let rows = sqlx::query(LIST_SYSTEM_CONFIG_ENTRIES_SQL)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(StoredSystemConfigEntry {
|
||||
key: row.try_get("key")?,
|
||||
value: row.try_get("value")?,
|
||||
description: row.try_get("description")?,
|
||||
key: row.try_get("key").map_postgres_err()?,
|
||||
value: row.try_get("value").map_postgres_err()?,
|
||||
description: row.try_get("description").map_postgres_err()?,
|
||||
updated_at_unix_secs: row
|
||||
.try_get::<Option<i64>, _>("updated_at_unix_secs")?
|
||||
.try_get::<Option<i64>, _>("updated_at_unix_secs")
|
||||
.map_postgres_err()?
|
||||
.map(|value| value.max(0) as u64),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect::<Result<Vec<_>, DataLayerError>>()
|
||||
}
|
||||
|
||||
pub async fn upsert_system_config_entry(
|
||||
@@ -358,13 +363,15 @@ impl PostgresBackend {
|
||||
.bind(value)
|
||||
.bind(description)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
Ok(StoredSystemConfigEntry {
|
||||
key: row.try_get("key")?,
|
||||
value: row.try_get("value")?,
|
||||
description: row.try_get("description")?,
|
||||
key: row.try_get("key").map_postgres_err()?,
|
||||
value: row.try_get("value").map_postgres_err()?,
|
||||
description: row.try_get("description").map_postgres_err()?,
|
||||
updated_at_unix_secs: row
|
||||
.try_get::<Option<i64>, _>("updated_at_unix_secs")?
|
||||
.try_get::<Option<i64>, _>("updated_at_unix_secs")
|
||||
.map_postgres_err()?
|
||||
.map(|value| value.max(0) as u64),
|
||||
})
|
||||
}
|
||||
@@ -373,19 +380,33 @@ impl PostgresBackend {
|
||||
let result = sqlx::query(DELETE_SYSTEM_CONFIG_VALUE_SQL)
|
||||
.bind(key)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn read_admin_system_stats(&self) -> Result<AdminSystemStats, DataLayerError> {
|
||||
let row = sqlx::query(READ_ADMIN_SYSTEM_STATS_SQL)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
Ok(AdminSystemStats {
|
||||
total_users: row.try_get::<i64, _>("total_users")?.max(0) as u64,
|
||||
active_users: row.try_get::<i64, _>("active_users")?.max(0) as u64,
|
||||
total_api_keys: row.try_get::<i64, _>("total_api_keys")?.max(0) as u64,
|
||||
total_requests: row.try_get::<i64, _>("total_requests")?.max(0) as u64,
|
||||
total_users: row
|
||||
.try_get::<i64, _>("total_users")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64,
|
||||
active_users: row
|
||||
.try_get::<i64, _>("active_users")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64,
|
||||
total_api_keys: row
|
||||
.try_get::<i64, _>("total_api_keys")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64,
|
||||
total_requests: row
|
||||
.try_get::<i64, _>("total_requests")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user