177 lines
4.8 KiB
TypeScript
177 lines
4.8 KiB
TypeScript
// The agent and model a profile runs, which every report names.
|
|
export interface ProfileIdentity {
|
|
type: "CLAUDE" | "CODEX" | "OPENCODE";
|
|
model: string;
|
|
}
|
|
|
|
export interface BotProfile extends ProfileIdentity {
|
|
token?: string;
|
|
}
|
|
|
|
// The lowercase name a profile type reads as in posted comments.
|
|
function profileName(type: ProfileIdentity["type"]): string {
|
|
switch (type) {
|
|
case "CLAUDE":
|
|
return "claude";
|
|
case "CODEX":
|
|
return "codex";
|
|
case "OPENCODE":
|
|
return "opencode";
|
|
}
|
|
}
|
|
|
|
export function profileFooter(profile: ProfileIdentity): string {
|
|
return `> Profile: \`${profileName(profile.type)}\` · \`${profile.model}\``;
|
|
}
|
|
|
|
export function withProfile(body: string, profile: ProfileIdentity): string {
|
|
return `${body}\n\n${profileFooter(profile)}`;
|
|
}
|
|
|
|
export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED";
|
|
|
|
// The words a failure reason reads as in logs and posted comments.
|
|
function toMessage(reason: FailureReason): string {
|
|
switch (reason) {
|
|
case "SIGN_IN_FAILED":
|
|
return "sign-in failed";
|
|
case "USAGE_EXHAUSTED":
|
|
return "usage exhausted";
|
|
case "FAILED":
|
|
return "failed";
|
|
}
|
|
}
|
|
|
|
export interface ProfileFailure extends ProfileIdentity {
|
|
reason: FailureReason;
|
|
}
|
|
|
|
// Where a profile's isolated user keeps its home.
|
|
interface ProfileHome {
|
|
type: ProfileIdentity["type"];
|
|
home: string;
|
|
}
|
|
|
|
// Raised once an agent's credentials are rejected and any re-login is spent.
|
|
export class AgentAuthError extends Error {
|
|
constructor(message: string, options?: ErrorOptions) {
|
|
super(message, options);
|
|
this.name = "AgentAuthError";
|
|
}
|
|
}
|
|
|
|
function isUsageExhausted(error: unknown): boolean {
|
|
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
|
|
String(error),
|
|
);
|
|
}
|
|
|
|
function failureReason(error: unknown): FailureReason {
|
|
if (error instanceof AgentAuthError) {
|
|
return "SIGN_IN_FAILED";
|
|
}
|
|
return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED";
|
|
}
|
|
|
|
export function failureFooter(failures: ProfileFailure[]): string {
|
|
if (failures.length === 0) {
|
|
return "> Profile: none";
|
|
}
|
|
return `> Tried: ${failures
|
|
.map(
|
|
({ type, model, reason }) => `\`${profileName(type)}\` · \`${model}\` (${toMessage(reason)})`,
|
|
)
|
|
.join(", ")}`;
|
|
}
|
|
|
|
// Tries the profiles in order, moving on only after a sign-in failure or
|
|
// exhausted usage, and records every profile it tried.
|
|
export async function runProfiles<T, P extends ProfileIdentity>(
|
|
profiles: P[],
|
|
attempt: (profile: P) => Promise<T>,
|
|
failures: ProfileFailure[] = [],
|
|
): Promise<T> {
|
|
for (const [index, profile] of profiles.entries()) {
|
|
try {
|
|
return await attempt(profile);
|
|
} catch (error) {
|
|
const reason = failureReason(error);
|
|
failures.push({ type: profile.type, model: profile.model, reason });
|
|
if (reason === "FAILED" || index === profiles.length - 1) {
|
|
throw error;
|
|
}
|
|
console.error(`${profile.type} ${toMessage(reason)}; trying next profile`);
|
|
}
|
|
}
|
|
throw new Error("profiles must contain at least one profile");
|
|
}
|
|
|
|
// Environments are maps from variable name to value, converted to the object
|
|
// form only where a child process is spawned.
|
|
export function sanitizedEnvironment(
|
|
environment: ReadonlyMap<string, string>,
|
|
): Map<string, string> {
|
|
const result = new Map<string, string>();
|
|
for (const name of [
|
|
"PATH",
|
|
"HOME",
|
|
"LANG",
|
|
"LANGUAGE",
|
|
"LC_ALL",
|
|
"TERM",
|
|
"COLORTERM",
|
|
"CI",
|
|
"TMPDIR",
|
|
"GITEA_TOKEN",
|
|
"GITEA_API_URL",
|
|
"GITEA_REPOSITORY",
|
|
"GITHUB_WORKSPACE",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"ALL_PROXY",
|
|
"NO_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"all_proxy",
|
|
"no_proxy",
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
"NODE_EXTRA_CA_CERTS",
|
|
]) {
|
|
const value = environment.get(name);
|
|
if (value !== undefined) {
|
|
result.set(name, value);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The environment variable each agent CLI reads its credential from.
|
|
function tokenVariable(type: ProfileIdentity["type"]): string {
|
|
switch (type) {
|
|
case "CLAUDE":
|
|
return "CLAUDE_CODE_OAUTH_TOKEN";
|
|
case "CODEX":
|
|
return "OPENAI_API_KEY";
|
|
case "OPENCODE":
|
|
return "OPENROUTER_API_KEY";
|
|
}
|
|
}
|
|
|
|
export function agentEnvironment(
|
|
environment: ReadonlyMap<string, string>,
|
|
profile: ProfileHome,
|
|
token?: string,
|
|
): Map<string, string> {
|
|
const result = sanitizedEnvironment(environment);
|
|
result.set("HOME", profile.home);
|
|
if (profile.type === "CODEX") {
|
|
result.set("CODEX_HOME", `${profile.home}/.codex`);
|
|
}
|
|
if (token !== undefined && token !== "") {
|
|
const tokenName = tokenVariable(profile.type);
|
|
result.set(tokenName, token);
|
|
}
|
|
return result;
|
|
}
|