agent_engine/runtime/trace/
openai.rs1use 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
37pub 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
48pub 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
60pub 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
70pub 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
77pub 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
89pub 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
108pub 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
135pub 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#[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 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
230fn 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 if exact_sent_bytes.is_none() {
257 structure.wire = None;
258 }
259 structure.cache = Default::default();
262 structure
263}
264
265pub struct StreamAttempt {
269 tracer: Option<RequestTracer>,
270 clock: AttemptClock,
271}
272
273impl StreamAttempt {
274 pub fn new(tracer: Option<RequestTracer>) -> Self {
276 Self {
277 tracer,
278 clock: AttemptClock::start(),
279 }
280 }
281
282 pub fn restart_clock(&mut self) {
285 self.clock = AttemptClock::start();
286 }
287
288 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 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 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 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 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}