///|
/// An LLM client bound to an OpenAI-compatible endpoint.
///
/// Construct with `Client::new`, optionally overriding `base_url`.
/// The default `base_url` targets the OpenAI public API; point it at any
/// OpenAI-compatible gateway (Azure, local vLLM, router services, ...).
pub struct Client {
api_key : String
base_url : String
timeout_ms : Int
/// Extra headers sent with every request (e.g. `OpenAI-Organization`,
/// Anthropic's `anthropic-version`, or a custom `x-api-key`).
extra_headers : Map[String, String]
/// The authentication scheme. Most OpenAI-compatible APIs use a bearer
/// token; some (e.g. Anthropic) use an `x-api-key` header instead.
auth : AuthScheme
}
///|
/// How the API key is presented to the server.
pub(all) enum AuthScheme {
/// `Authorization: Bearer ` — the OpenAI default.
Bearer
/// `x-api-key: ` — used by Anthropic.
ApiKeyHeader
/// No automatic auth header (supply your own via extra headers).
NoAuth
} derive(Eq, Debug)
///|
/// Create a client with the given API key.
///
/// - `base_url` defaults to `https://api.openai.com/v1`.
/// - `timeout_ms` defaults to 60_000.
pub fn Client::new(
api_key : String,
base_url? : String = "https://api.openai.com/v1",
timeout_ms? : Int = 60000,
) -> Client {
{
api_key,
base_url: normalize_base_url(base_url),
timeout_ms,
extra_headers: {},
auth: Bearer,
}
}
///|
/// Strip a single trailing slash so path joins are predictable.
fn normalize_base_url(base_url : String) -> String {
if base_url.has_suffix("/") {
base_url[:base_url.length() - 1].to_owned()
} else {
base_url
}
}
///|
/// A builder for `Client`, for when you need more than the key and base URL:
/// custom headers, an organization id, a non-bearer auth scheme, or a
/// specific timeout.
pub struct ClientBuilder {
mut api_key : String
mut base_url : String
mut timeout_ms : Int
headers : Map[String, String]
mut auth : AuthScheme
}
///|
/// Start building a client.
pub fn ClientBuilder::new() -> ClientBuilder {
{
api_key: "",
base_url: "https://api.openai.com/v1",
timeout_ms: 60000,
headers: {},
auth: Bearer,
}
}
///|
pub fn ClientBuilder::api_key(
self : ClientBuilder,
key : String,
) -> ClientBuilder {
self.api_key = key
self
}
///|
pub fn ClientBuilder::base_url(
self : ClientBuilder,
url : String,
) -> ClientBuilder {
self.base_url = url
self
}
///|
pub fn ClientBuilder::timeout_ms(
self : ClientBuilder,
ms : Int,
) -> ClientBuilder {
self.timeout_ms = ms
self
}
///|
/// Add a header sent with every request.
pub fn ClientBuilder::header(
self : ClientBuilder,
name : String,
value : String,
) -> ClientBuilder {
self.headers[name] = value
self
}
///|
/// Set the `OpenAI-Organization` header.
pub fn ClientBuilder::organization(
self : ClientBuilder,
org : String,
) -> ClientBuilder {
self.headers["OpenAI-Organization"] = org
self
}
///|
/// Set the authentication scheme.
pub fn ClientBuilder::auth(
self : ClientBuilder,
scheme : AuthScheme,
) -> ClientBuilder {
self.auth = scheme
self
}
///|
/// Configure the builder for the Anthropic Messages API: `x-api-key` auth and
/// the required `anthropic-version` header.
pub fn ClientBuilder::anthropic(
self : ClientBuilder,
version? : String = "2023-06-01",
) -> ClientBuilder {
self.auth = ApiKeyHeader
self.headers["anthropic-version"] = version
if self.base_url == "https://api.openai.com/v1" {
self.base_url = "https://api.anthropic.com/v1"
}
self
}
///|
/// Finalize the builder into a `Client`.
pub fn ClientBuilder::build(self : ClientBuilder) -> Client {
{
api_key: self.api_key,
base_url: normalize_base_url(self.base_url),
timeout_ms: self.timeout_ms,
extra_headers: self.headers,
auth: self.auth,
}
}
///|
/// Build the full URL for an endpoint path (e.g. `/embeddings`).
fn Client::endpoint(self : Client, path : String) -> String {
self.base_url + path
}
///|
/// Perform a POST with a JSON body to `path` and return the parsed response
/// JSON. Shared by all non-streaming endpoints. Handles transport failures,
/// non-2xx status, and JSON parse errors uniformly.
pub async fn Client::post_json(
self : Client,
path : String,
body : Json,
) -> Json raise LLMError {
let http = self.http()
let resp = http.post(self.endpoint(path)).json(body).send() catch {
err => raise Transport(err.to_string())
}
let code = resp.response.code
let text = resp.text() catch { err => raise Decode(err.to_string()) }
guard code >= 200 && code < 300 else { raise ApiError(code~, message=text) }
@json.parse(text) catch {
err => raise Decode("invalid JSON: " + err.to_string())
}
}
///|
/// Perform a GET request to `path` and return the parsed response JSON.
pub async fn Client::get_json(
self : Client,
path : String,
) -> Json raise LLMError {
let http = self.http()
let resp = http.get(self.endpoint(path)).send() catch {
err => raise Transport(err.to_string())
}
let code = resp.response.code
let text = resp.text() catch { err => raise Decode(err.to_string()) }
guard code >= 200 && code < 300 else { raise ApiError(code~, message=text) }
@json.parse(text) catch {
err => raise Decode("invalid JSON: " + err.to_string())
}
}
///|
/// Perform a DELETE request to `path` and return the parsed response JSON.
pub async fn Client::delete_json(
self : Client,
path : String,
) -> Json raise LLMError {
let http = self.http()
let resp = http.delete(self.endpoint(path)).send() catch {
err => raise Transport(err.to_string())
}
let code = resp.response.code
let text = resp.text() catch { err => raise Decode(err.to_string()) }
guard code >= 200 && code < 300 else { raise ApiError(code~, message=text) }
@json.parse(text) catch {
err => raise Decode("invalid JSON: " + err.to_string())
}
}
///|
/// Build a configured mio request client with auth, extra headers, and
/// timeout applied.
fn Client::http(self : Client) -> @mio.RequestClient {
let builder = @mio.RequestClient::builder()
.default_header("Content-Type", "application/json")
.timeout(self.timeout_ms)
match self.auth {
Bearer =>
builder.default_header("Authorization", "Bearer " + self.api_key)
|> ignore
ApiKeyHeader => builder.default_header("x-api-key", self.api_key) |> ignore
NoAuth => ()
}
for name, value in self.extra_headers {
builder.default_header(name, value) |> ignore
}
builder.build()
}
///|
/// Perform a non-streaming chat completion.
///
/// Raises `LLMError` on transport failure, non-2xx status, or decode failure.
pub async fn Client::chat(
self : Client,
request : ChatRequest,
) -> ChatResponse raise LLMError {
request.stream = false
let json = self.post_json("/chat/completions", request.to_json())
@json.from_json(json) catch {
err => raise Decode(err.to_string())
}
}
///|
/// Whether an error is worth retrying: transport failures and
/// rate-limit / server errors (429, 5xx).
fn is_retryable(err : LLMError) -> Bool {
match err {
Transport(_) => true
ApiError(code~, ..) => code == 429 || code >= 500
_ => false
}
}
///|
/// Perform a chat completion with automatic retries on transient failures.
///
/// Retries up to `max_retries` times with exponential backoff starting at
/// `base_delay_ms`. Non-retryable errors (4xx other than 429, decode errors)
/// are raised immediately.
pub async fn Client::chat_with_retry(
self : Client,
request : ChatRequest,
max_retries? : Int = 3,
base_delay_ms? : Int = 500,
) -> ChatResponse raise LLMError {
let mut attempt = 0
let mut delay = base_delay_ms
for ;; {
let result = Ok(self.chat(request)) catch { err => Err(err) }
match result {
Ok(resp) => return resp
Err(err) => {
if attempt >= max_retries || !is_retryable(err) {
raise err
}
@async.sleep(delay) catch {
e => raise Transport("interrupted during backoff: " + e.to_string())
}
attempt = attempt + 1
delay = delay * 2
}
}
}
}