agent: consolidate LLM provider and key resolution

Introduce an `Llm` struct to bundle the provider and API key together.
Add `UserError` to identify errors that have already printed human-
readable messages, preventing duplicate logging on exit.
This commit is contained in:
Adrià Arrufat
2026-05-21 15:01:44 +02:00
parent 7df1e80ce5
commit 651504efb5
2 changed files with 102 additions and 109 deletions

View File

@@ -35,6 +35,24 @@ const SlashCommand = @import("SlashCommand.zig");
const Agent = @This();
/// Errors raised by Agent.init / listModels where the function has already
/// printed a human-readable message to stderr. Callers should exit non-zero
/// without further logging.
pub const UserError = error{
MissingApiKey,
MissingProvider,
ConflictingFlags,
AmbiguousProvider,
NotInteractive,
};
pub fn isUserError(err: anyerror) bool {
inline for (@typeInfo(UserError).error_set.?) |e| {
if (err == @field(anyerror, e.name)) return true;
}
return false;
}
const default_system_prompt = script.mcp_driver_guidance ++
\\
\\Agent-specific behavior:
@@ -164,43 +182,30 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
const is_one_shot = opts.task != null;
const will_repl = !is_one_shot and (opts.interactive or opts.script_file == null);
const needs_llm = will_repl or is_one_shot;
// Precedence: --no-llm > --provider > env auto-detect.
const effective_provider: ?Config.AiProvider = if (opts.no_llm)
null
else if (opts.provider) |p|
p
else
try autoDetectProvider();
const llm: ?Llm = if (opts.no_llm) null else try Llm.resolve(opts);
// The REPL itself can run without an LLM (basic mode), but --task,
// --self-heal, and --pick-model genuinely need one.
const requires_llm = is_one_shot or opts.self_heal or opts.pick_model;
if (effective_provider == null and requires_llm) {
const hint: []const u8 = if (opts.no_llm)
"drop --no-llm, then set an API key or pass --provider"
else if (opts.self_heal)
"--self-heal needs an LLM; set an API key or pass --provider"
else if (opts.pick_model)
"--pick-model needs an LLM; set an API key or pass --provider"
else
"set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY) or pass --provider";
log.fatal(.app, "no LLM available", .{ .hint = hint });
if (llm == null and requires_llm) {
if (opts.no_llm) {
std.debug.print("--no-llm forbids LLM use; drop it to run this mode.\n", .{});
} else if (opts.self_heal) {
std.debug.print("--self-heal needs an LLM set an API key.\n", .{});
} else if (opts.pick_model) {
std.debug.print("--pick-model needs an LLM set an API key.\n", .{});
}
return error.MissingProvider;
}
const api_key = try resolveApiKey(effective_provider, needs_llm);
// Resolve model BEFORE the heavy init so --pick-model's prompt fires
// before tool_executor / ai_client setup.
// Precedence: --model > --pick-model > defaultModel.
const model: []u8 = if (opts.model) |m|
try allocator.dupe(u8, m)
else if (opts.pick_model and effective_provider != null and api_key != null)
try pickModel(allocator, effective_provider.?, api_key.?, opts.base_url)
else if (effective_provider) |p|
try allocator.dupe(u8, defaultModel(p))
else if (llm) |l|
if (opts.pick_model) try pickModel(allocator, l, opts.base_url) else try allocator.dupe(u8, defaultModel(l.provider))
else
try allocator.dupe(u8, "");
errdefer allocator.free(model);
@@ -211,14 +216,14 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
const self = try allocator.create(Agent);
errdefer allocator.destroy(self);
const ai_client: ?zenai.provider.Client = if (api_key) |key| switch (effective_provider.?) {
const ai_client: ?zenai.provider.Client = if (llm) |l| switch (l.provider) {
inline else => |tag| blk: {
const ProviderClient = zenai.provider.Client;
const ClientPtr = @FieldType(ProviderClient, @tagName(tag));
const Client = @typeInfo(ClientPtr).pointer.child;
const client = try allocator.create(Client);
const url: ?[]const u8 = opts.base_url orelse if (tag == .ollama) "http://localhost:11434/v1" else null;
client.* = Client.init(allocator, key, if (url) |u| .{ .base_url = u } else .{});
client.* = Client.init(allocator, l.key, if (url) |u| .{ .base_url = u } else .{});
break :blk @unionInit(ProviderClient, @tagName(tag), client);
},
} else null;
@@ -393,7 +398,7 @@ fn runRepl(self: *Agent) void {
if (self.ai_client) |ai_client| {
self.terminal.printInfoFmt("Provider: {s}, Model: {s}", .{ @tagName(std.meta.activeTag(ai_client)), self.model });
} else {
self.terminal.printInfo("Basic REPL — PandaScript only. Set an API key or pass --provider for natural-language, LOGIN, and ACCEPT_COOKIES (and drop --no-llm if you set it).");
self.terminal.printInfo("Basic REPL — PandaScript only. Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY) for natural-language, LOGIN, and ACCEPT_COOKIES (and drop --no-llm if you set it).");
}
repl: while (true) {
@@ -433,7 +438,7 @@ fn runRepl(self: *Agent) void {
}
if (cmd.needsLlm() and self.ai_client == null) {
self.terminal.printError("This command needs an LLM. Set an API key or pass --provider (and drop --no-llm if you set it). PandaScript commands (GOTO, CLICK, EXTRACT, ...) work without one.");
self.terminal.printError("This command needs an LLM. Set an API key (and drop --no-llm if you set it). PandaScript commands (GOTO, CLICK, EXTRACT, ...) work without one.");
continue;
}
@@ -1194,21 +1199,55 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co
}
}
/// An API key is only required when an LLM turn will actually run. Without a
/// provider, no key is needed.
fn resolveApiKey(provider: ?Config.AiProvider, needs_llm: bool) !?[:0]const u8 {
const p = provider orelse return null;
if (zenai.provider.envApiKey(p)) |key| return key;
if (!needs_llm) return null;
log.fatal(.app, "missing API key", .{
.hint = "Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY",
});
return error.MissingApiKey;
}
/// A provider + the env key that authenticates it. The two always travel
/// together: a provider tag is only meaningful when paired with the
/// corresponding env var, and we never carry one without the other.
const Llm = struct {
provider: Config.AiProvider,
key: [:0]const u8,
/// One-shot for `--list-models`: resolve the provider (explicit, then env
/// auto-detect), reject `--no-llm`, fetch chat-capable model IDs from the
/// provider, and print them to stdout (one per line).
/// Determine which provider to use and read its env key. Returns null
/// only when no `--provider` was given AND no env key exists (the caller
/// decides whether that's fatal — basic REPL tolerates it).
fn resolve(opts: Config.Agent) !?Llm {
if (opts.provider) |p| {
const key = zenai.provider.envApiKey(p) orelse {
std.debug.print(
"Missing API key for --provider {s}: set {s} — or pass --no-llm for the basic REPL.\n",
.{ @tagName(p), envVarName(p) },
);
return error.MissingApiKey;
};
return .{ .provider = p, .key = key };
}
const candidates = [_]Config.AiProvider{ .anthropic, .openai, .gemini };
var found: [candidates.len]Llm = undefined;
var n: usize = 0;
for (candidates) |p| if (zenai.provider.envApiKey(p)) |key| {
found[n] = .{ .provider = p, .key = key };
n += 1;
};
return switch (n) {
0 => blk: {
std.debug.print(
"No API key detected. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY — or pass --no-llm for the basic REPL.\n",
.{},
);
break :blk null;
},
1 => blk: {
std.debug.print("Detected {s} — using --provider {s}.\n", .{ envVarName(found[0].provider), @tagName(found[0].provider) });
break :blk found[0];
},
else => try pickProvider(found[0..n]),
};
}
};
/// One-shot for `--list-models`: resolve provider+key, fetch chat-capable
/// model IDs, print to stdout (one per line).
pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
if (opts.no_llm) {
log.fatal(.app, "list-models needs LLM", .{
@@ -1224,23 +1263,11 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
});
return error.ConflictingFlags;
}
const provider = opts.provider orelse (try autoDetectProvider()) orelse {
log.fatal(.app, "list-models needs LLM", .{
.hint = "set ANTHROPIC_API_KEY (or OPENAI_API_KEY / GOOGLE_API_KEY) or pass --provider",
});
return error.MissingProvider;
};
const api_key = zenai.provider.envApiKey(provider) orelse {
log.fatal(.app, "missing API key", .{
.provider = @tagName(provider),
.env = envVarName(provider),
});
return error.MissingApiKey;
};
const llm = (try Llm.resolve(opts)) orelse return error.MissingProvider;
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const ids = try zenai.provider.listChatModelIds(allocator, arena.allocator(), provider, api_key, opts.base_url);
const ids = try zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, opts.base_url);
var stdout_file = std.fs.File.stdout().writer(&.{});
const w = &stdout_file.interface;
@@ -1248,37 +1275,6 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
try w.flush();
}
/// Pick a provider from env keys when `--provider` was not given.
/// Notices go to stderr unconditionally so users always know which mode they're in.
pub fn autoDetectProvider() !?Config.AiProvider {
const candidates = [_]Config.AiProvider{ .anthropic, .openai, .gemini };
var found_buf: [candidates.len]Config.AiProvider = undefined;
var found_len: usize = 0;
for (candidates) |p| {
if (zenai.provider.envApiKey(p) != null) {
found_buf[found_len] = p;
found_len += 1;
}
}
const found = found_buf[0..found_len];
return switch (found.len) {
0 => blk: {
std.debug.print(
"No API key detected. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY, or pass --provider — or pass --no-llm for the basic REPL.\n",
.{},
);
break :blk null;
},
1 => blk: {
const p = found[0];
std.debug.print("Detected {s} — using --provider {s}.\n", .{ envVarName(p), @tagName(p) });
break :blk p;
},
else => try promptForProvider(found),
};
}
fn envVarName(p: Config.AiProvider) []const u8 {
return switch (p) {
.anthropic => "ANTHROPIC_API_KEY",
@@ -1297,7 +1293,7 @@ fn defaultModel(p: Config.AiProvider) []const u8 {
};
}
fn promptForProvider(found: []const Config.AiProvider) !Config.AiProvider {
fn pickProvider(found: []const Llm) !Llm {
if (!interactiveTty()) {
log.fatal(.app, "multiple API keys detected", .{
.hint = "Pass --provider explicitly when running non-interactively",
@@ -1305,10 +1301,10 @@ fn promptForProvider(found: []const Config.AiProvider) !Config.AiProvider {
return error.AmbiguousProvider;
}
var labels_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len][]const u8 = undefined;
for (found, 0..) |p, i| labels_buf[i] = @tagName(p);
var labels: [@typeInfo(Config.AiProvider).@"enum".fields.len][]const u8 = undefined;
for (found, 0..) |f, i| labels[i] = @tagName(f.provider);
const idx = promptNumberedChoice("Multiple API keys detected. Pick provider:", labels_buf[0..found.len], null) catch {
const idx = promptNumberedChoice("Multiple API keys detected. Pick provider:", labels[0..found.len], null) catch {
std.debug.print("Cancelled — pass --provider to skip the picker.\n", .{});
return error.UserCancelled;
};
@@ -1319,12 +1315,7 @@ fn promptForProvider(found: []const Config.AiProvider) !Config.AiProvider {
/// one. Empty input picks the baked-in default. Always returns an owned
/// heap buffer (including for the default case) so the caller has one
/// uniform free path.
fn pickModel(
allocator: std.mem.Allocator,
provider: Config.AiProvider,
api_key: [:0]const u8,
base_url: ?[:0]const u8,
) ![]u8 {
fn pickModel(allocator: std.mem.Allocator, llm: Llm, base_url: ?[:0]const u8) ![]u8 {
if (!interactiveTty()) {
log.fatal(.app, "pick-model needs a TTY", .{
.hint = "rerun in a terminal or pass --model explicitly",
@@ -1337,17 +1328,17 @@ fn pickModel(
// Runs before `SigBridge.attach` — Ctrl-C during the synchronous HTTP
// fetch is dropped; the picker prompt catches the next press via stdin EINTR.
std.debug.print("Fetching models for {s}…\n", .{@tagName(provider)});
const ids = zenai.provider.listChatModelIds(allocator, arena.allocator(), provider, api_key, base_url) catch |err| {
std.debug.print("Fetching models for {s}…\n", .{@tagName(llm.provider)});
const ids = zenai.provider.listChatModelIds(allocator, arena.allocator(), llm.provider, llm.key, base_url) catch |err| {
log.fatal(.app, "list models failed", .{ .err = @errorName(err) });
return err;
};
if (ids.len == 0) {
log.fatal(.app, "no models returned", .{ .provider = @tagName(provider) });
log.fatal(.app, "no models returned", .{ .provider = @tagName(llm.provider) });
return error.NoModels;
}
const default_model = defaultModel(provider);
const default_model = defaultModel(llm.provider);
var default_idx: ?usize = null;
for (ids, 0..) |id, i| if (std.mem.eql(u8, id, default_model)) {
default_idx = i;
@@ -1356,7 +1347,7 @@ fn pickModel(
var header_buf: [128]u8 = undefined;
const enter_hint: []const u8 = if (default_idx == null) "" else " (Enter for default)";
const header = std.fmt.bufPrint(&header_buf, "Pick model for {s}{s}:", .{ @tagName(provider), enter_hint }) catch
const header = std.fmt.bufPrint(&header_buf, "Pick model for {s}{s}:", .{ @tagName(llm.provider), enter_hint }) catch
"Pick model:";
const idx = promptNumberedChoice(header, ids, default_idx) catch {

View File

@@ -43,12 +43,13 @@ pub fn main() !void {
const main_arena = main_arena_instance.allocator();
defer main_arena_instance.deinit();
run(gpa, main_arena) catch |err| switch (err) {
error.UserCancelled => std.posix.exit(130),
else => {
log.fatal(.app, "exit", .{ .err = err });
std.posix.exit(1);
},
run(gpa, main_arena) catch |err| {
if (err == error.UserCancelled) std.posix.exit(130);
// error.AgentFailed: the agent thread reported the failure in-context.
// lp.Agent.UserError: a user-facing message was already printed.
if (err == error.AgentFailed or lp.Agent.isUserError(err)) std.posix.exit(1);
log.fatal(.app, "exit", .{ .err = err });
std.posix.exit(1);
};
}
@@ -215,7 +216,8 @@ fn agentThread(allocator: std.mem.Allocator, app: *App, opts: Config.Agent, fail
if (err == error.UserCancelled) {
cancelled.* = true;
} else {
log.fatal(.app, "agent init error", .{ .err = err });
// UserError: message already printed inside Agent.init.
if (!lp.Agent.isUserError(err)) log.fatal(.app, "agent init error", .{ .err = err });
failed.* = true;
}
return;