mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat: auth print (#7168)
Some checks are pending
CI / build-check-test (push) Waiting to run
Some checks are pending
CI / build-check-test (push) Waiting to run
This commit is contained in:
parent
2efa728d2e
commit
99e34013d1
8 changed files with 393 additions and 16 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() } }));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
152
packages/coding-agent/src/cli/credential-print.ts
Normal file
152
packages/coding-agent/src/cli/credential-print.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
104
packages/coding-agent/test/credential-print.test.ts
Normal file
104
packages/coding-agent/test/credential-print.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue