From 00cdc3ba8d1c062fdb6e04335db23fed2b408d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 28 Jul 2026 11:17:08 +0200 Subject: [PATCH] agent: update zenai and switch auth to interruptible HTTP --- build.zig.zon | 4 +- src/App.zig | 2 +- src/agent/Agent.zig | 73 ++++++++++------------- src/agent/auth/auth.zig | 79 ++++++++++--------------- src/agent/auth/codex.zig | 107 ++++++++++------------------------ src/agent/auth/models_dev.zig | 25 +------- src/agent/settings.zig | 14 +++-- 7 files changed, 107 insertions(+), 197 deletions(-) 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 {