1use crate::peek::{ResponsePeek, ResultType};
48use crate::price::PriceBook;
49use crate::{Method, TaskStatus};
50
51#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Call {
56 pub method: Method,
58 pub name: Option<String>,
60}
61
62impl Call {
63 #[must_use]
65 pub fn new(method: Method, name: Option<String>) -> Self {
66 Self { method, name }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum TaskOriginKind {
73 ToolsCall,
75 ResourcesRead,
77 PromptsGet,
79 Other,
81}
82
83impl TaskOriginKind {
84 #[must_use]
86 pub const fn from_method(method: &Method) -> Self {
87 match method {
88 Method::ToolsCall => Self::ToolsCall,
89 Method::ResourcesRead => Self::ResourcesRead,
90 Method::PromptsGet => Self::PromptsGet,
91 _ => Self::Other,
92 }
93 }
94
95 fn method(self) -> Method {
96 match self {
97 Self::ToolsCall => Method::ToolsCall,
98 Self::ResourcesRead => Method::ResourcesRead,
99 Self::PromptsGet => Method::PromptsGet,
100 Self::Other => Method::Other("[redacted-task-origin]".to_owned()),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct TaskAttribution {
112 origin_kind: TaskOriginKind,
113 units: u64,
114}
115
116impl TaskAttribution {
117 #[must_use]
119 pub const fn new(origin_kind: TaskOriginKind, units: u64) -> Self {
120 Self { origin_kind, units }
121 }
122
123 #[must_use]
125 pub fn from_call(call: &Call, prices: &PriceBook) -> Self {
126 Self {
127 origin_kind: TaskOriginKind::from_method(&call.method),
128 units: prices.units_for(&call.method, call.name.as_deref()),
129 }
130 }
131
132 #[must_use]
134 pub const fn origin_kind(self) -> TaskOriginKind {
135 self.origin_kind
136 }
137
138 #[must_use]
140 pub const fn units(self) -> u64 {
141 self.units
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Billable {
148 pub method: Method,
150 pub name: Option<String>,
152 pub units: u64,
154 pub idempotency_key: Option<String>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163pub enum FreeReason {
164 Discovery,
166 InterimInputRequired,
168 TaskCreated,
170 TaskInProgress,
172 TaskNotDelivered,
174 TaskDrive,
176 MissingTaskAttribution,
178 MissingTaskId,
181 Subscription,
183 ProtocolError,
185 UnrecognizedResult,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum Charge {
192 Billable(Billable),
194 Free(FreeReason),
196}
197
198impl Charge {
199 #[must_use]
201 pub fn units(&self) -> u64 {
202 match self {
203 Self::Billable(b) => b.units,
204 Self::Free(_) => 0,
205 }
206 }
207
208 #[must_use]
210 pub const fn is_billable(&self) -> bool {
211 matches!(self, Self::Billable(_))
212 }
213
214 #[must_use]
216 pub fn idempotency_key(&self) -> Option<&str> {
217 match self {
218 Self::Billable(b) => b.idempotency_key.as_deref(),
219 Self::Free(_) => None,
220 }
221 }
222}
223
224#[must_use]
231pub fn decide(call: &Call, response: &ResponsePeek, prices: &PriceBook) -> Charge {
232 decide_with_task_origin(call, response, prices, None)
233}
234
235enum TaskPricing<'a> {
236 Call(&'a Call),
237 Attribution(&'a TaskAttribution),
238}
239
240#[must_use]
247pub fn decide_with_task_origin(
248 call: &Call,
249 response: &ResponsePeek,
250 prices: &PriceBook,
251 task_origin: Option<&Call>,
252) -> Charge {
253 decide_with_task_pricing(call, response, prices, task_origin.map(TaskPricing::Call))
254}
255
256#[must_use]
262pub fn decide_with_task_attribution(
263 call: &Call,
264 response: &ResponsePeek,
265 prices: &PriceBook,
266 task_attribution: Option<&TaskAttribution>,
267) -> Charge {
268 decide_with_task_pricing(
269 call,
270 response,
271 prices,
272 task_attribution.map(TaskPricing::Attribution),
273 )
274}
275
276fn decide_with_task_pricing(
277 call: &Call,
278 response: &ResponsePeek,
279 prices: &PriceBook,
280 task_pricing: Option<TaskPricing<'_>>,
281) -> Charge {
282 if response.is_error {
283 return Charge::Free(FreeReason::ProtocolError);
284 }
285
286 if call.method.is_discovery() {
287 return Charge::Free(FreeReason::Discovery);
288 }
289
290 if matches!(call.method, Method::SubscriptionsListen) {
291 return Charge::Free(FreeReason::Subscription);
292 }
293
294 if matches!(response.result_type, ResultType::Task) {
297 return Charge::Free(FreeReason::TaskCreated);
298 }
299
300 if matches!(call.method, Method::TasksGet)
304 && let Some(task) = &response.task
305 {
306 return match &task.status {
307 s if s.delivered() => {
308 let Some(task_id) = task.task_id.clone() else {
309 return Charge::Free(FreeReason::MissingTaskId);
310 };
311 let Some(origin) = task_pricing else {
312 return Charge::Free(FreeReason::MissingTaskAttribution);
313 };
314 let (method, name, units) = match origin {
315 TaskPricing::Call(origin) => (
316 origin.method.clone(),
317 origin.name.clone(),
318 prices.units_for(&origin.method, origin.name.as_deref()),
319 ),
320 TaskPricing::Attribution(attribution) => {
321 (attribution.origin_kind.method(), None, attribution.units)
322 }
323 };
324 Charge::Billable(Billable {
325 method,
326 name,
327 units,
328 idempotency_key: Some(task_id),
330 })
331 }
332 s if s.is_terminal() => Charge::Free(FreeReason::TaskNotDelivered),
333 TaskStatus::Working | TaskStatus::InputRequired => {
334 Charge::Free(FreeReason::TaskInProgress)
335 }
336 _ => Charge::Free(FreeReason::TaskInProgress),
337 };
338 }
339
340 if call.method.is_task_drive() {
341 return Charge::Free(FreeReason::TaskDrive);
342 }
343
344 match &response.result_type {
345 ResultType::Complete => Charge::Billable(Billable {
346 method: call.method.clone(),
347 name: call.name.clone(),
348 units: prices.units_for(&call.method, call.name.as_deref()),
349 idempotency_key: None,
350 }),
351 ResultType::InputRequired => Charge::Free(FreeReason::InterimInputRequired),
352 ResultType::Task => Charge::Free(FreeReason::TaskCreated),
353 ResultType::Unknown(_) | ResultType::Absent => Charge::Free(FreeReason::UnrecognizedResult),
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::peek::{TaskPeek, response as peek_response};
361 use serde_json::json;
362
363 fn call(method: Method, name: &str) -> Call {
364 Call::new(method, Some(name.to_owned()))
365 }
366
367 fn book() -> PriceBook {
368 PriceBook::flat(1)
369 }
370
371 fn complete() -> ResponsePeek {
372 peek_response(&json!({ "result": { "resultType": "complete", "content": [] } }))
373 }
374
375 fn input_required() -> ResponsePeek {
376 peek_response(&json!({
377 "result": {
378 "resultType": "input_required",
379 "inputRequests": { "k": { "method": "elicitation/create" } },
380 "requestState": "AEAD-protected blob"
381 }
382 }))
383 }
384
385 fn task_poll(status: &str, id: &str) -> ResponsePeek {
386 peek_response(&json!({
387 "result": { "resultType": "complete", "taskId": id, "status": status }
388 }))
389 }
390
391 #[test]
392 fn a_delivered_tool_call_bills_once() {
393 let charge = decide(
394 &call(Method::ToolsCall, "get_weather"),
395 &complete(),
396 &book(),
397 );
398 assert!(charge.is_billable());
399 assert_eq!(charge.units(), 1);
400 assert_eq!(charge.idempotency_key(), None);
401 }
402
403 #[test]
404 fn an_mrtr_chain_of_any_length_bills_exactly_once() {
405 for rounds in 1..=10 {
408 let c = call(Method::ToolsCall, "get_weather");
409 let mut total = 0;
410 for round in 1..=rounds {
411 let response = if round == rounds {
412 complete()
413 } else {
414 input_required()
415 };
416 total += decide(&c, &response, &book()).units();
417 }
418 assert_eq!(total, 1, "a {rounds}-round chain billed {total} units");
419 }
420 }
421
422 #[test]
423 fn an_abandoned_chain_bills_nothing() {
424 let c = call(Method::ToolsCall, "get_weather");
425 let total: u64 = (0..5)
426 .map(|_| decide(&c, &input_required(), &book()).units())
427 .sum();
428 assert_eq!(total, 0);
429 }
430
431 #[test]
432 fn mrtr_applies_to_resources_read_and_prompts_get_too() {
433 for method in [Method::ResourcesRead, Method::PromptsGet] {
434 let c = call(method.clone(), "x");
435 assert_eq!(decide(&c, &input_required(), &book()).units(), 0);
436 assert_eq!(decide(&c, &complete(), &book()).units(), 1);
437 }
438 }
439
440 #[test]
441 fn a_task_polled_many_times_bills_once() {
442 let poll = Call::new(Method::TasksGet, None);
443 let origin = call(Method::ToolsCall, "long_job");
444 let mut total = 0;
445 let mut billed_keys: Vec<String> = Vec::new();
446
447 for _ in 0..300 {
449 total += decide_with_task_origin(
450 &poll,
451 &task_poll("working", "tsk_1"),
452 &book(),
453 Some(&origin),
454 )
455 .units();
456 }
457 let final_charge = decide_with_task_origin(
458 &poll,
459 &task_poll("completed", "tsk_1"),
460 &book(),
461 Some(&origin),
462 );
463 if let Some(key) = final_charge.idempotency_key() {
464 billed_keys.push(key.to_owned());
465 }
466 total += final_charge.units();
467
468 assert_eq!(total, 1, "300 polls plus a completion billed {total} units");
469 assert_eq!(billed_keys, vec!["tsk_1".to_owned()]);
470 }
471
472 #[test]
473 fn a_completed_task_carries_its_id_so_repeat_polls_can_be_suppressed() {
474 let poll = Call::new(Method::TasksGet, None);
477 let origin = call(Method::ToolsCall, "long_job");
478 let first = decide_with_task_origin(
479 &poll,
480 &task_poll("completed", "tsk_9"),
481 &book(),
482 Some(&origin),
483 );
484 let second = decide_with_task_origin(
485 &poll,
486 &task_poll("completed", "tsk_9"),
487 &book(),
488 Some(&origin),
489 );
490
491 assert!(first.is_billable() && second.is_billable());
492 assert_eq!(first.idempotency_key(), Some("tsk_9"));
493 assert_eq!(first.idempotency_key(), second.idempotency_key());
494 }
495
496 #[test]
497 fn failed_and_cancelled_tasks_are_free() {
498 let c = Call::new(Method::TasksGet, None);
499 assert_eq!(
500 decide(&c, &task_poll("failed", "t"), &book()),
501 Charge::Free(FreeReason::TaskNotDelivered)
502 );
503 assert_eq!(
504 decide(&c, &task_poll("cancelled", "t"), &book()),
505 Charge::Free(FreeReason::TaskNotDelivered)
506 );
507 }
508
509 #[test]
510 fn a_task_awaiting_input_is_free() {
511 let c = Call::new(Method::TasksGet, None);
512 assert_eq!(
513 decide(&c, &task_poll("input_required", "t"), &book()),
514 Charge::Free(FreeReason::TaskInProgress)
515 );
516 }
517
518 #[test]
519 fn creating_a_task_is_not_delivering_it() {
520 let created = peek_response(&json!({
521 "result": { "resultType": "task", "taskId": "tsk_1", "status": "working" }
522 }));
523 assert_eq!(
524 decide(&call(Method::ToolsCall, "t"), &created, &book()),
525 Charge::Free(FreeReason::TaskCreated)
526 );
527 }
528
529 #[test]
530 fn task_drive_calls_are_free() {
531 let ack = peek_response(&json!({ "result": { "resultType": "complete" } }));
532 for method in [Method::TasksUpdate, Method::TasksCancel] {
533 assert_eq!(
534 decide(&Call::new(method, None), &ack, &book()),
535 Charge::Free(FreeReason::TaskDrive)
536 );
537 }
538 }
539
540 #[test]
541 fn discovery_is_never_billed() {
542 for method in [
543 Method::ToolsList,
544 Method::PromptsList,
545 Method::ResourcesList,
546 Method::ResourcesTemplatesList,
547 Method::ServerDiscover,
548 ] {
549 assert_eq!(
550 decide(&Call::new(method, None), &complete(), &book()),
551 Charge::Free(FreeReason::Discovery)
552 );
553 }
554 }
555
556 #[test]
557 fn subscription_streams_are_free() {
558 assert_eq!(
559 decide(
560 &Call::new(Method::SubscriptionsListen, None),
561 &complete(),
562 &book()
563 ),
564 Charge::Free(FreeReason::Subscription)
565 );
566 }
567
568 #[test]
569 fn errors_are_never_billed_whatever_the_method() {
570 let err = peek_response(&json!({ "error": { "code": -32020, "message": "mismatch" } }));
571 for method in [Method::ToolsCall, Method::TasksGet, Method::ResourcesRead] {
572 assert_eq!(
573 decide(&call(method, "x"), &err, &book()),
574 Charge::Free(FreeReason::ProtocolError)
575 );
576 }
577 }
578
579 #[test]
580 fn unrecognized_results_fail_toward_not_charging() {
581 let weird = peek_response(&json!({ "result": { "resultType": "some_future_shape" } }));
582 assert_eq!(
583 decide(&call(Method::ToolsCall, "t"), &weird, &book()),
584 Charge::Free(FreeReason::UnrecognizedResult)
585 );
586
587 let empty = peek_response(&json!({ "jsonrpc": "2.0" }));
588 assert_eq!(
589 decide(&call(Method::ToolsCall, "t"), &empty, &book()),
590 Charge::Free(FreeReason::UnrecognizedResult)
591 );
592 }
593
594 #[test]
595 fn price_book_drives_the_units() {
596 let prices = PriceBook::flat(1).with_name("expensive", 250);
597 assert_eq!(
598 decide(&call(Method::ToolsCall, "expensive"), &complete(), &prices).units(),
599 250
600 );
601 assert_eq!(
602 decide(&call(Method::ToolsCall, "cheap"), &complete(), &prices).units(),
603 1
604 );
605 }
606
607 #[test]
608 fn a_zero_priced_tool_still_reports_as_billable_with_zero_units() {
609 let prices = PriceBook::flat(5).with_name("loss_leader", 0);
612 let charge = decide(
613 &call(Method::ToolsCall, "loss_leader"),
614 &complete(),
615 &prices,
616 );
617 assert!(charge.is_billable());
618 assert_eq!(charge.units(), 0);
619 }
620
621 #[test]
622 fn an_unknown_task_status_is_not_billed() {
623 let paused = peek_response(&json!({
624 "result": { "resultType": "complete", "taskId": "t", "status": "paused" }
625 }));
626 assert!(!decide(&Call::new(Method::TasksGet, None), &paused, &book()).is_billable());
627 }
628
629 #[test]
630 fn a_task_without_an_id_fails_toward_not_charging() {
631 let peek = ResponsePeek {
632 result_type: ResultType::Complete,
633 is_error: false,
634 task: Some(TaskPeek {
635 task_id: None,
636 status: TaskStatus::Completed,
637 }),
638 };
639 let charge = decide_with_task_origin(
640 &Call::new(Method::TasksGet, None),
641 &peek,
642 &book(),
643 Some(&call(Method::ToolsCall, "j")),
644 );
645 assert_eq!(charge, Charge::Free(FreeReason::MissingTaskId));
646 }
647
648 #[test]
649 fn a_completed_task_without_origin_fails_toward_not_charging() {
650 let charge = decide(
651 &Call::new(Method::TasksGet, None),
652 &task_poll("completed", "tsk_orphan"),
653 &book(),
654 );
655 assert_eq!(charge, Charge::Free(FreeReason::MissingTaskAttribution));
656 }
657
658 #[test]
659 fn a_completed_task_uses_the_originating_tool_price() {
660 let prices = PriceBook::flat(1).with_name("expensive_job", 250);
661 let charge = decide_with_task_origin(
662 &Call::new(Method::TasksGet, None),
663 &task_poll("completed", "tsk_expensive"),
664 &prices,
665 Some(&call(Method::ToolsCall, "expensive_job")),
666 );
667 assert_eq!(charge.units(), 250);
668 let Charge::Billable(billable) = charge else {
669 panic!("completed task should be billable");
670 };
671 assert_eq!(billable.method, Method::ToolsCall);
672 assert_eq!(billable.name.as_deref(), Some("expensive_job"));
673 }
674
675 #[test]
676 fn prepriced_task_attribution_discards_identifying_names() {
677 let prices = PriceBook::flat(1).with_name("file:///private/customer-record", 250);
678 let origin = Call::new(
679 Method::ResourcesRead,
680 Some("file:///private/customer-record".to_owned()),
681 );
682 let attribution = TaskAttribution::from_call(&origin, &prices);
683
684 assert_eq!(attribution.units(), 250);
685 assert_eq!(attribution.origin_kind(), TaskOriginKind::ResourcesRead);
686 assert!(!format!("{attribution:?}").contains("customer-record"));
687
688 let charge = decide_with_task_attribution(
689 &Call::new(Method::TasksGet, None),
690 &task_poll("completed", "tsk_private"),
691 &prices,
692 Some(&attribution),
693 );
694 let Charge::Billable(billable) = charge else {
695 panic!("completed task should be billable");
696 };
697 assert_eq!(billable.units, 250);
698 assert_eq!(billable.method, Method::ResourcesRead);
699 assert_eq!(billable.name, None);
700 }
701}