+167
@@ -0,0 +1,167 @@
|
||||
import { parse } from "jsr:@std/yaml@1";
|
||||
import { isAbsolute, relative, resolve } from "jsr:@std/path@1";
|
||||
|
||||
export type BotProfile = {
|
||||
type: "claude" | "codex";
|
||||
model: string;
|
||||
token?: string;
|
||||
tokenEnv?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-terra",
|
||||
};
|
||||
|
||||
export type ProfileFileSystem = {
|
||||
realPath(path: string): Promise<string>;
|
||||
stat(path: string): Promise<{ isFile: boolean }>;
|
||||
readTextFile(path: string): Promise<string>;
|
||||
};
|
||||
|
||||
const systemFileSystem: ProfileFileSystem = {
|
||||
realPath: Deno.realPath,
|
||||
async stat(path) {
|
||||
return { isFile: (await Deno.stat(path)).isFile };
|
||||
},
|
||||
readTextFile: Deno.readTextFile,
|
||||
};
|
||||
|
||||
export async function readProfilesFile(
|
||||
file: string,
|
||||
workspace: string,
|
||||
fileSystem: ProfileFileSystem = systemFileSystem,
|
||||
): Promise<string> {
|
||||
if (isAbsolute(file)) {
|
||||
throw new Error("profiles-file must be repository-relative");
|
||||
}
|
||||
const root = await fileSystem.realPath(workspace);
|
||||
const target = await fileSystem.realPath(resolve(root, file));
|
||||
const pathFromRoot = relative(root, target);
|
||||
if (
|
||||
pathFromRoot === "" || pathFromRoot === ".." ||
|
||||
pathFromRoot.startsWith("../") || pathFromRoot.startsWith("..\\") ||
|
||||
isAbsolute(pathFromRoot)
|
||||
) {
|
||||
throw new Error("profiles-file must stay inside the repository");
|
||||
}
|
||||
if (!(await fileSystem.stat(target)).isFile) {
|
||||
throw new Error("profiles-file must name a file");
|
||||
}
|
||||
return await fileSystem.readTextFile(target);
|
||||
}
|
||||
|
||||
export function parseProfiles(
|
||||
text: string,
|
||||
environment: Record<string, string>,
|
||||
): BotProfile[] {
|
||||
const value: unknown = parse(text);
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
return value.map((entry: unknown, index) => {
|
||||
const at = `profiles[${index}]`;
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
throw new Error(`${at}: expected a mapping`);
|
||||
}
|
||||
const allowed = new Set(["type", "model", "token-env"]);
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!allowed.has(key)) throw new Error(`${at}.${key}: unknown field`);
|
||||
}
|
||||
const { type, model, "token-env": tokenEnv } = entry as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (type !== "claude" && type !== "codex") {
|
||||
throw new Error(`${at}.type: expected "claude" or "codex"`);
|
||||
}
|
||||
if (model !== undefined && (typeof model !== "string" || model === "")) {
|
||||
throw new Error(`${at}.model: expected a model name`);
|
||||
}
|
||||
if (
|
||||
tokenEnv !== undefined &&
|
||||
(typeof tokenEnv !== "string" ||
|
||||
!/^BOT_PROFILE_[A-Z0-9_]+$/.test(tokenEnv))
|
||||
) {
|
||||
throw new Error(`${at}.token-env: must start with BOT_PROFILE_`);
|
||||
}
|
||||
if (type === "claude" && tokenEnv === undefined) {
|
||||
throw new Error(`${at}.token-env: required for Claude`);
|
||||
}
|
||||
const token = tokenEnv === undefined ? undefined : environment[tokenEnv];
|
||||
if (tokenEnv !== undefined && !token) {
|
||||
throw new Error(`${at}.token-env: ${tokenEnv} is not set`);
|
||||
}
|
||||
return {
|
||||
type,
|
||||
model: model ?? DEFAULT_MODELS[type],
|
||||
...(token && { token }),
|
||||
...(tokenEnv && { tokenEnv }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isUsageExhausted(error: unknown): boolean {
|
||||
return /(?:usage|rate) limit|hit your limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i
|
||||
.test(String(error));
|
||||
}
|
||||
|
||||
export async function runProfiles<T>(
|
||||
profiles: BotProfile[],
|
||||
attempt: (profile: BotProfile) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (const [index, profile] of profiles.entries()) {
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
if (!isUsageExhausted(error) || index === profiles.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
console.error(`${profile.type} usage exhausted; trying next profile`);
|
||||
}
|
||||
}
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
|
||||
export function sanitizedEnvironment(
|
||||
environment: Record<string, string>,
|
||||
profiles: BotProfile[],
|
||||
): Record<string, string> {
|
||||
const result = { ...environment };
|
||||
for (
|
||||
const name of [
|
||||
"PROFILES",
|
||||
"REVIEWER_TOKEN",
|
||||
"BOT_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
"CODEX_ACCESS_TOKEN",
|
||||
]
|
||||
) {
|
||||
delete result[name];
|
||||
}
|
||||
for (const name of Object.keys(result)) {
|
||||
if (name.startsWith("BOT_PROFILE_")) delete result[name];
|
||||
}
|
||||
for (const profile of profiles) {
|
||||
if (profile.tokenEnv) delete result[profile.tokenEnv];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function agentEnvironment(
|
||||
environment: Record<string, string>,
|
||||
profile: BotProfile,
|
||||
profiles: BotProfile[],
|
||||
): Record<string, string> {
|
||||
const result = sanitizedEnvironment(environment, profiles);
|
||||
if (profile.token) {
|
||||
result[
|
||||
profile.type === "claude" ? "CLAUDE_CODE_OAUTH_TOKEN" : "OPENAI_API_KEY"
|
||||
] = profile.token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user