Skip to content

Latest commit

 

History

174 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

zenai

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, Brave Search, Exa and Keenable, with room for sibling providers.

Meditating panda with incense smoke

Installation

zig fetch --save git+https://github.com/lightpanda-io/zenai

Then 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;
    // ...
}

Gemini

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 ""});

Streaming

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,
);

Chat

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 ""});

Function calling

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 ""});
}

Google Vertex AI

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.

OpenAI

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 ""});

Streaming

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,
);

Chat

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 ""});

Function calling

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 ""});
}

Anthropic

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 ""});

Streaming

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,
);

Chat

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 ""});

Function calling

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 (search)

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)

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 = environ.getPosix("BRAVE_API_KEY") orelse return error.MissingApiKey;
var client = zenai.search.brave.Client.init(io, 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 });
    }
}

Exa (search)

Exa is an embeddings-based search API aimed at agents. Snippet text is opt-in: a bare search returns metadata only, so request contents to get any. Set your API key (get one here):

export EXA_API_KEY='...'
const zenai = @import("zenai");

const api_key = environ.getPosix("EXA_API_KEY") orelse return error.MissingApiKey;
var client = zenai.search.exa.Client.init(io, allocator, api_key, .{});
defer client.deinit();

// Without `contents` the results carry no text at all; highlights are the cheapest form.
var response = try client.search("what is zig", .{
    .numResults = 5,
    .contents = .{ .highlights = .{ .numSentences = 3 } },
});
defer response.deinit();

for (response.value.results) |r| {
    // `title` is nullable here — Exa returns null for pages it could not title.
    std.debug.print("{s} — {s}\n", .{ r.title orelse "(untitled)", r.url });
}

Keenable (search)

Keenable is an AI-friendly search API returning {title, url, snippet} JSON. Unlike the other providers here it also answers without an API key: pass null and the client calls the public endpoint (rate-limited per client IP). A key (get one here) lifts the limits; it is not a prerequisite.

export KEENABLE_API_KEY='keen_...'   # optional
const zenai = @import("zenai");

// `null` selects the public endpoint. `app_title` has no default: it is sent as
// `X-Keenable-Title`, required keyless, and only the embedder knows its own name.
const api_key = environ.getPosix("KEENABLE_API_KEY");
var client = zenai.search.keenable.Client.init(io, allocator, api_key, .{ .app_title = "my-app" });
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.snippet });
}

Provider Abstraction

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 => {},
}

Features

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
  • Exa (zenai.search.exa) — embeddings-based search with search-type selection, category focus, domain include/exclude, published-date windows, and opt-in contents (highlights/text/summary)
  • Keenable (zenai.search.keenable) — web search that also works keyless through a public endpoint, with site restriction, published-date windows and a snippet-length hint

Provider abstraction:

  • Unified text generation, streaming, and embeddings
  • OpenAI-compatible backends: Ollama, Hugging Face, llama.cpp (llama-server), and any custom server via OPENAI_BASE_URL
  • lastError() to surface the status and message behind a failed request
  • Escape hatches to provider-specific APIs

License

Apache License 2.0 — see LICENSE for details.

Releases

Packages

Contributors

Languages