audacity-sdk 0.4.0

Rust SDK for the Audacity Investments AI gateway — Amazon Bedrock Converse-compatible API surface
Documentation

audacity-sdk (Rust)

Rust SDK for the Audacity Investments AI gateway. Exposes an Amazon Bedrock Converse-shaped API so teams migrating off Bedrock can swap the client construction + API key and keep the rest of their code.


Installation

Add to Cargo.toml:

[dependencies]
audacity-sdk = "0.1.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

Quickstart

Converse (non-streaming)

use audacity_sdk::{Client, ContentBlock, ConversationRole, Message};

#[tokio::main]
async fn main() -> Result<(), audacity_sdk::Error> {
    // Reads AUDACITY_API_KEY from the environment.
    let client = Client::from_env()?;

    let response = client.converse()
        .model_id("gpt-5.4-mini")
        .messages(
            Message::builder()
                .role(ConversationRole::User)
                .content(ContentBlock::Text("Hello!".into()))
                .build()?
        )
        .inference_config(
            audacity_sdk::InferenceConfiguration::builder()
                .max_tokens(500)
                .temperature(0.2)
                .build()
        )
        .send()
        .await?;

    let text = response
        .output().unwrap()
        .as_message().unwrap()
        .content().first().unwrap()
        .as_text().unwrap();

    println!("{text}");
    Ok(())
}

ConverseStream (streaming)

use audacity_sdk::{Client, ContentBlock, ConversationRole, ConverseStreamOutput, Message};

#[tokio::main]
async fn main() -> Result<(), audacity_sdk::Error> {
    let client = Client::from_env()?;

    let mut output = client.converse_stream()
        .model_id("gpt-5.4-mini")
        .messages(
            Message::builder()
                .role(ConversationRole::User)
                .content(ContentBlock::Text("Tell me a joke".into()))
                .build()?
        )
        .send()
        .await?;

    while let Some(event) = output.stream.recv().await? {
        if let ConverseStreamOutput::ContentBlockDelta(e) = event {
            if let audacity_sdk::ContentBlockDeltaPayload::Text(t) = e.delta {
                print!("{t}");
            }
        }
    }
    println!();
    Ok(())
}

Images (vision models)

Bedrock-style image content blocks are supported in user messages. Pass raw bytes (base64-encoded for you) or a URL (Audacity extension):

use audacity_sdk::{ContentBlock, ImageBlock, ImageFormat, ImageSource};

let image_bytes = std::fs::read("chart.png")?;

let response = client.converse()
    .model_id("gpt-5.5")
    .messages(
        Message::builder()
            .role(ConversationRole::User)
            .content(ContentBlock::Text("What does this chart show?".into()))
            .content(ContentBlock::Image(ImageBlock {
                format: ImageFormat::Png,
                source: ImageSource::Bytes(image_bytes),
            }))
            .build()?
    )
    .send()
    .await?;

// Or reference a hosted image directly (not available in Bedrock):
// source: ImageSource::Url("https://example.com/photo.jpg".into())

ImageFormat is one of Png, Jpeg, Gif, Webp. Use a vision-capable model.


Video input

Bedrock-style video content blocks are supported in user messages. Pass raw bytes; they are base64-encoded into an inline data URL for you:

use audacity_sdk::{ContentBlock, VideoBlock, VideoFormat, VideoSource};

let video_bytes = std::fs::read("demo.mp4")?;

let response = client.converse()
    .model_id("gemini-2.5-flash")
    .messages(
        Message::builder()
            .role(ConversationRole::User)
            .content(ContentBlock::Text("What happens in this clip?".into()))
            .content(ContentBlock::Video(VideoBlock {
                format: VideoFormat::Mp4,
                source: VideoSource::Bytes(video_bytes),
            }))
            .build()?
    )
    .send()
    .await?;

VideoFormat is one of Mp4, Mov, Mkv, Webm, Flv, Mpeg, Mpg, Wmv, ThreeGp.

Video is Gemini-only at the gateway: use gemini-2.5-flash, gemini-2.5-pro, or gemini-3-flash-preview — other models reject video input with an HTTP 400. Keep inline video ≤ ~20 MB (base64 encoding inflates the request body against the gateway's cap); upload larger files first and reference them by URI.

Media resolution (cheaper video tokens)

Set media_resolution to control how densely video (and image) input is sampled on Gemini models — Low cuts video token cost roughly 4x. Values are Low, Medium, High, UltraHigh; non-Gemini models ignore the field:

use audacity_sdk::MediaResolution;

let response = client.converse()
    .model_id("gemini-2.5-flash")
    .media_resolution(MediaResolution::Low)
    .messages(/* … video message … */)
    .send()
    .await?;

Large videos: upload + URI

For videos up to 1 GB, upload once via the file-upload helper and reference the returned audacity://files/… URI with VideoSource::Uri (mirrors Bedrock Converse's bytes | s3Location pattern):

use audacity_sdk::{ContentBlock, VideoBlock, VideoFormat, VideoSource};

let video_bytes = std::fs::read("large-demo.mp4")?;

// Under the hood: POST /v1/files issues a presigned upload URL (~15 min
// validity), then the bytes go up over a resumable upload session in 8 MiB
// chunks. If the connection drops mid-upload, the SDK automatically resumes
// from the last byte the server confirmed (bounded retries with jittered
// backoff) — no need to restart a large upload from zero.
let upload = client.upload_file()
    .data(video_bytes)
    .content_type("video/mp4")
    .send()
    .await?;

let response = client.converse()
    .model_id("gemini-2.5-flash")
    .messages(
        Message::builder()
            .role(ConversationRole::User)
            .content(ContentBlock::Text("Summarise this recording.".into()))
            .content(ContentBlock::Video(VideoBlock {
                format: VideoFormat::Mp4,
                source: VideoSource::Uri(upload.uri),
            }))
            .build()?
    )
    .send()
    .await?;

Uploaded files are transient inference inputs: they auto-delete after ~24 h, so upload shortly before use and re-upload for later sessions. Files are namespaced per API key's client — a URI from one client is not visible to another.


Image generation

Generate images from a text prompt with generate_image:

use base64::Engine as _;

let result = client.generate_image()
    .model("gpt-image-1")
    .prompt("A watercolor painting of a fox in a snowy forest")
    .size("1024x1024")
    .response_format("b64_json") // or "url" (default) for a signed link
    .send()
    .await?;

let b64 = result.data[0].b64_json.as_deref().unwrap();
let bytes = base64::engine::general_purpose::STANDARD.decode(b64)?;
std::fs::write("fox.png", bytes)?;
// With response_format "url": result.data[0].url (signed link, expires ~24 h)

Optional builder methods: n (1–10 images), size ("WxH", model-dependent), quality (e.g. "standard", "hd"), and user. The output carries created, data (each entry has url or b64_json, plus revised_prompt when the provider rewrites your prompt) and optional usage token counts. Errors map to the same Error variants as converse (401 → Error::AccessDenied, 429 → Error::Throttling, spend cap → Error::ServiceQuotaExceeded).


Prompt caching

Place a Bedrock-style cache-point block after the stable prefix you want the provider to cache (system prompt, large documents). Everything up to the cache point is cached provider-side on Claude models; OpenAI/Gemini models cache automatically and ignore the marker. At most 4 cache points per request.

use audacity_sdk::{CachePointBlock, ContentBlock, SystemContentBlock};

let response = client.converse()
    .model_id("claude-sonnet-4-5")
    .system(SystemContentBlock::text(long_system_prompt))
    .system(SystemContentBlock::cache_point())
    .messages(
        Message::builder()
            .role(ConversationRole::User)
            .content(ContentBlock::Text(big_reference_document))
            .content(ContentBlock::CachePoint(CachePointBlock::new()))
            .content(ContentBlock::Text("Summarise the key risks.".into()))
            .build()?
    )
    .send()
    .await?;

// Cache activity is reported in usage (Bedrock names):
println!("{}", response.usage().cache_read_input_tokens);  // tokens served from cache
println!("{}", response.usage().cache_write_input_tokens); // tokens written to cache

A cache point with nothing before it in the same message is silently ignored.


Migrating from aws-sdk-bedrockruntime

The SDK is designed to be a drop-in. Here is a side-by-side diff:

-use aws_sdk_bedrockruntime::Client;
-use aws_sdk_bedrockruntime::types::{ContentBlock, ConversationRole, Message};
+use audacity_sdk::{Client, ContentBlock, ConversationRole, Message};

-let config = aws_config::load_from_env().await;
-let client = Client::new(&config);
+let client = audacity_sdk::Client::from_env()?;   // reads AUDACITY_API_KEY

 let response = client.converse()
-    .model_id("anthropic.claude-3-5-sonnet-20241022-v2:0")
+    .model_id("gpt-5.4-mini")
     .messages(
         Message::builder()
             .role(ConversationRole::User)
-            .content(ContentBlock::Text("Hello".into()))
+            .content(ContentBlock::Text("Hello".into()))
             .build()?
     )
     .send()
     .await?;

Error handling

use audacity_sdk::{Client, Error};

match client.converse().model_id("m").messages(msg).send().await {
    Ok(resp) => { /* use resp */ }
    Err(Error::Throttling(d)) => {
        eprintln!("Rate limited (retry after {:?}s): {}", d.retry_after_seconds, d.message);
    }
    Err(Error::AccessDenied(d)) => {
        eprintln!("Access denied [{}]: {}", d.error_code.unwrap_or_default(), d.message);
    }
    Err(Error::MissingApiKey) => {
        eprintln!("Set AUDACITY_API_KEY");
    }
    Err(e) => eprintln!("Other error: {e}"),
}

Error variants

Variant Retryable Trigger
Validation no 400 / invalid request
AccessDenied no 401/403 / bad key
ServiceQuotaExceeded no 402 / budget exceeded
ResourceNotFound no 404 / unknown model
ModelTimeout yes 408 / TIMEOUT_ERROR
Throttling yes 429 / rate limited
ModelError no model-side error
ModelStreamError no stream-specific error
ServiceUnavailable yes 502/503/504
InternalServer yes 500
MissingApiKey no key in env/config
Sdk yes (network) network / decode failure

Every server error carries ErrorDetails { message, status_code, error_code, request_id, retry_after_seconds, raw_body }.


Configuration

Source API key Base URL
Explicit Config::builder().api_key("…") .base_url("…")
Environment AUDACITY_API_KEY AUDACITY_BASE_URL
Default https://portal.audacityinvestments.com

Other options:

use std::time::Duration;

let config = audacity_sdk::Config::builder()
    .api_key("audacity_api_…")
    .base_url("https://portal.audacityinvestments.com")
    .timeout(Duration::from_secs(120))
    .max_retries(2)   // 3 total attempts
    .build()?;

let client = audacity_sdk::Client::new(&config)?;

The timeout bounds each converse attempt end to end (including the body read). For converse_stream it only bounds the wait for response headers — the SSE body read is unbounded, so long generations are never cut off mid-stream.


License

Copyright Audacity Investments. All rights reserved.
See LICENSE.