audacity-sdk 0.2.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](https://portal.audacityinvestments.com) 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`:

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

---

## Quickstart

### Converse (non-streaming)

```rust
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)

```rust
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):

```rust
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.

---

## 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.

```rust
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:

```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

```rust
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:

```rust
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](LICENSE).