mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 17:25:58 -04:00
agent: update zenai and switch auth to interruptible HTTP
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#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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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: <data_dir>/auth.json, a JSON map keyed by descriptor id ---
|
||||
// --- On-disk token store: <data_dir>/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" {
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user