diff --git a/build.zig.zon b/build.zig.zon index 245f3e4e5..94707cffb 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#cab4242b2c498d18e5637a97045f8a51a74e1c32", - .hash = "zenai-0.0.0-iOY_VBbNBQCo_e6zGGSUX5yyB2UYmx0dy3XuVpgO6h2n", + .url = "git+https://github.com/lightpanda-io/zenai.git#dcf17cf1c944c8b16e5ecb7419374b134cf47df3", + .hash = "zenai-0.0.0-iOY_VH4NBgA-zH-jOigwD0H4QTUSq1Tvm5qH99XNqd7a", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/App.zig b/src/App.zig index 0b7d5ef09..5eff1f64e 100644 --- a/src/App.zig +++ b/src/App.zig @@ -129,7 +129,7 @@ fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 { return app_dir_path; } -fn getAppDataDir(allocator: Allocator, appname: []const u8) ![]const u8 { +pub fn getAppDataDir(allocator: Allocator, appname: []const u8) ![]const u8 { switch (@import("builtin").os.tag) { .macos, .ios => { const home = std.c.getenv("HOME") orelse return error.AppDataDirUnavailable; diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 888c4bb71..d77433a83 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -29,15 +29,16 @@ const Command = lp.Command; const Schema = lp.Schema; const Recorder = lp.Recorder; const ScriptRuntime = lp.Runtime; -const Credentials = zenai.provider.Credentials; +const Candidate = zenai.provider.Candidate; const App = @import("../App.zig"); const CDPNode = @import("../cdp/Node.zig"); const Conversation = @import("Conversation.zig"); const Terminal = @import("Terminal.zig"); -const ansi = @import("ansi.zig"); const SlashCommand = @import("SlashCommand.zig"); const settings = @import("settings.zig"); +const auth = @import("auth/auth.zig"); +const models_dev = @import("auth/models_dev.zig"); const picker = @import("picker.zig"); const save = @import("save.zig"); const welcome = @import("welcome.zig"); @@ -132,10 +133,12 @@ const synthesis_prompt = allocator: std.mem.Allocator, ai_client: ?zenai.provider.Client, -model_credentials: ?Credentials, -/// Allocated credentials key (Vertex gcloud token) — other keys are unowned -/// env pointers. The AI client references it: free only after client deinit. -owned_key: ?[:0]const u8, +/// The active LLM credential (null = LLM disabled). The AI client borrows +/// its key, so deinit only after the client is gone. +credential: ?settings.Credential, +/// App data dir (owned by `App`, which outlives the agent), used to cache the +/// models.dev catalog. Null when no data dir is available. +app_dir: ?[]const u8, /// True when the no-LLM state is a persisted preference (remembered null /// provider or runtime `/provider null`), so `reportSaved` writes /// `provider = null`. A transient `--no-llm` run leaves it false so saving @@ -192,7 +195,7 @@ api_error_buf: [512]u8 = undefined, api_error_detail: ?[]const u8 = null, pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent { - var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Credentials = undefined; + var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Candidate = undefined; const found_providers = settings.availableProviders(&providers_buf); const available_providers = try allocator.alloc([]const u8, found_providers.len); var provider_count: usize = 0; @@ -262,14 +265,13 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const banner_before = will_repl and (!resolve or settings.hasDetectableKey(opts, remembered)); if (banner_before) welcome.print(resolve); - const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; + var resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; // Before the ai_client errdefer, so on unwind the client goes first. - errdefer if (resolved) |r| if (r.key_owned) allocator.free(r.credentials.key); + errdefer if (resolved) |*r| r.credential.deinit(allocator); if (will_repl and !banner_before and resolved != null) welcome.print(resolve); - const llm: ?Credentials = if (resolved) |r| r.credentials else null; - if (llm == null and requires_llm) { + if (resolved == null and requires_llm) { if (opts.no_llm) { std.debug.print("--no-llm forbids LLM use; drop it to run this mode.\n", .{}); } @@ -281,10 +283,10 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent // The REPL skips this network round trip for snappy startup; an invalid // model surfaces on the first turn instead. - if (llm) |l| if (!will_repl) { - const remembered_matches = remembered != null and remembered.?.provider == l.provider; + if (resolved) |*r| if (!will_repl) { + const remembered_matches = remembered != null and remembered.?.provider == r.credential.provider; const explicit = opts.model != null or remembered_matches; - switch (try settings.reconcileModel(allocator, l, model, opts.base_url, explicit)) { + switch (try settings.reconcileModel(allocator, &r.credential, model, opts.base_url, explicit)) { .use => |m| { allocator.free(model); model = m; @@ -293,14 +295,14 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent } }; - const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null); + const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credential.provider else null); const verbosity = settings.resolveVerbosity(opts, remembered); const stream_enabled = settings.resolveStream(remembered); browser_tools.search_engine = settings.resolveSearchEngine(remembered); if (resolved) |r| { if (r.source == .picked) { - settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {}; + settings.saveRemembered(.{ .provider = r.credential.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {}; } // provider/model now live in the status bar; just space before the help std.debug.print("\n", .{}); @@ -320,8 +322,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent self.* = .{ .allocator = allocator, .ai_client = null, - .model_credentials = llm, - .owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null, + .credential = if (resolved) |r| r.credential else null, + .app_dir = app.app_dir_path, .no_llm_persisted = remembered_no_llm, .model_base_url = opts.base_url, .model_completions = null, @@ -354,7 +356,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent try self.startSession(); - self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null; + self.ai_client = if (self.credential) |*c| try zenai.provider.Client.init(lp.io, allocator, c.provider, c.keySlice(), .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(c.provider), .environ = lp.environ(), .account_id = c.accountId() }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); @@ -383,7 +385,7 @@ pub fn deinit(self: *Agent) void { self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); - if (self.owned_key) |k| self.allocator.free(k); + if (self.credential) |*c| c.deinit(self.allocator); self.allocator.free(self.model); for (self.available_providers) |p| self.allocator.free(p); self.allocator.free(self.available_providers); @@ -599,7 +601,7 @@ fn runRepl(self: *Agent) void { log.debug(.app, "tools loaded", .{ .count = globalTools().len }); if (self.ai_client != null) { - std.debug.print(" model: {s}{s} {s}effort: {s}{s} {s}stream: {s}{s}{s}\n", .{ ansi.dim, self.model, ansi.reset, ansi.dim, @tagName(self.effort), ansi.reset, ansi.dim, if (self.stream_enabled) "on" else "off", ansi.reset }); + self.terminal.printSessionBanner(self.model, @tagName(self.effort), self.stream_enabled); } repl: while (true) { @@ -849,7 +851,7 @@ const provider_off_keyword = "null"; const local_providers = [_]Config.AiProvider{ .ollama, .llama_cpp }; fn requireLlm(self: *Agent, name: []const u8) bool { - if (self.model_credentials == null) { + if (self.credential == null) { self.terminal.printError("{s} requires an LLM — " ++ llm_setup_hint ++ ".", .{name}); return false; } @@ -880,7 +882,7 @@ fn handleModel(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { /// credentials it persists `provider = null` only when that's an intentional /// preference (`no_llm_persisted`); a transient --no-llm run reports without saving. fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void { - const provider: ?Config.AiProvider = if (self.model_credentials) |c| c.provider else null; + const provider: ?Config.AiProvider = if (self.credential) |c| c.provider else null; // A transient --no-llm run has no provider and no intent to persist one; // report without saving so we don't clobber the remembered selection. if (provider == null and !self.no_llm_persisted) { @@ -905,7 +907,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace); if (trimmed.len == 0) { - if (self.model_credentials) |c| { + if (self.credential) |c| { self.terminal.printInfo("Current provider: {s}", .{@tagName(c.provider)}); } else { self.terminal.printInfo("Current provider: null — LLM disabled", .{}); @@ -922,9 +924,11 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("unknown provider: {s} (or 'null' to disable the LLM)", .{trimmed}); return; }; - // Re-selecting vertex falls through — that's the token-refresh path. + // Re-selecting vertex (token refresh) or a subscription provider (the + // keep/login/logout menu) falls through instead of no-op'ing. const vertex_project = provider == .vertex and settings.vertexProjectMode(); - if (self.model_credentials) |current| if (provider == current.provider and !vertex_project) { + const sub_desc = auth.descriptorFor(provider); + if (self.credential) |current| if (provider == current.provider and !vertex_project and sub_desc == null) { self.terminal.printInfo("provider: {s}", .{@tagName(provider)}); return; }; @@ -933,12 +937,24 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("could not obtain a Vertex access token: {s} (details above)", .{@errorName(err)}); return; }; - self.setProvider(.{ .provider = .vertex, .key = token }, token) catch |err| { + self.setProvider(.{ .provider = .vertex, .key = .{ .owned = token } }) catch |err| { self.allocator.free(token); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; return; } + // Subscription provider: menu over the stored session, or first-time login. + if (sub_desc) |desc| { + var owned = if (auth.sessionFor(self.allocator, provider) catch null) |stored| + self.promptStoredSubscription(desc, stored) orelse return + else + self.subscriptionLogin(desc) orelse return; + self.setProvider(.{ .provider = provider, .key = .{ .session = owned } }) catch |err| { + owned.deinit(); + self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); + }; + return; + } const key = zenai.provider.envApiKey(lp.environ(), provider) orelse { if (provider == .vertex) { self.terminal.printError("vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud)", .{}); @@ -956,19 +972,60 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("no llama.cpp server with a loaded model at {s}", .{self.model_base_url orelse zenai.provider.llama_cpp_default_base_url}); return; } - self.setProvider(.{ .provider = provider, .key = key }, null) catch |err| { + self.setProvider(.{ .provider = provider, .key = .{ .env = key } }) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; } +/// Interactive device-code login; cancellation/failure is reported here. +fn subscriptionLogin(self: *Agent, desc: *const auth.Descriptor) ?auth.Session { + return auth.login(self.allocator, desc, &self.http_interrupt) catch |err| { + if (err == error.LoginCancelled) { + // Undo `requestCancel`'s side effects before the next turn. + self.resetAfterCancel(self.conversation.messages.items.len); + self.terminal.printInfo("{s} login cancelled", .{desc.label}); + return null; + } + self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); + return null; + }; +} + +/// Menu for re-selecting an already-logged-in subscription provider: keep the +/// stored login, re-run it (switch accounts), or log out. Null when there is +/// nothing to activate. +fn promptStoredSubscription(self: *Agent, desc: *const auth.Descriptor, stored: auth.Session) ?auth.Session { + var session = stored; + var header_buf: [128]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "Already logged in with your {s}. Pick:", .{desc.label}) catch + "Already logged in. Pick:"; + const idx = picker.promptNumberedChoice(header, &.{ + "keep — use the stored login", + "login — run the login again (e.g. to switch accounts)", + "logout — forget the stored login", + }, 0) catch { + session.deinit(); + return null; + }; + if (idx == 0) return session; + session.deinit(); + if (idx == 1) return self.subscriptionLogin(desc); + auth.logout(self.allocator, desc) catch |err| { + self.terminal.printError("could not remove the stored login: {s}", .{@errorName(err)}); + return null; + }; + self.terminal.printInfo("{s} logged out.", .{desc.label}); + if (self.credential) |c| if (c.provider == desc.provider) self.disableProvider(); + return null; +} + /// Tear down the LLM client and persist a null provider so the next REPL launch /// starts in basic mode without re-prompting. Inverse of `setProvider`. fn disableProvider(self: *Agent) void { if (self.ai_client) |client| client.deinit(self.allocator); self.ai_client = null; - if (self.owned_key) |k| self.allocator.free(k); - self.owned_key = null; - self.model_credentials = null; + if (self.credential) |*c| c.deinit(self.allocator); + self.credential = null; self.model_completions = null; self.no_llm_persisted = true; self.reportSaved("provider", provider_off_keyword); @@ -982,34 +1039,55 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { return std.mem.span(v); } -/// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex -/// gcloud token) on success; on error the caller still owns it. -fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void { - const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() }); +/// `credential` transfers ownership of its key resources on success; on error +/// the caller still owns them. The previous credential is released only after +/// the old client is gone. +fn setProvider(self: *Agent, credential: settings.Credential) !void { + const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credential.provider, credential.keySlice(), .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credential.provider), .environ = lp.environ(), .account_id = credential.accountId() }); errdefer new_client.deinit(self.allocator); // A same-provider re-select (vertex token refresh) must not reset the model. - const same_provider = if (self.model_credentials) |c| c.provider == credentials.provider else false; - const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider)); + const same_provider = if (self.credential) |c| c.provider == credential.provider else false; + const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credential.provider)); if (self.ai_client) |client| client.deinit(self.allocator); - if (self.owned_key) |k| self.allocator.free(k); - self.owned_key = owned_key; + if (self.credential) |*c| c.deinit(self.allocator); + self.credential = credential; new_client.setInterrupt(&self.http_interrupt); self.ai_client = new_client; - self.model_credentials = credentials; self.no_llm_persisted = false; self.model_completions = null; self.allocator.free(self.model); self.model = new_model; - self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); - if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) { + if (auth.descriptorFor(credential.provider)) |d| { + self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credential.provider), d.label }); + } else { + self.terminal.printInfo("provider: {s}", .{@tagName(credential.provider)}); + } + if (zenai.provider.defaultEffort(credential.provider)) |e| if (e != self.effort) { self.effort = e; - self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) }); + self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credential.provider) }); }; self.reportSaved("model", self.model); + // Prime the completion cache on the command, not on a `/model` keystroke. _ = completionModels(self, self.allocator); } +/// Keep a subscription (bearer) token current before a model request: when the +/// active session's token is near expiry, re-import from the source and repoint +/// the client at the fresh token (`keySlice` readers see it automatically). +/// A no-op for API-key auth. Runs between turns on the single agent thread, so +/// the client is repointed before the next request borrows the token. +fn refreshAuthIfNeeded(self: *Agent) void { + const cred = if (self.credential) |*c| c else return; + if (cred.key != .session) return; + const session = &cred.key.session; + const refreshed = session.ensureFresh() catch |err| { + self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); + return; + }; + if (refreshed) if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); +} + const PathAndMode = struct { path: []const u8, mode: save.Mode }; fn resolveSavePathAndMode(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8) ?PathAndMode { @@ -1174,6 +1252,7 @@ fn saveOneShot(self: *Agent) void { /// interactive `/save` and one-shot `--save`. fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mode: save.Mode, prompt: ?[]const u8) void { const provider_client = self.ai_client.?; + self.refreshAuthIfNeeded(); // Only update feeds the saved script back to the model; append stays // blind — the script is synthesized from this session alone and written @@ -1569,8 +1648,9 @@ fn recordSlashToolCall( fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) []const u8 { const e = client.lastError(); const status = e.status orelse return @errorName(err); + const owned_key = if (self.credential) |c| c.key == .owned else false; const hint = if (status == 401 and client == .vertex) - if (self.owned_key != null) + if (owned_key) " (Vertex token may have expired; run /provider vertex to refresh)" else " (Vertex express mode needs an express API key — a Gemini Developer key won't work)" @@ -1620,6 +1700,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { } const provider_client = self.ai_client orelse return error.NoAiClient; + self.refreshAuthIfNeeded(); self.terminal.spinner.start(); var result = provider_client.runTools( @@ -1868,18 +1949,22 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { }); return error.ConflictingFlags; } - const resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; - const llm = resolved.credentials; - defer if (resolved.key_owned) allocator.free(llm.key); + var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; + defer resolved.credential.deinit(allocator); var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { - if (llm.provider == .vertex and !settings.vertexProjectMode()) { - std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); - } - return err; - }; + // A subscription provider can't list models via the provider API; use the + // free models.dev catalog. + const ids: []const []const u8 = if (auth.descriptorFor(resolved.credential.provider)) |desc| + models_dev.modelIds(arena.allocator(), desc.models_dev_id, auth.dataDir(arena.allocator())) + else + zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), resolved.credential.provider, resolved.credential.keySlice(), .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { + if (resolved.credential.provider == .vertex and !settings.vertexProjectMode()) { + std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); + } + return err; + }; var stdout_file = std.Io.File.stdout().writerStreaming(lp.io, &.{}); const w = &stdout_file.interface; @@ -1909,36 +1994,51 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const [] }; if (reachable[i]) extra += 1; } - const names = arena.alloc([]const u8, self.available_providers.len + 1 + extra) catch return &.{}; + const names = arena.alloc([]const u8, self.available_providers.len + auth.registry.len + 1 + extra) catch return &.{}; for (self.available_providers, 0..) |p, i| { names[i] = arena.dupe(u8, p) catch return &.{}; } var n = self.available_providers.len; + // Subscription providers complete even without a stored token — selecting + // one is what starts the login. + for (auth.registry) |desc| { + const name = @tagName(desc.provider); + if (!string.isOneOf(name, self.available_providers)) { + names[n] = name; + n += 1; + } + } for (local_providers, reachable) |tag, r| if (r) { names[n] = @tagName(tag); n += 1; }; names[n] = provider_off_keyword; - return names; + return names[0 .. n + 1]; } /// `CompletionSource.models`. Blocks on a one-time fetch per provider, caching /// success or empty so the per-keystroke hinter pays the round-trip once. fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u8 { const self: *Agent = @ptrCast(@alignCast(context)); - const llm = self.model_credentials orelse return &.{}; - if (self.model_completions) |c| if (c.provider == llm.provider) return c.ids; + const cred = if (self.credential) |*c| c else return &.{}; + if (self.model_completions) |c| if (c.provider == cred.provider) return c.ids; _ = self.model_completion_arena.reset(.retain_capacity); - const ids = zenai.provider.listChatModelIds( - lp.io, - self.allocator, - self.model_completion_arena.allocator(), - llm.provider, - llm.key, - .{ .base_url = self.model_base_url, .environ = lp.environ() }, - ) catch &.{}; - self.model_completions = .{ .provider = llm.provider, .ids = ids }; + const arena = self.model_completion_arena.allocator(); + // A subscription provider can't hit the provider's `/models` endpoint, so + // list from the free, unauthenticated models.dev catalog instead. + const ids = if (auth.descriptorFor(cred.provider)) |desc| + models_dev.modelIds(arena, desc.models_dev_id, self.app_dir) + else + zenai.provider.listChatModelIds( + lp.io, + self.allocator, + arena, + cred.provider, + cred.keySlice(), + .{ .base_url = self.model_base_url, .environ = lp.environ() }, + ) catch &.{}; + self.model_completions = .{ .provider = cred.provider, .ids = ids }; return ids; } diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index e7f6479a6..94875f910 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -364,6 +364,16 @@ pub fn printDimmed(self: *Terminal, comptime fmt: []const u8, args: anytype) voi std.debug.print(ansi.dim ++ fmt ++ ansi.reset ++ "\n", args); } +/// REPL startup banner line: plain labels, dimmed values. +pub fn printSessionBanner(self: *Terminal, model: []const u8, effort: []const u8, stream_enabled: bool) void { + if (!self.isRepl()) return; + std.debug.print(" model: {s}{s}{s} effort: {s}{s}{s} stream: {s}{s}{s}\n", .{ + ansi.dim, model, ansi.reset, + ansi.dim, effort, ansi.reset, + ansi.dim, if (stream_enabled) "on" else "off", ansi.reset, + }); +} + pub fn printItalic(self: *Terminal, comptime fmt: []const u8, args: anytype) void { if (!self.isRepl() and !self.verbosity.atLeast(.medium)) return; std.debug.print(ansi.italic ++ fmt ++ ansi.reset ++ "\n", args); diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig new file mode 100644 index 000000000..cb64147a7 --- /dev/null +++ b/src/agent/auth/auth.zig @@ -0,0 +1,296 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Provider-agnostic subscription (OAuth) auth for the agent. A `Descriptor` +//! names a provider plus its `loginFn`/`refreshFn` hooks (implemented per +//! provider, e.g. `codex.zig`); this module owns the on-disk token store (in the +//! app data dir) and the `Session` lifecycle (refresh-on-expiry with silent +//! persistence). The AI client borrows `Session.tokens.access_token`, so a +//! session must outlive it. + +const std = @import("std"); +const lp = @import("lightpanda"); +const log = lp.log; +const zenai = @import("zenai"); +const Config = lp.Config; +const codex = @import("codex.zig"); + +/// Wall-clock ms since the Unix epoch. +pub fn nowMs() i64 { + return @intCast(lp.datetime.milliTimestamp(.real)); +} + +/// Resolve the app data dir into `arena`, for callers without an `App` (the +/// store wrappers, `--list-models`). Null when no home is resolvable. +pub fn dataDir(arena: std.mem.Allocator) ?[]const u8 { + return lp.App.getAppDataDir(arena, "lightpanda") catch null; +} + +/// An OAuth credential set. `account_id` is a provider-specific extra (Codex's +/// ChatGPT account id from the JWT); null when the provider has none. +pub const TokenSet = struct { + access_token: [:0]const u8, + refresh_token: []const u8, + expires_at_ms: i64, + account_id: ?[]const u8 = null, + + pub fn dup(allocator: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at_ms: i64, account_id: ?[]const u8) !TokenSet { + const a = try allocator.dupeZ(u8, access); + errdefer allocator.free(a); + const r = try allocator.dupe(u8, refresh); + errdefer allocator.free(r); + const id = if (account_id) |v| try allocator.dupe(u8, v) else null; + return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms, .account_id = id }; + } + + pub fn deinit(self: TokenSet, allocator: std.mem.Allocator) void { + allocator.free(self.access_token); + allocator.free(self.refresh_token); + if (self.account_id) |v| allocator.free(v); + } +}; + +/// Per-provider login/refresh implementations; OAuth endpoints and client ids +/// live in the implementing file. +pub const Descriptor = struct { + provider: Config.AiProvider, + /// Provider key in the models.dev catalog (subscription backends have no + /// entry of their own; codex's models are listed under "openai"). + models_dev_id: []const u8, + /// Human label for the credential, e.g. "ChatGPT subscription". + label: []const u8, + /// Interactive login (device-code flow); returns freshly-minted tokens. + /// Firing `interrupt` aborts the flow with `error.LoginCancelled`. + loginFn: *const fn (std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) anyerror!TokenSet, + /// Exchange a refresh token for a new `TokenSet` (with re-derived account id). + refreshFn: *const fn (std.mem.Allocator, refresh_token: []const u8) anyerror!TokenSet, +}; + +pub const registry = [_]*const Descriptor{&codex.descriptor}; + +pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor { + for (registry) |d| if (d.provider == provider) return d; + return null; +} + +// --- On-disk token store: /auth.json, a JSON map keyed by provider tag --- +// The `*At` variants take a scratch arena and an explicit dir (unit-testable); +// the public wrappers build the arena and resolve the app data dir themselves. + +const StoredToken = struct { + access: []const u8, + refresh: []const u8, + expires_at_ms: i64, + account_id: ?[]const u8 = null, +}; + +fn storePath(arena: std.mem.Allocator, dir: []const u8) ![]const u8 { + return std.fs.path.join(arena, &.{ dir, "auth.json" }); +} + +/// The parsed store; empty on any failure (absent, unreadable, malformed). +fn readStoreFile(arena: std.mem.Allocator, path: []const u8) std.json.ArrayHashMap(StoredToken) { + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024)) catch return .{}; + return std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch .{}; +} + +/// Serialize `value` as JSON to `path` via temp+rename, so a failed write +/// can't corrupt an existing file. +pub fn writeJsonAtomic(path: []const u8, value: anytype, permissions: std.Io.File.Permissions) !void { + var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = permissions, .replace = true }); + defer af.deinit(lp.io); + var buf: [1024]u8 = undefined; + var w = af.file.writer(lp.io, &buf); + try std.json.Stringify.value(value, .{}, &w.interface); + try w.end(); + try af.replace(lp.io); +} + +fn storeLoadAt(allocator: std.mem.Allocator, dir: []const u8, id: []const u8) !?TokenSet { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const path = try storePath(a, dir); + const t = readStoreFile(a, path).map.get(id) orelse return null; + return try TokenSet.dup(allocator, t.access, t.refresh, t.expires_at_ms, t.account_id); +} + +fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens: TokenSet) !void { + const path = try storePath(arena, dir); + var map = readStoreFile(arena, path); + try map.map.put(arena, id, .{ + .access = tokens.access_token, + .refresh = tokens.refresh_token, + .expires_at_ms = tokens.expires_at_ms, + .account_id = tokens.account_id, + }); + // Secrets file: owner-only perms. + try writeJsonAtomic(path, map, .fromMode(0o600)); +} + +fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) !void { + const path = try storePath(arena, dir); + var map = readStoreFile(arena, path); + if (!map.map.swapRemove(id)) return; + try writeJsonAtomic(path, map, .fromMode(0o600)); +} + +/// Remove the stored token for `id`. No-op when absent. +fn storeDelete(allocator: std.mem.Allocator, id: []const u8) !void { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const dir = dataDir(a) orelse return error.NoDataDir; + return storeDeleteAt(a, dir, id); +} + +/// Load the stored token for `id`, or null when absent/unreadable/no data dir. +pub fn storeLoad(allocator: std.mem.Allocator, id: []const u8) !?TokenSet { + var da: std.heap.ArenaAllocator = .init(allocator); + defer da.deinit(); + const dir = dataDir(da.allocator()) orelse return null; + return storeLoadAt(allocator, dir, id); +} + +pub fn storeSave(allocator: std.mem.Allocator, id: []const u8, tokens: TokenSet) !void { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const dir = dataDir(a) orelse return error.NoDataDir; + return storeSaveAt(a, dir, id, tokens); +} + +/// Proactive-refresh margin: refresh once the token is within this window of +/// expiry, so a turn never starts on a token about to lapse. +const refresh_skew_ms: i64 = 5 * std.time.ms_per_min; + +/// A live subscription credential for one provider. Owns its `TokenSet` and +/// persists refreshed tokens back to the store. +pub const Session = struct { + allocator: std.mem.Allocator, + descriptor: *const Descriptor, + tokens: TokenSet, + /// The set displaced by the last refresh, kept until the next one (or + /// deinit) because the AI client borrows the access token until the + /// caller repoints it. + displaced: ?TokenSet = null, + + /// When the access token is within `refresh_skew_ms` of expiry, exchange + /// the refresh token for a new one and persist it. True when `tokens` was + /// replaced — repoint anything borrowing the old access token. + pub fn ensureFresh(self: *Session) !bool { + const now = nowMs(); + if (self.tokens.expires_at_ms - now > refresh_skew_ms) return false; + + const fresh = self.descriptor.refreshFn(self.allocator, self.tokens.refresh_token) catch |err| { + // A transient refresh failure while the token is still valid is not fatal. + if (self.tokens.expires_at_ms > now) return false; + return err; + }; + storeSave(self.allocator, @tagName(self.descriptor.provider), fresh) catch |err| { + log.warn(.app, "auth token persist failed", .{ .provider = @tagName(self.descriptor.provider), .err = err }); + }; + + if (self.displaced) |old| old.deinit(self.allocator); + self.displaced = self.tokens; + self.tokens = fresh; + return true; + } + + pub fn deinit(self: *Session) void { + if (self.displaced) |old| old.deinit(self.allocator); + self.tokens.deinit(self.allocator); + } +}; + +/// Load a stored session for `provider`, or null when the user hasn't logged in. +pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session { + const desc = descriptorFor(provider) orelse return null; + const tokens = (try storeLoad(allocator, @tagName(provider))) orelse return null; + return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; +} + +/// Run the interactive login and persist the result, returning a live session. +pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ?*zenai.http.Interrupt) !Session { + const tokens = try desc.loginFn(allocator, interrupt); + storeSave(allocator, @tagName(desc.provider), tokens) catch |err| { + log.warn(.app, "auth token persist failed", .{ .provider = @tagName(desc.provider), .err = err }); + }; + return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; +} + +/// Forget the stored credential for `desc`'s provider. +pub fn logout(allocator: std.mem.Allocator, desc: *const Descriptor) !void { + return storeDelete(allocator, @tagName(desc.provider)); +} + +/// Is a stored credential available for `provider` — unexpired, or refreshable +/// (`ensureFresh` runs before the first request)? Lets the picker offer the +/// subscription without an API-key env var. +pub fn subscriptionAvailable(provider: Config.AiProvider) bool { + if (descriptorFor(provider) == null) return false; + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + const dir = dataDir(a) orelse return false; + const path = storePath(a, dir) catch return false; + const t = readStoreFile(a, path).map.get(@tagName(provider)) orelse return false; + return t.refresh.len > 0 or t.expires_at_ms > nowMs(); +} + +test "TokenSet dup/deinit round-trips and is leak-free" { + const a = std.testing.allocator; + const t = try TokenSet.dup(a, "acc", "ref", 123, "acct-1"); + defer t.deinit(a); + try std.testing.expectEqualStrings("acc", t.access_token); + try std.testing.expectEqualStrings("ref", t.refresh_token); + try std.testing.expectEqualStrings("acct-1", t.account_id.?); + try std.testing.expectEqual(@as(i64, 123), t.expires_at_ms); +} + +test "descriptorFor resolves codex, null for a non-OAuth provider" { + try std.testing.expect(descriptorFor(.codex) != null); + try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai)); +} + +test "token store save/load/delete round-trips" { + const a = std.testing.allocator; + var arena: std.heap.ArenaAllocator = .init(a); + defer arena.deinit(); + const scratch = arena.allocator(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(lp.io, ".", scratch); + + const t = try TokenSet.dup(a, "acc-1", "ref-1", 999, "acct-9"); + defer t.deinit(a); + try storeSaveAt(scratch, dir, "codex", t); + + const st = try tmp.dir.statFile(lp.io, "auth.json", .{}); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), st.permissions.toMode() & 0o777); + + const loaded = (try storeLoadAt(a, dir, "codex")).?; + defer loaded.deinit(a); + try std.testing.expectEqualStrings("acc-1", loaded.access_token); + try std.testing.expectEqualStrings("ref-1", loaded.refresh_token); + try std.testing.expectEqualStrings("acct-9", loaded.account_id.?); + try std.testing.expectEqual(@as(i64, 999), loaded.expires_at_ms); + + try storeDeleteAt(scratch, dir, "codex"); + try std.testing.expectEqual(@as(?TokenSet, null), try storeLoadAt(a, dir, "codex")); +} diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig new file mode 100644 index 000000000..01b0cff06 --- /dev/null +++ b/src/agent/auth/codex.zig @@ -0,0 +1,291 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! OpenAI Codex (ChatGPT subscription) OAuth. Uses the device-code flow (no +//! local callback server): request a user code, the user enters it at the verify +//! URL, we poll for the authorization code, then exchange it for tokens. The +//! `ChatGPT-Account-Id` sent on every API request is derived from the OAuth JWT. +//! Constants mirror the official Codex CLI / opencode. + +const std = @import("std"); +const lp = @import("lightpanda"); +const log = lp.log; +const zenai = @import("zenai"); +const auth = @import("auth.zig"); + +const client_id = "app_EMoamEEZ73f0CkXaXp7hrann"; +const issuer = "https://auth.openai.com"; +const token_url = issuer ++ "/oauth/token"; +const device_code_url = issuer ++ "/api/accounts/deviceauth/usercode"; +const device_token_url = issuer ++ "/api/accounts/deviceauth/token"; +const verify_url = issuer ++ "/codex/device"; +/// Redirect URI used only in the device-flow token exchange body (never hit). +const device_redirect_uri = issuer ++ "/deviceauth/callback"; +const user_agent = "lightpanda"; +const poll_margin_ms: u64 = 3000; +const cancel_slice_ms: u64 = 200; + +pub const descriptor: auth.Descriptor = .{ + .provider = .codex, + .models_dev_id = "openai", + .label = "ChatGPT subscription", + .loginFn = deviceLogin, + .refreshFn = refreshGrant, +}; + +// --- JWT account-id extraction (pure) --- + +/// Extract the ChatGPT account id from an OAuth JWT (id_token preferred, else +/// access_token). Probes `chatgpt_account_id`, then +/// `["https://api.openai.com/auth"].chatgpt_account_id`, then +/// `organizations[0].id`. Returns a slice owned by `arena`, or null. +pub fn accountIdFromJwt(arena: std.mem.Allocator, token: []const u8) ?[]const u8 { + var it = std.mem.splitScalar(u8, token, '.'); + _ = it.next() orelse return null; + const payload_b64 = it.next() orelse return null; + + const dec = std.base64.url_safe_no_pad.Decoder; + const len = dec.calcSizeForSlice(payload_b64) catch return null; + const buf = arena.alloc(u8, len) catch return null; + dec.decode(buf, payload_b64) catch return null; + + const claims = std.json.parseFromSliceLeaky(std.json.Value, arena, buf, .{}) catch return null; + const obj = switch (claims) { + .object => |o| o, + else => return null, + }; + if (stringField(obj, "chatgpt_account_id")) |v| return v; + if (obj.get("https://api.openai.com/auth")) |a| if (a == .object) { + if (stringField(a.object, "chatgpt_account_id")) |v| return v; + }; + if (obj.get("organizations")) |orgs| if (orgs == .array and orgs.array.items.len > 0) { + const first = orgs.array.items[0]; + if (first == .object) if (stringField(first.object, "id")) |v| return v; + }; + return null; +} + +fn stringField(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 { + const v = obj.get(key) orelse return null; + return if (v == .string) v.string else null; +} + +// --- Request-body builders (pure) --- + +const TokenResponse = struct { + access_token: []const u8, + refresh_token: []const u8 = "", + id_token: ?[]const u8 = null, + expires_in: ?i64 = null, +}; + +/// Parse an OAuth token response into a `TokenSet`, deriving `account_id` from +/// the JWTs and computing an absolute expiry. +fn parseTokenResponse(allocator: std.mem.Allocator, body: []const u8) !auth.TokenSet { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const tr = try std.json.parseFromSliceLeaky(TokenResponse, a, body, .{ .ignore_unknown_fields = true }); + const account_id = (if (tr.id_token) |t| accountIdFromJwt(a, t) else null) orelse + accountIdFromJwt(a, tr.access_token); + const expires_at = auth.nowMs() + (tr.expires_in orelse 3600) * std.time.ms_per_s; + return auth.TokenSet.dup(allocator, tr.access_token, tr.refresh_token, expires_at, account_id); +} + +fn refreshBody(arena: std.mem.Allocator, refresh_token: []const u8) ![]u8 { + return std.fmt.allocPrint(arena, "grant_type=refresh_token&client_id=" ++ client_id ++ "&refresh_token={s}", .{ + try lp.URL.percentEncodeSegment(arena, refresh_token, .component), + }); +} + +fn exchangeBody(arena: std.mem.Allocator, code: []const u8, code_verifier: []const u8) ![]u8 { + return std.fmt.allocPrint(arena, "grant_type=authorization_code&client_id=" ++ client_id ++ + "&redirect_uri=" ++ device_redirect_uri ++ "&code={s}&code_verifier={s}", .{ + try lp.URL.percentEncodeSegment(arena, code, .component), + try lp.URL.percentEncodeSegment(arena, code_verifier, .component), + }); +} + +// --- Device-code flow --- + +const DeviceCode = struct { + device_auth_id: []const u8, + user_code: []const u8, + /// Seconds between polls. The spec says number; OpenAI returns a string; + /// std.json int parsing accepts both. + interval: u32 = 5, +}; + +const DeviceToken = struct { + authorization_code: []const u8, + code_verifier: []const u8, +}; + +/// Sleep in short slices, polling `interrupt` between them: the REPL's Ctrl-C +/// only fires the interrupt (it never kills the process). +fn waitCancellable(ms: u64, interrupt: ?*zenai.http.Interrupt) error{LoginCancelled}!void { + var remaining_ms = ms; + while (remaining_ms > 0) { + if (interrupt) |it| if (it.isFired()) return error.LoginCancelled; + const slice_ms = @min(remaining_ms, cancel_slice_ms); + lp.io.sleep(.fromMilliseconds(@intCast(slice_ms)), .awake) catch {}; + remaining_ms -= slice_ms; + } +} + +fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) !auth.TokenSet { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const code_res = try post(a, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}"); + if (code_res.status != .ok) { + log.warn(.app, "codex device-code request failed", .{ .status = @intFromEnum(code_res.status), .body = code_res.body }); + return error.DeviceCodeRequestFailed; + } + const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true }); + const interval_ms: u64 = @as(u64, dc.interval) * std.time.ms_per_s; + + std.debug.print( + "\nTo authorize Lightpanda with your ChatGPT subscription:\n 1. Open {s}\n 2. Enter the code: {s}\n\nWaiting for authorization...\n", + .{ verify_url, dc.user_code }, + ); + + const poll_body = try std.fmt.allocPrint(a, "{f}", .{std.json.fmt( + .{ .device_auth_id = dc.device_auth_id, .user_code = dc.user_code }, + .{}, + )}); + const dt: DeviceToken = while (true) { + try waitCancellable(interval_ms + poll_margin_ms, interrupt); + const res = try post(a, interrupt, device_token_url, "application/json", poll_body); + switch (res.status) { + .ok => break try std.json.parseFromSliceLeaky(DeviceToken, a, res.body, .{ .ignore_unknown_fields = true }), + // Still pending — the user hasn't finished authorizing. + .forbidden, .not_found => continue, + else => { + log.warn(.app, "codex device-auth poll failed", .{ .status = @intFromEnum(res.status), .body = res.body }); + return error.DeviceAuthFailed; + }, + } + }; + + const exchange = try exchangeBody(a, dt.authorization_code, dt.code_verifier); + const tok_res = try post(a, interrupt, token_url, "application/x-www-form-urlencoded", exchange); + if (tok_res.status != .ok) { + log.warn(.app, "codex token exchange failed", .{ .status = @intFromEnum(tok_res.status), .body = tok_res.body }); + return error.TokenExchangeFailed; + } + return parseTokenResponse(allocator, tok_res.body); +} + +fn refreshGrant(allocator: std.mem.Allocator, refresh_token: []const u8) !auth.TokenSet { + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const body = try refreshBody(a, refresh_token); + const res = try post(a, null, token_url, "application/x-www-form-urlencoded", body); + if (res.status != .ok) { + log.warn(.app, "codex token refresh failed", .{ .status = @intFromEnum(res.status), .body = res.body }); + return error.RefreshFailed; + } + return parseTokenResponse(allocator, res.body); +} + +const PostResult = struct { status: std.http.Status, body: []u8 }; + +/// One-shot POST. Firing `interrupt` aborts an in-flight exchange and surfaces +/// `error.LoginCancelled` instead of the read error it caused. +fn post(arena: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt, url: []const u8, content_type: []const u8, body: []const u8) !PostResult { + var client: std.http.Client = .{ .allocator = arena, .io = lp.io }; + defer client.deinit(); + + var out: std.Io.Writer.Allocating = .init(arena); + const status = zenai.http.fetchInterruptible(arena, &client, .{ + .location = .{ .url = url }, + .method = .POST, + .payload = body, + .headers = .{ .content_type = .{ .override = content_type }, .user_agent = .{ .override = user_agent } }, + }, &out.writer, interrupt) catch |err| { + if (interrupt) |it| if (it.isFired()) return error.LoginCancelled; + return err; + }; + return .{ .status = status, .body = out.written() }; +} + +// --- Tests (pure paths) --- + +fn makeJwt(arena: std.mem.Allocator, payload_json: []const u8) ![]const u8 { + const enc = std.base64.url_safe_no_pad.Encoder; + const p = try arena.alloc(u8, enc.calcSize(payload_json.len)); + _ = enc.encode(p, payload_json); + return std.fmt.allocPrint(arena, "aGVhZGVy.{s}.c2ln", .{p}); +} + +test "accountIdFromJwt: top-level chatgpt_account_id" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"chatgpt_account_id\":\"acct-top\"}"); + try std.testing.expectEqualStrings("acct-top", accountIdFromJwt(a, jwt).?); +} + +test "accountIdFromJwt: nested auth namespace" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct-ns\"}}"); + try std.testing.expectEqualStrings("acct-ns", accountIdFromJwt(a, jwt).?); +} + +test "accountIdFromJwt: organizations fallback, and null when absent" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"organizations\":[{\"id\":\"org-1\"}]}"); + try std.testing.expectEqualStrings("org-1", accountIdFromJwt(a, jwt).?); + const none = try makeJwt(a, "{\"sub\":\"x\"}"); + try std.testing.expectEqual(@as(?[]const u8, null), accountIdFromJwt(a, none)); + try std.testing.expectEqual(@as(?[]const u8, null), accountIdFromJwt(a, "not-a-jwt")); +} + +test "refreshBody / exchangeBody percent-encode values" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const rb = try refreshBody(a, "tok/with+special"); + try std.testing.expect(std.mem.find(u8, rb, "grant_type=refresh_token") != null); + try std.testing.expect(std.mem.find(u8, rb, "client_id=" ++ client_id) != null); + try std.testing.expect(std.mem.find(u8, rb, "tok%2Fwith%2Bspecial") != null); + const eb = try exchangeBody(a, "the code", "verifier"); + try std.testing.expect(std.mem.find(u8, eb, "grant_type=authorization_code") != null); + try std.testing.expect(std.mem.find(u8, eb, "code=the%20code") != null); +} + +test "parseTokenResponse derives account id and absolute expiry" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const jwt = try makeJwt(a, "{\"chatgpt_account_id\":\"acct-x\"}"); + const body = try std.fmt.allocPrint(a, "{{\"access_token\":\"acc\",\"refresh_token\":\"ref\",\"id_token\":\"{s}\",\"expires_in\":3600}}", .{jwt}); + const tokens = try parseTokenResponse(std.testing.allocator, body); + defer tokens.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("acc", tokens.access_token); + try std.testing.expectEqualStrings("ref", tokens.refresh_token); + try std.testing.expectEqualStrings("acct-x", tokens.account_id.?); + try std.testing.expect(tokens.expires_at_ms > auth.nowMs()); +} diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig new file mode 100644 index 000000000..9e3221b96 --- /dev/null +++ b/src/agent/auth/models_dev.zig @@ -0,0 +1,114 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Model catalog from models.dev — the free, unauthenticated registry opencode +//! uses. It lets a subscription (bearer) user list a provider's models without +//! an API key, and stays current upstream instead of drifting like a hardcoded +//! list. The full `api.json` is a few MB, so only each provider's id list is +//! cached on disk (`/models-dev-.json`) with a TTL; the big +//! payload is parsed transiently on refresh. + +const std = @import("std"); +const lp = @import("lightpanda"); +const auth = @import("auth.zig"); + +const api_url = "https://models.dev/api.json"; +const cache_ttl_ms: i64 = 24 * std.time.ms_per_hour; + +/// Chat-model ids for `provider_id` from models.dev, allocated in `arena`. +/// Serves a fresh on-disk cache, else fetches and refreshes it, falling back +/// to an expired cache when the fetch fails. Empty only when nothing is +/// available, so callers degrade to accepting a typed-in model name. +pub fn modelIds(arena: std.mem.Allocator, provider_id: []const u8, app_dir: ?[]const u8) []const []const u8 { + const cached: ?Cache = if (app_dir) |dir| readCache(arena, dir, provider_id) else null; + if (cached) |c| if (auth.nowMs() - c.fetched_ms <= cache_ttl_ms) return c.ids; + const stale: []const []const u8 = if (cached) |c| c.ids else &.{}; + + const catalog = fetch(arena) catch return stale; + const ids = parseProviderModels(arena, catalog, provider_id) catch return stale; + if (app_dir) |dir| writeCache(arena, dir, provider_id, ids) catch {}; + return ids; +} + +const Cache = struct { + fetched_ms: i64, + ids: []const []const u8, +}; + +fn cachePath(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ![]const u8 { + const name = try std.fmt.allocPrint(arena, "models-dev-{s}.json", .{provider_id}); + return std.fs.path.join(arena, &.{ app_dir, name }); +} + +fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ?Cache { + const path = cachePath(arena, app_dir, provider_id) catch return null; + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(1024 * 1024)) catch return null; + const parsed = std.json.parseFromSliceLeaky(Cache, arena, data, .{ .ignore_unknown_fields = true }) catch return null; + if (parsed.ids.len == 0) return null; + return parsed; +} + +fn writeCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void { + const path = try cachePath(arena, app_dir, provider_id); + try auth.writeJsonAtomic(path, Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .default_file); +} + +/// Model-id keys of `catalog[provider_id].models`, filtered to tool-call-capable +/// models (the agent needs tools; this also drops embedding/image entries). +/// `ignore_unknown_fields` skips the rest of the metadata, so no Value tree is +/// built for the multi-MB payload, but the parsed id map accumulates in +/// `arena`. The ids may alias `catalog`. +fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 { + const Model = struct { tool_call: bool = false }; + const Provider = struct { models: std.json.ArrayHashMap(Model) = .{} }; + const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), arena, catalog, .{ .ignore_unknown_fields = true }); + + const provider = parsed.map.get(provider_id) orelse return error.ProviderMissing; + var ids: std.ArrayList([]const u8) = .empty; + var it = provider.models.map.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.tool_call) try ids.append(arena, entry.key_ptr.*); + } + return ids.toOwnedSlice(arena); +} + +test "parseProviderModels filters to tool-call models" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const catalog = + \\{"openai":{"models":{ + \\ "gpt-5.5":{"tool_call":true}, + \\ "text-embedding-3-small":{"tool_call":false}, + \\ "gpt-image-1":{} + \\}}} + ; + const ids = try parseProviderModels(arena.allocator(), catalog, "openai"); + try std.testing.expectEqual(1, ids.len); + try std.testing.expectEqualStrings("gpt-5.5", ids[0]); + try std.testing.expectError(error.ProviderMissing, parseProviderModels(arena.allocator(), catalog, "nope")); +} + +fn fetch(arena: std.mem.Allocator) ![]u8 { + var client: std.http.Client = .{ .allocator = arena, .io = lp.io }; + defer client.deinit(); + + var out: std.Io.Writer.Allocating = .init(arena); + const res = try client.fetch(.{ .location = .{ .url = api_url }, .response_writer = &out.writer }); + if (res.status != .ok) return error.HttpStatus; + return out.written(); +} diff --git a/src/agent/settings.zig b/src/agent/settings.zig index d7d3bcae1..f428751d5 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -28,25 +28,61 @@ const lp = @import("lightpanda"); const Config = lp.Config; const picker = @import("picker.zig"); const string = @import("../string.zig"); -const Credentials = zenai.provider.Credentials; +const auth = @import("auth/auth.zig"); +const Candidate = zenai.provider.Candidate; -pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud)"; +pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud; Codex: a ChatGPT subscription via /provider codex)"; /// 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). pub const ResolvedProvider = struct { - credentials: Credentials, + credential: Credential, source: enum { flag, remembered, detected, picked }, - /// Key allocated (Vertex gcloud token) rather than an env pointer; the - /// caller frees it, only after the client that references it is gone. - key_owned: bool = false, +}; + +/// The provider credential in effect: the key lives in exactly one place — +/// inside the variant that owns it. The AI client borrows `keySlice()`, so +/// deinit only after that client is gone. +pub const Credential = struct { + provider: Config.AiProvider, + key: union(enum) { + /// Unowned env-var pointer. + env: [:0]const u8, + /// Allocated key (Vertex gcloud token). + owned: [:0]const u8, + /// Subscription (bearer) auth; the key is the access token, refreshed + /// between turns. + session: auth.Session, + }, + + pub fn keySlice(self: *const Credential) [:0]const u8 { + return switch (self.key) { + .env, .owned => |k| k, + .session => |*s| s.tokens.access_token, + }; + } + + pub fn accountId(self: *const Credential) ?[]const u8 { + return switch (self.key) { + .session => |*s| s.tokens.account_id, + else => null, + }; + } + + pub fn deinit(self: *Credential, allocator: std.mem.Allocator) void { + switch (self.key) { + .env => {}, + .owned => |k| allocator.free(k), + .session => |*s| s.deinit(), + } + } }; /// Probe a keyless local provider (Ollama, llama.cpp): its env key is a /// placeholder, so the only honest availability signal is the server answering /// `/v1/models` with a loaded model. Null means no server responded. -pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, base_url: ?[:0]const u8) ?Credentials { +pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, base_url: ?[:0]const u8) ?Candidate { const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); @@ -97,23 +133,29 @@ pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 { /// env-detected). Skips the Ollama probe so it isn't run twice at startup; the /// interactive picker only fires on detected keys, which this still catches. pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool { - if (opts.provider) |p| return zenai.provider.envApiKey(lp.environ(), p) != null or (p == .vertex and vertexProjectMode()); - if (remembered) |r| if (r.provider) |p| { - if (zenai.provider.envApiKey(lp.environ(), p) != null) return true; - if (p == .vertex and vertexProjectMode()) return true; - }; - var buf: [zenai.provider.default_candidates.len]Credentials = undefined; + if (opts.provider) |p| return detectableKey(p); + if (remembered) |r| if (r.provider) |p| if (detectableKey(p)) return true; + var buf: [zenai.provider.default_candidates.len]Candidate = undefined; return availableProviders(&buf).len > 0; } +fn detectableKey(p: Config.AiProvider) bool { + return zenai.provider.envApiKey(lp.environ(), p) != null or + (p == .vertex and vertexProjectMode()) or + auth.subscriptionAvailable(p); +} + /// Precedence: `--provider` > remembered (if its key is still set) > first /// detected. Null means no key at all (the reason is already printed). pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, remembered: ?Remembered, allow_pick: bool) !?ResolvedProvider { if (opts.provider) |p| { if (p == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); - return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .key_owned = true }; + return .{ .credential = .{ .provider = p, .key = .{ .owned = token } }, .source = .flag }; } + // A subscription takes priority over an API key; null = not a + // subscription provider (or none available), so fall through. + if (try subscriptionResolved(allocator, p, .flag)) |resolved| return resolved; const key = zenai.provider.envApiKey(lp.environ(), p) orelse { if (p == .vertex) { std.debug.print( @@ -128,28 +170,33 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme ); return error.MissingApiKey; }; - return .{ .credentials = .{ .provider = p, .key = key }, .source = .flag }; + return .{ .credential = .{ .provider = p, .key = .{ .env = key } }, .source = .flag }; } if (remembered) |r| if (r.provider) |p| { if (p == .vertex and vertexProjectMode()) { // On failure the reason is already printed; fall through to detection. if (gcloudAccessToken(allocator)) |token| { - return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .key_owned = true }; + return .{ .credential = .{ .provider = p, .key = .{ .owned = token } }, .source = .remembered }; } else |_| {} - } else if (zenai.provider.envApiKey(lp.environ(), p)) |key| { - return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + } else { + // Subscription takes priority over an API key; both fall through to + // detection on miss. + if (try subscriptionResolved(allocator, p, .remembered)) |resolved| return resolved; + if (zenai.provider.envApiKey(lp.environ(), p)) |key| { + return .{ .credential = .{ .provider = p, .key = .{ .env = key } }, .source = .remembered }; + } } }; - var buf: [zenai.provider.default_candidates.len]Credentials = undefined; + var buf: [zenai.provider.default_candidates.len]Candidate = undefined; const found = availableProviders(&buf); if (found.len == 0) { if (detectLocalProvider(allocator, .ollama, opts.base_url)) |creds| { - return .{ .credentials = creds, .source = .detected }; + return .{ .credential = .{ .provider = creds.provider, .key = .{ .env = creds.key } }, .source = .detected }; } if (detectLocalProvider(allocator, .llama_cpp, opts.base_url)) |creds| { - return .{ .credentials = creds, .source = .detected }; + return .{ .credential = .{ .provider = creds.provider, .key = .{ .env = creds.key } }, .source = .detected }; } std.debug.print( \\No API key detected. Set {s}, or run a local Ollama or llama.cpp server with a loaded model. @@ -173,14 +220,37 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme return try finishResolved(allocator, found[idx], .picked); } -/// Swaps the placeholder key of a detected project-mode Vertex for a real -/// gcloud token. -fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { - if (credentials.provider == .vertex and vertexProjectMode()) { +/// Swaps a placeholder credential for a live token: a gcloud token for +/// project-mode Vertex, or a stored subscription session for the subscription +/// (empty-key) placeholder. +fn finishResolved(allocator: std.mem.Allocator, candidate: Candidate, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { + if (candidate.provider == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); - return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true }; + return .{ .credential = .{ .provider = .vertex, .key = .{ .owned = token } }, .source = source }; } - return .{ .credentials = credentials, .source = source }; + if (auth.descriptorFor(candidate.provider) != null and candidate.key.len == 0) { + if (try subscriptionResolved(allocator, candidate.provider, source)) |resolved| return resolved; + return error.MissingApiKey; + } + return .{ .credential = .{ .provider = candidate.provider, .key = .{ .env = candidate.key } }, .source = source }; +} + +/// Load a stored subscription session and wrap it as a resolved credential. +/// The session owns the key; the caller takes ownership via +/// `ownership.deinit`. Null when the user hasn't logged in. +fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider { + const session = (try auth.sessionFor(allocator, provider)) orelse return null; + // Name the credential in effect — a set-but-ignored API key would otherwise + // be a silent surprise. + if (zenai.provider.envApiKey(lp.environ(), provider) != null) { + std.debug.print("{s}: using your {s}; {s} is set but the subscription takes priority.\n", .{ @tagName(provider), session.descriptor.label, zenai.provider.envVarName(provider) }); + } else { + std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label }); + } + return .{ + .credential = .{ .provider = provider, .key = .{ .session = session } }, + .source = source, + }; } pub const remembered_path = ".lp-agent.zon"; @@ -234,8 +304,21 @@ pub fn saveRemembered(remembered: Remembered) !void { /// a live probe (`detectLocalProvider`), too costly for an unconditional startup scan. /// Vertex project mode joins with a placeholder key — no subprocess during a /// scan; the gcloud token is fetched on selection (`finishResolved`). -pub fn availableProviders(buf: []Credentials) []Credentials { - const found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates); +pub fn availableProviders(buf: []Candidate) []Candidate { + var found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates); + // A subscription takes priority over an API key: offer it as a bearer + // placeholder (replacing any API-key entry) that `finishResolved` swaps for a + // live token on selection, mirroring Vertex project mode below. + for (auth.registry) |desc| { + if (!auth.subscriptionAvailable(desc.provider)) continue; + const placeholder: Candidate = .{ .provider = desc.provider, .key = "" }; + if (indexOfProvider(found, desc.provider)) |i| { + found[i] = placeholder; + } else if (found.len < buf.len) { + buf[found.len] = placeholder; + found = buf[0 .. found.len + 1]; + } + } if (zenai.provider.useVertex(lp.environ()) and vertexProjectMode() and found.len < buf.len) { buf[found.len] = .{ .provider = .vertex, .key = "" }; return buf[0 .. found.len + 1]; @@ -243,15 +326,20 @@ pub fn availableProviders(buf: []Credentials) []Credentials { return found; } +fn indexOfProvider(candidates: []const Candidate, provider: Config.AiProvider) ?usize { + for (candidates, 0..) |c, i| if (c.provider == provider) return i; + return null; +} + pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembered: ?Remembered) []const u8 { if (opts.model) |m| return m; if (resolved) |r| { // Use the remembered model whenever it matches the chosen provider, // not only when the provider itself came from the remembered file. if (remembered) |rem| { - if (rem.provider) |p| if (p == r.credentials.provider) return rem.model; + if (rem.provider) |p| if (p == r.credential.provider) return rem.model; } - return zenai.provider.defaultModel(r.credentials.provider); + return zenai.provider.defaultModel(r.credential.provider); } return ""; } @@ -303,26 +391,30 @@ pub const ReconciledModel = union(enum) { /// model when unloaded; cloud defaults are hardcoded real models, trusted as-is. pub fn reconcileModel( allocator: std.mem.Allocator, - llm: Credentials, + credential: *const Credential, desired: []const u8, base_url: ?[:0]const u8, explicit: bool, ) !ReconciledModel { + // A subscription provider can't list models via the provider API; trust the + // desired model as-is rather than error against `/models`. + if (auth.descriptorFor(credential.provider) != null) return .{ .use = try allocator.dupe(u8, desired) }; + var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = base_url, .environ = lp.environ() }) catch &.{}; + const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), credential.provider, credential.keySlice(), .{ .base_url = base_url, .environ = lp.environ() }) catch &.{}; if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) }; if (!explicit) { - switch (llm.provider) { + switch (credential.provider) { .ollama, .llama_cpp => {}, else => return .{ .use = try allocator.dupe(u8, desired) }, } - std.debug.print("Default {s} model '{s}' is not loaded; using '{s}'.\n", .{ @tagName(llm.provider), desired, ids[0] }); + std.debug.print("Default {s} model '{s}' is not loaded; using '{s}'.\n", .{ @tagName(credential.provider), desired, ids[0] }); return .{ .use = try allocator.dupe(u8, ids[0]) }; } - if (llm.provider == .ollama) { + if (credential.provider == .ollama) { const installed = std.mem.join(arena.allocator(), ", ", ids) catch ""; std.debug.print( "Model '{s}' is not installed in Ollama.\nInstalled: {s}\nRun `ollama pull {s}` to install it, or choose one of the above.\n", @@ -331,7 +423,7 @@ pub fn reconcileModel( } else { std.debug.print( "Model '{s}' is not available for {s}.\nRun with --list-models to see options.\n", - .{ desired, @tagName(llm.provider) }, + .{ desired, @tagName(credential.provider) }, ); } return .abort; @@ -378,3 +470,10 @@ test "resolveStream: default on, remembered wins" { try testing.expect(resolveStream(.{ .model = "m", .stream = true })); try testing.expect(!resolveStream(.{ .model = "m", .stream = false })); } + +test { + // Pull the auth module tests into the suite (a `const` import alone doesn't). + _ = @import("auth/auth.zig"); + _ = @import("auth/codex.zig"); + _ = @import("auth/models_dev.zig"); +}