From 8b6f7fe0647447d3c4ea6baee075efbe292988bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 13:05:46 +0200 Subject: [PATCH] agent: simplify credential and key ownership handling --- build.zig.zon | 4 +- src/agent/Agent.zig | 114 +++++++++++++++++++--------------------- src/agent/settings.zig | 116 ++++++++++++++++++++++------------------- 3 files changed, 119 insertions(+), 115 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 68845e049..916a0b3a3 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#58489db10aaa22204b0347c0938ee3293c8cd3e9", - .hash = "zenai-0.0.0-iOY_VAkABgBXETyPYATPQE2D3_4eF-A8k0Nt_c0G3nKt", + .url = "git+https://github.com/lightpanda-io/zenai.git#ceb6a7bf54ad032870da56b5ac6f1da0e476de6e", + .hash = "zenai-0.0.0-iOY_VAYABgCAHU20NPcSQotRdYG9aJf-ASE86YsN-AMJ", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 09fa7efd5..7894feb5e 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -29,7 +29,7 @@ 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"); @@ -134,10 +134,9 @@ const synthesis_prompt = allocator: std.mem.Allocator, ai_client: ?zenai.provider.Client, -model_credentials: ?Credentials, -/// Ownership of `model_credentials.key`. The AI client borrows the key, so -/// deinit only after the client is gone. -key_ownership: settings.KeyOwnership, +/// 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, @@ -197,7 +196,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; @@ -269,12 +268,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent 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| r.ownership.deinit(allocator); + 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", .{}); } @@ -286,10 +284,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; @@ -298,14 +296,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", .{}); @@ -325,8 +323,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent self.* = .{ .allocator = allocator, .ai_client = null, - .model_credentials = llm, - .key_ownership = if (resolved) |r| r.ownership else .env, + .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, @@ -360,7 +357,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(), .account_id = self.key_ownership.accountId() }) 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); @@ -389,7 +386,7 @@ pub fn deinit(self: *Agent) void { self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); - self.key_ownership.deinit(self.allocator); + 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); @@ -855,7 +852,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; } @@ -886,7 +883,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) { @@ -911,7 +908,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", .{}); @@ -932,7 +929,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { // after logging into its CLI) falls through instead of no-op'ing. const vertex_project = provider == .vertex and settings.vertexProjectMode(); const sub_desc = auth.descriptorFor(provider); - if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and sub_desc == null) { + if (self.credential) |current| if (provider == current.provider and !vertex_project and sub_desc == null) { self.terminal.printInfo("provider: {s}", .{@tagName(provider)}); return; }; @@ -941,7 +938,7 @@ 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 }, .{ .owned = 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)}); }; @@ -961,7 +958,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) }); return; }); - self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token }, .{ .session = owned }) catch |err| { + self.setProvider(.{ .provider = provider, .key = .{ .session = owned } }) catch |err| { owned.deinit(); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; @@ -984,7 +981,7 @@ 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 }, .env) catch |err| { + self.setProvider(.{ .provider = provider, .key = .{ .env = key } }) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; } @@ -994,8 +991,8 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { fn disableProvider(self: *Agent) void { if (self.ai_client) |client| client.deinit(self.allocator); self.ai_client = null; - self.key_ownership.deinit(self.allocator); - 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); @@ -1009,34 +1006,33 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { return std.mem.span(v); } -/// `ownership` transfers whatever owns `credentials.key` on success; on error -/// the caller still owns it. The previous credential is released only after +/// `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, credentials: Credentials, ownership: settings.KeyOwnership) !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(), .account_id = ownership.accountId() }); +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); - self.key_ownership.deinit(self.allocator); - self.key_ownership = ownership; + 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; - if (auth.descriptorFor(credentials.provider)) |d| { - self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), d.label }); + if (auth.descriptorFor(credential.provider)) |d| { + self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credential.provider), d.label }); } else { - self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); + self.terminal.printInfo("provider: {s}", .{@tagName(credential.provider)}); } - if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) { + 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. @@ -1045,19 +1041,20 @@ fn setProvider(self: *Agent, credentials: Credentials, ownership: settings.KeyOw /// 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 and credentials at the fresh token. A no-op for API-key auth. -/// Runs between turns on the single agent thread, never during a request, so -/// the displaced token is never dereferenced after it's freed here. +/// 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, +/// never during a request, so the displaced token is never dereferenced after +/// it's freed here. fn refreshAuthIfNeeded(self: *Agent) void { - if (self.key_ownership != .session) return; - const session = &self.key_ownership.session; + const cred = if (self.credential) |*c| c else return; + if (cred.key != .session) return; + const session = &cred.key.session; const displaced = session.ensureFresh() catch |err| { self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)}); return; }; if (displaced) |old| { if (self.ai_client) |client| client.setApiKey(session.tokens.access_token); - if (self.model_credentials) |*c| c.key = session.tokens.access_token; old.deinit(session.allocator); } } @@ -1623,7 +1620,7 @@ fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) [] const e = client.lastError(); const status = e.status orelse return @errorName(err); const hint = if (status == 401 and client == .vertex) - if (self.key_ownership == .owned) + if (self.credential != null and self.credential.?.key == .owned) " (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)" @@ -1923,18 +1920,17 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { return error.ConflictingFlags; } var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; - const llm = resolved.credentials; - defer resolved.ownership.deinit(allocator); + defer resolved.credential.deinit(allocator); var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); // 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(llm.provider)) |desc| + 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(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { - if (llm.provider == .vertex and !settings.vertexProjectMode()) { + 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; @@ -1997,25 +1993,25 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const [] /// 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 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(llm.provider)) |desc| + 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, - llm.provider, - llm.key, + cred.provider, + cred.keySlice(), .{ .base_url = self.model_base_url, .environ = lp.environ() }, ) catch &.{}; - self.model_completions = .{ .provider = llm.provider, .ids = ids }; + self.model_completions = .{ .provider = cred.provider, .ids = ids }; return ids; } diff --git a/src/agent/settings.zig b/src/agent/settings.zig index 9e5f86c2a..f428751d5 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -29,7 +29,7 @@ const Config = lp.Config; const picker = @import("picker.zig"); const string = @import("../string.zig"); const auth = @import("auth/auth.zig"); -const Credentials = zenai.provider.Credentials; +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; Codex: a ChatGPT subscription via /provider codex)"; @@ -37,43 +37,52 @@ pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF /// 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 }, - ownership: KeyOwnership = .env, }; -/// How the active `Credentials.key` is owned. `deinit` releases whatever the -/// active variant holds — call it only after the AI client that borrows the -/// key is gone. -pub const KeyOwnership = union(enum) { - /// Unowned env-var pointer. - env, - /// Allocated key (Vertex gcloud token); aliases the active `Credentials.key`. - owned: [:0]const u8, - /// Subscription (bearer) auth owning the key; refreshed between turns. - session: auth.Session, +/// 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 deinit(self: *KeyOwnership, allocator: std.mem.Allocator) void { - switch (self.*) { + 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(), } - self.* = .env; - } - - pub fn accountId(self: *const KeyOwnership) ?[]const u8 { - return switch (self.*) { - .session => |*s| s.tokens.account_id, - else => null, - }; } }; /// 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(); @@ -126,7 +135,7 @@ pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 { pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool { 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]Credentials = undefined; + var buf: [zenai.provider.default_candidates.len]Candidate = undefined; return availableProviders(&buf).len > 0; } @@ -142,7 +151,7 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme if (opts.provider) |p| { if (p == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); - return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .ownership = .{ .owned = token } }; + 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. @@ -161,33 +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, .ownership = .{ .owned = token } }; + return .{ .credential = .{ .provider = p, .key = .{ .owned = token } }, .source = .remembered }; } else |_| {} } 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 .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + 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. @@ -214,20 +223,20 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme /// 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, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { - if (credentials.provider == .vertex and vertexProjectMode()) { +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, .ownership = .{ .owned = token } }; + return .{ .credential = .{ .provider = .vertex, .key = .{ .owned = token } }, .source = source }; } - if (auth.descriptorFor(credentials.provider) != null and credentials.key.len == 0) { - if (try subscriptionResolved(allocator, credentials.provider, source)) |resolved| return resolved; + 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 .{ .credentials = credentials, .source = source }; + 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 `credentials.key`; the caller takes ownership via +/// 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; @@ -239,9 +248,8 @@ fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvide std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label }); } return .{ - .credentials = .{ .provider = provider, .key = session.tokens.access_token }, + .credential = .{ .provider = provider, .key = .{ .session = session } }, .source = source, - .ownership = .{ .session = session }, }; } @@ -296,14 +304,14 @@ 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 { +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: Credentials = .{ .provider = desc.provider, .key = "" }; + const placeholder: Candidate = .{ .provider = desc.provider, .key = "" }; if (indexOfProvider(found, desc.provider)) |i| { found[i] = placeholder; } else if (found.len < buf.len) { @@ -318,8 +326,8 @@ pub fn availableProviders(buf: []Credentials) []Credentials { return found; } -fn indexOfProvider(creds: []const Credentials, provider: Config.AiProvider) ?usize { - for (creds, 0..) |c, i| if (c.provider == provider) return i; +fn indexOfProvider(candidates: []const Candidate, provider: Config.AiProvider) ?usize { + for (candidates, 0..) |c, i| if (c.provider == provider) return i; return null; } @@ -329,9 +337,9 @@ pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembe // 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 ""; } @@ -383,30 +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(llm.provider) != null) return .{ .use = try allocator.dupe(u8, desired) }; + 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", @@ -415,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;