From 938795ec8deacccb1f8051c35e4f5df22a765433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sat, 9 May 2026 20:11:52 +0200 Subject: [PATCH] agent: add --pick-model for interactive selection --- build.zig.zon | 4 +- src/Config.zig | 7 +++ src/agent/Agent.zig | 100 ++++++++++++++++++++++++++++++++++++-- src/agent/list_models.zig | 73 ++-------------------------- 4 files changed, 110 insertions(+), 74 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 87922c4f..5ec074d6 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#580f172579e211b07ecaff311149543e6a47a1f3", - .hash = "zenai-0.0.0-iOY_VASPAwAYCAzeFaDegm0F227A-I-hYNFnZpV1yw3A", + .url = "git+https://github.com/lightpanda-io/zenai.git#fb7d356617dcf7559c2d47d56b73d9b082f81b9b", + .hash = "zenai-0.0.0-iOY_VOebAwA19L7IyQKx8CJRDbNwf75jN_nJ_F3OFD8m", }, .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 d0ee3516..2e5bcdb3 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -202,6 +202,7 @@ const Commands = cli.Builder(.{ .{ .name = "verbosity", .type = AgentVerbosity, .default = AgentVerbosity.low }, .{ .name = "list_models", .type = bool }, .{ .name = "no_llm", .type = bool }, + .{ .name = "pick_model", .type = bool }, }, .shared_options = CommonOptions, }, @@ -781,6 +782,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ \\--model The model name to use. \\ Defaults to a sensible default per provider. + \\ Wins over --pick-model. + \\ + \\--pick-model Fetch the provider's model list and prompt you to + \\ pick one at startup, instead of using the baked-in + \\ default. Requires a TTY. Ignored when --model is + \\ also passed. \\ \\--base-url Override the API base URL for the provider. \\ Defaults to the provider's standard endpoint. diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index d988a815..3c4db43e 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -99,6 +99,10 @@ recorder: Recorder, messages: std.ArrayList(zenai.provider.Message), message_arena: std.heap.ArenaAllocator, model: []const u8, +/// When non-null, `model` aliases this heap buffer (allocated by --pick-model) +/// and `deinit` must free it. When null, `model` aliases an arg-parser slice +/// or a comptime literal (`zenai.provider.defaultModel`) and we don't own it. +model_owned: ?[]u8, system_prompt: []const u8, script_file: ?[]const u8, self_heal: bool, @@ -120,14 +124,16 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self else try autoDetectProvider(); - // The REPL itself can run without an LLM (basic mode), but --task and - // --self-heal genuinely need one. - const requires_llm = is_one_shot or opts.self_heal; + // 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 }); @@ -136,6 +142,22 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self 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. + var model_owned: ?[]u8 = null; + errdefer if (model_owned) |buf| allocator.free(buf); + const model: []const u8 = if (opts.model) |m| + m + else if (opts.pick_model and effective_provider != null and api_key != null) blk: { + const picked = try pickModel(allocator, effective_provider.?, api_key.?, opts.base_url); + model_owned = picked; + break :blk picked; + } else if (effective_provider) |p| + zenai.provider.defaultModel(p) + else + ""; + const tool_executor: *ToolExecutor = try .init(allocator, app); errdefer tool_executor.deinit(); @@ -186,7 +208,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self .recorder = .init(allocator, recorder_path), .messages = .empty, .message_arena = .init(allocator), - .model = if (effective_provider) |p| (opts.model orelse zenai.provider.defaultModel(p)) else "", + .model = model, + .model_owned = model_owned, .system_prompt = opts.system_prompt orelse default_system_prompt, .script_file = opts.script_file, .self_heal = opts.self_heal, @@ -217,6 +240,7 @@ pub fn deinit(self: *Self) void { }, } } + if (self.model_owned) |buf| self.allocator.free(buf); self.allocator.destroy(self); } @@ -1011,6 +1035,74 @@ fn promptForProvider(found: []const Config.AiProvider) !Config.AiProvider { return error.AmbiguousProvider; } +/// Fetch the provider's chat-capable model list and prompt the user to pick +/// 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 { + const stdin_tty = std.posix.isatty(std.posix.STDIN_FILENO); + const stderr_tty = std.posix.isatty(std.posix.STDERR_FILENO); + if (!stdin_tty or !stderr_tty) { + log.fatal(.app, "pick-model needs a TTY", .{ + .hint = "rerun in a terminal or pass --model explicitly", + }); + return error.NotInteractive; + } + + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + std.debug.print("Fetching models for {s}…\n", .{@tagName(provider)}); + const ids = zenai.provider.listChatModelIds(allocator, arena, provider, api_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) }); + return error.NoModels; + } + + const default_model = zenai.provider.defaultModel(provider); + + var stdin_buf: [128]u8 = undefined; + var stdin = std.fs.File.stdin().reader(&stdin_buf); + + var attempt: u8 = 0; + while (attempt < 3) : (attempt += 1) { + std.debug.print("Pick model for {s} (Enter for default):\n", .{@tagName(provider)}); + for (ids, 0..) |id, idx| { + const marker: []const u8 = if (std.mem.eql(u8, id, default_model)) " (default)" else ""; + std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, id, marker }); + } + std.debug.print("> ", .{}); + + const line = stdin.interface.takeDelimiterExclusive('\n') catch |err| switch (err) { + error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled, + }; + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len == 0) { + std.debug.print("Using default: {s}\n", .{default_model}); + return try allocator.dupe(u8, default_model); + } + const choice = std.fmt.parseInt(usize, trimmed, 10) catch { + std.debug.print("Invalid input — type a number (or press Enter for default).\n", .{}); + continue; + }; + if (choice >= 1 and choice <= ids.len) { + return try allocator.dupe(u8, ids[choice - 1]); + } + std.debug.print("Out of range.\n", .{}); + } + log.fatal(.app, "could not pick model", .{ .hint = "Pass --model explicitly" }); + return error.NoChoice; +} + // --- Tests --- test "isHealAllowed: blocks goto and eval_js, allows page-local commands" { diff --git a/src/agent/list_models.zig b/src/agent/list_models.zig index 59b93779..fc16c503 100644 --- a/src/agent/list_models.zig +++ b/src/agent/list_models.zig @@ -5,14 +5,9 @@ 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. +/// List the chat-capable models for `provider` and print their IDs to stdout, +/// one per line, sorted. The per-provider listing logic lives in +/// `zenai.provider.listChatModelIds`. 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", .{ @@ -29,68 +24,10 @@ pub fn run(allocator: Allocator, provider: ProviderKind, base_url_override: ?[:0 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); + const ids = try zenai.provider.listChatModelIds(allocator, arena_state.allocator(), provider, api_key, base_url_override); var stdout_file = std.fs.File.stdout().writer(&.{}); const w = &stdout_file.interface; - for (ids.items) |id| try w.print("{s}\n", .{id}); + for (ids) |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); -}