Synced from monorepo
Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -5,7 +5,10 @@
|
|||
//! Cancellation is cooperative via `CancellationToken`.
|
||||
|
||||
use std::pin::pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
|
|
@ -26,7 +29,8 @@ use crate::metrics::InferenceLatencyStats;
|
|||
use crate::retry::{
|
||||
self as retry_mod, RetryDecision, classify_error, clone_error, resolve_max_retries,
|
||||
};
|
||||
use crate::stream::{stream_chat_completions, stream_messages, stream_responses};
|
||||
use crate::stream::responses::stream_responses_tracked;
|
||||
use crate::stream::{stream_chat_completions, stream_messages};
|
||||
use crate::types::RequestId;
|
||||
|
||||
/// Default per-chunk idle timeout when neither config nor caller
|
||||
|
|
@ -89,7 +93,12 @@ pub(crate) async fn run_request_task(
|
|||
.idle_timeout_secs
|
||||
.unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS),
|
||||
);
|
||||
let max_retries = resolve_max_retries(config.max_retries.or(Some(retry_policy.max_retries)));
|
||||
let configured_max_retries = config.max_retries.or(Some(retry_policy.max_retries));
|
||||
let max_retries = if configured_max_retries == Some(0) {
|
||||
0
|
||||
} else {
|
||||
resolve_max_retries(configured_max_retries)
|
||||
};
|
||||
|
||||
// Build the initial client. Configuration errors here are fatal
|
||||
// (no point retrying with the same broken config).
|
||||
|
|
@ -117,9 +126,12 @@ pub(crate) async fn run_request_task(
|
|||
let mut retry_count: u32 = 0;
|
||||
// Doom-loop recovery keeps its own resample budget, independent of the
|
||||
// transport/empty budget above.
|
||||
let doom_policy = config.doom_loop_recovery;
|
||||
let doom_policy = (max_retries > 0)
|
||||
.then_some(config.doom_loop_recovery)
|
||||
.flatten();
|
||||
let doom_max_retries = doom_policy.map_or(0, |p| p.max_retries);
|
||||
let mut doom_retry_count: u32 = 0;
|
||||
let output_observed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
loop {
|
||||
if cancel_token.is_cancelled() {
|
||||
|
|
@ -138,10 +150,18 @@ pub(crate) async fn run_request_task(
|
|||
&event_tx,
|
||||
&cancel_token,
|
||||
doom_check,
|
||||
Arc::clone(&output_observed),
|
||||
)
|
||||
.instrument(sampling_span.clone())
|
||||
.await;
|
||||
|
||||
let effective_max_retries =
|
||||
if retry_policy.retry_only_before_output && output_observed.load(Ordering::Relaxed) {
|
||||
0
|
||||
} else {
|
||||
max_retries
|
||||
};
|
||||
|
||||
match outcome {
|
||||
AttemptOutcome::Completed {
|
||||
response,
|
||||
|
|
@ -196,13 +216,14 @@ pub(crate) async fn run_request_task(
|
|||
if !apply_retry_decision(
|
||||
&err,
|
||||
&mut retry_count,
|
||||
max_retries,
|
||||
effective_max_retries,
|
||||
&retry_policy,
|
||||
&event_tx,
|
||||
&request_id,
|
||||
&mut request,
|
||||
&mut client,
|
||||
&config,
|
||||
&cancel_token,
|
||||
&mut completion_tx,
|
||||
)
|
||||
.await
|
||||
|
|
@ -215,6 +236,13 @@ pub(crate) async fn run_request_task(
|
|||
// consult the transport classifier, so no classifier change
|
||||
// can silently debit the transport budget for a doom failure.
|
||||
if let SamplingError::DoomLoopDetected { .. } = &error {
|
||||
if retry_policy.retry_only_before_output
|
||||
&& output_observed.load(Ordering::Relaxed)
|
||||
{
|
||||
emit_failed(&event_tx, &request_id, &error);
|
||||
send_completion(&mut completion_tx, Err(clone_error(&error)));
|
||||
return request_id;
|
||||
}
|
||||
let backoff = retry_mod::doom_loop_backoff(doom_retry_count + 1);
|
||||
doom_retry_count += 1;
|
||||
tracing::warn!(
|
||||
|
|
@ -232,19 +260,23 @@ pub(crate) async fn run_request_task(
|
|||
doom_max_retries,
|
||||
&error,
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
continue;
|
||||
if sleep_or_cancel(backoff, &cancel_token).await {
|
||||
continue;
|
||||
}
|
||||
handle_cancellation(&event_tx, &request_id, &mut completion_tx);
|
||||
return request_id;
|
||||
}
|
||||
if !apply_retry_decision(
|
||||
&error,
|
||||
&mut retry_count,
|
||||
max_retries,
|
||||
effective_max_retries,
|
||||
&retry_policy,
|
||||
&event_tx,
|
||||
&request_id,
|
||||
&mut request,
|
||||
&mut client,
|
||||
&config,
|
||||
&cancel_token,
|
||||
&mut completion_tx,
|
||||
)
|
||||
.await
|
||||
|
|
@ -260,13 +292,14 @@ pub(crate) async fn run_request_task(
|
|||
if !apply_retry_decision(
|
||||
&error,
|
||||
&mut retry_count,
|
||||
max_retries,
|
||||
effective_max_retries,
|
||||
&retry_policy,
|
||||
&event_tx,
|
||||
&request_id,
|
||||
&mut request,
|
||||
&mut client,
|
||||
&config,
|
||||
&cancel_token,
|
||||
&mut completion_tx,
|
||||
)
|
||||
.await
|
||||
|
|
@ -294,6 +327,7 @@ async fn apply_retry_decision(
|
|||
request: &mut ConversationRequest,
|
||||
client: &mut SamplingClient,
|
||||
config: &SamplerConfig,
|
||||
cancel_token: &CancellationToken,
|
||||
completion_tx: &mut Option<oneshot::Sender<CompletionResult>>,
|
||||
) -> bool {
|
||||
let rate_limit_threshold = if retry_policy.rate_limit_retry_threshold == 0 {
|
||||
|
|
@ -321,14 +355,22 @@ async fn apply_retry_decision(
|
|||
RetryDecision::Retry { backoff } => {
|
||||
*retry_count += 1;
|
||||
emit_retrying(event_tx, request_id, *retry_count, max_retries, err);
|
||||
tokio::time::sleep(backoff).await;
|
||||
true
|
||||
if sleep_or_cancel(backoff, cancel_token).await {
|
||||
true
|
||||
} else {
|
||||
handle_cancellation(event_tx, request_id, completion_tx);
|
||||
false
|
||||
}
|
||||
}
|
||||
RetryDecision::RetryWithBackoff { backoff, .. } => {
|
||||
*retry_count += 1;
|
||||
emit_retrying(event_tx, request_id, *retry_count, max_retries, err);
|
||||
tokio::time::sleep(backoff).await;
|
||||
true
|
||||
if sleep_or_cancel(backoff, cancel_token).await {
|
||||
true
|
||||
} else {
|
||||
handle_cancellation(event_tx, request_id, completion_tx);
|
||||
false
|
||||
}
|
||||
}
|
||||
RetryDecision::RetryWithImageStrip => {
|
||||
let stripped = request.strip_images();
|
||||
|
|
@ -345,7 +387,10 @@ async fn apply_retry_decision(
|
|||
RetryDecision::RetryWithClientRebuild { backoff } => {
|
||||
*retry_count += 1;
|
||||
emit_retrying(event_tx, request_id, *retry_count, max_retries, err);
|
||||
tokio::time::sleep(backoff).await;
|
||||
if !sleep_or_cancel(backoff, cancel_token).await {
|
||||
handle_cancellation(event_tx, request_id, completion_tx);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rebuild client with HTTP/1.1 fallback to escape poisoned
|
||||
// HTTP/2 connection pools.
|
||||
|
|
@ -408,6 +453,14 @@ async fn apply_retry_decision(
|
|||
}
|
||||
}
|
||||
|
||||
async fn sleep_or_cancel(duration: Duration, cancel_token: &CancellationToken) -> bool {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => false,
|
||||
_ = tokio::time::sleep(duration) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single attempt: build the raw stream, drive it through the
|
||||
/// matching L2 transform, and forward all non-terminal events to
|
||||
/// `event_tx`. Captures the rich `SamplingError` from the underlying
|
||||
|
|
@ -416,6 +469,7 @@ async fn apply_retry_decision(
|
|||
/// `doom_check` is the doom-loop policy while the resample budget lasts;
|
||||
/// `None` disarms the mid-stream abort and the terminal confidence check so
|
||||
/// the attempt completes and its response can be accepted.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_one_attempt(
|
||||
client: &SamplingClient,
|
||||
request: ConversationRequest,
|
||||
|
|
@ -424,6 +478,7 @@ async fn run_one_attempt(
|
|||
event_tx: &mpsc::UnboundedSender<SamplingEvent>,
|
||||
cancel_token: &CancellationToken,
|
||||
doom_check: Option<xai_grok_sampling_types::DoomLoopRecoveryPolicy>,
|
||||
output_observed: Arc<AtomicBool>,
|
||||
) -> AttemptOutcome {
|
||||
match client.api_backend() {
|
||||
ApiBackend::ChatCompletions => {
|
||||
|
|
@ -433,7 +488,16 @@ async fn run_one_attempt(
|
|||
};
|
||||
let (teed, captured) = tee_errors(raw);
|
||||
let l2 = stream_chat_completions(teed, metadata, request_id.clone(), idle_timeout);
|
||||
drive_l2(l2, request_id, event_tx, cancel_token, captured, None).await
|
||||
drive_l2(
|
||||
l2,
|
||||
request_id,
|
||||
event_tx,
|
||||
cancel_token,
|
||||
captured,
|
||||
None,
|
||||
output_observed,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ApiBackend::Responses => {
|
||||
let (raw, metadata, doom_loop) =
|
||||
|
|
@ -447,8 +511,24 @@ async fn run_one_attempt(
|
|||
collector.disarm_abort();
|
||||
}
|
||||
let (teed, captured) = tee_errors(raw);
|
||||
let l2 = stream_responses(teed, metadata, request_id.clone(), idle_timeout, doom_loop);
|
||||
drive_l2(l2, request_id, event_tx, cancel_token, captured, doom_check).await
|
||||
let l2 = stream_responses_tracked(
|
||||
teed,
|
||||
metadata,
|
||||
request_id.clone(),
|
||||
idle_timeout,
|
||||
doom_loop,
|
||||
Arc::clone(&output_observed),
|
||||
);
|
||||
drive_l2(
|
||||
l2,
|
||||
request_id,
|
||||
event_tx,
|
||||
cancel_token,
|
||||
captured,
|
||||
doom_check,
|
||||
output_observed,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ApiBackend::Messages => {
|
||||
let (raw, metadata) = match client.conversation_stream_messages(request).await {
|
||||
|
|
@ -457,7 +537,16 @@ async fn run_one_attempt(
|
|||
};
|
||||
let (teed, captured) = tee_errors(raw);
|
||||
let l2 = stream_messages(teed, metadata, request_id.clone(), idle_timeout);
|
||||
drive_l2(l2, request_id, event_tx, cancel_token, captured, None).await
|
||||
drive_l2(
|
||||
l2,
|
||||
request_id,
|
||||
event_tx,
|
||||
cancel_token,
|
||||
captured,
|
||||
None,
|
||||
output_observed,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -497,6 +586,7 @@ fn tee_errors<'a, T: Send + 'a>(
|
|||
/// the terminal event (or cancellation). `doom_check`, when set, turns a
|
||||
/// completed response carrying confident doom-loop signals into a
|
||||
/// retryable failure (belt-and-braces behind the mid-stream abort).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn drive_l2(
|
||||
l2: impl futures_util::Stream<Item = SamplingEvent>,
|
||||
request_id: RequestId,
|
||||
|
|
@ -504,6 +594,7 @@ async fn drive_l2(
|
|||
cancel_token: &CancellationToken,
|
||||
captured: ErrorCell,
|
||||
doom_check: Option<xai_grok_sampling_types::DoomLoopRecoveryPolicy>,
|
||||
output_observed: Arc<AtomicBool>,
|
||||
) -> AttemptOutcome {
|
||||
let mut l2 = pin!(l2);
|
||||
loop {
|
||||
|
|
@ -514,6 +605,7 @@ async fn drive_l2(
|
|||
}
|
||||
next = l2.next() => match next {
|
||||
Some(SamplingEvent::Completed { response, metrics, .. }) => {
|
||||
output_observed.store(true, Ordering::Relaxed);
|
||||
// Doom outranks the truncation/empty classes: a confident
|
||||
// loop poisons the attempt whatever else it looks like.
|
||||
if let Some(policy) = doom_check {
|
||||
|
|
@ -552,6 +644,16 @@ async fn drive_l2(
|
|||
return AttemptOutcome::Failed { error };
|
||||
}
|
||||
Some(other) => {
|
||||
if matches!(
|
||||
other,
|
||||
SamplingEvent::FirstToken { .. }
|
||||
| SamplingEvent::ChannelToken { .. }
|
||||
| SamplingEvent::ToolCallDelta { .. }
|
||||
| SamplingEvent::BackendToolCallStarted { .. }
|
||||
| SamplingEvent::BackendToolCallCompleted { .. }
|
||||
) {
|
||||
output_observed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let _ = event_tx.send(retag(other, &request_id));
|
||||
}
|
||||
None => {
|
||||
|
|
@ -842,6 +944,60 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retry_sleep_returns_immediately_on_cancellation() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let sleeper = sleep_or_cancel(Duration::from_secs(120), &cancel_token);
|
||||
tokio::pin!(sleeper);
|
||||
|
||||
cancel_token.cancel();
|
||||
assert!(!sleeper.await);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retry_decision_cancellation_emits_terminal_cancel() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||
let (completion_tx, completion_rx) = oneshot::channel();
|
||||
let mut completion_tx = Some(completion_tx);
|
||||
let mut retry_count = 0;
|
||||
let mut request = ConversationRequest::default();
|
||||
let config = SamplerConfig {
|
||||
base_url: "http://localhost".into(),
|
||||
model: "test-model".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut client = SamplingClient::new(config.clone()).expect("test client");
|
||||
let error = SamplingError::EventStreamError("retry me".into());
|
||||
|
||||
let should_continue = apply_retry_decision(
|
||||
&error,
|
||||
&mut retry_count,
|
||||
2,
|
||||
&RetryPolicy::default(),
|
||||
&event_tx,
|
||||
&RequestId::from("cancel-backoff"),
|
||||
&mut request,
|
||||
&mut client,
|
||||
&config,
|
||||
&cancel_token,
|
||||
&mut completion_tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!should_continue);
|
||||
assert!(matches!(
|
||||
event_rx.recv().await,
|
||||
Some(SamplingEvent::Retrying { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
event_rx.recv().await,
|
||||
Some(SamplingEvent::Failed { .. })
|
||||
));
|
||||
assert!(completion_rx.await.expect("completion sent").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tee_captures_first_error_only() {
|
||||
let items: Vec<SamplingResult<u32>> = vec![
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ pub struct RetryPolicy {
|
|||
/// After this many rate-limit (429) retries, escalate to the caller.
|
||||
/// Lower than `max_retries` because rate-limit waits can be long.
|
||||
pub rate_limit_retry_threshold: u32,
|
||||
#[serde(default)]
|
||||
pub retry_only_before_output: bool,
|
||||
}
|
||||
|
||||
impl Default for RetryPolicy {
|
||||
|
|
@ -191,6 +193,7 @@ impl Default for RetryPolicy {
|
|||
Self {
|
||||
max_retries: DEFAULT_MAX_RETRIES,
|
||||
rate_limit_retry_threshold: RATE_LIMIT_RETRY_THRESHOLD,
|
||||
retry_only_before_output: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ pub fn classify_error(
|
|||
if err.is_encrypted_content_error() {
|
||||
return RetryDecision::EmitToSession(clone_error(err));
|
||||
}
|
||||
if max_retries == 0 {
|
||||
return RetryDecision::Fatal(clone_error(err));
|
||||
}
|
||||
|
||||
// 413 Payload Too Large: strip inline images and try once. The
|
||||
// caller checks if there are images left after the strip; if not,
|
||||
|
|
@ -209,6 +212,9 @@ pub fn classify_error(
|
|||
if err.is_rate_limited() {
|
||||
let next_attempt = retry_count + 1;
|
||||
let effective_cap = max_retries.min(rate_limit_threshold);
|
||||
if effective_cap == 0 {
|
||||
return RetryDecision::Fatal(clone_error(err));
|
||||
}
|
||||
if next_attempt >= effective_cap {
|
||||
return RetryDecision::Fatal(clone_error(err));
|
||||
}
|
||||
|
|
@ -227,7 +233,7 @@ pub fn classify_error(
|
|||
// later retries just back off.
|
||||
if err.is_retryable() {
|
||||
let next_attempt = retry_count + 1;
|
||||
if next_attempt >= max_retries {
|
||||
if max_retries == 0 || next_attempt >= max_retries {
|
||||
return RetryDecision::Fatal(clone_error(err));
|
||||
}
|
||||
let backoff = err
|
||||
|
|
@ -619,6 +625,34 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_retry_budget_never_reuses_a_model_output_cap() {
|
||||
for err in [
|
||||
api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom"),
|
||||
api_err(StatusCode::PAYLOAD_TOO_LARGE, "too big"),
|
||||
api_err(StatusCode::BAD_REQUEST, "Could not process image"),
|
||||
SamplingError::EmptyResponse {
|
||||
context: xai_grok_sampling_types::EmptyResponseContext {
|
||||
reason: xai_grok_sampling_types::EmptyReason::NoVisibleContent,
|
||||
had_reasoning: false,
|
||||
content_len: 0,
|
||||
tool_call_count: 0,
|
||||
finish_reason: Some("stop".into()),
|
||||
completion_tokens: Some(1),
|
||||
reasoning_tokens: Some(0),
|
||||
prompt_tokens: Some(10),
|
||||
model: "m".into(),
|
||||
first_choice_seen: true,
|
||||
},
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
classify_error(&err, 0, 0, RATE_LIMIT_RETRY_THRESHOLD),
|
||||
RetryDecision::Fatal(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_5xx_first_retry_rebuilds_client() {
|
||||
let err = api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
//! [`SamplingEvent`]s. Pure: no I/O, no shell coupling.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
|
|
@ -45,8 +49,15 @@ pub(crate) fn responses_event_has_meaningful_content(event: &rs::ResponseStreamE
|
|||
ResponseStreamEvent::ResponseCodeInterpreterCallCodeDone(event) => !event.code.is_empty(),
|
||||
ResponseStreamEvent::ResponseCustomToolCallInputDelta(event) => !event.delta.is_empty(),
|
||||
ResponseStreamEvent::ResponseCustomToolCallInputDone(event) => !event.input.is_empty(),
|
||||
ResponseStreamEvent::ResponseFailed(event) => {
|
||||
!event.response.output.is_empty()
|
||||
|| event
|
||||
.response
|
||||
.usage
|
||||
.as_ref()
|
||||
.is_some_and(|usage| usage.output_tokens > 0)
|
||||
}
|
||||
ResponseStreamEvent::ResponseCompleted(_)
|
||||
| ResponseStreamEvent::ResponseFailed(_)
|
||||
| ResponseStreamEvent::ResponseIncomplete(_)
|
||||
| ResponseStreamEvent::ResponseOutputItemAdded(_)
|
||||
| ResponseStreamEvent::ResponseOutputItemDone(_)
|
||||
|
|
@ -78,6 +89,11 @@ pub(crate) fn responses_event_has_meaningful_content(event: &rs::ResponseStreamE
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn responses_event_may_have_output(event: &rs::ResponseStreamEvent) -> bool {
|
||||
!matches!(event, rs::ResponseStreamEvent::ResponseError(_))
|
||||
&& responses_event_has_meaningful_content(event)
|
||||
}
|
||||
|
||||
/// Transform a raw Responses API event stream into a stream of
|
||||
/// [`SamplingEvent`]s.
|
||||
///
|
||||
|
|
@ -97,6 +113,24 @@ pub fn stream_responses<'a>(
|
|||
request_id: RequestId,
|
||||
idle_timeout: Duration,
|
||||
doom_loop: Option<crate::doom_loop::DoomLoopSignalCollector>,
|
||||
) -> impl Stream<Item = SamplingEvent> + Send + 'a {
|
||||
stream_responses_tracked(
|
||||
raw_stream,
|
||||
model_metadata,
|
||||
request_id,
|
||||
idle_timeout,
|
||||
doom_loop,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn stream_responses_tracked<'a>(
|
||||
raw_stream: BoxStream<'a, Result<rs::ResponseStreamEvent, SamplingError>>,
|
||||
model_metadata: Option<ResponseModelMetadata>,
|
||||
request_id: RequestId,
|
||||
idle_timeout: Duration,
|
||||
doom_loop: Option<crate::doom_loop::DoomLoopSignalCollector>,
|
||||
output_observed: Arc<AtomicBool>,
|
||||
) -> impl Stream<Item = SamplingEvent> + Send + 'a {
|
||||
async_stream::stream! {
|
||||
use rs::{ResponseStreamEvent, Status};
|
||||
|
|
@ -158,6 +192,10 @@ pub fn stream_responses<'a>(
|
|||
}
|
||||
};
|
||||
|
||||
if responses_event_may_have_output(&event) {
|
||||
output_observed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// A confident server-detected loop aborts the attempt (dropping
|
||||
// the SSE connection) so the retry loop can resample instead of
|
||||
// streaming the burning tail. Checked before the event is
|
||||
|
|
@ -658,6 +696,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_failed_response_is_not_treated_as_output() {
|
||||
let event = rs::ResponseStreamEvent::ResponseFailed(rs_types::ResponseFailedEvent {
|
||||
response: failed_response_with_error("boom"),
|
||||
sequence_number: 0,
|
||||
});
|
||||
assert!(!responses_event_may_have_output(&event));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_failed_yields_failed_500() {
|
||||
let failed = rs::ResponseStreamEvent::ResponseFailed(rs_types::ResponseFailedEvent {
|
||||
|
|
@ -766,6 +813,67 @@ mod tests {
|
|||
assert!(responses_event_has_meaningful_content(&completed_event()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_classifier_covers_non_forwarded_backend_events() {
|
||||
let queued = rs::ResponseStreamEvent::ResponseQueued(rs_types::ResponseQueuedEvent {
|
||||
sequence_number: 0,
|
||||
response: empty_completed_response(),
|
||||
});
|
||||
assert!(!responses_event_may_have_output(&queued));
|
||||
|
||||
let response_error = rs::ResponseStreamEvent::ResponseError(rs_types::ResponseErrorEvent {
|
||||
sequence_number: 1,
|
||||
code: Some("server_error".into()),
|
||||
message: "failed before output".into(),
|
||||
param: None,
|
||||
});
|
||||
assert!(!responses_event_may_have_output(&response_error));
|
||||
|
||||
let refusal =
|
||||
rs::ResponseStreamEvent::ResponseRefusalDelta(rs_types::ResponseRefusalDeltaEvent {
|
||||
sequence_number: 1,
|
||||
item_id: "item-1".into(),
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: "no".into(),
|
||||
});
|
||||
assert!(responses_event_may_have_output(&refusal));
|
||||
|
||||
let backend_progress = rs::ResponseStreamEvent::ResponseWebSearchCallSearching(
|
||||
rs_types::ResponseWebSearchCallSearchingEvent {
|
||||
sequence_number: 2,
|
||||
output_index: 0,
|
||||
item_id: "search-1".into(),
|
||||
},
|
||||
);
|
||||
assert!(responses_event_may_have_output(&backend_progress));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tracked_stream_marks_non_forwarded_refusal_as_output() {
|
||||
let output_observed = Arc::new(AtomicBool::new(false));
|
||||
let refusal =
|
||||
rs::ResponseStreamEvent::ResponseRefusalDelta(rs_types::ResponseRefusalDeltaEvent {
|
||||
sequence_number: 0,
|
||||
item_id: "item-1".into(),
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: "no".into(),
|
||||
});
|
||||
let raw = stream::iter(vec![Ok(refusal), Ok(completed_event())]).boxed();
|
||||
let _ = collect(stream_responses_tracked(
|
||||
raw,
|
||||
None,
|
||||
rid(),
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
Arc::clone(&output_observed),
|
||||
))
|
||||
.await;
|
||||
|
||||
assert!(output_observed.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
fn function_call_added_event(
|
||||
output_index: u32,
|
||||
call_id: &str,
|
||||
|
|
|
|||
Loading…
Reference in a new issue