1mod 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
62const DEFAULT_TIMEOUT_SECS: u64 = 120;
64
65const ANTHROPIC_DEFAULT_MAX_TOKENS: u64 = 4096;
67
68const MAX_BODY_SIZE: u64 = 10 * 1024 * 1024;
70
71const DEFAULT_MAX_RETRIES: u32 = 2;
73
74const RETRY_BASE_DELAY_MS: u64 = 1000;
76
77const RETRY_MAX_DELAY_SECS: u64 = 30;
79
80const DEFAULT_MAX_TOOL_TURNS: u32 = 10;
82
83#[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 pub tools: bool,
100 pub resolve: bool,
105 pub max_tool_turns: u32,
107}
108
109impl LlmOpts {
110 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 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 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
179const PING_TIMEOUT_SECS: u64 = 5;
183
184pub fn llm_ping_impl(lua: &Lua, opts: Option<Table>) -> mlua::Result<Table> {
207 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 let url = format!(
245 "{}{}",
246 base_url.trim_end_matches('/'),
247 provider.health_path()
248 );
249
250 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 let start = std::time::Instant::now();
258
259 let mut req = agent.get(&url);
260 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 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
308pub 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 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 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 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
373pub fn llm_request_impl(lua: &Lua, args: (String, Option<Table>)) -> mlua::Result<Table> {
400 let (prompt, opts) = args;
401
402 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 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 let session_id = resolve_session_id(lua, &llm_opts.session_id);
435
436 let tools_json = if llm_opts.tools {
438 build_tools_for_provider(lua, llm_opts.provider)
439 } else {
440 None
441 };
442
443 let url = format!(
445 "{}{}",
446 llm_opts.base_url.trim_end_matches('/'),
447 llm_opts.provider.chat_path()
448 );
449
450 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 let mut messages = build_messages(lua, &session_id, &prompt, &llm_opts);
458
459 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 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 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 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 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 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 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#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[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 #[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 #[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 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 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 let json: String = lua
765 .load(r#"return orcs.llm_dump_sessions()"#)
766 .eval()
767 .expect("dump should succeed");
768
769 let _ = lua.remove_app_data::<SessionStore>();
771
772 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 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 #[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 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 #[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 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 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 let result =
978 llm_request_impl(&lua, ("hello".into(), Some(opts))).expect("should not panic");
979
980 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 #[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 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 #[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 assert!(
1043 elapsed < Duration::from_secs(5),
1044 "should not retry, elapsed: {:?}",
1045 elapsed
1046 );
1047 }
1048
1049 #[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 #[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 #[test]
1124 #[ignore = "requires running Ollama server"]
1125 fn e2e_ollama_multi_turn() {
1126 let lua = Lua::new();
1127
1128 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 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 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 assert_eq!(
1183 history.len(),
1184 4,
1185 "session should have 4 messages (2 turns), got: {}",
1186 history.len()
1187 );
1188 }
1189}