agent: pivot subscription auth from Claude Code to Codex

Replace the Anthropic Claude-Code-import flow with OpenAI Codex (ChatGPT
subscription) OAuth: device-code login, a real token store (auth.json,
0600) with refresh-grant, and JWT ChatGPT-Account-Id extraction. Codex is
now a distinct zenai provider, so the bearer-mode credential collapses to
provider==.codex; account_id threads to the client via InitOptions.

Unit-tested (build green): JWT account-id extraction, token store
round-trip, OAuth request-body encoding, token-response parsing. The
device-login / refresh network path is unverified pending a subscription.

Pins zenai to the codex branch (6967324).
This commit is contained in:
Adrià Arrufat committed 2026-07-27 11:24:01 +02:00
1 parent 41cd1cd734
commit e99a5ab99b
6 files changed
+530 -177

No files matched your search

+2 -2
View File
@@ -36,8 +36,8 @@
.hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz",
},
.zenai = .{
.url = "git+https://github.com/lightpanda-io/zenai.git#fce390119e2a4bb5d33f55579c6f6a79dfb245f6",
.hash = "zenai-0.0.0-iOY_VHzrBQBkTrBexUzTVhDNWGLNA7Vh2eoPuMnlUVZS",
.url = "git+https://github.com/lightpanda-io/zenai.git?ref=codex#69673248321adeee537dfedcf9c13eed18928c30",
.hash = "zenai-0.0.0-iOY_VP0GBgCClHsRHVmHvSVfYVT33yty_Lb-Qafpb2Gm",
},
.isocline = .{
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",
+26 -30
View File
@@ -364,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(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 (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(), .account_id = if (self.auth_session) |s| s.tokens.account_id else null }) else null;
errdefer if (self.ai_client) |c| c.deinit(allocator);
if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt);
@@ -936,27 +936,25 @@ fn handleProvider(self: *Agent, _: std.mem.Allocator, rest: []const u8) void {
};
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;
}
// Subscription provider: use the stored session, or run the interactive
// login (device-code flow) when there's none yet.
if (auth.descriptorFor(provider)) |desc| {
var owned = (auth.sessionFor(self.allocator, provider) catch null) orelse
(auth.login(self.allocator, desc) catch |err| {
self.terminal.printError("{s} login failed: {s}", .{ desc.label, @errorName(err) });
return;
});
self.setProvider(.{ .provider = provider, .key = owned.tokens.access_token }, 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;
};
@@ -1000,7 +998,7 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 {
/// 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() });
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(), .account_id = if (session) |s| s.tokens.account_id else null });
errdefer new_client.deinit(self.allocator);
// A same-provider re-select (vertex token refresh) must not reset the model.
@@ -1018,9 +1016,8 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8,
self.model_completions = null;
self.allocator.free(self.model);
self.model = new_model;
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 });
if (auth.descriptorFor(credentials.provider)) |d| {
self.terminal.printInfo("provider: {s} ({s})", .{ @tagName(credentials.provider), d.label });
} else {
self.terminal.printInfo("provider: {s}", .{@tagName(credentials.provider)});
}
@@ -1029,9 +1026,9 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8,
self.terminal.printInfo("effort: {s} ({s} default)", .{ @tagName(e), @tagName(credentials.provider) });
};
self.reportSaved("model", self.model);
// Priming warms the completion cache; skip it for bearer, whose catalog is a
// multi-MB models.dev download best deferred to first `/model` use.
if (credentials.auth != .bearer) _ = completionModels(self, self.allocator);
// Priming warms the completion cache; skip it for a subscription provider,
// whose catalog is a multi-MB models.dev download best deferred to first use.
if (auth.descriptorFor(credentials.provider) == null) _ = completionModels(self, self.allocator);
}
/// Keep a subscription (bearer) token current before a model request: when the
@@ -1042,7 +1039,7 @@ fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8,
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)});
self.terminal.printError("could not refresh the subscription token: {s}", .{@errorName(err)});
return;
};
if (new_token) |tok| {
@@ -1907,9 +1904,9 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void {
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
// A subscription (bearer) token can't list models via the provider; use the
// A subscription provider can't list models via the provider API; use the
// free models.dev catalog (uncached here — this is a one-shot CLI command).
if (llm.auth == .bearer) {
if (auth.descriptorFor(llm.provider) != null) {
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;
@@ -1974,10 +1971,9 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u
_ = self.model_completion_arena.reset(.retain_capacity);
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)
// 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(llm.provider) != null)
models_dev.modelIds(arena, @tagName(llm.provider), self.app_dir)
else
zenai.provider.listChatModelIds(
-64
View File
@@ -1,64 +0,0 @@
// 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/>.
//! 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);
}
+179 -69
View File
@@ -16,72 +16,178 @@
// 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 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.
//! Provider-agnostic subscription (OAuth) auth for the agent. A `Descriptor`
//! carries the endpoints plus `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 Config = lp.Config;
const anthropic = @import("anthropic.zig");
const codex = @import("codex.zig");
/// Wall-clock ms since the Unix epoch.
pub fn nowMs() i64 {
return std.Io.Clock.now(.real, lp.io).toMilliseconds();
}
/// An OAuth credential set. `refresh_token`/`expires_at_ms` back expiry handling
/// (and the deferred refresh grant).
/// Resolve the app data dir (mirrors `App.getAppDataDir("lightpanda")`) into
/// `arena`. Null when neither XDG_DATA_HOME nor HOME is set.
fn dataDir(arena: std.mem.Allocator) ?[]const u8 {
if (std.c.getenv("XDG_DATA_HOME")) |xdg| {
const x = std.mem.span(xdg);
if (x.len > 0) return std.fs.path.join(arena, &.{ x, "lightpanda" }) catch null;
}
const home = std.c.getenv("HOME") orelse return null;
return std.fs.path.join(arena, &.{ std.mem.span(home), ".local", "share", "lightpanda" }) catch null;
}
/// 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) !TokenSet {
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);
return .{ .access_token = a, .refresh_token = r, .expires_at_ms = expires_at_ms };
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);
}
};
/// 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.
/// Static per-provider OAuth configuration plus the login/refresh implementations.
pub const Descriptor = struct {
provider: Config.AiProvider,
/// Key in the on-disk store (`auth.json`).
id: []const u8,
/// Human label for the credential, e.g. "Claude subscription".
/// Human label for the credential, e.g. "ChatGPT 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,
/// OAuth token endpoint (code exchange + refresh grant).
token_url: []const u8,
/// Device-authorization endpoints and the URL the user visits to enter the code.
device_code_url: []const u8,
device_token_url: []const u8,
verify_url: []const u8,
/// Interactive login (device-code flow); returns freshly-minted tokens.
loginFn: *const fn (std.mem.Allocator, *const Descriptor) anyerror!TokenSet,
/// Exchange a refresh token for a new `TokenSet` (with re-derived account id).
refreshFn: *const fn (std.mem.Allocator, *const Descriptor, []const u8) anyerror!TokenSet,
};
pub const registry = [_]*const Descriptor{&anthropic.descriptor};
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;
}
/// 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.
// --- On-disk token store: <data_dir>/auth.json, a JSON map keyed by descriptor id ---
// The `*At` variants take an explicit dir (unit-testable); the public wrappers
// resolve the app data dir themselves so callers need not thread it.
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) ![:0]const u8 {
return std.fs.path.joinZ(arena, &.{ dir, "auth.json" });
}
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 data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024)) catch return null;
const parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch return null;
const t = parsed.map.get(id) orelse return null;
return try TokenSet.dup(allocator, t.access, t.refresh, t.expires_at_ms, t.account_id);
}
fn storeSaveAt(dir: []const u8, id: []const u8, tokens: TokenSet) !void {
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer arena.deinit();
const a = arena.allocator();
const path = try storePath(a, dir);
var map: std.json.ArrayHashMap(StoredToken) = .{};
if (std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024))) |data| {
map = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch .{};
} else |_| {}
try map.map.put(a, id, .{
.access = tokens.access_token,
.refresh = tokens.refresh_token,
.expires_at_ms = tokens.expires_at_ms,
.account_id = tokens.account_id,
});
var buf: std.Io.Writer.Allocating = .init(a);
try std.json.Stringify.value(map, .{}, &buf.writer);
try std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() });
// Secrets file: owner read/write only.
_ = std.c.chmod(path.ptr, 0o600);
}
fn storeDeleteAt(dir: []const u8, id: []const u8) void {
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer arena.deinit();
const a = arena.allocator();
const path = storePath(a, dir) catch return;
const data = std.Io.Dir.cwd().readFileAlloc(lp.io, path, a, .limited(64 * 1024)) catch return;
var parsed = std.json.parseFromSliceLeaky(std.json.ArrayHashMap(StoredToken), a, data, .{ .ignore_unknown_fields = true }) catch return;
_ = parsed.map.swapRemove(id);
var buf: std.Io.Writer.Allocating = .init(a);
std.json.Stringify.value(parsed, .{}, &buf.writer) catch return;
std.Io.Dir.cwd().writeFile(lp.io, .{ .sub_path = path, .data = buf.written() }) catch return;
_ = std.c.chmod(path.ptr, 0o600);
}
/// 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(id: []const u8, tokens: TokenSet) !void {
var da: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer da.deinit();
const dir = dataDir(da.allocator()) orelse return error.NoDataDir;
return storeSaveAt(dir, id, tokens);
}
pub fn storeDelete(id: []const u8) void {
var da: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer da.deinit();
const dir = dataDir(da.allocator()) orelse return;
storeDeleteAt(dir, id);
}
/// 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`; the AI
/// client borrows `tokens.access_token`, so the session must outlive the client.
/// 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,
@@ -90,24 +196,21 @@ pub const Session = struct {
/// 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.
/// When the access token is within `refresh_skew_ms` of expiry, exchange the
/// refresh token for a new one, persist it, and return the new access token
/// (owned by the session). Null when still fresh. The caller must repoint its
/// client before its next request; the old buffer stays valid until the
/// following `ensureFresh`/`deinit`.
pub fn ensureFresh(self: *Session) !?[:0]const u8 {
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);
}
const fresh = self.descriptor.refreshFn(self.allocator, self.descriptor, self.tokens.refresh_token) catch |err| {
// A transient refresh failure while the token is still valid is not fatal.
if (self.tokens.expires_at_ms > now) return null;
return err;
};
storeSave(self.descriptor.id, fresh) catch {};
if (self.previous) |p| p.deinit(self.allocator);
self.previous = self.tokens;
@@ -115,10 +218,6 @@ pub const Session = struct {
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);
@@ -126,51 +225,62 @@ pub const Session = struct {
}
};
/// Build a bearer session for `provider` from an available subscription
/// credential, or null when none is importable.
/// 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 importFn = desc.importFn orelse return null;
const tokens = (try importFn(allocator)) orelse return null;
const tokens = (try storeLoad(allocator, desc.id)) 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;
/// Run the interactive login and persist the result, returning a live session.
pub fn login(allocator: std.mem.Allocator, desc: *const Descriptor) !Session {
const tokens = try desc.loginFn(allocator, desc);
storeSave(desc.id, tokens) catch {};
return .{ .allocator = allocator, .descriptor = desc, .tokens = tokens };
}
fn probeSubscription(provider: Config.AiProvider) bool {
/// Is a usable (present, not hard-expired) stored token available for `provider`?
/// Lets the picker offer the subscription without an API-key env var.
pub fn subscriptionAvailable(provider: Config.AiProvider) bool {
const desc = descriptorFor(provider) orelse return false;
const 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.
const tokens = (storeLoad(std.heap.page_allocator, desc.id) catch return false) orelse return false;
defer tokens.deinit(std.heap.page_allocator);
return tokens.expires_at_ms == 0 or tokens.expires_at_ms > nowMs();
}
test "TokenSet dup/deinit round-trips and is leak-free" {
const a = std.testing.allocator;
const t = try TokenSet.dup(a, "acc", "ref", 123);
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 anthropic, null for a non-OAuth provider" {
try std.testing.expect(descriptorFor(.anthropic) != null);
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 tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir = try tmp.dir.realPathFileAlloc(lp.io, ".", a);
defer a.free(dir);
const t = try TokenSet.dup(a, "acc-1", "ref-1", 999, "acct-9");
defer t.deinit(a);
try storeSaveAt(dir, "codex", t);
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);
storeDeleteAt(dir, "codex");
try std.testing.expectEqual(@as(?TokenSet, null), try storeLoadAt(a, dir, "codex"));
}
+304
View File
@@ -0,0 +1,304 @@
// 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.
//!
//! NETWORK-UNVERIFIED: the HTTP flows (`deviceLogin`/`refreshGrant`) cannot be
//! tested without a live subscription; the pure builders + JWT parsing below are
//! unit-tested. Verify the live handshake once a subscription is available.
const std = @import("std");
const lp = @import("lightpanda");
const 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 scope = "openid profile email offline_access";
const user_agent = "lightpanda";
const poll_margin_ms: u64 = 3000;
pub const descriptor: auth.Descriptor = .{
.provider = .codex,
.id = "codex",
.label = "ChatGPT subscription",
.client_id = client_id,
.scope = scope,
.token_url = token_url,
.device_code_url = device_code_url,
.device_token_url = device_token_url,
.verify_url = verify_url,
.loginFn = deviceLogin,
.refreshFn = refreshGrant,
};
// --- 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 {
var buf: std.Io.Writer.Allocating = .init(arena);
try buf.writer.writeAll("grant_type=refresh_token&client_id=" ++ client_id ++ "&refresh_token=");
try percentEncode(&buf.writer, refresh_token);
return buf.written();
}
fn exchangeBody(arena: std.mem.Allocator, code: []const u8, code_verifier: []const u8) ![]u8 {
var buf: std.Io.Writer.Allocating = .init(arena);
try buf.writer.writeAll("grant_type=authorization_code&client_id=" ++ client_id ++
"&redirect_uri=" ++ device_redirect_uri ++ "&code=");
try percentEncode(&buf.writer, code);
try buf.writer.writeAll("&code_verifier=");
try percentEncode(&buf.writer, code_verifier);
return buf.written();
}
/// Percent-encode a form value (RFC 3986 unreserved chars pass through).
fn percentEncode(w: *std.Io.Writer, value: []const u8) !void {
for (value) |c| {
if (std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~') {
try w.writeByte(c);
} else {
try w.print("%{X:0>2}", .{c});
}
}
}
// --- Device-code flow (network-unverified) ---
const DeviceCode = struct {
device_auth_id: []const u8,
user_code: []const u8,
interval: []const u8 = "5",
};
const DeviceToken = struct {
authorization_code: []const u8,
code_verifier: []const u8,
};
fn deviceLogin(allocator: std.mem.Allocator, desc: *const auth.Descriptor) !auth.TokenSet {
_ = desc;
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const a = arena.allocator();
const code_res = try post(a, device_code_url, "application/json", "{\"client_id\":\"" ++ client_id ++ "\"}");
if (code_res.status != .ok) return error.DeviceCodeRequestFailed;
const dc = try std.json.parseFromSliceLeaky(DeviceCode, a, code_res.body, .{ .ignore_unknown_fields = true });
const interval_ms: u64 = @as(u64, @intCast(std.fmt.parseInt(u32, dc.interval, 10) catch 5)) * std.time.ms_per_s;
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, "{{\"device_auth_id\":\"{s}\",\"user_code\":\"{s}\"}}", .{ dc.device_auth_id, dc.user_code });
const dt: DeviceToken = while (true) {
lp.io.sleep(.fromMilliseconds(@intCast(interval_ms + poll_margin_ms)), .awake) catch {};
const res = try post(a, 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 => return error.DeviceAuthFailed,
}
};
const exchange = try exchangeBody(a, dt.authorization_code, dt.code_verifier);
const tok_res = try post(a, token_url, "application/x-www-form-urlencoded", exchange);
if (tok_res.status != .ok) return error.TokenExchangeFailed;
return parseTokenResponse(allocator, tok_res.body);
}
fn refreshGrant(allocator: std.mem.Allocator, desc: *const auth.Descriptor, refresh_token: []const u8) !auth.TokenSet {
_ = desc;
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
const a = arena.allocator();
const body = try refreshBody(a, refresh_token);
const res = try post(a, token_url, "application/x-www-form-urlencoded", body);
if (res.status != .ok) return error.RefreshFailed;
return parseTokenResponse(allocator, res.body);
}
const PostResult = struct { status: std.http.Status, body: []u8 };
fn post(arena: std.mem.Allocator, url: []const u8, content_type: []const u8, body: []const u8) !PostResult {
var client: std.http.Client = .{ .allocator = arena, .io = lp.io };
defer client.deinit();
const uri = try std.Uri.parse(url);
var req = try client.request(.POST, uri, .{
.redirect_behavior = .unhandled,
.headers = .{ .content_type = .{ .override = content_type }, .user_agent = .{ .override = user_agent } },
});
defer req.deinit();
req.transfer_encoding = .{ .content_length = body.len };
var sink = try req.sendBodyUnflushed(&.{});
try sink.writer.writeAll(body);
try sink.end();
try req.connection.?.flush();
var redirect_buffer: [4096]u8 = undefined;
var response = try req.receiveHead(&redirect_buffer);
const decompress_buffer: []u8 = switch (response.head.content_encoding) {
.identity => &.{},
.zstd => try arena.alloc(u8, std.compress.zstd.default_window_len),
.deflate, .gzip => try arena.alloc(u8, std.compress.flate.max_window_len),
.compress => return error.UnsupportedCompression,
};
var transfer_buffer: [4096]u8 = undefined;
var decompress: std.http.Decompress = undefined;
const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer);
var out: std.Io.Writer.Allocating = .init(arena);
_ = reader.streamRemaining(&out.writer) catch |err| switch (err) {
error.ReadFailed => return response.bodyErr().?,
else => |e| return e,
};
return .{ .status = response.head.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());
}
+19 -12
View File
@@ -31,7 +31,7 @@ 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; Anthropic: a Claude subscription from Claude Code)";
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
@@ -188,24 +188,24 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme
}
/// 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.
/// project-mode Vertex, or a stored subscription session for the subscription
/// (empty-key) 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 (auth.descriptorFor(credentials.provider) != null 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
/// Load a stored subscription session and wrap it as a resolved 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.
/// long as the AI client and free it with `session.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
@@ -216,7 +216,7 @@ fn subscriptionResolved(allocator: std.mem.Allocator, provider: Config.AiProvide
std.debug.print("{s}: using your {s}.\n", .{ @tagName(provider), session.descriptor.label });
}
return .{
.credentials = .{ .provider = provider, .key = session.tokens.access_token, .auth = .bearer },
.credentials = .{ .provider = provider, .key = session.tokens.access_token },
.source = source,
.session = session,
};
@@ -279,7 +279,7 @@ pub fn availableProviders(buf: []Credentials) []Credentials {
// 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 };
const placeholder: Credentials = .{ .provider = desc.provider, .key = "" };
if (indexOfProvider(found, desc.provider)) |i| {
found[i] = placeholder;
} else if (found.len < buf.len) {
@@ -357,9 +357,9 @@ 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) };
// 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(llm.provider) != null) return .{ .use = try allocator.dupe(u8, desired) };
var arena: std.heap.ArenaAllocator = .init(allocator);
defer arena.deinit();
@@ -419,3 +419,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");
}