mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 03:39:49 +08:00
refactor: 抽离 AI pipeline 与调度共享能力逻辑
This commit is contained in:
@@ -5,8 +5,9 @@ pub(crate) use crate::ai_pipeline::{
|
||||
core_error_default_client_api_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
|
||||
};
|
||||
pub(crate) use crate::ai_pipeline::{
|
||||
request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_transport_supported, sync_chat_response_conversion_kind,
|
||||
request_candidate_api_formats, request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_conversion_transport_supported,
|
||||
request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
};
|
||||
@@ -14,7 +15,7 @@ pub(crate) use crate::ai_pipeline::{
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
request_conversion_kind, sync_chat_response_conversion_kind,
|
||||
request_candidate_api_formats, request_conversion_kind, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
};
|
||||
@@ -22,7 +23,15 @@ mod tests {
|
||||
#[test]
|
||||
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
|
||||
assert_eq!(
|
||||
request_conversion_kind("claude:chat", "openai:chat"),
|
||||
request_conversion_kind("openai:chat", "openai:cli"),
|
||||
Some(RequestConversionKind::ToOpenAIFamilyCli)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "claude:cli"),
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:cli", "openai:chat"),
|
||||
Some(RequestConversionKind::ToOpenAIChat)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -30,12 +39,16 @@ mod tests {
|
||||
Some(RequestConversionKind::ToClaudeStandard)
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("gemini:cli", "openai:compact"),
|
||||
Some(RequestConversionKind::ToOpenAICompact)
|
||||
request_conversion_kind("openai:compact", "gemini:cli"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:compact", "gemini:cli"),
|
||||
Some(RequestConversionKind::ToGeminiStandard)
|
||||
request_conversion_kind("gemini:cli", "openai:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
request_conversion_kind("openai:chat", "openai:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(request_conversion_kind("claude:chat", "claude:chat"), None);
|
||||
}
|
||||
@@ -59,12 +72,50 @@ mod tests {
|
||||
Some(SyncCliResponseConversionKind::ToGeminiCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
|
||||
sync_cli_response_conversion_kind("claude:chat", "openai:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToOpenAIFamilyCli)
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("claude:cli", "openai:compact"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("openai:compact", "claude:cli"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
|
||||
Some(SyncCliResponseConversionKind::ToClaudeCli)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_candidate_registry_excludes_compact_as_cross_format_target() {
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:chat", false),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:cli", false),
|
||||
vec![
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request_candidate_api_formats("openai:compact", false),
|
||||
vec!["openai:compact"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,130 @@ fn gemini_cli_v1internal_stream_rewriter_unwraps_response_object() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_error_to_openai_cli_stream_rewriter_converts_to_response_failed() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "openai:cli",
|
||||
"needs_conversion": true,
|
||||
"mapped_model": "gpt-5.4",
|
||||
});
|
||||
let mut rewriter =
|
||||
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
|
||||
let output = rewriter
|
||||
.push_chunk(
|
||||
b"data: {\"error\":{\"message\":\"bad request\",\"type\":\"invalid_request_error\",\"code\":\"invalid_request\"}}\n\n",
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(output);
|
||||
assert!(output_text.contains("event: response.failed"));
|
||||
assert!(output_text.contains("\"sequence_number\":1"));
|
||||
assert!(output_text.contains("\"message\":\"bad request\""));
|
||||
assert!(output_text.contains("\"type\":\"invalid_request_error\""));
|
||||
assert!(!output_text.contains("data: [DONE]"));
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_error_to_openai_cli_stream_rewriter_converts_to_response_failed() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "claude:chat",
|
||||
"client_api_format": "openai:cli",
|
||||
"needs_conversion": true,
|
||||
"mapped_model": "claude-sonnet-4-5",
|
||||
});
|
||||
let mut rewriter =
|
||||
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
|
||||
let output = rewriter
|
||||
.push_chunk(
|
||||
concat!(
|
||||
"event: error\n",
|
||||
"data: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"slow down\",\"code\":\"rate_limit\"}}\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(output);
|
||||
assert!(output_text.contains("event: response.failed"));
|
||||
assert!(output_text.contains("\"sequence_number\":1"));
|
||||
assert!(output_text.contains("\"message\":\"slow down\""));
|
||||
assert!(output_text.contains("\"type\":\"rate_limit_error\""));
|
||||
assert!(!output_text.contains("data: [DONE]"));
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_error_to_openai_cli_stream_rewriter_converts_to_response_failed() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "gemini:cli",
|
||||
"client_api_format": "openai:cli",
|
||||
"needs_conversion": true,
|
||||
"mapped_model": "gemini-2.5-pro",
|
||||
});
|
||||
let mut rewriter =
|
||||
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
|
||||
let output = rewriter
|
||||
.push_chunk(
|
||||
b"data: {\"error\":{\"code\":429,\"message\":\"quota exceeded\",\"status\":\"RESOURCE_EXHAUSTED\"}}\n\n",
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(output);
|
||||
assert!(output_text.contains("event: response.failed"));
|
||||
assert!(output_text.contains("\"sequence_number\":1"));
|
||||
assert!(output_text.contains("\"message\":\"quota exceeded\""));
|
||||
assert!(output_text.contains("\"type\":\"rate_limit_error\""));
|
||||
assert!(!output_text.contains("data: [DONE]"));
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_error_to_claude_chat_stream_rewriter_uses_error_event_line() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "claude:chat",
|
||||
"needs_conversion": true,
|
||||
"mapped_model": "gpt-5.4",
|
||||
});
|
||||
let mut rewriter =
|
||||
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
|
||||
let output = rewriter
|
||||
.push_chunk(
|
||||
b"data: {\"error\":{\"message\":\"bad request\",\"type\":\"invalid_request_error\",\"code\":\"invalid_request\"}}\n\n",
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(output);
|
||||
assert!(output_text.contains("event: error"));
|
||||
assert!(output_text.contains("\"type\":\"error\""));
|
||||
assert!(output_text.contains("\"message\":\"bad request\""));
|
||||
assert!(output_text.contains("\"code\":\"invalid_request\""));
|
||||
assert!(!output_text.contains("data: [DONE]"));
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_error_to_gemini_chat_stream_rewriter_keeps_data_only_error() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "gemini:chat",
|
||||
"needs_conversion": true,
|
||||
"mapped_model": "gpt-5.4",
|
||||
});
|
||||
let mut rewriter =
|
||||
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
|
||||
let output = rewriter
|
||||
.push_chunk(
|
||||
b"data: {\"error\":{\"message\":\"rate limited\",\"type\":\"rate_limit_error\",\"code\":\"rate_limit\"}}\n\n",
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(output);
|
||||
assert!(output_text.starts_with("data: {\"error\":"));
|
||||
assert!(!output_text.contains("event: "));
|
||||
assert!(output_text.contains("\"message\":\"rate limited\""));
|
||||
assert!(output_text.contains("\"status\":\"RESOURCE_EXHAUSTED\""));
|
||||
assert!(!output_text.contains("data: [DONE]"));
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_to_openai_chat_stream_rewriter_converts_text_deltas() {
|
||||
let report_context = json!({
|
||||
@@ -234,6 +358,22 @@ fn openai_cli_to_openai_chat_stream_rewriter_converts_text_deltas_immediately()
|
||||
.expect("rewrite should succeed");
|
||||
let completed_text = String::from_utf8(completed).expect("utf8 should decode");
|
||||
assert!(completed_text.contains("\"finish_reason\":\"stop\""));
|
||||
assert!(
|
||||
completed_text.contains("\"choices\":[]"),
|
||||
"{completed_text}"
|
||||
);
|
||||
assert!(
|
||||
completed_text.contains("\"prompt_tokens\":1"),
|
||||
"{completed_text}"
|
||||
);
|
||||
assert!(
|
||||
completed_text.contains("\"completion_tokens\":2"),
|
||||
"{completed_text}"
|
||||
);
|
||||
assert!(
|
||||
completed_text.contains("\"total_tokens\":3"),
|
||||
"{completed_text}"
|
||||
);
|
||||
assert!(completed_text.contains("data: [DONE]"));
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
@@ -262,6 +402,13 @@ fn openai_cli_to_openai_chat_stream_rewriter_converts_completed_event_without_bu
|
||||
assert!(output_text.contains("\"role\":\"assistant\""));
|
||||
assert!(output_text.contains("\"content\":\"Hello Codex\""));
|
||||
assert!(output_text.contains("\"finish_reason\":\"stop\""));
|
||||
assert!(output_text.contains("\"choices\":[]"), "{output_text}");
|
||||
assert!(output_text.contains("\"prompt_tokens\":1"), "{output_text}");
|
||||
assert!(
|
||||
output_text.contains("\"completion_tokens\":2"),
|
||||
"{output_text}"
|
||||
);
|
||||
assert!(output_text.contains("\"total_tokens\":3"), "{output_text}");
|
||||
assert!(output_text.contains("data: [DONE]"));
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
@@ -514,6 +661,7 @@ fn openai_chat_to_claude_chat_stream_rewriter_converts_via_standard_matrix() {
|
||||
assert!(first_text.contains("event: content_block_start"));
|
||||
assert!(first_text.contains("event: content_block_delta"));
|
||||
assert!(first_text.contains("\"text\":\"Hello Claude\""));
|
||||
assert!(first_text.contains("\"usage\":{\"input_tokens\":0,\"output_tokens\":0}"));
|
||||
|
||||
let second = rewriter
|
||||
.push_chunk(
|
||||
@@ -531,6 +679,44 @@ fn openai_chat_to_claude_chat_stream_rewriter_converts_via_standard_matrix() {
|
||||
assert!(rewriter.finish().expect("finish should succeed").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_to_claude_chat_stream_rewriter_injects_default_usage_when_finish_chunk_lacks_usage()
|
||||
{
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "claude:chat",
|
||||
"needs_conversion": true,
|
||||
"mapped_model": "gpt-5.4",
|
||||
});
|
||||
let mut rewriter =
|
||||
maybe_build_local_stream_rewriter(Some(&report_context)).expect("rewriter should exist");
|
||||
let first = rewriter
|
||||
.push_chunk(
|
||||
"data: {\"id\":\"chatcmpl_usage_missing_123\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n"
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let first_text = utf8(first);
|
||||
assert!(first_text.contains("event: message_start"));
|
||||
|
||||
let second = rewriter
|
||||
.push_chunk(
|
||||
concat!(
|
||||
"data: {\"id\":\"chatcmpl_usage_missing_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("rewrite should succeed");
|
||||
let output_text = utf8(second);
|
||||
assert!(output_text.is_empty(), "{output_text}");
|
||||
let final_text = utf8(rewriter.finish().expect("finish should succeed"));
|
||||
assert!(final_text.contains("event: message_delta"));
|
||||
assert!(final_text.contains("\"stop_reason\":\"end_turn\""));
|
||||
assert!(final_text.contains("\"usage\":{\"input_tokens\":0,\"output_tokens\":0}"));
|
||||
assert!(final_text.contains("event: message_stop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_to_gemini_cli_stream_rewriter_converts_via_standard_matrix() {
|
||||
let report_context = json!({
|
||||
|
||||
@@ -68,6 +68,8 @@ pub(crate) fn build_execution_runtime_auth_context(
|
||||
ExecutionRuntimeAuthContext {
|
||||
user_id: auth_context.user_id.clone(),
|
||||
api_key_id: auth_context.api_key_id.clone(),
|
||||
username: auth_context.username.clone(),
|
||||
api_key_name: auth_context.api_key_name.clone(),
|
||||
balance_remaining: auth_context.balance_remaining,
|
||||
access_allowed: auth_context.access_allowed,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_pipeline::transport::resolve_transport_proxy_snapshot;
|
||||
use crate::ai_pipeline::PlannerAppState;
|
||||
use crate::AppState;
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use crate::ai_pipeline::{
|
||||
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, PlannerAppState,
|
||||
};
|
||||
use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
|
||||
use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerOrderingConfig};
|
||||
use aether_scheduler_core::{
|
||||
build_scheduler_affinity_cache_key_for_api_key_id, compare_candidates_by_priority_mode,
|
||||
requested_capability_priority_for_candidate, SchedulerAffinityTarget,
|
||||
SchedulerMinimalCandidateSelectionCandidate,
|
||||
};
|
||||
|
||||
const PLANNER_SCHEDULER_AFFINITY_MAX_ENTRIES: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum TunnelOwnerAffinityBucket {
|
||||
@@ -12,6 +21,12 @@ enum TunnelOwnerAffinityBucket {
|
||||
RemoteTunnel = 2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct CandidateExecutionOrdering {
|
||||
tunnel_bucket: TunnelOwnerAffinityBucket,
|
||||
keep_priority_on_conversion: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn prefer_local_tunnel_owner_candidates(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
@@ -28,11 +43,127 @@ pub(crate) async fn prefer_local_tunnel_owner_candidates(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn rank_local_execution_candidates(
|
||||
state: PlannerAppState<'_>,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
client_api_format: &str,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
) -> Vec<SchedulerMinimalCandidateSelectionCandidate> {
|
||||
let normalized_client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
let ordering_config = read_scheduler_ordering_config_or_default(state).await;
|
||||
let mut ranked = Vec::with_capacity(candidates.len());
|
||||
|
||||
for (original_index, candidate) in candidates.into_iter().enumerate() {
|
||||
let ordering =
|
||||
resolve_candidate_execution_ordering(state, &candidate, ordering_config).await;
|
||||
let is_same_format = candidate
|
||||
.endpoint_api_format
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(normalized_client_api_format.as_str());
|
||||
let demote_cross_format = !is_same_format && !ordering.keep_priority_on_conversion;
|
||||
let capability_priority =
|
||||
requested_capability_priority_for_candidate(required_capabilities, &candidate);
|
||||
ranked.push((
|
||||
capability_priority.0,
|
||||
capability_priority.1,
|
||||
ordering.tunnel_bucket,
|
||||
demote_cross_format,
|
||||
original_index,
|
||||
candidate,
|
||||
));
|
||||
}
|
||||
|
||||
ranked.sort_by(|left, right| {
|
||||
left.0
|
||||
.cmp(&right.0)
|
||||
.then(left.1.cmp(&right.1))
|
||||
.then(left.2.cmp(&right.2))
|
||||
.then(left.3.cmp(&right.3))
|
||||
.then_with(|| {
|
||||
compare_candidates_by_priority_mode(
|
||||
&left.5,
|
||||
&right.5,
|
||||
ordering_config.priority_mode,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.then(left.4.cmp(&right.4))
|
||||
});
|
||||
|
||||
ranked
|
||||
.into_iter()
|
||||
.map(|(_, _, _, _, _, candidate)| candidate)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn remember_scheduler_affinity_for_candidate(
|
||||
state: PlannerAppState<'_>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_api_format: &str,
|
||||
requested_model: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) {
|
||||
let Some(api_key_id) = auth_snapshot
|
||||
.map(|snapshot| snapshot.api_key_id.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(cache_key) = build_scheduler_affinity_cache_key_for_api_key_id(
|
||||
api_key_id,
|
||||
client_api_format,
|
||||
requested_model,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
state.app().remember_scheduler_affinity_target(
|
||||
&cache_key,
|
||||
SchedulerAffinityTarget {
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
},
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
PLANNER_SCHEDULER_AFFINITY_MAX_ENTRIES,
|
||||
);
|
||||
}
|
||||
|
||||
async fn resolve_candidate_tunnel_owner_affinity(
|
||||
state: PlannerAppState<'_>,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) -> TunnelOwnerAffinityBucket {
|
||||
let transport = match state
|
||||
let Some(transport) = read_candidate_transport_snapshot(state, candidate).await else {
|
||||
return TunnelOwnerAffinityBucket::Neutral;
|
||||
};
|
||||
|
||||
resolve_tunnel_owner_affinity_from_transport(state, &transport).await
|
||||
}
|
||||
|
||||
async fn resolve_candidate_execution_ordering(
|
||||
state: PlannerAppState<'_>,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
ordering_config: SchedulerOrderingConfig,
|
||||
) -> CandidateExecutionOrdering {
|
||||
let Some(transport) = read_candidate_transport_snapshot(state, candidate).await else {
|
||||
return CandidateExecutionOrdering {
|
||||
tunnel_bucket: TunnelOwnerAffinityBucket::Neutral,
|
||||
keep_priority_on_conversion: ordering_config.keep_priority_on_conversion,
|
||||
};
|
||||
};
|
||||
|
||||
CandidateExecutionOrdering {
|
||||
tunnel_bucket: resolve_tunnel_owner_affinity_from_transport(state, &transport).await,
|
||||
keep_priority_on_conversion: ordering_config.keep_priority_on_conversion
|
||||
|| transport.provider.keep_priority_on_conversion,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_candidate_transport_snapshot(
|
||||
state: PlannerAppState<'_>,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
) -> Option<GatewayProviderTransportSnapshot> {
|
||||
match state
|
||||
.read_provider_transport_snapshot(
|
||||
&candidate.provider_id,
|
||||
&candidate.endpoint_id,
|
||||
@@ -40,8 +171,8 @@ async fn resolve_candidate_tunnel_owner_affinity(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(transport)) => transport,
|
||||
Ok(None) => return TunnelOwnerAffinityBucket::Neutral,
|
||||
Ok(Some(transport)) => Some(transport),
|
||||
Ok(None) => None,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
event_name = "candidate_affinity_transport_load_failed",
|
||||
@@ -50,12 +181,17 @@ async fn resolve_candidate_tunnel_owner_affinity(
|
||||
endpoint_id = %candidate.endpoint_id,
|
||||
key_id = %candidate.key_id,
|
||||
error = ?error,
|
||||
"failed to load provider transport while evaluating tunnel owner affinity"
|
||||
"failed to load provider transport while evaluating execution ordering"
|
||||
);
|
||||
return TunnelOwnerAffinityBucket::Neutral;
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_tunnel_owner_affinity_from_transport(
|
||||
state: PlannerAppState<'_>,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> TunnelOwnerAffinityBucket {
|
||||
let Some(proxy) = resolve_transport_proxy_snapshot(&transport) else {
|
||||
return TunnelOwnerAffinityBucket::Neutral;
|
||||
};
|
||||
@@ -99,6 +235,23 @@ async fn resolve_candidate_tunnel_owner_affinity(
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_scheduler_ordering_config_or_default(
|
||||
state: PlannerAppState<'_>,
|
||||
) -> SchedulerOrderingConfig {
|
||||
match read_scheduler_ordering_config(state.app()).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
event_name = "planner_scheduler_ordering_config_load_failed",
|
||||
log_type = "event",
|
||||
error = ?error,
|
||||
"failed to load scheduler ordering config while ranking local execution candidates"
|
||||
);
|
||||
SchedulerOrderingConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -110,11 +263,15 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
prefer_local_tunnel_owner_candidates, AppState, PlannerAppState,
|
||||
prefer_local_tunnel_owner_candidates, rank_local_execution_candidates,
|
||||
remember_scheduler_affinity_for_candidate, PlannerAppState,
|
||||
SchedulerMinimalCandidateSelectionCandidate,
|
||||
};
|
||||
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::tunnel::TunnelAttachmentRecord;
|
||||
use crate::{scheduler::affinity::SCHEDULER_AFFINITY_TTL, AppState};
|
||||
use aether_data::repository::auth::StoredAuthApiKeySnapshot;
|
||||
|
||||
fn sample_candidate(
|
||||
endpoint_id: &str,
|
||||
@@ -142,22 +299,55 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sample_provider() -> StoredProviderCatalogProvider {
|
||||
sample_provider_with_options("provider-1", false, 0)
|
||||
}
|
||||
|
||||
fn sample_provider_with_options(
|
||||
id: &str,
|
||||
keep_priority_on_conversion: bool,
|
||||
provider_priority: i32,
|
||||
) -> StoredProviderCatalogProvider {
|
||||
StoredProviderCatalogProvider::new(
|
||||
"provider-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
id.to_string(),
|
||||
id.to_string(),
|
||||
Some("https://provider.example".to_string()),
|
||||
"custom".to_string(),
|
||||
)
|
||||
.expect("provider should build")
|
||||
.with_transport_fields(true, false, false, None, None, None, None, None, None)
|
||||
.with_transport_fields(
|
||||
true,
|
||||
keep_priority_on_conversion,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.with_routing_fields(provider_priority)
|
||||
}
|
||||
|
||||
fn sample_endpoint(id: &str) -> StoredProviderCatalogEndpoint {
|
||||
sample_endpoint_for_provider("provider-1", id, "openai:chat")
|
||||
}
|
||||
|
||||
fn sample_endpoint_for_provider(
|
||||
provider_id: &str,
|
||||
id: &str,
|
||||
api_format: &str,
|
||||
) -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
id.to_string(),
|
||||
"provider-1".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
Some("openai".to_string()),
|
||||
provider_id.to_string(),
|
||||
api_format.to_string(),
|
||||
Some(
|
||||
api_format
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(api_format)
|
||||
.to_string(),
|
||||
),
|
||||
Some("chat".to_string()),
|
||||
true,
|
||||
)
|
||||
@@ -176,9 +366,17 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sample_key(id: &str, node_id: &str) -> StoredProviderCatalogKey {
|
||||
sample_key_for_provider("provider-1", id, node_id)
|
||||
}
|
||||
|
||||
fn sample_key_for_provider(
|
||||
provider_id: &str,
|
||||
id: &str,
|
||||
node_id: &str,
|
||||
) -> StoredProviderCatalogKey {
|
||||
StoredProviderCatalogKey::new(
|
||||
id.to_string(),
|
||||
"provider-1".to_string(),
|
||||
provider_id.to_string(),
|
||||
id.to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
@@ -214,6 +412,35 @@ mod tests {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn sample_priority_candidate(
|
||||
provider_id: &str,
|
||||
endpoint_id: &str,
|
||||
key_id: &str,
|
||||
endpoint_api_format: &str,
|
||||
key_global_priority_for_format: Option<i32>,
|
||||
provider_priority: i32,
|
||||
) -> SchedulerMinimalCandidateSelectionCandidate {
|
||||
SchedulerMinimalCandidateSelectionCandidate {
|
||||
provider_id: provider_id.to_string(),
|
||||
provider_name: provider_id.to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
provider_priority,
|
||||
endpoint_id: endpoint_id.to_string(),
|
||||
endpoint_api_format: endpoint_api_format.to_string(),
|
||||
key_id: key_id.to_string(),
|
||||
key_name: key_id.to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_internal_priority: 0,
|
||||
key_global_priority_for_format,
|
||||
key_capabilities: None,
|
||||
model_id: format!("model-{provider_id}"),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "gpt-4.1".to_string(),
|
||||
selected_provider_model_name: "gpt-4.1".to_string(),
|
||||
mapping_matched_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefers_local_tunnel_owner_candidates_before_remote_tunnel_candidates() {
|
||||
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
|
||||
@@ -300,4 +527,339 @@ mod tests {
|
||||
assert_eq!(reordered[0].endpoint_id, "endpoint-a");
|
||||
assert_eq!(reordered[1].endpoint_id, "endpoint-b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_execution_ranking_demotes_cross_format_candidates_without_keep_priority() {
|
||||
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![
|
||||
sample_provider_with_options("provider-same", false, 10),
|
||||
sample_provider_with_options("provider-cross", false, 0),
|
||||
],
|
||||
vec![
|
||||
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
|
||||
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:chat"),
|
||||
],
|
||||
vec![
|
||||
sample_key_for_provider("provider-same", "key-same", ""),
|
||||
sample_key_for_provider("provider-cross", "key-cross", ""),
|
||||
],
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
|
||||
let ranked = rank_local_execution_candidates(
|
||||
PlannerAppState::new(&state),
|
||||
vec![
|
||||
sample_priority_candidate(
|
||||
"provider-cross",
|
||||
"endpoint-cross",
|
||||
"key-cross",
|
||||
"claude:chat",
|
||||
Some(0),
|
||||
0,
|
||||
),
|
||||
sample_priority_candidate(
|
||||
"provider-same",
|
||||
"endpoint-same",
|
||||
"key-same",
|
||||
"openai:chat",
|
||||
Some(10),
|
||||
10,
|
||||
),
|
||||
],
|
||||
"openai:chat",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(ranked[0].endpoint_id, "endpoint-same");
|
||||
assert_eq!(ranked[1].endpoint_id, "endpoint-cross");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_execution_ranking_keeps_cross_format_priority_when_enabled() {
|
||||
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![
|
||||
sample_provider_with_options("provider-same", false, 10),
|
||||
sample_provider_with_options("provider-cross", true, 0),
|
||||
],
|
||||
vec![
|
||||
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
|
||||
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:chat"),
|
||||
],
|
||||
vec![
|
||||
sample_key_for_provider("provider-same", "key-same", ""),
|
||||
sample_key_for_provider("provider-cross", "key-cross", ""),
|
||||
],
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
|
||||
let ranked = rank_local_execution_candidates(
|
||||
PlannerAppState::new(&state),
|
||||
vec![
|
||||
sample_priority_candidate(
|
||||
"provider-cross",
|
||||
"endpoint-cross",
|
||||
"key-cross",
|
||||
"claude:chat",
|
||||
Some(0),
|
||||
0,
|
||||
),
|
||||
sample_priority_candidate(
|
||||
"provider-same",
|
||||
"endpoint-same",
|
||||
"key-same",
|
||||
"openai:chat",
|
||||
Some(10),
|
||||
10,
|
||||
),
|
||||
],
|
||||
"openai:chat",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(ranked[0].endpoint_id, "endpoint-cross");
|
||||
assert_eq!(ranked[1].endpoint_id, "endpoint-same");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_execution_ranking_keeps_cross_format_priority_when_global_override_is_enabled() {
|
||||
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![
|
||||
sample_provider_with_options("provider-same", false, 10),
|
||||
sample_provider_with_options("provider-cross", false, 0),
|
||||
],
|
||||
vec![
|
||||
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
|
||||
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:chat"),
|
||||
],
|
||||
vec![
|
||||
sample_key_for_provider("provider-same", "key-same", ""),
|
||||
sample_key_for_provider("provider-cross", "key-cross", ""),
|
||||
],
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"keep_priority_on_conversion".to_string(),
|
||||
json!(true),
|
||||
)]);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
|
||||
let ranked = rank_local_execution_candidates(
|
||||
PlannerAppState::new(&state),
|
||||
vec![
|
||||
sample_priority_candidate(
|
||||
"provider-cross",
|
||||
"endpoint-cross",
|
||||
"key-cross",
|
||||
"claude:chat",
|
||||
Some(0),
|
||||
0,
|
||||
),
|
||||
sample_priority_candidate(
|
||||
"provider-same",
|
||||
"endpoint-same",
|
||||
"key-same",
|
||||
"openai:chat",
|
||||
Some(10),
|
||||
10,
|
||||
),
|
||||
],
|
||||
"openai:chat",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(ranked[0].endpoint_id, "endpoint-cross");
|
||||
assert_eq!(ranked[1].endpoint_id, "endpoint-same");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_execution_ranking_uses_provider_priority_mode_when_configured() {
|
||||
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![
|
||||
sample_provider_with_options("provider-provider-first", false, 0),
|
||||
sample_provider_with_options("provider-global-first", false, 10),
|
||||
],
|
||||
vec![
|
||||
sample_endpoint_for_provider(
|
||||
"provider-provider-first",
|
||||
"endpoint-provider-first",
|
||||
"openai:chat",
|
||||
),
|
||||
sample_endpoint_for_provider(
|
||||
"provider-global-first",
|
||||
"endpoint-global-first",
|
||||
"openai:chat",
|
||||
),
|
||||
],
|
||||
vec![
|
||||
sample_key_for_provider("provider-provider-first", "key-provider-first", ""),
|
||||
sample_key_for_provider("provider-global-first", "key-global-first", ""),
|
||||
],
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"provider_priority_mode".to_string(),
|
||||
json!("provider"),
|
||||
)]);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
|
||||
let ranked = rank_local_execution_candidates(
|
||||
PlannerAppState::new(&state),
|
||||
vec![
|
||||
sample_priority_candidate(
|
||||
"provider-global-first",
|
||||
"endpoint-global-first",
|
||||
"key-global-first",
|
||||
"openai:chat",
|
||||
Some(0),
|
||||
10,
|
||||
),
|
||||
sample_priority_candidate(
|
||||
"provider-provider-first",
|
||||
"endpoint-provider-first",
|
||||
"key-provider-first",
|
||||
"openai:chat",
|
||||
Some(10),
|
||||
0,
|
||||
),
|
||||
],
|
||||
"openai:chat",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(ranked[0].endpoint_id, "endpoint-provider-first");
|
||||
assert_eq!(ranked[1].endpoint_id, "endpoint-global-first");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_execution_ranking_prefers_candidates_matching_requested_capabilities() {
|
||||
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![
|
||||
sample_provider_with_options("provider-miss", false, 0),
|
||||
sample_provider_with_options("provider-hit", false, 0),
|
||||
],
|
||||
vec![
|
||||
sample_endpoint_for_provider("provider-miss", "endpoint-miss", "openai:chat"),
|
||||
sample_endpoint_for_provider("provider-hit", "endpoint-hit", "openai:chat"),
|
||||
],
|
||||
vec![
|
||||
sample_key_for_provider("provider-miss", "key-miss", ""),
|
||||
sample_key_for_provider("provider-hit", "key-hit", ""),
|
||||
],
|
||||
);
|
||||
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
std::sync::Arc::new(provider_catalog),
|
||||
"development-key",
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
|
||||
let mut candidate_miss = sample_priority_candidate(
|
||||
"provider-miss",
|
||||
"endpoint-miss",
|
||||
"key-miss",
|
||||
"openai:chat",
|
||||
Some(0),
|
||||
0,
|
||||
);
|
||||
let mut candidate_hit = sample_priority_candidate(
|
||||
"provider-hit",
|
||||
"endpoint-hit",
|
||||
"key-hit",
|
||||
"openai:chat",
|
||||
Some(0),
|
||||
0,
|
||||
);
|
||||
candidate_miss.key_capabilities = Some(json!({"cache_1h": false}));
|
||||
candidate_hit.key_capabilities = Some(json!({"cache_1h": true}));
|
||||
|
||||
let required_capabilities = json!({"cache_1h": true});
|
||||
let ranked = rank_local_execution_candidates(
|
||||
PlannerAppState::new(&state),
|
||||
vec![candidate_miss, candidate_hit],
|
||||
"openai:chat",
|
||||
Some(&required_capabilities),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(ranked[0].endpoint_id, "endpoint-hit");
|
||||
assert_eq!(ranked[1].endpoint_id, "endpoint-miss");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remembers_scheduler_affinity_for_candidate_using_requested_model_key() {
|
||||
let state = AppState::new().expect("state should build");
|
||||
let auth_snapshot = GatewayAuthApiKeySnapshot::from_stored(
|
||||
StoredAuthApiKeySnapshot::new(
|
||||
"user-1".to_string(),
|
||||
"alice".to_string(),
|
||||
Some("[email protected]".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"api-key-1".to_string(),
|
||||
Some("default".to_string()),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(60),
|
||||
Some(5),
|
||||
Some(4_102_444_800),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("stored auth snapshot should build"),
|
||||
current_unix_secs(),
|
||||
);
|
||||
let candidate = sample_candidate("endpoint-1", "key-1");
|
||||
|
||||
remember_scheduler_affinity_for_candidate(
|
||||
PlannerAppState::new(&state),
|
||||
Some(&auth_snapshot),
|
||||
"openai:chat",
|
||||
"gpt-5",
|
||||
&candidate,
|
||||
);
|
||||
|
||||
let remembered = state
|
||||
.read_scheduler_affinity_target(
|
||||
"scheduler_affinity:api-key-1:openai:chat:gpt-5",
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
)
|
||||
.expect("affinity target should be cached");
|
||||
assert_eq!(remembered.provider_id, "provider-1");
|
||||
assert_eq!(remembered.endpoint_id, "endpoint-1");
|
||||
assert_eq!(remembered.key_id, "key-1");
|
||||
}
|
||||
}
|
||||
|
||||
+35
-4
@@ -2,7 +2,9 @@ use serde_json::json;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||
use crate::ai_pipeline::planner::candidate_affinity::{
|
||||
rank_local_execution_candidates, remember_scheduler_affinity_for_candidate,
|
||||
};
|
||||
use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, ConversionMode, ExecutionStrategy,
|
||||
GatewayControlDecision, PlannerAppState,
|
||||
@@ -61,10 +63,20 @@ pub(crate) async fn resolve_local_same_format_provider_decision_input(
|
||||
}
|
||||
};
|
||||
|
||||
let required_capabilities = planner_state
|
||||
.resolve_request_candidate_required_capabilities(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(LocalSameFormatProviderDecisionInput {
|
||||
auth_context,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
required_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,16 +92,34 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
||||
spec.api_format,
|
||||
&input.requested_model,
|
||||
spec.require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(&input.auth_snapshot),
|
||||
current_unix_secs(),
|
||||
)
|
||||
.await?;
|
||||
let candidates = prefer_local_tunnel_owner_candidates(planner_state, candidates).await;
|
||||
let candidates = rank_local_execution_candidates(
|
||||
planner_state,
|
||||
candidates,
|
||||
spec.api_format,
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let created_at_unix_secs = current_unix_secs();
|
||||
let created_at_unix_ms = current_unix_secs();
|
||||
let mut attempts = Vec::with_capacity(candidates.len());
|
||||
let mut affinity_remembered = false;
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
let generated_candidate_id = Uuid::new_v4().to_string();
|
||||
if !affinity_remembered {
|
||||
remember_scheduler_affinity_for_candidate(
|
||||
planner_state,
|
||||
Some(&input.auth_snapshot),
|
||||
spec.api_format,
|
||||
&input.requested_model,
|
||||
&candidate,
|
||||
);
|
||||
affinity_remembered = true;
|
||||
}
|
||||
let extra_data = append_execution_contract_fields_to_value(
|
||||
json!({
|
||||
"provider_api_format": spec.api_format,
|
||||
@@ -116,8 +146,9 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&generated_candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(extra_data),
|
||||
created_at_unix_secs,
|
||||
created_at_unix_ms,
|
||||
"gateway local same-format decision request candidate upsert failed",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(crate) struct LocalSameFormatProviderDecisionInput {
|
||||
pub(crate) auth_context: ExecutionRuntimeAuthContext,
|
||||
pub(crate) requested_model: String,
|
||||
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||
pub(crate) required_capabilities: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -265,6 +265,8 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
||||
json!({
|
||||
"user_id": input.auth_context.user_id,
|
||||
"api_key_id": input.auth_context.api_key_id,
|
||||
"username": input.auth_context.username,
|
||||
"api_key_name": input.auth_context.api_key_name,
|
||||
"request_id": trace_id,
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_index": candidate_index,
|
||||
@@ -359,6 +361,7 @@ pub(super) async fn mark_skipped_local_same_format_provider_candidate(
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
"gateway local same-format decision failed to persist skipped candidate",
|
||||
|
||||
@@ -281,6 +281,8 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
|
||||
report_context: Some(json!({
|
||||
"user_id": input.auth_context.user_id,
|
||||
"api_key_id": input.auth_context.api_key_id,
|
||||
"username": input.auth_context.username,
|
||||
"api_key_name": input.auth_context.api_key_name,
|
||||
"request_id": trace_id,
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_index": candidate_index,
|
||||
|
||||
@@ -4,7 +4,9 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext;
|
||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||
use crate::ai_pipeline::planner::candidate_affinity::{
|
||||
rank_local_execution_candidates, remember_scheduler_affinity_for_candidate,
|
||||
};
|
||||
use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision,
|
||||
};
|
||||
@@ -20,6 +22,7 @@ pub(super) const GEMINI_FILES_REQUIRED_CAPABILITY: &str = "gemini_files";
|
||||
pub(super) struct LocalGeminiFilesDecisionInput {
|
||||
pub(super) auth_context: ExecutionRuntimeAuthContext,
|
||||
pub(super) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||
pub(super) required_capabilities: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -59,9 +62,20 @@ pub(super) async fn resolve_local_gemini_files_decision_input(
|
||||
}
|
||||
};
|
||||
|
||||
let explicit_required_capabilities = json!({ "gemini_files": true });
|
||||
let required_capabilities = planner_state
|
||||
.resolve_request_candidate_required_capabilities(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
None,
|
||||
Some(&explicit_required_capabilities),
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(LocalGeminiFilesDecisionInput {
|
||||
auth_context,
|
||||
auth_snapshot,
|
||||
required_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,12 +94,29 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
||||
current_unix_secs(),
|
||||
)
|
||||
.await?;
|
||||
let candidates = prefer_local_tunnel_owner_candidates(planner_state, candidates).await;
|
||||
let candidates = rank_local_execution_candidates(
|
||||
planner_state,
|
||||
candidates,
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let created_at_unix_secs = current_unix_secs();
|
||||
let created_at_unix_ms = current_unix_secs();
|
||||
let mut attempts = Vec::with_capacity(candidates.len());
|
||||
let mut affinity_remembered = false;
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
let generated_candidate_id = Uuid::new_v4().to_string();
|
||||
if !affinity_remembered {
|
||||
remember_scheduler_affinity_for_candidate(
|
||||
planner_state,
|
||||
Some(&input.auth_snapshot),
|
||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
&candidate.global_model_name,
|
||||
&candidate,
|
||||
);
|
||||
affinity_remembered = true;
|
||||
}
|
||||
let extra_data = json!({
|
||||
"provider_api_format": GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
"client_api_format": GEMINI_FILES_CLIENT_API_FORMAT,
|
||||
@@ -107,8 +138,9 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&generated_candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(extra_data),
|
||||
created_at_unix_secs,
|
||||
created_at_unix_ms,
|
||||
"gateway local gemini files request candidate upsert failed",
|
||||
)
|
||||
.await;
|
||||
@@ -140,6 +172,7 @@ pub(super) async fn mark_skipped_local_gemini_files_candidate(
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
"gateway local gemini files failed to persist skipped candidate",
|
||||
|
||||
@@ -248,6 +248,8 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
|
||||
report_context: Some(json!({
|
||||
"user_id": input.auth_context.user_id.clone(),
|
||||
"api_key_id": input.auth_context.api_key_id.clone(),
|
||||
"username": input.auth_context.username.clone(),
|
||||
"api_key_name": input.auth_context.api_key_name.clone(),
|
||||
"request_id": trace_id,
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_index": candidate_index,
|
||||
|
||||
@@ -5,7 +5,9 @@ use uuid::Uuid;
|
||||
|
||||
use super::{LocalVideoCreateFamily, LocalVideoCreateSpec};
|
||||
use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext;
|
||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||
use crate::ai_pipeline::planner::candidate_affinity::{
|
||||
rank_local_execution_candidates, remember_scheduler_affinity_for_candidate,
|
||||
};
|
||||
use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision,
|
||||
};
|
||||
@@ -18,6 +20,7 @@ pub(super) struct LocalVideoCreateDecisionInput {
|
||||
pub(super) auth_context: ExecutionRuntimeAuthContext,
|
||||
pub(super) requested_model: String,
|
||||
pub(super) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||
pub(super) required_capabilities: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -71,10 +74,20 @@ pub(super) async fn resolve_local_video_create_decision_input(
|
||||
}
|
||||
};
|
||||
|
||||
let required_capabilities = planner_state
|
||||
.resolve_request_candidate_required_capabilities(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(LocalVideoCreateDecisionInput {
|
||||
auth_context,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
required_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,6 +104,7 @@ pub(super) async fn list_local_video_create_candidate_attempts(
|
||||
api_format,
|
||||
&input.requested_model,
|
||||
false,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(&input.auth_snapshot),
|
||||
current_unix_secs(),
|
||||
)
|
||||
@@ -127,12 +141,29 @@ async fn materialize_local_video_create_candidate_attempts(
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
api_format: &str,
|
||||
) -> Vec<LocalVideoCreateCandidateAttempt> {
|
||||
let candidates = prefer_local_tunnel_owner_candidates(state, candidates).await;
|
||||
let created_at_unix_secs = current_unix_secs();
|
||||
let candidates = rank_local_execution_candidates(
|
||||
state,
|
||||
candidates,
|
||||
api_format,
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let created_at_unix_ms = current_unix_secs();
|
||||
let mut attempts = Vec::with_capacity(candidates.len());
|
||||
let mut affinity_remembered = false;
|
||||
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
let generated_candidate_id = Uuid::new_v4().to_string();
|
||||
if !affinity_remembered {
|
||||
remember_scheduler_affinity_for_candidate(
|
||||
state,
|
||||
Some(&input.auth_snapshot),
|
||||
api_format,
|
||||
&input.requested_model,
|
||||
&candidate,
|
||||
);
|
||||
affinity_remembered = true;
|
||||
}
|
||||
let extra_data = json!({
|
||||
"provider_api_format": api_format,
|
||||
"client_api_format": api_format,
|
||||
@@ -153,8 +184,9 @@ async fn materialize_local_video_create_candidate_attempts(
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&generated_candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(extra_data),
|
||||
created_at_unix_secs,
|
||||
created_at_unix_ms,
|
||||
"gateway local video decision request candidate upsert failed",
|
||||
)
|
||||
.await;
|
||||
@@ -186,6 +218,7 @@ pub(super) async fn mark_skipped_local_video_candidate(
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
"gateway local video decision failed to persist skipped candidate",
|
||||
|
||||
@@ -5,7 +5,13 @@ use serde_json::json;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||
use crate::ai_pipeline::conversion::{
|
||||
request_candidate_api_formats, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_pair_allowed_for_transport,
|
||||
};
|
||||
use crate::ai_pipeline::planner::candidate_affinity::{
|
||||
rank_local_execution_candidates, remember_scheduler_affinity_for_candidate,
|
||||
};
|
||||
use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, ConversionMode, ExecutionStrategy,
|
||||
GatewayControlDecision,
|
||||
@@ -16,7 +22,7 @@ use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
|
||||
|
||||
use super::{
|
||||
LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSourceFamily,
|
||||
LocalStandardSourceMode, LocalStandardSpec,
|
||||
LocalStandardSpec,
|
||||
};
|
||||
|
||||
pub(super) async fn resolve_local_standard_decision_input(
|
||||
@@ -63,10 +69,20 @@ pub(super) async fn resolve_local_standard_decision_input(
|
||||
}
|
||||
};
|
||||
|
||||
let required_capabilities = planner_state
|
||||
.resolve_request_candidate_required_capabilities(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(LocalStandardDecisionInput {
|
||||
auth_context,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
required_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,8 +95,10 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let mut seen_candidates = BTreeSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
for candidate_api_format in candidate_api_formats_for_spec(spec) {
|
||||
let auth_snapshot = if *candidate_api_format == spec.api_format {
|
||||
for candidate_api_format in
|
||||
request_candidate_api_formats(spec.api_format, spec.require_streaming)
|
||||
{
|
||||
let auth_snapshot = if candidate_api_format == spec.api_format {
|
||||
Some(&input.auth_snapshot)
|
||||
} else {
|
||||
None
|
||||
@@ -90,6 +108,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
||||
candidate_api_format,
|
||||
&input.requested_model,
|
||||
spec.require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
auth_snapshot,
|
||||
current_unix_secs(),
|
||||
)
|
||||
@@ -118,13 +137,71 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
||||
}
|
||||
}
|
||||
}
|
||||
let candidates = prefer_local_tunnel_owner_candidates(planner_state, candidates).await;
|
||||
let candidates = rank_local_execution_candidates(
|
||||
planner_state,
|
||||
candidates,
|
||||
spec.api_format,
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let created_at_unix_secs = current_unix_secs();
|
||||
let created_at_unix_ms = current_unix_secs();
|
||||
let mut attempts = Vec::with_capacity(candidates.len());
|
||||
let mut affinity_remembered = false;
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
let candidate_id = Uuid::new_v4().to_string();
|
||||
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
|
||||
if provider_api_format != spec.api_format {
|
||||
if let Ok(Some(transport)) = planner_state
|
||||
.read_provider_transport_snapshot(
|
||||
&candidate.provider_id,
|
||||
&candidate.endpoint_id,
|
||||
&candidate.key_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
) {
|
||||
let skip_reason =
|
||||
if request_conversion_kind(spec.api_format, provider_api_format.as_str())
|
||||
.is_some()
|
||||
&& request_conversion_requires_enable_flag(
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
)
|
||||
&& !transport.provider.enable_format_conversion
|
||||
{
|
||||
"format_conversion_disabled"
|
||||
} else {
|
||||
"transport_unsupported"
|
||||
};
|
||||
super::payload::mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !affinity_remembered {
|
||||
remember_scheduler_affinity_for_candidate(
|
||||
planner_state,
|
||||
Some(&input.auth_snapshot),
|
||||
spec.api_format,
|
||||
&input.requested_model,
|
||||
&candidate,
|
||||
);
|
||||
affinity_remembered = true;
|
||||
}
|
||||
let execution_strategy = if provider_api_format == spec.api_format {
|
||||
ExecutionStrategy::LocalSameFormat
|
||||
} else {
|
||||
@@ -166,8 +243,9 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(extra_data),
|
||||
created_at_unix_secs,
|
||||
created_at_unix_ms,
|
||||
"gateway local standard decision request candidate upsert failed",
|
||||
)
|
||||
.await;
|
||||
@@ -213,20 +291,6 @@ fn auth_snapshot_allows_cross_format_candidate(
|
||||
true
|
||||
}
|
||||
|
||||
fn candidate_api_formats_for_spec(spec: LocalStandardSpec) -> &'static [&'static str] {
|
||||
match spec.mode {
|
||||
LocalStandardSourceMode::Chat | LocalStandardSourceMode::Cli => &[
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_gemini_model_from_path(path: &str) -> Option<String> {
|
||||
let marker = "/models/";
|
||||
let start = path.find(marker)? + marker.len();
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(super) struct LocalStandardDecisionInput {
|
||||
pub(super) auth_context: ExecutionRuntimeAuthContext,
|
||||
pub(super) requested_model: String,
|
||||
pub(super) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||
pub(super) required_capabilities: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -91,6 +91,33 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
}
|
||||
};
|
||||
|
||||
if !crate::ai_pipeline::conversion::request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
) {
|
||||
let skip_reason = if crate::ai_pipeline::conversion::request_conversion_requires_enable_flag(
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
) && !transport.provider.enable_format_conversion
|
||||
{
|
||||
"format_conversion_disabled"
|
||||
} else {
|
||||
"transport_unsupported"
|
||||
};
|
||||
mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
&candidate,
|
||||
candidate_index,
|
||||
&candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
if !crate::ai_pipeline::conversion::request_conversion_transport_supported(
|
||||
&transport,
|
||||
conversion_kind,
|
||||
@@ -301,6 +328,8 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
json!({
|
||||
"user_id": input.auth_context.user_id,
|
||||
"api_key_id": input.auth_context.api_key_id,
|
||||
"username": input.auth_context.username,
|
||||
"api_key_name": input.auth_context.api_key_name,
|
||||
"request_id": trace_id,
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_index": candidate_index,
|
||||
@@ -349,6 +378,7 @@ pub(super) async fn mark_skipped_local_standard_candidate(
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
"gateway local standard decision failed to persist skipped candidate",
|
||||
|
||||
@@ -112,11 +112,6 @@ pub(crate) fn build_cross_format_openai_chat_upstream_url(
|
||||
parts.uri.query(),
|
||||
false,
|
||||
)),
|
||||
RequestConversionKind::ToOpenAICompact => Some(build_openai_cli_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
true,
|
||||
)),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::ai_pipeline::transport::antigravity::{
|
||||
};
|
||||
use crate::ai_pipeline::transport::apply_local_body_rules;
|
||||
use crate::ai_pipeline::transport::url::{
|
||||
build_claude_messages_url, build_gemini_content_url, build_openai_cli_url,
|
||||
build_passthrough_path_url,
|
||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||
build_openai_cli_url, build_passthrough_path_url,
|
||||
};
|
||||
use crate::ai_pipeline::{
|
||||
build_cross_format_openai_cli_request_body as pipeline_build_cross_format_openai_cli_request_body,
|
||||
@@ -140,16 +140,15 @@ pub(crate) fn build_cross_format_openai_cli_upstream_url(
|
||||
build_passthrough_path_url(&transport.endpoint.base_url, path, parts.uri.query(), &[])
|
||||
}
|
||||
None => match conversion_kind {
|
||||
RequestConversionKind::ToOpenAIChat => Some(build_openai_chat_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
)),
|
||||
RequestConversionKind::ToOpenAIFamilyCli => Some(build_openai_cli_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
false,
|
||||
)),
|
||||
RequestConversionKind::ToOpenAICompact => Some(build_openai_cli_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
true,
|
||||
)),
|
||||
RequestConversionKind::ToClaudeStandard => Some(build_claude_messages_url(
|
||||
&transport.endpoint.base_url,
|
||||
parts.uri.query(),
|
||||
@@ -160,7 +159,6 @@ pub(crate) fn build_cross_format_openai_cli_upstream_url(
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde_json::json;
|
||||
use super::build_cross_format_openai_cli_request_body;
|
||||
|
||||
#[test]
|
||||
fn builds_openai_family_cross_format_request_body_from_compact_source() {
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
@@ -12,18 +12,18 @@ fn builds_openai_family_cross_format_request_body_from_compact_source() {
|
||||
let provider_request_body = build_cross_format_openai_cli_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:compact",
|
||||
"openai:cli",
|
||||
"openai:chat",
|
||||
false,
|
||||
"openai",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("compact to openai cli body should build");
|
||||
.expect("openai cli to openai chat 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");
|
||||
assert_eq!(provider_request_body["messages"][0]["role"], "user");
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(super) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
)
|
||||
.await
|
||||
}
|
||||
"claude:chat" | "gemini:chat" | "openai:cli" | "openai:compact" => {
|
||||
"claude:chat" | "claude:cli" | "gemini:chat" | "gemini:cli" | "openai:cli" => {
|
||||
build_cross_format_local_openai_chat_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
|
||||
+27
-2
@@ -6,7 +6,9 @@ use tracing::warn;
|
||||
|
||||
use crate::ai_pipeline::collect_control_headers;
|
||||
use crate::ai_pipeline::conversion::{
|
||||
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
|
||||
request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_conversion_transport_supported,
|
||||
request_pair_allowed_for_transport,
|
||||
};
|
||||
use crate::ai_pipeline::planner::common::OPENAI_CHAT_STREAM_PLAN_KIND;
|
||||
use crate::ai_pipeline::planner::standard::{
|
||||
@@ -50,6 +52,28 @@ pub(super) async fn build_cross_format_local_openai_chat_decision_payload_for_ca
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !request_pair_allowed_for_transport(&transport, "openai:chat", provider_api_format.as_str())
|
||||
{
|
||||
let skip_reason =
|
||||
if request_conversion_requires_enable_flag("openai:chat", provider_api_format.as_str())
|
||||
&& !transport.provider.enable_format_conversion
|
||||
{
|
||||
"format_conversion_disabled"
|
||||
} else {
|
||||
"transport_unsupported"
|
||||
};
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
if !request_conversion_transport_supported(transport, conversion_kind) {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
@@ -63,7 +87,6 @@ pub(super) async fn build_cross_format_local_openai_chat_decision_payload_for_ca
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
let resolve_auth = request_conversion_direct_auth(transport, conversion_kind);
|
||||
let oauth_auth = if resolve_auth.is_none() {
|
||||
match planner_state
|
||||
@@ -258,6 +281,8 @@ pub(super) async fn build_cross_format_local_openai_chat_decision_payload_for_ca
|
||||
json!({
|
||||
"user_id": input.auth_context.user_id,
|
||||
"api_key_id": input.auth_context.api_key_id,
|
||||
"username": input.auth_context.username,
|
||||
"api_key_name": input.auth_context.api_key_name,
|
||||
"request_id": trace_id,
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_index": candidate_index,
|
||||
|
||||
+2
@@ -233,6 +233,8 @@ pub(super) async fn build_same_format_local_openai_chat_decision_payload_for_can
|
||||
json!({
|
||||
"user_id": input.auth_context.user_id,
|
||||
"api_key_id": input.auth_context.api_key_id,
|
||||
"username": input.auth_context.username,
|
||||
"api_key_name": input.auth_context.api_key_name,
|
||||
"request_id": trace_id,
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_index": candidate_index,
|
||||
|
||||
+71
-4
@@ -3,7 +3,13 @@ use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext;
|
||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||
use crate::ai_pipeline::conversion::{
|
||||
request_conversion_kind, request_conversion_requires_enable_flag,
|
||||
request_pair_allowed_for_transport,
|
||||
};
|
||||
use crate::ai_pipeline::planner::candidate_affinity::{
|
||||
rank_local_execution_candidates, remember_scheduler_affinity_for_candidate,
|
||||
};
|
||||
use crate::ai_pipeline::GatewayAuthApiKeySnapshot;
|
||||
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, PlannerAppState};
|
||||
use crate::clock::current_unix_secs;
|
||||
@@ -14,6 +20,7 @@ pub(crate) struct LocalOpenAiChatDecisionInput {
|
||||
pub(crate) auth_context: ExecutionRuntimeAuthContext,
|
||||
pub(crate) requested_model: String,
|
||||
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||
pub(crate) required_capabilities: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -48,6 +55,7 @@ pub(crate) async fn mark_skipped_local_openai_chat_candidate(
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
"gateway local openai chat decision failed to persist skipped candidate",
|
||||
@@ -62,13 +70,71 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
) -> Vec<LocalOpenAiChatCandidateAttempt> {
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let candidates = prefer_local_tunnel_owner_candidates(planner_state, candidates).await;
|
||||
let created_at_unix_secs = current_unix_secs();
|
||||
let candidates = rank_local_execution_candidates(
|
||||
planner_state,
|
||||
candidates,
|
||||
"openai:chat",
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let created_at_unix_ms = current_unix_secs();
|
||||
let mut attempts = Vec::with_capacity(candidates.len());
|
||||
let mut affinity_remembered = false;
|
||||
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
let generated_candidate_id = Uuid::new_v4().to_string();
|
||||
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
|
||||
if provider_api_format != "openai:chat" {
|
||||
if let Ok(Some(transport)) = planner_state
|
||||
.read_provider_transport_snapshot(
|
||||
&candidate.provider_id,
|
||||
&candidate.endpoint_id,
|
||||
&candidate.key_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
"openai:chat",
|
||||
provider_api_format.as_str(),
|
||||
) {
|
||||
let skip_reason =
|
||||
if request_conversion_kind("openai:chat", provider_api_format.as_str())
|
||||
.is_some()
|
||||
&& request_conversion_requires_enable_flag(
|
||||
"openai:chat",
|
||||
provider_api_format.as_str(),
|
||||
)
|
||||
&& !transport.provider.enable_format_conversion
|
||||
{
|
||||
"format_conversion_disabled"
|
||||
} else {
|
||||
"transport_unsupported"
|
||||
};
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&generated_candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !affinity_remembered {
|
||||
remember_scheduler_affinity_for_candidate(
|
||||
planner_state,
|
||||
Some(&input.auth_snapshot),
|
||||
"openai:chat",
|
||||
&input.requested_model,
|
||||
&candidate,
|
||||
);
|
||||
affinity_remembered = true;
|
||||
}
|
||||
let (execution_strategy, conversion_mode) = if provider_api_format == "openai:chat" {
|
||||
(ExecutionStrategy::LocalSameFormat, ConversionMode::None)
|
||||
} else {
|
||||
@@ -103,8 +169,9 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&generated_candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(extra_data),
|
||||
created_at_unix_secs,
|
||||
created_at_unix_ms,
|
||||
"gateway local openai chat decision request candidate upsert failed",
|
||||
)
|
||||
.await;
|
||||
|
||||
+3
-11
@@ -3,6 +3,7 @@ use std::collections::BTreeSet;
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
|
||||
use super::super::{GatewayError, LocalOpenAiChatDecisionInput};
|
||||
use crate::ai_pipeline::conversion::request_candidate_api_formats;
|
||||
use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState};
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::AppState;
|
||||
@@ -17,17 +18,7 @@ pub(crate) async fn list_local_openai_chat_candidates(
|
||||
let mut combined = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
|
||||
let api_formats = if require_streaming {
|
||||
vec!["openai:chat", "claude:chat", "gemini:chat", "openai:cli"]
|
||||
} else {
|
||||
vec![
|
||||
"openai:chat",
|
||||
"claude:chat",
|
||||
"gemini:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
]
|
||||
};
|
||||
let api_formats = request_candidate_api_formats("openai:chat", require_streaming);
|
||||
|
||||
for api_format in api_formats {
|
||||
let auth_snapshot = if api_format == "openai:chat" {
|
||||
@@ -40,6 +31,7 @@ pub(crate) async fn list_local_openai_chat_candidates(
|
||||
api_format,
|
||||
&input.requested_model,
|
||||
require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
auth_snapshot,
|
||||
now_unix_secs,
|
||||
)
|
||||
|
||||
@@ -108,9 +108,19 @@ pub(crate) async fn resolve_local_openai_chat_decision_input(
|
||||
}
|
||||
};
|
||||
|
||||
let required_capabilities = planner_state
|
||||
.resolve_request_candidate_required_capabilities(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(LocalOpenAiChatDecisionInput {
|
||||
auth_context,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
required_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
||||
json!({
|
||||
"user_id": input.auth_context.user_id,
|
||||
"api_key_id": input.auth_context.api_key_id,
|
||||
"username": input.auth_context.username,
|
||||
"api_key_name": input.auth_context.api_key_name,
|
||||
"request_id": trace_id,
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_index": candidate_index,
|
||||
|
||||
+33
-1
@@ -5,7 +5,9 @@ use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_pipeline::conversion::{
|
||||
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
|
||||
request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_conversion_transport_supported,
|
||||
request_pair_allowed_for_transport,
|
||||
};
|
||||
use crate::ai_pipeline::planner::common::force_upstream_streaming_for_provider;
|
||||
use crate::ai_pipeline::planner::standard::{
|
||||
@@ -115,6 +117,36 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
||||
|
||||
let same_format = provider_api_format == spec.api_format.trim().to_ascii_lowercase();
|
||||
let conversion_kind = request_conversion_kind(spec.api_format, provider_api_format.as_str());
|
||||
if !same_format
|
||||
&& !request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
)
|
||||
{
|
||||
let skip_reason = if conversion_kind.is_some()
|
||||
&& request_conversion_requires_enable_flag(
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
)
|
||||
&& !transport.provider.enable_format_conversion
|
||||
{
|
||||
"format_conversion_disabled"
|
||||
} else {
|
||||
"transport_unsupported"
|
||||
};
|
||||
mark_skipped_local_openai_cli_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
let transport_supported = if same_format {
|
||||
supports_local_standard_transport_with_network(&transport, provider_api_format.as_str())
|
||||
} else {
|
||||
|
||||
+86
-15
@@ -6,8 +6,13 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext;
|
||||
use crate::ai_pipeline::conversion::request_conversion_kind;
|
||||
use crate::ai_pipeline::planner::candidate_affinity::prefer_local_tunnel_owner_candidates;
|
||||
use crate::ai_pipeline::conversion::{
|
||||
request_candidate_api_formats, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_pair_allowed_for_transport,
|
||||
};
|
||||
use crate::ai_pipeline::planner::candidate_affinity::{
|
||||
rank_local_execution_candidates, remember_scheduler_affinity_for_candidate,
|
||||
};
|
||||
use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, ConversionMode, ExecutionStrategy,
|
||||
GatewayControlDecision,
|
||||
@@ -23,6 +28,7 @@ pub(crate) struct LocalOpenAiCliDecisionInput {
|
||||
pub(crate) auth_context: ExecutionRuntimeAuthContext,
|
||||
pub(crate) requested_model: String,
|
||||
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||
pub(crate) required_capabilities: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -70,10 +76,20 @@ pub(crate) async fn resolve_local_openai_cli_decision_input(
|
||||
}
|
||||
};
|
||||
|
||||
let required_capabilities = planner_state
|
||||
.resolve_request_candidate_required_capabilities(
|
||||
&auth_context.user_id,
|
||||
&auth_context.api_key_id,
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
Some(LocalOpenAiCliDecisionInput {
|
||||
auth_context,
|
||||
requested_model,
|
||||
auth_snapshot,
|
||||
required_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,8 +102,10 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let mut seen_candidates = BTreeSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
for candidate_api_format in candidate_api_formats_for_spec(spec) {
|
||||
let auth_snapshot = if *candidate_api_format == spec.api_format {
|
||||
for candidate_api_format in
|
||||
request_candidate_api_formats(spec.api_format, spec.require_streaming)
|
||||
{
|
||||
let auth_snapshot = if candidate_api_format == spec.api_format {
|
||||
Some(&input.auth_snapshot)
|
||||
} else {
|
||||
None
|
||||
@@ -97,6 +115,7 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
candidate_api_format,
|
||||
&input.requested_model,
|
||||
spec.require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
auth_snapshot,
|
||||
current_unix_secs(),
|
||||
)
|
||||
@@ -125,13 +144,71 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
}
|
||||
}
|
||||
}
|
||||
let candidates = prefer_local_tunnel_owner_candidates(planner_state, candidates).await;
|
||||
let candidates = rank_local_execution_candidates(
|
||||
planner_state,
|
||||
candidates,
|
||||
spec.api_format,
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let created_at_unix_secs = current_unix_secs();
|
||||
let created_at_unix_ms = current_unix_secs();
|
||||
let mut attempts = Vec::with_capacity(candidates.len());
|
||||
let mut affinity_remembered = false;
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
let generated_candidate_id = Uuid::new_v4().to_string();
|
||||
let provider_api_format = candidate.endpoint_api_format.trim().to_ascii_lowercase();
|
||||
if provider_api_format != spec.api_format.trim().to_ascii_lowercase() {
|
||||
if let Ok(Some(transport)) = planner_state
|
||||
.read_provider_transport_snapshot(
|
||||
&candidate.provider_id,
|
||||
&candidate.endpoint_id,
|
||||
&candidate.key_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !request_pair_allowed_for_transport(
|
||||
&transport,
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
) {
|
||||
let skip_reason =
|
||||
if request_conversion_kind(spec.api_format, provider_api_format.as_str())
|
||||
.is_some()
|
||||
&& request_conversion_requires_enable_flag(
|
||||
spec.api_format,
|
||||
provider_api_format.as_str(),
|
||||
)
|
||||
&& !transport.provider.enable_format_conversion
|
||||
{
|
||||
"format_conversion_disabled"
|
||||
} else {
|
||||
"transport_unsupported"
|
||||
};
|
||||
mark_skipped_local_openai_cli_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&generated_candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !affinity_remembered {
|
||||
remember_scheduler_affinity_for_candidate(
|
||||
planner_state,
|
||||
Some(&input.auth_snapshot),
|
||||
spec.api_format,
|
||||
&input.requested_model,
|
||||
&candidate,
|
||||
);
|
||||
affinity_remembered = true;
|
||||
}
|
||||
let execution_strategy =
|
||||
if provider_api_format == spec.api_format.trim().to_ascii_lowercase() {
|
||||
ExecutionStrategy::LocalSameFormat
|
||||
@@ -170,8 +247,9 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
&candidate,
|
||||
candidate_index as u32,
|
||||
&generated_candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
Some(extra_data),
|
||||
created_at_unix_secs,
|
||||
created_at_unix_ms,
|
||||
"gateway local openai cli decision request candidate upsert failed",
|
||||
)
|
||||
.await;
|
||||
@@ -217,14 +295,6 @@ fn auth_snapshot_allows_cross_format_openai_cli_candidate(
|
||||
true
|
||||
}
|
||||
|
||||
fn candidate_api_formats_for_spec(spec: LocalOpenAiCliSpec) -> &'static [&'static str] {
|
||||
match spec.api_format {
|
||||
"openai:compact" => &["openai:compact", "openai:cli", "claude:cli", "gemini:cli"],
|
||||
"openai:cli" => &["openai:cli", "claude:cli", "gemini:cli"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_skipped_local_openai_cli_candidate(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiCliDecisionInput,
|
||||
@@ -242,6 +312,7 @@ pub(crate) async fn mark_skipped_local_openai_cli_candidate(
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
"gateway local openai cli decision failed to persist skipped candidate",
|
||||
|
||||
@@ -4,6 +4,23 @@ use serde_json::Value;
|
||||
use super::PlannerAppState;
|
||||
|
||||
impl<'a> PlannerAppState<'a> {
|
||||
pub(crate) async fn resolve_request_candidate_required_capabilities(
|
||||
self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
requested_model: Option<&str>,
|
||||
explicit_required_capabilities: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
crate::request_candidate_runtime::resolve_request_candidate_required_capabilities(
|
||||
self.app(),
|
||||
user_id,
|
||||
api_key_id,
|
||||
requested_model,
|
||||
explicit_required_capabilities,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn persist_available_local_candidate(
|
||||
self,
|
||||
@@ -13,8 +30,9 @@ impl<'a> PlannerAppState<'a> {
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
required_capabilities: Option<&Value>,
|
||||
extra_data: Option<Value>,
|
||||
created_at_unix_secs: u64,
|
||||
created_at_unix_ms: u64,
|
||||
error_context: &'static str,
|
||||
) -> String {
|
||||
crate::request_candidate_runtime::persist_available_local_candidate(
|
||||
@@ -25,8 +43,9 @@ impl<'a> PlannerAppState<'a> {
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
required_capabilities,
|
||||
extra_data,
|
||||
created_at_unix_secs,
|
||||
created_at_unix_ms,
|
||||
error_context,
|
||||
)
|
||||
.await
|
||||
@@ -41,8 +60,9 @@ impl<'a> PlannerAppState<'a> {
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
required_capabilities: Option<&Value>,
|
||||
skip_reason: &str,
|
||||
finished_at_unix_secs: u64,
|
||||
finished_at_unix_ms: u64,
|
||||
error_context: &'static str,
|
||||
) {
|
||||
crate::request_candidate_runtime::persist_skipped_local_candidate(
|
||||
@@ -53,8 +73,9 @@ impl<'a> PlannerAppState<'a> {
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
required_capabilities,
|
||||
skip_reason,
|
||||
finished_at_unix_secs,
|
||||
finished_at_unix_ms,
|
||||
error_context,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -9,6 +9,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
api_format: &str,
|
||||
global_model_name: &str,
|
||||
require_streaming: bool,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||
@@ -18,6 +19,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
api_format,
|
||||
global_model_name,
|
||||
require_streaming,
|
||||
required_capabilities,
|
||||
auth_snapshot,
|
||||
now_unix_secs,
|
||||
)
|
||||
|
||||
@@ -44,18 +44,20 @@ pub(crate) use aether_ai_pipeline::api::{
|
||||
provider_adaptation_descriptor_for_provider_type,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope,
|
||||
provider_private_response_allows_sync_finalize, request_conversion_direct_auth,
|
||||
request_conversion_kind, request_conversion_transport_supported, resolve_claude_stream_spec,
|
||||
resolve_claude_sync_spec, resolve_execution_runtime_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind, resolve_finalize_stream_rewrite_mode,
|
||||
resolve_gemini_files_stream_spec, resolve_gemini_files_sync_spec, resolve_gemini_stream_spec,
|
||||
resolve_gemini_sync_spec, resolve_local_same_format_stream_spec,
|
||||
resolve_local_same_format_sync_spec, resolve_local_video_sync_spec,
|
||||
resolve_openai_chat_max_tokens, resolve_openai_cli_stream_spec, resolve_openai_cli_sync_spec,
|
||||
stream_body_contains_error_event, supports_stream_scheduler_decision_kind,
|
||||
supports_sync_scheduler_decision_kind, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, transform_provider_private_stream_line, value_as_u64,
|
||||
CanonicalStreamFrame, ClaudeClientEmitter, ClaudeProviderState, ExecutionRuntimeAuthContext,
|
||||
provider_private_response_allows_sync_finalize, request_candidate_api_formats,
|
||||
request_conversion_direct_auth, request_conversion_kind,
|
||||
request_conversion_requires_enable_flag, request_conversion_transport_supported,
|
||||
request_pair_allowed_for_transport, resolve_claude_stream_spec, resolve_claude_sync_spec,
|
||||
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
|
||||
resolve_finalize_stream_rewrite_mode, resolve_gemini_files_stream_spec,
|
||||
resolve_gemini_files_sync_spec, resolve_gemini_stream_spec, resolve_gemini_sync_spec,
|
||||
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
|
||||
resolve_local_video_sync_spec, resolve_openai_chat_max_tokens, resolve_openai_cli_stream_spec,
|
||||
resolve_openai_cli_sync_spec, stream_body_contains_error_event,
|
||||
supports_stream_scheduler_decision_kind, supports_sync_scheduler_decision_kind,
|
||||
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
|
||||
transform_provider_private_stream_line, value_as_u64, CanonicalStreamFrame,
|
||||
ClaudeClientEmitter, ClaudeProviderState, ExecutionRuntimeAuthContext,
|
||||
FinalizeStreamRewriteMode, GatewayControlPlanRequest, GatewayControlPlanResponse,
|
||||
GatewayControlSyncDecisionResponse, GeminiClientEmitter, GeminiProviderState,
|
||||
LocalCoreSyncErrorKind, LocalGeminiFilesSpec, LocalOpenAiCliSpec,
|
||||
|
||||
Reference in New Issue
Block a user