1pub 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
43pub struct StreamingPending {
50 pub delta_tx: mpsc::UnboundedSender<StreamChunk>,
51 pub final_tx: oneshot::Sender<Result<ChatResponse, String>>,
52}
53
54#[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
67pub 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 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 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
282pub 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#[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 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 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 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 }
552 });
553 task.await.unwrap();
554 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 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}