Zig client for AI APIs, supporting Google Gemini (Developer API and Vertex AI), OpenAI, and Anthropic. OpenAI-compatible endpoints — Ollama, Hugging Face Inference, and llama.cpp (llama-server) — are supported through the OpenAI client. Ported from the official Go Gen AI SDK, openai-go, and anthropic-sdk-go. Also ships an agent infrastructure namespace under zenai.search — currently Tavily and Brave Search, with room for sibling providers.
zig fetch --save git+https://github.com/lightpanda-io/zenaiThen add the dependency in your build.zig:
const zenai = b.dependency("zenai", .{});
exe.root_module.addImport("zenai", zenai.module("zenai"));Requires Zig >= 0.16.0. The examples below assume allocator, io, and environ are in scope; with Zig 0.16's main signature they come straight from std.process.Init:
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
const io = init.io;
const environ = init.minimal.environ;
// ...
}Set your API key (get one here):
export GOOGLE_API_KEY='your-api-key'const zenai = @import("zenai");
const api_key = environ.getPosix("GOOGLE_API_KEY") orelse return error.MissingApiKey;
var client = zenai.gemini.Client.init(io, allocator, api_key, .{});
defer client.deinit();
var response = try client.generateContentFromText("gemini-2.5-flash", "What is Zig?", .{}, .{});
defer response.deinit();
std.debug.print("{s}\n", .{response.value.text() orelse ""});try client.generateContentStreamFromText(
"gemini-2.5-flash",
"Write a poem about the moon.",
.{},
.{},
{},
&struct {
fn cb(_: void, response: zenai.gemini.types.GenerateContentResponse) void {
if (response.text()) |t| {
std.debug.print("{s}", .{t});
}
}
}.cb,
);var chat = zenai.gemini.Chat.init(&client, "gemini-2.5-flash", .{ .temperature = 0 }, .{});
defer chat.deinit();
const r1 = try chat.sendMessage("My name is Alice.");
std.debug.print("{s}\n", .{r1.text() orelse ""});
const r2 = try chat.sendMessage("What is my name?");
std.debug.print("{s}\n", .{r2.text() orelse ""});const tools = [_]zenai.gemini.types.Tool{.{
.functionDeclarations = &.{.{
.name = "get_weather",
.description = "Get the current weather for a city.",
.parameters = .{
.type = .OBJECT,
.properties = &.{
.{ .key = "city", .value = .{ .type = .STRING } },
},
.required = &.{"city"},
},
}},
}};
var response = try client.generateContentFromText(
"gemini-2.5-flash",
"What's the weather in Paris?",
.{},
.{ .tools = &tools },
);
defer response.deinit();
if (response.value.firstFunctionCall()) |fc| {
std.debug.print("Call: {s}\n", .{fc.name orelse ""});
}The same Gemini client can target Vertex AI instead of the Gemini Developer API. Two modes:
Express mode — a plain Vertex API key, no project needed:
var client = zenai.vertex.Client.init(io, allocator, api_key, .{ .vertex = .{} });
defer client.deinit();Project/location mode — pass an OAuth access token as the key (refreshing it is your job; tokens expire after ~1 hour):
export TOKEN=$(gcloud auth print-access-token)var client = zenai.vertex.Client.init(io, allocator, token, .{
.vertex = .{ .project = "my-project", .location = "global" },
});
defer client.deinit();Generation, streaming, Chat, and countTokens work in both modes. Model listing works on Vertex too, but Google's ListPublisherModels rejects API keys, so it needs project/location mode (OAuth). Embeddings, file uploads, and cached content are Developer-API-only and return error.UnsupportedByBackend on Vertex.
Through the provider abstraction, .vertex is env-detected when GOOGLE_GENAI_USE_VERTEXAI=1 (or true) is set: express mode reads GOOGLE_API_KEY; project mode reads GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION/GOOGLE_CLOUD_REGION (default "global") but needs the access token passed explicitly as the credential.
Set your API key (get one here):
export OPENAI_API_KEY='your-api-key'const zenai = @import("zenai");
const api_key = environ.getPosix("OPENAI_API_KEY") orelse return error.MissingApiKey;
var client = zenai.openai.Client.init(io, allocator, api_key, .{});
defer client.deinit();
var response = try client.chatCompletionFromText("gpt-4o", "What is Zig?", .{});
defer response.deinit();
std.debug.print("{s}\n", .{response.value.text() orelse ""});try client.chatCompletionStreamFromText(
"gpt-4o",
"Write a poem about the moon.",
.{},
{},
&struct {
fn cb(_: void, response: zenai.openai.types.ChatCompletionResponse) void {
if (response.text()) |t| {
std.debug.print("{s}", .{t});
}
}
}.cb,
);var chat = zenai.openai.Chat.init(&client, "gpt-4o", .{ .temperature = 0 });
defer chat.deinit();
const r1 = try chat.sendMessage("My name is Alice.");
std.debug.print("{s}\n", .{r1.text() orelse ""});
const r2 = try chat.sendMessage("What is my name?");
std.debug.print("{s}\n", .{r2.text() orelse ""});const tools = [_]zenai.openai.types.Tool{.{
.type = "function",
.function = .{
.name = "get_weather",
.description = "Get the current weather for a city.",
},
}};
var response = try client.chatCompletion("gpt-4o", &.{
.{ .role = .user, .content = "What's the weather in Paris?" },
}, .{ .tools = &tools });
defer response.deinit();
if (response.value.firstToolCall()) |tc| {
std.debug.print("Call: {s}\n", .{tc.function.?.name orelse ""});
}Set your API key (get one here):
export ANTHROPIC_API_KEY='your-api-key'const zenai = @import("zenai");
const api_key = environ.getPosix("ANTHROPIC_API_KEY") orelse return error.MissingApiKey;
var client = zenai.anthropic.Client.init(io, allocator, api_key, .{});
defer client.deinit();
var response = try client.createMessageFromText("claude-sonnet-4-6", "What is Zig?", 1024, .{});
defer response.deinit();
std.debug.print("{s}\n", .{response.value.text() orelse ""});try client.createMessageStreamFromText(
"claude-sonnet-4-6",
"Write a poem about the moon.",
1024,
.{},
{},
&struct {
fn cb(_: void, event: zenai.anthropic.types.StreamEvent) void {
if (event.delta) |delta| {
if (delta.text) |t| {
std.debug.print("{s}", .{t});
}
}
}
}.cb,
);var chat = zenai.anthropic.Chat.init(&client, "claude-sonnet-4-6", 1024, .{});
defer chat.deinit();
const r1 = try chat.sendMessage("My name is Alice.");
std.debug.print("{s}\n", .{r1.text() orelse ""});
const r2 = try chat.sendMessage("What is my name?");
std.debug.print("{s}\n", .{r2.text() orelse ""});const tools = [_]zenai.anthropic.types.Tool{.{
.name = "get_weather",
.description = "Get the current weather for a city.",
.input_schema = // JSON Schema as std.json.Value
}};
var response = try client.createMessage("claude-sonnet-4-6", &.{
.{ .role = .user, .content = &.{.{ .text = "What's the weather in Paris?" }} },
}, 1024, .{ .tools = &tools });
defer response.deinit();
if (response.value.firstToolUse()) |tu| {
std.debug.print("Call: {s}\n", .{tu.name orelse ""});
}Tavily is an AI-friendly search API that returns clean {title, url, content} JSON results — handy as a low-noise alternative to scraping a SERP. Set your API key (get one here):
export TAVILY_API_KEY='tvly-...'const zenai = @import("zenai");
const api_key = environ.getPosix("TAVILY_API_KEY") orelse return error.MissingApiKey;
var client = zenai.search.tavily.Client.init(io, allocator, api_key, .{});
defer client.deinit();
var response = try client.search("what is zig", .{ .max_results = 5 });
defer response.deinit();
for (response.value.results) |r| {
std.debug.print("{s} — {s}\n", .{ r.title, r.url });
}Brave Search is a web search API over Brave's independent index, returning JSON sections of {title, url, description} results. Set your API key (get one here):
export BRAVE_API_KEY='BSA...'const zenai = @import("zenai");
const api_key = std.posix.getenv("BRAVE_API_KEY") orelse return error.MissingApiKey;
var client = zenai.search.brave.Client.init(allocator, api_key, .{});
defer client.deinit();
// text_decorations=false strips <strong> highlight markup from descriptions.
var response = try client.search("what is zig", .{ .count = 5, .text_decorations = false });
defer response.deinit();
if (response.value.web) |web| {
for (web.results) |r| {
std.debug.print("{s} — {s}\n", .{ r.title, r.url });
}
}Use zenai.provider.Client to write provider-agnostic code. Swap providers by changing one line:
const zenai = @import("zenai");
// Pick your provider:
var gemini_client = zenai.gemini.Client.init(io, allocator, gemini_key, .{});
defer gemini_client.deinit();
const ai: zenai.provider.Client = .{ .gemini = &gemini_client };
// Or Vertex AI (same Gemini client, Vertex backend — see the Vertex section):
// var vertex_client = zenai.vertex.Client.init(io, allocator, token, .{
// .vertex = .{ .project = "my-project" },
// });
// const ai: zenai.provider.Client = .{ .vertex = &vertex_client };
// Or:
// var openai_client = zenai.openai.Client.init(io, allocator, openai_key, .{});
// const ai: zenai.provider.Client = .{ .openai = &openai_client };
// Or:
// var anthropic_client = zenai.anthropic.Client.init(io, allocator, anthropic_key, .{});
// const ai: zenai.provider.Client = .{ .anthropic = &anthropic_client };
// Or Hugging Face (OpenAI-compatible). Token comes from HF_TOKEN. Defaults to the
// serverless router; pass `.base_url` for a dedicated Inference Endpoint:
// var hf_client = zenai.huggingface.Client.init(io, allocator, hf_token, .{
// .base_url = "https://router.huggingface.co/v1",
// });
// const ai: zenai.provider.Client = .{ .huggingface = &hf_client };
// Or a local llama.cpp `llama-server` (OpenAI-compatible). No key needed;
// defaults to http://localhost:8080/v1 — override with `.base_url`:
// var llama_client = zenai.llama_cpp.Client.init(io, allocator, "llama.cpp", .{
// .base_url = "http://localhost:8080/v1",
// });
// const ai: zenai.provider.Client = .{ .llama_cpp = &llama_client };
// Or any OpenAI-compatible server (vLLM, LiteLLM, Together, Groq, etc.).
// Auto-detected by `provider.Client.init` when OPENAI_BASE_URL is set; the
// key comes from OPENAI_API_KEY. With the raw client, pass `.base_url`:
// var custom_client = zenai.openai_compatible.Client.init(io, allocator, api_key, .{
// .base_url = "https://my-custom-server.com/v1",
// });
// const ai: zenai.provider.Client = .{ .openai_compatible = &custom_client };
var result = try ai.generateContent("gemini-2.5-flash", &.{
.{ .role = .user, .content = "What is Zig?" },
}, .{});
defer result.deinit();
std.debug.print("{s}\n", .{result.text orelse ""});Drop down to provider-specific APIs when needed:
switch (ai) {
.gemini => |g| {
// Use Gemini-specific features like cached content, file uploads, etc.
var cached = try g.createCachedContent("gemini-2.5-flash", .{ ... });
},
else => {},
}Gemini:
- Text generation and streaming (SSE)
- Multi-turn chat with history management
- Function calling and tool use
- Embeddings
- Token counting
- File uploads (resumable protocol)
- Cached content
- Model listing and info
- Safety settings and content filtering
- Vertex AI backend (express-mode API key, or project/location with an OAuth access token) for generation, streaming, chat, token counting, and model listing
OpenAI:
- Chat completions and streaming (SSE)
- Multi-turn chat with history management
- Function calling and tool use
- Embeddings
- Model listing and info
Anthropic:
- Message creation and streaming (SSE)
- Multi-turn chat with history management
- Function calling and tool use
- Extended thinking support
Search providers:
- Tavily (
zenai.search.tavily) — JSON search API with optional synthesized answers, domain include/exclude, news/general topic, time-range filtering - Brave (
zenai.search.brave) — independent-index web search with country/language targeting, safesearch, freshness and section filtering, extra snippets
Provider abstraction:
- Unified text generation, streaming, and embeddings
- OpenAI-compatible backends: Ollama, Hugging Face, llama.cpp (
llama-server), and any custom server viaOPENAI_BASE_URL lastError()to surface the status and message behind a failed request- Escape hatches to provider-specific APIs
Apache License 2.0 — see LICENSE for details.