1use super::api::{LlmCallOptions, ThinkingConfig};
29use super::capabilities::WireDialect;
30
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct DispatchProvenance {
49 pub provider: Option<String>,
50 pub model: Option<String>,
51 pub wire_format: Option<String>,
52 pub thinking: Option<String>,
53 pub tool_format: Option<String>,
54}
55
56impl DispatchProvenance {
57 pub const INHERITED_FROM_PRIMARY: &'static str = "inherited_from_primary";
61 pub const OPERATOR_PIN: &'static str = "operator_pin";
62 pub const ESCALATION_OVERRIDE: &'static str = "escalation_override";
63 pub const PIPELINE_INPUT: &'static str = "pipeline_input";
64 pub const CATALOG_DEFAULT: &'static str = "catalog_default";
65
66 pub fn from_vm_value(value: &crate::value::VmValue) -> Option<Self> {
73 let dict = value.as_dict()?;
74 let field = |key: &str| -> Option<String> {
75 dict.get(key)
76 .map(|v| v.as_str_cow().into_owned())
77 .filter(|s| !s.is_empty())
78 };
79 Some(Self {
80 provider: field("provider"),
81 model: field("model"),
82 wire_format: field("wire_format"),
83 thinking: field("thinking"),
84 tool_format: field("tool_format"),
85 })
86 }
87
88 fn origin_or_unknown(value: &Option<String>) -> &str {
89 value.as_deref().unwrap_or("unknown")
90 }
91
92 fn to_json(&self) -> serde_json::Value {
93 serde_json::json!({
94 "provider": Self::origin_or_unknown(&self.provider),
95 "model": Self::origin_or_unknown(&self.model),
96 "wire_format": Self::origin_or_unknown(&self.wire_format),
97 "thinking": Self::origin_or_unknown(&self.thinking),
98 "tool_format": Self::origin_or_unknown(&self.tool_format),
99 })
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
112pub(crate) enum DispatchOutcome {
113 Served {
116 completion_tokens: i64,
117 content_len: usize,
118 },
119 EmptyCompletionTransientRecovered {
123 completion_tokens: i64,
124 content_len: usize,
125 empty_retries: usize,
126 },
127 EmptyCompletionTerminal { completion_tokens: i64 },
131 UsageLimit,
133 ProviderError { class: String },
135}
136
137impl DispatchOutcome {
138 pub(crate) fn from_result(result: &super::api::LlmResult, empty_retries: usize) -> Self {
145 let content_len = result.text.len();
146 let committed_nothing = result.committed_nothing_usable();
150 if committed_nothing && result.output_tokens > 0 {
151 return DispatchOutcome::EmptyCompletionTerminal {
152 completion_tokens: result.output_tokens,
153 };
154 }
155 if empty_retries > 0 {
156 return DispatchOutcome::EmptyCompletionTransientRecovered {
157 completion_tokens: result.output_tokens,
158 content_len,
159 empty_retries,
160 };
161 }
162 DispatchOutcome::Served {
163 completion_tokens: result.output_tokens,
164 content_len,
165 }
166 }
167
168 pub(crate) fn from_error_message(message: &str) -> Self {
174 let lower = message.to_lowercase();
175 if lower.contains("completion_tokens=")
176 && (lower.contains("delivered no content")
177 || (lower.contains("no dispatchable tool call or answer")
178 && lower.contains("upstream contract violation")))
179 {
180 return DispatchOutcome::EmptyCompletionTerminal {
185 completion_tokens: 0,
186 };
187 }
188 if lower.contains("rate limit")
189 || lower.contains("quota")
190 || lower.contains("usage limit")
191 || lower.contains("429")
192 {
193 return DispatchOutcome::UsageLimit;
194 }
195 DispatchOutcome::ProviderError {
196 class: provider_error_class(&lower),
197 }
198 }
199
200 pub(crate) fn label(&self) -> &'static str {
202 match self {
203 DispatchOutcome::Served { .. } => "served",
204 DispatchOutcome::EmptyCompletionTransientRecovered { .. } => {
205 "empty_completion_transient_recovered"
206 }
207 DispatchOutcome::EmptyCompletionTerminal { .. } => "empty_completion_terminal",
208 DispatchOutcome::UsageLimit => "usage_limit",
209 DispatchOutcome::ProviderError { .. } => "provider_error",
210 }
211 }
212
213 fn to_json(&self) -> serde_json::Value {
214 match self {
215 DispatchOutcome::Served {
216 completion_tokens,
217 content_len,
218 } => serde_json::json!({
219 "kind": "served",
220 "completion_tokens": completion_tokens,
221 "content_len": content_len,
222 }),
223 DispatchOutcome::EmptyCompletionTransientRecovered {
224 completion_tokens,
225 content_len,
226 empty_retries,
227 } => serde_json::json!({
228 "kind": "empty_completion_transient_recovered",
229 "completion_tokens": completion_tokens,
230 "content_len": content_len,
231 "empty_retries": empty_retries,
232 }),
233 DispatchOutcome::EmptyCompletionTerminal { completion_tokens } => serde_json::json!({
234 "kind": "empty_completion_terminal",
235 "completion_tokens": completion_tokens,
236 "content_len": 0,
237 }),
238 DispatchOutcome::UsageLimit => serde_json::json!({
239 "kind": "usage_limit",
240 }),
241 DispatchOutcome::ProviderError { class } => serde_json::json!({
242 "kind": "provider_error",
243 "class": class,
244 }),
245 }
246 }
247}
248
249fn provider_error_class(lower: &str) -> String {
252 for (needle, class) in [
253 ("api error", "api_error"),
254 ("timed out", "timeout"),
255 ("timeout", "timeout"),
256 ("connection", "connection"),
257 ("missing content array", "malformed_response"),
258 ("authentication", "auth"),
259 ("unauthorized", "auth"),
260 ("401", "auth"),
261 ("not found", "not_found"),
262 ("404", "not_found"),
263 ("overloaded", "overloaded"),
264 ("500", "server_error"),
265 ("502", "server_error"),
266 ("503", "server_error"),
267 ] {
268 if lower.contains(needle) {
269 return class.to_string();
270 }
271 }
272 "unknown".to_string()
273}
274
275pub fn wire_format_for(provider: &str, model: &str) -> &'static str {
279 match super::capabilities::lookup(provider, model).message_wire_format {
280 WireDialect::Anthropic => "anthropic_native",
281 WireDialect::OpenAiCompat => "openai_compat",
282 WireDialect::Ollama => "ollama",
283 WireDialect::Gemini => "gemini",
284 }
285}
286
287fn base_url_host(provider: &str) -> String {
291 let base_url = super::helpers::ResolvedProvider::resolve(provider).base_url;
292 base_url
293 .split("://")
294 .nth(1)
295 .and_then(|rest| rest.split('/').next())
296 .map(str::to_string)
297 .unwrap_or(base_url)
298}
299
300fn thinking_json(thinking: &ThinkingConfig) -> serde_json::Value {
301 match thinking {
302 ThinkingConfig::Disabled => serde_json::json!({"mode": "off", "enabled": false}),
303 ThinkingConfig::Enabled { budget_tokens } => serde_json::json!({
304 "mode": "enabled",
305 "enabled": true,
306 "budget_tokens": budget_tokens,
307 }),
308 ThinkingConfig::Adaptive => serde_json::json!({"mode": "adaptive", "enabled": true}),
309 ThinkingConfig::Effort { level } => serde_json::json!({
310 "mode": "effort",
311 "level": level.as_str(),
312 "enabled": !thinking.is_disabled(),
313 }),
314 }
315}
316
317pub(crate) fn build_record(
323 iteration: usize,
324 call_id: &str,
325 span_id: Option<u64>,
326 timestamp: String,
327 opts: &LlmCallOptions,
328 effective_tool_format: &str,
329 outcome: &DispatchOutcome,
330) -> serde_json::Value {
331 let provenance = opts.dispatch_provenance.clone().unwrap_or_default();
332 serde_json::json!({
333 "type": "resolved_dispatch",
334 "iteration": iteration,
335 "call_id": call_id,
336 "span_id": span_id,
337 "timestamp": timestamp,
338 "provider": opts.provider,
339 "model": opts.model,
340 "wire_format": wire_format_for(&opts.provider, &opts.model),
341 "thinking": thinking_json(&opts.thinking),
342 "tool_format": effective_tool_format,
343 "stop": opts.stop,
347 "base_url_host": base_url_host(&opts.provider),
348 "provenance": provenance.to_json(),
349 "outcome": outcome.to_json(),
350 "outcome_kind": outcome.label(),
351 })
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn wire_format_native_for_anthropic_claude() {
360 assert_eq!(
362 wire_format_for("anthropic", "claude-sonnet-4-6"),
363 "anthropic_native"
364 );
365 }
366
367 #[test]
368 fn wire_format_compat_for_openai_style() {
369 assert_eq!(wire_format_for("openai", "gpt-4o"), "openai_compat");
370 }
371
372 #[test]
373 fn wire_format_preserves_native_non_openai_dialects() {
374 assert_eq!(wire_format_for("gemini", "gemini-2.5-pro"), "gemini");
375 assert_eq!(wire_format_for("ollama", "llama3.2"), "ollama");
376 }
377
378 #[test]
379 fn outcome_empty_completion_terminal_from_billed_no_content() {
380 let msg = "anthropic-native model anthropic:claude-sonnet-4-6 reported \
383 completion_tokens=8 but delivered no content, reasoning, or tool calls";
384 assert!(matches!(
385 DispatchOutcome::from_error_message(msg),
386 DispatchOutcome::EmptyCompletionTerminal {
387 completion_tokens: 0
388 }
389 ));
390 }
391
392 #[test]
393 fn transient_recovered_is_not_served_empty() {
394 let recovered = DispatchOutcome::EmptyCompletionTransientRecovered {
397 completion_tokens: 487,
398 content_len: 1666,
399 empty_retries: 3,
400 };
401 assert_eq!(recovered.label(), "empty_completion_transient_recovered");
402 assert!(!matches!(
403 recovered,
404 DispatchOutcome::EmptyCompletionTerminal { .. }
405 ));
406 }
407
408 #[test]
409 fn outcome_usage_limit_from_quota() {
410 assert_eq!(
411 DispatchOutcome::from_error_message("provider returned 429 rate limit exceeded"),
412 DispatchOutcome::UsageLimit
413 );
414 }
415
416 #[test]
417 fn outcome_provider_error_class() {
418 match DispatchOutcome::from_error_message("anthropic API error: overloaded") {
419 DispatchOutcome::ProviderError { class } => assert_eq!(class, "api_error"),
420 other => panic!("expected provider_error, got {other:?}"),
421 }
422 }
423
424 #[test]
425 fn provenance_inherited_marker_is_stable() {
426 assert_eq!(
427 DispatchProvenance::INHERITED_FROM_PRIMARY,
428 "inherited_from_primary"
429 );
430 let prov = DispatchProvenance {
431 provider: Some(DispatchProvenance::INHERITED_FROM_PRIMARY.to_string()),
432 ..Default::default()
433 };
434 let json = prov.to_json();
435 assert_eq!(json["provider"], "inherited_from_primary");
436 assert_eq!(json["model"], "unknown");
439 }
440}