mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 12:09:50 +08:00
feat(routing): add strategy failover controls
This commit is contained in:
@@ -194,7 +194,7 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalSameFormatProviderSyncA
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||||
@@ -246,7 +246,7 @@ impl LocalExecutionAttemptSource<AiStreamAttempt>
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ pub(crate) fn build_local_execution_report_context(
|
|||||||
insert_pool_key_lease_report_context_fields(&mut extra_fields, parts.pool_key_lease);
|
insert_pool_key_lease_report_context_fields(&mut extra_fields, parts.pool_key_lease);
|
||||||
insert_scheduler_affinity_policy_report_context_field(&mut extra_fields, parts.routing_policy);
|
insert_scheduler_affinity_policy_report_context_field(&mut extra_fields, parts.routing_policy);
|
||||||
if let Some(policy) = parts.routing_policy {
|
if let Some(policy) = parts.routing_policy {
|
||||||
if let Ok(value) = serde_json::to_value(policy.execution_policy) {
|
if let Ok(value) = serde_json::to_value(&policy.execution_policy) {
|
||||||
extra_fields.insert(ROUTING_EXECUTION_POLICY_REPORT_FIELD.to_string(), value);
|
extra_fields.insert(ROUTING_EXECUTION_POLICY_REPORT_FIELD.to_string(), value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalGeminiFilesSyncAttemptS
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||||
@@ -224,7 +224,7 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalGeminiFilesStreamAtte
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiImageSyncAttemptS
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||||
@@ -302,7 +302,7 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiImageStreamAtte
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalVideoCreateSyncAttemptS
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalStandardSyncAttemptSour
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||||
@@ -232,7 +232,7 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalStandardStreamAttempt
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiChatStreamAttem
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiChatSyncAttemptSo
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiResponsesSyncAtte
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||||
@@ -216,7 +216,7 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiResponsesStream
|
|||||||
self.input
|
self.input
|
||||||
.routing_policy
|
.routing_policy
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|policy| policy.execution_policy)
|
.map(|policy| policy.execution_policy.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||||
|
|||||||
@@ -1036,6 +1036,7 @@ mod tests {
|
|||||||
policy,
|
policy,
|
||||||
LocalFailoverPolicy {
|
LocalFailoverPolicy {
|
||||||
max_retries: Some(1),
|
max_retries: Some(1),
|
||||||
|
routing_rules: Default::default(),
|
||||||
max_transfer_count: 0,
|
max_transfer_count: 0,
|
||||||
max_transfer_timeout_seconds: 0,
|
max_transfer_timeout_seconds: 0,
|
||||||
stop_status_codes: [503].into_iter().collect(),
|
stop_status_codes: [503].into_iter().collect(),
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const GEMINI_PRECOMMIT_MAX_WAIT: Duration = Duration::from_millis(750);
|
|||||||
pub(super) enum StreamCommitPolicy {
|
pub(super) enum StreamCommitPolicy {
|
||||||
ResponseHeaders,
|
ResponseHeaders,
|
||||||
FirstClassifiedBody,
|
FirstClassifiedBody,
|
||||||
|
FirstSseSemanticEvent {
|
||||||
|
max_bytes: usize,
|
||||||
|
max_wait: Duration,
|
||||||
|
},
|
||||||
FirstAnthropicSemanticEvent {
|
FirstAnthropicSemanticEvent {
|
||||||
max_bytes: usize,
|
max_bytes: usize,
|
||||||
max_wait: Duration,
|
max_wait: Duration,
|
||||||
@@ -36,16 +40,21 @@ impl StreamCommitPolicy {
|
|||||||
return Self::FirstClassifiedBody;
|
return Self::FirstClassifiedBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
if force_prefetch {
|
|
||||||
return Self::FirstClassifiedBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_type = content_type
|
let content_type = content_type
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if content_type.contains("text/event-stream") {
|
if content_type.contains("text/event-stream") {
|
||||||
|
if provider_api_format.eq_ignore_ascii_case("openai:image")
|
||||||
|
|| client_api_format.eq_ignore_ascii_case("openai:image")
|
||||||
|
{
|
||||||
|
return if force_prefetch {
|
||||||
|
Self::FirstClassifiedBody
|
||||||
|
} else {
|
||||||
|
Self::ResponseHeaders
|
||||||
|
};
|
||||||
|
}
|
||||||
if provider_api_format.eq_ignore_ascii_case("claude:messages")
|
if provider_api_format.eq_ignore_ascii_case("claude:messages")
|
||||||
&& provider_api_format.eq_ignore_ascii_case(client_api_format)
|
&& provider_api_format.eq_ignore_ascii_case(client_api_format)
|
||||||
&& !has_private_stream_normalizer
|
&& !has_private_stream_normalizer
|
||||||
@@ -62,7 +71,14 @@ impl StreamCommitPolicy {
|
|||||||
max_wait: GEMINI_PRECOMMIT_MAX_WAIT,
|
max_wait: GEMINI_PRECOMMIT_MAX_WAIT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return Self::ResponseHeaders;
|
return Self::FirstSseSemanticEvent {
|
||||||
|
max_bytes: MAX_STREAM_PREFETCH_BYTES,
|
||||||
|
max_wait: Duration::from_secs(30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if force_prefetch {
|
||||||
|
return Self::FirstClassifiedBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_private_stream_normalizer || has_local_stream_rewriter {
|
if has_private_stream_normalizer || has_local_stream_rewriter {
|
||||||
@@ -91,14 +107,17 @@ impl StreamCommitPolicy {
|
|||||||
pub(super) const fn requires_bounded_frame_wait(self) -> bool {
|
pub(super) const fn requires_bounded_frame_wait(self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
Self::FirstAnthropicSemanticEvent { .. } | Self::FirstGeminiSemanticEvent { .. }
|
Self::FirstAnthropicSemanticEvent { .. }
|
||||||
|
| Self::FirstGeminiSemanticEvent { .. }
|
||||||
|
| Self::FirstSseSemanticEvent { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) const fn max_precommit_wait(self) -> Option<Duration> {
|
pub(super) const fn max_precommit_wait(self) -> Option<Duration> {
|
||||||
match self {
|
match self {
|
||||||
Self::FirstAnthropicSemanticEvent { max_wait, .. }
|
Self::FirstAnthropicSemanticEvent { max_wait, .. }
|
||||||
| Self::FirstGeminiSemanticEvent { max_wait, .. } => Some(max_wait),
|
| Self::FirstGeminiSemanticEvent { max_wait, .. }
|
||||||
|
| Self::FirstSseSemanticEvent { max_wait, .. } => Some(max_wait),
|
||||||
Self::ResponseHeaders | Self::FirstClassifiedBody => None,
|
Self::ResponseHeaders | Self::FirstClassifiedBody => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +129,16 @@ impl StreamCommitPolicy {
|
|||||||
pub(super) const fn is_gemini(self) -> bool {
|
pub(super) const fn is_gemini(self) -> bool {
|
||||||
matches!(self, Self::FirstGeminiSemanticEvent { .. })
|
matches!(self, Self::FirstGeminiSemanticEvent { .. })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn with_precommit_wait(mut self, wait: Duration) -> Self {
|
||||||
|
match &mut self {
|
||||||
|
Self::FirstAnthropicSemanticEvent { max_wait, .. }
|
||||||
|
| Self::FirstGeminiSemanticEvent { max_wait, .. }
|
||||||
|
| Self::FirstSseSemanticEvent { max_wait, .. } => *max_wait = wait,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -133,6 +162,7 @@ pub(super) struct StreamCommitGate {
|
|||||||
observed_bytes: usize,
|
observed_bytes: usize,
|
||||||
anthropic: AnthropicSsePrecommitInspector,
|
anthropic: AnthropicSsePrecommitInspector,
|
||||||
gemini: GeminiSsePrecommitInspector,
|
gemini: GeminiSsePrecommitInspector,
|
||||||
|
generic: GenericSsePrecommitInspector,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamCommitGate {
|
impl StreamCommitGate {
|
||||||
@@ -148,6 +178,7 @@ impl StreamCommitGate {
|
|||||||
observed_bytes: 0,
|
observed_bytes: 0,
|
||||||
anthropic: AnthropicSsePrecommitInspector::default(),
|
anthropic: AnthropicSsePrecommitInspector::default(),
|
||||||
gemini: GeminiSsePrecommitInspector::default(),
|
gemini: GeminiSsePrecommitInspector::default(),
|
||||||
|
generic: GenericSsePrecommitInspector::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +202,9 @@ impl StreamCommitGate {
|
|||||||
StreamCommitPolicy::FirstGeminiSemanticEvent { max_bytes, .. } => {
|
StreamCommitPolicy::FirstGeminiSemanticEvent { max_bytes, .. } => {
|
||||||
(max_bytes, self.gemini.observe(chunk, max_bytes))
|
(max_bytes, self.gemini.observe(chunk, max_bytes))
|
||||||
}
|
}
|
||||||
|
StreamCommitPolicy::FirstSseSemanticEvent { max_bytes, .. } => {
|
||||||
|
(max_bytes, self.generic.observe(chunk, max_bytes))
|
||||||
|
}
|
||||||
StreamCommitPolicy::ResponseHeaders | StreamCommitPolicy::FirstClassifiedBody => {
|
StreamCommitPolicy::ResponseHeaders | StreamCommitPolicy::FirstClassifiedBody => {
|
||||||
return StreamPrecommitObservation::Pending;
|
return StreamPrecommitObservation::Pending;
|
||||||
}
|
}
|
||||||
@@ -217,6 +251,152 @@ enum SemanticSseObservation {
|
|||||||
Error { status_code: u16, body_json: Value },
|
Error { status_code: u16, body_json: Value },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct GenericSsePrecommitInspector {
|
||||||
|
buffered: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenericSsePrecommitInspector {
|
||||||
|
fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> SemanticSseObservation {
|
||||||
|
let remaining = max_bytes.saturating_sub(self.buffered.len());
|
||||||
|
self.buffered
|
||||||
|
.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
|
||||||
|
while let Some((record_end, separator_len)) = find_sse_record_boundary(&self.buffered) {
|
||||||
|
let record = self.buffered[..record_end].to_vec();
|
||||||
|
self.buffered.drain(..record_end + separator_len);
|
||||||
|
match classify_generic_sse_record(&record) {
|
||||||
|
SemanticSseObservation::Pending => {}
|
||||||
|
observation => return observation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chunk.len() > remaining {
|
||||||
|
SemanticSseObservation::SemanticEvent
|
||||||
|
} else {
|
||||||
|
SemanticSseObservation::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_generic_sse_record(record: &[u8]) -> SemanticSseObservation {
|
||||||
|
let Ok(record) = std::str::from_utf8(record) else {
|
||||||
|
return SemanticSseObservation::SemanticEvent;
|
||||||
|
};
|
||||||
|
let normalized = record.replace("\r\n", "\n").replace('\r', "\n");
|
||||||
|
let event_type = normalized
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("event:").map(str::trim));
|
||||||
|
let data = normalized
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
if data.trim().is_empty() || matches!(event_type, Some("ping" | "heartbeat" | "keepalive")) {
|
||||||
|
return SemanticSseObservation::Pending;
|
||||||
|
}
|
||||||
|
if data.trim() == "[DONE]" {
|
||||||
|
return SemanticSseObservation::SemanticEvent;
|
||||||
|
}
|
||||||
|
let Ok(body_json) = serde_json::from_str::<Value>(data.trim()) else {
|
||||||
|
return SemanticSseObservation::SemanticEvent;
|
||||||
|
};
|
||||||
|
let payload_type = body_json.get("type").and_then(Value::as_str).or(event_type);
|
||||||
|
if payload_type.is_some_and(is_anthropic_semantic_event_type) {
|
||||||
|
return classify_anthropic_sse_record(record.as_bytes());
|
||||||
|
}
|
||||||
|
let error = body_json
|
||||||
|
.get("error")
|
||||||
|
.filter(|value| !value.is_null())
|
||||||
|
.or_else(|| {
|
||||||
|
body_json
|
||||||
|
.pointer("/response/error")
|
||||||
|
.filter(|value| !value.is_null())
|
||||||
|
});
|
||||||
|
if error.is_some()
|
||||||
|
|| matches!(payload_type, Some("error" | "response.failed"))
|
||||||
|
|| body_json.get("status").and_then(Value::as_str) == Some("failed")
|
||||||
|
{
|
||||||
|
let failure = error
|
||||||
|
.map(|error| serde_json::json!({ "error": error }))
|
||||||
|
.unwrap_or_else(|| body_json.clone());
|
||||||
|
return SemanticSseObservation::Error {
|
||||||
|
status_code: crate::execution_runtime::submission::resolve_local_sync_error_status_code(
|
||||||
|
200, &failure,
|
||||||
|
),
|
||||||
|
body_json: failure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
payload_type,
|
||||||
|
Some("ping" | "response.created" | "response.in_progress" | "response.queued")
|
||||||
|
) {
|
||||||
|
return SemanticSseObservation::Pending;
|
||||||
|
}
|
||||||
|
if payload_type == Some("response.output_item.added")
|
||||||
|
&& matches!(
|
||||||
|
body_json.pointer("/item/type").and_then(Value::as_str),
|
||||||
|
Some("message" | "reasoning")
|
||||||
|
)
|
||||||
|
&& body_json
|
||||||
|
.pointer("/item/content")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_none_or(Vec::is_empty)
|
||||||
|
&& body_json
|
||||||
|
.pointer("/item/summary")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_none_or(Vec::is_empty)
|
||||||
|
{
|
||||||
|
return SemanticSseObservation::Pending;
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
payload_type,
|
||||||
|
Some("response.content_part.added" | "response.reasoning_summary_part.added")
|
||||||
|
) && matches!(
|
||||||
|
body_json.pointer("/part/type").and_then(Value::as_str),
|
||||||
|
Some("output_text" | "summary_text" | "refusal")
|
||||||
|
) && !body_json
|
||||||
|
.pointer("/part/text")
|
||||||
|
.is_some_and(value_has_semantic_content)
|
||||||
|
&& !body_json
|
||||||
|
.pointer("/part/refusal")
|
||||||
|
.is_some_and(value_has_semantic_content)
|
||||||
|
{
|
||||||
|
return SemanticSseObservation::Pending;
|
||||||
|
}
|
||||||
|
if let Some(choices) = body_json.get("choices").and_then(Value::as_array) {
|
||||||
|
let semantic = choices.iter().any(|choice| {
|
||||||
|
choice
|
||||||
|
.get("finish_reason")
|
||||||
|
.is_some_and(|value| !value.is_null())
|
||||||
|
|| choice.get("text").is_some_and(value_has_semantic_content)
|
||||||
|
|| choice
|
||||||
|
.get("delta")
|
||||||
|
.or_else(|| choice.get("message"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.is_some_and(|delta| {
|
||||||
|
delta.iter().any(|(name, value)| {
|
||||||
|
name != "role" && value_has_semantic_content(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return if semantic {
|
||||||
|
SemanticSseObservation::SemanticEvent
|
||||||
|
} else {
|
||||||
|
SemanticSseObservation::Pending
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SemanticSseObservation::SemanticEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_has_semantic_content(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Null => false,
|
||||||
|
Value::String(text) => !text.is_empty(),
|
||||||
|
Value::Array(values) => !values.is_empty(),
|
||||||
|
Value::Object(values) => !values.is_empty(),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct AnthropicSsePrecommitInspector {
|
struct AnthropicSsePrecommitInspector {
|
||||||
buffered: Vec<u8>,
|
buffered: Vec<u8>,
|
||||||
@@ -355,7 +535,30 @@ fn classify_anthropic_sse_record(record: &[u8]) -> SemanticSseObservation {
|
|||||||
(None, Some(payload_type)) => Some(payload_type),
|
(None, Some(payload_type)) => Some(payload_type),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if semantic_type.is_some_and(is_anthropic_semantic_event_type) {
|
let setup_only = match semantic_type {
|
||||||
|
Some("message_start") => body_json
|
||||||
|
.pointer("/message/content")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_none_or(Vec::is_empty),
|
||||||
|
Some("content_block_start") => {
|
||||||
|
let block_type = body_json
|
||||||
|
.pointer("/content_block/type")
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
matches!(block_type, Some("text" | "thinking"))
|
||||||
|
&& !body_json
|
||||||
|
.pointer("/content_block/text")
|
||||||
|
.is_some_and(value_has_semantic_content)
|
||||||
|
&& !body_json
|
||||||
|
.pointer("/content_block/thinking")
|
||||||
|
.is_some_and(value_has_semantic_content)
|
||||||
|
}
|
||||||
|
Some("content_block_stop") => true,
|
||||||
|
Some("message_delta") => body_json
|
||||||
|
.pointer("/delta/stop_reason")
|
||||||
|
.is_none_or(Value::is_null),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !setup_only && semantic_type.is_some_and(is_anthropic_semantic_event_type) {
|
||||||
SemanticSseObservation::SemanticEvent
|
SemanticSseObservation::SemanticEvent
|
||||||
} else {
|
} else {
|
||||||
SemanticSseObservation::Pending
|
SemanticSseObservation::Pending
|
||||||
@@ -507,6 +710,89 @@ pub(super) fn anthropic_error_status_code(body_json: &Value) -> u16 {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn image_streams_only_prefetch_when_explicitly_requested() {
|
||||||
|
for force_prefetch in [false, true] {
|
||||||
|
let policy = super::StreamCommitPolicy::for_response(
|
||||||
|
true,
|
||||||
|
Some("text/event-stream"),
|
||||||
|
"openai:image",
|
||||||
|
"openai:image",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
force_prefetch,
|
||||||
|
);
|
||||||
|
assert_eq!(policy.commits_on_response_headers(), !force_prefetch);
|
||||||
|
assert!(!policy.requires_bounded_frame_wait());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_sse_waits_through_setup_and_classifies_fragmented_errors() {
|
||||||
|
let setup = b"event: response.created\ndata: {\"type\":\"response.created\"}\n\n";
|
||||||
|
let failure = b"event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"capacity exhausted\"}}}\n\n";
|
||||||
|
for split in 1..failure.len() {
|
||||||
|
let policy = super::StreamCommitPolicy::FirstSseSemanticEvent {
|
||||||
|
max_bytes: 4096,
|
||||||
|
max_wait: std::time::Duration::from_secs(1),
|
||||||
|
};
|
||||||
|
let mut gate = super::StreamCommitGate::new(policy);
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(setup),
|
||||||
|
super::StreamPrecommitObservation::Pending
|
||||||
|
);
|
||||||
|
for control in [
|
||||||
|
b"event: ping\ndata: keepalive\n\n".as_slice(),
|
||||||
|
b"data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"summary\":[]}}\n\n".as_slice(),
|
||||||
|
b"data: {\"type\":\"response.reasoning_summary_part.added\",\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}\n\n".as_slice(),
|
||||||
|
] {
|
||||||
|
assert_eq!(gate.observe_provider_bytes(control), super::StreamPrecommitObservation::Pending);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(&failure[..split]),
|
||||||
|
super::StreamPrecommitObservation::Pending
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
gate.observe_provider_bytes(&failure[split..]),
|
||||||
|
super::StreamPrecommitObservation::UpstreamError { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_sse_commits_on_content_or_tool_call_but_not_role() {
|
||||||
|
for output in [
|
||||||
|
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
|
||||||
|
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-1\"}]}}]}\n\n",
|
||||||
|
] {
|
||||||
|
let mut gate =
|
||||||
|
super::StreamCommitGate::new(super::StreamCommitPolicy::FirstSseSemanticEvent {
|
||||||
|
max_bytes: 4096,
|
||||||
|
max_wait: std::time::Duration::from_secs(1),
|
||||||
|
});
|
||||||
|
assert_eq!(gate.observe_provider_bytes(b"data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\n"), super::StreamPrecommitObservation::Pending);
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(output.as_bytes()),
|
||||||
|
super::StreamPrecommitObservation::Commit
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(b"data: {\"error\":{\"message\":\"late error\"}}\n\n"),
|
||||||
|
super::StreamPrecommitObservation::Commit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_anthropic_setup_does_not_hide_an_early_error() {
|
||||||
|
let mut gate =
|
||||||
|
super::StreamCommitGate::new(super::StreamCommitPolicy::FirstAnthropicSemanticEvent {
|
||||||
|
max_bytes: 4096,
|
||||||
|
max_wait: std::time::Duration::from_secs(1),
|
||||||
|
});
|
||||||
|
assert_eq!(gate.observe_provider_bytes(b"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"content\":[]}}\n\n"), super::StreamPrecommitObservation::Pending);
|
||||||
|
assert_eq!(gate.observe_provider_bytes(b"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n"), super::StreamPrecommitObservation::Pending);
|
||||||
|
assert!(matches!(gate.observe_provider_bytes(b"event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\n\n"), super::StreamPrecommitObservation::UpstreamError { status_code: 529, .. }));
|
||||||
|
}
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -553,7 +839,7 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.commits_on_response_headers());
|
.requires_bounded_frame_wait());
|
||||||
assert!(StreamCommitPolicy::for_response(
|
assert!(StreamCommitPolicy::for_response(
|
||||||
true,
|
true,
|
||||||
Some("text/event-stream"),
|
Some("text/event-stream"),
|
||||||
@@ -563,7 +849,7 @@ mod tests {
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.commits_on_response_headers());
|
.requires_bounded_frame_wait());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -745,8 +1031,8 @@ mod tests {
|
|||||||
let mut gate = StreamCommitGate::new(native_anthropic_policy());
|
let mut gate = StreamCommitGate::new(native_anthropic_policy());
|
||||||
let observation = gate.observe_provider_bytes(
|
let observation = gate.observe_provider_bytes(
|
||||||
concat!(
|
concat!(
|
||||||
"event: message_start\n",
|
"event: content_block_delta\n",
|
||||||
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
|
||||||
"event: error\n",
|
"event: error\n",
|
||||||
"data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\n\n",
|
"data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\n\n",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1414,6 +1414,7 @@ async fn prefetch_direct_anthropic_stream_failure(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut gate = StreamCommitGate::new(policy);
|
let mut gate = StreamCommitGate::new(policy);
|
||||||
|
let mut semantic_commit_observed = false;
|
||||||
let precommit_started_at = Instant::now();
|
let precommit_started_at = Instant::now();
|
||||||
let max_wait = policy.max_precommit_wait()?;
|
let max_wait = policy.max_precommit_wait()?;
|
||||||
let mut observed_first_body = execution
|
let mut observed_first_body = execution
|
||||||
@@ -1464,7 +1465,10 @@ async fn prefetch_direct_anthropic_stream_failure(
|
|||||||
execution.prefetched_body.push_back(Ok(chunk.clone()));
|
execution.prefetched_body.push_back(Ok(chunk.clone()));
|
||||||
match gate.observe_provider_bytes(&chunk) {
|
match gate.observe_provider_bytes(&chunk) {
|
||||||
StreamPrecommitObservation::Pending => {}
|
StreamPrecommitObservation::Pending => {}
|
||||||
StreamPrecommitObservation::Commit => break,
|
StreamPrecommitObservation::Commit => {
|
||||||
|
semantic_commit_observed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
StreamPrecommitObservation::UpstreamError {
|
StreamPrecommitObservation::UpstreamError {
|
||||||
status_code,
|
status_code,
|
||||||
body_json,
|
body_json,
|
||||||
@@ -1478,7 +1482,7 @@ async fn prefetch_direct_anthropic_stream_failure(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
execution.stream_precommit_committed = !gate.is_uncommitted();
|
execution.stream_precommit_committed = semantic_commit_observed;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5696,6 +5700,30 @@ fn should_probe_success_failover_before_stream(headers: &BTreeMap<String, String
|
|||||||
content_type.contains("json") || content_type.ends_with("+json")
|
content_type.contains("json") || content_type.ends_with("+json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn record_prefetch_success_failover(
|
||||||
|
state: &AppState,
|
||||||
|
plan: &ExecutionPlan,
|
||||||
|
report_context: Option<&Value>,
|
||||||
|
elapsed_ms: u64,
|
||||||
|
) {
|
||||||
|
let finished_at = current_request_candidate_unix_ms();
|
||||||
|
record_local_request_candidate_status(
|
||||||
|
state,
|
||||||
|
plan,
|
||||||
|
report_context,
|
||||||
|
SchedulerRequestCandidateStatusUpdate {
|
||||||
|
status: RequestCandidateStatus::Failed,
|
||||||
|
status_code: Some(200),
|
||||||
|
error_type: Some("success_failover_pattern".to_string()),
|
||||||
|
error_message: Some("HTTP 200 response matched a precommit failover rule".to_string()),
|
||||||
|
latency_ms: Some(elapsed_ms),
|
||||||
|
started_at_unix_ms: None,
|
||||||
|
finished_at_unix_ms: Some(finished_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn probe_local_stream_success_failover_text<R>(
|
async fn probe_local_stream_success_failover_text<R>(
|
||||||
buffered_frames: &mut VecDeque<ObservedStreamFrame>,
|
buffered_frames: &mut VecDeque<ObservedStreamFrame>,
|
||||||
lines: &mut FramedRead<R, LinesCodec>,
|
lines: &mut FramedRead<R, LinesCodec>,
|
||||||
@@ -6316,6 +6344,23 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
report_context.as_ref(),
|
report_context.as_ref(),
|
||||||
)
|
)
|
||||||
.is_some_and(|policy| policy.cyber_continue_failover);
|
.is_some_and(|policy| policy.cyber_continue_failover);
|
||||||
|
let prefetch_failover_policy =
|
||||||
|
crate::orchestration::resolve_local_failover_policy(state, &plan, report_context.as_ref())
|
||||||
|
.await;
|
||||||
|
let prefetch_success_patterns = prefetch_failover_policy
|
||||||
|
.routing_rules
|
||||||
|
.success_failover_patterns
|
||||||
|
.iter()
|
||||||
|
.map(|rule| (&rule.pattern, &rule.status_codes))
|
||||||
|
.chain(
|
||||||
|
prefetch_failover_policy
|
||||||
|
.success_failover_patterns
|
||||||
|
.iter()
|
||||||
|
.map(|rule| (&rule.pattern, &rule.status_codes)),
|
||||||
|
)
|
||||||
|
.filter(|(_, status_codes)| status_codes.is_empty() || status_codes.contains(&200))
|
||||||
|
.filter_map(|(pattern, _)| regex::Regex::new(pattern.trim()).ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let stream_commit_policy = StreamCommitPolicy::for_response(
|
let stream_commit_policy = StreamCommitPolicy::for_response(
|
||||||
direct_stream_finalize_kind.is_some(),
|
direct_stream_finalize_kind.is_some(),
|
||||||
upstream_content_type,
|
upstream_content_type,
|
||||||
@@ -6323,15 +6368,24 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
plan.client_api_format.as_str(),
|
plan.client_api_format.as_str(),
|
||||||
private_stream_normalizer.is_some(),
|
private_stream_normalizer.is_some(),
|
||||||
local_stream_rewriter.is_some(),
|
local_stream_rewriter.is_some(),
|
||||||
prefetch_for_cyber_failover,
|
prefetch_for_cyber_failover || !prefetch_success_patterns.is_empty(),
|
||||||
);
|
)
|
||||||
let reuse_committed_precommit =
|
.with_precommit_wait(Duration::from_millis(
|
||||||
stream_precommit_committed && stream_commit_policy.is_native_anthropic();
|
plan.timeouts
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|timeouts| timeouts.first_byte_ms)
|
||||||
|
.unwrap_or(30_000)
|
||||||
|
.max(1),
|
||||||
|
));
|
||||||
|
let reuse_committed_precommit = stream_precommit_committed
|
||||||
|
&& stream_commit_policy.is_native_anthropic()
|
||||||
|
&& prefetch_success_patterns.is_empty();
|
||||||
let skip_direct_finalize_prefetch =
|
let skip_direct_finalize_prefetch =
|
||||||
stream_commit_policy.commits_on_response_headers() || reuse_committed_precommit;
|
stream_commit_policy.commits_on_response_headers() || reuse_committed_precommit;
|
||||||
let limit_direct_finalize_prefetch =
|
let limit_direct_finalize_prefetch =
|
||||||
should_limit_direct_finalize_prefetch(plan_kind, local_stream_rewriter.is_some())
|
should_limit_direct_finalize_prefetch(plan_kind, local_stream_rewriter.is_some())
|
||||||
|| stream_commit_policy.requires_bounded_frame_wait();
|
|| stream_commit_policy.requires_bounded_frame_wait()
|
||||||
|
|| !prefetch_success_patterns.is_empty();
|
||||||
let mut stream_commit_gate = StreamCommitGate::new(stream_commit_policy);
|
let mut stream_commit_gate = StreamCommitGate::new(stream_commit_policy);
|
||||||
let mut prefetch_client_completion_tracker = ClientVisibleStreamCompletionTracker::default();
|
let mut prefetch_client_completion_tracker = ClientVisibleStreamCompletionTracker::default();
|
||||||
let mut prefetched_client_visible_stream_completed = false;
|
let mut prefetched_client_visible_stream_completed = false;
|
||||||
@@ -6383,7 +6437,8 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
.max_precommit_wait()
|
.max_precommit_wait()
|
||||||
.map(|max_wait| max_wait.saturating_sub(precommit_started_at.elapsed()))
|
.map(|max_wait| max_wait.saturating_sub(precommit_started_at.elapsed()))
|
||||||
.unwrap_or(REWRITTEN_STREAM_PREFETCH_TIMEOUT);
|
.unwrap_or(REWRITTEN_STREAM_PREFETCH_TIMEOUT);
|
||||||
if prefetch_timeout.is_zero() {
|
if prefetch_timeout.is_zero() && !stream_commit_policy.requires_bounded_frame_wait()
|
||||||
|
{
|
||||||
stream_commit_gate.commit();
|
stream_commit_gate.commit();
|
||||||
debug!(
|
debug!(
|
||||||
event_name = "execution_runtime_stream_prefetch_limited",
|
event_name = "execution_runtime_stream_prefetch_limited",
|
||||||
@@ -6414,6 +6469,29 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
{
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
if stream_commit_policy.requires_bounded_frame_wait() {
|
||||||
|
let failure = build_stream_transport_failure_report(
|
||||||
|
"first_byte_timeout", "Upstream did not produce a semantic event before the first byte deadline", 504,
|
||||||
|
);
|
||||||
|
return handle_prefetch_stream_failure(
|
||||||
|
state,
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
&plan,
|
||||||
|
report_context,
|
||||||
|
request_id,
|
||||||
|
candidate_id,
|
||||||
|
report_kind,
|
||||||
|
headers,
|
||||||
|
prefetched_usage_telemetry.clone(),
|
||||||
|
&provider_prefetched_body,
|
||||||
|
candidate_started_unix_secs,
|
||||||
|
stream_elapsed_ms_since(stream_started_at),
|
||||||
|
failure,
|
||||||
|
retry_scope_out.as_deref_mut(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
stream_commit_gate.commit();
|
stream_commit_gate.commit();
|
||||||
debug!(
|
debug!(
|
||||||
event_name = "execution_runtime_stream_prefetch_limited",
|
event_name = "execution_runtime_stream_prefetch_limited",
|
||||||
@@ -6468,10 +6546,11 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}) else {
|
}) else {
|
||||||
if stream_commit_policy.is_native_anthropic() && stream_commit_gate.is_uncommitted()
|
if stream_commit_policy.requires_bounded_frame_wait()
|
||||||
|
&& stream_commit_gate.is_uncommitted()
|
||||||
{
|
{
|
||||||
let error_body_json = anthropic_premature_eof_error_body(
|
let error_body_json = anthropic_premature_eof_error_body(
|
||||||
"upstream Anthropic stream ended before the first semantic event",
|
"upstream stream ended before the first semantic event",
|
||||||
);
|
);
|
||||||
let error_status_code = anthropic_error_status_code(&error_body_json);
|
let error_status_code = anthropic_error_status_code(&error_body_json);
|
||||||
return handle_prefetch_provider_private_stream_error(
|
return handle_prefetch_provider_private_stream_error(
|
||||||
@@ -6511,6 +6590,12 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
"stream_first_data",
|
"stream_first_data",
|
||||||
stream_elapsed_ms_at(stream_started_at, frame_observed_at),
|
stream_elapsed_ms_at(stream_started_at, frame_observed_at),
|
||||||
);
|
);
|
||||||
|
state.usage_runtime.record_stream_started(
|
||||||
|
state.usage_lifecycle_data_state().as_ref(),
|
||||||
|
&lifecycle_seed,
|
||||||
|
status_code,
|
||||||
|
prefetched_usage_telemetry.as_ref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut chunk =
|
let mut chunk =
|
||||||
match decode_stream_data_chunk(chunk_b64.as_deref(), text.as_deref()) {
|
match decode_stream_data_chunk(chunk_b64.as_deref(), text.as_deref()) {
|
||||||
@@ -6576,7 +6661,29 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
&mut prefetched_inspection_body_truncated,
|
&mut prefetched_inspection_body_truncated,
|
||||||
);
|
);
|
||||||
|
|
||||||
let anthropic_commit_ready =
|
if !prefetch_success_patterns.is_empty()
|
||||||
|
&& crate::orchestration::attempt_identity_from_report_context(
|
||||||
|
report_context.as_ref(),
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let response_text = String::from_utf8_lossy(&prefetched_inspection_body);
|
||||||
|
if prefetch_success_patterns.iter().any(|pattern| pattern.is_match(&response_text))
|
||||||
|
&& crate::orchestration::classify_local_failover(
|
||||||
|
&prefetch_failover_policy,
|
||||||
|
crate::orchestration::LocalFailoverInput::new(status_code, Some(&response_text)),
|
||||||
|
) == crate::orchestration::LocalFailoverClassification::RetrySuccessPattern
|
||||||
|
{
|
||||||
|
record_prefetch_success_failover(state, &plan, report_context.as_ref(), stream_elapsed_ms_since(stream_started_at)).await;
|
||||||
|
if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
|
||||||
|
*retry_scope = AiAttemptRetryScope::Candidate;
|
||||||
|
}
|
||||||
|
warn!(event_name = "local_stream_candidate_retry_scheduled", log_type = "event", trace_id, request_id, status_code, "gateway retrying after a precommit success pattern match");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let semantic_commit_ready =
|
||||||
match stream_commit_gate.observe_provider_bytes(&chunk) {
|
match stream_commit_gate.observe_provider_bytes(&chunk) {
|
||||||
StreamPrecommitObservation::Pending => false,
|
StreamPrecommitObservation::Pending => false,
|
||||||
StreamPrecommitObservation::Commit => true,
|
StreamPrecommitObservation::Commit => true,
|
||||||
@@ -6584,6 +6691,14 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
status_code: error_status_code,
|
status_code: error_status_code,
|
||||||
body_json: error_body_json,
|
body_json: error_body_json,
|
||||||
} => {
|
} => {
|
||||||
|
let error_status_code = if plan
|
||||||
|
.provider_api_format
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
{
|
||||||
|
anthropic_error_status_code(&error_body_json)
|
||||||
|
} else {
|
||||||
|
error_status_code
|
||||||
|
};
|
||||||
return handle_prefetch_provider_private_stream_error(
|
return handle_prefetch_provider_private_stream_error(
|
||||||
state,
|
state,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -6606,7 +6721,7 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !anthropic_commit_ready {
|
if !semantic_commit_ready || private_stream_normalizer.is_some() {
|
||||||
if let Some(error_body_json) = extract_provider_private_stream_error_body(
|
if let Some(error_body_json) = extract_provider_private_stream_error_body(
|
||||||
report_context.as_ref(),
|
report_context.as_ref(),
|
||||||
&prefetched_inspection_body,
|
&prefetched_inspection_body,
|
||||||
@@ -6638,9 +6753,7 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let inspection = if stream_commit_policy.is_native_anthropic()
|
let inspection = if stream_commit_policy.requires_bounded_frame_wait() {
|
||||||
|| stream_commit_policy.is_gemini()
|
|
||||||
{
|
|
||||||
StreamPrefetchInspection::NeedMore
|
StreamPrefetchInspection::NeedMore
|
||||||
} else {
|
} else {
|
||||||
inspect_prefetched_stream_body(
|
inspect_prefetched_stream_body(
|
||||||
@@ -6650,55 +6763,30 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
};
|
};
|
||||||
match inspection {
|
match inspection {
|
||||||
StreamPrefetchInspection::EmbeddedError(body_json) => {
|
StreamPrefetchInspection::EmbeddedError(body_json) => {
|
||||||
debug!(
|
let error_status_code = resolve_provider_stream_error_status_code(
|
||||||
event_name = "execution_runtime_stream_prefetch_embedded_error_detected",
|
plan.provider_api_format.as_str(),
|
||||||
log_type = "debug",
|
|
||||||
trace_id = %trace_id,
|
|
||||||
request_id = %request_id_for_log,
|
|
||||||
candidate_id = ?candidate_id,
|
|
||||||
plan_kind,
|
|
||||||
report_kind,
|
|
||||||
provider_name,
|
|
||||||
endpoint_id = %plan.endpoint_id,
|
|
||||||
key_id = %plan.key_id,
|
|
||||||
model_name,
|
|
||||||
candidate_index = candidate_index.as_str(),
|
|
||||||
provider_prefetched_body_bytes = provider_prefetched_body.len(),
|
|
||||||
"gateway detected embedded error while prefetching execution runtime stream"
|
|
||||||
);
|
|
||||||
let request_diagnostics = current_request_diagnostics();
|
|
||||||
let terminal_report_context = report_context_with_request_diagnostics(
|
|
||||||
report_context,
|
|
||||||
request_diagnostics.as_ref(),
|
|
||||||
stream_started_at,
|
|
||||||
prefetched_usage_telemetry.as_ref(),
|
|
||||||
);
|
|
||||||
let payload = build_stream_sync_payload(
|
|
||||||
trace_id,
|
|
||||||
report_kind.clone(),
|
|
||||||
terminal_report_context,
|
|
||||||
status_code,
|
status_code,
|
||||||
headers,
|
&body_json,
|
||||||
Some(body_json),
|
|
||||||
None,
|
|
||||||
prefetched_usage_telemetry.clone(),
|
|
||||||
);
|
);
|
||||||
record_sync_terminal_usage_with_handoff(
|
return handle_prefetch_provider_private_stream_error(
|
||||||
state,
|
state,
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
&plan,
|
&plan,
|
||||||
payload.report_context.as_ref(),
|
report_context,
|
||||||
&payload,
|
request_id,
|
||||||
|
candidate_id,
|
||||||
|
report_kind,
|
||||||
|
headers,
|
||||||
|
prefetched_usage_telemetry.clone(),
|
||||||
|
&provider_prefetched_body,
|
||||||
|
status_code,
|
||||||
|
error_status_code,
|
||||||
|
body_json,
|
||||||
|
retry_scope_out.as_deref_mut(),
|
||||||
|
retry_fallback_out.as_deref_mut(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let response = submit_local_core_error_or_sync_finalize(
|
|
||||||
state, trace_id, decision, payload,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
return Ok(Some(attach_control_metadata_headers(
|
|
||||||
response,
|
|
||||||
Some(request_id),
|
|
||||||
candidate_id,
|
|
||||||
)?));
|
|
||||||
}
|
}
|
||||||
StreamPrefetchInspection::NeedMore => {}
|
StreamPrefetchInspection::NeedMore => {}
|
||||||
StreamPrefetchInspection::NonError => {}
|
StreamPrefetchInspection::NonError => {}
|
||||||
@@ -6842,8 +6930,12 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
prefetched_chunks.push(Bytes::from(rewritten_chunk));
|
prefetched_chunks.push(Bytes::from(rewritten_chunk));
|
||||||
}
|
}
|
||||||
|
|
||||||
if anthropic_commit_ready
|
if semantic_commit_ready
|
||||||
|| (matches!(inspection, StreamPrefetchInspection::NonError)
|
|| (matches!(inspection, StreamPrefetchInspection::NonError)
|
||||||
|
&& (prefetch_success_patterns.is_empty()
|
||||||
|
|| response_headers_indicate_sse(&upstream_headers)
|
||||||
|
|| parse_prefetched_sync_json_body(&prefetched_inspection_body)
|
||||||
|
.is_some())
|
||||||
&& (!prefetch_for_cyber_failover
|
&& (!prefetch_for_cyber_failover
|
||||||
|| prefetched_openai_responses_body_has_output_boundary(
|
|| prefetched_openai_responses_body_has_output_boundary(
|
||||||
&prefetched_inspection_body,
|
&prefetched_inspection_body,
|
||||||
@@ -6858,11 +6950,11 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
prefetched_telemetry = Some(frame_telemetry);
|
prefetched_telemetry = Some(frame_telemetry);
|
||||||
}
|
}
|
||||||
StreamFramePayload::Eof { summary } => {
|
StreamFramePayload::Eof { summary } => {
|
||||||
if stream_commit_policy.is_native_anthropic()
|
if stream_commit_policy.requires_bounded_frame_wait()
|
||||||
&& stream_commit_gate.is_uncommitted()
|
&& stream_commit_gate.is_uncommitted()
|
||||||
{
|
{
|
||||||
let error_body_json = anthropic_premature_eof_error_body(
|
let error_body_json = anthropic_premature_eof_error_body(
|
||||||
"upstream Anthropic stream ended before the first semantic event",
|
"upstream stream ended before the first semantic event",
|
||||||
);
|
);
|
||||||
let error_status_code = anthropic_error_status_code(&error_body_json);
|
let error_status_code = anthropic_error_status_code(&error_body_json);
|
||||||
return handle_prefetch_provider_private_stream_error(
|
return handle_prefetch_provider_private_stream_error(
|
||||||
@@ -7003,7 +7095,7 @@ async fn execute_stream_from_frame_stream_with_retry_scope(
|
|||||||
let native_anthropic_stream_for_report = stream_commit_policy.is_native_anthropic();
|
let native_anthropic_stream_for_report = stream_commit_policy.is_native_anthropic();
|
||||||
let plan_for_report = plan;
|
let plan_for_report = plan;
|
||||||
let emit_passthrough_sse_terminal_error = (skip_direct_finalize_prefetch
|
let emit_passthrough_sse_terminal_error = (skip_direct_finalize_prefetch
|
||||||
|| stream_commit_policy.is_native_anthropic()
|
|| stream_commit_policy.requires_bounded_frame_wait()
|
||||||
|| normalized_declared_stream_headers)
|
|| normalized_declared_stream_headers)
|
||||||
&& (response_headers_indicate_sse(&upstream_headers) || normalized_declared_stream_headers)
|
&& (response_headers_indicate_sse(&upstream_headers) || normalized_declared_stream_headers)
|
||||||
&& !is_openai_image_stream_for_report;
|
&& !is_openai_image_stream_for_report;
|
||||||
@@ -8921,6 +9013,182 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn execute_generic_sse_precommit(
|
||||||
|
chunks: Vec<&str>,
|
||||||
|
routing_policy: Value,
|
||||||
|
provider_config: Option<Value>,
|
||||||
|
stall: bool,
|
||||||
|
) -> Option<axum::http::Response<Body>> {
|
||||||
|
execute_generic_stream_precommit(
|
||||||
|
chunks,
|
||||||
|
routing_policy,
|
||||||
|
provider_config,
|
||||||
|
stall,
|
||||||
|
"text/event-stream",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_generic_stream_precommit(
|
||||||
|
chunks: Vec<&str>,
|
||||||
|
routing_policy: Value,
|
||||||
|
provider_config: Option<Value>,
|
||||||
|
stall: bool,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Option<axum::http::Response<Body>> {
|
||||||
|
let request_id = format!("generic-precommit-{}", uuid::Uuid::new_v4());
|
||||||
|
let mut plan = native_anthropic_stream_plan(&request_id);
|
||||||
|
plan.provider_api_format = "openai:responses".to_string();
|
||||||
|
plan.client_api_format = "openai:responses".to_string();
|
||||||
|
plan.timeouts = Some(ExecutionTimeouts {
|
||||||
|
first_byte_ms: Some(20),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let provider_catalog = provider_catalog_for_plan(&plan, provider_config);
|
||||||
|
let data_state = crate::data::GatewayDataState::with_provider_transport_reader_for_tests(
|
||||||
|
Arc::new(provider_catalog),
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
);
|
||||||
|
let state = AppState::new()
|
||||||
|
.unwrap()
|
||||||
|
.with_data_state_for_tests(data_state);
|
||||||
|
let chunks = chunks.into_iter().map(str::to_string).collect::<Vec<_>>();
|
||||||
|
let content_type = content_type.to_string();
|
||||||
|
let frames = stream! {
|
||||||
|
yield Ok::<Bytes, std::io::Error>(ndjson_frame(StreamFrame {
|
||||||
|
frame_type: StreamFrameType::Headers,
|
||||||
|
payload: StreamFramePayload::Headers {
|
||||||
|
status_code: 200,
|
||||||
|
headers: BTreeMap::from([("content-type".to_string(), content_type)]),
|
||||||
|
response_observation: None,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
for chunk in chunks {
|
||||||
|
yield Ok(ndjson_frame(StreamFrame {
|
||||||
|
frame_type: StreamFrameType::Data,
|
||||||
|
payload: StreamFramePayload::Data { text: Some(chunk), chunk_b64: None },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if stall { std::future::pending::<()>().await; }
|
||||||
|
yield Ok(ndjson_frame(StreamFrame {
|
||||||
|
frame_type: StreamFrameType::Eof,
|
||||||
|
payload: StreamFramePayload::Eof { summary: None },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
.boxed();
|
||||||
|
let mut scope = AiAttemptRetryScope::Provider;
|
||||||
|
execute_stream_from_frame_stream_with_retry_scope(
|
||||||
|
&state,
|
||||||
|
plan,
|
||||||
|
"trace-generic-precommit",
|
||||||
|
&test_decision(),
|
||||||
|
"openai_responses_stream",
|
||||||
|
Some("openai_responses_stream_success".to_string()),
|
||||||
|
Some(json!({
|
||||||
|
"request_id": request_id, "candidate_id": format!("candidate-{request_id}"),
|
||||||
|
"candidate_index": 0, "retry_index": 0,
|
||||||
|
"provider_api_format": "openai:responses", "client_api_format": "openai:responses",
|
||||||
|
"routing_execution_policy": routing_policy,
|
||||||
|
})),
|
||||||
|
crate::clock::current_unix_ms(),
|
||||||
|
Instant::now(),
|
||||||
|
RequestStageTrace::from_env(),
|
||||||
|
true,
|
||||||
|
frames,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
Some(&mut scope),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generic_stream_success_regex_matches_fragmented_plain_body() {
|
||||||
|
assert!(execute_generic_stream_precommit(
|
||||||
|
vec!["upstream CAPACITY ", "exhausted"],
|
||||||
|
json!({"failover_rules": {"success_failover_patterns": [{"pattern": "(?i)capacity.*exhausted"}]}}),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
"text/plain",
|
||||||
|
).await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generic_stream_200_json_error_obeys_global_stop_rules() {
|
||||||
|
for stop in [false, true] {
|
||||||
|
let response = execute_generic_stream_precommit(
|
||||||
|
vec![r#"{"error":{"type":"server_error","message":"do not retry"}}"#],
|
||||||
|
if stop { json!({"failover_rules": {"error_stop_patterns": [{"pattern": "do not retry"}]}}) } else { json!({}) },
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
"application/json",
|
||||||
|
).await;
|
||||||
|
assert_eq!(response.is_some(), stop);
|
||||||
|
if let Some(response) = response {
|
||||||
|
assert!(response.status().is_server_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generic_sse_200_setup_then_error_retries_before_client_output() {
|
||||||
|
let response = execute_generic_sse_precommit(vec![
|
||||||
|
"event: response.created\ndata: {\"type\":\"response.created\"}\n\n",
|
||||||
|
"event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"capacity exhausted\"}}}\n\n",
|
||||||
|
], json!({}), None, false).await;
|
||||||
|
assert!(response.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generic_sse_global_stop_rule_overrides_retryable_embedded_error() {
|
||||||
|
let response = execute_generic_sse_precommit(vec![
|
||||||
|
"event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"capacity exhausted\"}}}\n\n",
|
||||||
|
], json!({ "failover_rules": { "error_stop_patterns": [{ "pattern": "capacity" }] } }), None, false).await;
|
||||||
|
let response = response.expect("global stop must return a terminal response");
|
||||||
|
assert!(response.status().is_server_error());
|
||||||
|
to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generic_sse_success_regex_applies_to_global_and_provider_rules() {
|
||||||
|
let rule = json!({ "success_failover_patterns": [{ "pattern": "(?i)CAPACITY" }] });
|
||||||
|
for (global, provider) in [
|
||||||
|
(json!({ "failover_rules": rule.clone() }), None),
|
||||||
|
(json!({}), Some(json!({ "failover_rules": rule }))),
|
||||||
|
] {
|
||||||
|
let response = execute_generic_sse_precommit(vec![
|
||||||
|
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"capacity exhausted\"}\n\n",
|
||||||
|
], global, provider, false).await;
|
||||||
|
assert!(response.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generic_sse_late_error_does_not_replay_committed_content() {
|
||||||
|
let response = execute_generic_sse_precommit(vec![
|
||||||
|
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
|
||||||
|
"event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"late failure\"}}}\n\n",
|
||||||
|
], json!({}), None, false).await.expect("committed stream must not retry");
|
||||||
|
assert_eq!(response.status(), axum::http::StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&body).contains("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generic_sse_setup_timeout_always_retries() {
|
||||||
|
let response = execute_generic_sse_precommit(
|
||||||
|
vec!["event: response.created\ndata: {\"type\":\"response.created\"}\n\n"],
|
||||||
|
json!({}),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(response.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
fn native_anthropic_stream_plan(request_id: &str) -> ExecutionPlan {
|
fn native_anthropic_stream_plan(request_id: &str) -> ExecutionPlan {
|
||||||
ExecutionPlan {
|
ExecutionPlan {
|
||||||
request_id: request_id.to_string(),
|
request_id: request_id.to_string(),
|
||||||
@@ -10752,7 +11020,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("default Codex cyber policy handling should return the provider error");
|
.expect("default Codex cyber policy handling should return the provider error");
|
||||||
|
|
||||||
assert_eq!(response.status().as_u16(), 200);
|
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -12071,6 +12339,8 @@ mod tests {
|
|||||||
let raw = concat!(
|
let raw = concat!(
|
||||||
"event: message_start\n",
|
"event: message_start\n",
|
||||||
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
||||||
|
"event: content_block_delta\n",
|
||||||
|
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
|
||||||
"event: error\n",
|
"event: error\n",
|
||||||
"data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"late\"}}\n\n",
|
"data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"late\"}}\n\n",
|
||||||
);
|
);
|
||||||
@@ -12094,6 +12364,8 @@ mod tests {
|
|||||||
let message_start = concat!(
|
let message_start = concat!(
|
||||||
"event: message_start\n",
|
"event: message_start\n",
|
||||||
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
||||||
|
"event: content_block_delta\n",
|
||||||
|
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
|
||||||
);
|
);
|
||||||
let original_error = "upstream disconnected after message_start";
|
let original_error = "upstream disconnected after message_start";
|
||||||
let outcome = execute_native_anthropic_prefetch_stream_with_terminal_error(
|
let outcome = execute_native_anthropic_prefetch_stream_with_terminal_error(
|
||||||
@@ -12123,6 +12395,8 @@ mod tests {
|
|||||||
let message_start = concat!(
|
let message_start = concat!(
|
||||||
"event: message_start\n",
|
"event: message_start\n",
|
||||||
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
||||||
|
"event: content_block_delta\n",
|
||||||
|
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
|
||||||
);
|
);
|
||||||
let outcome = execute_native_anthropic_prefetch_stream(
|
let outcome = execute_native_anthropic_prefetch_stream(
|
||||||
"req-anthropic-postcommit-eof",
|
"req-anthropic-postcommit-eof",
|
||||||
@@ -12148,6 +12422,8 @@ mod tests {
|
|||||||
let message_start = concat!(
|
let message_start = concat!(
|
||||||
"event: message_start\n",
|
"event: message_start\n",
|
||||||
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
||||||
|
"event: content_block_delta\n",
|
||||||
|
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
|
||||||
);
|
);
|
||||||
let done = "data: [DONE]\n\n";
|
let done = "data: [DONE]\n\n";
|
||||||
let outcome = execute_native_anthropic_prefetch_stream(
|
let outcome = execute_native_anthropic_prefetch_stream(
|
||||||
@@ -12156,7 +12432,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let AiAttemptExecutionOutcome::Responded(response) = outcome else {
|
let AiAttemptExecutionOutcome::Responded(response) = outcome else {
|
||||||
panic!("message_start should commit the selected candidate")
|
panic!("text output should commit the selected candidate")
|
||||||
};
|
};
|
||||||
let body = to_bytes(response.into_body(), usize::MAX)
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
@@ -12748,8 +13024,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skips_prefetch_for_event_streams_even_when_cross_format_or_rewritten() {
|
fn keeps_prefetch_for_event_streams_even_when_cross_format_or_rewritten() {
|
||||||
assert!(should_skip_direct_finalize_prefetch(
|
assert!(!should_skip_direct_finalize_prefetch(
|
||||||
Some("claude_cli_sync_finalize"),
|
Some("claude_cli_sync_finalize"),
|
||||||
Some("text/event-stream"),
|
Some("text/event-stream"),
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
@@ -14383,21 +14659,21 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.with_execution_runtime_candidate(true);
|
.with_execution_runtime_candidate(true);
|
||||||
|
|
||||||
let response = execute_execution_runtime_stream(
|
let execution_task = tokio::spawn(async move {
|
||||||
&state,
|
execute_execution_runtime_stream(
|
||||||
plan,
|
&state,
|
||||||
"trace-live-stream-first-event",
|
plan,
|
||||||
&decision,
|
"trace-live-stream-first-event",
|
||||||
"openai_chat_stream",
|
&decision,
|
||||||
None,
|
"openai_chat_stream",
|
||||||
Some(json!({
|
None,
|
||||||
"provider_api_format": "openai:chat",
|
Some(json!({
|
||||||
"client_api_format": "openai:chat",
|
"provider_api_format": "openai:chat",
|
||||||
})),
|
"client_api_format": "openai:chat",
|
||||||
)
|
})),
|
||||||
.await
|
)
|
||||||
.expect("execution should succeed")
|
.await
|
||||||
.expect("execution should return a client response");
|
});
|
||||||
|
|
||||||
first_event_seen.notified().await;
|
first_event_seen.notified().await;
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||||
@@ -14418,9 +14694,16 @@ mod tests {
|
|||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
};
|
};
|
||||||
assert!(first_event_usage.first_byte_time_ms.is_some());
|
assert!(first_event_usage.first_byte_time_ms.is_some());
|
||||||
|
assert!(!execution_task.is_finished());
|
||||||
|
|
||||||
release_text.notify_one();
|
release_text.notify_one();
|
||||||
text_seen.notified().await;
|
text_seen.notified().await;
|
||||||
|
let response = tokio::time::timeout(Duration::from_secs(1), execution_task)
|
||||||
|
.await
|
||||||
|
.expect("semantic text should commit the response")
|
||||||
|
.expect("execution task should complete")
|
||||||
|
.expect("execution should succeed")
|
||||||
|
.expect("execution should return a client response");
|
||||||
|
|
||||||
release_terminal.notify_one();
|
release_terminal.notify_one();
|
||||||
let body = to_bytes(response.into_body(), usize::MAX)
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
|||||||
@@ -529,7 +529,13 @@ fn classify_local_sync_error_kind(
|
|||||||
{
|
{
|
||||||
return LocalCoreSyncErrorKind::Overloaded;
|
return LocalCoreSyncErrorKind::Overloaded;
|
||||||
}
|
}
|
||||||
if (500..600).contains(&status_code) {
|
if (500..600).contains(&status_code)
|
||||||
|
|| raw_type.is_some_and(|value| {
|
||||||
|
["server_error", "internal_error", "api_error"]
|
||||||
|
.iter()
|
||||||
|
.any(|kind| value.trim().eq_ignore_ascii_case(kind))
|
||||||
|
})
|
||||||
|
{
|
||||||
return LocalCoreSyncErrorKind::ServerError;
|
return LocalCoreSyncErrorKind::ServerError;
|
||||||
}
|
}
|
||||||
LocalCoreSyncErrorKind::InvalidRequest
|
LocalCoreSyncErrorKind::InvalidRequest
|
||||||
@@ -676,6 +682,13 @@ pub(crate) async fn submit_local_core_error_or_sync_finalize(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn success_http_status_does_not_misclassify_explicit_server_errors_as_bad_requests() {
|
||||||
|
for error_type in ["server_error", "internal_error", "api_error"] {
|
||||||
|
let body = serde_json::json!({ "error": { "type": error_type, "message": "failed" } });
|
||||||
|
assert_eq!(super::resolve_local_sync_error_status_code(200, &body), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
use axum::body::to_bytes;
|
use axum::body::to_bytes;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
|||||||
@@ -655,6 +655,73 @@ struct ProviderTransferState {
|
|||||||
struct ProviderTransferStateTracker {
|
struct ProviderTransferStateTracker {
|
||||||
by_provider: BTreeMap<String, ProviderTransferState>,
|
by_provider: BTreeMap<String, ProviderTransferState>,
|
||||||
exhausted_provider_ids: BTreeSet<String>,
|
exhausted_provider_ids: BTreeSet<String>,
|
||||||
|
global: GlobalTransferState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct GlobalTransferState {
|
||||||
|
first_attempt_started_at: Option<Instant>,
|
||||||
|
last_candidate: Option<(String, String, String)>,
|
||||||
|
transfer_count: u64,
|
||||||
|
limits: Option<ProviderTransferLimits>,
|
||||||
|
exhausted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalTransferState {
|
||||||
|
fn load_policy(&mut self, report_context: Option<&serde_json::Value>) {
|
||||||
|
if self.limits.is_none() {
|
||||||
|
if let Some(policy) =
|
||||||
|
crate::orchestration::routing_execution_policy_from_report_context(report_context)
|
||||||
|
{
|
||||||
|
self.limits = Some(ProviderTransferLimits {
|
||||||
|
max_transfer_count: policy.max_transfer_count,
|
||||||
|
max_transfer_timeout_seconds: policy.max_transfer_timeout_seconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changes_candidate(&self, plan: &aether_contracts::ExecutionPlan) -> bool {
|
||||||
|
self.last_candidate
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|(provider, endpoint, key)| {
|
||||||
|
provider != &plan.provider_id
|
||||||
|
|| endpoint != &plan.endpoint_id
|
||||||
|
|| key != &plan.key_id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_attempt_started(&mut self, plan: &aether_contracts::ExecutionPlan, now: Instant) {
|
||||||
|
self.first_attempt_started_at.get_or_insert(now);
|
||||||
|
if self.changes_candidate(plan) {
|
||||||
|
self.transfer_count = self.transfer_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
self.last_candidate = Some((
|
||||||
|
plan.provider_id.clone(),
|
||||||
|
plan.endpoint_id.clone(),
|
||||||
|
plan.key_id.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_before_attempt(
|
||||||
|
&mut self,
|
||||||
|
plan: &aether_contracts::ExecutionPlan,
|
||||||
|
now: Instant,
|
||||||
|
) -> Option<(bool, bool)> {
|
||||||
|
let limits = self.limits?;
|
||||||
|
let started_at = self.first_attempt_started_at?;
|
||||||
|
let count_reached = self.changes_candidate(plan)
|
||||||
|
&& limits.max_transfer_count > 0
|
||||||
|
&& self.transfer_count >= limits.max_transfer_count;
|
||||||
|
let timeout_reached = limits.max_transfer_timeout_seconds > 0
|
||||||
|
&& now.saturating_duration_since(started_at)
|
||||||
|
>= Duration::from_secs(limits.max_transfer_timeout_seconds);
|
||||||
|
if !count_reached && !timeout_reached {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.exhausted = true;
|
||||||
|
Some((count_reached, timeout_reached))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -717,6 +784,7 @@ struct ProviderTransferLimitReached {
|
|||||||
|
|
||||||
impl ProviderTransferStateTracker {
|
impl ProviderTransferStateTracker {
|
||||||
fn record_attempt_started(&mut self, plan: &aether_contracts::ExecutionPlan, now: Instant) {
|
fn record_attempt_started(&mut self, plan: &aether_contracts::ExecutionPlan, now: Instant) {
|
||||||
|
self.global.record_attempt_started(plan, now);
|
||||||
match self.by_provider.entry(plan.provider_id.clone()) {
|
match self.by_provider.entry(plan.provider_id.clone()) {
|
||||||
std::collections::btree_map::Entry::Vacant(entry) => {
|
std::collections::btree_map::Entry::Vacant(entry) => {
|
||||||
entry.insert(ProviderTransferState {
|
entry.insert(ProviderTransferState {
|
||||||
@@ -903,11 +971,42 @@ async fn should_skip_provider_transfer_attempt<Attempt>(
|
|||||||
where
|
where
|
||||||
Attempt: AiExecutionAttempt + Send + Sync + 'static,
|
Attempt: AiExecutionAttempt + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let reached = tracker
|
let owned_report_context = attempt
|
||||||
.state
|
.report_context_ref()
|
||||||
.lock()
|
.is_none()
|
||||||
.await
|
.then(|| attempt.report_context())
|
||||||
.check_before_attempt(attempt.execution_plan(), Instant::now());
|
.flatten();
|
||||||
|
let report_context = attempt
|
||||||
|
.report_context_ref()
|
||||||
|
.or(owned_report_context.as_ref());
|
||||||
|
let mut tracker = tracker.state.lock().await;
|
||||||
|
tracker.global.load_policy(report_context);
|
||||||
|
if tracker.global.exhausted {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Some((count_reached, timeout_reached)) = tracker
|
||||||
|
.global
|
||||||
|
.check_before_attempt(attempt.execution_plan(), now)
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
event_name = "routing_transfer_limit_reached",
|
||||||
|
log_type = "event",
|
||||||
|
trace_id,
|
||||||
|
plan_kind,
|
||||||
|
transfer_count = tracker.global.transfer_count,
|
||||||
|
elapsed_ms = tracker
|
||||||
|
.global
|
||||||
|
.first_attempt_started_at
|
||||||
|
.map(|started| now.saturating_duration_since(started).as_millis() as u64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
count_reached,
|
||||||
|
timeout_reached,
|
||||||
|
"gateway exhausted the routing strategy transfer budget"
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let reached = tracker.check_before_attempt(attempt.execution_plan(), now);
|
||||||
let Some(reached) = reached else {
|
let Some(reached) = reached else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -2465,6 +2564,93 @@ mod tests {
|
|||||||
assert_eq!(port.unused.lock().unwrap().as_slice(), ["a-key3-retry0"]);
|
assert_eq!(port.unused.lock().unwrap().as_slice(), ["a-key3-retry0"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn routing_transfer_budget_counts_switches_across_providers_not_same_key_retries() {
|
||||||
|
for (limit, succeeds) in [(1, false), (2, true)] {
|
||||||
|
let state = AppState::new().unwrap();
|
||||||
|
let port = TransferTestPort::new(&state);
|
||||||
|
let mut attempts = transfer_test_attempts();
|
||||||
|
for attempt in &mut attempts {
|
||||||
|
attempt.report_context["routing_execution_policy"] =
|
||||||
|
json!({ "max_transfer_count": limit });
|
||||||
|
}
|
||||||
|
let outcome = run_ai_attempt_loop(&port, attempts).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
matches!(outcome, AiAttemptLoopOutcome::Responded(_)),
|
||||||
|
succeeds
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let executed = port.executed.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&executed[..3],
|
||||||
|
["a-key1-retry0", "a-key2-retry0", "a-key2-retry1"]
|
||||||
|
);
|
||||||
|
assert_eq!(executed.len(), if succeeds { 4 } else { 3 });
|
||||||
|
}
|
||||||
|
assert_eq!(port.tracker.state.lock().await.global.transfer_count, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dynamic_loop_honors_global_transfer_budget_across_providers() {
|
||||||
|
let state = AppState::new().unwrap();
|
||||||
|
let port = TransferTestPort::new(&state);
|
||||||
|
let mut attempts = transfer_test_attempts();
|
||||||
|
for attempt in &mut attempts {
|
||||||
|
attempt.report_context["routing_execution_policy"] = json!({ "max_transfer_count": 1 });
|
||||||
|
}
|
||||||
|
let mut source = TransferTestAttemptSource {
|
||||||
|
attempts: attempts.into(),
|
||||||
|
skipped_providers: Vec::new(),
|
||||||
|
};
|
||||||
|
let outcome = run_dynamic_attempt_loop(
|
||||||
|
&port,
|
||||||
|
&mut source,
|
||||||
|
"global-budget",
|
||||||
|
"test",
|
||||||
|
Duration::from_secs(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
outcome,
|
||||||
|
LocalExecutionRequestOutcome::Exhausted(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
port.executed.lock().unwrap().as_slice(),
|
||||||
|
["a-key1-retry0", "a-key2-retry0", "a-key2-retry1"]
|
||||||
|
);
|
||||||
|
assert_eq!(source.skipped_providers, ["provider-a", "provider-b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routing_time_budget_is_cumulative_and_zero_is_unlimited() {
|
||||||
|
let mut global = super::GlobalTransferState::default();
|
||||||
|
global.load_policy(Some(
|
||||||
|
&json!({ "routing_execution_policy": { "max_transfer_timeout_seconds": 60 } }),
|
||||||
|
));
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let plan = test_plan(None);
|
||||||
|
global.record_attempt_started(&plan, now);
|
||||||
|
global.record_attempt_started(&plan, now + Duration::from_secs(40));
|
||||||
|
assert_eq!(global.transfer_count, 0);
|
||||||
|
assert_eq!(
|
||||||
|
global.check_before_attempt(&plan, now + Duration::from_secs(59)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
global.check_before_attempt(&plan, now + Duration::from_secs(60)),
|
||||||
|
Some((false, true))
|
||||||
|
);
|
||||||
|
let mut unlimited = super::GlobalTransferState::default();
|
||||||
|
unlimited.load_policy(Some(&json!({ "routing_execution_policy": {} })));
|
||||||
|
unlimited.record_attempt_started(&plan, now);
|
||||||
|
assert_eq!(
|
||||||
|
unlimited.check_before_attempt(&plan, now + Duration::from_secs(86_400)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cloned_tracker_preserves_transfer_budget_across_candidate_loops() {
|
async fn cloned_tracker_preserves_transfer_budget_across_candidate_loops() {
|
||||||
let state = AppState::new().expect("state should build");
|
let state = AppState::new().expect("state should build");
|
||||||
|
|||||||
@@ -300,6 +300,34 @@ pub(crate) fn classify_local_failover(
|
|||||||
policy: &LocalFailoverPolicy,
|
policy: &LocalFailoverPolicy,
|
||||||
input: LocalFailoverInput<'_>,
|
input: LocalFailoverInput<'_>,
|
||||||
) -> LocalFailoverClassification {
|
) -> LocalFailoverClassification {
|
||||||
|
if input.status_code >= 400
|
||||||
|
&& policy.routing_rules.error_stop_patterns.iter().any(|rule| {
|
||||||
|
failover_pattern_matches(
|
||||||
|
&rule.pattern,
|
||||||
|
&rule.status_codes,
|
||||||
|
input.response_text,
|
||||||
|
input.status_code,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return LocalFailoverClassification::StopErrorPattern;
|
||||||
|
}
|
||||||
|
if input.status_code == 200
|
||||||
|
&& policy
|
||||||
|
.routing_rules
|
||||||
|
.success_failover_patterns
|
||||||
|
.iter()
|
||||||
|
.any(|rule| {
|
||||||
|
failover_pattern_matches(
|
||||||
|
&rule.pattern,
|
||||||
|
&rule.status_codes,
|
||||||
|
input.response_text,
|
||||||
|
input.status_code,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return LocalFailoverClassification::RetrySuccessPattern;
|
||||||
|
}
|
||||||
if policy.stop_status_codes.contains(&input.status_code) {
|
if policy.stop_status_codes.contains(&input.status_code) {
|
||||||
return LocalFailoverClassification::StopStatusCode;
|
return LocalFailoverClassification::StopStatusCode;
|
||||||
}
|
}
|
||||||
@@ -487,13 +515,27 @@ fn local_failover_regex_rule_matches(
|
|||||||
response_text: Option<&str>,
|
response_text: Option<&str>,
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if !rule.status_codes.is_empty() && !rule.status_codes.contains(&status_code) {
|
failover_pattern_matches(
|
||||||
|
&rule.pattern,
|
||||||
|
&rule.status_codes,
|
||||||
|
response_text,
|
||||||
|
status_code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failover_pattern_matches(
|
||||||
|
pattern: &str,
|
||||||
|
status_codes: &std::collections::BTreeSet<u16>,
|
||||||
|
response_text: Option<&str>,
|
||||||
|
status_code: u16,
|
||||||
|
) -> bool {
|
||||||
|
if !status_codes.is_empty() && !status_codes.contains(&status_code) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pattern = rule.pattern.trim();
|
let pattern = pattern.trim();
|
||||||
if pattern.is_empty() {
|
if pattern.is_empty() {
|
||||||
return !rule.status_codes.is_empty();
|
return !status_codes.is_empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(response_text) = response_text else {
|
let Some(response_text) = response_text else {
|
||||||
@@ -507,6 +549,72 @@ fn local_failover_regex_rule_matches(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn routing_rules_precede_provider_rules_and_keep_provider_fallback() {
|
||||||
|
let policy = super::LocalFailoverPolicy {
|
||||||
|
routing_rules: aether_routing_core::RoutingFailoverRules {
|
||||||
|
success_failover_patterns: vec![aether_routing_core::RoutingFailoverRule {
|
||||||
|
pattern: "(?i)capacity.*exhausted".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
error_stop_patterns: vec![aether_routing_core::RoutingFailoverRule {
|
||||||
|
pattern: "invalid.*parameter".to_string(),
|
||||||
|
status_codes: [400].into_iter().collect(),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
stop_status_codes: [200, 403].into_iter().collect(),
|
||||||
|
continue_status_codes: [400].into_iter().collect(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for (status, body, expected) in [
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
"CAPACITY exhausted",
|
||||||
|
super::LocalFailoverClassification::RetrySuccessPattern,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
400,
|
||||||
|
"invalid request parameter",
|
||||||
|
super::LocalFailoverClassification::StopErrorPattern,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
400,
|
||||||
|
"capacity exhausted",
|
||||||
|
super::LocalFailoverClassification::RetryStatusCode,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
403,
|
||||||
|
"permission denied",
|
||||||
|
super::LocalFailoverClassification::StopStatusCode,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
429,
|
||||||
|
"rate limited",
|
||||||
|
super::LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
super::classify_local_failover(
|
||||||
|
&policy,
|
||||||
|
super::LocalFailoverInput::new(status, Some(body))
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routing_transport_stop_cannot_be_overridden_by_provider() {
|
||||||
|
let policy = super::LocalFailoverPolicy {
|
||||||
|
stop_on_transport_errors: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
super::classify_local_transport_error(&policy),
|
||||||
|
super::LocalTransportFailoverClassification::StopTransportError
|
||||||
|
);
|
||||||
|
}
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use aether_contracts::ExecutionPlan;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use aether_routing_core::RoutingExecutionPolicy;
|
use aether_routing_core::{RoutingExecutionPolicy, RoutingFailoverRules};
|
||||||
|
|
||||||
use crate::provider_transport::GatewayProviderTransportSnapshot;
|
use crate::provider_transport::GatewayProviderTransportSnapshot;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -14,6 +14,7 @@ pub(crate) const ROUTING_EXECUTION_POLICY_REPORT_FIELD: &str = "routing_executio
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct LocalFailoverPolicy {
|
pub(crate) struct LocalFailoverPolicy {
|
||||||
|
pub(crate) routing_rules: RoutingFailoverRules,
|
||||||
pub(crate) max_retries: Option<u64>,
|
pub(crate) max_retries: Option<u64>,
|
||||||
pub(crate) max_transfer_count: u64,
|
pub(crate) max_transfer_count: u64,
|
||||||
pub(crate) max_transfer_timeout_seconds: u64,
|
pub(crate) max_transfer_timeout_seconds: u64,
|
||||||
@@ -29,6 +30,7 @@ pub(crate) struct LocalFailoverPolicy {
|
|||||||
impl Default for LocalFailoverPolicy {
|
impl Default for LocalFailoverPolicy {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
routing_rules: RoutingFailoverRules::default(),
|
||||||
max_retries: None,
|
max_retries: None,
|
||||||
max_transfer_count: 0,
|
max_transfer_count: 0,
|
||||||
max_transfer_timeout_seconds: 0,
|
max_transfer_timeout_seconds: 0,
|
||||||
@@ -61,8 +63,10 @@ pub(crate) async fn resolve_local_failover_policy(
|
|||||||
Ok(Some(transport)) => local_failover_policy_from_transport(&transport),
|
Ok(Some(transport)) => local_failover_policy_from_transport(&transport),
|
||||||
Ok(None) | Err(_) => LocalFailoverPolicy::default(),
|
Ok(None) | Err(_) => LocalFailoverPolicy::default(),
|
||||||
};
|
};
|
||||||
let cyber_continue_failover = routing_execution_policy_from_report_context(report_context)
|
let routing_policy =
|
||||||
.is_some_and(|policy| policy.cyber_continue_failover);
|
routing_execution_policy_from_report_context(report_context).unwrap_or_default();
|
||||||
|
let cyber_continue_failover = routing_policy.cyber_continue_failover;
|
||||||
|
policy.routing_rules = routing_policy.failover_rules;
|
||||||
policy.stop_cyber_policy_errors = !cyber_continue_failover;
|
policy.stop_cyber_policy_errors = !cyber_continue_failover;
|
||||||
debug!(
|
debug!(
|
||||||
event_name = "local_failover_policy_loaded",
|
event_name = "local_failover_policy_loaded",
|
||||||
@@ -80,6 +84,8 @@ pub(crate) async fn resolve_local_failover_policy(
|
|||||||
stop_on_transport_errors = policy.stop_on_transport_errors,
|
stop_on_transport_errors = policy.stop_on_transport_errors,
|
||||||
success_failover_pattern_count = policy.success_failover_patterns.len(),
|
success_failover_pattern_count = policy.success_failover_patterns.len(),
|
||||||
error_stop_pattern_count = policy.error_stop_patterns.len(),
|
error_stop_pattern_count = policy.error_stop_patterns.len(),
|
||||||
|
global_success_pattern_count = policy.routing_rules.success_failover_patterns.len(),
|
||||||
|
global_stop_pattern_count = policy.routing_rules.error_stop_patterns.len(),
|
||||||
cyber_continue_failover,
|
cyber_continue_failover,
|
||||||
"gateway loaded local failover policy from transport snapshot"
|
"gateway loaded local failover policy from transport snapshot"
|
||||||
);
|
);
|
||||||
@@ -122,6 +128,7 @@ pub(crate) fn local_failover_policy_from_transport(
|
|||||||
});
|
});
|
||||||
|
|
||||||
LocalFailoverPolicy {
|
LocalFailoverPolicy {
|
||||||
|
routing_rules: RoutingFailoverRules::default(),
|
||||||
max_retries,
|
max_retries,
|
||||||
max_transfer_count: provider_config
|
max_transfer_count: provider_config
|
||||||
.and_then(|value| value.get("max_transfer_count"))
|
.and_then(|value| value.get("max_transfer_count"))
|
||||||
@@ -184,6 +191,10 @@ pub(crate) fn local_failover_policy_from_report_context(
|
|||||||
.as_object()?;
|
.as_object()?;
|
||||||
|
|
||||||
Some(LocalFailoverPolicy {
|
Some(LocalFailoverPolicy {
|
||||||
|
routing_rules: object
|
||||||
|
.get("routing_rules")
|
||||||
|
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
max_retries: object.get("max_retries").and_then(parse_u64_value),
|
max_retries: object.get("max_retries").and_then(parse_u64_value),
|
||||||
max_transfer_count: object
|
max_transfer_count: object
|
||||||
.get("max_transfer_count")
|
.get("max_transfer_count")
|
||||||
@@ -267,6 +278,7 @@ fn parse_status_code_list(value: &Value) -> BTreeSet<u16> {
|
|||||||
|
|
||||||
fn local_failover_policy_to_value(policy: &LocalFailoverPolicy) -> Value {
|
fn local_failover_policy_to_value(policy: &LocalFailoverPolicy) -> Value {
|
||||||
json!({
|
json!({
|
||||||
|
"routing_rules": policy.routing_rules,
|
||||||
"max_retries": policy.max_retries,
|
"max_retries": policy.max_retries,
|
||||||
"max_transfer_count": policy.max_transfer_count,
|
"max_transfer_count": policy.max_transfer_count,
|
||||||
"max_transfer_timeout_seconds": policy.max_transfer_timeout_seconds,
|
"max_transfer_timeout_seconds": policy.max_transfer_timeout_seconds,
|
||||||
@@ -525,6 +537,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
local_failover_policy_from_report_context(Some(&report_context)),
|
local_failover_policy_from_report_context(Some(&report_context)),
|
||||||
Some(LocalFailoverPolicy {
|
Some(LocalFailoverPolicy {
|
||||||
|
routing_rules: Default::default(),
|
||||||
max_retries: Some(2),
|
max_retries: Some(2),
|
||||||
max_transfer_count: 10,
|
max_transfer_count: 10,
|
||||||
max_transfer_timeout_seconds: 60,
|
max_transfer_timeout_seconds: 60,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ pub(crate) fn resolve_gateway_routing_policy(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(routing_policy_error)?;
|
.map_err(routing_policy_error)?;
|
||||||
crate::request_lifecycle::configure_client_disconnect(policy.execution_policy);
|
crate::request_lifecycle::configure_client_disconnect(policy.execution_policy.clone());
|
||||||
Ok(policy)
|
Ok(policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ pub(crate) fn resolve_gateway_static_default_routing_policy(
|
|||||||
let Some(default_policy) = static_default_policy_fields(input.group_config_json)? else {
|
let Some(default_policy) = static_default_policy_fields(input.group_config_json)? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
crate::request_lifecycle::configure_client_disconnect(default_policy.execution_policy);
|
crate::request_lifecycle::configure_client_disconnect(default_policy.execution_policy.clone());
|
||||||
|
|
||||||
Ok(Some(ResolvedRoutingPolicy {
|
Ok(Some(ResolvedRoutingPolicy {
|
||||||
group_id: input.group_id.map(str::to_string),
|
group_id: input.group_id.map(str::to_string),
|
||||||
@@ -145,32 +145,11 @@ fn static_default_policy_fields(
|
|||||||
.ok_or_else(invalid_routing_group_config)?,
|
.ok_or_else(invalid_routing_group_config)?,
|
||||||
None => DEFAULT_STICKY_KEY_ATTEMPTS,
|
None => DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||||
};
|
};
|
||||||
let enable_cf_heartbeat = routing_bool_field(
|
let execution_policy: aether_routing_core::RoutingExecutionPolicy =
|
||||||
default_policy.get("enable_cf_heartbeat"),
|
serde_json::from_value(Value::Object(default_policy.clone()))
|
||||||
"enable_cf_heartbeat",
|
.map_err(|_| invalid_routing_group_config())?;
|
||||||
)?;
|
aether_routing_core::validate_routing_failover_rules(&execution_policy.failover_rules)
|
||||||
// Older strategies stored separate image/text heartbeat flags. Treat
|
.map_err(|_| invalid_routing_group_config())?;
|
||||||
// either legacy flag as enabling the unified CF heartbeat setting while
|
|
||||||
// allowing newly saved strategies to use only the canonical key.
|
|
||||||
let legacy_image_heartbeat = routing_bool_field(
|
|
||||||
default_policy.get("enable_openai_image_sync_heartbeat"),
|
|
||||||
"enable_openai_image_sync_heartbeat",
|
|
||||||
)?;
|
|
||||||
let legacy_text_heartbeat = routing_bool_field(
|
|
||||||
default_policy.get("enable_standard_text_sync_heartbeat"),
|
|
||||||
"enable_standard_text_sync_heartbeat",
|
|
||||||
)?;
|
|
||||||
let execution_policy = aether_routing_core::RoutingExecutionPolicy {
|
|
||||||
enable_cf_heartbeat: enable_cf_heartbeat || legacy_image_heartbeat || legacy_text_heartbeat,
|
|
||||||
cyber_continue_failover: routing_bool_field(
|
|
||||||
default_policy.get("cyber_continue_failover"),
|
|
||||||
"cyber_continue_failover",
|
|
||||||
)?,
|
|
||||||
cancel_on_client_disconnect: routing_bool_field(
|
|
||||||
default_policy.get("cancel_on_client_disconnect"),
|
|
||||||
"cancel_on_client_disconnect",
|
|
||||||
)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(RoutingDefaultPolicy {
|
Ok(Some(RoutingDefaultPolicy {
|
||||||
priority_mode,
|
priority_mode,
|
||||||
@@ -181,13 +160,6 @@ fn static_default_policy_fields(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn routing_bool_field(value: Option<&Value>, _field: &str) -> Result<bool, GatewayError> {
|
|
||||||
match value {
|
|
||||||
Some(value) => value.as_bool().ok_or_else(invalid_routing_group_config),
|
|
||||||
None => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn routing_array_field_is_missing_or_empty(
|
fn routing_array_field_is_missing_or_empty(
|
||||||
object: &serde_json::Map<String, Value>,
|
object: &serde_json::Map<String, Value>,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -246,7 +218,13 @@ mod tests {
|
|||||||
"priority_mode": "global_key",
|
"priority_mode": "global_key",
|
||||||
"scheduling_mode": "load_balance",
|
"scheduling_mode": "load_balance",
|
||||||
"keep_priority_on_conversion": true,
|
"keep_priority_on_conversion": true,
|
||||||
"cancel_on_client_disconnect": true
|
"cancel_on_client_disconnect": true,
|
||||||
|
"max_transfer_count": 3,
|
||||||
|
"max_transfer_timeout_seconds": 90,
|
||||||
|
"failover_rules": {
|
||||||
|
"success_failover_patterns": [{"pattern": "(?i)capacity.*exhausted"}],
|
||||||
|
"error_stop_patterns": [{"status_codes": [400]}]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"allowed_models": ["legacy-model"],
|
"allowed_models": ["legacy-model"],
|
||||||
"model_policies": [],
|
"model_policies": [],
|
||||||
@@ -282,6 +260,19 @@ mod tests {
|
|||||||
.expect("full policy should resolve");
|
.expect("full policy should resolve");
|
||||||
|
|
||||||
assert_eq!(static_policy, full_policy);
|
assert_eq!(static_policy, full_policy);
|
||||||
|
assert_eq!(static_policy.execution_policy.max_transfer_count, 3);
|
||||||
|
assert_eq!(
|
||||||
|
static_policy.execution_policy.max_transfer_timeout_seconds,
|
||||||
|
90
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
static_policy
|
||||||
|
.execution_policy
|
||||||
|
.failover_rules
|
||||||
|
.error_stop_patterns
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
static_policy.priority_mode,
|
static_policy.priority_mode,
|
||||||
RoutingSetPriorityMode::GlobalKey
|
RoutingSetPriorityMode::GlobalKey
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const MAX_ROUTING_FAILOVER_RULES: usize = 64;
|
||||||
|
pub const MAX_ROUTING_FAILOVER_PATTERN_BYTES: usize = 4096;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct RoutingFailoverRule {
|
||||||
|
pub pattern: String,
|
||||||
|
pub status_codes: BTreeSet<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct RoutingFailoverRules {
|
||||||
|
pub success_failover_patterns: Vec<RoutingFailoverRule>,
|
||||||
|
pub error_stop_patterns: Vec<RoutingFailoverRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_routing_failover_rules(rules: &RoutingFailoverRules) -> Result<(), String> {
|
||||||
|
for (name, entries, success) in [
|
||||||
|
(
|
||||||
|
"success_failover_patterns",
|
||||||
|
&rules.success_failover_patterns,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("error_stop_patterns", &rules.error_stop_patterns, false),
|
||||||
|
] {
|
||||||
|
if entries.len() > MAX_ROUTING_FAILOVER_RULES {
|
||||||
|
return Err(format!("{name} exceeds {MAX_ROUTING_FAILOVER_RULES} rules"));
|
||||||
|
}
|
||||||
|
for (index, rule) in entries.iter().enumerate() {
|
||||||
|
let pattern = rule.pattern.trim();
|
||||||
|
if pattern.is_empty() && (success || rule.status_codes.is_empty()) {
|
||||||
|
return Err(format!(
|
||||||
|
"{name}[{index}] requires a pattern or error status codes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pattern.len() > MAX_ROUTING_FAILOVER_PATTERN_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"{name}[{index}] pattern exceeds {MAX_ROUTING_FAILOVER_PATTERN_BYTES} bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !pattern.is_empty() {
|
||||||
|
Regex::new(pattern)
|
||||||
|
.map_err(|error| format!("{name}[{index}] invalid regex: {error}"))?;
|
||||||
|
}
|
||||||
|
if rule.status_codes.iter().any(|status| {
|
||||||
|
if success {
|
||||||
|
*status != 200
|
||||||
|
} else {
|
||||||
|
!(400..=599).contains(status)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
return Err(format!("{name}[{index}] contains invalid status codes"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_regex_and_status_only_stop_rules() {
|
||||||
|
let rules = RoutingFailoverRules {
|
||||||
|
success_failover_patterns: vec![RoutingFailoverRule {
|
||||||
|
pattern: "(?i)capacity.*exhausted".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
error_stop_patterns: vec![RoutingFailoverRule {
|
||||||
|
status_codes: [400, 413].into_iter().collect(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(validate_routing_failover_rules(&rules).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_or_unbounded_rule_configuration() {
|
||||||
|
for rule in [
|
||||||
|
RoutingFailoverRule::default(),
|
||||||
|
RoutingFailoverRule {
|
||||||
|
pattern: "[".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
RoutingFailoverRule {
|
||||||
|
pattern: "error".to_string(),
|
||||||
|
status_codes: [429].into_iter().collect(),
|
||||||
|
},
|
||||||
|
RoutingFailoverRule {
|
||||||
|
pattern: "a".repeat(MAX_ROUTING_FAILOVER_PATTERN_BYTES + 1),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let rules = RoutingFailoverRules {
|
||||||
|
success_failover_patterns: vec![rule],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(validate_routing_failover_rules(&rules).is_err());
|
||||||
|
}
|
||||||
|
let rules = RoutingFailoverRules {
|
||||||
|
error_stop_patterns: vec![
|
||||||
|
RoutingFailoverRule {
|
||||||
|
status_codes: [400].into_iter().collect(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
MAX_ROUTING_FAILOVER_RULES + 1
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(validate_routing_failover_rules(&rules).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod actions;
|
mod actions;
|
||||||
mod conditions;
|
mod conditions;
|
||||||
|
mod failover;
|
||||||
mod model;
|
mod model;
|
||||||
mod mutations;
|
mod mutations;
|
||||||
mod policy;
|
mod policy;
|
||||||
@@ -12,6 +13,10 @@ pub use actions::{
|
|||||||
RoutingSchedulingMode, RoutingSetPriorityMode,
|
RoutingSchedulingMode, RoutingSetPriorityMode,
|
||||||
};
|
};
|
||||||
pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingConditionOp};
|
pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingConditionOp};
|
||||||
|
pub use failover::{
|
||||||
|
validate_routing_failover_rules, RoutingFailoverRule, RoutingFailoverRules,
|
||||||
|
MAX_ROUTING_FAILOVER_PATTERN_BYTES, MAX_ROUTING_FAILOVER_RULES,
|
||||||
|
};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
RoutingDefaultPolicy, RoutingExecutionPolicy, RoutingGroupBinding, RoutingGroupBindingSubject,
|
RoutingDefaultPolicy, RoutingExecutionPolicy, RoutingGroupBinding, RoutingGroupBindingSubject,
|
||||||
RoutingGroupConfig, RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy,
|
RoutingGroupConfig, RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::actions::{
|
|||||||
RoutingAction, RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode,
|
RoutingAction, RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode,
|
||||||
};
|
};
|
||||||
use crate::conditions::RoutingCondition;
|
use crate::conditions::RoutingCondition;
|
||||||
|
use crate::RoutingFailoverRules;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RoutingSchedulingPreset {
|
pub struct RoutingSchedulingPreset {
|
||||||
@@ -33,7 +34,7 @@ pub const DEFAULT_STICKY_KEY_ATTEMPTS: u32 = 2;
|
|||||||
/// transport configuration. A resolved policy is snapshotted for the request
|
/// transport configuration. A resolved policy is snapshotted for the request
|
||||||
/// and can therefore be consumed by execution without rereading mutable
|
/// and can therefore be consumed by execution without rereading mutable
|
||||||
/// system settings.
|
/// system settings.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
|
||||||
pub struct RoutingExecutionPolicy {
|
pub struct RoutingExecutionPolicy {
|
||||||
#[serde(default, skip_serializing_if = "is_false")]
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
pub enable_cf_heartbeat: bool,
|
pub enable_cf_heartbeat: bool,
|
||||||
@@ -41,6 +42,12 @@ pub struct RoutingExecutionPolicy {
|
|||||||
pub cyber_continue_failover: bool,
|
pub cyber_continue_failover: bool,
|
||||||
#[serde(default, skip_serializing_if = "is_false")]
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
pub cancel_on_client_disconnect: bool,
|
pub cancel_on_client_disconnect: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_transfer_count: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_transfer_timeout_seconds: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub failover_rules: RoutingFailoverRules,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for RoutingExecutionPolicy {
|
impl<'de> Deserialize<'de> for RoutingExecutionPolicy {
|
||||||
@@ -60,6 +67,12 @@ impl<'de> Deserialize<'de> for RoutingExecutionPolicy {
|
|||||||
cyber_continue_failover: bool,
|
cyber_continue_failover: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
cancel_on_client_disconnect: bool,
|
cancel_on_client_disconnect: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
max_transfer_count: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
max_transfer_timeout_seconds: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
failover_rules: RoutingFailoverRules,
|
||||||
}
|
}
|
||||||
|
|
||||||
let value = LegacyCompatibleExecutionPolicy::deserialize(deserializer)?;
|
let value = LegacyCompatibleExecutionPolicy::deserialize(deserializer)?;
|
||||||
@@ -69,6 +82,9 @@ impl<'de> Deserialize<'de> for RoutingExecutionPolicy {
|
|||||||
|| value.enable_standard_text_sync_heartbeat,
|
|| value.enable_standard_text_sync_heartbeat,
|
||||||
cyber_continue_failover: value.cyber_continue_failover,
|
cyber_continue_failover: value.cyber_continue_failover,
|
||||||
cancel_on_client_disconnect: value.cancel_on_client_disconnect,
|
cancel_on_client_disconnect: value.cancel_on_client_disconnect,
|
||||||
|
max_transfer_count: value.max_transfer_count,
|
||||||
|
max_transfer_timeout_seconds: value.max_transfer_timeout_seconds,
|
||||||
|
failover_rules: value.failover_rules,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +132,29 @@ fn is_false(value: &bool) -> bool {
|
|||||||
mod execution_policy_tests {
|
mod execution_policy_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routing_failover_configuration_round_trips_and_validates() {
|
||||||
|
let config: RoutingGroupConfig = serde_json::from_value(serde_json::json!({
|
||||||
|
"default_policy": {
|
||||||
|
"max_transfer_count": 3,
|
||||||
|
"max_transfer_timeout_seconds": 90,
|
||||||
|
"failover_rules": {
|
||||||
|
"success_failover_patterns": [{ "pattern": "(?i)capacity" }],
|
||||||
|
"error_stop_patterns": [{ "status_codes": [400, 413] }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
crate::validate_routing_group_config(&config).unwrap();
|
||||||
|
assert_eq!(config.default_policy.execution_policy.max_transfer_count, 3);
|
||||||
|
let value = serde_json::to_value(&config).unwrap();
|
||||||
|
assert_eq!(value["default_policy"]["max_transfer_timeout_seconds"], 90);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<RoutingGroupConfig>(value).unwrap(),
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancellation_defaults_off_and_round_trips_with_legacy_heartbeat() {
|
fn cancellation_defaults_off_and_round_trips_with_legacy_heartbeat() {
|
||||||
let default: RoutingDefaultPolicy = serde_json::from_str("{}").unwrap();
|
let default: RoutingDefaultPolicy = serde_json::from_str("{}").unwrap();
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub fn resolve_routing_policy(
|
|||||||
scheduling_mode: config.default_policy.scheduling_mode,
|
scheduling_mode: config.default_policy.scheduling_mode,
|
||||||
keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion,
|
keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion,
|
||||||
sticky_key_attempts: config.default_policy.sticky_key_attempts,
|
sticky_key_attempts: config.default_policy.sticky_key_attempts,
|
||||||
execution_policy: config.default_policy.execution_policy,
|
execution_policy: config.default_policy.execution_policy.clone(),
|
||||||
ranking_overlay: RankingOverlay::default(),
|
ranking_overlay: RankingOverlay::default(),
|
||||||
mutation_plan: MutationPlan::default(),
|
mutation_plan: MutationPlan::default(),
|
||||||
pool_policy_overrides: BTreeMap::new(),
|
pool_policy_overrides: BTreeMap::new(),
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const ROUTING_POOL_PRESETS: &[&str] = &[
|
|||||||
|
|
||||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
pub enum RoutingValidationError {
|
pub enum RoutingValidationError {
|
||||||
|
#[error("routing failover rules are invalid: {0}")]
|
||||||
|
InvalidFailoverRules(String),
|
||||||
#[error("routing rule id is empty")]
|
#[error("routing rule id is empty")]
|
||||||
EmptyRuleId,
|
EmptyRuleId,
|
||||||
#[error("duplicate routing rule id: {0}")]
|
#[error("duplicate routing rule id: {0}")]
|
||||||
@@ -69,6 +71,8 @@ pub enum RoutingValidationError {
|
|||||||
pub fn validate_routing_group_config(
|
pub fn validate_routing_group_config(
|
||||||
config: &RoutingGroupConfig,
|
config: &RoutingGroupConfig,
|
||||||
) -> Result<(), RoutingValidationError> {
|
) -> Result<(), RoutingValidationError> {
|
||||||
|
crate::validate_routing_failover_rules(&config.default_policy.execution_policy.failover_rules)
|
||||||
|
.map_err(RoutingValidationError::InvalidFailoverRules)?;
|
||||||
let mut rule_ids = BTreeSet::new();
|
let mut rule_ids = BTreeSet::new();
|
||||||
for model_policy in &config.model_policies {
|
for model_policy in &config.model_policies {
|
||||||
if model_policy.model.trim().is_empty() {
|
if model_policy.model.trim().is_empty() {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 调度策略级故障转移
|
||||||
|
|
||||||
|
调度策略的 `default_policy` 支持跨提供商的转移预算与错误规则。它们跟随当前请求的 `routing_execution_policy` 快照进入执行器,不依赖运行中修改全局系统设置。已有策略缺省为不限次数、不限累计时间、无全局错误规则。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default_policy": {
|
||||||
|
"sticky_key_attempts": 2,
|
||||||
|
"max_transfer_count": 3,
|
||||||
|
"max_transfer_timeout_seconds": 90,
|
||||||
|
"failover_rules": {
|
||||||
|
"success_failover_patterns": [
|
||||||
|
{ "pattern": "(?i)capacity.*exhausted" }
|
||||||
|
],
|
||||||
|
"error_stop_patterns": [
|
||||||
|
{ "status_codes": [400, 413], "pattern": "invalid.*parameter" },
|
||||||
|
{ "status_codes": [422] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 预算语义
|
||||||
|
|
||||||
|
- `sticky_key_attempts` 是首个粘性候选上的总尝试次数,`2` 表示首次请求加一次同 Key 重试。该行为保持不变。
|
||||||
|
- 全局 `max_transfer_count` 统计切换候选的次数,首次尝试不计数。同一提供商、端点、Key 上的重试不计数;改变该组合计一次。`3` 最多允许首次候选之后再切换三次。
|
||||||
|
- 全局 `max_transfer_timeout_seconds` 从首次候选开始执行时计时,覆盖后续重试与切换间的累计耗时。它在准备下一次尝试时检查,不会强制打断已经执行中的调用或已提交给客户端的流;单次连接、首字节、读取和非流式完整调用超时仍独立生效。
|
||||||
|
- 两个全局预算的 `0` 都表示不限制。被筛除、禁用或被提供商级预算跳过而未执行的候选不计数。
|
||||||
|
- 提供商自身的转移次数和时间预算继续生效。提供商预算耗尽只跳过该提供商,仍可尝试其他提供商;全局预算耗尽则不再执行任何提供商的新尝试。
|
||||||
|
- 预算仅约束当前请求,不能重置或替代客户端取消策略、权限校验及本地执行异常的终止行为。
|
||||||
|
|
||||||
|
## 错误规则
|
||||||
|
|
||||||
|
先匹配调度策略的全局显式规则;未匹配时继续使用提供商的规则及协议默认行为。本地执行函数真正返回 `Err` 时仍然终止,不做兜底重放。
|
||||||
|
|
||||||
|
- **成功转移规则**:仅当 HTTP 200 响应匹配配置的正则时继续转移,不是对所有 200 进行重试。非流式请求匹配响应体;流式请求只匹配尚未交付业务输出的有界预读取内容。
|
||||||
|
- **错误提前终止**:适用于 400–599 错误。状态码与正则都填写时要求同时满足;只填状态码表示该状态一律终止;只填正则表示在所有错误状态上匹配。流内错误使用解析后的错误状态,而不是外层 200。
|
||||||
|
- **网络错误**:没有上游 HTTP 状态的连接、TLS、DNS、提交前超时等错误统一继续转移;提供商级别若单独配置了停止规则,仍按提供商规则处理。
|
||||||
|
- 正则使用 Rust `regex` 语法,支持 `(?i)` 等内联标志。服务端拒绝无效正则、无意义的空规则以及错误状态范围。每组最多 64 条,每条表达式最多 4096 字节。
|
||||||
|
|
||||||
|
## 流式 200 的恢复窗口
|
||||||
|
|
||||||
|
上游 HTTP 200 响应头不再默认关闭标准文本 SSE 的恢复窗口。执行器先缓冲协议开场事件,例如 Responses 的 `response.created`、Chat 的 role-only 增量、Anthropic 的空 `message_start` / 文本块起始事件。图片专用流保留原来的响应头提交行为,除非显式配置了需要预读取的规则。
|
||||||
|
|
||||||
|
首个业务内容之前的结构化错误、过早 EOF、首字节超时以及 200 正则命中会进入统一故障转移判断。真实文本、思考、工具调用或正常结束事件确定后,缓冲内容按原顺序交付;之后发生的错误保持终止,不重新执行原请求。
|
||||||
|
|
||||||
|
预读取受单次首字节时间和既有字节上限约束。达到字节上限时保守提交,避免无限缓冲;这意味着不能承诺识别响应任意位置的错误或正则。原始 HTTP 状态与最终执行结果是不同观测值,不能因流内失败而伪改已经发送的 HTTP 状态码。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
- `routing_transfer_limit_reached`:当前调度策略的累计次数或时间预算耗尽。
|
||||||
|
- `provider_transfer_limit_reached`:提供商自身的预算耗尽。
|
||||||
|
- `local_stream_candidate_retry_scheduled`:输出前的流内错误或 200 规则触发了继续调度。
|
||||||
|
- `local_stream_transport_retry_scheduled`:输出前传输错误触发了继续调度。
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createApp, h, nextTick, ref, type App } from 'vue'
|
||||||
|
import RoutingFailoverPolicyEditor from '../components/RoutingFailoverPolicyEditor.vue'
|
||||||
|
import { normalizeRoutingFailoverPolicy, type RoutingFailoverPolicy } from '../utils/routingFailover'
|
||||||
|
|
||||||
|
const mounted: Array<{ app: App, root: HTMLElement }> = []
|
||||||
|
|
||||||
|
function mountEditor() {
|
||||||
|
const policy = ref(normalizeRoutingFailoverPolicy())
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
const app = createApp({
|
||||||
|
setup: () => () => h(RoutingFailoverPolicyEditor, {
|
||||||
|
modelValue: policy.value,
|
||||||
|
'onUpdate:modelValue': (value: RoutingFailoverPolicy) => { policy.value = value },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
app.mount(root)
|
||||||
|
mounted.push({ app, root })
|
||||||
|
return { root, policy }
|
||||||
|
}
|
||||||
|
|
||||||
|
function control<T extends HTMLElement>(root: HTMLElement, label: string): T {
|
||||||
|
const element = root.querySelector<T>(`[aria-label="${label}"]`)
|
||||||
|
if (!element) throw new Error(`Missing control: ${label}`)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const { app, root } of mounted.splice(0)) {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RoutingFailoverPolicyEditor', () => {
|
||||||
|
it('edits independent global budgets and documents sticky retry exclusion', async () => {
|
||||||
|
const { root, policy } = mountEditor()
|
||||||
|
expect(root.textContent).toContain('首次尝试和粘性同 Key 重试不计入')
|
||||||
|
expect(root.textContent).toContain('不会中断已开始的调用')
|
||||||
|
const count = control<HTMLInputElement>(root, '全局最大转移次数')
|
||||||
|
count.value = '4'
|
||||||
|
count.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
expect(policy.value.max_transfer_count).toBe(4)
|
||||||
|
expect(policy.value.max_transfer_timeout_seconds).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds regex and status-only rules and reports invalid drafts', async () => {
|
||||||
|
const { root, policy } = mountEditor()
|
||||||
|
control<HTMLButtonElement>(root, '添加成功转移规则').click()
|
||||||
|
await nextTick()
|
||||||
|
expect(root.querySelector('[role="alert"]')?.textContent).toContain('正则表达式')
|
||||||
|
const regex = control<HTMLInputElement>(root, '成功转移规则 1 正则')
|
||||||
|
regex.value = '(?i)capacity.*exhausted'
|
||||||
|
regex.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
expect(policy.value.failover_rules.success_failover_patterns[0].pattern).toBe('(?i)capacity.*exhausted')
|
||||||
|
control<HTMLButtonElement>(root, '添加错误提前终止规则').click()
|
||||||
|
await nextTick()
|
||||||
|
const statuses = control<HTMLInputElement>(root, '终止规则 1 状态码')
|
||||||
|
statuses.value = '400, 413'
|
||||||
|
statuses.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
expect(policy.value.failover_rules.error_stop_patterns[0].status_codes).toEqual([400, 413])
|
||||||
|
expect(root.querySelector('[role="alert"]')).toBeNull()
|
||||||
|
control<HTMLButtonElement>(root, '删除成功转移规则 1').click()
|
||||||
|
await nextTick()
|
||||||
|
expect(policy.value.failover_rules.success_failover_patterns).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits and applies both rule groups through JSON mode', async () => {
|
||||||
|
const { root, policy } = mountEditor()
|
||||||
|
control<HTMLButtonElement>(root, '切到成功转移规则 JSON').click()
|
||||||
|
await nextTick()
|
||||||
|
const successJson = root.querySelector<HTMLTextAreaElement>('textarea')
|
||||||
|
if (!successJson) throw new Error('Missing success JSON editor')
|
||||||
|
successJson.value = '[{"pattern":"capacity"}]'
|
||||||
|
successJson.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
control<HTMLButtonElement>(root, '切回成功转移规则表单').click()
|
||||||
|
await nextTick()
|
||||||
|
expect(policy.value.failover_rules.success_failover_patterns).toEqual([{ pattern: 'capacity', status_codes: [] }])
|
||||||
|
|
||||||
|
control<HTMLButtonElement>(root, '切到错误提前终止规则 JSON').click()
|
||||||
|
await nextTick()
|
||||||
|
const errorJson = root.querySelector<HTMLTextAreaElement>('textarea')
|
||||||
|
if (!errorJson) throw new Error('Missing error JSON editor')
|
||||||
|
errorJson.value = '[{"status_codes":[429,500],"pattern":"rate"}]'
|
||||||
|
errorJson.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
control<HTMLButtonElement>(root, '切回错误提前终止规则表单').click()
|
||||||
|
await nextTick()
|
||||||
|
expect(policy.value.failover_rules.error_stop_patterns).toEqual([{ pattern: 'rate', status_codes: [429, 500] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps invalid JSON visible until it is corrected', async () => {
|
||||||
|
const { root, policy } = mountEditor()
|
||||||
|
control<HTMLButtonElement>(root, '切到错误提前终止规则 JSON').click()
|
||||||
|
await nextTick()
|
||||||
|
const editor = root.querySelector<HTMLTextAreaElement>('textarea')
|
||||||
|
if (!editor) throw new Error('Missing JSON editor')
|
||||||
|
editor.value = '{'
|
||||||
|
editor.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
control<HTMLButtonElement>(root, '切回错误提前终止规则表单').click()
|
||||||
|
await nextTick()
|
||||||
|
expect(root.querySelector('[role="alert"]')?.textContent).toContain('JSON')
|
||||||
|
expect(policy.value.failover_rules.error_stop_patterns).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy } from '../utils/routingFailover'
|
||||||
|
import { createEmptyRoutingGroupConfig, getModelScheduling, normalizeRoutingGroupConfig, upsertModelSchedulingRule } from '../utils/routingPolicy'
|
||||||
|
|
||||||
|
describe('routing failover policy', () => {
|
||||||
|
it('keeps legacy strategies unlimited with empty global rules', () => {
|
||||||
|
const policy = normalizeRoutingGroupConfig({}).default_policy
|
||||||
|
expect(policy.max_transfer_count).toBe(0)
|
||||||
|
expect(policy.max_transfer_timeout_seconds).toBe(0)
|
||||||
|
expect(policy.failover_rules).toEqual({ success_failover_patterns: [], error_stop_patterns: [] })
|
||||||
|
expect(validateRoutingFailoverPolicy(policy)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves global limits and rules across model edits without sharing mutable arrays', () => {
|
||||||
|
const config = createEmptyRoutingGroupConfig()
|
||||||
|
Object.assign(config.default_policy, {
|
||||||
|
max_transfer_count: 3,
|
||||||
|
max_transfer_timeout_seconds: 90,
|
||||||
|
failover_rules: {
|
||||||
|
success_failover_patterns: [{ pattern: '(?i)capacity', status_codes: [] }],
|
||||||
|
error_stop_patterns: [{ pattern: '', status_codes: [400, 413] }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const updated = upsertModelSchedulingRule(config, 'model-a', { priority_mode: 'provider', scheduling_mode: 'fixed_order' })
|
||||||
|
const policy = getModelScheduling(updated, 'model-a')
|
||||||
|
expect(policy.max_transfer_count).toBe(3)
|
||||||
|
expect(policy.max_transfer_timeout_seconds).toBe(90)
|
||||||
|
expect(policy.failover_rules).toEqual(config.default_policy.failover_rules)
|
||||||
|
expect(validateRoutingFailoverPolicy(policy)).toBeNull()
|
||||||
|
policy.failover_rules.error_stop_patterns[0].status_codes.push(422)
|
||||||
|
expect(config.default_policy.failover_rules.error_stop_patterns[0].status_codes).toEqual([400, 413])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid budgets and ambiguous empty rules before saving', () => {
|
||||||
|
const policy = normalizeRoutingFailoverPolicy()
|
||||||
|
policy.max_transfer_count = -1
|
||||||
|
expect(validateRoutingFailoverPolicy(policy)).toContain('非负整数')
|
||||||
|
policy.max_transfer_count = 0
|
||||||
|
policy.max_transfer_timeout_seconds = 0.5
|
||||||
|
expect(validateRoutingFailoverPolicy(policy)).toContain('非负整数')
|
||||||
|
policy.max_transfer_timeout_seconds = 0
|
||||||
|
policy.failover_rules.error_stop_patterns.push({ pattern: '', status_codes: [] })
|
||||||
|
expect(validateRoutingFailoverPolicy(policy)).toContain('状态码或正则')
|
||||||
|
policy.failover_rules.error_stop_patterns[0].status_codes = [200]
|
||||||
|
expect(validateRoutingFailoverPolicy(policy)).toContain('400–599')
|
||||||
|
policy.failover_rules.error_stop_patterns[0].status_codes = [400]
|
||||||
|
expect(validateRoutingFailoverPolicy(policy)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -91,7 +91,7 @@ describe('routingPolicy', () => {
|
|||||||
expect(createEmptyRoutingGroupConfig().default_policy.sticky_key_attempts).toBe(2)
|
expect(createEmptyRoutingGroupConfig().default_policy.sticky_key_attempts).toBe(2)
|
||||||
expect(normalizeRoutingGroupConfig({}).default_policy.sticky_key_attempts).toBe(2)
|
expect(normalizeRoutingGroupConfig({}).default_policy.sticky_key_attempts).toBe(2)
|
||||||
expect(normalizeRoutingGroupConfig({
|
expect(normalizeRoutingGroupConfig({
|
||||||
default_policy: { priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3, enable_cf_heartbeat: false, cyber_continue_failover: false, cancel_on_client_disconnect: false },
|
default_policy: { ...createEmptyRoutingGroupConfig().default_policy, priority_mode: 'provider', scheduling_mode: 'cache_affinity', keep_priority_on_conversion: false, sticky_key_attempts: 3, enable_cf_heartbeat: false, cyber_continue_failover: false, cancel_on_client_disconnect: false },
|
||||||
}).default_policy.sticky_key_attempts).toBe(3)
|
}).default_policy.sticky_key_attempts).toBe(3)
|
||||||
expect(normalizeStickyKeyAttempts('5')).toBe(5)
|
expect(normalizeStickyKeyAttempts('5')).toBe(5)
|
||||||
expect(normalizeStickyKeyAttempts(-1)).toBe(2)
|
expect(normalizeStickyKeyAttempts(-1)).toBe(2)
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="space-y-4 rounded-lg border border-border/60 p-4"
|
||||||
|
data-testid="routing-failover-policy"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium">
|
||||||
|
故障转移规则
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
作用于当前调度策略的所有提供商。先检查全局规则,再检查提供商自身规则;业务内容输出后不再重放请求。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<label class="space-y-1.5 text-sm">
|
||||||
|
<span>全局最大转移次数</span>
|
||||||
|
<Input
|
||||||
|
:model-value="modelValue.max_transfer_count"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
aria-label="全局最大转移次数"
|
||||||
|
@update:model-value="updateLimit('max_transfer_count', $event)"
|
||||||
|
/>
|
||||||
|
<span class="block text-xs leading-relaxed text-muted-foreground">0 不限制。首次尝试和粘性同 Key 重试不计入;每次切换候选计 1 次。</span>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1.5 text-sm">
|
||||||
|
<span>全局最大转移时间(秒)</span>
|
||||||
|
<Input
|
||||||
|
:model-value="modelValue.max_transfer_timeout_seconds"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
aria-label="全局最大转移时间"
|
||||||
|
@update:model-value="updateLimit('max_transfer_timeout_seconds', $event)"
|
||||||
|
/>
|
||||||
|
<span class="block text-xs leading-relaxed text-muted-foreground">0 不限制。从首次尝试累计,耗尽后不再启动下一次尝试;不会中断已开始的调用,单次超时仍独立生效。</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="section in ruleSections"
|
||||||
|
:key="section.key"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h4 class="text-sm font-medium">
|
||||||
|
{{ section.title }}
|
||||||
|
</h4>
|
||||||
|
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{{ section.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
:title="jsonMode[section.key] ? `切回${section.title}表单` : `切到${section.title} JSON`"
|
||||||
|
:aria-label="jsonMode[section.key] ? `切回${section.title}表单` : `切到${section.title} JSON`"
|
||||||
|
@click="toggleJsonMode(section.key)"
|
||||||
|
>
|
||||||
|
<Code2 class="mr-1 h-3 w-3" />
|
||||||
|
{{ jsonMode[section.key] ? '表单' : 'JSON' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="jsonMode[section.key]"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
:title="`格式化${section.title} JSON`"
|
||||||
|
:aria-label="`格式化${section.title} JSON`"
|
||||||
|
@click="formatJsonDraft(section.key)"
|
||||||
|
>
|
||||||
|
<AlignLeft class="mr-1 h-3 w-3" />格式化
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!jsonMode[section.key]"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
:disabled="disabled || modelValue.failover_rules[section.key].length >= MAX_ROUTING_FAILOVER_RULES"
|
||||||
|
:aria-label="`添加${section.title}`"
|
||||||
|
@click="addRule(section.key)"
|
||||||
|
>
|
||||||
|
<Plus class="mr-1 h-3 w-3" />添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="jsonMode[section.key]"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
:model-value="jsonDraft[section.key]"
|
||||||
|
class="min-h-[160px] font-mono text-xs leading-relaxed"
|
||||||
|
:disabled="disabled"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="jsonPlaceholder(section.key)"
|
||||||
|
@update:model-value="updateJsonDraft(section.key, $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="jsonError[section.key]"
|
||||||
|
role="alert"
|
||||||
|
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{{ jsonError[section.key] }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
仅管理{{ section.title }};JSON 应为数组,<code class="rounded bg-muted px-1">pattern</code>{{ section.key === 'success_failover_patterns' ? ' 必填。' : ' 和 status_codes 至少填写一个。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<p
|
||||||
|
v-if="modelValue.failover_rules[section.key].length === 0"
|
||||||
|
class="rounded-md border border-dashed p-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无规则
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(rule, index) in modelValue.failover_rules[section.key]"
|
||||||
|
:key="index"
|
||||||
|
class="flex min-w-0 items-center gap-1"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-if="section.key === 'error_stop_patterns'"
|
||||||
|
:model-value="rule.status_codes.join(',')"
|
||||||
|
:disabled="disabled"
|
||||||
|
size="sm"
|
||||||
|
class="w-full shrink-0 font-mono text-xs sm:w-40"
|
||||||
|
:aria-label="`终止规则 ${index + 1} 状态码`"
|
||||||
|
placeholder="状态码(选填,可多个)"
|
||||||
|
@update:model-value="updateStatuses(index, String($event))"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="rule.pattern"
|
||||||
|
:disabled="disabled"
|
||||||
|
size="sm"
|
||||||
|
class="min-w-0 flex-1 font-mono text-xs"
|
||||||
|
:aria-label="`${section.title} ${index + 1} 正则`"
|
||||||
|
:placeholder="section.key === 'success_failover_patterns' ? '(?i)capacity.*exhausted' : '正则内容(选填)'"
|
||||||
|
@update:model-value="updateRule(section.key, index, { pattern: String($event) })"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 w-8 shrink-0 p-0"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="`删除${section.title} ${index + 1}`"
|
||||||
|
@click="removeRule(section.key, index)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
正则支持 (?i) 忽略大小写;服务端在保存时校验语法。每组最多 64 条,每条正则最多 4096 字节。
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="validationError"
|
||||||
|
role="alert"
|
||||||
|
class="text-sm text-destructive"
|
||||||
|
>
|
||||||
|
{{ validationError }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
import { AlignLeft, Code2, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { Button, Input, Textarea } from '@/components/ui'
|
||||||
|
import {
|
||||||
|
MAX_ROUTING_FAILOVER_PATTERN_BYTES,
|
||||||
|
MAX_ROUTING_FAILOVER_RULES,
|
||||||
|
validateRoutingFailoverPolicy,
|
||||||
|
type RoutingFailoverPolicy,
|
||||||
|
type RoutingFailoverRule,
|
||||||
|
type RoutingFailoverRules,
|
||||||
|
} from '../utils/routingFailover'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: RoutingFailoverPolicy, disabled?: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: RoutingFailoverPolicy] }>()
|
||||||
|
type RuleSection = 'success_failover_patterns' | 'error_stop_patterns'
|
||||||
|
const jsonMode = reactive<Record<RuleSection, boolean>>({
|
||||||
|
success_failover_patterns: false,
|
||||||
|
error_stop_patterns: false,
|
||||||
|
})
|
||||||
|
const jsonDraft = reactive<Record<RuleSection, string>>({
|
||||||
|
success_failover_patterns: '',
|
||||||
|
error_stop_patterns: '',
|
||||||
|
})
|
||||||
|
const jsonError = reactive<Record<RuleSection, string | null>>({
|
||||||
|
success_failover_patterns: null,
|
||||||
|
error_stop_patterns: null,
|
||||||
|
})
|
||||||
|
const jsonDirty = reactive<Record<RuleSection, boolean>>({
|
||||||
|
success_failover_patterns: false,
|
||||||
|
error_stop_patterns: false,
|
||||||
|
})
|
||||||
|
const ruleSections: Array<{ key: RuleSection, title: string, description: string }> = [
|
||||||
|
{ key: 'success_failover_patterns', title: '成功转移规则', description: 'HTTP 200 的响应体或流式输出前的缓冲内容命中正则时,放弃当前候选并继续转移;不是对所有 200 都重试。' },
|
||||||
|
{ key: 'error_stop_patterns', title: '错误提前终止规则', description: '状态码与正则同时满足时立即终止。可只填状态码,或只填正则匹配全部 400–599 错误;对流内错误使用解析后的错误状态。' },
|
||||||
|
]
|
||||||
|
const validationError = computed(() => validateRoutingFailoverPolicy(props.modelValue))
|
||||||
|
|
||||||
|
function updateLimit(field: 'max_transfer_count' | 'max_transfer_timeout_seconds', value: string | number) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: Number(value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRules(patch: Partial<RoutingFailoverRules>) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, failover_rules: { ...props.modelValue.failover_rules, ...patch } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonPlaceholder(section: RuleSection): string {
|
||||||
|
return section === 'success_failover_patterns'
|
||||||
|
? '[{ "pattern": "(?i)capacity.*exhausted" }]'
|
||||||
|
: '[{ "status_codes": [400, 413], "pattern": "invalid.*parameter" }]'
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyRules(rules: RoutingFailoverRule[]): string {
|
||||||
|
return JSON.stringify(rules, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshJsonDraft(section: RuleSection) {
|
||||||
|
jsonDraft[section] = stringifyRules(props.modelValue.failover_rules[section])
|
||||||
|
jsonError[section] = null
|
||||||
|
jsonDirty[section] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJsonDraft(section: RuleSection, value: string) {
|
||||||
|
jsonDraft[section] = value
|
||||||
|
jsonDirty[section] = true
|
||||||
|
jsonError[section] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonStatusCodes(value: unknown, section: RuleSection, index: number): number[] | string {
|
||||||
|
if (value === undefined || value === null) return []
|
||||||
|
if (!Array.isArray(value)) return `${section} 第 ${index + 1} 条的 status_codes 必须是数组`
|
||||||
|
const statusCodes: number[] = []
|
||||||
|
for (const status of value) {
|
||||||
|
if (!Number.isInteger(status)) return `${section} 第 ${index + 1} 条的 status_codes 只能包含整数`
|
||||||
|
if (section === 'success_failover_patterns' ? status !== 200 : status < 400 || status > 599) {
|
||||||
|
return `${section} 第 ${index + 1} 条的 status_codes 包含无效状态码`
|
||||||
|
}
|
||||||
|
if (!statusCodes.includes(status)) statusCodes.push(status)
|
||||||
|
}
|
||||||
|
return statusCodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonRules(section: RuleSection, draft: string): RoutingFailoverRule[] | string {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(draft.trim() || '[]')
|
||||||
|
} catch (error) {
|
||||||
|
return error instanceof Error ? error.message : 'JSON 格式无效'
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: unknown = parsed
|
||||||
|
if (!Array.isArray(parsed) && parsed !== null && typeof parsed === 'object') {
|
||||||
|
const root = parsed as Record<string, unknown>
|
||||||
|
const nested = root.failover_rules
|
||||||
|
const source = nested !== null && typeof nested === 'object' && !Array.isArray(nested)
|
||||||
|
? nested as Record<string, unknown>
|
||||||
|
: root
|
||||||
|
entries = source[section] ?? []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(entries)) return `${section} JSON 必须是数组`
|
||||||
|
if (entries.length > MAX_ROUTING_FAILOVER_RULES) return `${section} 最多 ${MAX_ROUTING_FAILOVER_RULES} 条`
|
||||||
|
|
||||||
|
const rules: RoutingFailoverRule[] = []
|
||||||
|
for (const [index, entry] of entries.entries()) {
|
||||||
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||||
|
return `${section} 第 ${index + 1} 条必须是对象`
|
||||||
|
}
|
||||||
|
const rawRule = entry as Record<string, unknown>
|
||||||
|
if (rawRule.pattern !== undefined && typeof rawRule.pattern !== 'string') {
|
||||||
|
return `${section} 第 ${index + 1} 条的 pattern 必须是字符串`
|
||||||
|
}
|
||||||
|
const pattern = typeof rawRule.pattern === 'string' ? rawRule.pattern.trim() : ''
|
||||||
|
if (new TextEncoder().encode(pattern).length > MAX_ROUTING_FAILOVER_PATTERN_BYTES) {
|
||||||
|
return `${section} 第 ${index + 1} 条正则过长`
|
||||||
|
}
|
||||||
|
const statusCodes = parseJsonStatusCodes(rawRule.status_codes, section, index)
|
||||||
|
if (typeof statusCodes === 'string') return statusCodes
|
||||||
|
if (!pattern && (section === 'success_failover_patterns' || statusCodes.length === 0)) {
|
||||||
|
return `${section} 第 ${index + 1} 条需要${section === 'success_failover_patterns' ? '正则表达式' : '状态码或正则表达式'}`
|
||||||
|
}
|
||||||
|
rules.push({ pattern, status_codes: statusCodes })
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJsonDraft(section: RuleSection): boolean {
|
||||||
|
const parsed = parseJsonRules(section, jsonDraft[section])
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
jsonError[section] = parsed
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
updateRules({ [section]: parsed })
|
||||||
|
jsonDraft[section] = stringifyRules(parsed)
|
||||||
|
jsonError[section] = null
|
||||||
|
jsonDirty[section] = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleJsonMode(section: RuleSection) {
|
||||||
|
if (jsonMode[section]) {
|
||||||
|
if (jsonDirty[section] && !applyJsonDraft(section)) return
|
||||||
|
jsonMode[section] = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshJsonDraft(section)
|
||||||
|
jsonMode[section] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonDraft(section: RuleSection) {
|
||||||
|
const parsed = parseJsonRules(section, jsonDraft[section])
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
jsonError[section] = parsed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const formatted = stringifyRules(parsed)
|
||||||
|
if (formatted !== jsonDraft[section]) jsonDirty[section] = true
|
||||||
|
jsonDraft[section] = formatted
|
||||||
|
jsonError[section] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRule(section: RuleSection) {
|
||||||
|
updateRules({ [section]: [...props.modelValue.failover_rules[section], { pattern: '', status_codes: [] }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRule(section: RuleSection, index: number, patch: Partial<RoutingFailoverRule>) {
|
||||||
|
updateRules({ [section]: props.modelValue.failover_rules[section].map((rule, position) => position === index ? { ...rule, ...patch } : rule) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatuses(index: number, value: string | number) {
|
||||||
|
const input = String(value).trim()
|
||||||
|
updateRule('error_stop_patterns', index, { status_codes: input ? input.split(/[,,\s]+/).map(Number) : [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRule(section: RuleSection, index: number) {
|
||||||
|
updateRules({ [section]: props.modelValue.failover_rules[section].filter((_, position) => position !== index) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitJsonDrafts(): boolean {
|
||||||
|
const parsedRules = new Map<RuleSection, RoutingFailoverRule[]>()
|
||||||
|
for (const section of ruleSections.map(item => item.key)) {
|
||||||
|
if (!jsonMode[section] || !jsonDirty[section]) continue
|
||||||
|
const parsed = parseJsonRules(section, jsonDraft[section])
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
jsonError[section] = parsed
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parsedRules.set(section, parsed)
|
||||||
|
}
|
||||||
|
for (const [section, rules] of parsedRules) {
|
||||||
|
updateRules({ [section]: rules })
|
||||||
|
jsonDraft[section] = stringifyRules(rules)
|
||||||
|
jsonDirty[section] = false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ commitJsonDrafts })
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as RoutingDryRunDialog } from './RoutingDryRunDialog.vue'
|
export { default as RoutingDryRunDialog } from './RoutingDryRunDialog.vue'
|
||||||
export { default as RoutingGroupEditor } from './RoutingGroupEditor.vue'
|
export { default as RoutingGroupEditor } from './RoutingGroupEditor.vue'
|
||||||
|
export { default as RoutingFailoverPolicyEditor } from './RoutingFailoverPolicyEditor.vue'
|
||||||
export { default as RoutingGroupList } from './RoutingGroupList.vue'
|
export { default as RoutingGroupList } from './RoutingGroupList.vue'
|
||||||
export { default as RoutingModelPolicyEditor } from './RoutingModelPolicyEditor.vue'
|
export { default as RoutingModelPolicyEditor } from './RoutingModelPolicyEditor.vue'
|
||||||
export { default as RoutingPriorityPolicyEditor } from './RoutingPriorityPolicyEditor.vue'
|
export { default as RoutingPriorityPolicyEditor } from './RoutingPriorityPolicyEditor.vue'
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export const MAX_ROUTING_FAILOVER_RULES = 64
|
||||||
|
export const MAX_ROUTING_FAILOVER_PATTERN_BYTES = 4096
|
||||||
|
|
||||||
|
export interface RoutingFailoverRule {
|
||||||
|
pattern: string
|
||||||
|
status_codes: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingFailoverRules {
|
||||||
|
success_failover_patterns: RoutingFailoverRule[]
|
||||||
|
error_stop_patterns: RoutingFailoverRule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingFailoverPolicy {
|
||||||
|
max_transfer_count: number
|
||||||
|
max_transfer_timeout_seconds: number
|
||||||
|
failover_rules: RoutingFailoverRules
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRoutingFailoverPolicy(value?: Partial<RoutingFailoverPolicy>): RoutingFailoverPolicy {
|
||||||
|
const rules = value?.failover_rules
|
||||||
|
const cloneRules = (entries?: RoutingFailoverRule[]): RoutingFailoverRule[] => (
|
||||||
|
Array.isArray(entries) ? entries.map(rule => ({
|
||||||
|
pattern: String(rule.pattern ?? ''),
|
||||||
|
status_codes: Array.isArray(rule.status_codes) ? [...rule.status_codes] : [],
|
||||||
|
})) : []
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
max_transfer_count: Number(value?.max_transfer_count ?? 0),
|
||||||
|
max_transfer_timeout_seconds: Number(value?.max_transfer_timeout_seconds ?? 0),
|
||||||
|
failover_rules: {
|
||||||
|
success_failover_patterns: cloneRules(rules?.success_failover_patterns),
|
||||||
|
error_stop_patterns: cloneRules(rules?.error_stop_patterns),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRoutingFailoverPolicy(policy: RoutingFailoverPolicy): string | null {
|
||||||
|
for (const [name, value] of [
|
||||||
|
['全局最大转移次数', policy.max_transfer_count],
|
||||||
|
['全局最大转移时间', policy.max_transfer_timeout_seconds],
|
||||||
|
] as const) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) return `${name}必须是非负整数`
|
||||||
|
}
|
||||||
|
for (const [name, entries, success] of [
|
||||||
|
['成功转移规则', policy.failover_rules.success_failover_patterns, true],
|
||||||
|
['错误提前终止规则', policy.failover_rules.error_stop_patterns, false],
|
||||||
|
] as const) {
|
||||||
|
if (entries.length > MAX_ROUTING_FAILOVER_RULES) return `${name}最多 ${MAX_ROUTING_FAILOVER_RULES} 条`
|
||||||
|
for (const [index, rule] of entries.entries()) {
|
||||||
|
if (!rule.pattern.trim() && (success || rule.status_codes.length === 0)) {
|
||||||
|
return `${name}第 ${index + 1} 条需要${success ? '正则表达式' : '状态码或正则表达式'}`
|
||||||
|
}
|
||||||
|
if (new TextEncoder().encode(rule.pattern.trim()).length > MAX_ROUTING_FAILOVER_PATTERN_BYTES) {
|
||||||
|
return `${name}第 ${index + 1} 条正则过长`
|
||||||
|
}
|
||||||
|
if (rule.status_codes.some(status => !Number.isInteger(status) || (success ? status !== 200 : status < 400 || status > 599))) {
|
||||||
|
return `${name}第 ${index + 1} 条状态码必须为${success ? ' 200' : ' 400–599'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeRoutingFailoverPolicy, type RoutingFailoverPolicy } from './routingFailover'
|
||||||
|
|
||||||
export type RoutingPriorityMode = 'provider' | 'global_key'
|
export type RoutingPriorityMode = 'provider' | 'global_key'
|
||||||
export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_balance'
|
export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_balance'
|
||||||
export type RoutingRulePhase = 'client_request' | 'provider_request'
|
export type RoutingRulePhase = 'client_request' | 'provider_request'
|
||||||
@@ -6,7 +8,7 @@ export type RoutingSortingScope = 'unified' | 'per_model'
|
|||||||
/** 首个候选(粘性 Key)的总尝试次数默认值:失败后同 Key 重试 1 次 */
|
/** 首个候选(粘性 Key)的总尝试次数默认值:失败后同 Key 重试 1 次 */
|
||||||
export const DEFAULT_STICKY_KEY_ATTEMPTS = 2
|
export const DEFAULT_STICKY_KEY_ATTEMPTS = 2
|
||||||
|
|
||||||
export interface RoutingDefaultPolicy {
|
export interface RoutingDefaultPolicy extends RoutingFailoverPolicy {
|
||||||
priority_mode: RoutingPriorityMode
|
priority_mode: RoutingPriorityMode
|
||||||
scheduling_mode: RoutingSchedulingMode
|
scheduling_mode: RoutingSchedulingMode
|
||||||
keep_priority_on_conversion: boolean
|
keep_priority_on_conversion: boolean
|
||||||
@@ -74,6 +76,7 @@ export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
|
|||||||
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
|
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
|
||||||
return {
|
return {
|
||||||
default_policy: {
|
default_policy: {
|
||||||
|
...normalizeRoutingFailoverPolicy(),
|
||||||
priority_mode: 'provider',
|
priority_mode: 'provider',
|
||||||
scheduling_mode: 'cache_affinity',
|
scheduling_mode: 'cache_affinity',
|
||||||
keep_priority_on_conversion: false,
|
keep_priority_on_conversion: false,
|
||||||
@@ -122,6 +125,7 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
|
|||||||
default_policy: {
|
default_policy: {
|
||||||
...base.default_policy,
|
...base.default_policy,
|
||||||
...defaultPolicyWithoutLegacyHeartbeat,
|
...defaultPolicyWithoutLegacyHeartbeat,
|
||||||
|
...normalizeRoutingFailoverPolicy(rawDefaultPolicy),
|
||||||
enable_cf_heartbeat: Boolean(
|
enable_cf_heartbeat: Boolean(
|
||||||
rawDefaultPolicy.enable_cf_heartbeat || legacyImageHeartbeat || legacyTextHeartbeat,
|
rawDefaultPolicy.enable_cf_heartbeat || legacyImageHeartbeat || legacyTextHeartbeat,
|
||||||
),
|
),
|
||||||
@@ -371,6 +375,7 @@ export function getModelScheduling(
|
|||||||
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
|
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
|
||||||
const action = rule?.actions.find(isSetSchedulingAction)
|
const action = rule?.actions.find(isSetSchedulingAction)
|
||||||
return {
|
return {
|
||||||
|
...normalized.default_policy,
|
||||||
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
|
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
|
||||||
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
|
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
|
||||||
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
|
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
|
||||||
|
|||||||
@@ -500,6 +500,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<RoutingFailoverPolicyEditor
|
||||||
|
ref="routingFailoverPolicyEditor"
|
||||||
|
:model-value="draft.config_json.default_policy"
|
||||||
|
:disabled="saving"
|
||||||
|
@update:model-value="draft.config_json.default_policy = { ...draft.config_json.default_policy, ...$event }"
|
||||||
|
/>
|
||||||
|
|
||||||
<section class="space-y-4 rounded-lg border border-border/60 p-4">
|
<section class="space-y-4 rounded-lg border border-border/60 p-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium">
|
<h3 class="text-sm font-medium">
|
||||||
@@ -882,7 +889,8 @@ import {
|
|||||||
type RoutingSchedulingMode,
|
type RoutingSchedulingMode,
|
||||||
type RoutingSortingScope,
|
type RoutingSortingScope,
|
||||||
} from '@/features/routing/utils/routingPolicy'
|
} from '@/features/routing/utils/routingPolicy'
|
||||||
import { RoutingPriorityPolicyEditor } from '@/features/routing/components'
|
import { RoutingFailoverPolicyEditor, RoutingPriorityPolicyEditor } from '@/features/routing/components'
|
||||||
|
import { validateRoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
|
||||||
import {
|
import {
|
||||||
createRoutingGroup,
|
createRoutingGroup,
|
||||||
deleteRoutingGroup,
|
deleteRoutingGroup,
|
||||||
@@ -926,6 +934,7 @@ const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> =
|
|||||||
const groups = ref<RoutingGroupRecord[]>([])
|
const groups = ref<RoutingGroupRecord[]>([])
|
||||||
const selectedGroupId = ref<string | null>(null)
|
const selectedGroupId = ref<string | null>(null)
|
||||||
const draft = ref<RoutingGroupDraft | null>(null)
|
const draft = ref<RoutingGroupDraft | null>(null)
|
||||||
|
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
|
||||||
const savedDraftSnapshot = ref<string | null>(null)
|
const savedDraftSnapshot = ref<string | null>(null)
|
||||||
const sortingScope = ref<RoutingSortingScope>('unified')
|
const sortingScope = ref<RoutingSortingScope>('unified')
|
||||||
const selectedPerModelName = ref<string | null>(null)
|
const selectedPerModelName = ref<string | null>(null)
|
||||||
@@ -1648,7 +1657,13 @@ async function saveDraft(): Promise<void> {
|
|||||||
showError('策略名称不能为空')
|
showError('策略名称不能为空')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (routingFailoverPolicyEditor.value && !routingFailoverPolicyEditor.value.commitJsonDrafts()) return
|
||||||
const config = cloneConfig(draft.value.config_json)
|
const config = cloneConfig(draft.value.config_json)
|
||||||
|
const failoverError = validateRoutingFailoverPolicy(config.default_policy)
|
||||||
|
if (failoverError) {
|
||||||
|
showError(failoverError)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
|
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
|
||||||
showError('按模型排序时至少选择一个模型')
|
showError('按模型排序时至少选择一个模型')
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user