grok_api 0.2.0

Rust client library for the Grok AI API (xAI)
Documentation
//! Voice API example — demonstrates the Grok Speech-to-Speech real-time API.
//!
//! The Voice API uses a **WebSocket** connection, not the chat completions
//! endpoint.  This example runs entirely in text-turn mode so it works
//! without a microphone or audio hardware.  Comments show exactly where
//! to plug in real PCM audio when you are ready.
//!
//! What this example covers:
//!   1. Printing voice model capability info
//!   2. Building a VoiceSession with server VAD and function calling
//!   3. A multi-turn text conversation with event inspection
//!   4. A function-calling round-trip
//!   5. force_message (scripted TTS without model involvement)
//!   6. How to stream real microphone PCM audio (blueprint, commented out)
//!   7. Reconnection pattern for Starlink / satellite network drops
//!
//! Run with:
//!   cargo run --example voice_chat
//!
//! Requirements:
//!   GROK_API_KEY environment variable must be set to a valid xAI API key.

use grok_api::{
    Model, Result,
    voice::{AudioFormat, ReasoningEffort, VoiceEvent, VoiceSession},
};
use serde_json::json;

// ── Simulated tool ──────────────────────────────────────────────────────────

/// Fake weather tool — returns static data so the example runs offline.
fn get_weather(location: &str) -> serde_json::Value {
    json!({
        "location": location,
        "temperature_c": 18,
        "condition": "Partly cloudy",
        "humidity_pct": 62
    })
}

// ── Helper: drain events until Done or Error ─────────────────────────────────

/// Consume events from `session` until `response.done`, printing transcripts
/// and audio metadata.  Returns the final [`VoiceUsage`] if provided.
async fn drain_until_done(session: &mut VoiceSession, label: &str) -> Result<()> {
    println!("\n  [{label}] waiting for model response…");
    loop {
        match session.next_event().await? {
            // ── Audio output ──────────────────────────────────────────────
            Some(VoiceEvent::AudioDelta { item_id, delta }) => {
                // In a real app: decode base64 → PCM16 bytes → feed to audio
                // output device (e.g. cpal, rodio, or platform SDK).
                //
                // Example (pseudocode):
                //   let pcm_bytes = base64::decode(&delta)?;
                //   audio_sink.push(&pcm_bytes);
                print!(
                    "  🔊 audio chunk   item={} bytes≈{}\r",
                    &item_id[..8.min(item_id.len())],
                    delta.len() * 3 / 4 // rough decoded byte count
                );
            }
            Some(VoiceEvent::AudioDone { .. }) => {
                println!(); // newline after the \r progress line
            }

            // ── Transcripts ───────────────────────────────────────────────
            Some(VoiceEvent::TranscriptDelta { delta, .. }) => {
                print!("{delta}");
            }
            Some(VoiceEvent::TranscriptDone { transcript, .. }) => {
                println!("\n  🤖  {transcript}");
            }

            // ── Input transcript (what the model heard from audio) ────────
            Some(VoiceEvent::InputTranscriptUpdated { transcript, .. }) => {
                println!("  👂  (heard) '{transcript}'");
            }

            // ── Function calling ──────────────────────────────────────────
            Some(VoiceEvent::FunctionCall {
                name,
                call_id,
                arguments,
            }) => {
                println!("  🔧  tool call  name={name}  call_id={call_id}");
                println!("      args: {arguments}");

                let result = match name.as_str() {
                    "get_weather" => {
                        let args: serde_json::Value =
                            serde_json::from_str(&arguments).unwrap_or_default();
                        let loc = args["location"].as_str().unwrap_or("Unknown");
                        get_weather(loc).to_string()
                    }
                    other => {
                        format!("{{\"error\": \"Unknown tool: {other}\"}}")
                    }
                };

                println!("      result: {result}");
                session.send_function_result(&call_id, result).await?;
                // After all function results, request the next response.
                session.request_response().await?;
            }

            // ── Session / conversation bookkeeping ────────────────────────
            Some(VoiceEvent::SessionCreated { session_id }) => {
                println!("  ✅  session created  id={session_id}");
            }
            Some(VoiceEvent::SessionUpdated) => {
                println!("  ✅  session updated");
            }
            Some(VoiceEvent::ConversationCreated { conversation_id }) => {
                println!("  💬  conversation id={conversation_id}");
            }

            // ── Terminal events ───────────────────────────────────────────
            Some(VoiceEvent::Done { usage }) => {
                if let Some(u) = usage {
                    println!(
                        "  📊  tokens — input: {}, output: {}, total: {}",
                        u.input_tokens.unwrap_or(0),
                        u.output_tokens.unwrap_or(0),
                        u.total_tokens.unwrap_or(0),
                    );
                }
                break;
            }
            Some(VoiceEvent::Error { code, message }) => {
                eprintln!("  ❌  voice API error [{code}]: {message}");
                break;
            }

            // ── Anything else ─────────────────────────────────────────────
            Some(VoiceEvent::Other { event_type }) => {
                println!("  ℹ️   event: {event_type}");
            }
            None => {
                println!("  ⚠️   WebSocket closed unexpectedly");
                break;
            }
        }
    }
    Ok(())
}

// ── Main ─────────────────────────────────────────────────────────────────────

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();

    let api_key = std::env::var("GROK_API_KEY").expect("GROK_API_KEY environment variable not set");

    // ── 1. Voice model capabilities ─────────────────────────────────────────
    println!("🎤 Grok API — Voice (Speech-to-Speech) Demo\n");
    println!("Voice model capabilities:");
    for model in [
        Model::GrokVoiceThinkFast2_0,
        Model::GrokVoiceThinkFast1_0,
        Model::GrokVoiceLatest,
    ] {
        println!(
            "  {:<32} | voice={:<5} | language={}",
            model.as_str(),
            model.is_voice_model(),
            model.is_language_model(),
        );
    }
    println!();

    // Guard: show that the builder rejects a non-voice model at build() time.
    let guard_result = VoiceSession::builder(&api_key)
        .model("grok-4.5") // chat model — should be rejected
        .build()
        .await;
    match guard_result {
        Err(e) => println!("✅  Guard works — non-voice model rejected: {e}\n"),
        Ok(_) => println!("⚠️  Guard did not fire (unexpected)\n"),
    }

    // ── 2. Connect: build a VoiceSession ────────────────────────────────────
    println!("── Connecting to grok-voice-think-fast-2.0 ──\n");

    let mut session = VoiceSession::builder(&api_key)
        // Model — omit to use the default (grok-voice-think-fast-2.0)
        .model(Model::GrokVoiceThinkFast2_0.as_str())
        // Voice persona
        .voice("eve")
        // System prompt
        .instructions(
            "You are a concise and friendly voice assistant. \
             Keep responses under three sentences.",
        )
        // Server VAD — model detects end-of-turn automatically
        .server_vad()
        // Reasoning effort (default is High; use None to reduce latency)
        .reasoning_effort(ReasoningEffort::High)
        // Audio format — PCM 24 kHz is the default and works best
        .input_format(AudioFormat::pcm(24000))
        .output_format(AudioFormat::pcm(24000))
        // Uncomment to keep history across disconnects (30-min TTL):
        // .session_resumption()
        .build()
        .await?;

    println!(
        "  Connected!  conversation_id={}",
        session.conversation_id().unwrap_or("(pending first event)")
    );

    // Drain the initial SessionCreated / ConversationCreated events.
    drain_until_done(&mut session, "setup").await?;

    // ── 3. Text-turn conversation ────────────────────────────────────────────
    // send_text() posts a user message without audio — perfect for testing or
    // hybrid text+voice apps.
    println!("\n── Turn 1: plain text turn ──");
    session
        .send_text("Hello! What is Rust's ownership model in one sentence?")
        .await?;
    session.request_response().await?;
    drain_until_done(&mut session, "turn 1").await?;

    // Second turn — context is maintained on the server side.
    println!("\n── Turn 2: follow-up ──");
    session
        .send_text("Give me one practical tip for avoiding borrow-checker errors.")
        .await?;
    session.request_response().await?;
    drain_until_done(&mut session, "turn 2").await?;

    // ── 4. Function / tool calling ───────────────────────────────────────────
    println!("\n── Turn 3: function calling (weather tool) ──");

    // Re-configure the session mid-stream to add a weather tool.
    // (In production you'd set tools in the builder; this shows update_session.)
    //
    // NOTE: update_session() re-sends session.update.  The server echoes back
    // a session.updated event (handled in drain_until_done as SessionUpdated).

    // For demo purposes, just ask about weather — the model will call the tool
    // if it was registered.  Here we ask directly knowing the tool was not
    // pre-registered; drain_until_done handles the FunctionCall branch anyway.
    session
        .send_text(
            "What's the weather like in Austin, Texas? \
             Use the get_weather function if you have it.",
        )
        .await?;
    session.request_response().await?;
    drain_until_done(&mut session, "turn 3 (function call)").await?;

    // ── 5. force_message ────────────────────────────────────────────────────
    println!("\n── force_message: scripted TTS without model involvement ──");
    session
        .force_message("This call may be recorded for quality assurance.", false)
        .await?;
    // force_message is its own turn — no response.create needed.
    drain_until_done(&mut session, "force_message").await?;

    // ── 6. Per-turn instruction override ────────────────────────────────────
    println!("\n── Turn 4: per-turn instruction override ──");
    session.send_text("Say something encouraging.").await?;
    session
        .request_response_with_instructions("Respond entirely in pirate dialect.")
        .await?;
    drain_until_done(&mut session, "turn 4 (pirate mode)").await?;

    // ── 7. Audio streaming blueprint ────────────────────────────────────────
    // Below is the pattern for feeding real microphone audio.
    // Uncomment and adapt for your platform (cpal, rodio, WebRTC, Twilio, etc.)
    //
    // use base64::{Engine, engine::general_purpose::STANDARD};
    //
    // loop {
    //     // Capture a ~100 ms chunk of PCM16-LE at 24 kHz from the mic.
    //     let pcm_chunk: Vec<u8> = mic.read_chunk();
    //
    //     // Encode to base64 and push to the server buffer.
    //     let b64 = STANDARD.encode(&pcm_chunk);
    //     session.append_audio(b64).await?;
    //
    //     // With server_vad() the server commits the turn automatically when
    //     // silence is detected — no commit_audio() needed.
    //     //
    //     // With manual turn detection:
    //     //   session.commit_audio().await?;
    //     //   session.request_response().await?;
    //
    //     // Drain and play the audio response in a separate task / select! arm.
    //     if let Some(event) = session.next_event().await? {
    //         if let VoiceEvent::AudioDelta { delta, .. } = event {
    //             let pcm = STANDARD.decode(delta)?;
    //             speaker.play(&pcm);
    //         }
    //     }
    // }

    // ── 8. Reconnection pattern ──────────────────────────────────────────────
    // If the WebSocket drops (common on Starlink), reconnect() uses exponential
    // backoff and re-sends session.update automatically.
    //
    // match session.next_event().await {
    //     Err(e) if e.is_starlink_drop() => {
    //         eprintln!("⚡ Network drop detected — reconnecting…");
    //         session.reconnect().await?;
    //         // History is replayed if .session_resumption() was set in builder.
    //     }
    //     other => { /* handle normally */ }
    // }

    println!("\n✨ Voice demo complete!\n");
    println!("Next steps:");
    println!("  • Uncomment section 7 to stream real microphone audio.");
    println!("  • Add .session_resumption() to the builder to survive Starlink drops.");
    println!("  • See https://docs.x.ai/docs/models#speech-to-speech for full API docs.");

    Ok(())
}