diff --git a/build.zig.zon b/build.zig.zon
index 2f92925df..68845e049 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#2fb52c9ef814b6b57596692477862d5183969d7c",
- .hash = "zenai-0.0.0-iOY_VOb-BQCiU8PDX1qxvpJm-AQHLGFOp_DN6xIV5iJx",
+ .url = "git+https://github.com/lightpanda-io/zenai.git#58489db10aaa22204b0347c0938ee3293c8cd3e9",
+ .hash = "zenai-0.0.0-iOY_VAkABgBXETyPYATPQE2D3_4eF-A8k0Nt_c0G3nKt",
},
.isocline = .{
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",
diff --git a/src/App.zig b/src/App.zig
index 0b7d5ef09..5eff1f64e 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -129,7 +129,7 @@ fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 {
return app_dir_path;
}
-fn getAppDataDir(allocator: Allocator, appname: []const u8) ![]const u8 {
+pub fn getAppDataDir(allocator: Allocator, appname: []const u8) ![]const u8 {
switch (@import("builtin").os.tag) {
.macos, .ios => {
const home = std.c.getenv("HOME") orelse return error.AppDataDirUnavailable;
diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig
index 887e029cb..e6c6e86d4 100644
--- a/src/agent/Agent.zig
+++ b/src/agent/Agent.zig
@@ -937,8 +937,8 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
// Re-selecting vertex (token refresh) or a subscription provider (re-import
// after logging into its CLI) falls through instead of no-op'ing.
const vertex_project = provider == .vertex and settings.vertexProjectMode();
- const subscription = auth.descriptorFor(provider) != null;
- if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and !subscription) {
+ const sub_desc = auth.descriptorFor(provider);
+ if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and sub_desc == null) {
self.terminal.printInfo("provider: {s}", .{@tagName(provider)});
return;
};
@@ -955,9 +955,9 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
}
// Subscription provider: use the stored session, or run the interactive
// login (device-code flow) when there's none yet.
- if (auth.descriptorFor(provider)) |desc| {
+ if (sub_desc) |desc| {
var owned = (auth.sessionFor(self.allocator, provider) catch null) orelse
- (auth.login(self.allocator, desc, &self.cancel_requested) catch |err| {
+ (auth.login(self.allocator, desc, &self.http_interrupt) catch |err| {
if (err == error.LoginCancelled) {
// Undo `requestCancel`'s side effects before the next turn.
self.resetAfterCancel(self.conversation.messages.items.len);
@@ -1049,25 +1049,25 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8,
self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) });
};
self.reportSaved("model", self.model);
- // Priming warms the completion cache; skip it for a subscription provider,
- // whose catalog is a multi-MB models.dev download best deferred to first use.
- if (auth.descriptorFor(credentials.provider) == null) _ = completionModels(self, self.allocator);
+ // Prime the completion cache on the command, not on a `/model` keystroke.
+ _ = completionModels(self, self.allocator);
}
/// Keep a subscription (bearer) token current before a model request: when the
/// active session's token is near expiry, re-import from the source and repoint
/// the client 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
-/// old buffer (held one cycle by `auth.Session`) is never dereferenced freed.
+/// 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.auth_session) |*session| {
- const new_token = session.ensureFresh() catch |err| {
+ const displaced = session.ensureFresh() catch |err| {
self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)});
return;
};
- if (new_token) |tok| {
- if (self.ai_client) |client| client.setApiKey(tok);
- if (self.model_credentials) |*c| c.key = tok;
+ 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);
}
}
}
@@ -1940,21 +1940,16 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
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 (uncached here — this is a one-shot CLI command).
- if (auth.descriptorFor(llm.provider)) |desc| {
- const sub_ids = models_dev.modelIds(arena.allocator(), desc.models_dev_id, null);
- var stdout_sub = std.Io.File.stdout().writerStreaming(lp.io, &.{});
- const ws = &stdout_sub.interface;
- for (sub_ids) |id| try ws.print("{s}\n", .{id});
- try ws.flush();
- return;
- }
- const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| {
- if (llm.provider == .vertex and !settings.vertexProjectMode()) {
- std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{});
- }
- return err;
- };
+ // free models.dev catalog.
+ const ids: []const []const u8 = if (auth.descriptorFor(llm.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()) {
+ std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{});
+ }
+ return err;
+ };
var stdout_file = std.Io.File.stdout().writerStreaming(lp.io, &.{});
const w = &stdout_file.interface;
@@ -1984,35 +1979,29 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const []
};
if (reachable[i]) extra += 1;
}
+ const names = arena.alloc([]const u8, self.available_providers.len + auth.registry.len + 1 + extra) catch return &.{};
+ for (self.available_providers, 0..) |p, i| {
+ names[i] = arena.dupe(u8, p) catch return &.{};
+ }
+ var n = self.available_providers.len;
// Subscription providers complete even without a stored token — selecting
// one is what starts the login.
- var subs: [auth.registry.len][]const u8 = undefined;
- var n_subs: usize = 0;
for (auth.registry) |desc| {
const name = @tagName(desc.provider);
const detected = for (self.available_providers) |p| {
if (std.mem.eql(u8, p, name)) break true;
} else false;
if (!detected) {
- subs[n_subs] = name;
- n_subs += 1;
+ names[n] = name;
+ n += 1;
}
}
- const names = arena.alloc([]const u8, self.available_providers.len + n_subs + 1 + extra) catch return &.{};
- for (self.available_providers, 0..) |p, i| {
- names[i] = arena.dupe(u8, p) catch return &.{};
- }
- var n = self.available_providers.len;
- for (subs[0..n_subs]) |name| {
- names[n] = name;
- n += 1;
- }
for (local_providers, reachable) |tag, r| if (r) {
names[n] = @tagName(tag);
n += 1;
};
names[n] = provider_off_keyword;
- return names;
+ return names[0 .. n + 1];
}
/// `CompletionSource.models`. Blocks on a one-time fetch per provider, caching
diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig
index 8d994f24b..d1fae55cb 100644
--- a/src/agent/auth/auth.zig
+++ b/src/agent/auth/auth.zig
@@ -17,7 +17,7 @@
// along with this program. If not, see .
//! Provider-agnostic subscription (OAuth) auth for the agent. A `Descriptor`
-//! carries the endpoints plus `loginFn`/`refreshFn` hooks (implemented per
+//! names a provider plus its `loginFn`/`refreshFn` hooks (implemented per
//! provider, e.g. `codex.zig`); this module owns the on-disk token store (in the
//! app data dir) and the `Session` lifecycle (refresh-on-expiry with silent
//! persistence). The AI client borrows `Session.tokens.access_token`, so a
@@ -25,23 +25,19 @@
const std = @import("std");
const lp = @import("lightpanda");
+const zenai = @import("zenai");
const Config = lp.Config;
const codex = @import("codex.zig");
/// Wall-clock ms since the Unix epoch.
pub fn nowMs() i64 {
- return std.Io.Clock.now(.real, lp.io).toMilliseconds();
+ return @intCast(lp.datetime.milliTimestamp(.real));
}
-/// Resolve the app data dir (mirrors `App.getAppDataDir("lightpanda")`) into
-/// `arena`. Null when neither XDG_DATA_HOME nor HOME is set.
-fn dataDir(arena: std.mem.Allocator) ?[]const u8 {
- if (std.c.getenv("XDG_DATA_HOME")) |xdg| {
- const x = std.mem.span(xdg);
- if (x.len > 0) return std.fs.path.join(arena, &.{ x, "lightpanda" }) catch null;
- }
- const home = std.c.getenv("HOME") orelse return null;
- return std.fs.path.join(arena, &.{ std.mem.span(home), ".local", "share", "lightpanda" }) catch null;
+/// Resolve the app data dir into `arena`, for callers without an `App` (the
+/// store wrappers, `--list-models`). Null when no home is resolvable.
+pub fn dataDir(arena: std.mem.Allocator) ?[]const u8 {
+ return lp.App.getAppDataDir(arena, "lightpanda") catch null;
}
/// An OAuth credential set. `account_id` is a provider-specific extra (Codex's
@@ -68,29 +64,25 @@ pub const TokenSet = struct {
}
};
-/// Static per-provider OAuth configuration plus the login/refresh implementations.
+/// Per-provider login/refresh implementations; OAuth endpoints and client ids
+/// live in the implementing file.
pub const Descriptor = struct {
provider: Config.AiProvider,
- /// Key in the on-disk store (`auth.json`).
- id: []const u8,
/// Provider key in the models.dev catalog (subscription backends have no
/// entry of their own; codex's models are listed under "openai").
models_dev_id: []const u8,
/// Human label for the credential, e.g. "ChatGPT subscription".
label: []const u8,
- client_id: []const u8,
- scope: []const u8,
- /// OAuth token endpoint (code exchange + refresh grant).
- token_url: []const u8,
- /// Device-authorization endpoints and the URL the user visits to enter the code.
- device_code_url: []const u8,
- device_token_url: []const u8,
- verify_url: []const u8,
/// Interactive login (device-code flow); returns freshly-minted tokens.
- /// A set `cancel` flag aborts the flow with `error.LoginCancelled`.
- loginFn: *const fn (std.mem.Allocator, *const Descriptor, cancel: ?*const std.atomic.Value(bool)) anyerror!TokenSet,
+ /// Firing `interrupt` aborts the flow with `error.LoginCancelled`.
+ loginFn: *const fn (std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) anyerror!TokenSet,
/// Exchange a refresh token for a new `TokenSet` (with re-derived account id).
- refreshFn: *const fn (std.mem.Allocator, *const Descriptor, []const u8) anyerror!TokenSet,
+ refreshFn: *const fn (std.mem.Allocator, refresh_token: []const u8) anyerror!TokenSet,
+
+ /// Key in the on-disk store (`auth.json`).
+ pub fn storeKey(self: *const Descriptor) []const u8 {
+ return @tagName(self.provider);
+ }
};
pub const registry = [_]*const Descriptor{&codex.descriptor};
@@ -100,7 +92,7 @@ pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor {
return null;
}
-// --- On-disk token store: /auth.json, a JSON map keyed by descriptor id ---
+// --- On-disk token store: /auth.json, a JSON map keyed by storeKey ---
// The `*At` variants take an explicit dir (unit-testable); the public wrappers
// resolve the app data dir themselves so callers need not thread it.
@@ -196,50 +188,43 @@ pub const Session = struct {
allocator: std.mem.Allocator,
descriptor: *const Descriptor,
tokens: TokenSet,
- /// The immediately-prior token, kept alive one refresh cycle so a client
- /// still pointing at it (until `setApiKey`) never dereferences freed memory.
- previous: ?TokenSet = null,
/// When the access token is within `refresh_skew_ms` of expiry, exchange the
- /// refresh token for a new one, persist it, and return the new access token
- /// (owned by the session). Null when still fresh. The caller must repoint its
- /// client before its next request; the old buffer stays valid until the
- /// following `ensureFresh`/`deinit`.
- pub fn ensureFresh(self: *Session) !?[:0]const u8 {
+ /// refresh token for a new one, persist it, and return the displaced set.
+ /// Null when still fresh. The caller must repoint anything borrowing from
+ /// the displaced set (`tokens` holds the fresh one) before freeing it.
+ pub fn ensureFresh(self: *Session) !?TokenSet {
const now = nowMs();
if (self.tokens.expires_at_ms - now > refresh_skew_ms) return null;
- const fresh = self.descriptor.refreshFn(self.allocator, self.descriptor, self.tokens.refresh_token) catch |err| {
+ const fresh = self.descriptor.refreshFn(self.allocator, self.tokens.refresh_token) catch |err| {
// A transient refresh failure while the token is still valid is not fatal.
if (self.tokens.expires_at_ms > now) return null;
return err;
};
- storeSave(self.descriptor.id, fresh) catch {};
+ storeSave(self.descriptor.storeKey(), fresh) catch {};
- if (self.previous) |p| p.deinit(self.allocator);
- self.previous = self.tokens;
+ const displaced = self.tokens;
self.tokens = fresh;
- return self.tokens.access_token;
+ return displaced;
}
pub fn deinit(self: *Session) void {
self.tokens.deinit(self.allocator);
- if (self.previous) |p| p.deinit(self.allocator);
- self.previous = null;
}
};
/// Load a stored session for `provider`, or null when the user hasn't logged in.
pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session {
const desc = descriptorFor(provider) orelse return null;
- const tokens = (try storeLoad(allocator, desc.id)) orelse return null;
+ const tokens = (try storeLoad(allocator, desc.storeKey())) orelse return null;
return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens };
}
/// Run the interactive login and persist the result, returning a live session.
-pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, cancel: ?*const std.atomic.Value(bool)) !Session {
- const tokens = try desc.loginFn(allocator, desc, cancel);
- storeSave(desc.id, tokens) catch {};
+pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, interrupt: ?*zenai.http.Interrupt) !Session {
+ const tokens = try desc.loginFn(allocator, interrupt);
+ storeSave(desc.storeKey(), tokens) catch {};
return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens };
}
@@ -247,9 +232,9 @@ pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor, cancel: ?*co
/// Lets the picker offer the subscription without an API-key env var.
pub fn subscriptionAvailable(provider: Config.AiProvider) bool {
const desc = descriptorFor(provider) orelse return false;
- const tokens = (storeLoad(std.heap.page_allocator, desc.id) catch return false) orelse return false;
+ const tokens = (storeLoad(std.heap.page_allocator, desc.storeKey()) catch return false) orelse return false;
defer tokens.deinit(std.heap.page_allocator);
- return tokens.expires_at_ms == 0 or tokens.expires_at_ms > nowMs();
+ return tokens.expires_at_ms > nowMs();
}
test "TokenSet dup/deinit round-trips and is leak-free" {
diff --git a/src/agent/auth/codex.zig b/src/agent/auth/codex.zig
index 089aac379..d2eb67a58 100644
--- a/src/agent/auth/codex.zig
+++ b/src/agent/auth/codex.zig
@@ -21,13 +21,10 @@
//! URL, we poll for the authorization code, then exchange it for tokens. The
//! `ChatGPT-Account-Id` sent on every API request is derived from the OAuth JWT.
//! Constants mirror the official Codex CLI / opencode.
-//!
-//! NETWORK-UNVERIFIED: the HTTP flows (`deviceLogin`/`refreshGrant`) cannot be
-//! tested without a live subscription; the pure builders + JWT parsing below are
-//! unit-tested. Verify the live handshake once a subscription is available.
const std = @import("std");
const lp = @import("lightpanda");
+const zenai = @import("zenai");
const auth = @import("auth.zig");
const client_id = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -38,22 +35,14 @@ const device_token_url = issuer ++ "/api/accounts/deviceauth/token";
const verify_url = issuer ++ "/codex/device";
/// Redirect URI used only in the device-flow token exchange body (never hit).
const device_redirect_uri = issuer ++ "/deviceauth/callback";
-const scope = "openid profile email offline_access";
const user_agent = "lightpanda";
const poll_margin_ms: u64 = 3000;
const cancel_slice_ms: u64 = 200;
pub const descriptor: auth.Descriptor = .{
.provider = .codex,
- .id = "codex",
.models_dev_id = "openai",
.label = "ChatGPT subscription",
- .client_id = client_id,
- .scope = scope,
- .token_url = token_url,
- .device_code_url = device_code_url,
- .device_token_url = device_token_url,
- .verify_url = verify_url,
.loginFn = deviceLogin,
.refreshFn = refreshGrant,
};
@@ -111,41 +100,27 @@ fn parseTokenResponse(allocator: std.mem.Allocator, body: []const u8) !auth.Toke
defer arena.deinit();
const a = arena.allocator();
const tr = try std.json.parseFromSliceLeaky(TokenResponse, a, body, .{ .ignore_unknown_fields = true });
- const account_id = if (tr.id_token) |t| accountIdFromJwt(a, t) else null orelse
+ const account_id = (if (tr.id_token) |t| accountIdFromJwt(a, t) else null) orelse
accountIdFromJwt(a, tr.access_token);
const expires_at = auth.nowMs() + (tr.expires_in orelse 3600) * std.time.ms_per_s;
return auth.TokenSet.dup(allocator, tr.access_token, tr.refresh_token, expires_at, account_id);
}
fn refreshBody(arena: std.mem.Allocator, refresh_token: []const u8) ![]u8 {
- var buf: std.Io.Writer.Allocating = .init(arena);
- try buf.writer.writeAll("grant_type=refresh_token&client_id=" ++ client_id ++ "&refresh_token=");
- try percentEncode(&buf.writer, refresh_token);
- return buf.written();
+ return std.fmt.allocPrint(arena, "grant_type=refresh_token&client_id=" ++ client_id ++ "&refresh_token={s}", .{
+ try lp.URL.percentEncodeSegment(arena, refresh_token, .component),
+ });
}
fn exchangeBody(arena: std.mem.Allocator, code: []const u8, code_verifier: []const u8) ![]u8 {
- var buf: std.Io.Writer.Allocating = .init(arena);
- try buf.writer.writeAll("grant_type=authorization_code&client_id=" ++ client_id ++
- "&redirect_uri=" ++ device_redirect_uri ++ "&code=");
- try percentEncode(&buf.writer, code);
- try buf.writer.writeAll("&code_verifier=");
- try percentEncode(&buf.writer, code_verifier);
- return buf.written();
+ return std.fmt.allocPrint(arena, "grant_type=authorization_code&client_id=" ++ client_id ++
+ "&redirect_uri=" ++ device_redirect_uri ++ "&code={s}&code_verifier={s}", .{
+ try lp.URL.percentEncodeSegment(arena, code, .component),
+ try lp.URL.percentEncodeSegment(arena, code_verifier, .component),
+ });
}
-/// Percent-encode a form value (RFC 3986 unreserved chars pass through).
-fn percentEncode(w: *std.Io.Writer, value: []const u8) !void {
- for (value) |c| {
- if (std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~') {
- try w.writeByte(c);
- } else {
- try w.print("%{X:0>2}", .{c});
- }
- }
-}
-
-// --- Device-code flow (network-unverified) ---
+// --- Device-code flow ---
const DeviceCode = struct {
device_auth_id: []const u8,
@@ -158,13 +133,12 @@ const DeviceToken = struct {
code_verifier: []const u8,
};
-fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor, cancel: ?*const std.atomic.Value(bool)) !auth.TokenSet {
- _ = desc;
+fn deviceLogin(allocator: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt) !auth.TokenSet {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const a = arena.allocator();
- const code_res = try post(a, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}");
+ const code_res = try post(a, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}");
if (code_res.status != .ok) return error.DeviceCodeRequestFailed;
const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true });
const interval_ms: u64 = @as(u64, @intCast(std.fmt.parseInt(u32, dc.interval, 10) catch 5)) * std.time.ms_per_s;
@@ -176,16 +150,16 @@ fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor, cance
const poll_body = try std.fmt.allocPrint(a, "{{\"device_auth_id\":\"{s}\",\"user_code\":\"{s}\"}}", .{ dc.device_auth_id, dc.user_code });
const dt: DeviceToken = while (true) {
- // The REPL's Ctrl-C only sets the cancel flag (it never kills the
+ // The REPL's Ctrl-C only fires the interrupt (it never kills the
// process), so the wait must poll it.
var remaining_ms: u64 = interval_ms + poll_margin_ms;
while (remaining_ms > 0) {
- if (cancel) |flag| if (flag.load(.acquire)) return error.LoginCancelled;
+ if (interrupt) |it| if (it.isFired()) return error.LoginCancelled;
const slice_ms = @min(remaining_ms, cancel_slice_ms);
lp.io.sleep(.fromMilliseconds(@intCast(slice_ms)), .awake) catch {};
remaining_ms -= slice_ms;
}
- const res = try post(a, device_token_url, "application/json", poll_body);
+ const res = try post(a, interrupt, device_token_url, "application/json", poll_body);
switch (res.status) {
.ok => break try std.json.parseFromSliceLeaky(DeviceToken, a, res.body, .{ .ignore_unknown_fields = true }),
// Still pending — the user hasn't finished authorizing.
@@ -195,59 +169,40 @@ fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor, cance
};
const exchange = try exchangeBody(a, dt.authorization_code, dt.code_verifier);
- const tok_res = try post(a, token_url, "application/x-www-form-urlencoded", exchange);
+ const tok_res = try post(a, interrupt, token_url, "application/x-www-form-urlencoded", exchange);
if (tok_res.status != .ok) return error.TokenExchangeFailed;
return parseTokenResponse(allocator, tok_res.body);
}
-fn refreshGrant(allocator: std.mem.Allocator, desc: *const auth.Descriptor, refresh_token: []const u8) !auth.TokenSet {
- _ = desc;
+fn refreshGrant(allocator: std.mem.Allocator, refresh_token: []const u8) !auth.TokenSet {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const a = arena.allocator();
const body = try refreshBody(a, refresh_token);
- const res = try post(a, token_url, "application/x-www-form-urlencoded", body);
+ const res = try post(a, null, token_url, "application/x-www-form-urlencoded", body);
if (res.status != .ok) return error.RefreshFailed;
return parseTokenResponse(allocator, res.body);
}
const PostResult = struct { status: std.http.Status, body: []u8 };
-fn post(arena: std.mem.Allocator, url: []const u8, content_type: []const u8, body: []const u8) !PostResult {
+/// One-shot POST. Firing `interrupt` aborts an in-flight exchange and surfaces
+/// `error.LoginCancelled` instead of the read error it caused.
+fn post(arena: std.mem.Allocator, interrupt: ?*zenai.http.Interrupt, url: []const u8, content_type: []const u8, body: []const u8) !PostResult {
var client: std.http.Client = .{ .allocator = arena, .io = lp.io };
defer client.deinit();
- const uri = try std.Uri.parse(url);
- var req = try client.request(.POST, uri, .{
- .redirect_behavior = .unhandled,
- .headers = .{ .content_type = .{ .override = content_type }, .user_agent = .{ .override = user_agent } },
- });
- defer req.deinit();
- req.transfer_encoding = .{ .content_length = body.len };
- var sink = try req.sendBodyUnflushed(&.{});
- try sink.writer.writeAll(body);
- try sink.end();
- try req.connection.?.flush();
-
- var redirect_buffer: [4096]u8 = undefined;
- var response = try req.receiveHead(&redirect_buffer);
-
- const decompress_buffer: []u8 = switch (response.head.content_encoding) {
- .identity => &.{},
- .zstd => try arena.alloc(u8, std.compress.zstd.default_window_len),
- .deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len),
- .compress => return error.UnsupportedCompression,
- };
- var transfer_buffer: [4096]u8 = undefined;
- var decompress: std.http.Decompress = undefined;
- const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer);
-
var out: std.Io.Writer.Allocating = .init(arena);
- _ = reader.streamRemaining(&out.writer) catch |err| switch (err) {
- error.ReadFailed => return response.bodyErr().?,
- else => |e| return e,
+ const status = zenai.http.fetchInterruptible(arena, &client, .{
+ .location = .{ .url = url },
+ .method = .POST,
+ .payload = body,
+ .headers = .{ .content_type = .{ .override = content_type }, .user_agent = .{ .override = user_agent } },
+ }, &out.writer, interrupt) catch |err| {
+ if (interrupt) |it| if (it.isFired()) return error.LoginCancelled;
+ return err;
};
- return .{ .status = response.head.status, .body = out.written() };
+ return .{ .status = status, .body = out.written() };
}
// --- Tests (pure paths) ---
diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig
index 30efef3c9..51b3f5b5c 100644
--- a/src/agent/auth/models_dev.zig
+++ b/src/agent/auth/models_dev.zig
@@ -117,29 +117,8 @@ fn fetch(arena: std.mem.Allocator) ![]u8 {
var client: std.http.Client = .{ .allocator = arena, .io = lp.io };
defer client.deinit();
- const uri = try std.Uri.parse(api_url);
- var req = try client.request(.GET, uri, .{ .redirect_behavior = @enumFromInt(3) });
- defer req.deinit();
- try req.sendBodiless();
-
- var redirect_buffer: [8 * 1024]u8 = undefined;
- var response = try req.receiveHead(&redirect_buffer);
- if (response.head.status != .ok) return error.HttpStatus;
-
- const decompress_buffer: []u8 = switch (response.head.content_encoding) {
- .identity => &.{},
- .zstd => try arena.alloc(u8, std.compress.zstd.default_window_len),
- .deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len),
- .compress => return error.UnsupportedCompression,
- };
- var transfer_buffer: [4096]u8 = undefined;
- var decompress: std.http.Decompress = undefined;
- const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer);
-
var out: std.Io.Writer.Allocating = .init(arena);
- _ = reader.streamRemaining(&out.writer) catch |err| switch (err) {
- error.ReadFailed => return response.bodyErr().?,
- else => |e| return e,
- };
+ const res = try client.fetch(.{ .location = .{ .url = api_url }, .response_writer = &out.writer });
+ if (res.status != .ok) return error.HttpStatus;
return out.written();
}
diff --git a/src/agent/settings.zig b/src/agent/settings.zig
index dacae01af..8b94ea339 100644
--- a/src/agent/settings.zig
+++ b/src/agent/settings.zig
@@ -102,16 +102,18 @@ pub fn gcloudAccessToken(allocator: std.mem.Allocator) ![:0]const u8 {
/// env-detected). Skips the Ollama probe so it isn't run twice at startup; the
/// interactive picker only fires on detected keys, which this still catches.
pub fn hasDetectableKey(opts: Config.Agent, remembered: ?Remembered) bool {
- if (opts.provider) |p| return zenai.provider.envApiKey(lp.environ(), p) != null or (p == .vertex and vertexProjectMode()) or auth.subscriptionAvailable(p);
- if (remembered) |r| if (r.provider) |p| {
- if (zenai.provider.envApiKey(lp.environ(), p) != null) return true;
- if (p == .vertex and vertexProjectMode()) return true;
- if (auth.subscriptionAvailable(p)) return true;
- };
+ 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;
return availableProviders(&buf).len > 0;
}
+fn detectableKey(p: Config.AiProvider) bool {
+ return zenai.provider.envApiKey(lp.environ(), p) != null or
+ (p == .vertex and vertexProjectMode()) or
+ auth.subscriptionAvailable(p);
+}
+
/// Precedence: `--provider` > remembered (if its key is still set) > first
/// detected. Null means no key at all (the reason is already printed).
pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, remembered: ?Remembered, allow_pick: bool) !?ResolvedProvider {