//! Subcommand dispatch for the `panto` CLI. //! //! Routes argv[1] to one of: //! - `lua` — drop into the embedded standalone Lua interpreter //! (panto's `lua.c` build), with luarocks's runtime //! bootstrap completed first so `require("luarocks.*")` //! and the configured rocks tree work the same as in //! the agent process. //! - `bootstrap` — run the luarocks runtime bootstrap pipeline only; //! exit before entering any agent loop. Lets users //! do first-run setup on a fresh machine without //! starting a chat session. //! - anything else (or absent) — fall through to the agent REPL. //! //! Both `lua` and `bootstrap` end up calling `luarocks_runtime.bootstrap` //! before doing their thing. The agent path does the same; the only //! difference is whether the agent loop runs afterward. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const lua_bridge = @import("lua_bridge.zig"); const luarocks_runtime = @import("luarocks_runtime.zig"); const session_paths = @import("session_paths.zig"); const config_file = @import("config_file.zig"); const auth_manager = @import("auth_manager.zig"); const models_toml = @import("models_toml.zig"); const panto_home = @import("panto_home.zig"); const panto = @import("panto"); const c = lua_bridge.c; pub const Action = enum { /// Continue with the default agent REPL. agent, /// Bootstrap is already done; the dispatcher consumed the subcommand. /// `main` should exit immediately. done, }; /// Inspect `argv[1]`, run the appropriate subcommand, and return what /// the caller should do next. On `.agent`, the dispatcher leaves argv /// untouched and `main` continues as before. On `.done`, the caller /// must return promptly (the subcommand has already produced output). /// /// `panto_executable_path` is the absolute path of the running panto /// binary, used both to wire up the embedded luarocks `LUA` variable /// and to `exec` ourselves where needed. pub fn dispatch( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, args: std.process.Args, panto_executable_path: []const u8, ) !Action { var it = args.iterate(); defer it.deinit(); _ = it.next(); // argv[0] const sub = it.next() orelse return .agent; if (std.mem.eql(u8, sub, "lua")) { try runLuaSubcommand(allocator, io, environ_map, args, panto_executable_path); return .done; } if (std.mem.eql(u8, sub, "bootstrap")) { var force = false; while (it.next()) |flag| { if (std.mem.eql(u8, flag, "--force")) { force = true; } else { std.log.err("panto bootstrap: unknown flag '{s}'", .{flag}); return error.UnknownFlag; } } try runBootstrapSubcommand(allocator, io, environ_map, panto_executable_path, .{ .force = force }); return .done; } if (std.mem.eql(u8, sub, "update")) { try runUpdateSubcommand(allocator, io, environ_map, panto_executable_path); return .done; } if (std.mem.eql(u8, sub, "sessions")) { try runSessionsSubcommand(allocator, io, environ_map); return .done; } if (std.mem.eql(u8, sub, "models")) { try runModelsSubcommand(allocator, io, environ_map, &it); return .done; } if (std.mem.eql(u8, sub, "auth")) { try runAuthSubcommand(allocator, io, environ_map, &it); return .done; } if (std.mem.eql(u8, sub, "--help") or std.mem.eql(u8, sub, "-h") or std.mem.eql(u8, sub, "help")) { try printHelp(io); return .done; } return .agent; } fn printHelp(io: Io) !void { var buffer: [4096]u8 = undefined; var stdout_file = std.Io.File.stdout().writer(io, &buffer); const w = &stdout_file.interface; try w.writeAll( \\panto — a conversational coding agent \\ \\Usage: \\ panto Start a new conversation. \\ panto --resume Resume the most recent conversation in this directory. \\ panto --resume Resume the conversation whose id begins with . \\ panto sessions List saved sessions for this directory. \\ panto models sync Fetch models.dev and rebuild the base models.toml. \\ panto auth status Show configured auth sessions and login state. \\ panto auth login \\ Log in to an OAuth auth session (device flow). \\ panto auth logout \\ Forget a stored OAuth token. \\ panto bootstrap [--force] \\ Run the luarocks bootstrap and exit. \\ panto update Install/update the rocks in extensions.rocks. \\ panto lua [args...] Drop into the embedded Lua interpreter. \\ panto help Show this message. \\ \\Configuration (TOML, merged base → user → project): \\ $XDG_DATA_HOME/panto/config.toml (base; auto-generated) \\ $XDG_CONFIG_HOME/panto/config.toml (user) \\ ./.panto/config.toml (project) \\ Define providers under [providers.], pick a default with \\ [defaults] model = ":", and gate extensions with \\ [extensions] allow/deny globs (plus [extensions] paths/rocks to add \\ sources). Model aliases (wire name, reasoning, max_tokens, pricing) \\ live in models.toml. \\ \\Environment: \\ OPENAI_API_KEY, ANTHROPIC_API_KEY Consumed by the default providers. \\ PANTO_DEBUG Write std.log output to /debug/.log. \\ PANTO_SESSION_DIR Override the base sessions directory. Defaults to \\ $XDG_DATA_HOME/panto/sessions or ~/.local/share/panto/sessions. \\ ); try stdout_file.flush(); } pub const BootstrapOptions = struct { /// Wipe the per-Lua-version tree before reinstalling everything. /// Surfaced as `panto bootstrap --force`. Equivalent to deleting /// the data-home `rocks/lua-X.Y.Z/` tree by hand and then running /// `panto bootstrap`. force: bool = false, }; // --------------------------------------------------------------------------- // `panto lua` // --------------------------------------------------------------------------- extern "c" fn panto_lua_pmain(L: *c.lua_State, argc: c_int, argv: [*]?[*:0]u8) c_int; /// Drop into the embedded Lua standalone interpreter, with the /// luarocks runtime bootstrap completed so `require("luarocks.*")` /// and rocks installed under the panto data home are visible. /// /// argv is rewritten so the interpreter sees `lua [...args]` rather /// than `panto lua [...args]` — matching upstream behavior. The first /// argument visible to `pmain` is the program name; this matters for /// `arg[0]` and error reporting. fn runLuaSubcommand( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, args: std.process.Args, panto_executable_path: []const u8, ) !void { // Build a fresh lua_State that we own, configure it like luarocks // expects, then hand it to `pmain`. const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); // Run bootstrap against this state. This installs the embedded // searcher, configures package.path/cpath, and stages on-disk // resources. We deliberately do NOT call `luaL_openlibs` here — // `pmain` does that itself, and we want exactly the upstream // ordering for everything that runs inside the REPL. // // The searcher install only requires `package.searchers` to be // present; the stock libs ship it. We open libs once here just // to satisfy that, then pmain's own `luaL_openlibs` is idempotent. c.luaL_openlibs(L); const rt = try luarocks_runtime.bootstrap( allocator, io, environ_map, L, panto_executable_path, ); defer rt.deinit(); // Re-create the argv the standalone interpreter expects. argv[0] // is the program name; argv[1..] are the user's args. var raw_args = args.iterate(); defer raw_args.deinit(); _ = raw_args.next(); // panto _ = raw_args.next(); // lua var argv_list: std.array_list.Managed([:0]u8) = .init(allocator); defer { for (argv_list.items) |s| allocator.free(s); argv_list.deinit(); } // Program name first. try argv_list.append(try allocator.dupeZ(u8, "lua")); while (raw_args.next()) |a| { try argv_list.append(try allocator.dupeZ(u8, a)); } // Build a `[*]?[*:0]u8` argv pointer array. lua.c expects a // NULL-terminated array (it uses `argv[i]` indexed access through // argc; the trailing NULL is conventional for C `main`). var argv_c: std.array_list.Managed(?[*:0]u8) = .init(allocator); defer argv_c.deinit(); for (argv_list.items) |s| { try argv_c.append(s.ptr); } try argv_c.append(null); const exit_code = panto_lua_pmain(L, @intCast(argv_list.items.len), argv_c.items.ptr); if (exit_code != 0) std.process.exit(@intCast(exit_code)); } // --------------------------------------------------------------------------- // `panto bootstrap` // --------------------------------------------------------------------------- /// Run the luarocks bootstrap and exit. Useful for first-run setup on /// a clean machine (downloads + compiles batteries, stages headers, /// materializes config) and for CI/scripted installs. /// /// Idempotent: subsequent invocations no-op fast, unless `force` was /// passed — then the entire per-Lua-version tree is wiped before the /// regular bootstrap pipeline runs. fn runBootstrapSubcommand( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, panto_executable_path: []const u8, opts: BootstrapOptions, ) !void { if (opts.force) { try luarocks_runtime.wipeTree(allocator, io, environ_map); } const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); const rt = try luarocks_runtime.bootstrap( allocator, io, environ_map, L, panto_executable_path, ); defer rt.deinit(); // Pleasant single-line confirmation. The interesting bits (rock // installs etc.) print their own progress. std.log.info( "panto bootstrap: tree ready at {s}", .{rt.layout.tree}, ); } // --------------------------------------------------------------------------- // `panto update` // --------------------------------------------------------------------------- /// `panto update` — (re)install every rock listed in `extensions.rocks`, /// unconditionally. This is the one place we intentionally hit luarocks (and /// the network): startup only installs a rock that is missing, so changing a /// pin within an already-satisfied range, or forcing a re-resolve, is done /// here. Analogous to `bootstrap`, but for user rocks rather than batteries. fn runUpdateSubcommand( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, panto_executable_path: []const u8, ) !void { var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd_n = try std.process.currentPath(io, &cwd_buf); const cwd = cwd_buf[0..cwd_n]; var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| { std.log.err("panto update: failed to load config ({t})", .{err}); return err; }; defer cfg.deinit(); var out_buf: [1024]u8 = undefined; var out_file = std.Io.File.stdout().writer(io, &out_buf); const out = &out_file.interface; if (cfg.ext_rocks.len == 0) { try out.writeAll("panto update: no extensions.rocks configured\n"); try out_file.flush(); return; } const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); const rt = try luarocks_runtime.bootstrap(allocator, io, environ_map, L, panto_executable_path); defer rt.deinit(); var installed: usize = 0; var failed: usize = 0; for (cfg.ext_rocks) |spec| { std.log.info("panto update: installing '{s}'", .{spec.value}); luarocks_runtime.installRock(rt, allocator, spec.value) catch |err| { std.log.err("panto update: failed to install '{s}': {t}", .{ spec.value, err }); failed += 1; continue; }; installed += 1; } try out.print("panto update: {d} rock(s) installed, {d} failed\n", .{ installed, failed }); try out_file.flush(); if (failed > 0) return error.LuarocksInstallFailed; } // --------------------------------------------------------------------------- // `panto sessions` // --------------------------------------------------------------------------- /// List sessions for the current working directory. /// /// Output format (one session per line): /// messages /// /// where `` is the first 8 hex chars of the session UUIDv7. fn runSessionsSubcommand( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, ) !void { var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd_n = try std.process.currentPath(io, &cwd_buf); const cwd = cwd_buf[0..cwd_n]; const session_dir = try session_paths.sessionDirForCwd(allocator, environ_map, cwd); defer allocator.free(session_dir); var store_impl = try panto.FileSystemJSONLStore.init(allocator, io, session_dir); defer store_impl.deinit(); const store = store_impl.store(); const infos = try store.list(); defer store.freeSessionInfos(infos); var stdout_buffer: [4096]u8 = undefined; var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_file.interface; if (infos.len == 0) { try stdout.print("no sessions for {s}\n", .{cwd}); try stdout_file.flush(); return; } for (infos) |info| { const short = info.id[0..@min(8, info.id.len)]; // `created` is ISO 8601 (e.g. `2026-04-25T17:40:15.990Z`). Trim // to `YYYY-MM-DD HH:MM` for terseness. const created_short = trimCreated(info.created); try stdout.print( "{s} {s} {d} messages\n", .{ short, created_short, info.message_count }, ); } try stdout_file.flush(); } fn trimCreated(iso: []const u8) []const u8 { if (iso.len < 16) return iso; // `YYYY-MM-DDTHH:MM:...` → `YYYY-MM-DD HH:MM` (T → space). // We can't mutate a borrowed slice, so just return a 16-byte slice // of the original; the caller prints character-by-character via // format, so the 'T' will still appear. Use a small buffer trick: // return the slice unmodified — the 'T' is fine and unambiguous. return iso[0..16]; } // --------------------------------------------------------------------------- // `panto models` // --------------------------------------------------------------------------- const ModelsSyncResult = struct { content: []u8, providers_synced: usize, providers_skipped: usize, models_written: usize, }; /// `panto models sync` — fetch models.dev and rebuild the base-layer /// `models.toml` from the configured providers only. fn runModelsSubcommand( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, it: *std.process.Args.Iterator, ) !void { var out_buf: [4096]u8 = undefined; var out_file = std.Io.File.stdout().writer(io, &out_buf); const out = &out_file.interface; const action = it.next() orelse { try out.writeAll("usage: panto models sync\n"); try out_file.flush(); return; }; if (!std.mem.eql(u8, action, "sync")) { try out.print("panto models: unknown action '{s}'\nusage: panto models sync\n", .{action}); try out_file.flush(); return; } if (it.next()) |extra| { try out.print("panto models sync: unexpected argument '{s}'\n", .{extra}); try out_file.flush(); return; } var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd_n = try std.process.currentPath(io, &cwd_buf); const cwd = cwd_buf[0..cwd_n]; var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| { std.log.err("panto models sync: failed to load config ({t})", .{err}); return err; }; defer cfg.deinit(); var layout = try panto_home.resolve(allocator, environ_map); defer layout.deinit(); panto.init(allocator, io); defer panto.deinit(); const client = panto.httpClient(); var auth_mgr = auth_manager.AuthManager.init(allocator, io, client, layout.auth_dir, &cfg); defer auth_mgr.deinit(); const catalog_json = try fetchModelsDevCatalog(allocator, client); defer allocator.free(catalog_json); const sync = try buildSyncedModelsToml(allocator, &cfg, &auth_mgr, catalog_json); defer allocator.free(sync.content); const base_models_path = try models_toml.basePath(allocator, environ_map); defer allocator.free(base_models_path); if (std.fs.path.dirname(base_models_path)) |parent| { Io.Dir.cwd().createDirPath(io, parent) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; } try Io.Dir.cwd().writeFile(io, .{ .sub_path = base_models_path, .data = sync.content }); try out.print( "synced {d} model(s) across {d} provider(s) to {s}\n", .{ sync.models_written, sync.providers_synced, base_models_path }, ); if (sync.providers_skipped > 0) { try out.print( "{d} configured provider(s) had no matching catalog entry or no usable synced models and were skipped\n", .{sync.providers_skipped}, ); } try out_file.flush(); } fn fetchModelsDevCatalog(allocator: Allocator, client: *std.http.Client) ![]u8 { var body: std.Io.Writer.Allocating = .init(allocator); defer body.deinit(); const headers = [_]std.http.Header{.{ .name = "User-Agent", .value = "panto models sync" }}; const res = try client.fetch(.{ .location = .{ .url = "https://models.dev/api.json" }, .extra_headers = &headers, .response_writer = &body.writer, }); if (res.status != .ok) return error.ModelCatalogFetchFailed; return try body.toOwnedSlice(); } fn buildSyncedModelsToml( allocator: Allocator, cfg: *const config_file.Config, auth_mgr: *auth_manager.AuthManager, catalog_json: []const u8, ) !ModelsSyncResult { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, catalog_json, .{}); defer parsed.deinit(); const root = switch (parsed.value) { .object => |obj| obj, else => return error.InvalidModelCatalog, }; var out: std.Io.Writer.Allocating = .init(allocator); errdefer out.deinit(); try out.writer.writeAll( "# panto base models config (generated by `panto models sync`).\n" ++ "#\n" ++ "# This is the lowest-precedence models layer. Override anything here in:\n" ++ "# ~/.config/panto/models.toml\n" ++ "# ./.panto/models.toml\n" ++ "# ./.panto/models.local.toml\n" ++ "#\n" ++ "# Generated from https://models.dev/api.json for the providers currently\n" ++ "# configured in panto. When a configured provider exposes a JSON `GET\n" ++ "# /models` endpoint, the synced list is intersected with that live\n" ++ "# provider listing to drop unsupported/outdated entries. Safe to\n" ++ "# overwrite by running the sync again.\n\n", ); var providers_synced: usize = 0; var providers_skipped: usize = 0; var models_written: usize = 0; for (cfg.providers) |*p| { const catalog_name = p.model_catalog_name orelse p.name; const provider_v = root.get(catalog_name) orelse { providers_skipped += 1; continue; }; const provider_obj = switch (provider_v) { .object => |obj| obj, else => { providers_skipped += 1; continue; }, }; const models_v = provider_obj.get("models") orelse { providers_skipped += 1; continue; }; const models_obj = switch (models_v) { .object => |obj| obj, else => { providers_skipped += 1; continue; }, }; var provider_models_filter = try fetchProviderModelsFilter(allocator, auth_mgr, cfg, p); defer if (provider_models_filter) |*f| f.deinit(); var wrote_provider = false; var it_models = models_obj.iterator(); while (it_models.next()) |kv| { const model_obj = switch (kv.value_ptr.*) { .object => |obj| obj, else => continue, }; const model_id = if (model_obj.get("id")) |id_v| switch (id_v) { .string => |s| s, else => kv.key_ptr.*, } else kv.key_ptr.*; if (provider_models_filter) |*filter| { if (!filter.containsForStyle(model_id, p.style)) continue; } if (!wrote_provider) { wrote_provider = true; providers_synced += 1; try out.writer.print("# provider {s} (catalog: {s})\n", .{ p.name, catalog_name }); } try writeTomlTableHeader(&out.writer, p.name, model_id); if (model_obj.get("limit")) |limit_v| switch (limit_v) { .object => |limit_obj| { if (limit_obj.get("context")) |context_v| { if (jsonPositiveU32(context_v)) |n| { try out.writer.print("context_window = {d}\n", .{n}); } } if (limit_obj.get("output")) |output_v| { if (jsonPositiveU32(output_v)) |n| { try out.writer.print("max_tokens = {d}\n", .{n}); } } }, else => {}, }; if (model_obj.get("cost")) |cost_v| switch (cost_v) { .object => |cost_obj| { try writeTomlPriceField(&out.writer, "input", cost_obj.get("input")); try writeTomlPriceField(&out.writer, "output", cost_obj.get("output")); try writeTomlPriceField(&out.writer, "cache_read", cost_obj.get("cache_read")); try writeTomlPriceField(&out.writer, "cache_write", cost_obj.get("cache_write")); }, else => {}, }; try out.writer.writeByte('\n'); models_written += 1; } if (wrote_provider) { try out.writer.writeByte('\n'); } else { providers_skipped += 1; } } return .{ .content = try out.toOwnedSlice(), .providers_synced = providers_synced, .providers_skipped = providers_skipped, .models_written = models_written, }; } const ProviderSupportedEndpoints = struct { chat_completions: bool = false, anthropic_messages: bool = false, responses: bool = false, fn supportsStyle(self: ProviderSupportedEndpoints, style: config_file.APIStyle) bool { return switch (style) { .openai_chat => self.chat_completions, .anthropic_messages => self.anthropic_messages, .openai_responses, .openai_codex_responses => self.responses, }; } fn any(self: ProviderSupportedEndpoints) bool { return self.chat_completions or self.anthropic_messages or self.responses; } }; const ProviderModelInfo = struct { supported_endpoints_present: bool = false, supported_endpoints: ProviderSupportedEndpoints = .{}, }; const ProviderModelsFilter = struct { arena: std.heap.ArenaAllocator, models: std.StringHashMap(ProviderModelInfo), fn deinit(self: *ProviderModelsFilter) void { self.models.deinit(); self.arena.deinit(); } fn containsForStyle(self: *const ProviderModelsFilter, model_id: []const u8, style: config_file.APIStyle) bool { const info = self.models.get(model_id) orelse return false; if (!info.supported_endpoints_present) return true; return info.supported_endpoints.supportsStyle(style); } }; fn fetchProviderModelsFilter( allocator: Allocator, auth_mgr: *auth_manager.AuthManager, cfg: *const config_file.Config, prov: *const config_file.Provider, ) !?ProviderModelsFilter { const auth = cfg.auth(prov.auth_name) orelse return null; var live: panto.Config = .{ .provider = liveProviderConfigForSync(prov, auth) }; auth_mgr.resolveInto(&live, prov.name, false, null) catch |err| switch (err) { error.OutOfMemory => return err, else => { std.log.debug("models sync: skipping live /models filter for {s} ({t})", .{ prov.name, err }); return null; }, }; return fetchProviderModelsFilterFromLive(allocator, auth_mgr.client, prov.name, live.provider); } fn liveProviderConfigForSync( prov: *const config_file.Provider, auth: *const config_file.ResolvedAuth, ) panto.ProviderConfig { const api_key: []const u8 = auth.resolved_api_key orelse ""; const anthropic_use_bearer_auth = auth.config == .oauth_device; const dummy_model = "__models_probe__"; return switch (prov.style) { .openai_chat => .{ .openai_chat = .{ .api_key = api_key, .base_url = prov.base_url, .model = dummy_model, .extra_headers = prov.extra_headers, } }, .anthropic_messages => .{ .anthropic_messages = .{ .api_key = api_key, .base_url = prov.base_url, .model = dummy_model, .use_bearer_auth = anthropic_use_bearer_auth, .extra_headers = prov.extra_headers, } }, .openai_responses => .{ .openai_responses = .{ .api_key = api_key, .base_url = prov.base_url, .model = dummy_model, .extra_headers = prov.extra_headers, } }, .openai_codex_responses => .{ .openai_codex_responses = .{ .api_key = api_key, .base_url = prov.base_url, .model = dummy_model, .extra_headers = prov.extra_headers, } }, }; } fn fetchProviderModelsFilterFromLive( allocator: Allocator, client: *std.http.Client, provider_name: []const u8, prov_cfg: panto.ProviderConfig, ) !?ProviderModelsFilter { const url = try providerModelsURL(allocator, switch (prov_cfg) { .openai_chat => |pcfg| pcfg.base_url, .anthropic_messages => |pcfg| pcfg.base_url, .openai_responses => |pcfg| pcfg.base_url, .openai_codex_responses => |pcfg| pcfg.base_url, }); defer allocator.free(url); var headers: std.ArrayList(panto.Header) = .empty; defer headers.deinit(allocator); try headers.append(allocator, .{ .name = "User-Agent", .value = "panto models sync" }); var auth_value: ?[]u8 = null; defer if (auth_value) |v| allocator.free(v); switch (prov_cfg) { .openai_chat => |pcfg| { auth_value = try std.fmt.allocPrint(allocator, "Bearer {s}", .{pcfg.api_key}); try headers.append(allocator, .{ .name = "authorization", .value = auth_value.? }); try headers.appendSlice(allocator, pcfg.extra_headers); }, .anthropic_messages => |pcfg| { if (pcfg.use_bearer_auth) { auth_value = try std.fmt.allocPrint(allocator, "Bearer {s}", .{pcfg.api_key}); try headers.append(allocator, .{ .name = "authorization", .value = auth_value.? }); } else { try headers.append(allocator, .{ .name = "x-api-key", .value = pcfg.api_key }); } try headers.append(allocator, .{ .name = "anthropic-version", .value = pcfg.api_version }); try headers.appendSlice(allocator, pcfg.extra_headers); }, .openai_responses => |pcfg| { auth_value = try std.fmt.allocPrint(allocator, "Bearer {s}", .{pcfg.api_key}); try headers.append(allocator, .{ .name = "authorization", .value = auth_value.? }); try headers.appendSlice(allocator, pcfg.extra_headers); }, .openai_codex_responses => |pcfg| { auth_value = try std.fmt.allocPrint(allocator, "Bearer {s}", .{pcfg.api_key}); try headers.append(allocator, .{ .name = "authorization", .value = auth_value.? }); try headers.appendSlice(allocator, pcfg.extra_headers); }, } const res = panto.http.request(allocator, client, .GET, url, .{ .headers = headers.items }) catch |err| switch (err) { error.OutOfMemory => return err, else => { std.log.debug("models sync: {s} /models probe failed ({t})", .{ provider_name, err }); return null; }, }; defer res.deinit(); if (!res.ok()) { std.log.debug("models sync: {s} /models probe returned HTTP {d}", .{ provider_name, res.status }); return null; } return parseProviderModelsFilter(allocator, res.body) catch |err| switch (err) { error.OutOfMemory => return err, else => { std.log.debug("models sync: {s} /models probe was not a usable JSON model listing ({t})", .{ provider_name, err }); return null; }, }; } fn providerModelsURL(allocator: Allocator, base_url: []const u8) ![]u8 { const trimmed = std.mem.trim(u8, base_url, "/"); return std.fmt.allocPrint(allocator, "{s}/models", .{trimmed}); } fn parseProviderModelsFilter(allocator: Allocator, body: []const u8) !?ProviderModelsFilter { var arena = std.heap.ArenaAllocator.init(allocator); errdefer arena.deinit(); const aa = arena.allocator(); const parsed = std.json.parseFromSlice(std.json.Value, aa, body, .{}) catch |err| switch (err) { error.OutOfMemory => return err, else => return null, }; var models: std.StringHashMap(ProviderModelInfo) = .init(aa); const found = try collectProviderModels(aa, &models, parsed.value); if (!found or models.count() == 0) { models.deinit(); arena.deinit(); return null; } return .{ .arena = arena, .models = models }; } fn collectProviderModels( allocator: Allocator, models: *std.StringHashMap(ProviderModelInfo), root: std.json.Value, ) !bool { return switch (root) { .array => |arr| collectModelsFromArray(allocator, models, arr.items), .object => |obj| blk: { if (obj.get("data")) |data_v| { if (try collectModelsFromArrayLike(allocator, models, data_v)) break :blk true; } if (obj.get("models")) |models_v| { if (try collectModelsFromModelsNode(allocator, models, models_v)) break :blk true; } break :blk try collectModelsFromTopLevelObjectMap(allocator, models, obj); }, else => false, }; } fn collectModelsFromArrayLike( allocator: Allocator, models: *std.StringHashMap(ProviderModelInfo), v: std.json.Value, ) !bool { return switch (v) { .array => |arr| collectModelsFromArray(allocator, models, arr.items), .object => |obj| collectModelsFromModelsObject(allocator, models, obj), else => false, }; } fn collectModelsFromModelsNode( allocator: Allocator, models: *std.StringHashMap(ProviderModelInfo), v: std.json.Value, ) !bool { return switch (v) { .array => |arr| collectModelsFromArray(allocator, models, arr.items), .object => |obj| collectModelsFromModelsObject(allocator, models, obj), else => false, }; } fn collectModelsFromArray( allocator: Allocator, models: *std.StringHashMap(ProviderModelInfo), items: []const std.json.Value, ) !bool { var found = false; for (items) |item| switch (item) { .string => |s| { try putProviderModel(allocator, models, s, null); found = true; }, .object => |obj| { const model_id = switch (obj.get("id") orelse continue) { .string => |s| s, else => continue, }; try putProviderModel(allocator, models, model_id, obj.get("supported_endpoints")); found = true; }, else => {}, }; return found; } fn collectModelsFromModelsObject( allocator: Allocator, models: *std.StringHashMap(ProviderModelInfo), obj: std.json.ObjectMap, ) !bool { var found = false; var it = obj.iterator(); while (it.next()) |kv| { const model_id, const supported_endpoints = switch (kv.value_ptr.*) { .object => |child| blk: { const child_id = if (child.get("id")) |id_v| switch (id_v) { .string => |s| s, else => kv.key_ptr.*, } else kv.key_ptr.*; break :blk .{ child_id, child.get("supported_endpoints") }; }, .string => |s| .{ s, null }, else => .{ kv.key_ptr.*, null }, }; try putProviderModel(allocator, models, model_id, supported_endpoints); found = true; } return found; } fn collectModelsFromTopLevelObjectMap( allocator: Allocator, models: *std.StringHashMap(ProviderModelInfo), obj: std.json.ObjectMap, ) !bool { var total: usize = 0; var it = obj.iterator(); while (it.next()) |kv| { total += 1; switch (kv.value_ptr.*) { .object, .string => {}, else => return false, } } if (total == 0) return false; return collectModelsFromModelsObject(allocator, models, obj); } fn parseSupportedEndpoints(v: ?std.json.Value) ProviderSupportedEndpoints { const raw = v orelse return .{}; const arr = switch (raw) { .array => |a| a.items, else => return .{}, }; var out: ProviderSupportedEndpoints = .{}; for (arr) |item| { const s = switch (item) { .string => |str| str, else => continue, }; if (std.mem.eql(u8, s, "/chat/completions")) { out.chat_completions = true; } else if (std.mem.eql(u8, s, "/v1/messages")) { out.anthropic_messages = true; } else if (std.mem.eql(u8, s, "/responses")) { out.responses = true; } } return out; } fn putProviderModel( allocator: Allocator, models: *std.StringHashMap(ProviderModelInfo), model_id: []const u8, supported_endpoints_value: ?std.json.Value, ) !void { if (model_id.len == 0) return; const supported_endpoints_present = supported_endpoints_value != null; const supported_endpoints = parseSupportedEndpoints(supported_endpoints_value); const gop = try models.getOrPut(model_id); if (!gop.found_existing) { gop.key_ptr.* = try allocator.dupe(u8, model_id); gop.value_ptr.* = .{ .supported_endpoints_present = supported_endpoints_present, .supported_endpoints = supported_endpoints, }; return; } if (!gop.value_ptr.supported_endpoints_present and supported_endpoints_present) { gop.value_ptr.supported_endpoints_present = true; gop.value_ptr.supported_endpoints = supported_endpoints; } } fn writeTomlTableHeader(w: *std.Io.Writer, provider: []const u8, alias: []const u8) !void { try w.writeByte('['); try writeTomlBasicString(w, provider); try w.writeByte('.'); try writeTomlBasicString(w, alias); try w.writeAll("]\n"); } fn writeTomlBasicString(w: *std.Io.Writer, s: []const u8) !void { try w.writeByte('"'); for (s) |ch| switch (ch) { '\\' => try w.writeAll("\\\\"), '"' => try w.writeAll("\\\""), '\n' => try w.writeAll("\\n"), '\r' => try w.writeAll("\\r"), '\t' => try w.writeAll("\\t"), else => try w.writeByte(ch), }; try w.writeByte('"'); } fn jsonPositiveU32(v: std.json.Value) ?u32 { return switch (v) { .integer => |i| if (i > 0 and i <= std.math.maxInt(u32)) @intCast(i) else null, .float => |f| if (std.math.isFinite(f) and f > 0 and f <= @as(f64, @floatFromInt(std.math.maxInt(u32)))) @intFromFloat(f) else null, .number_string => |s| std.fmt.parseInt(u32, s, 10) catch null, else => null, }; } fn jsonNonNegativeNumber(v: std.json.Value) ?f64 { return switch (v) { .integer => |i| if (i >= 0) @floatFromInt(i) else null, .float => |f| if (std.math.isFinite(f) and f >= 0) f else null, .number_string => |s| blk: { const f = std.fmt.parseFloat(f64, s) catch break :blk null; break :blk if (std.math.isFinite(f) and f >= 0) f else null; }, else => null, }; } fn writeTomlPriceField(w: *std.Io.Writer, name: []const u8, v: ?std.json.Value) !void { const raw = v orelse return; const n = jsonNonNegativeNumber(raw) orelse return; if (@round(n) == n) { try w.print("{s} = {d}\n", .{ name, @as(i64, @intFromFloat(n)) }); } else { try w.print("{s} = {d}\n", .{ name, n }); } } // --------------------------------------------------------------------------- // `panto auth` // --------------------------------------------------------------------------- /// Line-based device-code presenter for the `panto auth login` flow (the TUI /// is not running here). Prints the verification URL + user code to stdout. const CliPresenter = struct { io: Io, fn deviceCode(ptr: *anyopaque, prompt: panto.DeviceCodePrompt) void { const self: *CliPresenter = @ptrCast(@alignCast(ptr)); var buf: [1024]u8 = undefined; var fw = std.Io.File.stdout().writer(self.io, &buf); const w = &fw.interface; w.print( "\nTo authorize, open this URL in a browser:\n {s}\n\nand enter the code:\n {s}\n\n", .{ prompt.verification_uri, prompt.user_code }, ) catch {}; fw.flush() catch {}; } fn status(ptr: *anyopaque, msg: []const u8) void { const self: *CliPresenter = @ptrCast(@alignCast(ptr)); var buf: [256]u8 = undefined; var fw = std.Io.File.stdout().writer(self.io, &buf); const w = &fw.interface; w.print("{s}\n", .{msg}) catch {}; fw.flush() catch {}; } const vtable: panto.Presenter.VTable = .{ .on_device_code = deviceCode, .on_status = status, }; fn presenter(self: *CliPresenter) panto.Presenter { return .{ .ptr = self, .vtable = &vtable }; } }; fn nowUnix(io: Io) i64 { const ns = std.Io.Clock.now(.real, io).nanoseconds; return @intCast(@divFloor(ns, std.time.ns_per_s)); } /// `panto auth [status|login |logout ]`. fn runAuthSubcommand( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, it: *std.process.Args.Iterator, ) !void { var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd_n = try std.process.currentPath(io, &cwd_buf); const cwd = cwd_buf[0..cwd_n]; var cfg = config_file.load(allocator, io, environ_map, cwd) catch |err| { std.log.err("panto auth: failed to load config ({t})", .{err}); return err; }; defer cfg.deinit(); var layout = try panto_home.resolve(allocator, environ_map); defer layout.deinit(); const auth_dir = layout.auth_dir; var out_buf: [4096]u8 = undefined; var out_file = std.Io.File.stdout().writer(io, &out_buf); const out = &out_file.interface; const action = it.next() orelse "status"; if (std.mem.eql(u8, action, "status")) { try authStatus(allocator, io, &cfg, auth_dir, out); try out_file.flush(); return; } if (std.mem.eql(u8, action, "logout")) { const name = it.next() orelse { try out.writeAll("usage: panto auth logout \n"); try out_file.flush(); return; }; const removed = try panto.deleteTokenSet(allocator, io, auth_dir, name); if (removed) { try out.print("logged out of '{s}'\n", .{name}); } else { try out.print("no stored token for '{s}'\n", .{name}); } try out_file.flush(); return; } if (std.mem.eql(u8, action, "login")) { const name = it.next() orelse { try out.writeAll("usage: panto auth login \n"); try out_file.flush(); return; }; try authLogin(allocator, io, &cfg, auth_dir, name, out); try out_file.flush(); return; } try out.print("unknown auth action '{s}' (try: status, login, logout)\n", .{action}); try out_file.flush(); } fn authStatus( allocator: Allocator, io: Io, cfg: *const config_file.Config, auth_dir: []const u8, out: *std.Io.Writer, ) !void { if (cfg.auths.len == 0) { try out.writeAll("no auth sessions configured\n"); return; } const now = nowUnix(io); for (cfg.auths) |a| { switch (a.config) { .api_key => { const state = if (a.resolved_api_key != null) "resolved" else "unresolved (key/env missing)"; try out.print("{s} api_key {s}\n", .{ a.name, state }); }, .oauth_device => { var loaded = panto.loadTokenSet(allocator, io, auth_dir, a.name) catch null; defer if (loaded) |*l| l.deinit(); if (loaded) |l| { const ts = l.value; if (ts.expires_at) |exp| { const mins = @divFloor(exp - now, 60); try out.print("{s} oauth_device logged in (access expires in ~{d}m)\n", .{ a.name, mins }); } else { try out.print("{s} oauth_device logged in\n", .{a.name}); } } else { try out.print("{s} oauth_device not logged in (run: panto auth login {s})\n", .{ a.name, a.name }); } }, } } } fn authLogin( allocator: Allocator, io: Io, cfg: *const config_file.Config, auth_dir: []const u8, name: []const u8, out: *std.Io.Writer, ) !void { const a = cfg.auth(name) orelse { try out.print("no auth session named '{s}' in config\n", .{name}); return; }; const oauth = switch (a.config) { .oauth_device => |o| o, .api_key => { try out.print("'{s}' is an api_key session; nothing to log in to\n", .{name}); return; }, }; panto.init(allocator, io); defer panto.deinit(); const client = panto.httpClient(); // The auth HTTP calls carry the identity headers of a provider that uses // this session (e.g. Copilot's editor headers). 1:1 in practice; if no // provider references it yet, send none. const headers: []const panto.Header = blk: { for (cfg.providers) |p| { if (std.mem.eql(u8, p.auth_name, name)) break :blk p.extra_headers; } break :blk &.{}; }; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const aa = arena.allocator(); var presenter = CliPresenter{ .io = io }; const toks = panto.oauthLogin(aa, io, client, oauth, presenter.presenter(), headers) catch |err| { try out.print("login failed: {t}\n", .{err}); return; }; const now = nowUnix(io); var ts = try panto.tokensToTokenSet(aa, oauth, toks, now); // Run the secondary exchange now (if configured) so the first turn is // immediately usable and we surface any exchange error during login. if (oauth.exchange) |exchange| { if (ts.access_token) |access| { ts.exchange = panto.runExchange(aa, client, exchange, access, headers) catch |err| blk: { try out.print("note: token exchange failed ({t}); will retry on first use\n", .{err}); break :blk null; }; } } try panto.saveTokenSet(allocator, io, auth_dir, name, ts); try out.print("\nauthorized — '{s}' is ready to use.\n", .{name}); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; // Note: `dispatch` reads from the process's real argv, which isn't // controllable from a unit test. The behavior is exercised by // integration runs of the panto binary. We test the smaller pieces. // test "providerModelsURL appends /models once" { const url = try providerModelsURL(testing.allocator, "https://api.example.com/v1/"); defer testing.allocator.free(url); try testing.expectEqualStrings("https://api.example.com/v1/models", url); } test "parseProviderModelsFilter: openai-style data array" { const body = \\{"object":"list","data":[{"id":"gpt-4o"},{"id":"o4-mini"}]} ; var filter = (try parseProviderModelsFilter(testing.allocator, body)).?; defer filter.deinit(); try testing.expect(filter.containsForStyle("gpt-4o", .openai_chat)); try testing.expect(filter.containsForStyle("o4-mini", .openai_responses)); try testing.expect(!filter.containsForStyle("nope", .openai_chat)); } test "parseProviderModelsFilter: supported_endpoints further restrict by style" { const body = \\{"data":[{"id":"claude-sonnet-4.6","supported_endpoints":["/chat/completions","/v1/messages"]},{"id":"gpt-5.5","supported_endpoints":["/responses","ws:/responses"]},{"id":"gemini-3.5-flash","supported_endpoints":["/chat/completions"]},{"id":"gpt-4o"}]} ; var filter = (try parseProviderModelsFilter(testing.allocator, body)).?; defer filter.deinit(); try testing.expect(filter.containsForStyle("claude-sonnet-4.6", .anthropic_messages)); try testing.expect(filter.containsForStyle("claude-sonnet-4.6", .openai_chat)); try testing.expect(!filter.containsForStyle("claude-sonnet-4.6", .openai_responses)); try testing.expect(filter.containsForStyle("gpt-5.5", .openai_responses)); try testing.expect(!filter.containsForStyle("gpt-5.5", .openai_chat)); try testing.expect(filter.containsForStyle("gemini-3.5-flash", .openai_chat)); try testing.expect(!filter.containsForStyle("gemini-3.5-flash", .anthropic_messages)); try testing.expect(filter.containsForStyle("gpt-4o", .openai_chat)); try testing.expect(filter.containsForStyle("gpt-4o", .openai_responses)); } test "parseProviderModelsFilter: websocket-only responses endpoint does not imply SSE responses support" { const body = \\{"data":[{"id":"ws-only","supported_endpoints":["ws:/responses"]}]} ; var filter = (try parseProviderModelsFilter(testing.allocator, body)).?; defer filter.deinit(); try testing.expect(!filter.containsForStyle("ws-only", .openai_responses)); } test "parseProviderModelsFilter: keyed models object" { const body = \\{"models":{"claude-sonnet-4":{},"claude-haiku-4":{"id":"claude-haiku-4"}}} ; var filter = (try parseProviderModelsFilter(testing.allocator, body)).?; defer filter.deinit(); try testing.expect(filter.containsForStyle("claude-sonnet-4", .anthropic_messages)); try testing.expect(filter.containsForStyle("claude-haiku-4", .openai_chat)); } test "parseProviderModelsFilter: unrecognized json returns null" { try testing.expect((try parseProviderModelsFilter(testing.allocator, "{\"ok\":true}")) == null); }