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/// 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}