Skip to content

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:

  1. ApiKey set ⇒ only that.
  2. Else ApiKeyEnv set ⇒ the env var it names.
  3. Else ONLY the env var that belongs to the BaseUrl host, and only over https: api.openai.com → OPENAI_API_KEY, api.anthropic.com → ANTHROPIC_API_KEY, openrouter.ai (and subdomains) → OPENROUTER_API_KEY.
  4. 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.

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.

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 { }
}
}
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 { }
}
}
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.
  • 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.