mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 09:16:07 -04:00
agent: simplify credential and key ownership handling
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user