Skip to main content

nexo_core/agent/llm_remote/
mod.rs

1//! `LlmClient` impl backed by a subprocess plugin's stdio bridge.
2//!
3//! Subprocess plugins declaring `[plugin.extends].llm_providers
4//! = ["cohere"]` get one `RemoteLlmFactory` per provider name
5//! registered into `LlmRegistry`. When the agent runtime resolves
6//! `model.provider = "cohere"`, the factory builds a
7//! `RemoteLlmClient` that translates trait calls into
8//! `llm.chat` JSON-RPC requests over the subprocess plugin's
9//! stdio pipe (the same `Inner.stdin_tx` + `pending` shared with
10//! `RemoteChannelAdapter`).
11//!
12//! Streaming uses a separate `streaming_pending` map keyed on
13//! request id — `llm.chat.delta` notifications push chunks into
14//! a per-request `mpsc::UnboundedReceiver`; the final response
15//! resolves the oneshot + drops the chunks sender.
16
17pub mod wire;
18
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use dashmap::DashMap;
25use futures::stream::BoxStream;
26use futures::StreamExt;
27use nexo_config::types::llm::{LlmProviderConfig, RetryConfig};
28use nexo_llm::client::LlmClient;
29use nexo_llm::registry::LlmProviderFactory;
30use nexo_llm::stream::StreamChunk;
31use nexo_llm::types::{ChatRequest, ChatResponse};
32use serde_json::Value;
33use tokio::sync::{mpsc, oneshot};
34
35use self::wire::{request_to_wire, wire_to_response, WireChatResponse};
36
37#[allow(unused_imports)]
38pub(crate) use self::wire::{wire_to_chunk, WireStreamChunk};
39
40const DEFAULT_CHAT_TIMEOUT: Duration = Duration::from_secs(60);
41const DEFAULT_STREAM_TIMEOUT: Duration = Duration::from_secs(300);
42
43/// Per-request streaming state stored in
44/// `Inner.streaming_pending`. The reader pushes
45/// `llm.chat.delta` chunks to `delta_tx`; when the final reply
46/// arrives, the response handler resolves `final_tx` AND drops
47/// the entry so `delta_tx` closes (signalling end-of-stream to
48/// the consumer).
49pub struct StreamingPending {
50    pub delta_tx: mpsc::UnboundedSender<StreamChunk>,
51    pub final_tx: oneshot::Sender<Result<ChatResponse, String>>,
52}
53
54/// Errors surfaced by `register_remote_llm_providers`.
55#[derive(Debug, thiserror::Error)]
56pub enum LlmProviderRegistrationError {
57    #[error(
58        "LLM provider `{name}` already registered (cannot collide with built-ins or prior plugins)"
59    )]
60    AlreadyRegistered { name: String },
61    #[error(
62        "subprocess plugin inner not initialized — call register_remote_llm_providers AFTER init()"
63    )]
64    InnerUnavailable,
65}
66
67/// `LlmClient` impl backed by a subprocess plugin's stdio bridge.
68pub struct RemoteLlmClient {
69    provider: String,
70    model: String,
71    plugin_id: String,
72    stdin_tx: mpsc::Sender<Value>,
73    pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
74    streaming_pending: Arc<DashMap<u64, StreamingPending>>,
75    next_id: Arc<AtomicU64>,
76    chat_timeout: Duration,
77    stream_timeout: Duration,
78}
79
80impl RemoteLlmClient {
81    pub fn new(
82        provider: String,
83        model: String,
84        plugin_id: String,
85        stdin_tx: mpsc::Sender<Value>,
86        pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
87        streaming_pending: Arc<DashMap<u64, StreamingPending>>,
88        next_id: Arc<AtomicU64>,
89    ) -> Self {
90        let (chat_timeout, stream_timeout) = Self::resolve_timeouts();
91        Self {
92            provider,
93            model,
94            plugin_id,
95            stdin_tx,
96            pending,
97            streaming_pending,
98            next_id,
99            chat_timeout,
100            stream_timeout,
101        }
102    }
103
104    fn resolve_timeouts() -> (Duration, Duration) {
105        let env_override = std::env::var("NEXO_PLUGIN_LLM_TIMEOUT_MS")
106            .ok()
107            .and_then(|s| s.parse::<u64>().ok())
108            .map(Duration::from_millis);
109        match env_override {
110            Some(t) => (t, t),
111            None => (DEFAULT_CHAT_TIMEOUT, DEFAULT_STREAM_TIMEOUT),
112        }
113    }
114
115    pub fn plugin_id(&self) -> &str {
116        &self.plugin_id
117    }
118
119    fn next_request_id(&self) -> u64 {
120        self.next_id.fetch_add(1, Ordering::SeqCst)
121    }
122
123    fn build_request_frame(&self, id: u64, request: &ChatRequest, stream: bool) -> Value {
124        serde_json::json!({
125            "jsonrpc": "2.0",
126            "id": id,
127            "method": "llm.chat",
128            "params": {
129                "provider": &self.provider,
130                "model": &self.model,
131                "stream": stream,
132                "request": request_to_wire(request),
133            },
134        })
135    }
136
137    /// Map a stringified JSON-RPC error object onto an
138    /// `anyhow::Error` whose message encodes the structured info
139    /// (operator can grep for `rate_limited`, `model_not_found`,
140    /// etc.).
141    fn parse_error_string(&self, s: &str) -> anyhow::Error {
142        let parsed: Value = match serde_json::from_str(s) {
143            Ok(v) => v,
144            Err(_) => return anyhow::anyhow!("provider {} error: {}", self.provider, s),
145        };
146        let code = parsed.get("code").and_then(|v| v.as_i64()).unwrap_or(0);
147        let message = parsed
148            .get("message")
149            .and_then(|v| v.as_str())
150            .unwrap_or("")
151            .to_string();
152        let data = parsed.get("data").cloned().unwrap_or(Value::Null);
153        let provider = &self.provider;
154
155        match code {
156            -32601 => anyhow::anyhow!("provider {provider} method not implemented: {message}"),
157            -32602 => anyhow::anyhow!("invalid llm.chat params: {message}"),
158            -32603 => anyhow::anyhow!("provider {provider} internal error: {message}"),
159            -33101 => anyhow::anyhow!("connection failed: {message}"),
160            -33102 => anyhow::anyhow!("authentication failed: {message}"),
161            -33103 => {
162                let secs = data
163                    .get("retry_after_secs")
164                    .and_then(|v| v.as_u64())
165                    .unwrap_or(0);
166                anyhow::anyhow!("rate limited; retry after {secs}s")
167            }
168            -33104 => anyhow::anyhow!("model {} not available on provider {provider}", self.model),
169            -33105 => anyhow::anyhow!("context too long: {message}"),
170            _ => anyhow::anyhow!("provider {provider} error code {code}: {message}"),
171        }
172    }
173}
174
175#[async_trait]
176impl LlmClient for RemoteLlmClient {
177    fn provider(&self) -> &str {
178        &self.provider
179    }
180
181    fn model_id(&self) -> &str {
182        &self.model
183    }
184
185    async fn chat(&self, req: ChatRequest) -> anyhow::Result<ChatResponse> {
186        let id = self.next_request_id();
187        let frame = self.build_request_frame(id, &req, false);
188        let (tx, rx) = oneshot::channel();
189        self.pending.insert(id, tx);
190
191        if let Err(e) = self.stdin_tx.send(frame).await {
192            self.pending.remove(&id);
193            anyhow::bail!("provider {} stdin closed: {e}", self.provider);
194        }
195
196        let result = match tokio::time::timeout(self.chat_timeout, rx).await {
197            Ok(Ok(Ok(value))) => value,
198            Ok(Ok(Err(err_str))) => return Err(self.parse_error_string(&err_str)),
199            Ok(Err(_)) => {
200                self.pending.remove(&id);
201                anyhow::bail!(
202                    "provider {} pending dropped (subprocess gone)",
203                    self.provider
204                );
205            }
206            Err(_) => {
207                self.pending.remove(&id);
208                anyhow::bail!(
209                    "provider {} llm.chat timed out after {}s",
210                    self.provider,
211                    self.chat_timeout.as_secs()
212                );
213            }
214        };
215
216        let wire: WireChatResponse = serde_json::from_value(result)
217            .map_err(|e| anyhow::anyhow!("decode WireChatResponse: {e}"))?;
218        Ok(wire_to_response(wire))
219    }
220
221    async fn stream<'a>(
222        &'a self,
223        req: ChatRequest,
224    ) -> anyhow::Result<BoxStream<'a, anyhow::Result<StreamChunk>>> {
225        let id = self.next_request_id();
226        let frame = self.build_request_frame(id, &req, true);
227
228        let (delta_tx, delta_rx) = mpsc::unbounded_channel::<StreamChunk>();
229        let (final_tx, final_rx) = oneshot::channel::<Result<ChatResponse, String>>();
230        self.streaming_pending
231            .insert(id, StreamingPending { delta_tx, final_tx });
232
233        if let Err(e) = self.stdin_tx.send(frame).await {
234            self.streaming_pending.remove(&id);
235            anyhow::bail!("provider {} stdin closed: {e}", self.provider);
236        }
237
238        let provider = self.provider.clone();
239        let stream_timeout = self.stream_timeout;
240        let streaming_pending = self.streaming_pending.clone();
241
242        // Driver task: awaits final_rx, emits End/Err to the chunk
243        // stream, removes the streaming_pending entry. Drops the
244        // delta_tx clone on exit so the consumer's stream closes.
245        let chunk_stream = futures::stream::unfold(delta_rx, |mut rx| async move {
246            rx.recv()
247                .await
248                .map(|chunk| (Ok::<StreamChunk, anyhow::Error>(chunk), rx))
249        });
250        let final_chunk = async move {
251            let outcome = match tokio::time::timeout(stream_timeout, final_rx).await {
252                Ok(Ok(Ok(resp))) => Ok(StreamChunk::End {
253                    finish_reason: resp.finish_reason,
254                }),
255                Ok(Ok(Err(err_str))) => Err(anyhow::anyhow!(
256                    "provider {} stream error: {}",
257                    provider,
258                    err_str
259                )),
260                Ok(Err(_)) => Err(anyhow::anyhow!(
261                    "provider {} streaming pending dropped (subprocess gone)",
262                    provider
263                )),
264                Err(_) => {
265                    streaming_pending.remove(&id);
266                    Err(anyhow::anyhow!(
267                        "provider {} llm.chat stream timed out after {}s",
268                        provider,
269                        stream_timeout.as_secs()
270                    ))
271                }
272            };
273            outcome
274        };
275        let final_stream = futures::stream::once(final_chunk);
276
277        let combined = chunk_stream.chain(final_stream);
278        Ok(combined.boxed())
279    }
280}
281
282/// `LlmProviderFactory` for subprocess-backed providers. Cloned
283/// per `LlmRegistry::build` call; produces a fresh
284/// `RemoteLlmClient` per request.
285pub struct RemoteLlmFactory {
286    provider: String,
287    plugin_id: String,
288    stdin_tx: mpsc::Sender<Value>,
289    pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
290    streaming_pending: Arc<DashMap<u64, StreamingPending>>,
291    next_id: Arc<AtomicU64>,
292}
293
294impl RemoteLlmFactory {
295    pub fn new(
296        provider: String,
297        plugin_id: String,
298        stdin_tx: mpsc::Sender<Value>,
299        pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
300        streaming_pending: Arc<DashMap<u64, StreamingPending>>,
301        next_id: Arc<AtomicU64>,
302    ) -> Self {
303        Self {
304            provider,
305            plugin_id,
306            stdin_tx,
307            pending,
308            streaming_pending,
309            next_id,
310        }
311    }
312}
313
314impl LlmProviderFactory for RemoteLlmFactory {
315    fn name(&self) -> &str {
316        &self.provider
317    }
318
319    fn build(
320        &self,
321        _provider_cfg: &LlmProviderConfig,
322        model: &str,
323        _retry: RetryConfig,
324    ) -> anyhow::Result<Arc<dyn LlmClient>> {
325        Ok(Arc::new(RemoteLlmClient::new(
326            self.provider.clone(),
327            model.to_string(),
328            self.plugin_id.clone(),
329            self.stdin_tx.clone(),
330            self.pending.clone(),
331            self.streaming_pending.clone(),
332            self.next_id.clone(),
333        )))
334    }
335}
336
337// ── Tests ────────────────────────────────────────────────────────
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use nexo_llm::types::{ChatMessage, ChatRole};
343
344    fn build() -> (
345        Arc<RemoteLlmClient>,
346        mpsc::Receiver<Value>,
347        Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
348        Arc<DashMap<u64, StreamingPending>>,
349    ) {
350        let (stdin_tx, stdin_rx) = mpsc::channel(8);
351        let pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>> =
352            Arc::new(DashMap::new());
353        let streaming_pending: Arc<DashMap<u64, StreamingPending>> = Arc::new(DashMap::new());
354        let next_id = Arc::new(AtomicU64::new(1));
355        let client = Arc::new(RemoteLlmClient::new(
356            "mock_llm".to_string(),
357            "any-model".to_string(),
358            "mock_plugin".to_string(),
359            stdin_tx,
360            pending.clone(),
361            streaming_pending.clone(),
362            next_id,
363        ));
364        (client, stdin_rx, pending, streaming_pending)
365    }
366
367    fn fixture_request() -> ChatRequest {
368        ChatRequest::new(
369            "any-model",
370            vec![ChatMessage {
371                role: ChatRole::User,
372                content: "hi".into(),
373                tool_call_id: None,
374                name: None,
375                tool_calls: Vec::new(),
376                attachments: Vec::new(),
377            }],
378        )
379    }
380
381    #[test]
382    fn provider_returns_declared_id() {
383        let (client, _, _, _) = build();
384        assert_eq!(client.provider(), "mock_llm");
385        assert_eq!(client.model_id(), "any-model");
386        assert_eq!(client.plugin_id(), "mock_plugin");
387    }
388
389    #[tokio::test]
390    async fn chat_serializes_request_to_wire() {
391        let (client, mut stdin_rx, pending, _) = build();
392        let task = tokio::spawn({
393            let client = client.clone();
394            async move { client.chat(fixture_request()).await }
395        });
396
397        let frame = stdin_rx.recv().await.expect("frame");
398        assert_eq!(frame["method"], "llm.chat");
399        assert_eq!(frame["params"]["provider"], "mock_llm");
400        assert_eq!(frame["params"]["stream"], false);
401        assert_eq!(frame["params"]["request"]["model"], "any-model");
402        let id = frame["id"].as_u64().unwrap();
403
404        // Resolve with success.
405        if let Some((_, sender)) = pending.remove(&id) {
406            let _ = sender.send(Ok(serde_json::json!({
407                "content": { "type": "text", "text": "ack" },
408                "usage": { "prompt_tokens": 1, "completion_tokens": 1 },
409                "finish_reason": { "kind": "stop" }
410            })));
411        }
412        let _resp = task.await.unwrap().unwrap();
413    }
414
415    #[tokio::test]
416    async fn chat_deserializes_response_from_wire() {
417        let (client, mut stdin_rx, pending, _) = build();
418        let task = tokio::spawn({
419            let client = client.clone();
420            async move { client.chat(fixture_request()).await }
421        });
422        let frame = stdin_rx.recv().await.expect("frame");
423        let id = frame["id"].as_u64().unwrap();
424        if let Some((_, sender)) = pending.remove(&id) {
425            let _ = sender.send(Ok(serde_json::json!({
426                "content": { "type": "text", "text": "hello world" },
427                "usage": { "prompt_tokens": 4, "completion_tokens": 2 },
428                "finish_reason": { "kind": "stop" }
429            })));
430        }
431        let resp = task.await.unwrap().unwrap();
432        match resp.content {
433            nexo_llm::types::ResponseContent::Text(t) => assert_eq!(t, "hello world"),
434            other => panic!("expected Text, got {other:?}"),
435        }
436        assert_eq!(resp.usage.prompt_tokens, 4);
437    }
438
439    #[tokio::test]
440    async fn chat_unsupported_method_maps_to_anyhow() {
441        let (client, mut stdin_rx, pending, _) = build();
442        let task = tokio::spawn({
443            let client = client.clone();
444            async move { client.chat(fixture_request()).await }
445        });
446        let frame = stdin_rx.recv().await.expect("frame");
447        let id = frame["id"].as_u64().unwrap();
448        if let Some((_, sender)) = pending.remove(&id) {
449            let err = serde_json::json!({
450                "code": -32601,
451                "message": "llm.chat"
452            });
453            let _ = sender.send(Err(err.to_string()));
454        }
455        let err = task.await.unwrap().unwrap_err();
456        assert!(err.to_string().contains("not implemented"));
457    }
458
459    #[tokio::test]
460    async fn chat_rate_limited_extracts_retry_after_seconds() {
461        let (client, mut stdin_rx, pending, _) = build();
462        let task = tokio::spawn({
463            let client = client.clone();
464            async move { client.chat(fixture_request()).await }
465        });
466        let frame = stdin_rx.recv().await.expect("frame");
467        let id = frame["id"].as_u64().unwrap();
468        if let Some((_, sender)) = pending.remove(&id) {
469            let err = serde_json::json!({
470                "code": -33103,
471                "message": "rate limited",
472                "data": { "retry_after_secs": 30 }
473            });
474            let _ = sender.send(Err(err.to_string()));
475        }
476        let err = task.await.unwrap().unwrap_err();
477        let msg = err.to_string();
478        assert!(msg.contains("rate limited"));
479        assert!(msg.contains("30s"));
480    }
481
482    #[tokio::test]
483    async fn stream_emits_chunks_in_order_then_final_response() {
484        let (client, mut stdin_rx, _, streaming_pending) = build();
485        let task = tokio::spawn({
486            let client = client.clone();
487            async move {
488                let stream = client.stream(fixture_request()).await.unwrap();
489                let mut chunks: Vec<StreamChunk> = Vec::new();
490                let mut s = stream;
491                while let Some(item) = s.next().await {
492                    match item {
493                        Ok(c) => chunks.push(c),
494                        Err(e) => panic!("stream err: {e}"),
495                    }
496                }
497                chunks
498            }
499        });
500        let frame = stdin_rx.recv().await.expect("frame");
501        let id = frame["id"].as_u64().unwrap();
502        assert_eq!(frame["params"]["stream"], true);
503
504        // Push 2 deltas + resolve final.
505        if let Some(entry) = streaming_pending.get(&id) {
506            let _ = entry.delta_tx.send(StreamChunk::TextDelta {
507                delta: "hello".into(),
508            });
509            let _ = entry.delta_tx.send(StreamChunk::TextDelta {
510                delta: " world".into(),
511            });
512        }
513        // Drop entry to resolve final + close delta_rx.
514        if let Some((_, entry)) = streaming_pending.remove(&id) {
515            let _ = entry.final_tx.send(Ok(ChatResponse {
516                content: nexo_llm::types::ResponseContent::Text("".into()),
517                usage: nexo_llm::types::TokenUsage {
518                    prompt_tokens: 1,
519                    completion_tokens: 2,
520                },
521                finish_reason: nexo_llm::types::FinishReason::Stop,
522                cache_usage: None,
523            }));
524        }
525
526        let chunks = task.await.unwrap();
527        assert!(chunks.iter().any(|c| matches!(
528            c,
529            StreamChunk::TextDelta { delta } if delta == "hello"
530        )));
531        assert!(chunks.iter().any(|c| matches!(
532            c,
533            StreamChunk::TextDelta { delta } if delta == " world"
534        )));
535        assert!(chunks.iter().any(|c| matches!(
536            c,
537            StreamChunk::End {
538                finish_reason: nexo_llm::types::FinishReason::Stop
539            }
540        )));
541    }
542
543    #[tokio::test]
544    async fn stream_dropped_subscription_cleans_up_pending() {
545        let (client, _stdin_rx, _, streaming_pending) = build();
546        let task = tokio::spawn({
547            let client = client.clone();
548            async move {
549                let _stream = client.stream(fixture_request()).await.unwrap();
550                // Drop immediately.
551            }
552        });
553        task.await.unwrap();
554        // Streaming pending may or may not be cleaned by the
555        // driver — defer cleanup is an 81.25.b reaper. For now,
556        // just verify no panic.
557        let _ = streaming_pending.len();
558    }
559
560    #[tokio::test(flavor = "current_thread", start_paused = true)]
561    async fn request_timeout_returns_anyhow_err() {
562        let (stdin_tx, mut stdin_rx) = mpsc::channel(8);
563        let pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>> =
564            Arc::new(DashMap::new());
565        let streaming_pending: Arc<DashMap<u64, StreamingPending>> = Arc::new(DashMap::new());
566        let next_id = Arc::new(AtomicU64::new(1));
567        let client = RemoteLlmClient {
568            provider: "mock_llm".into(),
569            model: "x".into(),
570            plugin_id: "mock_plugin".into(),
571            stdin_tx,
572            pending,
573            streaming_pending,
574            next_id,
575            chat_timeout: Duration::from_millis(50),
576            stream_timeout: Duration::from_millis(50),
577        };
578        let task = tokio::spawn(async move { client.chat(fixture_request()).await });
579        let _frame = stdin_rx.recv().await.expect("frame");
580        // Don't resolve — let timeout fire.
581        tokio::time::advance(Duration::from_millis(200)).await;
582        let err = task.await.unwrap().unwrap_err();
583        assert!(err.to_string().contains("timed out"));
584    }
585}