mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-14 23:15:14 -04:00
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:
1 parent
41cd1cd734
commit
e99a5ab99b
6 files changed
+530
-177
No files matched your search
+2
-2
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user