feat: auth print (#7168)
Some checks are pending
CI / build-check-test (push) Waiting to run

This commit is contained in:
Cristina Poncela Cubeiro 2026-07-27 16:13:06 +02:00 • committed by GitHub
parent 2efa728d2e
commit 99e34013d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 393 additions and 16 deletions

View file

@ -17,6 +17,8 @@ export type ModelsErrorCode = "model_source" | "model_validation" | "provider" |
export interface AuthResolutionOverrides {
apiKey?: string;
env?: ProviderEnv;
/** Require this much remaining OAuth-token validity; defaults to five minutes. */
minOAuthValidityMs?: number;
}
export class ModelsError extends Error {
@ -62,7 +64,13 @@ export async function resolveProviderAuth(
const stored = await readCredential(credentials, provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
return resolveStoredOAuth(credentials, provider.id, provider.auth.oauth, stored);
return resolveStoredOAuth(
credentials,
provider.id,
provider.auth.oauth,
stored,
overrides?.minOAuthValidityMs,
);
}
if (stored.type === "api_key" && provider.auth.apiKey) {
const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored;
@ -84,27 +92,31 @@ function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext
};
}
const DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000;
/**
* OAuth resolution with double-checked locking (same pattern as today's
* AuthStorage): valid tokens cost zero locks; expired tokens lock, re-check
* expiry under the lock, refresh once globally, and persist the rotated
* credential before release.
* OAuth resolution with double-checked locking: tokens with less than five
* minutes remaining lock, re-check expiry under the lock, refresh once
* globally, and persist the rotated credential before release.
*/
async function resolveStoredOAuth(
credentials: CredentialStore,
providerId: string,
oauth: OAuthAuth,
stored: OAuthCredential,
minOAuthValidityMs?: number,
): Promise<AuthResult | undefined> {
const minimumValidityMs = Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS, minOAuthValidityMs ?? 0);
const expiresSoon = (credential: OAuthCredential) => Date.now() + minimumValidityMs >= credential.expires;
let credential = stored;
if (Date.now() >= credential.expires) {
if (expiresSoon(credential)) {
// Optimistic check said expired; the authoritative check runs under the lock.
let post: Credential | undefined;
try {
post = await credentials.modify(providerId, async (current) => {
if (current?.type !== "oauth") return undefined; // logged out meanwhile
if (Date.now() < current.expires) return undefined; // another process/request refreshed
if (!expiresSoon(current)) return undefined; // another process/request refreshed
try {
return await oauth.refresh(current);
} catch (error) {
@ -117,6 +129,12 @@ async function resolveStoredOAuth(
}
if (post?.type !== "oauth") return undefined; // logged out meanwhile
credential = post;
// The normal five-minute window triggers a refresh but does not impose a
// provider contract. Explicit callers (such as bearer-token export) do
// require the requested minimum after the refresh.
if (minOAuthValidityMs !== undefined && expiresSoon(credential)) {
throw new ModelsError("oauth", `OAuth refresh returned a token that expires too soon for ${providerId}`);
}
}
try {

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts";
import { calculateCost, createModels, createProvider, hasApi, type Provider } from "../src/models.ts";
@ -382,7 +382,7 @@ describe("Models runtime", () => {
type: "oauth",
access: "oauth-token",
refresh: "r",
expires: Date.now() + 100000,
expires: Date.now() + 10 * 60_000,
}));
const resolution = await models.getAuth(model.provider);
expect(resolution?.auth.apiKey).toBe("oauth-token");
@ -460,7 +460,7 @@ describe("Models runtime", () => {
it("refreshes expired oauth credentials and persists the rotated credential", async () => {
const credentials = new InMemoryCredentialStore();
const oauth = testOAuth({
refresh: async (credential) => ({ ...credential, access: "new-token", expires: Date.now() + 60_000 }),
refresh: async (credential) => ({ ...credential, access: "new-token", expires: Date.now() + 60 * 60_000 }),
});
const models = createModels({ credentials });
models.setProvider(testProvider({ id: "p1", auth: { oauth } }));
@ -476,6 +476,46 @@ describe("Models runtime", () => {
expect(((await credentials.read("p1")) as { access: string }).access).toBe("new-token");
});
it("refreshes oauth credentials with less than five minutes remaining", async () => {
const credentials = new InMemoryCredentialStore();
const refresh = vi.fn(async (credential) => ({
...credential,
access: "new-token",
expires: Date.now() + 60 * 60_000,
}));
const models = createModels({ credentials });
models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth({ refresh }) } }));
await credentials.modify("p1", async () => ({
type: "oauth",
access: "old-token",
refresh: "r",
expires: Date.now() + 60_000,
}));
expect((await models.getAuth("p1"))?.auth.apiKey).toBe("new-token");
expect(refresh).toHaveBeenCalledOnce();
});
it("honors a caller's longer OAuth minimum validity", async () => {
const credentials = new InMemoryCredentialStore();
const refresh = vi.fn(async (credential) => ({
...credential,
access: "new-token",
expires: Date.now() + 60 * 60_000,
}));
const models = createModels({ credentials });
models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth({ refresh }) } }));
await credentials.modify("p1", async () => ({
type: "oauth",
access: "old-token",
refresh: "r",
expires: Date.now() + 10 * 60_000,
}));
expect((await models.getAuth("p1", { minOAuthValidityMs: 30 * 60_000 }))?.auth.apiKey).toBe("new-token");
expect(refresh).toHaveBeenCalledOnce();
});
it("rejects with code oauth when refresh fails, preserving the stored credential", async () => {
const credentials = new InMemoryCredentialStore();
const oauth = testOAuth({
@ -501,7 +541,7 @@ describe("Models runtime", () => {
refresh: async () => {
refreshes++;
await new Promise((resolve) => setTimeout(resolve, 10));
return { type: "oauth", access: `new-${refreshes}`, refresh: "r2", expires: Date.now() + 60_000 };
return { type: "oauth", access: `new-${refreshes}`, refresh: "r2", expires: Date.now() + 60 * 60_000 };
},
});
const models = createModels({ credentials });
@ -530,7 +570,7 @@ describe("Models runtime", () => {
type: "oauth",
access: "valid",
refresh: "r",
expires: Date.now() + 60_000,
expires: Date.now() + 10 * 60_000,
}));
const models = createModels({ credentials });
models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } }));

View file

@ -117,7 +117,8 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
type: "oauth",
access: "oauth-access-token",
refresh: "r",
expires: Date.now() + 60_000,
// Keep this beyond getAuth()'s refresh window.
expires: Date.now() + 10 * 60_000,
}));
const models = createModels({ credentials });
models.setProvider(anthropicProvider());
@ -135,7 +136,8 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
type: "oauth",
access,
refresh: "r",
expires: Date.now() + 60_000,
// Keep this beyond getAuth()'s refresh window.
expires: Date.now() + 10 * 60_000,
}));
const models = createModels({ credentials });
models.setProvider(githubCopilotProvider());

View file

@ -232,7 +232,8 @@ ${chalk.bold("Commands:")}
${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs
${APP_NAME} list List installed extensions from settings
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config
${APP_NAME} auth <command> Print credentials for external clients
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config/auth
${chalk.bold("Options:")}
--provider <name> Provider name (default: google)
@ -280,6 +281,12 @@ ${chalk.bold("Options:")}
Extensions can register additional flags (e.g., --plan from plan-mode extension).${extensionFlagsText}
${chalk.bold("Examples:")}
# Print a provider API key for an external client
${APP_NAME} auth print-api-key --provider openai --model gpt-5.5
# Print an OAuth bearer token for an external client (refreshes if expired)
${APP_NAME} auth print-bearer-token --provider openai-codex --model gpt-5.5
# Interactive mode
${APP_NAME}

View file

@ -0,0 +1,152 @@
import type { Api, CredentialInfo, Model } from "@earendil-works/pi-ai";
import { resolveCliModel } from "../core/model-resolver.ts";
import type { ModelRuntime } from "../core/model-runtime.ts";
import type { Args } from "./args.ts";
export type CredentialPrintKind = "api_key" | "bearer_token";
const DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000;
export interface CredentialPrintCommand {
kind: CredentialPrintKind;
args: string[];
minExpiryMs?: number;
}
export class CredentialPrintError extends Error {}
export function isCredentialPrintHelp(args: string[]): boolean {
return (
args[0] === "auth" && (args[1] === undefined || args[1] === "help" || args[1] === "--help" || args[1] === "-h")
);
}
export function printCredentialPrintHelp(): void {
console.log(`Usage:
pi auth print-api-key --model <model> [--provider <provider>]
pi auth print-bearer-token --model <model> [--provider <provider>] [--min-expiry <duration>]
Prints the configured credential alone on stdout. Provider inference uses configured credentials; specify --provider to select explicitly. Bearer tokens have a 30-minute minimum expiry by default. --min-expiry accepts ms, s, m, or h (for example, 30m).`);
}
/** Parse the small, extensible `pi auth` command surface before normal startup. */
export function parseCredentialPrintCommand(args: string[]): CredentialPrintCommand | undefined {
if (args[0] !== "auth") return undefined;
const kind = args[1] === "print-api-key" ? "api_key" : args[1] === "print-bearer-token" ? "bearer_token" : undefined;
if (!kind) {
throw new CredentialPrintError(
`Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key" or "pi auth print-bearer-token".`,
);
}
const commandArgs: string[] = [];
let minExpiryMs: number | undefined;
for (let index = 2; index < args.length; index++) {
if (args[index] !== "--min-expiry") {
commandArgs.push(args[index]);
continue;
}
if (kind !== "bearer_token") {
throw new CredentialPrintError("--min-expiry is only supported by print-bearer-token");
}
const value = args[++index];
const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined;
if (!match) {
throw new CredentialPrintError("--min-expiry must use a duration such as 30m or 1h");
}
const amount = Number(match[1]);
const unit = match[2];
minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000);
}
return minExpiryMs === undefined ? { kind, args: commandArgs } : { kind, args: commandArgs, minExpiryMs };
}
export function validateCredentialPrintArgs(args: Args): void {
if (!args.model?.trim()) {
throw new CredentialPrintError("Credential printing requires --model <model>");
}
if (args.apiKey !== undefined) {
throw new CredentialPrintError("Credential printing reads configured credentials; --api-key is not supported");
}
if (args.messages.length > 0 || args.fileArgs.length > 0 || args.unknownFlags.size > 0) {
throw new CredentialPrintError("Credential printing only accepts --provider and --model");
}
}
/**
* Resolve one request credential for a specific provider/model pair.
*
* This intentionally calls ModelRuntime.getAuth(), which refreshes and persists
* OAuth credentials with less than five minutes remaining through the normal request-auth path.
*/
export async function resolveCredentialForPrint(
args: Args,
modelRuntime: ModelRuntime,
kind: CredentialPrintKind,
minExpiryMs?: number,
): Promise<string> {
validateCredentialPrintArgs(args);
const credentialTypes = new Map<string, CredentialInfo["type"]>(
(await modelRuntime.listCredentials()).map((credential) => [credential.providerId, credential.type]),
);
const models: Model<Api>[] = [];
if (args.provider) {
const resolved = resolveCliModel({ cliProvider: args.provider, cliModel: args.model, modelRuntime });
if (resolved.error || !resolved.model) {
throw new CredentialPrintError(resolved.error ?? "Unable to resolve the requested provider/model");
}
models.push(resolved.model);
} else {
for (const provider of modelRuntime.getProviders()) {
if (!credentialTypes.has(provider.id)) continue;
const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: args.model, modelRuntime });
if (resolved.model && !resolved.error && !resolved.warning?.includes("Using custom model id")) {
models.push(resolved.model);
}
}
if (models.length === 0) {
throw new CredentialPrintError(`Model "${args.model}" not found. Use --list-models to see available models.`);
}
}
const credentials: Array<{ providerId: string; value: string }> = [];
for (const model of models) {
const type = credentialTypes.get(model.provider);
if (kind === "api_key" && type === "oauth") continue;
if (kind === "bearer_token" && type !== "oauth") continue;
const auth = await modelRuntime.getAuth(
model,
kind === "bearer_token"
? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS }
: undefined,
);
const authorization = Object.entries(auth?.auth.headers ?? {}).find(
([name]) => name.toLowerCase() === "authorization",
)?.[1];
const bearerToken = typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined;
const value = kind === "bearer_token" ? (auth?.auth.apiKey ?? bearerToken) : auth?.auth.apiKey;
if (value) credentials.push({ providerId: model.provider, value });
}
if (credentials.length === 1) return credentials[0].value;
if (credentials.length === 0) {
const providerId = models[0]?.provider;
const type = providerId ? credentialTypes.get(providerId) : undefined;
if (args.provider && kind === "api_key" && type === "oauth") {
throw new CredentialPrintError(`Provider "${providerId}" is configured with OAuth, not an API key`);
}
if (args.provider && kind === "bearer_token" && type !== "oauth") {
throw new CredentialPrintError(`Provider "${providerId}" is not configured with an OAuth bearer token`);
}
throw new CredentialPrintError(
`No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`,
);
}
throw new CredentialPrintError(
`Model "${args.model}" has multiple configured providers (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`,
);
}

View file

@ -72,6 +72,8 @@ export interface CreateModelRuntimeOptions {
export interface ModelRuntimeAuthOverrides {
apiKey?: string;
env?: Record<string, string>;
/** Require this much remaining OAuth-token validity; defaults to five minutes. */
minOAuthValidityMs?: number;
}
function mergeHeaders(

View file

@ -9,6 +9,15 @@ import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import chalk from "chalk";
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts";
import {
type CredentialPrintCommand,
CredentialPrintError,
isCredentialPrintHelp,
parseCredentialPrintCommand,
printCredentialPrintHelp,
resolveCredentialForPrint,
validateCredentialPrintArgs,
} from "./cli/credential-print.ts";
import { processFileArguments } from "./cli/file-processor.ts";
import { buildInitialMessage } from "./cli/initial-message.ts";
import { listModels } from "./cli/list-models.ts";
@ -27,7 +36,7 @@ import { exportFromFile } from "./core/export-html/index.ts";
import type { InlineExtension } from "./core/extensions/types.ts";
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
import type { ModelRuntime } from "./core/model-runtime.ts";
import { ModelRuntime } from "./core/model-runtime.ts";
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
import type { CreateAgentSessionOptions } from "./core/sdk.ts";
@ -118,6 +127,45 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean {
return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined);
}
async function runCredentialPrintCommand(args: string[]): Promise<boolean> {
if (isCredentialPrintHelp(args)) {
printCredentialPrintHelp();
return true;
}
let command: CredentialPrintCommand | undefined;
try {
command = parseCredentialPrintCommand(args);
} catch (error) {
const message = error instanceof CredentialPrintError ? error.message : "Failed to parse auth command";
console.error(chalk.red(`Error: ${message}`));
process.exitCode = 1;
return true;
}
if (!command) return false;
const parsed = parseArgs(command.args);
if (parsed.diagnostics.length > 0) {
for (const diagnostic of parsed.diagnostics) {
console.error(chalk.red(`Error: ${diagnostic.message}`));
}
process.exitCode = 1;
return true;
}
try {
validateCredentialPrintArgs(parsed);
const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false });
const credential = await resolveCredentialForPrint(parsed, modelRuntime, command.kind, command.minExpiryMs);
process.stdout.write(`${credential}\n`);
} catch (error) {
const message = error instanceof CredentialPrintError ? error.message : "Failed to resolve credential";
console.error(chalk.red(`Error: ${message}`));
process.exitCode = 1;
}
return true;
}
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
@ -506,6 +554,10 @@ export async function main(args: string[], options?: MainOptions) {
return;
}
if (await runCredentialPrintCommand(args)) {
return;
}
const parsed = parseArgs(args);
if (parsed.diagnostics.length > 0) {
for (const d of parsed.diagnostics) {

View file

@ -0,0 +1,104 @@
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
import { describe, expect, test, vi } from "vitest";
import { parseArgs } from "../src/cli/args.ts";
import {
CredentialPrintError,
isCredentialPrintHelp,
parseCredentialPrintCommand,
resolveCredentialForPrint,
} from "../src/cli/credential-print.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRuntime } from "../src/core/model-runtime.ts";
async function createRuntime(credentials: AuthStorage): Promise<ModelRuntime> {
return ModelRuntime.create({
credentials,
modelsPath: null,
modelsStore: new InMemoryModelsStore(),
allowModelNetwork: false,
});
}
describe("credential print commands", () => {
test("prints a resolved API key", async () => {
const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-api-key" } }));
const args = parseArgs(["--model", "gpt-5.5"]);
await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key");
});
test("prints bearer tokens resolved from an Authorization header", async () => {
const runtime = await createRuntime(
AuthStorage.inMemory({
"kimi-coding": {
type: "oauth",
access: "header-test-token",
refresh: "test-refresh-token",
expires: Date.now() + 60 * 60 * 1000,
},
}),
);
const args = parseArgs(["--provider", "kimi-coding", "--model", "kimi-for-coding"]);
await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("header-test-token");
});
test("refreshes an expired OAuth token before printing it", async () => {
const storage = AuthStorage.inMemory({
"openai-codex": {
type: "oauth",
access: "old-test-token",
refresh: "test-refresh-token",
expires: 0,
},
});
const runtime = await createRuntime(storage);
const refresh = vi.fn(async () => ({
type: "oauth" as const,
access: "fresh-test-token",
refresh: "test-refresh-token",
expires: Date.now() + 60 * 60 * 1000,
}));
const oauth = runtime.getProvider("openai-codex")?.auth.oauth;
if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered");
oauth.refresh = refresh;
const args = parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]);
await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("fresh-test-token");
expect(refresh).toHaveBeenCalledOnce();
expect(await storage.read("openai-codex")).toMatchObject({ access: "fresh-test-token" });
});
test("parses credential commands and rejects invalid arguments or credential types", async () => {
const runtime = await createRuntime(
AuthStorage.inMemory({
"openai-codex": {
type: "oauth",
access: "test-token-not-to-be-printed",
refresh: "test-refresh-token",
expires: Date.now() + 60 * 60 * 1000,
},
}),
);
expect(parseCredentialPrintCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({
kind: "api_key",
args: ["--provider", "openai"],
});
expect(parseCredentialPrintCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" });
expect(parseCredentialPrintCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({
kind: "bearer_token",
args: [],
minExpiryMs: 30 * 60_000,
});
expect(() => parseCredentialPrintCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow(
"only supported by print-bearer-token",
);
expect(isCredentialPrintHelp(["auth", "--help"])).toBe(true);
expect(() => parseCredentialPrintCommand(["auth", "unknown"])).toThrow(CredentialPrintError);
await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow("requires --model");
await expect(
resolveCredentialForPrint(parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]), runtime, "api_key"),
).rejects.toThrow("configured with OAuth");
});
});