Skip to main content

agent_engine/runtime/trace/
openai.rs

1//! Task 10A: trace wiring for the OpenAI-compatible transports — Chat
2//! Completions (broker-proxied) and Responses/Codex (broker-proxied xAI and
3//! direct ChatGPT-backend). Mirrors the Anthropic seam in `trace::anthropic`
4//! and `runtime/api.rs`:
5//!
6//! - **Exact bytes only.** The wire digest/length always come from the one
7//!   buffer serialized by the transport. Broker-backed sends pass that same
8//!   buffer through the typed [`ProxyRequest::body_bytes`] handoff, which
9//!   `LocalBroker` sends verbatim. On a **remote** broker the daemon
10//!   re-serializes the JSON value out of process, so those records carry
11//!   `wire: None` and `TransportKind::CloudProxy` — never a digest of bytes
12//!   this process did not send.
13//! - **One record per actual HTTP send/attempt**, sharing a request ID with
14//!   strictly increasing attempt ordinals (rule documented in
15//!   [`trace::emit`](super::emit)).
16//! - **Validated identity only.** Error codes are static literals or
17//!   `http_<status>`; provider request IDs pass [`TraceId`] validation;
18//!   normalized stop reasons come from a closed mapping. Raw provider text
19//!   never enters a record.
20//! - **Correctness firewall.** Everything degrades to no-ops when the
21//!   context is disabled or identity is unrepresentable.
22//!
23//! [`ProxyRequest::body_bytes`]: agent_core::auth::ProxyRequest
24
25use super::emit::{AttemptClock, RequestStructure, RequestTracer, TraceContext};
26use super::key::TraceDigestKey;
27use super::types::{
28    EndpointMeta, RetryClass, StopReason, TraceId, TranslationAction, TranslationElement,
29    TranslationLoss, TransportKind, UsageMeta, UsageProvenance,
30};
31use crate::runtime::openai::translate::ToolNameMap;
32use agent_core::prompt::QualifiedModelId;
33use agent_core::TurnOutcome;
34use serde_json::Value;
35use std::time::Duration;
36
37/// Map an OpenAI-compatible HTTP failure status to a coarse retry class.
38pub fn retry_class_for_status(status: u16) -> RetryClass {
39    match status {
40        429 => RetryClass::RateLimited,
41        500 | 502 | 503 | 504 | 520 | 529 => RetryClass::ServerError,
42        401 | 403 => RetryClass::Auth,
43        408 => RetryClass::Timeout,
44        _ => RetryClass::Other,
45    }
46}
47
48/// Normalize an OpenAI Chat Completions `finish_reason` into the trace enum.
49/// Unknown values collapse to `Other` — the raw string is never stored.
50pub fn stop_reason_from_finish_reason(raw: &str) -> StopReason {
51    match raw {
52        "stop" => StopReason::EndTurn,
53        "length" => StopReason::MaxTokens,
54        "tool_calls" | "function_call" => StopReason::ToolUse,
55        "content_filter" => StopReason::Refusal,
56        _ => StopReason::Other,
57    }
58}
59
60/// Extract the upstream HTTP status from a redacted broker proxy error
61/// (`… provider request failed: <status> <reason>`). Returns only a parsed
62/// numeric status — never any provider-authored text.
63pub fn broker_error_status(message: &str) -> Option<u16> {
64    const MARKER: &str = "provider request failed: ";
65    let rest = &message[message.find(MARKER)? + MARKER.len()..];
66    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
67    digits.parse().ok().filter(|s| (100..=599).contains(s))
68}
69
70/// Validate a provider-assigned request ID from response headers into a
71/// bounded [`TraceId`]. Invalid or hostile values are omitted — never copied
72/// raw into a trace record.
73pub fn provider_request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<TraceId> {
74    crate::runtime::telemetry::request_id_from_headers(headers).and_then(|s| TraceId::new(s).ok())
75}
76
77/// Provider-reported usage for a trace record. `None` inputs stay absent —
78/// zeros are only recorded when the provider actually reported them.
79pub fn provider_usage(input: u64, output: u64, cached: u64) -> UsageMeta {
80    UsageMeta {
81        provenance: UsageProvenance::ProviderReported,
82        input_tokens: Some(input),
83        output_tokens: Some(output),
84        cache_read_tokens: Some(cached),
85        cache_write_tokens: None,
86    }
87}
88
89/// Translation-report entries for the rewrites the OpenAI adapter is known
90/// to perform today: tool names sanitized for the OpenAI wire grammar.
91/// Element IDs are the *original* names when they fit the bounded safe
92/// grammar; otherwise the entry stands without an ID (never raw text). The
93/// full per-element OpenAI IR report is incremental follow-up work (Task 9
94/// allows per-provider reports to land incrementally) — but a known rename
95/// is never silently claimed lossless.
96pub fn renamed_tool_losses(name_map: &ToolNameMap) -> Vec<TranslationLoss> {
97    name_map
98        .renamed_originals()
99        .into_iter()
100        .map(|original| TranslationLoss {
101            action: TranslationAction::Renamed,
102            element: TranslationElement::Tool,
103            element_id: TraceId::new(original).ok(),
104        })
105        .collect()
106}
107
108/// Text documents lose structural provenance on the OpenAI wire. Never let
109/// that lowering bypass attachment privacy: consume the optional content arm
110/// visibly without a bundle (metadata tracing still proceeds normally).
111pub fn capture_request_content(
112    trace: &TraceContext,
113    request_id: &TraceId,
114    body: &[u8],
115    messages: &[crate::SharedMessage],
116) {
117    fn text_document(block: &Value) -> bool {
118        (block["type"] == "document" && block["source"]["type"] == "text")
119            || (block["type"] == "tool_result"
120                && block["content"]
121                    .as_array()
122                    .is_some_and(|inner| inner.iter().any(text_document)))
123    }
124    if messages.iter().any(|message| {
125        message["content"]
126            .as_array()
127            .is_some_and(|blocks| blocks.iter().any(text_document))
128    }) {
129        trace.capture_unsupported("text_document_attachment_content_withheld");
130    } else {
131        trace.capture_request_content(request_id, body);
132    }
133}
134
135/// Metadata-only attachment rewrites. Native image/file bytes survive, but
136/// origin labels and tool-result lifts synthesize user blocks; text documents
137/// are lowered from documents to ordinary input text. Never record filenames,
138/// tool outputs, URLs, or source payloads in this report.
139pub fn attachment_translation_losses(messages: &[crate::SharedMessage]) -> Vec<TranslationLoss> {
140    fn inspect(block: &Value, id: String, nested: bool, out: &mut Vec<TranslationLoss>) {
141        if matches!(block["type"].as_str(), Some("image" | "document")) {
142            out.push(TranslationLoss {
143                action: if block["source"]["type"] == "text" {
144                    TranslationAction::Downgraded
145                } else {
146                    TranslationAction::Synthesized
147                },
148                element: TranslationElement::MessageBlock,
149                element_id: TraceId::new(&id).ok(),
150            });
151            if nested && block["source"]["type"] == "text" {
152                out.push(TranslationLoss {
153                    action: TranslationAction::Synthesized,
154                    element: TranslationElement::MessageBlock,
155                    element_id: TraceId::new(&id).ok(),
156                });
157            }
158        } else if block["type"] == "tool_result" {
159            if let Some(inner) = block["content"].as_array() {
160                for (i, b) in inner.iter().enumerate() {
161                    inspect(b, format!("{id}.content[{i}]"), true, out);
162                }
163            }
164        }
165    }
166    let mut losses = Vec::new();
167    for (m, message) in messages.iter().enumerate() {
168        if let Some(blocks) = message["content"].as_array() {
169            for (b, block) in blocks.iter().enumerate() {
170                inspect(
171                    block,
172                    format!("messages[{m}].blocks[{b}]"),
173                    false,
174                    &mut losses,
175                );
176            }
177        }
178    }
179    losses
180}
181
182/// Begin tracing one OpenAI-compatible request. Returns `None` when tracing
183/// is disabled or any structural identity is unrepresentable — tracing can
184/// never fail the request.
185///
186/// `exact_sent_bytes` MUST be the exact buffer this process hands to the
187/// transport (reqwest body or `ProxyRequest::body_bytes`); pass `None` on
188/// remote-broker paths where the upstream bytes are serialized out of
189/// process — the record then carries no wire digest rather than a false one.
190#[allow(clippy::too_many_arguments)]
191pub async fn begin_openai_tracer(
192    trace: &TraceContext,
193    provider: &str,
194    model: &str,
195    transport: TransportKind,
196    url: &str,
197    exact_sent_bytes: Option<&[u8]>,
198    messages: &[crate::SharedMessage],
199    system_prompt: Option<&str>,
200    tools_schema: &[Value],
201    translation: Vec<TranslationLoss>,
202) -> Option<RequestTracer> {
203    if !trace.enabled() {
204        return None;
205    }
206    let parsed = reqwest::Url::parse(url).ok()?;
207    let host = parsed.host_str()?;
208    let host = match parsed.port() {
209        Some(port) => format!("{host}:{port}"),
210        None => host.to_string(),
211    };
212    let endpoint = EndpointMeta::new(host, parsed.path()).ok()?;
213    let model = QualifiedModelId::parse(format!("{provider}/{model}")).ok()?;
214    // Lazy, spawn_blocking-backed key load; `None` degrades the record to
215    // digest-free sections and is counted, never surfaced as an error.
216    let key = trace.digest_key().await;
217    let mut translation = translation;
218    translation.extend(attachment_translation_losses(messages));
219    let structure = openai_request_structure(
220        key.as_deref(),
221        exact_sent_bytes,
222        messages,
223        system_prompt,
224        tools_schema,
225        translation,
226    );
227    RequestTracer::begin(trace, None, model, transport, endpoint, structure)
228}
229
230/// Structural, metadata-only description of one OpenAI-compatible request.
231/// The walker operates on the internal normalized (Anthropic-shaped)
232/// message array shared by every transport; prompt-cache markers are not
233/// representable on the OpenAI wire, so the cache section is always empty.
234fn openai_request_structure(
235    key: Option<&TraceDigestKey>,
236    exact_sent_bytes: Option<&[u8]>,
237    messages: &[crate::SharedMessage],
238    system_prompt: Option<&str>,
239    tools_schema: &[Value],
240    translation: Vec<TranslationLoss>,
241) -> RequestStructure {
242    let mut structure = super::anthropic::anthropic_request_structure(
243        key,
244        exact_sent_bytes.unwrap_or(&[]),
245        messages,
246        system_prompt,
247        tools_schema,
248        None,
249        false,
250        false,
251        translation,
252        None,
253    );
254    // No exact bytes → no wire claim, ever (remote broker serializes the
255    // upstream body out of process).
256    if exact_sent_bytes.is_none() {
257        structure.wire = None;
258    }
259    // cache_control annotations on the internal messages are NOT sent on
260    // the OpenAI wire — reporting them would fabricate cache boundaries.
261    structure.cache = Default::default();
262    structure
263}
264
265/// Per-attempt trace state for one OpenAI-compatible request: the optional
266/// tracer plus this attempt's monotonic clock. Every method is a no-op when
267/// tracing is disabled; nothing here can fail the request.
268pub struct StreamAttempt {
269    tracer: Option<RequestTracer>,
270    clock: AttemptClock,
271}
272
273impl StreamAttempt {
274    /// Start the first attempt's clock. Call immediately before the send.
275    pub fn new(tracer: Option<RequestTracer>) -> Self {
276        Self {
277            tracer,
278            clock: AttemptClock::start(),
279        }
280    }
281
282    /// Restart the per-attempt clock (retry loops call this right before
283    /// each re-send — retry clocks reset per attempt).
284    pub fn restart_clock(&mut self) {
285        self.clock = AttemptClock::start();
286    }
287
288    /// The trace request ID, when a tracer began (for content-capture
289    /// correlation). `None` when tracing is disabled for this request.
290    pub fn request_id(&self) -> Option<&super::TraceId> {
291        self.tracer.as_ref().map(|t| t.request_id())
292    }
293
294    pub fn mark_headers(&mut self) {
295        self.clock.mark_headers();
296    }
297
298    pub fn mark_first_byte(&mut self) {
299        self.clock.mark_first_byte();
300    }
301
302    pub fn mark_first_model_event(&mut self) {
303        self.clock.mark_first_model_event();
304    }
305
306    /// Record a failed attempt that WILL be retried (one record per actual
307    /// send). `code` must be a static literal or `http_<status>`.
308    pub fn attempt_failed(
309        &mut self,
310        class: RetryClass,
311        delay: Duration,
312        http_status: Option<u16>,
313        provider_request_id: Option<TraceId>,
314        code: &str,
315    ) {
316        if let Some(t) = self.tracer.as_mut() {
317            t.attempt_failed(
318                self.clock,
319                class,
320                delay,
321                http_status,
322                provider_request_id,
323                code,
324            );
325        }
326    }
327
328    /// Final record: request completed. Subsequent finish calls are no-ops
329    /// (the tracer is taken), so error paths can never double-emit.
330    pub fn finish_success(
331        &mut self,
332        http_status: Option<u16>,
333        provider_request_id: Option<TraceId>,
334        stop_reason: Option<StopReason>,
335        usage: Option<UsageMeta>,
336    ) {
337        self.clock.mark_stream_end();
338        if let Some(t) = self.tracer.take() {
339            t.finish(
340                self.clock,
341                http_status,
342                provider_request_id,
343                stop_reason,
344                usage,
345                TurnOutcome::Completed,
346            );
347        }
348    }
349
350    /// Final record: request canceled mid-attempt.
351    pub fn finish_canceled(&mut self, http_status: Option<u16>, usage: Option<UsageMeta>) {
352        self.clock.mark_stream_end();
353        if let Some(t) = self.tracer.take() {
354            t.finish(
355                self.clock,
356                http_status,
357                None,
358                None,
359                usage,
360                TurnOutcome::Canceled,
361            );
362        }
363    }
364
365    /// Final record: terminal provider/transport failure. `code` must be a
366    /// static literal or `http_<status>` — never raw provider text.
367    pub fn finish_failed(
368        &mut self,
369        code: &str,
370        http_status: Option<u16>,
371        provider_request_id: Option<TraceId>,
372    ) {
373        self.clock.mark_stream_end();
374        if let Some(t) = self.tracer.take() {
375            let terminal = t.failed_terminal(code);
376            t.finish(
377                self.clock,
378                http_status,
379                provider_request_id,
380                None,
381                None,
382                terminal,
383            );
384        }
385    }
386}
387
388#[cfg(test)]
389mod attachment_capture_tests {
390    use super::*;
391    use std::sync::Arc;
392    #[test]
393    fn lowered_document_capture_is_visibly_withheld_using_canonical_provenance() {
394        let temp = tempfile::tempdir().unwrap();
395        let capture = Arc::new(super::super::controls::ContentCapture::new(
396            temp.path().to_path_buf(),
397        ));
398        let trace = TraceContext::disabled().with_content_capture(capture);
399        let messages = vec![Arc::new(serde_json::json!({"role":"user","content":[
400            {"type":"document","title":"private.txt","source":{"type":"text","media_type":"text/plain","data":"FILE_SENTINEL"}}
401        ]}))];
402        capture_request_content(
403            &trace,
404            &TraceId::new("capture-test").unwrap(),
405            br#"{"input":"FILE_SENTINEL"}"#,
406            &messages,
407        );
408        assert_eq!(trace.degraded_records(), 1);
409        assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
410    }
411}