rust_llm 2.1.1

One API for chat, tools, agents, structured output, embeddings, images, audio, batches, MCP, and judgments across LLM providers.
Documentation
//! Ports of the render/parse examples in `spec/ruby_llm/protocols/{gemini,chat_completions}/
//! transcription_spec.rb` and `protocols/gemini/file_transcription_spec.rb`, against the private
//! seams those specs call with `send`.

use serde_json::{Value, json};

use super::*;

fn request<'a>(model: &'a str, provider_options: &'a Value) -> Request<'a> {
    Request {
        model,
        language: None,
        prompt: None,
        temperature: None,
        format: None,
        speaker_names: None,
        speaker_references: None,
        provider_options,
    }
}

fn ruby_wav() -> Attachment {
    let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/ruby.wav");
    Attachment::from_bytes(std::fs::read(path).unwrap(), "ruby.wav", None)
}

// ---- protocols/gemini/transcription_spec.rb ------------------------------------------------

// spec: protocols/gemini/transcription_spec.rb:29 #render_transcription_payload > passes the requested format and temperature through
#[test]
fn gemini_passes_the_requested_format_and_temperature_through() {
    let options = json!({});
    let r = Request {
        format: Some("application/json"),
        temperature: Some(0.2),
        ..request("gemini-2.5-flash", &options)
    };
    let payload = gemini_payload(&ruby_wav(), &r).unwrap();
    assert_eq!(
        payload["generationConfig"],
        json!({ "responseMimeType": "application/json", "temperature": 0.2 })
    );
}

// spec: protocols/gemini/transcription_spec.rb:37 #render_transcription_payload > refuses an attachment that is not audio
#[test]
fn gemini_refuses_an_attachment_that_is_not_audio() {
    let notes = Attachment::from_bytes(b"not audio".to_vec(), "notes.txt", None);
    let options = json!({});
    let error = gemini_payload(&notes, &request("gemini-2.5-flash", &options)).unwrap_err();
    assert!(
        matches!(error, Error::UnsupportedAttachment(_)),
        "{error:?}"
    );
}

/// `parse(body)`: `parse_transcription_response` on a 200 response with this body.
fn parse(body: &Value) -> Result<Transcription> {
    parse_gemini(body, 200, "gemini-2.5-flash")
}

/// The `usage` the blocked-response examples share.
fn blocked_usage() -> Value {
    json!({ "promptTokenCount": 133, "totalTokenCount": 133,
            "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 14 },
                                    { "modality": "AUDIO", "tokenCount": 119 }] })
}

fn content_filter_message(result: Result<Transcription>) -> (String, Option<ErrorResponse>) {
    match result {
        Err(Error::ContentFilter(message, response)) => (message, response),
        other => panic!("expected ContentFilterError, got {other:?}"),
    }
}

// spec: protocols/gemini/transcription_spec.rb:71 #parse_transcription_response > leaves the text nil when the response is not a JSON object
#[test]
fn gemini_leaves_the_text_nil_when_the_response_is_not_a_json_object() {
    assert_eq!(parse(&json!("not a hash")).unwrap().text, None);
}

// spec: protocols/gemini/transcription_spec.rb:75 #parse_transcription_response > leaves the text nil when the candidate carries no text parts
#[test]
fn gemini_leaves_the_text_nil_when_the_candidate_carries_no_text_parts() {
    let inline = json!({ "candidates": [{ "content": { "parts": [{ "inlineData": {} }] } }] });
    assert_eq!(parse(&inline).unwrap().text, None);
    let empty = json!({ "candidates": [{ "content": {} }] });
    assert_eq!(parse(&empty).unwrap().text, None);
}

// spec: protocols/gemini/transcription_spec.rb:80 #parse_transcription_response > leaves the token counts nil when the response carries no usage
#[test]
fn gemini_leaves_the_token_counts_nil_when_the_response_carries_no_usage() {
    let data = json!({ "candidates": [{ "content": { "parts": [{ "text": "hi" }] } }] });
    let t = parse(&data).unwrap();
    assert_eq!(t.tokens().input, None);
    assert_eq!(t.tokens().output, None);
}

// spec: protocols/gemini/transcription_spec.rb:87 #parse_transcription_response > raises with the block reason when Gemini blocks the audio prompt
#[test]
fn gemini_raises_with_the_block_reason_when_gemini_blocks_the_audio_prompt() {
    let body = json!({ "promptFeedback": { "blockReason": "SAFETY" }, "usageMetadata": blocked_usage(),
                       "modelVersion": "gemini-3.8-flash", "responseId": "b2WsavzrBt3ZxN8PmJyl6Aw" });

    let (message, response) = content_filter_message(parse(&body));

    assert_eq!(message, "Gemini blocked the transcription: SAFETY");
    let response = response.expect("the error carries the response");
    assert_eq!(serde_json::from_str::<Value>(&response.body).unwrap(), body);
}

// spec: protocols/gemini/transcription_spec.rb:97 #parse_transcription_response > raises with the finish reason when Gemini blocks the transcript
#[test]
fn gemini_raises_with_the_finish_reason_when_gemini_blocks_the_transcript() {
    let body = json!({
        "candidates": [{
            "finishReason": "SAFETY", "index": 0,
            "safetyRatings": [
                { "category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE" },
                { "category": "HARM_CATEGORY_HARASSMENT", "probability": "MEDIUM", "blocked": true }
            ]
        }],
        "usageMetadata": blocked_usage(), "modelVersion": "gemini-3.8-flash"
    });

    let (message, _) = content_filter_message(parse(&body));

    assert_eq!(message, "Gemini blocked the transcription: SAFETY");
}

// spec: protocols/gemini/transcription_spec.rb:112 #parse_transcription_response > raises when Gemini returns no candidates
#[test]
fn gemini_raises_when_gemini_returns_no_candidates() {
    let body = json!({ "usageMetadata": blocked_usage(), "modelVersion": "gemini-3.8-flash" });

    let (message, _) = content_filter_message(parse(&body));

    assert_eq!(message, "Gemini blocked the transcription");
}

// spec: protocols/gemini/transcription_spec.rb:117 #parse_transcription_response > returns an empty transcript for silent audio
#[test]
fn gemini_returns_an_empty_transcript_for_silent_audio() {
    let body = json!({ "candidates": [{ "content": { "role": "model", "parts": [{ "text": "" }] },
                                        "finishReason": "STOP", "index": 0 }],
                       "usageMetadata": blocked_usage(), "modelVersion": "gemini-3.8-flash" });

    assert_eq!(parse(&body).unwrap().text.as_deref(), Some(""));
}

// spec: protocols/gemini/transcription_spec.rb:125 #parse_transcription_response > keeps the text a blocked transcript already carries
#[test]
fn gemini_keeps_the_text_a_blocked_transcript_already_carries() {
    let body = json!({ "candidates": [{ "content": { "role": "model", "parts": [{ "text": "Guten Tag" }] },
                                        "finishReason": "SAFETY", "index": 0 }],
                       "usageMetadata": blocked_usage(), "modelVersion": "gemini-3.8-flash" });

    assert_eq!(parse(&body).unwrap().text.as_deref(), Some("Guten Tag"));
}

// ---- protocols/gemini/file_transcription_spec.rb -------------------------------------------

// spec: protocols/gemini/file_transcription_spec.rb:63 preserves Interactions speaker-only annotations without fabricating timing
#[test]
fn interactions_preserve_speaker_only_annotations_without_fabricating_timing() {
    let data = json!({ "status": "completed", "steps": [{ "type": "model_output", "content": [
        { "type": "text", "text": "Hello", "annotations": [
            { "type": "word_info", "text": "Hello", "speaker": "spk:0" }
        ] }
    ] }], "usage": { "total_input_tokens": 14, "total_output_tokens": 0 } });

    let t = parse_interactions(&data, "gemini-3.5-transcribe").unwrap();

    assert_eq!(t.text.as_deref(), Some("Hello"));
    assert_eq!(
        t.words,
        Some(vec![json!({ "word": "Hello", "speaker": "spk:0" })])
    );
    assert_eq!((t.tokens().input, t.tokens().output), (Some(14), Some(0)));
    assert_eq!(t.duration, None);
}

// spec: protocols/gemini/file_transcription_spec.rb:109 leaves combining custom vocabulary with diarization to Gemini
#[test]
fn interactions_leave_combining_custom_vocabulary_with_diarization_to_gemini() {
    let options = json!({});
    let names = vec!["Alice".to_string()];
    let r = Request {
        speaker_names: Some(&names),
        prompt: Some("RubyLLM"),
        ..request("gemini-3.5-transcribe", &options)
    };

    let payload = interactions_payload(&ruby_wav(), &r).unwrap();

    let config = &payload["generation_config"]["transcription_config"];
    assert_eq!(config["custom_vocabulary"], json!(["RubyLLM"]));
    assert_eq!(
        config["mode"],
        json!({ "type": "verbatim", "diarization_mode": "speaker" })
    );
}

// spec: protocols/gemini/file_transcription_spec.rb:118 rejects unknown granularities and unsupported reference clips instead of ignoring them
// (The reference-clip half transcribes through Vertex AI, a provider this port leaves out.)
#[test]
fn interactions_reject_unknown_granularities() {
    let error = Family::Interactions
        .render_options(Some(&["segment"]), None, false)
        .unwrap_err();
    assert!(
        matches!(&error, Error::Argument(m) if m.contains("must be word")),
        "{error:?}"
    );
}

// ---- protocols/chat_completions/transcription_spec.rb --------------------------------------

// spec: protocols/chat_completions/transcription_spec.rb:19 .render_transcription_payload > keeps the format the caller asked for on a diarize model
#[test]
fn chat_completions_keeps_the_format_the_caller_asked_for_on_a_diarize_model() {
    let options = json!({});
    let r = Request {
        format: Some("json"),
        ..request("gpt-4o-transcribe-diarize", &options)
    };
    let payload = multipart_payload(Family::OpenAI, &r).unwrap();
    assert_eq!(payload["response_format"], json!("json"));
}

// spec: protocols/chat_completions/transcription_spec.rb:33 .render_transcription_payload > maps format to response_format
#[test]
fn chat_completions_maps_format_to_response_format() {
    let options = json!({});
    let r = Request {
        format: Some("verbose_json"),
        ..request("whisper-1", &options)
    };
    let payload = multipart_payload(Family::OpenAI, &r).unwrap();
    assert_eq!(payload["response_format"], json!("verbose_json"));
}

// spec: protocols/chat_completions/transcription_spec.rb:41 .render_transcription_payload > maps speaker names and references to the known speaker fields
#[test]
fn chat_completions_maps_speaker_names_and_references_to_the_known_speaker_fields() {
    let options = json!({});
    let names = vec!["Alice".to_string()];
    let references = vec![ruby_wav()];
    let r = Request {
        speaker_names: Some(&names),
        speaker_references: Some(&references),
        ..request("gpt-4o-transcribe-diarize", &options)
    };
    let payload = multipart_payload(Family::OpenAI, &r).unwrap();
    assert_eq!(payload["known_speaker_names"], json!(["Alice"]));
    let refs = payload["known_speaker_references"].as_array().unwrap();
    assert_eq!(refs.len(), 1);
    assert!(
        refs[0]
            .as_str()
            .unwrap()
            .starts_with("data:audio/wav;base64,")
    );
}

// spec: protocols/chat_completions/transcription_spec.rb:52 .render_transcription_payload > merges provider options over rendered defaults
#[test]
fn chat_completions_merges_provider_options_over_rendered_defaults() {
    let options =
        json!({ "chunking_strategy": { "type": "server_vad" }, "response_format": "json" });
    let payload = multipart_payload(
        Family::OpenAI,
        &request("gpt-4o-transcribe-diarize", &options),
    )
    .unwrap();
    assert_eq!(
        payload["chunking_strategy"],
        json!({ "type": "server_vad" })
    );
    assert_eq!(payload["response_format"], json!("json"));
}