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/// Begin tracing one OpenAI-compatible request. Returns `None` when tracing
109/// is disabled or any structural identity is unrepresentable — tracing can
110/// never fail the request.
111///
112/// `exact_sent_bytes` MUST be the exact buffer this process hands to the
113/// transport (reqwest body or `ProxyRequest::body_bytes`); pass `None` on
114/// remote-broker paths where the upstream bytes are serialized out of
115/// process — the record then carries no wire digest rather than a false one.
116#[allow(clippy::too_many_arguments)]
117pub async fn begin_openai_tracer(
118 trace: &TraceContext,
119 provider: &str,
120 model: &str,
121 transport: TransportKind,
122 url: &str,
123 exact_sent_bytes: Option<&[u8]>,
124 messages: &[crate::SharedMessage],
125 system_prompt: Option<&str>,
126 tools_schema: &[Value],
127 translation: Vec<TranslationLoss>,
128) -> Option<RequestTracer> {
129 if !trace.enabled() {
130 return None;
131 }
132 let parsed = reqwest::Url::parse(url).ok()?;
133 let host = parsed.host_str()?;
134 let host = match parsed.port() {
135 Some(port) => format!("{host}:{port}"),
136 None => host.to_string(),
137 };
138 let endpoint = EndpointMeta::new(host, parsed.path()).ok()?;
139 let model = QualifiedModelId::parse(format!("{provider}/{model}")).ok()?;
140 // Lazy, spawn_blocking-backed key load; `None` degrades the record to
141 // digest-free sections and is counted, never surfaced as an error.
142 let key = trace.digest_key().await;
143 let structure = openai_request_structure(
144 key.as_deref(),
145 exact_sent_bytes,
146 messages,
147 system_prompt,
148 tools_schema,
149 translation,
150 );
151 RequestTracer::begin(trace, None, model, transport, endpoint, structure)
152}
153
154/// Structural, metadata-only description of one OpenAI-compatible request.
155/// The walker operates on the internal normalized (Anthropic-shaped)
156/// message array shared by every transport; prompt-cache markers are not
157/// representable on the OpenAI wire, so the cache section is always empty.
158fn openai_request_structure(
159 key: Option<&TraceDigestKey>,
160 exact_sent_bytes: Option<&[u8]>,
161 messages: &[crate::SharedMessage],
162 system_prompt: Option<&str>,
163 tools_schema: &[Value],
164 translation: Vec<TranslationLoss>,
165) -> RequestStructure {
166 let mut structure = super::anthropic::anthropic_request_structure(
167 key,
168 exact_sent_bytes.unwrap_or(&[]),
169 messages,
170 system_prompt,
171 tools_schema,
172 None,
173 false,
174 false,
175 translation,
176 None,
177 );
178 // No exact bytes → no wire claim, ever (remote broker serializes the
179 // upstream body out of process).
180 if exact_sent_bytes.is_none() {
181 structure.wire = None;
182 }
183 // cache_control annotations on the internal messages are NOT sent on
184 // the OpenAI wire — reporting them would fabricate cache boundaries.
185 structure.cache = Default::default();
186 structure
187}
188
189/// Per-attempt trace state for one OpenAI-compatible request: the optional
190/// tracer plus this attempt's monotonic clock. Every method is a no-op when
191/// tracing is disabled; nothing here can fail the request.
192pub struct StreamAttempt {
193 tracer: Option<RequestTracer>,
194 clock: AttemptClock,
195}
196
197impl StreamAttempt {
198 /// Start the first attempt's clock. Call immediately before the send.
199 pub fn new(tracer: Option<RequestTracer>) -> Self {
200 Self {
201 tracer,
202 clock: AttemptClock::start(),
203 }
204 }
205
206 /// Restart the per-attempt clock (retry loops call this right before
207 /// each re-send — retry clocks reset per attempt).
208 pub fn restart_clock(&mut self) {
209 self.clock = AttemptClock::start();
210 }
211
212 /// The trace request ID, when a tracer began (for content-capture
213 /// correlation). `None` when tracing is disabled for this request.
214 pub fn request_id(&self) -> Option<&super::TraceId> {
215 self.tracer.as_ref().map(|t| t.request_id())
216 }
217
218 pub fn mark_headers(&mut self) {
219 self.clock.mark_headers();
220 }
221
222 pub fn mark_first_byte(&mut self) {
223 self.clock.mark_first_byte();
224 }
225
226 pub fn mark_first_model_event(&mut self) {
227 self.clock.mark_first_model_event();
228 }
229
230 /// Record a failed attempt that WILL be retried (one record per actual
231 /// send). `code` must be a static literal or `http_<status>`.
232 pub fn attempt_failed(
233 &mut self,
234 class: RetryClass,
235 delay: Duration,
236 http_status: Option<u16>,
237 provider_request_id: Option<TraceId>,
238 code: &str,
239 ) {
240 if let Some(t) = self.tracer.as_mut() {
241 t.attempt_failed(
242 self.clock,
243 class,
244 delay,
245 http_status,
246 provider_request_id,
247 code,
248 );
249 }
250 }
251
252 /// Final record: request completed. Subsequent finish calls are no-ops
253 /// (the tracer is taken), so error paths can never double-emit.
254 pub fn finish_success(
255 &mut self,
256 http_status: Option<u16>,
257 provider_request_id: Option<TraceId>,
258 stop_reason: Option<StopReason>,
259 usage: Option<UsageMeta>,
260 ) {
261 self.clock.mark_stream_end();
262 if let Some(t) = self.tracer.take() {
263 t.finish(
264 self.clock,
265 http_status,
266 provider_request_id,
267 stop_reason,
268 usage,
269 TurnOutcome::Completed,
270 );
271 }
272 }
273
274 /// Final record: request canceled mid-attempt.
275 pub fn finish_canceled(&mut self, http_status: Option<u16>, usage: Option<UsageMeta>) {
276 self.clock.mark_stream_end();
277 if let Some(t) = self.tracer.take() {
278 t.finish(
279 self.clock,
280 http_status,
281 None,
282 None,
283 usage,
284 TurnOutcome::Canceled,
285 );
286 }
287 }
288
289 /// Final record: terminal provider/transport failure. `code` must be a
290 /// static literal or `http_<status>` — never raw provider text.
291 pub fn finish_failed(
292 &mut self,
293 code: &str,
294 http_status: Option<u16>,
295 provider_request_id: Option<TraceId>,
296 ) {
297 self.clock.mark_stream_end();
298 if let Some(t) = self.tracer.take() {
299 let terminal = t.failed_terminal(code);
300 t.finish(
301 self.clock,
302 http_status,
303 provider_request_id,
304 None,
305 None,
306 terminal,
307 );
308 }
309 }
310}