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