Skip to main content

orcs_lua/llm_command/
mod.rs

1//! Multi-provider LLM client for `orcs.llm()`.
2//!
3//! Direct HTTP implementation (via `ureq`) supporting Ollama, OpenAI, and Anthropic.
4//! Provider-agnostic blocking HTTP client supporting Ollama, OpenAI, and Anthropic.
5//!
6//! # Architecture
7//!
8//! ```text
9//! Lua: orcs.llm(prompt, opts)
10//!   → Capability::LLM gate (ctx_fns / child)
11//!   → llm_request_impl (Rust/ureq)
12//!       ├── Ollama:    POST {base_url}/api/chat
13//!       ├── OpenAI:    POST {base_url}/v1/chat/completions
14//!       └── Anthropic: POST {base_url}/v1/messages
15//! ```
16//!
17//! # Session Management
18//!
19//! Conversation history is stored in-memory per Lua VM via `SessionStore` (Lua app_data).
20//! - `session_id = nil` → create new session (UUID v4), return session_id in response
21//! - `session_id = "existing-id"` → append to existing history and continue
22//!
23//! # Rate Limiting & Retry
24//!
25//! Automatic retry with exponential backoff for transient errors:
26//! - HTTP 429: respects `Retry-After` header, falls back to exponential backoff
27//! - HTTP 5xx: exponential backoff (1s, 2s, 4s, capped at 30s)
28//! - Transport errors (timeout, connection reset): exponential backoff
29//! - Default: 2 retries (3 total attempts), configurable via `opts.max_retries`
30//!
31//! # Session Persistence
32//!
33//! - `orcs.llm_dump_sessions()` → JSON string of all session histories
34//! - `orcs.llm_load_sessions(json)` → restore sessions from JSON
35//!
36//! # Technical Debt
37//!
38//! - Streaming not supported (`stream: false` fixed)
39//! - Multi-turn tool loops not supported (Phase 6: resolve flow)
40
41mod provider;
42mod resolve;
43mod retry;
44mod session;
45
46use mlua::{Lua, Table};
47use orcs_types::intent::StopReason;
48use std::collections::HashMap;
49use std::time::Duration;
50
51use provider::{build_request_body, build_tools_for_provider, Provider};
52use resolve::{
53    build_assistant_content_blocks, build_lua_result, dispatch_intents_to_results,
54    parse_response_body, ResponseOrError,
55};
56use retry::{build_error_result, classify_ureq_error, send_with_retry, SendError};
57use session::{
58    append_message, build_messages, ensure_session_store, resolve_session_id, update_session,
59    Message, SessionStore,
60};
61
62/// Default timeout in seconds for LLM requests.
63const DEFAULT_TIMEOUT_SECS: u64 = 120;
64
65/// Default max_tokens for Anthropic (required field).
66const ANTHROPIC_DEFAULT_MAX_TOKENS: u64 = 4096;
67
68/// Maximum response body size (10 MiB).
69const MAX_BODY_SIZE: u64 = 10 * 1024 * 1024;
70
71/// Default number of retries for transient errors (429, 5xx).
72const DEFAULT_MAX_RETRIES: u32 = 2;
73
74/// Base delay for exponential backoff (milliseconds).
75const RETRY_BASE_DELAY_MS: u64 = 1000;
76
77/// Maximum delay between retries (seconds).
78const RETRY_MAX_DELAY_SECS: u64 = 30;
79
80/// Default maximum number of tool-loop turns (resolve mode).
81const DEFAULT_MAX_TOOL_TURNS: u32 = 10;
82
83// ── Parsed Options ─────────────────────────────────────────────────────
84
85/// Parsed and validated options from the Lua opts table.
86#[derive(Debug)]
87pub(super) struct LlmOpts {
88    pub provider: Provider,
89    pub base_url: String,
90    pub model: String,
91    pub api_key: Option<String>,
92    pub system_prompt: Option<String>,
93    pub session_id: Option<String>,
94    pub temperature: Option<f64>,
95    pub max_tokens: Option<u64>,
96    pub timeout: u64,
97    pub max_retries: u32,
98    /// Whether to send IntentDefs as tools to the LLM (default: true).
99    pub tools: bool,
100    /// Whether to auto-resolve intents in Rust (default: false).
101    /// When true, tool_call intents are dispatched automatically and
102    /// results are fed back to the LLM in a multi-turn loop.
103    /// When false, intents are returned to Lua for manual dispatch.
104    pub resolve: bool,
105    /// Maximum number of tool-loop turns before stopping (default: 10).
106    pub max_tool_turns: u32,
107}
108
109impl LlmOpts {
110    /// Parse from Lua opts table. Missing fields use provider defaults.
111    fn from_lua(opts: Option<&Table>) -> Result<Self, String> {
112        let provider_str = opts
113            .and_then(|o| o.get::<String>("provider").ok())
114            .unwrap_or_else(|| "ollama".to_string());
115        let provider = Provider::from_str(&provider_str)?;
116
117        // base_url resolution: opts.base_url > ORCS_LLM_BASE_URL env > provider default
118        let base_url = opts
119            .and_then(|o| o.get::<String>("base_url").ok())
120            .or_else(|| std::env::var("ORCS_LLM_BASE_URL").ok())
121            .unwrap_or_else(|| provider.default_base_url().to_string());
122
123        let model = opts
124            .and_then(|o| o.get::<String>("model").ok())
125            .unwrap_or_else(|| provider.default_model().to_string());
126
127        // API key resolution: opts.api_key > env var > None
128        let api_key = opts
129            .and_then(|o| o.get::<String>("api_key").ok())
130            .or_else(|| {
131                provider
132                    .api_key_env()
133                    .and_then(|env_name| std::env::var(env_name).ok())
134            });
135
136        let system_prompt = opts.and_then(|o| o.get::<String>("system_prompt").ok());
137        let session_id = opts.and_then(|o| o.get::<String>("session_id").ok());
138        let temperature = opts.and_then(|o| o.get::<f64>("temperature").ok());
139        let max_tokens = opts.and_then(|o| o.get::<u64>("max_tokens").ok());
140
141        let timeout = opts
142            .and_then(|o| o.get::<u64>("timeout").ok())
143            .unwrap_or(DEFAULT_TIMEOUT_SECS);
144
145        let max_retries = opts
146            .and_then(|o| o.get::<u32>("max_retries").ok())
147            .unwrap_or(DEFAULT_MAX_RETRIES);
148
149        let tools = opts
150            .and_then(|o| o.get::<bool>("tools").ok())
151            .unwrap_or(true);
152
153        let resolve = opts
154            .and_then(|o| o.get::<bool>("resolve").ok())
155            .unwrap_or(false);
156
157        let max_tool_turns = opts
158            .and_then(|o| o.get::<u32>("max_tool_turns").ok())
159            .unwrap_or(DEFAULT_MAX_TOOL_TURNS);
160
161        Ok(Self {
162            provider,
163            base_url,
164            model,
165            api_key,
166            system_prompt,
167            session_id,
168            temperature,
169            max_tokens,
170            timeout,
171            max_retries,
172            tools,
173            resolve,
174            max_tool_turns,
175        })
176    }
177}
178
179// ── Ping Implementation ───────────────────────────────────────────────
180
181/// Default timeout in seconds for health-check pings.
182const PING_TIMEOUT_SECS: u64 = 5;
183
184/// Executes a lightweight connectivity check against the LLM provider.
185///
186/// Sends a single HTTP GET to the provider's health endpoint and measures
187/// round-trip latency. Does **not** consume tokens or create sessions.
188///
189/// # Arguments (from Lua)
190///
191/// * `opts` - Optional table:
192///   - `provider`  - "ollama" (default), "openai", "anthropic"
193///   - `base_url`  - Provider base URL (default per provider)
194///   - `api_key`   - API key (falls back to env var)
195///   - `timeout`   - Timeout in seconds (default: 5)
196///
197/// # Returns (Lua table)
198///
199/// * `ok`         - boolean (true if HTTP response received, even non-2xx)
200/// * `provider`   - Provider name string
201/// * `base_url`   - Resolved base URL
202/// * `latency_ms` - Round-trip time in milliseconds
203/// * `status`     - HTTP status code (when response received)
204/// * `error`      - Error message (when ok=false)
205/// * `error_kind` - Error classification (when ok=false)
206pub fn llm_ping_impl(lua: &Lua, opts: Option<Table>) -> mlua::Result<Table> {
207    // Parse provider/base_url/api_key from opts (reuse LlmOpts parsing logic)
208    let provider_str = opts
209        .as_ref()
210        .and_then(|o| o.get::<String>("provider").ok())
211        .unwrap_or_else(|| "ollama".to_string());
212    let provider = match Provider::from_str(&provider_str) {
213        Ok(p) => p,
214        Err(e) => {
215            let result = lua.create_table()?;
216            result.set("ok", false)?;
217            result.set("error", e)?;
218            result.set("error_kind", "invalid_options")?;
219            return Ok(result);
220        }
221    };
222
223    let base_url = opts
224        .as_ref()
225        .and_then(|o| o.get::<String>("base_url").ok())
226        .or_else(|| std::env::var("ORCS_LLM_BASE_URL").ok())
227        .unwrap_or_else(|| provider.default_base_url().to_string());
228
229    let api_key = opts
230        .as_ref()
231        .and_then(|o| o.get::<String>("api_key").ok())
232        .or_else(|| {
233            provider
234                .api_key_env()
235                .and_then(|env_name| std::env::var(env_name).ok())
236        });
237
238    let timeout = opts
239        .as_ref()
240        .and_then(|o| o.get::<u64>("timeout").ok())
241        .unwrap_or(PING_TIMEOUT_SECS);
242
243    // Build URL
244    let url = format!(
245        "{}{}",
246        base_url.trim_end_matches('/'),
247        provider.health_path()
248    );
249
250    // Configure agent with short timeout
251    let config = ureq::Agent::config_builder()
252        .timeout_global(Some(Duration::from_secs(timeout)))
253        .build();
254    let agent = ureq::Agent::new_with_config(config);
255
256    // Send GET and measure latency
257    let start = std::time::Instant::now();
258
259    let mut req = agent.get(&url);
260    // Attach auth headers for providers that need them
261    match provider {
262        Provider::Ollama => {}
263        Provider::OpenAI => {
264            if let Some(ref key) = api_key {
265                req = req.header("Authorization", &format!("Bearer {}", key));
266            }
267        }
268        Provider::Anthropic => {
269            if let Some(ref key) = api_key {
270                req = req.header("x-api-key", key);
271            }
272            req = req.header("anthropic-version", "2023-06-01");
273        }
274    }
275
276    let result = lua.create_table()?;
277    result.set("provider", format!("{:?}", provider).to_lowercase())?;
278    result.set("base_url", base_url.as_str())?;
279
280    match req.call() {
281        Ok(resp) => {
282            let latency = start.elapsed();
283            let status = resp.status().as_u16();
284            result.set("ok", true)?;
285            result.set("status", status)?;
286            result.set("latency_ms", latency.as_millis() as u64)?;
287        }
288        Err(e) => {
289            let latency = start.elapsed();
290            result.set("latency_ms", latency.as_millis() as u64)?;
291
292            // If we got an HTTP response (non-2xx), connectivity is confirmed
293            if let ureq::Error::StatusCode(status) = &e {
294                result.set("ok", true)?;
295                result.set("status", *status)?;
296            } else {
297                let (error_kind, error_msg) = classify_ureq_error(&e);
298                result.set("ok", false)?;
299                result.set("error", error_msg)?;
300                result.set("error_kind", error_kind)?;
301            }
302        }
303    }
304
305    Ok(result)
306}
307
308// ── Deny Stub ──────────────────────────────────────────────────────────
309
310/// Registers `orcs.llm` as a deny-by-default stub.
311///
312/// The real implementation is injected by `ctx_fns.rs` / `child.rs`
313/// when a `ChildContext` with `Capability::LLM` is available.
314pub fn register_llm_deny_stub(lua: &Lua, orcs_table: &Table) -> Result<(), mlua::Error> {
315    if orcs_table.get::<mlua::Function>("llm").is_err() {
316        let llm_fn = lua.create_function(|lua, _args: mlua::MultiValue| {
317            let result = lua.create_table()?;
318            result.set("ok", false)?;
319            result.set(
320                "error",
321                "llm denied: no execution context (ChildContext with Capability::LLM required)",
322            )?;
323            result.set("error_kind", "permission_denied")?;
324            Ok(result)
325        })?;
326        orcs_table.set("llm", llm_fn)?;
327    }
328
329    // llm_ping deny stub
330    if orcs_table.get::<mlua::Function>("llm_ping").is_err() {
331        let ping_fn = lua.create_function(|lua, _args: mlua::MultiValue| {
332            let result = lua.create_table()?;
333            result.set("ok", false)?;
334            result.set(
335                "error",
336                "llm_ping denied: no execution context (ChildContext with Capability::LLM required)",
337            )?;
338            result.set("error_kind", "permission_denied")?;
339            Ok(result)
340        })?;
341        orcs_table.set("llm_ping", ping_fn)?;
342    }
343
344    // Session persistence: dump all sessions to JSON string
345    let dump_fn = lua.create_function(|lua, ()| {
346        ensure_session_store(lua);
347        match lua.app_data_ref::<SessionStore>() {
348            Some(store) => serde_json::to_string(&store.0)
349                .map_err(|e| mlua::Error::RuntimeError(format!("session serialize error: {e}"))),
350            None => Ok("{}".to_string()),
351        }
352    })?;
353    orcs_table.set("llm_dump_sessions", dump_fn)?;
354
355    // Session persistence: load sessions from JSON string
356    let load_fn = lua.create_function(|lua, json_str: String| {
357        let sessions: HashMap<String, Vec<Message>> = serde_json::from_str(&json_str)
358            .map_err(|e| mlua::Error::RuntimeError(format!("session deserialize error: {e}")))?;
359        let count = sessions.len();
360        let _ = lua.remove_app_data::<SessionStore>();
361        lua.set_app_data(SessionStore(sessions));
362
363        let result = lua.create_table()?;
364        result.set("ok", true)?;
365        result.set("count", count)?;
366        Ok(result)
367    })?;
368    orcs_table.set("llm_load_sessions", load_fn)?;
369
370    Ok(())
371}
372
373// ── Request Implementation ─────────────────────────────────────────────
374
375/// Executes an LLM chat request. Called from capability-gated context.
376///
377/// # Arguments (from Lua)
378///
379/// * `prompt` - User message text
380/// * `opts` - Optional table:
381///   - `provider` - "ollama" (default), "openai", "anthropic"
382///   - `base_url` - Provider base URL (default per provider)
383///   - `model` - Model name (default per provider)
384///   - `api_key` - API key (falls back to env var)
385///   - `system_prompt` - System prompt text
386///   - `session_id` - Session ID for multi-turn (nil = new session)
387///   - `temperature` - Sampling temperature
388///   - `max_tokens` - Max completion tokens
389///   - `timeout` - Request timeout in seconds (default: 120)
390///
391/// # Returns (Lua table)
392///
393/// * `ok` - boolean
394/// * `content` - Response text (when ok=true)
395/// * `model` - Model name from response
396/// * `session_id` - Session ID (new or existing)
397/// * `error` - Error message (when ok=false)
398/// * `error_kind` - Error classification
399pub fn llm_request_impl(lua: &Lua, args: (String, Option<Table>)) -> mlua::Result<Table> {
400    let (prompt, opts) = args;
401
402    // Parse options
403    let llm_opts = match LlmOpts::from_lua(opts.as_ref()) {
404        Ok(o) => o,
405        Err(e) => {
406            let result = lua.create_table()?;
407            result.set("ok", false)?;
408            result.set("error", e)?;
409            result.set("error_kind", "invalid_options")?;
410            return Ok(result);
411        }
412    };
413
414    // Validate API key requirement
415    if llm_opts.provider != Provider::Ollama && llm_opts.api_key.is_none() {
416        let env_name = llm_opts
417            .provider
418            .api_key_env()
419            .unwrap_or("(unknown env var)");
420        let result = lua.create_table()?;
421        result.set("ok", false)?;
422        result.set(
423            "error",
424            format!(
425                "API key required for {:?}: set opts.api_key or {} environment variable",
426                llm_opts.provider, env_name
427            ),
428        )?;
429        result.set("error_kind", "missing_api_key")?;
430        return Ok(result);
431    }
432
433    // Session management: get or create session
434    let session_id = resolve_session_id(lua, &llm_opts.session_id);
435
436    // Build tools JSON from IntentRegistry (when opts.tools is true)
437    let tools_json = if llm_opts.tools {
438        build_tools_for_provider(lua, llm_opts.provider)
439    } else {
440        None
441    };
442
443    // Build URL
444    let url = format!(
445        "{}{}",
446        llm_opts.base_url.trim_end_matches('/'),
447        llm_opts.provider.chat_path()
448    );
449
450    // Configure ureq agent (reused across retries and tool turns)
451    let config = ureq::Agent::config_builder()
452        .timeout_global(Some(Duration::from_secs(llm_opts.timeout)))
453        .build();
454    let agent = ureq::Agent::new_with_config(config);
455
456    // ── First turn: build messages from history + prompt ──
457    let mut messages = build_messages(lua, &session_id, &prompt, &llm_opts);
458
459    // ── Tool loop ──
460    // Each iteration: build body → send → parse → if tool_use && resolve → dispatch → append results → repeat
461    for tool_turn in 0..=llm_opts.max_tool_turns {
462        let request_body = match build_request_body(&llm_opts, &messages, tools_json.as_ref()) {
463            Ok(body) => body,
464            Err(e) => {
465                let result = lua.create_table()?;
466                result.set("ok", false)?;
467                result.set("error", e)?;
468                result.set("error_kind", "request_build_error")?;
469                return Ok(result);
470            }
471        };
472
473        let body_str = request_body.to_string();
474        tracing::debug!(
475            "llm request turn={}: {} {} ({}B)",
476            tool_turn,
477            llm_opts.provider.chat_path(),
478            llm_opts.model,
479            body_str.len()
480        );
481
482        // Send with retry
483        let resp = match send_with_retry(&agent, &url, &llm_opts, &body_str) {
484            Ok(resp) => resp,
485            Err(SendError::Transport(e)) => return build_error_result(lua, e, &session_id),
486        };
487
488        // Parse response
489        let parsed_resp = match parse_response_body(lua, resp, &llm_opts, &session_id)? {
490            ResponseOrError::Parsed(p) => p,
491            ResponseOrError::ErrorTable(t) => return Ok(t),
492        };
493
494        let is_tool_use = parsed_resp.stop_reason == StopReason::ToolUse;
495        let should_resolve = is_tool_use && llm_opts.resolve && !parsed_resp.intents.is_empty();
496
497        if should_resolve && tool_turn < llm_opts.max_tool_turns {
498            // ── Auto-resolve: dispatch intents and continue loop ──
499
500            // Build assistant message with ContentBlocks (preserves tool_use blocks)
501            let assistant_blocks = build_assistant_content_blocks(&parsed_resp);
502            messages.push(session::Message {
503                role: orcs_types::intent::Role::Assistant,
504                content: assistant_blocks.clone(),
505            });
506            append_message(
507                lua,
508                &session_id,
509                orcs_types::intent::Role::Assistant,
510                assistant_blocks,
511            );
512
513            // Dispatch each intent and collect tool results
514            let tool_result_content = dispatch_intents_to_results(lua, &parsed_resp.intents)?;
515            messages.push(session::Message {
516                role: orcs_types::intent::Role::User,
517                content: tool_result_content.clone(),
518            });
519            append_message(
520                lua,
521                &session_id,
522                orcs_types::intent::Role::User,
523                tool_result_content,
524            );
525
526            let intent_names: Vec<&str> = parsed_resp
527                .intents
528                .iter()
529                .map(|i| i.name.as_str())
530                .collect();
531            tracing::info!(
532                "tool turn {}: resolved {} intent(s) [{}], continuing",
533                tool_turn,
534                parsed_resp.intents.len(),
535                intent_names.join(", ")
536            );
537            continue;
538        }
539
540        // ── Final response: return to Lua ──
541        // For non-resolve mode or final turn: store text-only in session
542        update_session(lua, &session_id, &prompt, &parsed_resp.content);
543
544        return build_lua_result(lua, &parsed_resp, &llm_opts, &session_id);
545    }
546
547    // Tool loop exhausted
548    let result = lua.create_table()?;
549    result.set("ok", false)?;
550    result.set(
551        "error",
552        format!(
553            "tool loop exceeded max_tool_turns ({})",
554            llm_opts.max_tool_turns
555        ),
556    )?;
557    result.set("error_kind", "tool_loop_limit")?;
558    result.set("session_id", session_id)?;
559    Ok(result)
560}
561
562// ── Tests ──────────────────────────────────────────────────────────────
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    // ── LlmOpts tests ─────────────────────────────────────────────────
569
570    #[test]
571    fn llm_opts_defaults_to_ollama() {
572        let opts = LlmOpts::from_lua(None).expect("should parse None opts");
573        assert_eq!(opts.provider, Provider::Ollama);
574        assert_eq!(opts.base_url, "http://localhost:11434");
575        assert_eq!(opts.model, "llama3.2");
576        assert_eq!(opts.timeout, 120);
577        assert!(opts.api_key.is_none());
578    }
579
580    #[test]
581    fn llm_opts_parses_provider() {
582        let lua = Lua::new();
583        let tbl = lua.create_table().expect("create table");
584        tbl.set("provider", "anthropic").expect("set provider");
585        tbl.set("api_key", "test-key").expect("set api_key");
586
587        let opts = LlmOpts::from_lua(Some(&tbl)).expect("should parse opts");
588        assert_eq!(opts.provider, Provider::Anthropic);
589        assert_eq!(opts.base_url, "https://api.anthropic.com");
590        assert_eq!(opts.model, "claude-sonnet-4-20250514");
591        assert_eq!(opts.api_key.as_deref(), Some("test-key"));
592    }
593
594    #[test]
595    fn llm_opts_custom_overrides() {
596        let lua = Lua::new();
597        let tbl = lua.create_table().expect("create table");
598        tbl.set("provider", "openai").expect("set provider");
599        tbl.set("base_url", "https://custom.api.com")
600            .expect("set base_url");
601        tbl.set("model", "gpt-4o-mini").expect("set model");
602        tbl.set("temperature", 0.5).expect("set temperature");
603        tbl.set("max_tokens", 2048u64).expect("set max_tokens");
604        tbl.set("timeout", 60u64).expect("set timeout");
605        tbl.set("api_key", "sk-test").expect("set api_key");
606
607        let opts = LlmOpts::from_lua(Some(&tbl)).expect("should parse opts");
608        assert_eq!(opts.provider, Provider::OpenAI);
609        assert_eq!(opts.base_url, "https://custom.api.com");
610        assert_eq!(opts.model, "gpt-4o-mini");
611        assert_eq!(opts.temperature, Some(0.5));
612        assert_eq!(opts.max_tokens, Some(2048));
613        assert_eq!(opts.timeout, 60);
614        assert_eq!(opts.api_key.as_deref(), Some("sk-test"));
615    }
616
617    #[test]
618    fn llm_opts_invalid_provider() {
619        let lua = Lua::new();
620        let tbl = lua.create_table().expect("create table");
621        tbl.set("provider", "gpt").expect("set provider");
622
623        let err = LlmOpts::from_lua(Some(&tbl)).expect_err("should reject invalid provider");
624        assert!(
625            err.contains("unsupported provider"),
626            "error should mention unsupported, got: {}",
627            err
628        );
629    }
630
631    #[test]
632    fn llm_opts_default_max_retries() {
633        let opts = LlmOpts::from_lua(None).expect("should parse None opts");
634        assert_eq!(opts.max_retries, DEFAULT_MAX_RETRIES);
635    }
636
637    #[test]
638    fn llm_opts_custom_max_retries() {
639        let lua = Lua::new();
640        let tbl = lua.create_table().expect("create table");
641        tbl.set("max_retries", 5u32).expect("set max_retries");
642
643        let opts = LlmOpts::from_lua(Some(&tbl)).expect("should parse opts");
644        assert_eq!(opts.max_retries, 5);
645    }
646
647    #[test]
648    fn llm_opts_zero_max_retries() {
649        let lua = Lua::new();
650        let tbl = lua.create_table().expect("create table");
651        tbl.set("max_retries", 0u32).expect("set max_retries");
652
653        let opts = LlmOpts::from_lua(Some(&tbl)).expect("should parse opts");
654        assert_eq!(opts.max_retries, 0);
655    }
656
657    #[test]
658    fn llm_opts_resolve_defaults() {
659        let lua = Lua::new();
660        let opts_tbl = lua.create_table().expect("create table");
661        let opts = LlmOpts::from_lua(Some(&opts_tbl)).expect("parse opts");
662        assert!(!opts.resolve, "resolve should default to false");
663        assert_eq!(
664            opts.max_tool_turns, DEFAULT_MAX_TOOL_TURNS,
665            "max_tool_turns should default"
666        );
667    }
668
669    #[test]
670    fn llm_opts_resolve_custom() {
671        let lua = Lua::new();
672        let opts_tbl = lua.create_table().expect("create table");
673        opts_tbl.set("resolve", true).expect("set resolve");
674        opts_tbl
675            .set("max_tool_turns", 3u32)
676            .expect("set max_tool_turns");
677        let opts = LlmOpts::from_lua(Some(&opts_tbl)).expect("parse opts");
678        assert!(opts.resolve, "resolve should be true");
679        assert_eq!(opts.max_tool_turns, 3, "max_tool_turns should be 3");
680    }
681
682    // ── Deny stub test ─────────────────────────────────────────────────
683
684    #[test]
685    fn deny_stub_returns_permission_denied() {
686        let lua = Lua::new();
687        let orcs = crate::orcs_helpers::ensure_orcs_table(&lua).expect("create orcs table");
688        register_llm_deny_stub(&lua, &orcs).expect("register stub");
689
690        let result: Table = lua
691            .load(r#"return orcs.llm("hello")"#)
692            .eval()
693            .expect("should return deny table");
694
695        assert!(!result.get::<bool>("ok").expect("get ok"));
696        let error: String = result.get("error").expect("get error");
697        assert!(
698            error.contains("llm denied"),
699            "expected permission denied, got: {error}"
700        );
701        assert_eq!(
702            result.get::<String>("error_kind").expect("get error_kind"),
703            "permission_denied"
704        );
705    }
706
707    // ── Session persistence tests ─────────────────────────────────────
708
709    #[test]
710    fn session_dump_empty() {
711        let lua = Lua::new();
712        let orcs = crate::orcs_helpers::ensure_orcs_table(&lua).expect("create orcs table");
713        register_llm_deny_stub(&lua, &orcs).expect("register stub");
714
715        let json: String = lua
716            .load(r#"return orcs.llm_dump_sessions()"#)
717            .eval()
718            .expect("should return json string");
719        assert_eq!(json, "{}");
720    }
721
722    #[test]
723    fn session_dump_with_history() {
724        let lua = Lua::new();
725        let orcs = crate::orcs_helpers::ensure_orcs_table(&lua).expect("create orcs table");
726        register_llm_deny_stub(&lua, &orcs).expect("register stub");
727
728        // Create a session with some history
729        let sid = resolve_session_id(&lua, &None);
730        update_session(&lua, &sid, "hello", "world");
731
732        let json: String = lua
733            .load(r#"return orcs.llm_dump_sessions()"#)
734            .eval()
735            .expect("should return json string");
736
737        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should be valid JSON");
738        assert!(parsed.is_object(), "should be JSON object");
739        let sessions = parsed.as_object().expect("should be object");
740        assert_eq!(sessions.len(), 1, "should have one session");
741
742        let history = sessions.get(&sid).expect("should have session by id");
743        let msgs = history.as_array().expect("should be array");
744        assert_eq!(msgs.len(), 2, "should have 2 messages");
745        assert_eq!(msgs[0]["role"], "user");
746        assert_eq!(msgs[0]["content"], "hello");
747        assert_eq!(msgs[1]["role"], "assistant");
748        assert_eq!(msgs[1]["content"], "world");
749    }
750
751    #[test]
752    fn session_load_roundtrip() {
753        let lua = Lua::new();
754        let orcs = crate::orcs_helpers::ensure_orcs_table(&lua).expect("create orcs table");
755        register_llm_deny_stub(&lua, &orcs).expect("register stub");
756
757        // Create sessions
758        let sid1 = resolve_session_id(&lua, &None);
759        update_session(&lua, &sid1, "q1", "a1");
760        let sid2 = resolve_session_id(&lua, &None);
761        update_session(&lua, &sid2, "q2", "a2");
762
763        // Dump
764        let json: String = lua
765            .load(r#"return orcs.llm_dump_sessions()"#)
766            .eval()
767            .expect("dump should succeed");
768
769        // Clear store
770        let _ = lua.remove_app_data::<SessionStore>();
771
772        // Load back
773        lua.globals()
774            .get::<Table>("orcs")
775            .expect("get orcs table")
776            .get::<mlua::Function>("llm_load_sessions")
777            .expect("get load fn")
778            .call::<Table>(json.clone())
779            .expect("load should succeed");
780
781        // Verify restored
782        let store = lua
783            .app_data_ref::<SessionStore>()
784            .expect("store should exist");
785        assert_eq!(store.0.len(), 2, "should have 2 sessions");
786        let h1 = store.0.get(&sid1).expect("session 1 should exist");
787        assert_eq!(h1.len(), 2);
788        assert_eq!(h1[0].content.text(), Some("q1"));
789        assert_eq!(h1[1].content.text(), Some("a1"));
790    }
791
792    #[test]
793    fn session_load_invalid_json() {
794        let lua = Lua::new();
795        let orcs = crate::orcs_helpers::ensure_orcs_table(&lua).expect("create orcs table");
796        register_llm_deny_stub(&lua, &orcs).expect("register stub");
797
798        let result = lua
799            .load(r#"return orcs.llm_load_sessions("not valid json")"#)
800            .eval::<Table>();
801
802        assert!(
803            result.is_err(),
804            "should error on invalid JSON, got: {:?}",
805            result
806        );
807    }
808
809    #[test]
810    fn session_load_returns_count() {
811        let lua = Lua::new();
812        let orcs = crate::orcs_helpers::ensure_orcs_table(&lua).expect("create orcs table");
813        register_llm_deny_stub(&lua, &orcs).expect("register stub");
814
815        let json = r#"{"sess-1": [{"role":"user","content":"hi"}], "sess-2": []}"#;
816        let result: Table = lua
817            .load(format!(
818                r#"return orcs.llm_load_sessions('{}')"#,
819                json.replace('\'', "\\'")
820            ))
821            .eval()
822            .expect("load should succeed");
823
824        assert!(result.get::<bool>("ok").expect("get ok"));
825        assert_eq!(
826            result.get::<i64>("count").expect("get count"),
827            2,
828            "should report 2 sessions loaded"
829        );
830    }
831
832    // ── Ping tests ──────────────────────────────────────────────────────
833
834    #[test]
835    fn ping_defaults_to_ollama() {
836        let lua = Lua::new();
837        let result = llm_ping_impl(&lua, None).expect("should not panic");
838
839        let provider: String = result.get("provider").expect("get provider");
840        assert_eq!(provider, "ollama");
841
842        let base_url: String = result.get("base_url").expect("get base_url");
843        assert_eq!(base_url, "http://localhost:11434");
844
845        // latency_ms is always present
846        let _: u64 = result.get("latency_ms").expect("get latency_ms");
847    }
848
849    #[test]
850    fn ping_invalid_provider() {
851        let lua = Lua::new();
852        let opts = lua.create_table().expect("create opts");
853        opts.set("provider", "gemini").expect("set provider");
854
855        let result = llm_ping_impl(&lua, Some(opts)).expect("should not panic");
856        assert!(!result.get::<bool>("ok").expect("get ok"));
857        assert_eq!(
858            result.get::<String>("error_kind").expect("get error_kind"),
859            "invalid_options"
860        );
861    }
862
863    #[test]
864    fn ping_connection_refused() {
865        let lua = Lua::new();
866        let opts = lua.create_table().expect("create opts");
867        opts.set("provider", "ollama").expect("set provider");
868        opts.set("base_url", "http://127.0.0.1:1")
869            .expect("set base_url");
870        opts.set("timeout", 2u64).expect("set timeout");
871
872        let result = llm_ping_impl(&lua, Some(opts)).expect("should not panic");
873        assert!(
874            !result.get::<bool>("ok").expect("get ok"),
875            "should fail when nothing is listening"
876        );
877
878        let error_kind: String = result.get("error_kind").expect("get error_kind");
879        assert!(
880            error_kind == "connection_refused"
881                || error_kind == "network"
882                || error_kind == "timeout",
883            "expected connection error, got: {}",
884            error_kind
885        );
886    }
887
888    #[test]
889    fn ping_deny_stub_returns_permission_denied() {
890        let lua = Lua::new();
891        let orcs = crate::orcs_helpers::ensure_orcs_table(&lua).expect("create orcs table");
892        register_llm_deny_stub(&lua, &orcs).expect("register stub");
893
894        let result: Table = lua
895            .load(r#"return orcs.llm_ping()"#)
896            .eval()
897            .expect("should return deny table");
898
899        assert!(!result.get::<bool>("ok").expect("get ok"));
900        let error: String = result.get("error").expect("get error");
901        assert!(
902            error.contains("llm_ping denied"),
903            "expected permission denied, got: {error}"
904        );
905        assert_eq!(
906            result.get::<String>("error_kind").expect("get error_kind"),
907            "permission_denied"
908        );
909    }
910
911    // ── Integration: llm_request_impl with missing API key ─────────────
912
913    #[test]
914    fn openai_missing_api_key_returns_error() {
915        let lua = Lua::new();
916        let opts = lua.create_table().expect("create opts");
917        opts.set("provider", "openai").expect("set provider");
918        // Deliberately not setting api_key or env var
919
920        // Temporarily clear the env var to ensure test isolation
921        let prev = std::env::var("OPENAI_API_KEY").ok();
922        std::env::remove_var("OPENAI_API_KEY");
923
924        let result =
925            llm_request_impl(&lua, ("hello".into(), Some(opts))).expect("should not panic");
926
927        // Restore env
928        if let Some(val) = prev {
929            std::env::set_var("OPENAI_API_KEY", val);
930        }
931
932        assert!(!result.get::<bool>("ok").expect("get ok"));
933        assert_eq!(
934            result.get::<String>("error_kind").expect("get error_kind"),
935            "missing_api_key"
936        );
937        let error: String = result.get("error").expect("get error");
938        assert!(
939            error.contains("OPENAI_API_KEY"),
940            "error should mention env var, got: {}",
941            error
942        );
943    }
944
945    #[test]
946    fn anthropic_missing_api_key_returns_error() {
947        let lua = Lua::new();
948        let opts = lua.create_table().expect("create opts");
949        opts.set("provider", "anthropic").expect("set provider");
950
951        let prev = std::env::var("ANTHROPIC_API_KEY").ok();
952        std::env::remove_var("ANTHROPIC_API_KEY");
953
954        let result =
955            llm_request_impl(&lua, ("hello".into(), Some(opts))).expect("should not panic");
956
957        if let Some(val) = prev {
958            std::env::set_var("ANTHROPIC_API_KEY", val);
959        }
960
961        assert!(!result.get::<bool>("ok").expect("get ok"));
962        assert_eq!(
963            result.get::<String>("error_kind").expect("get error_kind"),
964            "missing_api_key"
965        );
966    }
967
968    #[test]
969    fn ollama_no_api_key_required() {
970        let lua = Lua::new();
971        let opts = lua.create_table().expect("create opts");
972        opts.set("provider", "ollama").expect("set provider");
973        opts.set("timeout", 1u64).expect("set timeout");
974
975        // This will fail to connect (Ollama likely not running) but should not
976        // fail due to missing API key
977        let result =
978            llm_request_impl(&lua, ("hello".into(), Some(opts))).expect("should not panic");
979
980        // If Ollama is running, ok=true; if not, ok=false but NOT missing_api_key
981        if !result.get::<bool>("ok").expect("get ok") {
982            let error_kind: String = result.get("error_kind").expect("get error_kind");
983            assert_ne!(
984                error_kind, "missing_api_key",
985                "ollama should not require API key"
986            );
987        }
988    }
989
990    // ── Integration: connection error ──────────────────────────────────
991
992    #[test]
993    fn connection_refused_returns_network_error() {
994        let lua = Lua::new();
995        let opts = lua.create_table().expect("create opts");
996        opts.set("provider", "ollama").expect("set provider");
997        opts.set("base_url", "http://127.0.0.1:1")
998            .expect("set base_url");
999        opts.set("timeout", 2u64).expect("set timeout");
1000
1001        let result =
1002            llm_request_impl(&lua, ("hello".into(), Some(opts))).expect("should not panic");
1003        assert!(!result.get::<bool>("ok").expect("get ok"));
1004
1005        let error_kind: String = result.get("error_kind").expect("get error_kind");
1006        assert!(
1007            error_kind == "connection_refused"
1008                || error_kind == "network"
1009                || error_kind == "timeout",
1010            "expected connection error, got: {}",
1011            error_kind
1012        );
1013
1014        // Should still have session_id
1015        let session_id: String = result.get("session_id").expect("get session_id");
1016        assert!(
1017            session_id.starts_with("sess-"),
1018            "should have session_id, got: {}",
1019            session_id
1020        );
1021    }
1022
1023    /// connection_refused is NOT retried by default (server not running).
1024    /// With max_retries=0, no retry attempt is made.
1025    #[test]
1026    fn connection_refused_no_retry_with_zero_retries() {
1027        let lua = Lua::new();
1028        let opts = lua.create_table().expect("create opts");
1029        opts.set("provider", "ollama").expect("set provider");
1030        opts.set("base_url", "http://127.0.0.1:1")
1031            .expect("set base_url");
1032        opts.set("timeout", 1u64).expect("set timeout");
1033        opts.set("max_retries", 0u32).expect("set max_retries");
1034
1035        let start = std::time::Instant::now();
1036        let result =
1037            llm_request_impl(&lua, ("hello".into(), Some(opts))).expect("should not panic");
1038        let elapsed = start.elapsed();
1039
1040        assert!(!result.get::<bool>("ok").expect("get ok"));
1041        // Should complete quickly (no retry delay)
1042        assert!(
1043            elapsed < Duration::from_secs(5),
1044            "should not retry, elapsed: {:?}",
1045            elapsed
1046        );
1047    }
1048
1049    // ── E2E tests (require running Ollama) ──────────────────────────────
1050
1051    /// E2E: ping a real Ollama server.
1052    /// Run with: cargo test -p orcs-lua --lib llm_command::tests::e2e_ollama_ping -- --ignored --nocapture
1053    #[test]
1054    #[ignore = "requires running Ollama server"]
1055    fn e2e_ollama_ping() {
1056        let lua = Lua::new();
1057        let opts = lua.create_table().expect("create opts");
1058        opts.set("provider", "ollama").expect("set provider");
1059        opts.set("timeout", 5u64).expect("set timeout");
1060
1061        let result = llm_ping_impl(&lua, Some(opts)).expect("should not panic");
1062
1063        let ok = result.get::<bool>("ok").expect("get ok");
1064        assert!(ok, "should succeed with running Ollama");
1065
1066        let status: u16 = result.get("status").expect("get status");
1067        assert_eq!(status, 200, "Ollama root should return 200");
1068
1069        let latency: u64 = result.get("latency_ms").expect("get latency_ms");
1070        assert!(
1071            latency < 5000,
1072            "latency should be under 5s, got: {}ms",
1073            latency
1074        );
1075
1076        let provider: String = result.get("provider").expect("get provider");
1077        assert_eq!(provider, "ollama");
1078
1079        eprintln!("[E2E] ping ok={ok} status={status} latency={latency}ms");
1080    }
1081
1082    /// E2E: single-turn call to real Ollama server.
1083    /// Run with: cargo test -p orcs-lua --lib llm_command::tests::e2e_ollama_single_turn -- --ignored --nocapture
1084    #[test]
1085    #[ignore = "requires running Ollama server"]
1086    fn e2e_ollama_single_turn() {
1087        let lua = Lua::new();
1088        let opts = lua.create_table().expect("create opts");
1089        opts.set("provider", "ollama").expect("set provider");
1090        opts.set("model", "qwen2.5-coder:1.5b").expect("set model");
1091        opts.set("timeout", 30u64).expect("set timeout");
1092        opts.set("max_retries", 0u32).expect("set max_retries");
1093
1094        let result = llm_request_impl(&lua, ("Say exactly: HELLO_ORCS".into(), Some(opts)))
1095            .expect("should not panic");
1096
1097        let ok = result.get::<bool>("ok").expect("get ok");
1098        assert!(ok, "should succeed with running Ollama");
1099
1100        let content: String = result.get("content").expect("get content");
1101        assert!(!content.is_empty(), "content should not be empty");
1102
1103        let session_id: String = result.get("session_id").expect("get session_id");
1104        assert!(
1105            session_id.starts_with("sess-"),
1106            "should have session_id, got: {}",
1107            session_id
1108        );
1109
1110        let model: String = result.get("model").expect("get model");
1111        assert!(
1112            model.contains("qwen"),
1113            "model should contain qwen, got: {}",
1114            model
1115        );
1116
1117        eprintln!("[E2E] ok={ok} model={model} session_id={session_id}");
1118        eprintln!("[E2E] content: {content}");
1119    }
1120
1121    /// E2E: multi-turn session with real Ollama server.
1122    /// Run with: cargo test -p orcs-lua --lib llm_command::tests::e2e_ollama_multi_turn -- --ignored --nocapture
1123    #[test]
1124    #[ignore = "requires running Ollama server"]
1125    fn e2e_ollama_multi_turn() {
1126        let lua = Lua::new();
1127
1128        // Turn 1
1129        let opts1 = lua.create_table().expect("create opts");
1130        opts1.set("provider", "ollama").expect("set provider");
1131        opts1.set("model", "qwen2.5-coder:1.5b").expect("set model");
1132        opts1.set("timeout", 30u64).expect("set timeout");
1133        opts1
1134            .set("system_prompt", "You are a helpful assistant. Be concise.")
1135            .expect("set system_prompt");
1136
1137        let r1 = llm_request_impl(
1138            &lua,
1139            (
1140                "My name is ORCS_TEST_USER. Remember it.".into(),
1141                Some(opts1),
1142            ),
1143        )
1144        .expect("turn 1 should not panic");
1145
1146        assert!(
1147            r1.get::<bool>("ok").expect("get ok"),
1148            "turn 1 should succeed"
1149        );
1150        let sid: String = r1.get("session_id").expect("get session_id");
1151        let content1: String = r1.get("content").expect("get content");
1152        eprintln!("[E2E turn 1] session={sid} content: {content1}");
1153
1154        // Turn 2: use same session
1155        let opts2 = lua.create_table().expect("create opts");
1156        opts2.set("provider", "ollama").expect("set provider");
1157        opts2.set("model", "qwen2.5-coder:1.5b").expect("set model");
1158        opts2.set("timeout", 30u64).expect("set timeout");
1159        opts2
1160            .set("session_id", sid.as_str())
1161            .expect("set session_id");
1162
1163        let r2 = llm_request_impl(&lua, ("What is my name?".into(), Some(opts2)))
1164            .expect("turn 2 should not panic");
1165
1166        assert!(
1167            r2.get::<bool>("ok").expect("get ok"),
1168            "turn 2 should succeed"
1169        );
1170        let sid2: String = r2.get("session_id").expect("get session_id");
1171        assert_eq!(sid, sid2, "session_id should be preserved across turns");
1172
1173        let content2: String = r2.get("content").expect("get content");
1174        eprintln!("[E2E turn 2] content: {content2}");
1175
1176        // Verify session store has history
1177        let store = lua
1178            .app_data_ref::<SessionStore>()
1179            .expect("store should exist");
1180        let history = store.0.get(&sid).expect("session should exist");
1181        // Turn 1: user + assistant = 2, Turn 2: user + assistant = 2, total = 4
1182        assert_eq!(
1183            history.len(),
1184            4,
1185            "session should have 4 messages (2 turns), got: {}",
1186            history.len()
1187        );
1188    }
1189}