diff --git a/build.zig.zon b/build.zig.zon index c5b8b61e..87922c4f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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", diff --git a/src/Config.zig b/src/Config.zig index 8e493a39..81d9b5aa 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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; diff --git a/src/agent.zig b/src/agent.zig index 69d646c4..d65ffa17 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -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; diff --git a/src/agent/list_models.zig b/src/agent/list_models.zig new file mode 100644 index 00000000..59b93779 --- /dev/null +++ b/src/agent/list_models.zig @@ -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/"; 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); +} diff --git a/src/main.zig b/src/main.zig index 234798e7..9bd944d3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 => {}, }