Merge pull request #3075 from lightpanda-io/codex

agent: support subscription auth via Codex
This commit is contained in:
Adrià Arrufat
2026-07-30 08:02:11 +02:00
committed by GitHub
8 changed files with 1016 additions and 106 deletions

View File

@@ -36,8 +36,8 @@
.hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz",
},
.zenai = .{
.url = "git+https://github.com/lightpanda-io/zenai.git#cab4242b2c498d18e5637a97045f8a51a74e1c32",
.hash = "zenai-0.0.0-iOY_VBbNBQCo_e6zGGSUX5yyB2UYmx0dy3XuVpgO6h2n",
.url = "git+https://github.com/lightpanda-io/zenai.git#dcf17cf1c944c8b16e5ecb7419374b134cf47df3",
.hash = "zenai-0.0.0-iOY_VH4NBgA-zH-jOigwD0H4QTUSq1Tvm5qH99XNqd7a",
},
.isocline = .{
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",

View File

@@ -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;

View File

@@ -29,15 +29,16 @@ const Command = lp.Command;
const Schema = lp.Schema;
const Recorder = lp.Recorder;
const ScriptRuntime = lp.Runtime;
const Credentials = zenai.provider.Credentials;
const Candidate = zenai.provider.Candidate;
const App = @import("../App.zig");
const CDPNode = @import("../cdp/Node.zig");
const Conversation = @import("Conversation.zig");
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");
@@ -132,10 +133,12 @@ const synthesis_prompt =
allocator: std.mem.Allocator,
ai_client: ?zenai.provider.Client,
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,
/// The active LLM credential (null = LLM disabled). The AI client borrows
/// its key, so deinit only after the client is gone.
credential: ?settings.Credential,
/// App data dir (owned by `App`, which outlives the agent), used to cache the
/// models.dev catalog. Null when no data dir is available.
app_dir: ?[]const u8,
/// 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
@@ -192,7 +195,7 @@ api_error_buf: [512]u8 = undefined,
api_error_detail: ?[]const u8 = null,
pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent {
var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Credentials = undefined;
var providers_buf: [@typeInfo(Config.AiProvider).@"enum".fields.len]Candidate = undefined;
const found_providers = settings.availableProviders(&providers_buf);
const available_providers = try allocator.alloc([]const u8, found_providers.len);
var provider_count: usize = 0;
@@ -262,14 +265,13 @@ 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| r.credential.deinit(allocator);
if (will_repl and !banner_before and resolved != null) welcome.print(resolve);
const llm: ?Credentials = if (resolved) |r| r.credentials else null;
if (llm == null and requires_llm) {
if (resolved == null and requires_llm) {
if (opts.no_llm) {
std.debug.print("--no-llm forbids LLM use; drop it to run this mode.\n", .{});
}
@@ -281,10 +283,10 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
// The REPL skips this network round trip for snappy startup; an invalid
// model surfaces on the first turn instead.
if (llm) |l| if (!will_repl) {
const remembered_matches = remembered != null and remembered.?.provider == l.provider;
if (resolved) |*r| if (!will_repl) {
const remembered_matches = remembered != null and remembered.?.provider == r.credential.provider;
const explicit = opts.model != null or remembered_matches;
switch (try settings.reconcileModel(allocator, l, model, opts.base_url, explicit)) {
switch (try settings.reconcileModel(allocator, &r.credential, model, opts.base_url, explicit)) {
.use => |m| {
allocator.free(model);
model = m;
@@ -293,14 +295,14 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
}
};
const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null);
const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credential.provider else null);
const verbosity = settings.resolveVerbosity(opts, remembered);
const stream_enabled = settings.resolveStream(remembered);
browser_tools.search_engine = settings.resolveSearchEngine(remembered);
if (resolved) |r| {
if (r.source == .picked) {
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {};
settings.saveRemembered(.{ .provider = r.credential.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {};
}
// provider/model now live in the status bar; just space before the help
std.debug.print("\n", .{});
@@ -320,8 +322,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
self.* = .{
.allocator = allocator,
.ai_client = null,
.model_credentials = llm,
.owned_key = if (resolved) |r| (if (r.key_owned) r.credentials.key else null) else null,
.credential = if (resolved) |r| r.credential else null,
.app_dir = app.app_dir_path,
.no_llm_persisted = remembered_no_llm,
.model_base_url = opts.base_url,
.model_completions = null,
@@ -354,7 +356,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
try self.startSession();
self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null;
self.ai_client = if (self.credential) |*c| try zenai.provider.Client.init(lp.io, allocator, c.provider, c.keySlice(), .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(c.provider), .environ = lp.environ(), .account_id = c.accountId() }) else null;
errdefer if (self.ai_client) |c| c.deinit(allocator);
if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt);
@@ -383,7 +385,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.owned_key) |k| self.allocator.free(k);
if (self.credential) |*c| c.deinit(self.allocator);
self.allocator.free(self.model);
for (self.available_providers) |p| self.allocator.free(p);
self.allocator.free(self.available_providers);
@@ -599,7 +601,7 @@ fn runRepl(self: *Agent) void {
log.debug(.app, "tools loaded", .{ .count = globalTools().len });
if (self.ai_client != null) {
std.debug.print(" model: {s}{s} {s}effort: {s}{s} {s}stream: {s}{s}{s}\n", .{ ansi.dim, self.model, ansi.reset, ansi.dim, @tagName(self.effort), ansi.reset, ansi.dim, if (self.stream_enabled) "on" else "off", ansi.reset });
self.terminal.printSessionBanner(self.model, @tagName(self.effort), self.stream_enabled);
}
repl: while (true) {
@@ -849,7 +851,7 @@ const provider_off_keyword = "null";
const local_providers = [_]Config.AiProvider{ .ollama, .llama_cpp };
fn requireLlm(self: *Agent, name: []const u8) bool {
if (self.model_credentials == null) {
if (self.credential == null) {
self.terminal.printError("{s} requires an LLM — " ++ llm_setup_hint ++ ".", .{name});
return false;
}
@@ -880,7 +882,7 @@ fn handleModel(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
/// credentials it persists `provider = null` only when that's an intentional
/// preference (`no_llm_persisted`); a transient --no-llm run reports without saving.
fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void {
const provider: ?Config.AiProvider = if (self.model_credentials) |c| c.provider else null;
const provider: ?Config.AiProvider = if (self.credential) |c| c.provider else null;
// A transient --no-llm run has no provider and no intent to persist one;
// report without saving so we don't clobber the remembered selection.
if (provider == null and !self.no_llm_persisted) {
@@ -905,7 +907,7 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
const trimmed = std.mem.trim(u8, rest, &std.ascii.whitespace);
if (trimmed.len == 0) {
if (self.model_credentials) |c| {
if (self.credential) |c| {
self.terminal.printInfo("Current provider: {s}", .{@tagName(c.provider)});
} else {
self.terminal.printInfo("Current provider: null — LLM disabled", .{});
@@ -922,9 +924,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 (the
// keep/login/logout menu) 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 sub_desc = auth.descriptorFor(provider);
if (self.credential) |current| if (provider == current.provider and !vertex_project and sub_desc == null) {
self.terminal.printInfo("provider: {s}", .{@tagName(provider)});
return;
};
@@ -933,12 +937,24 @@ 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 = .{ .owned = token } }) catch |err| {
self.allocator.free(token);
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
};
return;
}
// Subscription provider: menu over the stored session, or first-time login.
if (sub_desc) |desc| {
var owned = if (auth.sessionFor(self.allocator, provider) catch null) |stored|
self.promptStoredSubscription(desc, stored) orelse return
else
self.subscriptionLogin(desc) orelse return;
self.setProvider(.{ .provider = provider, .key = .{ .session = 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)", .{});
@@ -956,19 +972,60 @@ 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 = .{ .env = key } }) catch |err| {
self.terminal.printError("failed to set provider: {s}", .{@errorName(err)});
};
}
/// Interactive device-code login; cancellation/failure is reported here.
fn subscriptionLogin(self: *Agent, desc: *const auth.Descriptor) ?auth.Session {
return 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);
self.terminal.printInfo("{s} login cancelled", .{desc.label});
return null;
}
self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) });
return null;
};
}
/// Menu for re-selecting an already-logged-in subscription provider: keep the
/// stored login, re-run it (switch accounts), or log out. Null when there is
/// nothing to activate.
fn promptStoredSubscription(self: *Agent, desc: *const auth.Descriptor, stored: auth.Session) ?auth.Session {
var session = stored;
var header_buf: [128]u8 = undefined;
const header = std.fmt.bufPrint(&header_buf, "Already logged in with your {s}. Pick:", .{desc.label}) catch
"Already logged in. Pick:";
const idx = picker.promptNumberedChoice(header, &.{
"keep — use the stored login",
"login — run the login again (e.g. to switch accounts)",
"logout — forget the stored login",
}, 0) catch {
session.deinit();
return null;
};
if (idx == 0) return session;
session.deinit();
if (idx == 1) return self.subscriptionLogin(desc);
auth.logout(self.allocator, desc) catch |err| {
self.terminal.printError("could not remove the stored login: {s}", .{@errorName(err)});
return null;
};
self.terminal.printInfo("{s} logged out.", .{desc.label});
if (self.credential) |c| if (c.provider == desc.provider) self.disableProvider();
return null;
}
/// Tear down the LLM client and persist a null provider so the next REPL launch
/// starts in basic mode without re-prompting. Inverse of `setProvider`.
fn disableProvider(self: *Agent) void {
if (self.ai_client) |client| client.deinit(self.allocator);
self.ai_client = null;
if (self.owned_key) |k| self.allocator.free(k);
self.owned_key = null;
self.model_credentials = null;
if (self.credential) |*c| c.deinit(self.allocator);
self.credential = null;
self.model_completions = null;
self.no_llm_persisted = true;
self.reportSaved("provider", provider_off_keyword);
@@ -982,34 +1039,55 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 {
return std.mem.span(v);
}
/// `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(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() });
/// `credential` transfers ownership of its key resources on success; on error
/// the caller still owns them. The previous credential is released only after
/// the old client is gone.
fn setProvider(self: *Agent, credential: settings.Credential) !void {
const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credential.provider, credential.keySlice(), .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credential.provider), .environ = lp.environ(), .account_id = credential.accountId() });
errdefer new_client.deinit(self.allocator);
// A same-provider re-select (vertex token refresh) must not reset the model.
const same_provider = if (self.model_credentials) |c| c.provider == credentials.provider else false;
const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credentials.provider));
const same_provider = if (self.credential) |c| c.provider == credential.provider else false;
const new_model = try self.allocator.dupe(u8, if (same_provider) self.model else zenai.provider.defaultModel(credential.provider));
if (self.ai_client) |client| client.deinit(self.allocator);
if (self.owned_key) |k| self.allocator.free(k);
self.owned_key = owned_key;
if (self.credential) |*c| c.deinit(self.allocator);
self.credential = credential;
new_client.setInterrupt(&self.http_interrupt);
self.ai_client = new_client;
self.model_credentials = credentials;
self.no_llm_persisted = false;
self.model_completions = null;
self.allocator.free(self.model);
self.model = new_model;
self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)});
if (zenai.provider.defaultEffort(credentials.provider)) |e| if (e != self.effort) {
if (auth.descriptorFor(credential.provider)) |d| {
self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credential.provider), d.label });
} else {
self.terminal.printInfo("provider: {s}", .{@tagName(credential.provider)});
}
if (zenai.provider.defaultEffort(credential.provider)) |e| if (e != self.effort) {
self.effort = e;
self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) });
self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credential.provider) });
};
self.reportSaved("model", self.model);
// Prime the completion cache on the command, not on a `/model` keystroke.
_ = 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 at the fresh token (`keySlice` readers see it automatically).
/// A no-op for API-key auth. Runs between turns on the single agent thread, so
/// the client is repointed before the next request borrows the token.
fn refreshAuthIfNeeded(self: *Agent) void {
const cred = if (self.credential) |*c| c else return;
if (cred.key != .session) return;
const session = &cred.key.session;
const refreshed = session.ensureFresh() catch |err| {
self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)});
return;
};
if (refreshed) if (self.ai_client) |client| client.setApiKey(session.tokens.access_token);
}
const PathAndMode = struct { path: []const u8, mode: save.Mode };
fn resolveSavePathAndMode(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8) ?PathAndMode {
@@ -1174,6 +1252,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
@@ -1569,8 +1648,9 @@ fn recordSlashToolCall(
fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) []const u8 {
const e = client.lastError();
const status = e.status orelse return @errorName(err);
const owned_key = if (self.credential) |c| c.key == .owned else false;
const hint = if (status == 401 and client == .vertex)
if (self.owned_key != null)
if (owned_key)
" (Vertex token may have expired; run /provider vertex to refresh)"
else
" (Vertex express mode needs an express API key — a Gemini Developer key won't work)"
@@ -1620,6 +1700,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(
@@ -1868,18 +1949,22 @@ 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;
const llm = resolved.credentials;
defer if (resolved.key_owned) allocator.free(llm.key);
var resolved = (try settings.resolveCredentials(allocator, opts, null, false)) orelse return error.MissingProvider;
defer resolved.credential.deinit(allocator);
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
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;
};
// A subscription provider can't list models via the provider API; use the
// free models.dev catalog.
const ids: []const []const u8 = if (auth.descriptorFor(resolved.credential.provider)) |desc|
models_dev.modelIds(arena.allocator(), desc.models_dev_id, auth.dataDir(arena.allocator()))
else
zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), resolved.credential.provider, resolved.credential.keySlice(), .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| {
if (resolved.credential.provider == .vertex and !settings.vertexProjectMode()) {
std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{});
}
return err;
};
var stdout_file = std.Io.File.stdout().writerStreaming(lp.io, &.{});
const w = &stdout_file.interface;
@@ -1909,36 +1994,51 @@ fn completionProviders(context: *anyopaque, arena: std.mem.Allocator) []const []
};
if (reachable[i]) extra += 1;
}
const names = arena.alloc([]const u8, self.available_providers.len + 1 + extra) catch return &.{};
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.
for (auth.registry) |desc| {
const name = @tagName(desc.provider);
if (!string.isOneOf(name, self.available_providers)) {
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
/// success or empty so the per-keystroke hinter pays the round-trip once.
fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u8 {
const self: *Agent = @ptrCast(@alignCast(context));
const llm = self.model_credentials orelse return &.{};
if (self.model_completions) |c| if (c.provider == llm.provider) return c.ids;
const cred = if (self.credential) |*c| c else return &.{};
if (self.model_completions) |c| if (c.provider == cred.provider) return c.ids;
_ = self.model_completion_arena.reset(.retain_capacity);
const ids = zenai.provider.listChatModelIds(
lp.io,
self.allocator,
self.model_completion_arena.allocator(),
llm.provider,
llm.key,
.{ .base_url = self.model_base_url, .environ = lp.environ() },
) catch &.{};
self.model_completions = .{ .provider = llm.provider, .ids = ids };
const arena = self.model_completion_arena.allocator();
// A subscription provider can't hit the provider's `/models` endpoint, so
// list from the free, unauthenticated models.dev catalog instead.
const ids = if (auth.descriptorFor(cred.provider)) |desc|
models_dev.modelIds(arena, desc.models_dev_id, self.app_dir)
else
zenai.provider.listChatModelIds(
lp.io,
self.allocator,
arena,
cred.provider,
cred.keySlice(),
.{ .base_url = self.model_base_url, .environ = lp.environ() },
) catch &.{};
self.model_completions = .{ .provider = cred.provider, .ids = ids };
return ids;
}

View File

@@ -364,6 +364,16 @@ pub fn printDimmed(self: *Terminal, comptime fmt: []const u8, args: anytype) voi
std.debug.print(ansi.dim ++ fmt ++ ansi.reset ++ "\n", args);
}
/// REPL startup banner line: plain labels, dimmed values.
pub fn printSessionBanner(self: *Terminal, model: []const u8, effort: []const u8, stream_enabled: bool) void {
if (!self.isRepl()) return;
std.debug.print(" model: {s}{s}{s} effort: {s}{s}{s} stream: {s}{s}{s}\n", .{
ansi.dim, model, ansi.reset,
ansi.dim, effort, ansi.reset,
ansi.dim, if (stream_enabled) "on" else "off", ansi.reset,
});
}
pub fn printItalic(self: *Terminal, comptime fmt: []const u8, args: anytype) void {
if (!self.isRepl() and !self.verbosity.atLeast(.medium)) return;
std.debug.print(ansi.italic ++ fmt ++ ansi.reset ++ "\n", args);

296
src/agent/auth/auth.zig Normal file
View File

@@ -0,0 +1,296 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! Provider-agnostic subscription (OAuth) auth for the agent. A `Descriptor`
//! 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
//! session must outlive it.
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
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 @intCast(lp.datetime.milliTimestamp(.real));
}
/// 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
/// ChatGPT account id from the JWT); null when the provider has none.
pub const TokenSet = struct {
access_token: [:0]const u8,
refresh_token: []const u8,
expires_at_ms: i64,
account_id: ?[]const u8 = null,
pub fn dup(allocator: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at_ms: i64, account_id: ?[]const u8) !TokenSet {
const a = try allocator.dupeZ(u8, access);
errdefer allocator.free(a);
const r = try allocator.dupe(u8, refresh);
errdefer allocator.free(r);
const id = if (account_id) |v| try allocator.dupe(u8, v) else null;
return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms, .account_id = id };
}
pub fn deinit(self: TokenSet, allocator: std.mem.Allocator) void {
allocator.free(self.access_token);
allocator.free(self.refresh_token);
if (self.account_id) |v| allocator.free(v);
}
};
/// Per-provider login/refresh implementations; OAuth endpoints and client ids
/// live in the implementing file.
pub const Descriptor = struct {
provider: Config.AiProvider,
/// 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,
/// Interactive login (device-code flow); returns freshly-minted tokens.
/// 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, refresh_token: []const u8) anyerror!TokenSet,
};
pub const registry = [_]*const Descriptor{&codex.descriptor};
pub fn descriptorFor(provider: Config.AiProvider) ?*const Descriptor {
for (registry) |d| if (d.provider == provider) return d;
return null;
}
// --- On-disk token store: <data_dir>/auth.json, a JSON map keyed by provider tag ---
// The `*At` variants take a scratch arena and an explicit dir (unit-testable);
// the public wrappers build the arena and resolve the app data dir themselves.
const StoredToken = struct {
access: []const u8,
refresh: []const u8,
expires_at_ms: i64,
account_id: ?[]const u8 = null,
};
fn storePath(arena: std.mem.Allocator, dir: []const u8) ![]const u8 {
return std.fs.path.join(arena, &.{ dir, "auth.json" });
}
/// The parsed store; empty on any failure (absent, unreadable, malformed).
fn readStoreFile(arena: std.mem.Allocator, path: []const u8) std.json.ArrayHashMap(StoredToken) {
const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, arena, .limited(64 * 1024)) catch return .{};
return std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), arena, data, .{ .ignore_unknown_fields = true }) catch .{};
}
/// Serialize `value` as JSON to `path` via temp+rename, so a failed write
/// can't corrupt an existing file.
pub fn writeJsonAtomic(path: []const u8, value: anytype, permissions: std.Io.File.Permissions) !void {
var af = try std.Io.Dir.cwd().createFileAtomic(lp.io, path, .{ .permissions = permissions, .replace = true });
defer af.deinit(lp.io);
var buf: [1024]u8 = undefined;
var w = af.file.writer(lp.io, &buf);
try std.json.Stringify.value(value, .{}, &w.interface);
try w.end();
try af.replace(lp.io);
}
fn storeLoadAt(allocator: std.mem.Allocator, dir: []const u8, id: []const u8) !?TokenSet {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const a = arena.allocator();
const path = try storePath(a, dir);
const t = readStoreFile(a, path).map.get(id) orelse return null;
return try TokenSet.dup(allocator, t.access, t.refresh, t.expires_at_ms, t.account_id);
}
fn storeSaveAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8, tokens: TokenSet) !void {
const path = try storePath(arena, dir);
var map = readStoreFile(arena, path);
try map.map.put(arena, id, .{
.access = tokens.access_token,
.refresh = tokens.refresh_token,
.expires_at_ms = tokens.expires_at_ms,
.account_id = tokens.account_id,
});
// Secrets file: owner-only perms.
try writeJsonAtomic(path, map, .fromMode(0o600));
}
fn storeDeleteAt(arena: std.mem.Allocator, dir: []const u8, id: []const u8) !void {
const path = try storePath(arena, dir);
var map = readStoreFile(arena, path);
if (!map.map.swapRemove(id)) return;
try writeJsonAtomic(path, map, .fromMode(0o600));
}
/// Remove the stored token for `id`. No-op when absent.
fn storeDelete(allocator: std.mem.Allocator, id: []const u8) !void {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const a = arena.allocator();
const dir = dataDir(a) orelse return error.NoDataDir;
return storeDeleteAt(a, dir, id);
}
/// Load the stored token for `id`, or null when absent/unreadable/no data dir.
pub fn storeLoad(allocator: std.mem.Allocator, id: []const u8) !?TokenSet {
var da: std.heap.ArenaAllocator = .init(allocator);
defer da.deinit();
const dir = dataDir(da.allocator()) orelse return null;
return storeLoadAt(allocator, dir, id);
}
pub fn storeSave(allocator: std.mem.Allocator, id: []const u8, tokens: TokenSet) !void {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const a = arena.allocator();
const dir = dataDir(a) orelse return error.NoDataDir;
return storeSaveAt(a, dir, id, tokens);
}
/// Proactive-refresh margin: refresh 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` and
/// persists refreshed tokens back to the store.
pub const Session = struct {
allocator: std.mem.Allocator,
descriptor: *const Descriptor,
tokens: TokenSet,
/// The set displaced by the last refresh, kept until the next one (or
/// deinit) because the AI client borrows the access token until the
/// caller repoints it.
displaced: ?TokenSet = null,
/// When the access token is within `refresh_skew_ms` of expiry, exchange
/// the refresh token for a new one and persist it. True when `tokens` was
/// replaced — repoint anything borrowing the old access token.
pub fn ensureFresh(self: *Session) !bool {
const now = nowMs();
if (self.tokens.expires_at_ms - now > refresh_skew_ms) return false;
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 false;
return err;
};
storeSave(self.allocator, @tagName(self.descriptor.provider), fresh) catch |err| {
log.warn(.app, "auth token persist failed", .{ .provider = @tagName(self.descriptor.provider), .err = err });
};
if (self.displaced) |old| old.deinit(self.allocator);
self.displaced = self.tokens;
self.tokens = fresh;
return true;
}
pub fn deinit(self: *Session) void {
if (self.displaced) |old| old.deinit(self.allocator);
self.tokens.deinit(self.allocator);
}
};
/// 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, @tagName(provider))) 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, interrupt: ?*zenai.http.Interrupt) !Session {
const tokens = try desc.loginFn(allocator, interrupt);
storeSave(allocator, @tagName(desc.provider), tokens) catch |err| {
log.warn(.app, "auth token persist failed", .{ .provider = @tagName(desc.provider), .err = err });
};
return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens };
}
/// Forget the stored credential for `desc`'s provider.
pub fn logout(allocator: std.mem.Allocator, desc: *const Descriptor) !void {
return storeDelete(allocator, @tagName(desc.provider));
}
/// Is a stored credential available for `provider` — unexpired, or refreshable
/// (`ensureFresh` runs before the first request)? Lets the picker offer the
/// subscription without an API-key env var.
pub fn subscriptionAvailable(provider: Config.AiProvider) bool {
if (descriptorFor(provider) == null) return false;
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer arena.deinit();
const a = arena.allocator();
const dir = dataDir(a) orelse return false;
const path = storePath(a, dir) catch return false;
const t = readStoreFile(a, path).map.get(@tagName(provider)) orelse return false;
return t.refresh.len > 0 or t.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, "acct-1");
defer t.deinit(a);
try std.testing.expectEqualStrings("acc", t.access_token);
try std.testing.expectEqualStrings("ref", t.refresh_token);
try std.testing.expectEqualStrings("acct-1", t.account_id.?);
try std.testing.expectEqual(@as(i64, 123), t.expires_at_ms);
}
test "descriptorFor resolves codex, null for a non-OAuth provider" {
try std.testing.expect(descriptorFor(.codex) != null);
try std.testing.expectEqual(@as(?*const Descriptor, null), descriptorFor(.openai));
}
test "token store save/load/delete round-trips" {
const a = std.testing.allocator;
var arena: std.heap.ArenaAllocator = .init(a);
defer arena.deinit();
const scratch = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir = try tmp.dir.realPathFileAlloc(lp.io, ".", scratch);
const t = try TokenSet.dup(a, "acc-1", "ref-1", 999, "acct-9");
defer t.deinit(a);
try storeSaveAt(scratch, dir, "codex", t);
const st = try tmp.dir.statFile(lp.io, "auth.json", .{});
try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), st.permissions.toMode() & 0o777);
const loaded = (try storeLoadAt(a, dir, "codex")).?;
defer loaded.deinit(a);
try std.testing.expectEqualStrings("acc-1", loaded.access_token);
try std.testing.expectEqualStrings("ref-1", loaded.refresh_token);
try std.testing.expectEqualStrings("acct-9", loaded.account_id.?);
try std.testing.expectEqual(@as(i64, 999), loaded.expires_at_ms);
try storeDeleteAt(scratch, dir, "codex");
try std.testing.expectEqual(@as(?TokenSet, null), try storeLoadAt(a, dir, "codex"));
}

291
src/agent/auth/codex.zig Normal file
View File

@@ -0,0 +1,291 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! OpenAI Codex (ChatGPT subscription) OAuth. Uses the device-code flow (no
//! local callback server): request a user code, the user enters it at the verify
//! 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.
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const zenai = @import("zenai");
const auth = @import("auth.zig");
const client_id = "app_EMoamEEZ73f0CkXaXp7hrann";
const issuer = "https://auth.openai.com";
const token_url = issuer ++ "/oauth/token";
const device_code_url = issuer ++ "/api/accounts/deviceauth/usercode";
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 user_agent = "lightpanda";
const poll_margin_ms: u64 = 3000;
const cancel_slice_ms: u64 = 200;
pub const descriptor: auth.Descriptor = .{
.provider = .codex,
.models_dev_id = "openai",
.label = "ChatGPT subscription",
.loginFn = deviceLogin,
.refreshFn = refreshGrant,
};
// --- JWT account-id extraction (pure) ---
/// Extract the ChatGPT account id from an OAuth JWT (id_token preferred, else
/// access_token). Probes `chatgpt_account_id`, then
/// `["https://api.openai.com/auth"].chatgpt_account_id`, then
/// `organizations[0].id`. Returns a slice owned by `arena`, or null.
pub fn accountIdFromJwt(arena: std.mem.Allocator, token: []const u8) ?[]const u8 {
var it = std.mem.splitScalar(u8, token, '.');
_ = it.next() orelse return null;
const payload_b64 = it.next() orelse return null;
const dec = std.base64.url_safe_no_pad.Decoder;
const len = dec.calcSizeForSlice(payload_b64) catch return null;
const buf = arena.alloc(u8, len) catch return null;
dec.decode(buf, payload_b64) catch return null;
const claims = std.json.parseFromSliceLeaky(std.json.Value, arena, buf, .{}) catch return null;
const obj = switch (claims) {
.object => |o| o,
else => return null,
};
if (stringField(obj, "chatgpt_account_id")) |v| return v;
if (obj.get("https://api.openai.com/auth")) |a| if (a == .object) {
if (stringField(a.object, "chatgpt_account_id")) |v| return v;
};
if (obj.get("organizations")) |orgs| if (orgs == .array and orgs.array.items.len > 0) {
const first = orgs.array.items[0];
if (first == .object) if (stringField(first.object, "id")) |v| return v;
};
return null;
}
fn stringField(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 {
const v = obj.get(key) orelse return null;
return if (v == .string) v.string else null;
}
// --- Request-body builders (pure) ---
const TokenResponse = struct {
access_token: []const u8,
refresh_token: []const u8 = "",
id_token: ?[]const u8 = null,
expires_in: ?i64 = null,
};
/// Parse an OAuth token response into a `TokenSet`, deriving `account_id` from
/// the JWTs and computing an absolute expiry.
fn parseTokenResponse(allocator: std.mem.Allocator, body: []const u8) !auth.TokenSet {
var arena: std.heap.ArenaAllocator = .init(allocator);
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
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 {
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 {
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),
});
}
// --- Device-code flow ---
const DeviceCode = struct {
device_auth_id: []const u8,
user_code: []const u8,
/// Seconds between polls. The spec says number; OpenAI returns a string;
/// std.json int parsing accepts both.
interval: u32 = 5,
};
const DeviceToken = struct {
authorization_code: []const u8,
code_verifier: []const u8,
};
/// Sleep in short slices, polling `interrupt` between them: the REPL's Ctrl-C
/// only fires the interrupt (it never kills the process).
fn waitCancellable(ms: u64, interrupt: ?*zenai.http.Interrupt) error{LoginCancelled}!void {
var remaining_ms = ms;
while (remaining_ms > 0) {
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;
}
}
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, interrupt, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}");
if (code_res.status != .ok) {
log.warn(.app, "codex device-code request failed", .{ .status = @intFromEnum(code_res.status), .body = code_res.body });
return error.DeviceCodeRequestFailed;
}
const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true });
const interval_ms: u64 = @as(u64, dc.interval) * std.time.ms_per_s;
std.debug.print(
"\nTo authorize Lightpanda with your ChatGPT subscription:\n 1. Open {s}\n 2. Enter the code: {s}\n\nWaiting for authorization...\n",
.{ verify_url, dc.user_code },
);
const poll_body = try std.fmt.allocPrint(a, "{f}", .{std.json.fmt(
.{ .device_auth_id = dc.device_auth_id, .user_code = dc.user_code },
.{},
)});
const dt: DeviceToken = while (true) {
try waitCancellable(interval_ms + poll_margin_ms, interrupt);
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.
.forbidden, .not_found => continue,
else => {
log.warn(.app, "codex device-auth poll failed", .{ .status = @intFromEnum(res.status), .body = res.body });
return error.DeviceAuthFailed;
},
}
};
const exchange = try exchangeBody(a, dt.authorization_code, dt.code_verifier);
const tok_res = try post(a, interrupt, token_url, "application/x-www-form-urlencoded", exchange);
if (tok_res.status != .ok) {
log.warn(.app, "codex token exchange failed", .{ .status = @intFromEnum(tok_res.status), .body = tok_res.body });
return error.TokenExchangeFailed;
}
return parseTokenResponse(allocator, tok_res.body);
}
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, null, token_url, "application/x-www-form-urlencoded", body);
if (res.status != .ok) {
log.warn(.app, "codex token refresh failed", .{ .status = @intFromEnum(res.status), .body = res.body });
return error.RefreshFailed;
}
return parseTokenResponse(allocator, res.body);
}
const PostResult = struct { status: std.http.Status, body: []u8 };
/// 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();
var out: std.Io.Writer.Allocating = .init(arena);
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 = status, .body = out.written() };
}
// --- Tests (pure paths) ---
fn makeJwt(arena: std.mem.Allocator, payload_json: []const u8) ![]const u8 {
const enc = std.base64.url_safe_no_pad.Encoder;
const p = try arena.alloc(u8, enc.calcSize(payload_json.len));
_ = enc.encode(p, payload_json);
return std.fmt.allocPrint(arena, "aGVhZGVy.{s}.c2ln", .{p});
}
test "accountIdFromJwt: top-level chatgpt_account_id" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const jwt = try makeJwt(a, "{\"chatgpt_account_id\":\"acct-top\"}");
try std.testing.expectEqualStrings("acct-top", accountIdFromJwt(a, jwt).?);
}
test "accountIdFromJwt: nested auth namespace" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const jwt = try makeJwt(a, "{\"https://api.openai.com/auth\":{\"chatgpt_account_id\":\"acct-ns\"}}");
try std.testing.expectEqualStrings("acct-ns", accountIdFromJwt(a, jwt).?);
}
test "accountIdFromJwt: organizations fallback, and null when absent" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const jwt = try makeJwt(a, "{\"organizations\":[{\"id\":\"org-1\"}]}");
try std.testing.expectEqualStrings("org-1", accountIdFromJwt(a, jwt).?);
const none = try makeJwt(a, "{\"sub\":\"x\"}");
try std.testing.expectEqual(@as(?[]const u8, null), accountIdFromJwt(a, none));
try std.testing.expectEqual(@as(?[]const u8, null), accountIdFromJwt(a, "not-a-jwt"));
}
test "refreshBody / exchangeBody percent-encode values" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const rb = try refreshBody(a, "tok/with+special");
try std.testing.expect(std.mem.find(u8, rb, "grant_type=refresh_token") != null);
try std.testing.expect(std.mem.find(u8, rb, "client_id=" ++ client_id) != null);
try std.testing.expect(std.mem.find(u8, rb, "tok%2Fwith%2Bspecial") != null);
const eb = try exchangeBody(a, "the code", "verifier");
try std.testing.expect(std.mem.find(u8, eb, "grant_type=authorization_code") != null);
try std.testing.expect(std.mem.find(u8, eb, "code=the%20code") != null);
}
test "parseTokenResponse derives account id and absolute expiry" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const jwt = try makeJwt(a, "{\"chatgpt_account_id\":\"acct-x\"}");
const body = try std.fmt.allocPrint(a, "{{\"access_token\":\"acc\",\"refresh_token\":\"ref\",\"id_token\":\"{s}\",\"expires_in\":3600}}", .{jwt});
const tokens = try parseTokenResponse(std.testing.allocator, body);
defer tokens.deinit(std.testing.allocator);
try std.testing.expectEqualStrings("acc", tokens.access_token);
try std.testing.expectEqualStrings("ref", tokens.refresh_token);
try std.testing.expectEqualStrings("acct-x", tokens.account_id.?);
try std.testing.expect(tokens.expires_at_ms > auth.nowMs());
}

View File

@@ -0,0 +1,114 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! 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 (`<app_dir>/models-dev-<provider>.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`.
/// Serves a fresh on-disk cache, else fetches and refreshes it, falling back
/// to an expired cache when the fetch fails. Empty only when nothing is
/// available, 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 {
const cached: ?Cache = if (app_dir) |dir| readCache(arena, dir, provider_id) else null;
if (cached) |c| if (auth.nowMs() - c.fetched_ms <= cache_ttl_ms) return c.ids;
const stale: []const []const u8 = if (cached) |c| c.ids else &.{};
const catalog = fetch(arena) catch return stale;
const ids = parseProviderModels(arena, catalog, provider_id) catch return stale;
if (app_dir) |dir| writeCache(arena, 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) ?Cache {
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 (parsed.ids.len == 0) return null;
return parsed;
}
fn writeCache(arena: std.mem.Allocator, app_dir: []const u8, provider_id: []const u8, ids: []const []const u8) !void {
const path = try cachePath(arena, app_dir, provider_id);
try auth.writeJsonAtomic(path, Cache{ .fetched_ms = auth.nowMs(), .ids = ids }, .default_file);
}
/// Model-id keys of `catalog[provider_id].models`, filtered to tool-call-capable
/// models (the agent needs tools; this also drops embedding/image entries).
/// `ignore_unknown_fields` skips the rest of the metadata, so no Value tree is
/// built for the multi-MB payload, but the parsed id map accumulates in
/// `arena`. The ids may alias `catalog`.
fn parseProviderModels(arena: std.mem.Allocator, catalog: []const u8, provider_id: []const u8) ![]const []const u8 {
const Model = struct { tool_call: bool = false };
const Provider = struct { models: std.json.ArrayHashMap(Model) = .{} };
const parsed = try std.json.parseFromSliceLeaky(std.json.ArrayHashMap(Provider), arena, catalog, .{ .ignore_unknown_fields = true });
const provider = parsed.map.get(provider_id) orelse return error.ProviderMissing;
var ids: std.ArrayList([]const u8) = .empty;
var it = provider.models.map.iterator();
while (it.next()) |entry| {
if (entry.value_ptr.tool_call) try ids.append(arena, entry.key_ptr.*);
}
return ids.toOwnedSlice(arena);
}
test "parseProviderModels filters to tool-call models" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const catalog =
\\{"openai":{"models":{
\\ "gpt-5.5":{"tool_call":true},
\\ "text-embedding-3-small":{"tool_call":false},
\\ "gpt-image-1":{}
\\}}}
;
const ids = try parseProviderModels(arena.allocator(), catalog, "openai");
try std.testing.expectEqual(1, ids.len);
try std.testing.expectEqualStrings("gpt-5.5", ids[0]);
try std.testing.expectError(error.ProviderMissing, parseProviderModels(arena.allocator(), catalog, "nope"));
}
fn fetch(arena: std.mem.Allocator) ![]u8 {
var client: std.http.Client = .{ .allocator = arena, .io = lp.io };
defer client.deinit();
var out: std.Io.Writer.Allocating = .init(arena);
const res = try client.fetch(.{ .location = .{ .url = api_url }, .response_writer = &out.writer });
if (res.status != .ok) return error.HttpStatus;
return out.written();
}

View File

@@ -28,25 +28,61 @@ const lp = @import("lightpanda");
const Config = lp.Config;
const picker = @import("picker.zig");
const string = @import("../string.zig");
const Credentials = zenai.provider.Credentials;
const auth = @import("auth/auth.zig");
const Candidate = zenai.provider.Candidate;
pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud)";
pub const api_keys_hint = "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or MISTRAL_API_KEY (Vertex AI: VERTEX_API_KEY, or GOOGLE_CLOUD_PROJECT via gcloud; Codex: a ChatGPT subscription via /provider codex)";
/// 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
/// decides whether that's fatal — basic REPL tolerates it).
pub const ResolvedProvider = struct {
credentials: Credentials,
credential: Credential,
source: enum { flag, remembered, detected, picked },
/// 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,
};
/// The provider credential in effect: the key lives in exactly one place —
/// inside the variant that owns it. The AI client borrows `keySlice()`, so
/// deinit only after that client is gone.
pub const Credential = struct {
provider: Config.AiProvider,
key: union(enum) {
/// Unowned env-var pointer.
env: [:0]const u8,
/// Allocated key (Vertex gcloud token).
owned: [:0]const u8,
/// Subscription (bearer) auth; the key is the access token, refreshed
/// between turns.
session: auth.Session,
},
pub fn keySlice(self: *const Credential) [:0]const u8 {
return switch (self.key) {
.env, .owned => |k| k,
.session => |*s| s.tokens.access_token,
};
}
pub fn accountId(self: *const Credential) ?[]const u8 {
return switch (self.key) {
.session => |*s| s.tokens.account_id,
else => null,
};
}
pub fn deinit(self: *Credential, allocator: std.mem.Allocator) void {
switch (self.key) {
.env => {},
.owned => |k| allocator.free(k),
.session => |*s| s.deinit(),
}
}
};
/// Probe a keyless local provider (Ollama, llama.cpp): its env key is a
/// placeholder, so the only honest availability signal is the server answering
/// `/v1/models` with a loaded model. Null means no server responded.
pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, base_url: ?[:0]const u8) ?Credentials {
pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, base_url: ?[:0]const u8) ?Candidate {
const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null;
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
@@ -97,23 +133,29 @@ 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 (remembered) |r| if (r.provider) |p| {
if (zenai.provider.envApiKey(lp.environ(), p) != null) return true;
if (p == .vertex and vertexProjectMode()) return true;
};
var buf: [zenai.provider.default_candidates.len]Credentials = undefined;
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]Candidate = 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 {
if (opts.provider) |p| {
if (p == .vertex and vertexProjectMode()) {
const token = try gcloudAccessToken(allocator);
return .{ .credentials = .{ .provider = p, .key = token }, .source = .flag, .key_owned = true };
return .{ .credential = .{ .provider = p, .key = .{ .owned = token } }, .source = .flag };
}
// A subscription takes priority over an API key; null = not a
// subscription provider (or none available), so fall through.
if (try subscriptionResolved(allocator, p, .flag)) |resolved| return resolved;
const key = zenai.provider.envApiKey(lp.environ(), p) orelse {
if (p == .vertex) {
std.debug.print(
@@ -128,28 +170,33 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme
);
return error.MissingApiKey;
};
return .{ .credentials = .{ .provider = p, .key = key }, .source = .flag };
return .{ .credential = .{ .provider = p, .key = .{ .env = key } }, .source = .flag };
}
if (remembered) |r| if (r.provider) |p| {
if (p == .vertex and vertexProjectMode()) {
// On failure the reason is already printed; fall through to detection.
if (gcloudAccessToken(allocator)) |token| {
return .{ .credentials = .{ .provider = p, .key = token }, .source = .remembered, .key_owned = true };
return .{ .credential = .{ .provider = p, .key = .{ .owned = token } }, .source = .remembered };
} 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 .{ .credential = .{ .provider = p, .key = .{ .env = key } }, .source = .remembered };
}
}
};
var buf: [zenai.provider.default_candidates.len]Credentials = undefined;
var buf: [zenai.provider.default_candidates.len]Candidate = undefined;
const found = availableProviders(&buf);
if (found.len == 0) {
if (detectLocalProvider(allocator, .ollama, opts.base_url)) |creds| {
return .{ .credentials = creds, .source = .detected };
return .{ .credential = .{ .provider = creds.provider, .key = .{ .env = creds.key } }, .source = .detected };
}
if (detectLocalProvider(allocator, .llama_cpp, opts.base_url)) |creds| {
return .{ .credentials = creds, .source = .detected };
return .{ .credential = .{ .provider = creds.provider, .key = .{ .env = creds.key } }, .source = .detected };
}
std.debug.print(
\\No API key detected. Set {s}, or run a local Ollama or llama.cpp server with a loaded model.
@@ -173,14 +220,37 @@ 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.
fn finishResolved(allocator: std.mem.Allocator, credentials: Credentials, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider {
if (credentials.provider == .vertex and vertexProjectMode()) {
/// Swaps a placeholder credential for a live token: a gcloud token for
/// project-mode Vertex, or a stored subscription session for the subscription
/// (empty-key) placeholder.
fn finishResolved(allocator: std.mem.Allocator, candidate: Candidate, source: @FieldType(ResolvedProvider, "source")) !ResolvedProvider {
if (candidate.provider == .vertex and vertexProjectMode()) {
const token = try gcloudAccessToken(allocator);
return .{ .credentials = .{ .provider = .vertex, .key = token }, .source = source, .key_owned = true };
return .{ .credential = .{ .provider = .vertex, .key = .{ .owned = token } }, .source = source };
}
return .{ .credentials = credentials, .source = source };
if (auth.descriptorFor(candidate.provider) != null and candidate.key.len == 0) {
if (try subscriptionResolved(allocator, candidate.provider, source)) |resolved| return resolved;
return error.MissingApiKey;
}
return .{ .credential = .{ .provider = candidate.provider, .key = .{ .env = candidate.key } }, .source = source };
}
/// Load a stored subscription session and wrap it as a resolved credential.
/// The session owns the key; the caller takes ownership via
/// `ownership.deinit`. Null when the user hasn't logged in.
fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvider, source: @FieldType(ResolvedProvider, "source")) !?ResolvedProvider {
const session = (try auth.sessionFor(allocator, provider)) orelse return null;
// 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 .{
.credential = .{ .provider = provider, .key = .{ .session = session } },
.source = source,
};
}
pub const remembered_path = ".lp-agent.zon";
@@ -234,8 +304,21 @@ pub fn saveRemembered(remembered: Remembered) !void {
/// a live probe (`detectLocalProvider`), too costly for an unconditional startup scan.
/// Vertex project mode joins with a placeholder key — no subprocess during a
/// scan; the gcloud token is fetched on selection (`finishResolved`).
pub fn availableProviders(buf: []Credentials) []Credentials {
const found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates);
pub fn availableProviders(buf: []Candidate) []Candidate {
var found = zenai.provider.detectKeys(lp.environ(), buf, zenai.provider.default_candidates);
// A subscription takes priority over an API key: offer it as a bearer
// placeholder (replacing any API-key entry) that `finishResolved` swaps for a
// live token on selection, mirroring Vertex project mode below.
for (auth.registry) |desc| {
if (!auth.subscriptionAvailable(desc.provider)) continue;
const placeholder: Candidate = .{ .provider = desc.provider, .key = "" };
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];
@@ -243,15 +326,20 @@ pub fn availableProviders(buf: []Credentials) []Credentials {
return found;
}
fn indexOfProvider(candidates: []const Candidate, provider: Config.AiProvider) ?usize {
for (candidates, 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| {
// Use the remembered model whenever it matches the chosen provider,
// not only when the provider itself came from the remembered file.
if (remembered) |rem| {
if (rem.provider) |p| if (p == r.credentials.provider) return rem.model;
if (rem.provider) |p| if (p == r.credential.provider) return rem.model;
}
return zenai.provider.defaultModel(r.credentials.provider);
return zenai.provider.defaultModel(r.credential.provider);
}
return "";
}
@@ -303,26 +391,30 @@ pub const ReconciledModel = union(enum) {
/// model when unloaded; cloud defaults are hardcoded real models, trusted as-is.
pub fn reconcileModel(
allocator: std.mem.Allocator,
llm: Credentials,
credential: *const Credential,
desired: []const u8,
base_url: ?[:0]const u8,
explicit: bool,
) !ReconciledModel {
// A subscription provider can't list models via the provider API; trust the
// desired model as-is rather than error against `/models`.
if (auth.descriptorFor(credential.provider) != null) return .{ .use = try allocator.dupe(u8, desired) };
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = base_url, .environ = lp.environ() }) catch &.{};
const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), credential.provider, credential.keySlice(), .{ .base_url = base_url, .environ = lp.environ() }) catch &.{};
if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) };
if (!explicit) {
switch (llm.provider) {
switch (credential.provider) {
.ollama, .llama_cpp => {},
else => return .{ .use = try allocator.dupe(u8, desired) },
}
std.debug.print("Default {s} model '{s}' is not loaded; using '{s}'.\n", .{ @tagName(llm.provider), desired, ids[0] });
std.debug.print("Default {s} model '{s}' is not loaded; using '{s}'.\n", .{ @tagName(credential.provider), desired, ids[0] });
return .{ .use = try allocator.dupe(u8, ids[0]) };
}
if (llm.provider == .ollama) {
if (credential.provider == .ollama) {
const installed = std.mem.join(arena.allocator(), ", ", ids) catch "";
std.debug.print(
"Model '{s}' is not installed in Ollama.\nInstalled: {s}\nRun `ollama pull {s}` to install it, or choose one of the above.\n",
@@ -331,7 +423,7 @@ pub fn reconcileModel(
} else {
std.debug.print(
"Model '{s}' is not available for {s}.\nRun with --list-models to see options.\n",
.{ desired, @tagName(llm.provider) },
.{ desired, @tagName(credential.provider) },
);
}
return .abort;
@@ -378,3 +470,10 @@ test "resolveStream: default on, remembered wins" {
try testing.expect(resolveStream(.{ .model = "m", .stream = true }));
try testing.expect(!resolveStream(.{ .model = "m", .stream = false }));
}
test {
// Pull the auth module tests into the suite (a `const` import alone doesn't).
_ = @import("auth/auth.zig");
_ = @import("auth/codex.zig");
_ = @import("auth/models_dev.zig");
}