Options.ApiKeyEnv
C# · package Toolnexus · SPEC §8 · LlmClientHost.cs
public sealed class LlmClient.Options{ public string? ApiKey { get; set; } // used as-is when set public string? ApiKeyEnv { get; set; } // the NAME of an env var, never the value // ...}Which key the client sends, and to whom. In order:
ApiKeyset ⇒ only that.- Else
ApiKeyEnvset ⇒ the env var it names. - Else ONLY the env var that belongs to the
BaseUrlhost, and only 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 ⇒
InvalidOperationException("No API key: set ApiKey or ApiKeyEnv …").
There is no cross-provider fallback. The same rule applies to
LlmClient.ListModelsAsync. The key value is never logged.
When to use it
Section titled “When to use it”Set ApiKey when the host already holds the key (a secret store, a config value). Set
ApiKeyEnv when the key lives in an env var whose name is not the provider’s own, which is the
usual case for a local server, a corporate proxy, or a second OpenAI-compatible provider. Leave
both unset only for the three hosted providers above, where the matching env var is read.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. A local server with an explicit key
Section titled “1. A local server with an explicit key”using System.Net;using System.Text;using Toolnexus;
string? auth = null;using var stub = new Stub((ctx, body) =>{ auth = ctx.Request.Headers["Authorization"]; Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """);});
// Local / custom BaseUrl: the env is never consulted, so pass a key (any placeholder the server accepts).var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "local-model", ApiKey = "YOUR_KEY_HERE",});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());var result = await client.RunAsync("hi", tk);
if (auth != "Bearer YOUR_KEY_HERE") throw new Exception(auth);Console.WriteLine($"ok: {result.Text} with an explicit key");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext, string> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}2. No key for a custom host is an error, not a leak
Section titled “2. No key for a custom host is an error, not a leak”using System.Net;using System.Text;using Toolnexus;
using var stub = new Stub((ctx, body) => Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """));
// Even with OPENAI_API_KEY set, a local host never receives it.Environment.SetEnvironmentVariable("OPENAI_API_KEY", "YOUR_KEY_HERE");var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "m" });
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());try{ await client.RunAsync("hi", tk); throw new Exception("expected a missing-key error");}catch (InvalidOperationException e) when (e.Message.Contains("ApiKeyEnv")){ Console.WriteLine("ok: refused to send a provider key to a custom host");}
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext, string> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}3. ApiKeyEnv names the variable to read
Section titled “3. ApiKeyEnv names the variable to read”using System.Net;using System.Text;using Toolnexus;
string? auth = null;using var stub = new Stub((ctx, body) =>{ auth = ctx.Request.Headers["Authorization"]; Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """);});
// In real code this is set outside the process; the client reads it at request time.Environment.SetEnvironmentVariable("MY_PROXY_KEY", "YOUR_KEY_HERE");var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "m", ApiKeyEnv = "MY_PROXY_KEY",});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());await client.RunAsync("hi", tk);
if (auth != "Bearer YOUR_KEY_HERE") throw new Exception(auth);Console.WriteLine("ok: key read from MY_PROXY_KEY");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext, string> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}Fields
Section titled “Fields”| Field | Type | What it is |
|---|---|---|
ApiKey |
string? |
The key itself. Wins over everything. |
ApiKeyEnv |
string? |
The name of the env var to read when ApiKey is empty. |
See also
Section titled “See also”LlmClient.Create— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.LlmClient.RunAsync— Send a prompt, let the loop call tools until the model stops, get a RunResult.LlmClient.StreamAsync— The streaming loop: text deltas, tool-call events, and suspension events as they happen.LlmClient.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.