blob: 206352bc0b05a70d845e14107cd0fcf65a6a8243 [file]
//! One prompt to one small model through the egress, in whichever request shape the
//! destination speaks: Messages, chat completions, or generateContent. What mining and
//! the recall deputy share.
use serde_json::{json, Value};
/// The default model per destination, small and cheap: a librarian's or a deputy's job.
#[must_use]
pub fn default_model(destination: &str) -> &'static str {
match destination {
"anthropic" | "llmao" => "claude-haiku-4-5-20251001",
"gemini" | "google" => "gemini-2.5-flash",
"xai" => "grok-4-fast",
"dashscope" => "qwen-flash",
_ => "gpt-5-mini",
}
}
/// The model to use on a destination: the one given, else for the gateway the one
/// `LLMAO_MODEL` names, else the smallest-looking model the gateway lists, else the
/// destination's default. A gateway serves whatever its operator put behind it, so a
/// fixed name is a guess that fails with a 400 on a gateway that does not serve it; the
/// Foundation's serves open models only.
pub async fn resolve(egress: &dm_egress::Egress, destination: &str, given: Option<&str>) -> String {
if let Some(m) = given {
return m.to_string();
}
if destination == "llmao" {
if let Ok(m) = std::env::var("LLMAO_MODEL") {
if !m.trim().is_empty() {
return m.trim().to_string();
}
}
if let Ok(v) = egress.get(destination, "v1/models").await {
if let Some(m) = smallest(&model_ids(&v)) {
return m;
}
}
}
default_model(destination).to_string()
}
/// The model a name says is small, by the markers vendors use, else the first listed.
#[must_use]
pub fn smallest(ids: &[String]) -> Option<String> {
const SMALL: [&str; 8] = ["haiku", "mini", "flash", "lite", "small", "8b", "7b", "4b"];
SMALL
.iter()
.find_map(|marker| ids.iter().find(|id| id.to_lowercase().contains(marker)))
.or_else(|| ids.first())
.cloned()
}
/// Model ids out of a listing in OpenAI's shape (`data[].id`), Google's
/// (`models[].name`, prefixed `models/`) or a plain `models[].id`.
#[must_use]
pub fn model_ids(v: &Value) -> Vec<String> {
let items = v
.get("data")
.or_else(|| v.get("models"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut ids: Vec<String> = items
.iter()
.filter_map(|m| {
m.get("id")
.or_else(|| m.get("name"))
.and_then(Value::as_str)
.map(|s| s.trim_start_matches("models/").to_string())
})
.collect();
ids.sort();
ids.dedup();
ids
}
/// The path and body of one user-turn request for a destination's API shape.
#[must_use]
pub fn request(destination: &str, model: &str, prompt: &str) -> (String, Value) {
match destination {
"anthropic" => (
"v1/messages".into(),
json!({ "model": model, "max_tokens": 4000, "messages": [{ "role": "user", "content": prompt }] }),
),
"gemini" | "google" => (
format!("v1beta/models/{model}:generateContent"),
json!({ "contents": [{ "role": "user", "parts": [{ "text": prompt }] }] }),
),
_ => (
"v1/chat/completions".into(),
json!({ "model": model, "messages": [{ "role": "user", "content": prompt }] }),
),
}
}
/// As [`request`], with the sampling pinned in the destination's own names: what the
/// drift probes send, so a prompt is asked the same way on every run. Mining, the
/// checker and the deputy keep the destination's defaults through [`request`].
#[must_use]
pub fn request_sampled(
destination: &str,
model: &str,
prompt: &str,
temperature: f64,
max_tokens: u32,
) -> (String, Value) {
let (path, mut body) = request(destination, model, prompt);
match destination {
"gemini" | "google" => {
body["generationConfig"] =
json!({ "temperature": temperature, "maxOutputTokens": max_tokens });
}
// OpenAI's own API counts output in max_completion_tokens; the gateways and the
// other chat-completions destinations still take max_tokens
"openai" => {
body["temperature"] = json!(temperature);
body["max_completion_tokens"] = json!(max_tokens);
}
_ => {
body["temperature"] = json!(temperature);
body["max_tokens"] = json!(max_tokens);
}
}
(path, body)
}
/// The model's text out of a response in any of the shapes: Messages, chat
/// completions, generateContent, and the Responses API's `output` items, whichever
/// destination served it.
#[must_use]
pub fn answer_text(destination: &str, v: &Value) -> String {
if let Some(items) = v.get("output").and_then(Value::as_array) {
return items
.iter()
.filter_map(|i| i.get("content").and_then(Value::as_array))
.flatten()
.filter(|c| c.get("type").and_then(Value::as_str) == Some("output_text"))
.filter_map(|c| c.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
}
let path = match destination {
"anthropic" => v.pointer("/content/0/text"),
"gemini" | "google" => v.pointer("/candidates/0/content/parts/0/text"),
_ => v.pointer("/choices/0/message/content"),
};
path.and_then(Value::as_str).unwrap_or_default().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_follow_the_destination_shape() {
let (p, b) = request("anthropic", "claude-haiku-4-5-20251001", "ask");
assert_eq!(p, "v1/messages");
assert_eq!(b["model"], "claude-haiku-4-5-20251001");
assert_eq!(b["messages"][0]["content"], "ask");
let (p, b) = request("gemini", "gemini-2.5-flash", "ask");
assert_eq!(p, "v1beta/models/gemini-2.5-flash:generateContent");
assert_eq!(b["contents"][0]["parts"][0]["text"], "ask");
let (p, b) = request("llmao", "m", "ask");
assert_eq!(p, "v1/chat/completions");
assert_eq!(b["model"], "m");
assert_eq!(default_model("anthropic"), "claude-haiku-4-5-20251001");
assert_eq!(default_model("openai"), "gpt-5-mini");
}
#[test]
fn the_smallest_listed_model_is_the_default_on_a_gateway() {
let ids = |v: &[&str]| v.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
assert_eq!(
smallest(&ids(&["gemma4-26b", "qwen3-8b", "qwen3.8-27b"])).as_deref(),
Some("qwen3-8b")
);
assert_eq!(
smallest(&ids(&["claude-haiku-4-5", "gpt-5-mini"])).as_deref(),
Some("claude-haiku-4-5")
);
assert_eq!(smallest(&ids(&["big-model"])).as_deref(), Some("big-model"));
assert_eq!(smallest(&[]), None);
let listing = serde_json::json!({"data": [{"id": "b"}, {"id": "a"}, {"id": "a"}]});
assert_eq!(model_ids(&listing), ids(&["a", "b"]));
}
#[test]
fn sampled_requests_pin_temperature_and_length_in_each_shape() {
let (_, b) = request_sampled("anthropic", "m", "ask", 0.0, 64);
assert_eq!(b["temperature"], 0.0);
assert_eq!(b["max_tokens"], 64);
let (_, b) = request_sampled("google", "m", "ask", 0.0, 64);
assert_eq!(b["generationConfig"]["maxOutputTokens"], 64);
let (_, b) = request_sampled("openai", "m", "ask", 0.0, 64);
assert_eq!(b["max_completion_tokens"], 64);
let (p, b) = request_sampled("llmao", "m", "ask", 0.0, 64);
assert_eq!(p, "v1/chat/completions");
assert_eq!(
(b["temperature"].as_f64(), b["max_tokens"].as_u64()),
(Some(0.0), Some(64))
);
assert_eq!(b["messages"][0]["content"], "ask");
// the unsampled request is what it was
assert!(request("llmao", "m", "ask").1.get("temperature").is_none());
}
#[test]
fn a_responses_api_answer_is_read_from_its_output_items() {
let v = serde_json::json!({"output": [
{"type": "reasoning", "content": [{"type": "reasoning_text", "text": "think"}]},
{"type": "message", "content": [{"type": "output_text", "text": "DR-19 is open."}]}
]});
assert_eq!(answer_text("llmao", &v), "DR-19 is open.");
}
#[test]
fn answers_are_read_from_each_shape() {
let a = json!({ "content": [{ "type": "text", "text": "hi" }] });
assert_eq!(answer_text("anthropic", &a), "hi");
let g = json!({ "candidates": [{ "content": { "parts": [{ "text": "g" }] } }] });
assert_eq!(answer_text("google", &g), "g");
let o = json!({ "choices": [{ "message": { "content": "o" } }] });
assert_eq!(answer_text("openai", &o), "o");
assert_eq!(answer_text("openai", &json!({ "error": "x" })), "");
}
}