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.text.is_empty()
147 && result.tool_calls.is_empty()
148 && result
149 .thinking
150 .as_deref()
151 .map(str::is_empty)
152 .unwrap_or(true);
153 if committed_nothing && result.output_tokens > 0 {
154 return DispatchOutcome::EmptyCompletionTerminal {
155 completion_tokens: result.output_tokens,
156 };
157 }
158 if empty_retries > 0 {
159 return DispatchOutcome::EmptyCompletionTransientRecovered {
160 completion_tokens: result.output_tokens,
161 content_len,
162 empty_retries,
163 };
164 }
165 DispatchOutcome::Served {
166 completion_tokens: result.output_tokens,
167 content_len,
168 }
169 }
170
171 pub(crate) fn from_error_message(message: &str) -> Self {
177 let lower = message.to_lowercase();
178 if lower.contains("completion_tokens=")
179 && (lower.contains("delivered no content")
180 || (lower.contains("no dispatchable tool call or answer")
181 && lower.contains("upstream contract violation")))
182 {
183 return DispatchOutcome::EmptyCompletionTerminal {
188 completion_tokens: 0,
189 };
190 }
191 if lower.contains("rate limit")
192 || lower.contains("quota")
193 || lower.contains("usage limit")
194 || lower.contains("429")
195 {
196 return DispatchOutcome::UsageLimit;
197 }
198 DispatchOutcome::ProviderError {
199 class: provider_error_class(&lower),
200 }
201 }
202
203 pub(crate) fn label(&self) -> &'static str {
205 match self {
206 DispatchOutcome::Served { .. } => "served",
207 DispatchOutcome::EmptyCompletionTransientRecovered { .. } => {
208 "empty_completion_transient_recovered"
209 }
210 DispatchOutcome::EmptyCompletionTerminal { .. } => "empty_completion_terminal",
211 DispatchOutcome::UsageLimit => "usage_limit",
212 DispatchOutcome::ProviderError { .. } => "provider_error",
213 }
214 }
215
216 fn to_json(&self) -> serde_json::Value {
217 match self {
218 DispatchOutcome::Served {
219 completion_tokens,
220 content_len,
221 } => serde_json::json!({
222 "kind": "served",
223 "completion_tokens": completion_tokens,
224 "content_len": content_len,
225 }),
226 DispatchOutcome::EmptyCompletionTransientRecovered {
227 completion_tokens,
228 content_len,
229 empty_retries,
230 } => serde_json::json!({
231 "kind": "empty_completion_transient_recovered",
232 "completion_tokens": completion_tokens,
233 "content_len": content_len,
234 "empty_retries": empty_retries,
235 }),
236 DispatchOutcome::EmptyCompletionTerminal { completion_tokens } => serde_json::json!({
237 "kind": "empty_completion_terminal",
238 "completion_tokens": completion_tokens,
239 "content_len": 0,
240 }),
241 DispatchOutcome::UsageLimit => serde_json::json!({
242 "kind": "usage_limit",
243 }),
244 DispatchOutcome::ProviderError { class } => serde_json::json!({
245 "kind": "provider_error",
246 "class": class,
247 }),
248 }
249 }
250}
251
252fn provider_error_class(lower: &str) -> String {
255 for (needle, class) in [
256 ("api error", "api_error"),
257 ("timed out", "timeout"),
258 ("timeout", "timeout"),
259 ("connection", "connection"),
260 ("missing content array", "malformed_response"),
261 ("authentication", "auth"),
262 ("unauthorized", "auth"),
263 ("401", "auth"),
264 ("not found", "not_found"),
265 ("404", "not_found"),
266 ("overloaded", "overloaded"),
267 ("500", "server_error"),
268 ("502", "server_error"),
269 ("503", "server_error"),
270 ] {
271 if lower.contains(needle) {
272 return class.to_string();
273 }
274 }
275 "unknown".to_string()
276}
277
278pub fn wire_format_for(provider: &str, model: &str) -> &'static str {
282 match super::capabilities::lookup(provider, model).message_wire_format {
283 WireDialect::Anthropic => "anthropic_native",
284 WireDialect::OpenAiCompat => "openai_compat",
285 WireDialect::Ollama => "ollama",
286 WireDialect::Gemini => "gemini",
287 }
288}
289
290fn base_url_host(provider: &str) -> String {
294 let base_url = super::helpers::ResolvedProvider::resolve(provider).base_url;
295 base_url
296 .split("://")
297 .nth(1)
298 .and_then(|rest| rest.split('/').next())
299 .map(str::to_string)
300 .unwrap_or(base_url)
301}
302
303fn thinking_json(thinking: &ThinkingConfig) -> serde_json::Value {
304 match thinking {
305 ThinkingConfig::Disabled => serde_json::json!({"mode": "off", "enabled": false}),
306 ThinkingConfig::Enabled { budget_tokens } => serde_json::json!({
307 "mode": "enabled",
308 "enabled": true,
309 "budget_tokens": budget_tokens,
310 }),
311 ThinkingConfig::Adaptive => serde_json::json!({"mode": "adaptive", "enabled": true}),
312 ThinkingConfig::Effort { level } => serde_json::json!({
313 "mode": "effort",
314 "level": level.as_str(),
315 "enabled": !thinking.is_disabled(),
316 }),
317 }
318}
319
320pub(crate) fn build_record(
326 iteration: usize,
327 call_id: &str,
328 span_id: Option<u64>,
329 timestamp: String,
330 opts: &LlmCallOptions,
331 effective_tool_format: &str,
332 outcome: &DispatchOutcome,
333) -> serde_json::Value {
334 let provenance = opts.dispatch_provenance.clone().unwrap_or_default();
335 serde_json::json!({
336 "type": "resolved_dispatch",
337 "iteration": iteration,
338 "call_id": call_id,
339 "span_id": span_id,
340 "timestamp": timestamp,
341 "provider": opts.provider,
342 "model": opts.model,
343 "wire_format": wire_format_for(&opts.provider, &opts.model),
344 "thinking": thinking_json(&opts.thinking),
345 "tool_format": effective_tool_format,
346 "base_url_host": base_url_host(&opts.provider),
347 "provenance": provenance.to_json(),
348 "outcome": outcome.to_json(),
349 "outcome_kind": outcome.label(),
350 })
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn wire_format_native_for_anthropic_claude() {
359 assert_eq!(
361 wire_format_for("anthropic", "claude-sonnet-4-6"),
362 "anthropic_native"
363 );
364 }
365
366 #[test]
367 fn wire_format_compat_for_openai_style() {
368 assert_eq!(wire_format_for("openai", "gpt-4o"), "openai_compat");
369 }
370
371 #[test]
372 fn wire_format_preserves_native_non_openai_dialects() {
373 assert_eq!(wire_format_for("gemini", "gemini-2.5-pro"), "gemini");
374 assert_eq!(wire_format_for("ollama", "llama3.2"), "ollama");
375 }
376
377 #[test]
378 fn outcome_empty_completion_terminal_from_billed_no_content() {
379 let msg = "anthropic-native model anthropic:claude-sonnet-4-6 reported \
382 completion_tokens=8 but delivered no content, reasoning, or tool calls";
383 assert!(matches!(
384 DispatchOutcome::from_error_message(msg),
385 DispatchOutcome::EmptyCompletionTerminal {
386 completion_tokens: 0
387 }
388 ));
389 }
390
391 #[test]
392 fn transient_recovered_is_not_served_empty() {
393 let recovered = DispatchOutcome::EmptyCompletionTransientRecovered {
396 completion_tokens: 487,
397 content_len: 1666,
398 empty_retries: 3,
399 };
400 assert_eq!(recovered.label(), "empty_completion_transient_recovered");
401 assert!(!matches!(
402 recovered,
403 DispatchOutcome::EmptyCompletionTerminal { .. }
404 ));
405 }
406
407 #[test]
408 fn outcome_usage_limit_from_quota() {
409 assert_eq!(
410 DispatchOutcome::from_error_message("provider returned 429 rate limit exceeded"),
411 DispatchOutcome::UsageLimit
412 );
413 }
414
415 #[test]
416 fn outcome_provider_error_class() {
417 match DispatchOutcome::from_error_message("anthropic API error: overloaded") {
418 DispatchOutcome::ProviderError { class } => assert_eq!(class, "api_error"),
419 other => panic!("expected provider_error, got {other:?}"),
420 }
421 }
422
423 #[test]
424 fn provenance_inherited_marker_is_stable() {
425 assert_eq!(
426 DispatchProvenance::INHERITED_FROM_PRIMARY,
427 "inherited_from_primary"
428 );
429 let prov = DispatchProvenance {
430 provider: Some(DispatchProvenance::INHERITED_FROM_PRIMARY.to_string()),
431 ..Default::default()
432 };
433 let json = prov.to_json();
434 assert_eq!(json["provider"], "inherited_from_primary");
435 assert_eq!(json["model"], "unknown");
438 }
439}