agent: add --list-models flag

This commit is contained in:
Adrià Arrufat
2026-05-09 19:16:48 +02:00
parent 5f2a46b311
commit 357033eb0c
5 changed files with 112 additions and 2 deletions

View File

@@ -39,8 +39,8 @@
.hash = "N-V-__8AAJ4HAgCX79UDBfNwhqAqUVoGC44ib6UYa18q6oa_",
},
.zenai = .{
.url = "git+https://github.com/lightpanda-io/zenai.git#37cad37184736fafea8afcff9d1593b7009a71eb",
.hash = "zenai-0.0.0-iOY_VGRyAwCRQnr-DCFhnQBC-eM2JVHjS1mUKWBHTWmm",
.url = "git+https://github.com/lightpanda-io/zenai.git#580f172579e211b07ecaff311149543e6a47a1f3",
.hash = "zenai-0.0.0-iOY_VASPAwAYCAzeFaDegm0F227A-I-hYNFnZpV1yw3A",
},
.libidn2 = .{
.url = "https://ftp.gnu.org/gnu/libidn/libidn2-2.3.8.tar.gz",

View File

@@ -200,6 +200,7 @@ const Commands = cli.Builder(.{
.{ .name = "task", .type = ?[]const u8 },
.{ .name = "task_attachments", .type = []const u8, .multiple = true },
.{ .name = "verbosity", .type = AgentVerbosity, .default = AgentVerbosity.low },
.{ .name = "list_models", .type = bool },
},
.shared_options = CommonOptions,
},
@@ -783,6 +784,10 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ a positional script is present, any new commands
\\ entered in the REPL are appended to that file.
\\
\\--list-models Print the model IDs usable with `agent` for
\\ --provider, one per line, sorted, and exit.
\\ Requires --provider; the API key must be set.
\\
\\--verbosity Stderr chatter level: low, medium, high.
\\ Default: low. In a TTY REPL, low shows a single-
\\ line agent indicator and a per-turn summary;

View File

@@ -6,6 +6,7 @@ pub const CommandExecutor = @import("agent/CommandExecutor.zig");
pub const Recorder = @import("agent/Recorder.zig");
pub const Verifier = @import("agent/Verifier.zig");
pub const SlashCommand = @import("agent/SlashCommand.zig");
pub const listModels = @import("agent/list_models.zig").run;
test {
_ = Agent;

96
src/agent/list_models.zig Normal file
View File

@@ -0,0 +1,96 @@
const std = @import("std");
const zenai = @import("zenai");
const log = @import("../log.zig");
const Allocator = std.mem.Allocator;
const ProviderKind = zenai.provider.ProviderKind;
/// List the models usable with the lightpanda agent for `provider` and print
/// their IDs to stdout, one per line, sorted alphabetically. Returns
/// `error.MissingApiKey` when the provider's env var isn't set; other errors
/// propagate from the underlying HTTP call.
///
/// Filtering uses each provider's `isChatModel` predicate from zenai.
/// Ollama is unfiltered — local catalogs don't follow a naming convention
/// the heuristic could rely on.
pub fn run(allocator: Allocator, provider: ProviderKind, base_url_override: ?[:0]const u8) !void {
const api_key = zenai.provider.envApiKey(provider) orelse {
log.fatal(.app, "missing API key", .{
.provider = @tagName(provider),
.env = switch (provider) {
.anthropic => "ANTHROPIC_API_KEY",
.openai => "OPENAI_API_KEY",
.gemini => "GOOGLE_API_KEY or GEMINI_API_KEY",
.ollama => "(none)",
},
});
return error.MissingApiKey;
};
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var ids: std.ArrayList([]const u8) = .empty;
switch (provider) {
.anthropic => {
var client = zenai.anthropic.Client.init(allocator, api_key, .{});
defer client.deinit();
var resp = try client.listModels();
defer resp.deinit();
for (resp.value.data orelse &.{}) |m| {
if (!zenai.anthropic.Client.isChatModel(m)) continue;
if (m.id) |id| try ids.append(arena, try arena.dupe(u8, id));
}
},
.openai => {
var client = zenai.openai.Client.init(allocator, api_key, if (base_url_override) |u| .{ .base_url = u } else .{});
defer client.deinit();
var resp = try client.listModels();
defer resp.deinit();
for (resp.value.data orelse &.{}) |m| {
if (!zenai.openai.Client.isChatModel(m)) continue;
if (m.id) |id| try ids.append(arena, try arena.dupe(u8, id));
}
},
.ollama => {
const opts: zenai.openai.Client.InitOptions = if (base_url_override) |u|
.{ .base_url = u }
else
.{ .base_url = "http://localhost:11434/v1" };
var client = zenai.openai.Client.init(allocator, api_key, opts);
defer client.deinit();
var resp = try client.listModels();
defer resp.deinit();
for (resp.value.data orelse &.{}) |m| {
if (m.id) |id| try ids.append(arena, try arena.dupe(u8, id));
}
},
.gemini => {
var client = zenai.gemini.Client.init(allocator, api_key, .{});
defer client.deinit();
var resp = try client.listModels(.{});
defer resp.deinit();
for (resp.value.models orelse &.{}) |m| {
if (!zenai.gemini.Client.isChatModel(m)) continue;
const name = m.name orelse continue;
// Gemini returns "models/<id>"; strip the prefix so the
// output is pipe-ready into `--model`.
const stripped = if (std.mem.startsWith(u8, name, "models/")) name["models/".len..] else name;
try ids.append(arena, try arena.dupe(u8, stripped));
}
},
}
std.mem.sort([]const u8, ids.items, {}, lessThan);
var stdout_file = std.fs.File.stdout().writer(&.{});
const w = &stdout_file.interface;
for (ids.items) |id| try w.print("{s}\n", .{id});
try w.flush();
}
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}

View File

@@ -63,6 +63,14 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
try stdout.interface.print("{s}\n", .{lp.build_config.version});
return std.process.cleanExit();
},
.agent => |opts| if (opts.list_models) {
const provider = opts.provider orelse {
log.fatal(.app, "missing --provider", .{ .flag = "--list-models" });
return args.printUsageAndExit(false);
};
try lp.agent.listModels(allocator, provider, opts.base_url);
return std.process.cleanExit();
},
else => {},
}