返回 CodeWhale
bridge.rs
根目录 / crates / tui / src / rlm / bridge.rs
1 //! RPC bridge that services `llm_query` / `rlm_query` calls coming back
2 //! from the long-lived Python REPL during an RLM turn.
3 //!
4 //! This is the spiritual successor to the HTTP sidecar from earlier
5 //! versions — except instead of binding a localhost port and routing
6 //! through `urllib`, requests come in through stdin/stdout and we just
7 //! call the LLM client directly here in Rust.
8 //!
9 //! The bridge tracks cumulative token usage and the recursion budget. For
10 //! `Rlm` / `RlmBatch` requests it recursively calls `run_rlm_turn_inner`
11 //! at depth-1; the future-type cycle (bridge → run_rlm_turn_inner →
12 //! bridge) is broken by `run_rlm_turn_inner` returning a boxed dyn future.
13
14 use std::sync::Arc;
15 use std::time::Duration;
16 use std::{future::Future, pin::Pin};
17
18 use anyhow::Result;
19 use futures_util::future::join_all;
20 use tokio::sync::Mutex;
21 use uuid::Uuid;
22
23 use crate::llm_client::LlmClient;
24 use crate::repl::runtime::{BatchResp, RpcDispatcher, RpcRequest, RpcResponse, SingleResp};
25 use crate::utils::spawn_supervised;
26 use codewhale_models::Role;
27 use codewhale_models::{
28 ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage,
29 is_incomplete_stop_reason, stop_reason_detail,
30 };
31
32 /// One pre-dispatch reservation in the shared routed-usage ledger.
33 #[derive(Debug, Clone, Copy)]
34 pub(crate) struct RlmUsageReservation {
35 index: usize,
36 }
37
38 #[derive(Debug, Default)]
39 struct RlmUsageState {
40 ledger_id: String,
41 usage: Usage,
42 records: Vec<Option<RlmUsageSlot>>,
43 drop_records: Vec<crate::cost_status::RuntimeUsageDropRecord>,
44 dropped_records: u64,
45 }
46
47 #[derive(Debug)]
48 struct RlmUsageSlot {
49 record: crate::cost_status::RuntimeUsageRecord,
50 completed: bool,
51 }
52
53 /// Shared, bounded provider-call ledger for one complete RLM tree.
54 ///
55 /// Every root, child, batch member, and recursive call reserves one slot
56 /// before invoking a provider. A distinct call is never coalesced merely
57 /// because it used the same route: its dispatch instant and frozen quote are
58 /// independent accounting evidence. Sharing one accumulator across recursion
59 /// makes the bound global instead of allowing every nested bridge to reset it.
60 #[derive(Debug, Clone)]
61 pub(crate) struct RlmUsageAccumulator {
62 state: Arc<Mutex<RlmUsageState>>,
63 }
64
65 /// Atomic snapshot returned after all RPC work for a round has settled.
66 #[derive(Debug, Clone, Default)]
67 pub(crate) struct RlmUsageSnapshot {
68 pub usage: Usage,
69 pub records: Vec<crate::cost_status::RuntimeUsageRecord>,
70 /// Exact frozen routes for provider-success responses that did not carry
71 /// authoritative usage. Keeping these separate prevents a missing payload
72 /// from becoming a priced zero-usage receipt.
73 pub drop_records: Vec<crate::cost_status::RuntimeUsageDropRecord>,
74 /// Calls whose execution/usage became ambiguous (for example a timeout).
75 /// They are never represented as authoritative zero-usage responses.
76 pub dropped_records: u64,
77 }
78
79 impl RlmUsageAccumulator {
80 #[must_use]
81 pub(crate) fn new() -> Self {
82 Self {
83 state: Arc::new(Mutex::new(RlmUsageState {
84 ledger_id: Uuid::new_v4().simple().to_string(),
85 ..RlmUsageState::default()
86 })),
87 }
88 }
89
90 /// Reserve durable accounting capacity before a provider request.
91 /// Definite transport failure cancels the slot; ambiguous cancellation is
92 /// explicit incomplete coverage. Reaching the cap rejects before any
93 /// unreceipted provider work can occur.
94 pub(crate) async fn reserve(
95 &self,
96 route: crate::cost_status::EffectiveRouteEnvelope,
97 ) -> std::result::Result<RlmUsageReservation, String> {
98 let mut state = self.state.lock().await;
99 if state.records.len() == crate::cost_status::MAX_CHILD_USAGE_RECORDS {
100 return Err(format!(
101 "RLM provider-call receipt limit reached ({}); request rejected before dispatch",
102 crate::cost_status::MAX_CHILD_USAGE_RECORDS
103 ));
104 }
105 let index = state.records.len();
106 let source_id = format!("rlm:{}:request:{index}", state.ledger_id);
107 state.records.push(Some(RlmUsageSlot {
108 record: crate::cost_status::RuntimeUsageRecord {
109 source_id,
110 usage: crate::cost_status::EffectiveRouteUsage {
111 route: route.sanitized_for_persistence(),
112 usage: Usage::default(),
113 },
114 },
115 completed: false,
116 }));
117 Ok(RlmUsageReservation { index })
118 }
119
120 /// Attach a provider's reported usage to its already-reserved exact route.
121 pub(crate) async fn complete(&self, reservation: RlmUsageReservation, usage: &Usage) {
122 let mut state = self.state.lock().await;
123 let completed = if let Some(Some(slot)) = state.records.get_mut(reservation.index)
124 && !slot.completed
125 {
126 super::add_usage_with_prompt_cache(&mut slot.record.usage.usage, usage);
127 slot.completed = true;
128 true
129 } else {
130 false
131 };
132 if completed {
133 super::add_usage_with_prompt_cache(&mut state.usage, usage);
134 }
135 }
136
137 /// Settle a decoded provider-success response without inventing usage.
138 /// `MessageResponse::usage == Usage::default()` is also what adapters
139 /// produce when the provider omitted the payload, so it is not proof of a
140 /// genuine zero-token request.
141 pub(crate) async fn settle_provider_success(
142 &self,
143 reservation: RlmUsageReservation,
144 usage: &Usage,
145 ) {
146 if usage == &Usage::default() {
147 self.cancel(reservation, true).await;
148 } else {
149 self.complete(reservation, usage).await;
150 }
151 }
152
153 /// Remove a reservation that never produced provider-reported usage.
154 /// Ambiguous execution increments explicit incomplete coverage instead of
155 /// being persisted as a priced-zero response.
156 pub(crate) async fn cancel(&self, reservation: RlmUsageReservation, coverage_unknown: bool) {
157 let mut state = self.state.lock().await;
158 let cancelled = state.records.get_mut(reservation.index).and_then(|slot| {
159 if slot.as_ref().is_some_and(|slot| !slot.completed) {
160 slot.take()
161 } else {
162 None
163 }
164 });
165 if let Some(slot) = cancelled
166 && coverage_unknown
167 {
168 state
169 .drop_records
170 .push(crate::cost_status::RuntimeUsageDropRecord {
171 source_id: slot.record.source_id,
172 route: slot.record.usage.route,
173 });
174 state.dropped_records = state.dropped_records.saturating_add(1);
175 }
176 }
177
178 pub(crate) async fn snapshot(&self) -> RlmUsageSnapshot {
179 let state = self.state.lock().await;
180 let pending = state
181 .records
182 .iter()
183 .flatten()
184 .filter(|slot| !slot.completed)
185 .map(|slot| crate::cost_status::RuntimeUsageDropRecord {
186 source_id: slot.record.source_id.clone(),
187 route: slot.record.usage.route.clone(),
188 })
189 .collect::<Vec<_>>();
190 let mut drop_records = state.drop_records.clone();
191 drop_records.extend(pending.iter().cloned());
192 RlmUsageSnapshot {
193 usage: state.usage.clone(),
194 records: state
195 .records
196 .iter()
197 .flatten()
198 .filter(|slot| slot.completed)
199 .map(|slot| slot.record.clone())
200 .collect(),
201 drop_records,
202 dropped_records: state
203 .dropped_records
204 .saturating_add(u64::try_from(pending.len()).unwrap_or(u64::MAX)),
205 }
206 }
207 }
208
209 /// Object-safe runtime-model adapter for a working kernel.
210 ///
211 /// The normal turn loop owns a `SharedModelClient`, while the original RLM
212 /// bridge predates that boundary and accepts the concrete [`LlmClient`] trait.
213 /// Keeping this small adapter here means a persistent kernel follows exactly
214 /// the selected model route (including custom providers) without teaching the
215 /// kernel about provider transports or falling back to a side channel.
216 pub(crate) struct ModelClientRlmAdapter {
217 client: crate::core::model_client::SharedModelClient,
218 }
219
220 impl ModelClientRlmAdapter {
221 pub(crate) fn new(client: crate::core::model_client::SharedModelClient) -> Self {
222 Self { client }
223 }
224 }
225
226 /// Per-child completion timeout — same as the previous sidecar default.
227 const CHILD_TIMEOUT_SECS: u64 = 120;
228 /// Hard cap on prompts per batch RPC.
229 pub const MAX_BATCH: usize = 16;
230
231 /// Object-safe slice of the LLM client interface that the RLM bridge needs.
232 ///
233 /// `LlmClient` itself uses native async trait methods, which are not dyn-safe.
234 /// The bridge only needs non-streaming completions, so this boxed-future shim
235 /// gives tests a clean mock seam without changing the wider provider trait.
236 pub(crate) trait RlmLlmClient: Send + Sync {
237 fn effective_route_envelope(
238 &self,
239 requested_model: &str,
240 dispatched_at: chrono::DateTime<chrono::Utc>,
241 ) -> crate::cost_status::EffectiveRouteEnvelope;
242
243 fn effective_max_output_tokens(&self, requested_model: &str) -> u32;
244
245 fn create_message_boxed(
246 &self,
247 request: MessageRequest,
248 ) -> Pin<Box<dyn Future<Output = Result<MessageResponse>> + Send + '_>>;
249 }
250
251 impl RlmLlmClient for ModelClientRlmAdapter {
252 fn effective_route_envelope(
253 &self,
254 requested_model: &str,
255 dispatched_at: chrono::DateTime<chrono::Utc>,
256 ) -> crate::cost_status::EffectiveRouteEnvelope {
257 self.client
258 .effective_route_envelope(requested_model, dispatched_at)
259 }
260
261 fn effective_max_output_tokens(&self, requested_model: &str) -> u32 {
262 self.client.effective_max_output_tokens(requested_model)
263 }
264
265 fn create_message_boxed(
266 &self,
267 request: MessageRequest,
268 ) -> Pin<Box<dyn Future<Output = Result<MessageResponse>> + Send + '_>> {
269 let client = Arc::clone(&self.client);
270 Box::pin(async move { client.create_message(request).await })
271 }
272 }
273
274 impl<T> RlmLlmClient for T
275 where
276 T: LlmClient + Send + Sync,
277 {
278 fn effective_route_envelope(
279 &self,
280 requested_model: &str,
281 dispatched_at: chrono::DateTime<chrono::Utc>,
282 ) -> crate::cost_status::EffectiveRouteEnvelope {
283 LlmClient::effective_route_envelope(self, requested_model, dispatched_at)
284 }
285
286 fn effective_max_output_tokens(&self, requested_model: &str) -> u32 {
287 LlmClient::effective_max_output_tokens(self, requested_model)
288 }
289
290 fn create_message_boxed(
291 &self,
292 request: MessageRequest,
293 ) -> Pin<Box<dyn Future<Output = Result<MessageResponse>> + Send + '_>> {
294 Box::pin(self.create_message(request))
295 }
296 }
297
298 /// State shared with the bridge across all RPC calls in one turn.
299 pub struct RlmBridge {
300 client: Arc<dyn RlmLlmClient>,
301 child_model: String,
302 /// Recursion budget remaining for `Rlm` / `RlmBatch` requests. When
303 /// zero, those requests fall back to plain `Llm` completions.
304 depth_remaining: u32,
305 usage: RlmUsageAccumulator,
306 }
307
308 impl RlmBridge {
309 pub(crate) fn new(
310 client: Arc<dyn RlmLlmClient>,
311 child_model: String,
312 depth_remaining: u32,
313 ) -> Self {
314 Self::with_usage_accumulator(
315 client,
316 child_model,
317 depth_remaining,
318 RlmUsageAccumulator::new(),
319 )
320 }
321
322 pub(crate) fn with_usage_accumulator(
323 client: Arc<dyn RlmLlmClient>,
324 child_model: String,
325 depth_remaining: u32,
326 usage: RlmUsageAccumulator,
327 ) -> Self {
328 Self {
329 client,
330 child_model,
331 depth_remaining,
332 usage,
333 }
334 }
335
336 pub(crate) async fn usage_snapshot(&self) -> RlmUsageSnapshot {
337 self.usage.snapshot().await
338 }
339
340 async fn dispatch_llm(
341 &self,
342 prompt: String,
343 _model: Option<String>,
344 max_tokens: Option<u32>,
345 system: Option<String>,
346 ) -> SingleResp {
347 let request_route = self
348 .client
349 .effective_route_envelope(&self.child_model, chrono::Utc::now());
350 let reservation = match self.usage.reserve(request_route.clone()).await {
351 Ok(reservation) => reservation,
352 Err(error) => {
353 return SingleResp {
354 text: String::new(),
355 error: Some(error),
356 };
357 }
358 };
359 let route_max_tokens = self
360 .client
361 .effective_max_output_tokens(&request_route.model);
362 let request = MessageRequest {
363 // The Python helper accepts `model=` for older snippets, but it is
364 // intentionally not authoritative. RLM child calls are pinned to
365 // the tool's configured child model so model-generated Python
366 // cannot silently upgrade cheap fanout work to an expensive model.
367 model: self.child_model.clone(),
368 messages: vec![Message {
369 role: Role::User,
370 content: vec![ContentBlock::Text {
371 text: prompt,
372 cache_control: None,
373 }],
374 }],
375 // An explicit RLM helper bound remains authoritative, but the
376 // default is the selected route's ordinary allowance rather than
377 // a hidden 4K ceiling.
378 max_tokens: max_tokens.map_or(route_max_tokens, |limit| limit.min(route_max_tokens)),
379 system: system.map(SystemPrompt::Text),
380 tools: None,
381 tool_choice: None,
382 metadata: None,
383 thinking: None,
384 reasoning_effort: None,
385 stream: Some(false),
386 temperature: None,
387 top_p: None,
388 };
389
390 let fut = self.client.create_message_boxed(request);
391 let response =
392 match tokio::time::timeout(Duration::from_secs(CHILD_TIMEOUT_SECS), fut).await {
393 Ok(Ok(r)) => r,
394 Ok(Err(e)) => {
395 self.usage.cancel(reservation, false).await;
396 return SingleResp {
397 text: String::new(),
398 error: Some(format!("llm_query failed: {e}")),
399 };
400 }
401 Err(_) => {
402 self.usage.cancel(reservation, true).await;
403 return SingleResp {
404 text: String::new(),
405 error: Some(format!("llm_query timed out after {CHILD_TIMEOUT_SECS}s")),
406 };
407 }
408 };
409
410 // Incomplete output is rejected below, but it is still a successful
411 // provider response and therefore billed. Complete the reserved route
412 // before inspecting the stop reason.
413 self.usage
414 .settle_provider_success(reservation, &response.usage)
415 .await;
416
417 if is_incomplete_stop_reason(response.stop_reason.as_deref()) {
418 return SingleResp {
419 text: String::new(),
420 error: Some(format!(
421 "llm_query response incomplete: provider stop reason `{}`; partial output was not accepted.",
422 stop_reason_detail(response.stop_reason.as_deref())
423 )),
424 };
425 }
426
427 let text = response
428 .content
429 .iter()
430 .filter_map(|b| match b {
431 ContentBlock::Text { text, .. } => Some(text.as_str()),
432 _ => None,
433 })
434 .collect::<Vec<_>>()
435 .join("\n");
436
437 SingleResp { text, error: None }
438 }
439
440 async fn dispatch_llm_batch(
441 &self,
442 prompts: Vec<String>,
443 _model: Option<String>,
444 dependency_mode: Option<String>,
445 ) -> BatchResp {
446 if let Some(resp) = batch_guard(prompts.len(), dependency_mode.as_deref()) {
447 return resp;
448 }
449
450 let model = Arc::new(self.child_model.clone());
451
452 let futures = prompts.into_iter().map(|prompt| {
453 let model = Arc::clone(&model);
454 async move {
455 self.dispatch_llm((*prompt).to_string(), Some((*model).clone()), None, None)
456 .await
457 }
458 });
459
460 BatchResp {
461 results: join_all(futures).await,
462 }
463 }
464
465 async fn dispatch_rlm(&self, prompt: String, _model: Option<String>) -> SingleResp {
466 if self.depth_remaining == 0 {
467 // Budget exhausted — fall back to a one-shot child completion
468 // rather than returning an error. Matches the paper's behaviour
469 // ("sub_RLM gracefully degrades to llm_query at depth=0").
470 return self.dispatch_llm(prompt, None, None, None).await;
471 }
472
473 // Build a drain channel to absorb status events from the nested
474 // turn (we don't surface them; this dispatch is invisible to the
475 // outer agent stream).
476 let (tx, mut rx) = tokio::sync::mpsc::channel(64);
477 let drain = spawn_supervised(
478 "rlm-bridge-drain",
479 std::panic::Location::caller(),
480 async move { while rx.recv().await.is_some() {} },
481 );
482
483 let child_model = self.child_model.clone();
484
485 // Recursive call. The dyn-erasure on `run_rlm_turn_inner` breaks
486 // the `bridge → turn → bridge` opaque-future cycle.
487 let result = super::turn::run_rlm_turn_inner_with_usage(
488 Arc::clone(&self.client),
489 child_model.clone(),
490 prompt,
491 None,
492 child_model,
493 tx,
494 self.depth_remaining.saturating_sub(1),
495 self.usage.clone(),
496 )
497 .await;
498
499 drain.abort();
500
501 SingleResp {
502 text: result.answer,
503 error: result.error,
504 }
505 }
506
507 async fn dispatch_rlm_batch(
508 &self,
509 prompts: Vec<String>,
510 _model: Option<String>,
511 dependency_mode: Option<String>,
512 ) -> BatchResp {
513 if let Some(resp) = batch_guard(prompts.len(), dependency_mode.as_deref()) {
514 return resp;
515 }
516
517 let futures = prompts
518 .into_iter()
519 .map(|p| async move { self.dispatch_rlm(p, None).await });
520 BatchResp {
521 results: join_all(futures).await,
522 }
523 }
524 }
525
526 fn batch_guard(prompt_count: usize, dependency_mode: Option<&str>) -> Option<BatchResp> {
527 if prompt_count == 0 {
528 return Some(BatchResp { results: vec![] });
529 }
530 if prompt_count > MAX_BATCH {
531 return Some(BatchResp {
532 results: (0..prompt_count)
533 .map(|_| SingleResp {
534 text: String::new(),
535 error: Some(format!("batch too large: {prompt_count} > {MAX_BATCH}")),
536 })
537 .collect(),
538 });
539 }
540 let mode = dependency_mode
541 .unwrap_or_default()
542 .trim()
543 .to_ascii_lowercase()
544 .replace(['-', ' '], "_");
545 if !matches!(
546 mode.as_str(),
547 "independent" | "parallel_safe" | "map_reduce"
548 ) {
549 return Some(BatchResp {
550 results: (0..prompt_count)
551 .map(|_| SingleResp {
552 text: String::new(),
553 error: Some(
554 "batch requires dependency_mode='independent'; use sub_query_sequence or sequential sub_query calls for dependent work"
555 .to_string(),
556 ),
557 })
558 .collect(),
559 });
560 }
561 None
562 }
563
564 impl RpcDispatcher for RlmBridge {
565 fn dispatch<'a>(
566 &'a self,
567 req: RpcRequest,
568 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RpcResponse> + Send + 'a>> {
569 Box::pin(async move {
570 match req {
571 RpcRequest::Llm {
572 prompt,
573 model,
574 max_tokens,
575 system,
576 } => {
577 RpcResponse::Single(self.dispatch_llm(prompt, model, max_tokens, system).await)
578 }
579 RpcRequest::LlmBatch {
580 prompts,
581 model,
582 dependency_mode,
583 safety_note: _,
584 } => RpcResponse::Batch(
585 self.dispatch_llm_batch(prompts, model, dependency_mode)
586 .await,
587 ),
588 RpcRequest::Rlm { prompt, model } => {
589 RpcResponse::Single(self.dispatch_rlm(prompt, model).await)
590 }
591 RpcRequest::RlmBatch {
592 prompts,
593 model,
594 dependency_mode,
595 safety_note: _,
596 } => RpcResponse::Batch(
597 self.dispatch_rlm_batch(prompts, model, dependency_mode)
598 .await,
599 ),
600 }
601 })
602 }
603 }
604
605 #[cfg(test)]
606 mod tests {
607 use super::*;
608 use crate::llm_client::mock::MockLlmClient;
609
610 fn mock_response_with_usage(text: &str, usage: Usage) -> MessageResponse {
611 MessageResponse {
612 id: "mock_msg".to_string(),
613 r#type: "message".to_string(),
614 role: "assistant".to_string(),
615 content: vec![ContentBlock::Text {
616 text: text.to_string(),
617 cache_control: None,
618 }],
619 model: "mock-model".to_string(),
620 stop_reason: Some("end_turn".to_string()),
621 stop_sequence: None,
622 container: None,
623 usage,
624 }
625 }
626
627 fn mock_response(text: &str, input_tokens: u32, output_tokens: u32) -> MessageResponse {
628 mock_response_with_usage(
629 text,
630 Usage {
631 input_tokens,
632 output_tokens,
633 ..Usage::default()
634 },
635 )
636 }
637
638 fn bridge_for(mock: Arc<MockLlmClient>, depth_remaining: u32) -> RlmBridge {
639 let client: Arc<dyn RlmLlmClient> = mock;
640 RlmBridge::new(client, "child-model".to_string(), depth_remaining)
641 }
642
643 #[test]
644 fn batch_guard_allows_non_empty_batches_at_the_cap() {
645 assert!(batch_guard(MAX_BATCH, Some("independent")).is_none());
646 }
647
648 #[test]
649 fn batch_guard_returns_empty_response_for_empty_batches() {
650 let response = batch_guard(0, None).expect("empty batch should be handled");
651 assert!(response.results.is_empty());
652 }
653
654 #[test]
655 fn batch_guard_returns_one_error_per_oversized_prompt() {
656 let response = batch_guard(MAX_BATCH + 2, Some("independent"))
657 .expect("oversized batch should be handled");
658 assert_eq!(response.results.len(), MAX_BATCH + 2);
659 assert!(response.results.iter().all(|result| {
660 result.text.is_empty()
661 && result
662 .error
663 .as_deref()
664 .is_some_and(|err| err.contains("batch too large"))
665 }));
666 }
667
668 #[test]
669 fn batch_guard_requires_explicit_independence_for_parallel_work() {
670 let response = batch_guard(2, None).expect("missing dependency mode should be handled");
671 assert_eq!(response.results.len(), 2);
672 assert!(response.results.iter().all(|result| {
673 result.text.is_empty()
674 && result
675 .error
676 .as_deref()
677 .is_some_and(|err| err.contains("dependency_mode='independent'"))
678 }));
679
680 let response = batch_guard(2, Some("sequential"))
681 .expect("dependent dependency mode should be handled");
682 assert!(response.results.iter().all(|result| {
683 result
684 .error
685 .as_deref()
686 .is_some_and(|err| err.contains("sub_query_sequence"))
687 }));
688 }
689
690 #[tokio::test]
691 async fn llm_dispatch_pins_configured_child_model() {
692 let mock = Arc::new(MockLlmClient::new(Vec::new()));
693 mock.push_message_response(mock_response("child answer", 7, 11));
694 let bridge = bridge_for(Arc::clone(&mock), 1);
695
696 let response = bridge
697 .dispatch(RpcRequest::Llm {
698 prompt: "child prompt".to_string(),
699 model: Some("override-model".to_string()),
700 max_tokens: Some(123),
701 system: Some("child system".to_string()),
702 })
703 .await;
704
705 match response {
706 RpcResponse::Single(single) => {
707 assert_eq!(single.text, "child answer");
708 assert!(single.error.is_none());
709 }
710 other => panic!("expected single response, got {other:?}"),
711 }
712
713 let captured = mock.captured_requests();
714 assert_eq!(captured.len(), 1);
715 assert_eq!(captured[0].model, "child-model");
716 assert_eq!(captured[0].max_tokens, 123);
717 assert_eq!(
718 captured[0].system,
719 Some(SystemPrompt::Text("child system".to_string()))
720 );
721
722 let snapshot = bridge.usage_snapshot().await;
723 assert_eq!(snapshot.usage.input_tokens, 7);
724 assert_eq!(snapshot.usage.output_tokens, 11);
725 assert_eq!(snapshot.records.len(), 1);
726 assert_eq!(snapshot.records[0].usage.usage, snapshot.usage);
727 assert!(snapshot.drop_records.is_empty());
728 assert_eq!(snapshot.dropped_records, 0);
729 }
730
731 #[tokio::test]
732 async fn llm_dispatch_keeps_semantic_success_but_marks_missing_usage_once() {
733 let mock = Arc::new(MockLlmClient::new(Vec::new()));
734 mock.push_message_response(mock_response_with_usage(
735 "usable child answer",
736 Usage::default(),
737 ));
738 let bridge = bridge_for(Arc::clone(&mock), 1);
739
740 let response = bridge
741 .dispatch(RpcRequest::Llm {
742 prompt: "child prompt".to_string(),
743 model: None,
744 max_tokens: None,
745 system: None,
746 })
747 .await;
748
749 let RpcResponse::Single(response) = response else {
750 panic!("expected single response");
751 };
752 assert_eq!(response.text, "usable child answer");
753 assert!(response.error.is_none());
754
755 let first = bridge.usage_snapshot().await;
756 let replay = bridge.usage_snapshot().await;
757 assert_eq!(first.usage, Usage::default());
758 assert!(first.records.is_empty());
759 assert_eq!(first.drop_records.len(), 1);
760 assert_eq!(first.dropped_records, 1);
761 assert_eq!(replay.drop_records, first.drop_records);
762 assert_eq!(replay.dropped_records, 1);
763 assert_eq!(first.drop_records[0].route.model, "child-model");
764 assert!(first.drop_records[0].source_id.starts_with("rlm:"));
765 }
766
767 #[tokio::test]
768 async fn repeated_reservation_settlement_cannot_duplicate_usage_or_missing_coverage() {
769 let mock = Arc::new(MockLlmClient::new(Vec::new()));
770 let bridge = bridge_for(Arc::clone(&mock), 1);
771 let route = RlmLlmClient::effective_route_envelope(
772 mock.as_ref(),
773 "child-model",
774 chrono::Utc::now(),
775 );
776
777 let usage_reservation = bridge
778 .usage
779 .reserve(route.clone())
780 .await
781 .expect("usage reservation");
782 let reported = Usage {
783 input_tokens: 3,
784 output_tokens: 5,
785 ..Usage::default()
786 };
787 bridge.usage.complete(usage_reservation, &reported).await;
788 bridge.usage.complete(usage_reservation, &reported).await;
789
790 let missing_reservation = bridge
791 .usage
792 .reserve(route)
793 .await
794 .expect("missing reservation");
795 bridge.usage.cancel(missing_reservation, true).await;
796 bridge.usage.cancel(missing_reservation, true).await;
797
798 let snapshot = bridge.usage_snapshot().await;
799 assert_eq!(snapshot.usage, reported);
800 assert_eq!(snapshot.records.len(), 1);
801 assert_eq!(snapshot.drop_records.len(), 1);
802 assert_eq!(snapshot.dropped_records, 1);
803 }
804
805 #[tokio::test]
806 async fn llm_dispatch_preserves_prompt_cache_usage() {
807 let mock = Arc::new(MockLlmClient::new(Vec::new()));
808 mock.push_message_response(mock_response_with_usage(
809 "cached child answer",
810 Usage {
811 input_tokens: 1000,
812 output_tokens: 100,
813 prompt_cache_hit_tokens: Some(800),
814 prompt_cache_miss_tokens: Some(200),
815 ..Usage::default()
816 },
817 ));
818 let bridge = bridge_for(Arc::clone(&mock), 1);
819
820 let response = bridge
821 .dispatch(RpcRequest::Llm {
822 prompt: "child prompt".to_string(),
823 model: None,
824 max_tokens: None,
825 system: None,
826 })
827 .await;
828
829 match response {
830 RpcResponse::Single(single) => {
831 assert_eq!(single.text, "cached child answer");
832 assert!(single.error.is_none());
833 }
834 other => panic!("expected single response, got {other:?}"),
835 }
836
837 let usage = bridge.usage_snapshot().await.usage;
838 assert_eq!(usage.input_tokens, 1000);
839 assert_eq!(usage.output_tokens, 100);
840 assert_eq!(usage.prompt_cache_hit_tokens, Some(800));
841 assert_eq!(usage.prompt_cache_miss_tokens, Some(200));
842 }
843
844 #[tokio::test]
845 async fn llm_dispatch_rejects_max_tokens_partial_output_after_charging_usage() {
846 let mock = Arc::new(MockLlmClient::new(Vec::new()));
847 let usage = Usage {
848 input_tokens: 23,
849 output_tokens: 4096,
850 reasoning_tokens: Some(4000),
851 ..Usage::default()
852 };
853 let mut response = mock_response_with_usage(
854 "FINAL('partial answer')\n```repl\nFINAL('also partial')\n```",
855 usage.clone(),
856 );
857 response.stop_reason = Some("max_tokens".to_string());
858 mock.push_message_response(response);
859 let bridge = bridge_for(Arc::clone(&mock), 1);
860
861 let response = bridge
862 .dispatch(RpcRequest::Llm {
863 prompt: "child prompt".to_string(),
864 model: None,
865 max_tokens: None,
866 system: None,
867 })
868 .await;
869
870 match response {
871 RpcResponse::Single(single) => {
872 assert!(
873 single.text.is_empty(),
874 "partial output must not be accepted"
875 );
876 let error = single.error.expect("truncation must surface as an error");
877 assert!(error.contains("incomplete"), "{error}");
878 assert!(error.contains("max_tokens"), "{error}");
879 }
880 other => panic!("expected single response, got {other:?}"),
881 }
882
883 let snapshot = bridge.usage_snapshot().await;
884 assert_eq!(snapshot.usage, usage);
885 assert_eq!(snapshot.records.len(), 1);
886 assert_eq!(snapshot.records[0].usage.usage, usage);
887 assert_eq!(mock.call_count(), 1, "truncation must not retry");
888 }
889
890 #[tokio::test]
891 async fn llm_batch_dispatch_pins_configured_child_model() {
892 let mock = Arc::new(MockLlmClient::new(Vec::new()));
893 mock.push_message_response(mock_response("one", 1, 2));
894 mock.push_message_response(mock_response("two", 3, 4));
895 mock.push_message_response(mock_response("three", 5, 6));
896 let bridge = bridge_for(Arc::clone(&mock), 1);
897
898 let response = bridge
899 .dispatch(RpcRequest::LlmBatch {
900 prompts: vec!["a".to_string(), "b".to_string(), "c".to_string()],
901 model: Some("batch-model".to_string()),
902 dependency_mode: Some("independent".to_string()),
903 safety_note: Some("test prompts are independent".to_string()),
904 })
905 .await;
906
907 match response {
908 RpcResponse::Batch(batch) => {
909 let texts: Vec<_> = batch
910 .results
911 .iter()
912 .map(|result| result.text.as_str())
913 .collect();
914 assert_eq!(texts, ["one", "two", "three"]);
915 assert!(batch.results.iter().all(|result| result.error.is_none()));
916 }
917 other => panic!("expected batch response, got {other:?}"),
918 }
919
920 let captured = mock.captured_requests();
921 assert_eq!(captured.len(), 3);
922 assert!(
923 captured
924 .iter()
925 .all(|request| request.model == "child-model")
926 );
927
928 let snapshot = bridge.usage_snapshot().await;
929 assert_eq!(snapshot.usage.input_tokens, 9);
930 assert_eq!(snapshot.usage.output_tokens, 12);
931 assert_eq!(snapshot.records.len(), 3);
932 assert_ne!(
933 snapshot.records[0].source_id, snapshot.records[1].source_id,
934 "distinct provider calls must keep distinct stable identities"
935 );
936 }
937
938 #[tokio::test]
939 async fn shared_accumulator_rejects_the_first_unreceipted_request_before_provider_work() {
940 let mock = Arc::new(MockLlmClient::new(Vec::new()));
941 let client: Arc<dyn RlmLlmClient> = mock.clone();
942 let usage = RlmUsageAccumulator::new();
943 let bridge = RlmBridge::with_usage_accumulator(
944 Arc::clone(&client),
945 "child-model".to_string(),
946 1,
947 usage.clone(),
948 );
949 let nested_bridge =
950 RlmBridge::with_usage_accumulator(client, "child-model".to_string(), 1, usage);
951 let route = RlmLlmClient::effective_route_envelope(
952 mock.as_ref(),
953 "child-model",
954 chrono::Utc::now(),
955 );
956 for _ in 0..crate::cost_status::MAX_CHILD_USAGE_RECORDS {
957 let reservation = bridge
958 .usage
959 .reserve(route.clone())
960 .await
961 .expect("receipt slot below cap");
962 bridge
963 .usage
964 .complete(
965 reservation,
966 &Usage {
967 input_tokens: 1,
968 ..Usage::default()
969 },
970 )
971 .await;
972 }
973
974 let response = nested_bridge
975 .dispatch(RpcRequest::Llm {
976 prompt: "must not reach provider".to_string(),
977 model: None,
978 max_tokens: None,
979 system: None,
980 })
981 .await;
982 let RpcResponse::Single(response) = response else {
983 panic!("expected single response");
984 };
985 assert!(
986 response
987 .error
988 .as_deref()
989 .is_some_and(|error| error.contains("rejected before dispatch"))
990 );
991 assert_eq!(mock.call_count(), 0);
992 let snapshot = bridge.usage_snapshot().await;
993 assert_eq!(
994 snapshot.records.len(),
995 crate::cost_status::MAX_CHILD_USAGE_RECORDS
996 );
997 assert_eq!(snapshot.dropped_records, 0);
998 assert!(snapshot.drop_records.is_empty());
999 }
1000
1001 #[tokio::test]
1002 async fn rlm_dispatch_at_depth_zero_pins_configured_child_model() {
1003 let mock = Arc::new(MockLlmClient::new(Vec::new()));
1004 mock.push_message_response(mock_response("fallback answer", 3, 5));
1005 let bridge = bridge_for(Arc::clone(&mock), 0);
1006
1007 let response = bridge
1008 .dispatch(RpcRequest::Rlm {
1009 prompt: "nested prompt".to_string(),
1010 model: Some("override-model".to_string()),
1011 })
1012 .await;
1013
1014 match response {
1015 RpcResponse::Single(single) => {
1016 assert_eq!(single.text, "fallback answer");
1017 assert!(single.error.is_none());
1018 }
1019 other => panic!("expected single response, got {other:?}"),
1020 }
1021
1022 let usage = bridge.usage_snapshot().await.usage;
1023 assert_eq!(usage.input_tokens, 3);
1024 assert_eq!(usage.output_tokens, 5);
1025
1026 let captured = mock.captured_requests();
1027 assert_eq!(captured.len(), 1);
1028 assert_eq!(captured[0].model, "child-model");
1029 }
1030 }
1031
1031 lines RUST