diff --git a/build.zig.zon b/build.zig.zon index 725fbed4d..00c2eeac8 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#00c793fdd2797ae5fc9700c189d8617f167ae0dc", - .hash = "zenai-0.0.0-iOY_VGOnBQDaWE_Gn8cwhh0XXE8RZ8ESE7QtcITF-P73", + .url = "git+https://github.com/lightpanda-io/zenai.git#fce390119e2a4bb5d33f55579c6f6a79dfb245f6", + .hash = "zenai-0.0.0-iOY_VHzrBQBkTrBexUzTVhDNWGLNA7Vh2eoPuMnlUVZS", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index c2481a919..d270e8a52 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -38,6 +38,8 @@ const Terminal = @import("Terminal.zig"); const ansi = @import("ansi.zig"); const SlashCommand = @import("SlashCommand.zig"); const settings = @import("settings.zig"); +const auth = @import("auth/auth.zig"); +const models_dev = @import("auth/models_dev.zig"); const picker = @import("picker.zig"); const save = @import("save.zig"); const welcome = @import("welcome.zig"); @@ -136,6 +138,12 @@ model_credentials: ?Credentials, /// Allocated credentials key (Vertex gcloud token) — other keys are unowned /// env pointers. The AI client references it: free only after client deinit. owned_key: ?[:0]const u8, +/// Active subscription (bearer) auth. Owns `model_credentials.key`, so it must +/// outlive the AI client; refreshed between turns by `refreshAuthIfNeeded`. +auth_session: ?auth.Session, +/// 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, /// True when the no-LLM state is a persisted preference (remembered null /// provider or runtime `/provider null`), so `reportSaved` writes /// `provider = null`. A transient `--no-llm` run leaves it false so saving @@ -262,9 +270,10 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const banner_before = will_repl and (!resolve or settings.hasDetectableKey(opts, remembered)); if (banner_before) welcome.print(resolve); - const resolved: ?settings.ResolvedProvider = if (resolve) try settings.resolveCredentials(allocator, opts, remembered, will_repl) else null; + 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| if (r.key_owned) allocator.free(r.credentials.key); + errdefer if (resolved) |*r| if (r.session) |*s| s.deinit(); if (will_repl and !banner_before and resolved != null) welcome.print(resolve); const llm: ?Credentials = if (resolved) |r| r.credentials else null; @@ -321,6 +330,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .ai_client = null, .model_credentials = llm, .owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null, + .auth_session = if (resolved) |r| r.session else null, + .app_dir = app.app_dir_path, .no_llm_persisted = remembered_no_llm, .model_base_url = opts.base_url, .model_completions = null, @@ -353,7 +364,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(allocator, lp.io, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null; + 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() }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); @@ -382,6 +393,7 @@ pub fn deinit(self: *Agent) void { self.browser.deinit(); self.notification.deinit(); if (self.ai_client) |ai_client| ai_client.deinit(self.allocator); + if (self.auth_session) |*s| s.deinit(); if (self.owned_key) |k| self.allocator.free(k); self.allocator.free(self.model); for (self.available_providers) |p| self.allocator.free(p); @@ -905,9 +917,11 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void { self.terminal.printError("unknown provider: {s} (or 'null' to disable the LLM)", .{trimmed}); return; }; - // Re-selecting vertex falls through — that's the token-refresh path. + // 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(); - if (self.model_credentials) |current| if (provider == current.provider and !vertex_project) { + const subscription = auth.descriptorFor(provider) != null; + if (self.model_credentials) |current| if (provider == current.provider and !vertex_project and !subscription) { self.terminal.printInfo("provider: {s}", .{@tagName(provider)}); return; }; @@ -916,17 +930,33 @@ 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 }, token) catch |err| { + self.setProvider(.{ .provider = .vertex, .key = token }, token, null) catch |err| { self.allocator.free(token); self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; return; } + // Subscription takes priority; on no importable credential, fall through to + // the API-key path. + if (subscription) { + if (auth.sessionFor(self.allocator, provider) catch null) |session| { + var owned = session; + self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token, .auth = .bearer }, null, owned) catch |err| { + owned.deinit(); + self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); + }; + return; + } + } const key = zenai.provider.envApiKey(lp.environ(), provider) orelse { if (provider == .vertex) { self.terminal.printError("vertex needs VERTEX_API_KEY (express mode) or GOOGLE_CLOUD_PROJECT (project mode, token via gcloud)", .{}); return; } + if (subscription) { + self.terminal.printError("no API key or subscription for {s}; set {s} or log into Claude Code", .{ @tagName(provider), zenai.provider.envVarName(provider) }); + return; + } self.terminal.printError("no API key for {s}; set {s}", .{ @tagName(provider), zenai.provider.envVarName(provider) }); return; }; @@ -939,7 +969,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 }, null) catch |err| { + self.setProvider(.{ .provider = provider, .key = key }, null, null) catch |err| { self.terminal.printError("failed to set provider: {s}", .{@errorName(err)}); }; } @@ -966,9 +996,11 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { } /// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex -/// gcloud token) on success; on error the caller still owns it. -fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void { - const new_client = try zenai.provider.Client.init(self.allocator, lp.io, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() }); +/// gcloud token) on success; on error the caller still owns it. `session` +/// likewise transfers a subscription session that owns `credentials.key`; the +/// previous session is freed only after the old client is gone. +fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8, session: ?auth.Session) !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() }); errdefer new_client.deinit(self.allocator); // A same-provider re-select (vertex token refresh) must not reset the model. @@ -976,7 +1008,9 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider)); if (self.ai_client) |client| client.deinit(self.allocator); if (self.owned_key) |k| self.allocator.free(k); + if (self.auth_session) |*s| s.deinit(); self.owned_key = owned_key; + self.auth_session = session; new_client.setInterrupt(&self.http_interrupt); self.ai_client = new_client; self.model_credentials = credentials; @@ -984,13 +1018,38 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) self.model_completions = null; self.allocator.free(self.model); self.model = new_model; - self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); + if (credentials.auth == .bearer) { + const label = if (auth.descriptorFor(credentials.provider)) |d| d.label else "subscription"; + self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), label }); + } else { + self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)}); + } if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) { self.effort = e; self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) }); }; self.reportSaved("model", self.model); - _ = completionModels(self, self.allocator); + // Priming warms the completion cache; skip it for bearer, whose catalog is a + // multi-MB models.dev download best deferred to first `/model` use. + if (credentials.auth != .bearer) _ = 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. +fn refreshAuthIfNeeded(self: *Agent) void { + if (self.auth_session) |*session| { + const new_token = session.ensureFresh() catch |err| { + self.terminal.printError("could not refresh the Claude 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; + } + } } const PathAndMode = struct { path: []const u8, mode: save.Mode }; @@ -1157,6 +1216,7 @@ fn saveOneShot(self: *Agent) void { /// interactive `/save` and one-shot `--save`. fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mode: save.Mode, prompt: ?[]const u8) void { const provider_client = self.ai_client.?; + self.refreshAuthIfNeeded(); // Only update feeds the saved script back to the model; append stays // blind — the script is synthesized from this session alone and written @@ -1599,6 +1659,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { } const provider_client = self.ai_client orelse return error.NoAiClient; + self.refreshAuthIfNeeded(); self.terminal.spinner.start(); var result = provider_client.runTools( @@ -1839,13 +1900,24 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { }); return error.ConflictingFlags; } - const resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; + var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider; const llm = resolved.credentials; defer if (resolved.key_owned) allocator.free(llm.key); + defer if (resolved.session) |*s| s.deinit(); var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, opts.base_url, lp.environ()) catch |err| { + // A subscription (bearer) token can't list models via the provider; use the + // free models.dev catalog (uncached here — this is a one-shot CLI command). + if (llm.auth == .bearer) { + const sub_ids = models_dev.modelIds(arena.allocator(), @tagName(llm.provider), 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", .{}); } @@ -1901,15 +1973,21 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u if (self.model_completions) |c| if (c.provider == llm.provider) return c.ids; _ = self.model_completion_arena.reset(.retain_capacity); - const ids = zenai.provider.listChatModelIds( - self.allocator, - lp.io, - self.model_completion_arena.allocator(), - llm.provider, - llm.key, - self.model_base_url, - lp.environ(), - ) catch &.{}; + const arena = self.model_completion_arena.allocator(); + // A subscription (bearer) token can't hit the provider's `/models` endpoint + // (it 401s), so list from the free, unauthenticated models.dev catalog + // instead — no API key needed. + const ids = if (llm.auth == .bearer) + models_dev.modelIds(arena, @tagName(llm.provider), self.app_dir) + else + zenai.provider.listChatModelIds( + lp.io, + self.allocator, + arena, + llm.provider, + llm.key, + .{ .base_url = self.model_base_url, .environ = lp.environ() }, + ) catch &.{}; self.model_completions = .{ .provider = llm.provider, .ids = ids }; return ids; } diff --git a/src/agent/auth/anthropic.zig b/src/agent/auth/anthropic.zig new file mode 100644 index 000000000..679437f7f --- /dev/null +++ b/src/agent/auth/anthropic.zig @@ -0,0 +1,64 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Anthropic subscription-auth descriptor plus the Claude Code credential +//! importer. v1 reuses the OAuth token Claude Code already maintains at +//! `$HOME/.claude/.credentials.json`; the login/refresh-endpoint fields on the +//! descriptor are the seam for the deferred own-OAuth flow and are unused today. + +const std = @import("std"); +const lp = @import("lightpanda"); +const auth = @import("auth.zig"); + +pub const descriptor: auth.Descriptor = .{ + .provider = .anthropic, + .id = "anthropic", + .label = "Claude subscription", + .authorize_url = "https://claude.ai/oauth/authorize", + .token_url = "https://console.anthropic.com/v1/oauth/token", + .client_id = "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + .redirect_uri = "https://console.anthropic.com/oauth/code/callback", + .scope = "org:create_api_key user:profile user:inference", + .importFn = importClaudeCode, +}; + +/// Read Claude Code's OAuth credentials from `$HOME/.claude/.credentials.json`. +/// Returns null when the file is absent, unreadable, or malformed (the caller +/// falls back to API-key auth). Tokens are duped into `allocator`. +pub fn importClaudeCode(allocator: std.mem.Allocator) !?auth.TokenSet { + const home = std.c.getenv("HOME") orelse return null; + const path = try std.fs.path.join(allocator, &.{ std.mem.span(home), ".claude", ".credentials.json" }); + defer allocator.free(path); + + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, allocator, .limited(64 * 1024)) catch return null; + defer allocator.free(data); + + const Shape = struct { + claudeAiOauth: ?struct { + accessToken: []const u8, + refreshToken: []const u8 = "", + expiresAt: i64 = 0, + } = null, + }; + const parsed = std.json.parseFromSlice(Shape, allocator, data, .{ .ignore_unknown_fields = true }) catch return null; + defer parsed.deinit(); + + const oauth = parsed.value.claudeAiOauth orelse return null; + if (oauth.accessToken.len == 0) return null; + return try auth.TokenSet.dup(allocator, oauth.accessToken, oauth.refreshToken, oauth.expiresAt); +} diff --git a/src/agent/auth/auth.zig b/src/agent/auth/auth.zig new file mode 100644 index 000000000..e66c45da7 --- /dev/null +++ b/src/agent/auth/auth.zig @@ -0,0 +1,176 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Provider-agnostic subscription (OAuth bearer) auth for the agent. v1 imports +//! the token a subscription CLI already maintains on disk — Claude Code for +//! `.anthropic` — and re-reads it on expiry, so the agent never calls the OAuth +//! token endpoint and cannot disturb that CLI's own login. The `Descriptor`'s +//! login-endpoint fields are the seam for a future own-OAuth login flow. + +const std = @import("std"); +const lp = @import("lightpanda"); +const Config = lp.Config; +const anthropic = @import("anthropic.zig"); + +/// Wall-clock ms since the Unix epoch. +pub fn nowMs() i64 { + return std.Io.Clock.now(.real, lp.io).toMilliseconds(); +} + +/// An OAuth credential set. `refresh_token`/`expires_at_ms` back expiry handling +/// (and the deferred refresh grant). +pub const TokenSet = struct { + access_token: [:0]const u8, + refresh_token: []const u8, + expires_at_ms: i64, + + pub fn dup(allocator: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at_ms: i64) !TokenSet { + const a = try allocator.dupeZ(u8, access); + errdefer allocator.free(a); + const r = try allocator.dupe(u8, refresh); + return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms }; + } + + pub fn deinit(self: TokenSet, allocator: std.mem.Allocator) void { + allocator.free(self.access_token); + allocator.free(self.refresh_token); + } +}; + +/// Static per-provider OAuth configuration. Adding a provider is a data addition +/// here plus an `importFn`. The login-endpoint fields are the seam for the +/// deferred own-OAuth login flow and are unused in v1. +pub const Descriptor = struct { + provider: Config.AiProvider, + id: []const u8, + /// Human label for the credential, e.g. "Claude subscription". + label: []const u8, + authorize_url: []const u8, + token_url: []const u8, + client_id: []const u8, + redirect_uri: []const u8, + scope: []const u8, + /// Import a subscription token another CLI already maintains on disk. + importFn: ?*const fn (std.mem.Allocator) anyerror!?TokenSet = null, +}; + +pub const registry = [_]*const Descriptor{&anthropic.descriptor}; + +pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor { + for (registry) |d| if (d.provider == provider) return d; + return null; +} + +/// Proactive-refresh margin: re-check the source once the token is within this +/// window of expiry, so a turn never starts on a token about to lapse. +const refresh_skew_ms: i64 = 5 * std.time.ms_per_min; + +/// A live subscription credential for one provider. Owns its `TokenSet`; the AI +/// client borrows `tokens.access_token`, so the session must outlive the client. +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, re-import + /// from the source and, if the source has a newer token, adopt it and return + /// the new access token (owned by the session). Returns null when nothing + /// changed. The caller must repoint its client with the returned token + /// before its next request; the old buffer stays valid until the following + /// `ensureFresh`/`deinit`. Errors `SubscriptionTokenExpired` when the token + /// has lapsed and the source has no fresher one. + pub fn ensureFresh(self: *Session) !?[:0]const u8 { + const now = nowMs(); + if (self.tokens.expires_at_ms - now > refresh_skew_ms) return null; + + const importFn = self.descriptor.importFn orelse return self.staleResult(now); + const fresh = (try importFn(self.allocator)) orelse return self.staleResult(now); + // The source (e.g. Claude Code) hasn't refreshed yet. + if (fresh.expires_at_ms <= self.tokens.expires_at_ms) { + fresh.deinit(self.allocator); + return self.staleResult(now); + } + + if (self.previous) |p| p.deinit(self.allocator); + self.previous = self.tokens; + self.tokens = fresh; + return self.tokens.access_token; + } + + fn staleResult(self: *Session, now: i64) error{SubscriptionTokenExpired}!?[:0]const u8 { + return if (self.tokens.expires_at_ms > now) null else error.SubscriptionTokenExpired; + } + + pub fn deinit(self: *Session) void { + self.tokens.deinit(self.allocator); + if (self.previous) |p| p.deinit(self.allocator); + self.previous = null; + } +}; + +/// Build a bearer session for `provider` from an available subscription +/// credential, or null when none is importable. +pub fn sessionFor(allocator: std.mem.Allocator, provider: Config.AiProvider) !?Session { + const desc = descriptorFor(provider) orelse return null; + const importFn = desc.importFn orelse return null; + const tokens = (try importFn(allocator)) orelse return null; + return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens }; +} + +/// Process-lifetime memo for `subscriptionAvailable`: the credential file is +/// probed several times across a single startup resolution, and it doesn't +/// change under us there (runtime `/provider` re-import goes through `sessionFor`, +/// which always reads fresh). +var availability_cache: std.enums.EnumArray(Config.AiProvider, ?bool) = .initFill(null); + +/// Is a usable (present, not hard-expired) subscription token importable for +/// `provider`? Lets the picker offer a subscription without its API-key env var, +/// and not offer it when no credential exists. +pub fn subscriptionAvailable(provider: Config.AiProvider) bool { + if (availability_cache.get(provider)) |cached| return cached; + const result = probeSubscription(provider); + availability_cache.set(provider, result); + return result; +} + +fn probeSubscription(provider: Config.AiProvider) bool { + const desc = descriptorFor(provider) orelse return false; + const importFn = desc.importFn orelse return false; + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const tokens = (importFn(arena.allocator()) catch return false) orelse return false; + // expires_at_ms == 0 means "unknown"; let a live request be the judge. + return tokens.expires_at_ms == 0 or tokens.expires_at_ms > nowMs(); +} + +test "TokenSet dup/deinit round-trips and is leak-free" { + const a = std.testing.allocator; + const t = try TokenSet.dup(a, "acc", "ref", 123); + defer t.deinit(a); + try std.testing.expectEqualStrings("acc", t.access_token); + try std.testing.expectEqualStrings("ref", t.refresh_token); + try std.testing.expectEqual(@as(i64, 123), t.expires_at_ms); +} + +test "descriptorFor resolves anthropic, null for a non-OAuth provider" { + try std.testing.expect(descriptorFor(.anthropic) != null); + try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai)); +} diff --git a/src/agent/auth/models_dev.zig b/src/agent/auth/models_dev.zig new file mode 100644 index 000000000..2a44f4d2d --- /dev/null +++ b/src/agent/auth/models_dev.zig @@ -0,0 +1,125 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Model catalog from models.dev — the free, unauthenticated registry opencode +//! uses. It lets a subscription (bearer) user list a provider's models without +//! an API key, and stays current upstream instead of drifting like a hardcoded +//! list. The full `api.json` is a few MB, so only each provider's id list is +//! cached on disk (`/models-dev-.json`) with a TTL; the big +//! payload is parsed transiently on refresh. + +const std = @import("std"); +const lp = @import("lightpanda"); +const auth = @import("auth.zig"); + +const api_url = "https://models.dev/api.json"; +const cache_ttl_ms: i64 = 24 * std.time.ms_per_hour; + +/// Chat-model ids for `provider_id` from models.dev, allocated in `arena`. Reads +/// a fresh on-disk cache when present, else fetches and refreshes it. Returns an +/// empty slice on any failure (offline, parse error, no cache) so callers +/// degrade to accepting a typed-in model name. +pub fn modelIds(arena: std.mem.Allocator, provider_id: []const u8, app_dir: ?[]const u8) []const []const u8 { + if (app_dir) |dir| { + if (readCache(arena, dir, provider_id)) |ids| return ids; + } + const catalog = fetch(arena) catch return &.{}; + const ids = parseProviderModels(arena, catalog, provider_id) catch return &.{}; + if (app_dir) |dir| writeCache(dir, provider_id, ids) catch {}; + return ids; +} + +const Cache = struct { + fetched_ms: i64, + ids: []const []const u8, +}; + +fn cachePath(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ![]const u8 { + const name = try std.fmt.allocPrint(arena, "models-dev-{s}.json", .{provider_id}); + return std.fs.path.join(arena, &.{ app_dir, name }); +} + +fn readCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8) ?[]const []const u8 { + const path = cachePath(arena, app_dir, provider_id) catch return null; + const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(1024 * 1024)) catch return null; + const parsed = std.json.parseFromSliceLeaky(Cache, arena, data, .{ .ignore_unknown_fields = true }) catch return null; + if (auth.nowMs() - parsed.fetched_ms > cache_ttl_ms) return null; + if (parsed.ids.len == 0) return null; + return parsed.ids; +} + +fn writeCache(app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void { + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const a = arena.allocator(); + const path = try cachePath(a, app_dir, provider_id); + + var buf: std.Io.Writer.Allocating = .init(a); + try std.json.Stringify.value(Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .{}, &buf.writer); + try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }); +} + +/// Model-id keys of `catalog[provider_id].models`. `ignore_unknown_fields` skips +/// every provider and model's metadata, so only the id strings are built — no +/// Value tree for the multi-MB payload. The parse runs in a scoped arena; the +/// ids are duped into `arena`. +fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 { + var parse_arena: std.heap.ArenaAllocator = .init(arena); + defer parse_arena.deinit(); + + const Empty = struct {}; + const Provider = struct { models: std.json.ArrayHashMap(Empty) = .{} }; + const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), parse_arena.allocator(), catalog, .{ .ignore_unknown_fields = true }); + + const provider = parsed.map.get(provider_id) orelse return error.ProviderMissing; + const keys = provider.models.map.keys(); + const ids = try arena.alloc([]const u8, keys.len); + for (keys, ids) |k, *dst| dst.* = try arena.dupe(u8, k); + return ids; +} + +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, + }; + return out.written(); +} diff --git a/src/agent/settings.zig b/src/agent/settings.zig index f819758dd..ff1bdf770 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -28,9 +28,10 @@ const lp = @import("lightpanda"); 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; -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)"; +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; Anthropic: a Claude subscription from Claude Code)"; /// Determine which provider to use and read its env key. Returns null /// only when no `--provider` was given AND no env key exists (the caller @@ -41,6 +42,10 @@ pub const ResolvedProvider = struct { /// Key allocated (Vertex gcloud token) rather than an env pointer; the /// caller frees it, only after the client that references it is gone. key_owned: bool = false, + /// Set for subscription (bearer) auth. Owns `credentials.key`, so it must + /// outlive the AI client; the caller takes ownership. When set, `key_owned` + /// stays false — the session frees the key, not the caller directly. + session: ?auth.Session = null, }; /// Probe a keyless local provider (Ollama, llama.cpp): its env key is a @@ -50,7 +55,7 @@ pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), tag, key, base_url, lp.environ()) catch return null; + const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), tag, key, .{ .base_url = base_url, .environ = lp.environ() }) catch return null; if (ids.len == 0) return null; return .{ .provider = tag, .key = key }; } @@ -97,10 +102,11 @@ 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()); + 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; }; var buf: [zenai.provider.default_candidates.len]Credentials = undefined; return availableProviders(&buf).len > 0; @@ -114,6 +120,9 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme const token = try gcloudAccessToken(allocator); return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .key_owned = true }; } + // A subscription takes priority over an API key; null = not a + // subscription provider (or none available), so fall through. + if (try subscriptionResolved(allocator, p, .flag)) |resolved| return resolved; const key = zenai.provider.envApiKey(lp.environ(), p) orelse { if (p == .vertex) { std.debug.print( @@ -137,8 +146,13 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme if (gcloudAccessToken(allocator)) |token| { return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .key_owned = true }; } else |_| {} - } else if (zenai.provider.envApiKey(lp.environ(), p)) |key| { - return .{ .credentials = .{ .provider = p, .key = key }, .source = .remembered }; + } 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 }; + } } }; @@ -173,16 +187,41 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme return try finishResolved(allocator, found[idx], .picked); } -/// Swaps the placeholder key of a detected project-mode Vertex for a real -/// gcloud token. +/// Swaps a placeholder credential for a live token: a gcloud token for +/// project-mode Vertex, or an imported subscription session for the Anthropic +/// bearer placeholder. fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider { if (credentials.provider == .vertex and vertexProjectMode()) { const token = try gcloudAccessToken(allocator); return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true }; } + if (credentials.auth == .bearer and credentials.key.len == 0) { + if (try subscriptionResolved(allocator, credentials.provider, source)) |resolved| return resolved; + return error.MissingApiKey; + } return .{ .credentials = credentials, .source = source }; } +/// Import a subscription and wrap it as a resolved bearer credential. The +/// returned `session` owns `credentials.key`; the caller must keep it alive as +/// long as the AI client and free it with `session.deinit`. Null when no +/// subscription is importable. +fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider { + const session = (try auth.sessionFor(allocator, provider)) orelse return null; + // Name the credential in effect — a set-but-ignored API key would otherwise + // be a silent surprise. + if (zenai.provider.envApiKey(lp.environ(), provider) != null) { + std.debug.print("{s}: using your {s}; {s} is set but the subscription takes priority.\n", .{ @tagName(provider), session.descriptor.label, zenai.provider.envVarName(provider) }); + } else { + std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label }); + } + return .{ + .credentials = .{ .provider = provider, .key = session.tokens.access_token, .auth = .bearer }, + .source = source, + .session = session, + }; +} + pub const remembered_path = ".lp-agent.zon"; /// Last user-selected provider/model/effort/verbosity, persisted per-directory @@ -234,7 +273,20 @@ pub fn saveRemembered(remembered: Remembered) !void { /// 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 { - const found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates); + 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 = "", .auth = .bearer }; + if (indexOfProvider(found, desc.provider)) |i| { + found[i] = placeholder; + } else if (found.len < buf.len) { + buf[found.len] = placeholder; + found = buf[0 .. found.len + 1]; + } + } if (zenai.provider.useVertex(lp.environ()) and vertexProjectMode() and found.len < buf.len) { buf[found.len] = .{ .provider = .vertex, .key = "" }; return buf[0 .. found.len + 1]; @@ -242,6 +294,11 @@ 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; + return null; +} + pub fn resolveModelName(opts: Config.Agent, resolved: ?ResolvedProvider, remembered: ?Remembered) []const u8 { if (opts.model) |m| return m; if (resolved) |r| { @@ -300,9 +357,13 @@ pub fn reconcileModel( base_url: ?[:0]const u8, explicit: bool, ) !ReconciledModel { + // A subscription (bearer) token can't list models; trust the desired model + // as-is rather than 401 against `/models`. + if (llm.auth == .bearer) 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(allocator, lp.io, arena.allocator(), llm.provider, llm.key, base_url, lp.environ()) catch &.{}; + 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 &.{}; if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) }; if (!explicit) { diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 6e295d8a1..8ae9d077e 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -982,7 +982,7 @@ fn tavilySearch( api_key: []const u8, query: []const u8, ) ![]const u8 { - var client: tavily.Client = .init(arena, lp.io, api_key, .{}); + var client: tavily.Client = .init(lp.io, arena, api_key, .{}); defer client.deinit(); var response = client.search(query, .{ .max_results = 10 }) catch |err| {