ClientOptions.api_key_env
Rust · crate toolnexus · SPEC §8
pub struct ClientOptions { /// Sent as-is. Wins over everything else. pub api_key: String, /// The NAME of the env var to read the key from (never the value). pub api_key_env: String, // ...}
impl Client { /// The key this client would send, by the rules below (no request is made). pub fn resolve_key(&self) -> Result<String, ClientError>;}pub fn key_env_for_base_url(base_url: &str) -> &'static str;The client decides which key goes to which host, in this order:
api_keyset ⇒ only it.- Else
api_key_envset ⇒ the env var it names. - Else only the env var that belongs to the
base_urlhost, over https:api.openai.com→OPENAI_API_KEY,api.anthropic.com→ANTHROPIC_API_KEY,openrouter.ai(and subdomains) →OPENROUTER_API_KEY. - Any other host, or plain http, with neither set ⇒ the run fails with
ClientError::NoApiKey("no API key: set APIKey or APIKeyEnv …"). Nothing is sent.
When to use it
Section titled “When to use it”Set api_key_env whenever the base_url is not one of the three known hosts — a local llama.cpp
or vLLM server, a corporate gateway, a proxy — and the key lives in the environment. Set api_key
when the host already has the value in hand (a secret manager, a test). For the three known hosts
you can set neither: the matching env var is read automatically.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”Each example uses a loopback stub or no request at all — no network, fake keys only.
1. The smallest useful call — a local server with api_key_env
Section titled “1. The smallest useful call — a local server with api_key_env”use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};use tokio::net::TcpListener;use toolnexus::client::{ClientOptions, Style, create_client};use toolnexus::{ToolkitOptions, create_toolkit};
/// A loopback HTTP stub: `reply(n, raw_request)` answers the n-th request (1-based)/// with `(status, extra header lines, body)`.async fn stub<F>(reply: F) -> Stringwhere F: Fn(usize, &str) -> (u16, String, String) + Send + Sync + 'static,{ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let reply = Arc::new(reply); tokio::spawn(async move { let mut n = 0; while let Ok((mut sock, _)) = listener.accept().await { n += 1; let (reply, i) = (reply.clone(), n); tokio::spawn(async move { let (mut buf, mut chunk) = (Vec::new(), [0u8; 4096]); loop { let k = sock.read(&mut chunk).await.unwrap_or(0); if k == 0 { break; } buf.extend_from_slice(&chunk[..k]); let s = String::from_utf8_lossy(&buf).to_string(); if let Some(h) = s.find("\r\n\r\n") { let len = s[..h] .lines() .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap_or(0))) .unwrap_or(0); if buf.len() >= h + 4 + len { break; } } } let (status, extra, body) = reply(i, &String::from_utf8_lossy(&buf)); let head = format!( "HTTP/1.1 {status} STUB\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n{extra}\r\n", body.len() ); let _ = sock.write_all(format!("{head}{body}").as_bytes()).await; }); } }); url}
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { // SAFETY: set before any other code reads the environment. unsafe { std::env::set_var("MY_LOCAL_LLM_KEY", "YOUR_KEY_HERE") };
let auth = Arc::new(Mutex::new(String::new())); let seen = auth.clone(); let url = stub(move |_, raw| { let line = raw.lines().find(|l| l.to_ascii_lowercase().starts_with("authorization:")); *seen.lock().unwrap() = line.map(|l| l[14..].trim().to_owned()).unwrap_or_default(); (200, String::new(), r#"{"choices":[{"message":{"role":"assistant","content":"hi"}}]}"#.into()) }) .await;
let tk = create_toolkit(ToolkitOptions::default()).await?; let client = create_client(ClientOptions { base_url: url, style: Style::OpenAI, model: "local".into(), api_key_env: "MY_LOCAL_LLM_KEY".into(), // the NAME, not the value ..Default::default() }); let res = client.run("hello", &tk).await?; assert_eq!(*auth.lock().unwrap(), "Bearer YOUR_KEY_HERE"); println!("ok: {}", res.text); Ok(())}2. The realistic case — a local host with no key fails before sending
Section titled “2. The realistic case — a local host with no key fails before sending”A provider key in the environment is never sent to a host it does not belong to.
use std::sync::Arc;use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};use tokio::net::TcpListener;use toolnexus::client::{ClientError, ClientOptions, Style, create_client};use toolnexus::{ToolkitOptions, create_toolkit};
/// A loopback HTTP stub: `reply(n, raw_request)` answers the n-th request (1-based)/// with `(status, extra header lines, body)`.async fn stub<F>(reply: F) -> Stringwhere F: Fn(usize, &str) -> (u16, String, String) + Send + Sync + 'static,{ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let reply = Arc::new(reply); tokio::spawn(async move { let mut n = 0; while let Ok((mut sock, _)) = listener.accept().await { n += 1; let (reply, i) = (reply.clone(), n); tokio::spawn(async move { let (mut buf, mut chunk) = (Vec::new(), [0u8; 4096]); loop { let k = sock.read(&mut chunk).await.unwrap_or(0); if k == 0 { break; } buf.extend_from_slice(&chunk[..k]); let s = String::from_utf8_lossy(&buf).to_string(); if let Some(h) = s.find("\r\n\r\n") { let len = s[..h] .lines() .find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap_or(0))) .unwrap_or(0); if buf.len() >= h + 4 + len { break; } } } let (status, extra, body) = reply(i, &String::from_utf8_lossy(&buf)); let head = format!( "HTTP/1.1 {status} STUB\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n{extra}\r\n", body.len() ); let _ = sock.write_all(format!("{head}{body}").as_bytes()).await; }); } }); url}
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { // SAFETY: set before any other code reads the environment. unsafe { std::env::set_var("OPENAI_API_KEY", "YOUR_KEY_HERE") };
let hits = Arc::new(AtomicUsize::new(0)); let counter = hits.clone(); let url = stub(move |_, _| { counter.fetch_add(1, Ordering::SeqCst); (200, String::new(), r#"{"choices":[{"message":{"role":"assistant","content":"hi"}}]}"#.into()) }) .await;
let tk = create_toolkit(ToolkitOptions::default()).await?; let client = create_client(ClientOptions { base_url: url, style: Style::OpenAI, model: "local".into(), ..Default::default() }); let err = client.run("hello", &tk).await.unwrap_err(); assert!(matches!(err, ClientError::NoApiKey(_)) && err.to_string().contains("no API key"), "{err}"); assert_eq!(hits.load(Ordering::SeqCst), 0, "OPENAI_API_KEY must never reach a local server"); println!("ok: refused, {} requests sent", hits.load(Ordering::SeqCst)); Ok(())}3. The full surface — the host picks its own env var
Section titled “3. The full surface — the host picks its own env var”With several provider keys set, https://api.openai.com gets OPENAI_API_KEY and nothing else.
resolve_key answers the question without sending a request.
use toolnexus::client::{ClientOptions, Style, create_client, key_env_for_base_url};
fn main() -> Result<(), Box<dyn std::error::Error>> { // SAFETY: single-threaded, before anything reads the environment. unsafe { std::env::set_var("OPENROUTER_API_KEY", "YOUR_OPENROUTER_KEY_HERE"); std::env::set_var("OPENAI_API_KEY", "YOUR_OPENAI_KEY_HERE"); } let client = |base: &str| { create_client(ClientOptions { base_url: base.into(), style: Style::OpenAI, model: "m".into(), ..Default::default() }) };
assert_eq!(client("https://api.openai.com/v1").resolve_key()?, "YOUR_OPENAI_KEY_HERE"); assert_eq!(client("https://openrouter.ai/api/v1").resolve_key()?, "YOUR_OPENROUTER_KEY_HERE"); assert_eq!(key_env_for_base_url("https://api.anthropic.com"), "ANTHROPIC_API_KEY"); // Plain http, even to a known host name, gets no provider key. assert!(client("http://api.openai.com/v1").resolve_key().is_err());
println!("ok: api.openai.com got OPENAI_API_KEY only"); Ok(())}See also
Section titled “See also”create_client— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.list_models— the same key rules apply when listing a provider’s models.Client.run— Send a prompt, let the loop call tools until the model stops, get a RunResult.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.