From 7bdfb9bbcdaecf4a476bc69ad0e7ade2d5aa791f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Wed, 29 Apr 2026 04:04:11 -0500 Subject: [PATCH] Add native LLM calling functions (llm_call, llm_parallel, llm_configure) to El VM --- bin/el/src/main.rs | 267 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) diff --git a/bin/el/src/main.rs b/bin/el/src/main.rs index d9933db..483e3ff 100644 --- a/bin/el/src/main.rs +++ b/bin/el/src/main.rs @@ -5996,6 +5996,110 @@ fn dispatch_builtin( stack.push(Value::Nil); BuiltinResult::Handled } + // ── LLM native functions ────────────────────────────────────────────── + + // llm_call(model, prompt) -> String + "llm_call" => { + let prompt = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + let model = match stack.pop().unwrap_or(Value::Str("claude".to_string())) { + Value::Str(s) => s, + _ => "claude".to_string(), + }; + let result = call_llm_blocking(&model, None, &prompt); + stack.push(Value::Str(result)); + BuiltinResult::Handled + } + + // llm_call_system(model, system, prompt) -> String + "llm_call_system" => { + let prompt = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + let system = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + let model = match stack.pop().unwrap_or(Value::Str("claude".to_string())) { + Value::Str(s) => s, + _ => "claude".to_string(), + }; + let result = call_llm_blocking(&model, Some(&system), &prompt); + stack.push(Value::Str(result)); + BuiltinResult::Handled + } + + // llm_stream(model, prompt) -> String (blocking for now; streaming is future work) + "llm_stream" => { + let prompt = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + let model = match stack.pop().unwrap_or(Value::Str("claude".to_string())) { + Value::Str(s) => s, + _ => "claude".to_string(), + }; + let result = call_llm_blocking(&model, None, &prompt); + stack.push(Value::Str(result)); + BuiltinResult::Handled + } + + // llm_parallel(models: List, prompt: String) -> Map + "llm_parallel" => { + let prompt = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + let models: Vec = match stack.pop().unwrap_or(Value::List(vec![])) { + Value::List(l) => l.into_iter().filter_map(|v| { + if let Value::Str(s) = v { Some(s) } else { None } + }).collect(), + _ => vec![], + }; + let mut pairs: Vec<(String, Value)> = Vec::new(); + for model in models { + let response = call_llm_blocking(&model, None, &prompt); + pairs.push((model, Value::Str(response))); + } + stack.push(Value::Map(pairs)); + BuiltinResult::Handled + } + + // llm_models() -> List + "llm_models" => { + let mut model_names: Vec = LLM_CONFIG.with(|c| { + c.borrow().keys().cloned().collect() + }); + model_names.sort(); + let list = model_names.into_iter().map(Value::Str).collect(); + stack.push(Value::List(list)); + BuiltinResult::Handled + } + + // llm_configure(name, endpoint, api_key) -> Bool + "llm_configure" => { + let api_key = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + let endpoint = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + let name = match stack.pop().unwrap_or(Value::Str(String::new())) { + Value::Str(s) => s, + _ => String::new(), + }; + LLM_CONFIG.with(|c| { + c.borrow_mut().insert(name, (endpoint, api_key)); + }); + stack.push(Value::Bool(true)); + BuiltinResult::Handled + } + _ => BuiltinResult::NotBuiltin, } } @@ -6132,6 +6236,167 @@ thread_local! { static OBSERVER_ID_COUNTER: std::cell::Cell = std::cell::Cell::new(1); } +thread_local! { + /// LLM model name → (endpoint_url, api_key) + static LLM_CONFIG: std::cell::RefCell> = + std::cell::RefCell::new({ + let mut m = std::collections::HashMap::new(); + // Claude via Anthropic API + m.insert("claude".to_string(), ( + "https://api.anthropic.com/v1/messages".to_string(), + std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), + )); + m.insert("claude-3-5-sonnet".to_string(), ( + "https://api.anthropic.com/v1/messages".to_string(), + std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), + )); + m.insert("claude-opus".to_string(), ( + "https://api.anthropic.com/v1/messages".to_string(), + std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), + )); + // OpenAI + m.insert("gpt4o".to_string(), ( + "https://api.openai.com/v1/chat/completions".to_string(), + std::env::var("OPENAI_API_KEY").unwrap_or_default(), + )); + m.insert("gpt4".to_string(), ( + "https://api.openai.com/v1/chat/completions".to_string(), + std::env::var("OPENAI_API_KEY").unwrap_or_default(), + )); + // Local Ollama + m.insert("ollama".to_string(), ( + "http://localhost:11434/api/chat".to_string(), + String::new(), + )); + // Neuron (the platform itself) + m.insert("neuron".to_string(), ( + std::env::var("NEURON_ENDPOINT") + .unwrap_or_else(|_| "https://api.neurontechnologies.ai/v1/chat/completions".to_string()), + std::env::var("NEURON_API_KEY").unwrap_or_default(), + )); + m + }); +} + +// ── LLM helper functions ────────────────────────────────────────────────────── + +/// Map a short model name to the actual Anthropic model id string. +fn get_anthropic_model(name: &str) -> &'static str { + match name { + "claude-opus" | "claude-3-opus" => "claude-opus-4-5", + "claude-haiku" => "claude-haiku-4-5", + _ => "claude-sonnet-4-5", + } +} + +/// Call an LLM synchronously. Returns the response text (or an error string). +/// Supports Anthropic, Ollama, and OpenAI-compatible endpoints based on URL. +fn call_llm_blocking(model: &str, system: Option<&str>, prompt: &str) -> String { + let (endpoint, api_key) = LLM_CONFIG.with(|c| { + c.borrow() + .get(model) + .cloned() + .unwrap_or_else(|| ("http://localhost:11434/api/chat".to_string(), String::new())) + }); + + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + { + Ok(c) => c, + Err(e) => return format!("llm_call error: {}", e), + }; + + if endpoint.contains("anthropic.com") { + // Anthropic Messages API + let body = if let Some(sys) = system { + serde_json::json!({ + "model": get_anthropic_model(model), + "max_tokens": 4096, + "system": sys, + "messages": [{"role": "user", "content": prompt}] + }) + } else { + serde_json::json!({ + "model": get_anthropic_model(model), + "max_tokens": 4096, + "messages": [{"role": "user", "content": prompt}] + }) + }; + match client + .post(&endpoint) + .header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + { + Ok(r) => match r.json::() { + Ok(v) => v["content"][0]["text"].as_str().unwrap_or("").to_string(), + Err(e) => format!("llm_call error: {}", e), + }, + Err(e) => format!("llm_call error: {}", e), + } + } else if endpoint.contains("ollama") || endpoint.contains("11434") { + // Ollama chat API + let model_name = model.trim_start_matches("ollama:").to_string(); + let messages_arr = if let Some(sys) = system { + serde_json::json!([ + {"role": "system", "content": sys}, + {"role": "user", "content": prompt} + ]) + } else { + serde_json::json!([{"role": "user", "content": prompt}]) + }; + let body = serde_json::json!({ + "model": if model_name == "ollama" { "llama3.2".to_string() } else { model_name }, + "messages": messages_arr, + "stream": false + }); + match client.post(&endpoint).json(&body).send() { + Ok(r) => match r.json::() { + Ok(v) => v["message"]["content"].as_str().unwrap_or("").to_string(), + Err(e) => format!("llm_call error: {}", e), + }, + Err(e) => format!("llm_call error: {}", e), + } + } else { + // OpenAI-compatible format (OpenAI, Neuron, and most others) + let messages_arr = if let Some(sys) = system { + serde_json::json!([ + {"role": "system", "content": sys}, + {"role": "user", "content": prompt} + ]) + } else { + serde_json::json!([{"role": "user", "content": prompt}]) + }; + let model_id = model + .replace("gpt4o", "gpt-4o") + .replace("gpt4", "gpt-4-turbo"); + let body = serde_json::json!({ + "model": model_id, + "messages": messages_arr, + "max_tokens": 4096 + }); + match client + .post(&endpoint) + .header("Authorization", format!("Bearer {}", api_key)) + .header("content-type", "application/json") + .json(&body) + .send() + { + Ok(r) => match r.json::() { + Ok(v) => v["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .to_string(), + Err(e) => format!("llm_call error: {}", e), + }, + Err(e) => format!("llm_call error: {}", e), + } + } +} + /// Compare two runtime values for ordering (used by Lt/Gt/LtEq/GtEq). fn cmp_values(a: &el_compiler::Value, b: &el_compiler::Value) -> std::cmp::Ordering { use el_compiler::Value; @@ -6192,6 +6457,8 @@ fn is_builtin(name: &str) -> bool { | "time_add" | "time_diff" | "time_start_of" | "time_tz_offset" | "time_to_tz" // Observer | "observe" | "unobserve" + // LLM native functions + | "llm_call" | "llm_call_system" | "llm_stream" | "llm_parallel" | "llm_models" | "llm_configure" ) }