Add native LLM calling functions (llm_call, llm_parallel, llm_configure) to El VM
This commit is contained in:
@@ -5996,6 +5996,110 @@ fn dispatch_builtin(
|
|||||||
stack.push(Value::Nil); BuiltinResult::Handled
|
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<String> = 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<String> = 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,
|
_ => BuiltinResult::NotBuiltin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6132,6 +6236,167 @@ thread_local! {
|
|||||||
static OBSERVER_ID_COUNTER: std::cell::Cell<i64> = std::cell::Cell::new(1);
|
static OBSERVER_ID_COUNTER: std::cell::Cell<i64> = std::cell::Cell::new(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// LLM model name → (endpoint_url, api_key)
|
||||||
|
static LLM_CONFIG: std::cell::RefCell<std::collections::HashMap<String, (String, String)>> =
|
||||||
|
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::<serde_json::Value>() {
|
||||||
|
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::<serde_json::Value>() {
|
||||||
|
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::<serde_json::Value>() {
|
||||||
|
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).
|
/// 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 {
|
fn cmp_values(a: &el_compiler::Value, b: &el_compiler::Value) -> std::cmp::Ordering {
|
||||||
use el_compiler::Value;
|
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"
|
| "time_add" | "time_diff" | "time_start_of" | "time_tz_offset" | "time_to_tz"
|
||||||
// Observer
|
// Observer
|
||||||
| "observe" | "unobserve"
|
| "observe" | "unobserve"
|
||||||
|
// LLM native functions
|
||||||
|
| "llm_call" | "llm_call_system" | "llm_stream" | "llm_parallel" | "llm_models" | "llm_configure"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user