agent: simplify credential and key ownership handling

This commit is contained in:
Adrià Arrufat
2026-07-28 13:05:46 +02:00
parent a53fbcc6eb
commit 8b6f7fe064
3 changed files with 119 additions and 115 deletions

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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;