///|
/// Adapter for Google's Gemini `generateContent` API.
///
/// Gemini's native request/response shape differs from OpenAI's:
///
/// - Messages are `contents`, each `{role, parts:[{text}|{inline_data}]}`.
/// - Roles are `user` and `model` (not `assistant`); there is no `system`
/// role — the system prompt goes in a top-level `systemInstruction`.
/// - The response is `candidates[].content.parts[].text`, with token usage in
/// `usageMetadata`.
///
/// This module converts a common `ChatRequest` into a Gemini body and parses a
/// Gemini response back into the common `ChatResponse`.
///|
/// Build a Gemini `generateContent` request body from a `ChatRequest`.
pub fn gemini_request_body(request : ChatRequest) -> Json {
let contents = []
let system = StringBuilder::new()
for msg in request.messages {
match msg.role {
System =>
match msg.content {
Str(s) => {
if system.to_string().length() > 0 {
system.write_string("\n\n")
}
system.write_string(s)
}
Parts(_) => ()
}
_ =>
contents.push(
Json::object({
"role": Json::string(gemini_role(msg.role)),
"parts": gemini_parts(msg.content),
}),
)
}
}
let obj : Map[String, Json] = { "contents": Json::array(contents) }
let sys = system.to_string()
if sys.length() > 0 {
obj["systemInstruction"] = Json::object({
"parts": Json::array([Json::object({ "text": Json::string(sys) })]),
})
}
// Generation config maps the sampling params.
let gen_config : Map[String, Json] = {}
if request.temperature is Some(t) {
gen_config["temperature"] = Json::number(t)
}
if request.top_p is Some(p) {
gen_config["topP"] = Json::number(p)
}
if request.max_tokens is Some(m) {
gen_config["maxOutputTokens"] = Json::number(m.to_double())
}
if request.stop is Some(s) {
gen_config["stopSequences"] = s.to_json()
}
if gen_config.length() > 0 {
obj["generationConfig"] = Json::object(gen_config)
}
Json::object(obj)
}
///|
/// Map a common role to a Gemini role (`user` or `model`).
fn gemini_role(role : Role) -> String {
match role {
Assistant => "model"
_ => "user"
}
}
///|
/// Convert message content into Gemini `parts`.
fn gemini_parts(content : Content) -> Json {
match content {
Str(s) => Json::array([Json::object({ "text": Json::string(s) })])
Parts(parts) => {
let arr = []
for p in parts {
match p {
Text(t) => arr.push(Json::object({ "text": Json::string(t) }))
ImageUrl(u) =>
// Gemini uses fileData for URLs (best-effort mime).
arr.push(
Json::object({
"fileData": Json::object({
"mimeType": Json::string("image/*"),
"fileUri": Json::string(u),
}),
}),
)
}
}
Json::array(arr)
}
}
}
///|
/// Parse a Gemini `generateContent` response into the common `ChatResponse`.
pub fn parse_gemini_response(json : Json) -> ChatResponse raise LLMError {
guard json is Object(obj) else {
raise Decode("gemini response: expected object")
}
let text = StringBuilder::new()
let mut finish_reason : String? = None
match obj.get("candidates") {
Some(Array(cands)) =>
if cands.get(0) is Some(Object(cand)) {
match cand.get("content") {
Some(Object(content)) =>
match content.get("parts") {
Some(Array(parts)) =>
for part in parts {
match part {
Object(p) =>
match p.get("text") {
Some(String(t)) => text.write_string(t)
_ => ()
}
_ => ()
}
}
_ => ()
}
_ => ()
}
match cand.get("finishReason") {
Some(String(r)) => finish_reason = Some(gemini_finish_reason(r))
_ => ()
}
}
_ => ()
}
let usage = match obj.get("usageMetadata") {
Some(Object(u)) => {
let prompt = match u.get("promptTokenCount") {
Some(Number(n, ..)) => n.to_int()
_ => 0
}
let completion = match u.get("candidatesTokenCount") {
Some(Number(n, ..)) => n.to_int()
_ => 0
}
let total = match u.get("totalTokenCount") {
Some(Number(n, ..)) => n.to_int()
_ => prompt + completion
}
Some(Usage::{
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: total,
})
}
_ => None
}
let message = Message::assistant(text.to_string())
{
id: "",
object: "chat.completion",
created: 0L,
model: "",
choices: [{ index: 0, message, finish_reason }],
usage,
system_fingerprint: None,
}
}
///|
/// Map a Gemini `finishReason` to the OpenAI-style `finish_reason`.
fn gemini_finish_reason(reason : String) -> String {
match reason {
"STOP" => "stop"
"MAX_TOKENS" => "length"
"SAFETY" => "content_filter"
"RECITATION" => "content_filter"
other => other
}
}