feat!: redesign model config + auth profiles
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
|
||||
const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent");
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
|
||||
export function resolveClawdbotAgentDir(): string {
|
||||
const defaultAgentDir = path.join(resolveConfigDir(), "agent");
|
||||
const override =
|
||||
process.env.CLAWDBOT_AGENT_DIR?.trim() ||
|
||||
process.env.PI_CODING_AGENT_DIR?.trim() ||
|
||||
DEFAULT_AGENT_DIR;
|
||||
defaultAgentDir;
|
||||
return resolveUserPath(override);
|
||||
}
|
||||
|
||||
|
||||
42
src/agents/auth-profiles.test.ts
Normal file
42
src/agents/auth-profiles.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("prioritizes preferred profiles", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store,
|
||||
provider: "anthropic",
|
||||
preferredProfile: "anthropic:work",
|
||||
});
|
||||
expect(order[0]).toBe("anthropic:work");
|
||||
expect(order).toContain("anthropic:default");
|
||||
});
|
||||
|
||||
it("prioritizes last-good profile when no preferred override", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: { ...store, lastGood: { anthropic: "anthropic:work" } },
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order[0]).toBe("anthropic:work");
|
||||
});
|
||||
});
|
||||
314
src/agents/auth-profiles.ts
Normal file
314
src/agents/auth-profiles.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getOAuthApiKey,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveOAuthPath } from "../config/paths.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
|
||||
const AUTH_STORE_VERSION = 1;
|
||||
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||
const LEGACY_AUTH_FILENAME = "auth.json";
|
||||
|
||||
export type ApiKeyCredential = {
|
||||
type: "api_key";
|
||||
provider: string;
|
||||
key: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type OAuthCredential = OAuthCredentials & {
|
||||
type: "oauth";
|
||||
provider: OAuthProvider;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
|
||||
|
||||
export type AuthProfileStore = {
|
||||
version: number;
|
||||
profiles: Record<string, AuthProfileCredential>;
|
||||
lastGood?: Record<string, string>;
|
||||
};
|
||||
|
||||
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||
|
||||
function resolveAuthStorePath(): string {
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
return path.join(agentDir, AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
function resolveLegacyAuthStorePath(): string {
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
return path.join(agentDir, LEGACY_AUTH_FILENAME);
|
||||
}
|
||||
|
||||
function loadJsonFile(pathname: string): unknown {
|
||||
try {
|
||||
if (!fs.existsSync(pathname)) return undefined;
|
||||
const raw = fs.readFileSync(pathname, "utf8");
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveJsonFile(pathname: string, data: unknown) {
|
||||
const dir = path.dirname(pathname);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
fs.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
if ("profiles" in record) return null;
|
||||
const entries: LegacyAuthStore = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
entries[key] = {
|
||||
...typed,
|
||||
provider: typed.provider ?? (key as OAuthProvider),
|
||||
} as AuthProfileCredential;
|
||||
}
|
||||
return Object.keys(entries).length > 0 ? entries : null;
|
||||
}
|
||||
|
||||
function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
if (!record.profiles || typeof record.profiles !== "object") return null;
|
||||
const profiles = record.profiles as Record<string, unknown>;
|
||||
const normalized: Record<string, AuthProfileCredential> = {};
|
||||
for (const [key, value] of Object.entries(profiles)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
if (!typed.provider) continue;
|
||||
normalized[key] = typed as AuthProfileCredential;
|
||||
}
|
||||
return {
|
||||
version: Number(record.version ?? AUTH_STORE_VERSION),
|
||||
profiles: normalized,
|
||||
lastGood:
|
||||
record.lastGood && typeof record.lastGood === "object"
|
||||
? (record.lastGood as Record<string, string>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
if (!oauthRaw || typeof oauthRaw !== "object") return false;
|
||||
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
|
||||
let mutated = false;
|
||||
for (const [provider, creds] of Object.entries(oauthEntries)) {
|
||||
if (!creds || typeof creds !== "object") continue;
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) continue;
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: provider as OAuthProvider,
|
||||
...creds,
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
export function loadAuthProfileStore(): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) return asStore;
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
if (legacy) {
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
for (const [provider, cred] of Object.entries(legacy)) {
|
||||
const profileId = `${provider}:default`;
|
||||
store.profiles[profileId] = {
|
||||
...cred,
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
};
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) return asStore;
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
const store = legacy
|
||||
? {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: Object.fromEntries(
|
||||
Object.entries(legacy).map(([provider, cred]) => [
|
||||
`${provider}:default`,
|
||||
{ ...cred, provider: cred.provider ?? (provider as OAuthProvider) },
|
||||
]),
|
||||
),
|
||||
}
|
||||
: { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
|
||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||
const shouldWrite = legacy !== null || mergedOAuth;
|
||||
if (shouldWrite) {
|
||||
saveJsonFile(authPath, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(store: AuthProfileStore): void {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const payload = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: store.profiles,
|
||||
lastGood: store.lastGood ?? undefined,
|
||||
} satisfies AuthProfileStore;
|
||||
saveJsonFile(authPath, payload);
|
||||
}
|
||||
|
||||
export function upsertAuthProfile(params: {
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
}): void {
|
||||
const store = ensureAuthProfileStore();
|
||||
store.profiles[params.profileId] = params.credential;
|
||||
saveAuthProfileStore(store);
|
||||
}
|
||||
|
||||
export function listProfilesForProvider(
|
||||
store: AuthProfileStore,
|
||||
provider: string,
|
||||
): string[] {
|
||||
return Object.entries(store.profiles)
|
||||
.filter(([, cred]) => cred.provider === provider)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export function resolveAuthProfileOrder(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
preferredProfile?: string;
|
||||
}): string[] {
|
||||
const { cfg, store, provider, preferredProfile } = params;
|
||||
const configuredOrder = cfg?.auth?.order?.[provider] ?? [];
|
||||
const lastGood = store.lastGood?.[provider];
|
||||
const order =
|
||||
configuredOrder.length > 0
|
||||
? configuredOrder
|
||||
: listProfilesForProvider(store, provider);
|
||||
|
||||
const filtered = order.filter((profileId) => {
|
||||
const cred = store.profiles[profileId];
|
||||
return cred ? cred.provider === provider : true;
|
||||
});
|
||||
const deduped: string[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (!deduped.includes(entry)) deduped.push(entry);
|
||||
}
|
||||
if (preferredProfile && deduped.includes(preferredProfile)) {
|
||||
const rest = deduped.filter((entry) => entry !== preferredProfile);
|
||||
if (lastGood && rest.includes(lastGood)) {
|
||||
return [
|
||||
preferredProfile,
|
||||
lastGood,
|
||||
...rest.filter((entry) => entry !== lastGood),
|
||||
];
|
||||
}
|
||||
return [preferredProfile, ...rest];
|
||||
}
|
||||
if (lastGood && deduped.includes(lastGood)) {
|
||||
return [lastGood, ...deduped.filter((entry) => entry !== lastGood)];
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function resolveApiKeyForProfile(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
||||
const { cfg, store, profileId } = params;
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred) return null;
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||
|
||||
if (cred.type === "api_key") {
|
||||
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
|
||||
}
|
||||
|
||||
const oauthCreds: Record<string, OAuthCredentials> = {
|
||||
[cred.provider]: cred,
|
||||
};
|
||||
const result = await getOAuthApiKey(cred.provider, oauthCreds);
|
||||
if (!result) return null;
|
||||
store.profiles[profileId] = {
|
||||
...cred,
|
||||
...result.newCredentials,
|
||||
type: "oauth",
|
||||
};
|
||||
saveAuthProfileStore(store);
|
||||
return {
|
||||
apiKey: result.apiKey,
|
||||
provider: cred.provider,
|
||||
email: cred.email,
|
||||
};
|
||||
}
|
||||
|
||||
export function markAuthProfileGood(params: {
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
profileId: string;
|
||||
}): void {
|
||||
const { store, provider, profileId } = params;
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile || profile.provider !== provider) return;
|
||||
store.lastGood = { ...(store.lastGood ?? {}), [provider]: profileId };
|
||||
saveAuthProfileStore(store);
|
||||
}
|
||||
|
||||
export function resolveAuthStorePathForDisplay(): string {
|
||||
const pathname = resolveAuthStorePath();
|
||||
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
|
||||
}
|
||||
|
||||
export function resolveAuthProfileDisplayLabel(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
}): string {
|
||||
const { cfg, store, profileId } = params;
|
||||
const profile = store.profiles[profileId];
|
||||
const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
|
||||
const email = configEmail || profile?.email?.trim();
|
||||
if (email) return `${profileId} (${email})`;
|
||||
return profileId;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const oauthFixture = {
|
||||
@@ -13,12 +12,16 @@ const oauthFixture = {
|
||||
};
|
||||
|
||||
describe("getApiKeyForModel", () => {
|
||||
it("migrates legacy oauth.json into auth.json", async () => {
|
||||
it("migrates legacy oauth.json into auth-profiles.json", async () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
|
||||
|
||||
try {
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const oauthDir = path.join(tempDir, "credentials");
|
||||
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
|
||||
@@ -28,10 +31,6 @@ describe("getApiKeyForModel", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
|
||||
vi.resetModules();
|
||||
const { getApiKeyForModel } = await import("./model-auth.js");
|
||||
|
||||
@@ -41,18 +40,21 @@ describe("getApiKeyForModel", () => {
|
||||
api: "openai-codex-responses",
|
||||
} as Model<Api>;
|
||||
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
expect(apiKey).toBe(oauthFixture.access);
|
||||
const apiKey = await getApiKeyForModel({ model });
|
||||
expect(apiKey.apiKey).toBe(oauthFixture.access);
|
||||
|
||||
const authJson = await fs.readFile(
|
||||
path.join(agentDir, "auth.json"),
|
||||
const authProfiles = await fs.readFile(
|
||||
path.join(tempDir, "agent", "auth-profiles.json"),
|
||||
"utf8",
|
||||
);
|
||||
const authData = JSON.parse(authJson) as Record<string, unknown>;
|
||||
expect(authData["openai-codex"]).toMatchObject({
|
||||
type: "oauth",
|
||||
access: oauthFixture.access,
|
||||
refresh: oauthFixture.refresh,
|
||||
const authData = JSON.parse(authProfiles) as Record<string, unknown>;
|
||||
expect(authData.profiles).toMatchObject({
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: oauthFixture.access,
|
||||
refresh: oauthFixture.refresh,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
@@ -60,6 +62,16 @@ describe("getApiKeyForModel", () => {
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,179 +1,147 @@
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import {
|
||||
type Api,
|
||||
getEnvApiKey,
|
||||
getOAuthApiKey,
|
||||
type Model,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
export {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
const OAUTH_FILENAME = "oauth.json";
|
||||
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
|
||||
let oauthStorageConfigured = false;
|
||||
let oauthStorageMigrated = false;
|
||||
|
||||
type OAuthStorage = Record<string, OAuthCredentials>;
|
||||
|
||||
function resolveClawdbotOAuthPath(): string {
|
||||
const overrideDir =
|
||||
process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
|
||||
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
|
||||
export function getCustomProviderApiKey(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
provider: string,
|
||||
): string | undefined {
|
||||
const providers = cfg?.models?.providers ?? {};
|
||||
const entry = providers[provider] as ModelProviderConfig | undefined;
|
||||
const key = entry?.apiKey?.trim();
|
||||
return key || undefined;
|
||||
}
|
||||
|
||||
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
|
||||
if (!fsSync.existsSync(pathname)) return null;
|
||||
try {
|
||||
const content = fsSync.readFileSync(pathname, "utf8");
|
||||
const json = JSON.parse(content) as OAuthStorage;
|
||||
if (!json || typeof json !== "object") return null;
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function resolveApiKeyForProvider(params: {
|
||||
provider: string;
|
||||
cfg?: ClawdbotConfig;
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||
const { provider, cfg, profileId, preferredProfile } = params;
|
||||
const store = params.store ?? ensureAuthProfileStore();
|
||||
|
||||
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
|
||||
const entry = storage.anthropic as
|
||||
| {
|
||||
refresh?: string;
|
||||
refresh_token?: string;
|
||||
refreshToken?: string;
|
||||
access?: string;
|
||||
access_token?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!entry) return false;
|
||||
const refresh =
|
||||
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
|
||||
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
|
||||
return Boolean(refresh.trim() && access.trim());
|
||||
}
|
||||
|
||||
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
|
||||
const dir = path.dirname(pathname);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fsSync.writeFileSync(
|
||||
pathname,
|
||||
`${JSON.stringify(storage, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fsSync.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
function legacyOAuthPaths(): string[] {
|
||||
const paths: string[] = [];
|
||||
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (piOverride) {
|
||||
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
|
||||
}
|
||||
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
function importLegacyOAuthIfNeeded(destPath: string): void {
|
||||
if (fsSync.existsSync(destPath)) return;
|
||||
for (const legacyPath of legacyOAuthPaths()) {
|
||||
const storage = loadOAuthStorageAt(legacyPath);
|
||||
if (!storage || !hasAnthropicOAuth(storage)) continue;
|
||||
saveOAuthStorageAt(destPath, storage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureOAuthStorage(): void {
|
||||
if (oauthStorageConfigured) return;
|
||||
oauthStorageConfigured = true;
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
importLegacyOAuthIfNeeded(oauthPath);
|
||||
}
|
||||
|
||||
function isValidOAuthCredential(
|
||||
entry: OAuthCredentials | undefined,
|
||||
): entry is OAuthCredentials {
|
||||
if (!entry) return false;
|
||||
return Boolean(
|
||||
entry.access?.trim() &&
|
||||
entry.refresh?.trim() &&
|
||||
Number.isFinite(entry.expires),
|
||||
);
|
||||
}
|
||||
|
||||
function migrateOAuthStorageToAuthStorage(
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): void {
|
||||
if (oauthStorageMigrated) return;
|
||||
oauthStorageMigrated = true;
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
const storage = loadOAuthStorageAt(oauthPath);
|
||||
if (!storage) return;
|
||||
for (const [provider, creds] of Object.entries(storage)) {
|
||||
if (!isValidOAuthCredential(creds)) continue;
|
||||
if (authStorage.get(provider)) continue;
|
||||
authStorage.set(provider, { type: "oauth", ...creds });
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateAuthStorage(
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): void {
|
||||
ensureOAuthStorage();
|
||||
migrateOAuthStorageToAuthStorage(authStorage);
|
||||
}
|
||||
|
||||
function isOAuthProvider(provider: string): provider is OAuthProvider {
|
||||
return (
|
||||
provider === "anthropic" ||
|
||||
provider === "anthropic-oauth" ||
|
||||
provider === "google" ||
|
||||
provider === "openai" ||
|
||||
provider === "openai-compatible" ||
|
||||
provider === "openai-codex" ||
|
||||
provider === "github-copilot" ||
|
||||
provider === "google-gemini-cli" ||
|
||||
provider === "google-antigravity"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(
|
||||
model: Model<Api>,
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): Promise<string> {
|
||||
ensureOAuthStorage();
|
||||
migrateOAuthStorageToAuthStorage(authStorage);
|
||||
const storedKey = await authStorage.getApiKey(model.provider);
|
||||
if (storedKey) return storedKey;
|
||||
if (model.provider === "anthropic") {
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
if (oauthEnv?.trim()) return oauthEnv.trim();
|
||||
}
|
||||
const envKey = getEnvApiKey(model.provider);
|
||||
if (envKey) return envKey;
|
||||
if (isOAuthProvider(model.provider)) {
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
const storage = loadOAuthStorageAt(oauthPath);
|
||||
if (storage) {
|
||||
try {
|
||||
const result = await getOAuthApiKey(model.provider, storage);
|
||||
if (result?.apiKey) {
|
||||
storage[model.provider] = result.newCredentials;
|
||||
saveOAuthStorageAt(oauthPath, storage);
|
||||
return result.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
if (profileId) {
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
profileId,
|
||||
});
|
||||
if (!resolved) {
|
||||
throw new Error(`No credentials found for profile "${profileId}".`);
|
||||
}
|
||||
return {
|
||||
apiKey: resolved.apiKey,
|
||||
profileId,
|
||||
source: `profile:${profileId}`,
|
||||
};
|
||||
}
|
||||
throw new Error(`No API key found for provider "${model.provider}"`);
|
||||
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider,
|
||||
preferredProfile,
|
||||
});
|
||||
for (const candidate of order) {
|
||||
try {
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
profileId: candidate,
|
||||
});
|
||||
if (resolved) {
|
||||
return {
|
||||
apiKey: resolved.apiKey,
|
||||
profileId: candidate,
|
||||
source: `profile:${candidate}`,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const envResolved = resolveEnvApiKey(provider);
|
||||
if (envResolved) {
|
||||
return { apiKey: envResolved.apiKey, source: envResolved.source };
|
||||
}
|
||||
|
||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
||||
if (customKey) {
|
||||
return { apiKey: customKey, source: "models.json" };
|
||||
}
|
||||
|
||||
throw new Error(`No API key found for provider "${provider}".`);
|
||||
}
|
||||
|
||||
export type EnvApiKeyResult = { apiKey: string; source: string };
|
||||
|
||||
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
const pick = (envVar: string): EnvApiKeyResult | null => {
|
||||
const value = process.env[envVar]?.trim();
|
||||
if (!value) return null;
|
||||
const source = applied.has(envVar)
|
||||
? `shell env: ${envVar}`
|
||||
: `env: ${envVar}`;
|
||||
return { apiKey: value, source };
|
||||
};
|
||||
|
||||
if (provider === "github-copilot") {
|
||||
return (
|
||||
pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN")
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
if (provider === "google-vertex") {
|
||||
const envKey = getEnvApiKey(provider);
|
||||
if (!envKey) return null;
|
||||
return { apiKey: envKey, source: "gcloud adc" };
|
||||
}
|
||||
|
||||
const envMap: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
};
|
||||
const envVar = envMap[provider];
|
||||
if (!envVar) return null;
|
||||
return pick(envVar);
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(params: {
|
||||
model: Model<Api>;
|
||||
cfg?: ClawdbotConfig;
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||
return resolveApiKeyForProvider({
|
||||
provider: params.model.provider,
|
||||
cfg: params.cfg,
|
||||
profileId: params.profileId,
|
||||
preferredProfile: params.preferredProfile,
|
||||
store: params.store,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ function buildAllowedModelKeys(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
defaultProvider: string,
|
||||
): Set<string> | null {
|
||||
const rawAllowlist = cfg?.agent?.allowedModels ?? [];
|
||||
const rawAllowlist = (() => {
|
||||
const modelMap = cfg?.agent?.models ?? {};
|
||||
return Object.keys(modelMap);
|
||||
})();
|
||||
if (rawAllowlist.length === 0) return null;
|
||||
const keys = new Set<string>();
|
||||
for (const raw of rawAllowlist) {
|
||||
@@ -81,11 +84,28 @@ function resolveImageFallbackCandidates(params: {
|
||||
|
||||
if (params.modelOverride?.trim()) {
|
||||
addRaw(params.modelOverride, false);
|
||||
} else if (params.cfg?.agent?.imageModel?.trim()) {
|
||||
addRaw(params.cfg.agent.imageModel, false);
|
||||
} else {
|
||||
const imageModel = params.cfg?.agent?.imageModel as
|
||||
| { primary?: string }
|
||||
| string
|
||||
| undefined;
|
||||
const primary =
|
||||
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
|
||||
if (primary?.trim()) addRaw(primary, false);
|
||||
}
|
||||
|
||||
for (const raw of params.cfg?.agent?.imageModelFallbacks ?? []) {
|
||||
const imageFallbacks = (() => {
|
||||
const imageModel = params.cfg?.agent?.imageModel as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
if (imageModel && typeof imageModel === "object") {
|
||||
return imageModel.fallbacks ?? [];
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
for (const raw of imageFallbacks) {
|
||||
addRaw(raw, true);
|
||||
}
|
||||
|
||||
@@ -121,7 +141,16 @@ function resolveFallbackCandidates(params: {
|
||||
|
||||
addCandidate({ provider, model }, false);
|
||||
|
||||
for (const raw of params.cfg?.agent?.modelFallbacks ?? []) {
|
||||
const modelFallbacks = (() => {
|
||||
const model = params.cfg?.agent?.model as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
if (model && typeof model === "object") return model.fallbacks ?? [];
|
||||
return [];
|
||||
})();
|
||||
|
||||
for (const raw of modelFallbacks) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
@@ -224,7 +253,7 @@ export async function runWithImageModelFallback<T>(params: {
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
"No image model configured. Set agent.imageModel or agent.imageModelFallbacks.",
|
||||
"No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import { resolveConfiguredModelRef } from "./model-selection.js";
|
||||
|
||||
describe("resolveConfiguredModelRef", () => {
|
||||
it("parses provider/model from agent.model", () => {
|
||||
it("parses provider/model from agent.model.primary", () => {
|
||||
const cfg = {
|
||||
agent: { model: "openai/gpt-4.1-mini" },
|
||||
agent: { model: { primary: "openai/gpt-4.1-mini" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
@@ -19,9 +19,9 @@ describe("resolveConfiguredModelRef", () => {
|
||||
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
||||
});
|
||||
|
||||
it("falls back to anthropic when agent.model omits provider", () => {
|
||||
it("falls back to anthropic when agent.model.primary omits provider", () => {
|
||||
const cfg = {
|
||||
agent: { model: "claude-opus-4-5" },
|
||||
agent: { model: { primary: "claude-opus-4-5" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
@@ -54,9 +54,9 @@ describe("resolveConfiguredModelRef", () => {
|
||||
it("resolves agent.model aliases when configured", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "Opus",
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
model: { primary: "Opus" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
@@ -72,4 +72,18 @@ describe("resolveConfiguredModelRef", () => {
|
||||
model: "claude-opus-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("still resolves legacy agent.model string", () => {
|
||||
const cfg = {
|
||||
agent: { model: "openai/gpt-4.1-mini" },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,18 +41,17 @@ export function buildModelAliasIndex(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
defaultProvider: string;
|
||||
}): ModelAliasIndex {
|
||||
const rawAliases = params.cfg.agent?.modelAliases ?? {};
|
||||
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
|
||||
const byKey = new Map<string, string[]>();
|
||||
|
||||
for (const [aliasRaw, targetRaw] of Object.entries(rawAliases)) {
|
||||
const alias = aliasRaw.trim();
|
||||
if (!alias) continue;
|
||||
const parsed = parseModelRef(
|
||||
String(targetRaw ?? ""),
|
||||
params.defaultProvider,
|
||||
);
|
||||
const rawModels = params.cfg.agent?.models ?? {};
|
||||
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
|
||||
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
|
||||
if (!parsed) continue;
|
||||
const alias = String(
|
||||
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
|
||||
).trim();
|
||||
if (!alias) continue;
|
||||
const aliasKey = normalizeAliasKey(alias);
|
||||
byAlias.set(aliasKey, { alias, ref: parsed });
|
||||
const key = modelKey(parsed.provider, parsed.model);
|
||||
@@ -88,7 +87,14 @@ export function resolveConfiguredModelRef(params: {
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
}): ModelRef {
|
||||
const rawModel = params.cfg.agent?.model?.trim() || "";
|
||||
const rawModel = (() => {
|
||||
const raw = params.cfg.agent?.model as
|
||||
| { primary?: string }
|
||||
| string
|
||||
| undefined;
|
||||
if (typeof raw === "string") return raw.trim();
|
||||
return raw?.primary?.trim() ?? "";
|
||||
})();
|
||||
if (rawModel) {
|
||||
const trimmed = rawModel.trim();
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
@@ -116,7 +122,10 @@ export function buildAllowedModelSet(params: {
|
||||
allowedCatalog: ModelCatalogEntry[];
|
||||
allowedKeys: Set<string>;
|
||||
} {
|
||||
const rawAllowlist = params.cfg.agent?.allowedModels ?? [];
|
||||
const rawAllowlist = (() => {
|
||||
const modelMap = params.cfg.agent?.models ?? {};
|
||||
return Object.keys(modelMap);
|
||||
})();
|
||||
const allowAny = rawAllowlist.length === 0;
|
||||
const catalogKeys = new Set(
|
||||
params.catalog.map((entry) => modelKey(entry.provider, entry.id)),
|
||||
|
||||
@@ -120,12 +120,40 @@ export function isRateLimitAssistantError(
|
||||
if (!msg || msg.stopReason !== "error") return false;
|
||||
const raw = (msg.errorMessage ?? "").toLowerCase();
|
||||
if (!raw) return false;
|
||||
return isRateLimitErrorMessage(raw);
|
||||
}
|
||||
|
||||
export function isRateLimitErrorMessage(raw: string): boolean {
|
||||
const value = raw.toLowerCase();
|
||||
return (
|
||||
/rate[_ ]limit|too many requests|429/.test(raw) ||
|
||||
raw.includes("exceeded your current quota")
|
||||
/rate[_ ]limit|too many requests|429/.test(value) ||
|
||||
value.includes("exceeded your current quota")
|
||||
);
|
||||
}
|
||||
|
||||
export function isAuthErrorMessage(raw: string): boolean {
|
||||
const value = raw.toLowerCase();
|
||||
if (!value) return false;
|
||||
return (
|
||||
/invalid[_ ]?api[_ ]?key/.test(value) ||
|
||||
value.includes("incorrect api key") ||
|
||||
value.includes("invalid token") ||
|
||||
value.includes("authentication") ||
|
||||
value.includes("unauthorized") ||
|
||||
value.includes("forbidden") ||
|
||||
value.includes("access denied") ||
|
||||
/\b401\b/.test(value) ||
|
||||
/\b403\b/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAuthAssistantError(
|
||||
msg: AssistantMessage | undefined,
|
||||
): boolean {
|
||||
if (!msg || msg.stopReason !== "error") return false;
|
||||
return isAuthErrorMessage(msg.errorMessage ?? "");
|
||||
}
|
||||
|
||||
function extractSupportedValues(raw: string): string[] {
|
||||
const match =
|
||||
raw.match(/supported values are:\s*([^\n.]+)/i) ??
|
||||
|
||||
@@ -24,15 +24,23 @@ import {
|
||||
} from "../process/command-queue.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import { markAuthProfileGood } from "./auth-profiles.js";
|
||||
import type { BashElevatedDefaults } from "./bash-tools.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import { getApiKeyForModel } from "./model-auth.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getApiKeyForModel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
ensureSessionHeader,
|
||||
formatAssistantErrorText,
|
||||
isAuthAssistantError,
|
||||
isAuthErrorMessage,
|
||||
isRateLimitAssistantError,
|
||||
isRateLimitErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
sanitizeSessionMessagesImages,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
@@ -311,6 +319,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
authProfileId?: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
bashElevated?: BashElevatedDefaults;
|
||||
@@ -368,11 +377,67 @@ export async function runEmbeddedPiAgent(params: {
|
||||
if (!model) {
|
||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
|
||||
let thinkLevel = params.thinkLevel ?? "off";
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const explicitProfileId = params.authProfileId?.trim();
|
||||
const profileOrder = resolveAuthProfileOrder({
|
||||
cfg: params.config,
|
||||
store: authStore,
|
||||
provider,
|
||||
preferredProfile: explicitProfileId,
|
||||
});
|
||||
if (explicitProfileId && !profileOrder.includes(explicitProfileId)) {
|
||||
throw new Error(
|
||||
`Auth profile "${explicitProfileId}" is not configured for ${provider}.`,
|
||||
);
|
||||
}
|
||||
const profileCandidates =
|
||||
profileOrder.length > 0 ? profileOrder : [undefined];
|
||||
let profileIndex = 0;
|
||||
const initialThinkLevel = params.thinkLevel ?? "off";
|
||||
let thinkLevel = initialThinkLevel;
|
||||
const attemptedThinking = new Set<ThinkLevel>();
|
||||
let apiKeyInfo: Awaited<ReturnType<typeof getApiKeyForModel>> | null =
|
||||
null;
|
||||
|
||||
const resolveApiKeyForCandidate = async (candidate?: string) => {
|
||||
return getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.config,
|
||||
profileId: candidate,
|
||||
store: authStore,
|
||||
});
|
||||
};
|
||||
|
||||
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
||||
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
};
|
||||
|
||||
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||
let nextIndex = profileIndex + 1;
|
||||
while (nextIndex < profileCandidates.length) {
|
||||
const candidate = profileCandidates[nextIndex];
|
||||
try {
|
||||
await applyApiKeyInfo(candidate);
|
||||
profileIndex = nextIndex;
|
||||
thinkLevel = initialThinkLevel;
|
||||
attemptedThinking.clear();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (candidate && candidate === explicitProfileId) throw err;
|
||||
nextIndex += 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
await applyApiKeyInfo(profileCandidates[profileIndex]);
|
||||
} catch (err) {
|
||||
if (profileCandidates[profileIndex] === explicitProfileId) throw err;
|
||||
const advanced = await advanceAuthProfile();
|
||||
if (!advanced) throw err;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const thinkingLevel = mapThinkingLevel(thinkLevel);
|
||||
@@ -611,8 +676,16 @@ export async function runEmbeddedPiAgent(params: {
|
||||
params.abortSignal?.removeEventListener?.("abort", onAbort);
|
||||
}
|
||||
if (promptError && !aborted) {
|
||||
const errorText = describeUnknownError(promptError);
|
||||
if (
|
||||
(isAuthErrorMessage(errorText) ||
|
||||
isRateLimitErrorMessage(errorText)) &&
|
||||
(await advanceAuthProfile())
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const fallbackThinking = pickFallbackThinkingLevel({
|
||||
message: describeUnknownError(promptError),
|
||||
message: errorText,
|
||||
attempted: attemptedThinking,
|
||||
});
|
||||
if (fallbackThinking) {
|
||||
@@ -645,13 +718,25 @@ export async function runEmbeddedPiAgent(params: {
|
||||
}
|
||||
|
||||
const fallbackConfigured =
|
||||
(params.config?.agent?.modelFallbacks?.length ?? 0) > 0;
|
||||
if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) {
|
||||
const message =
|
||||
lastAssistant?.errorMessage?.trim() ||
|
||||
(lastAssistant ? formatAssistantErrorText(lastAssistant) : "") ||
|
||||
"LLM request rate limited.";
|
||||
throw new Error(message);
|
||||
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
|
||||
const authFailure = isAuthAssistantError(lastAssistant);
|
||||
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
||||
if (!aborted && (authFailure || rateLimitFailure)) {
|
||||
const rotated = await advanceAuthProfile();
|
||||
if (rotated) {
|
||||
continue;
|
||||
}
|
||||
if (fallbackConfigured) {
|
||||
const message =
|
||||
lastAssistant?.errorMessage?.trim() ||
|
||||
(lastAssistant
|
||||
? formatAssistantErrorText(lastAssistant)
|
||||
: "") ||
|
||||
(rateLimitFailure
|
||||
? "LLM request rate limited."
|
||||
: "LLM request unauthorized.");
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const usage = lastAssistant?.usage;
|
||||
@@ -717,6 +802,13 @@ export async function runEmbeddedPiAgent(params: {
|
||||
log.debug(
|
||||
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
|
||||
);
|
||||
if (apiKeyInfo?.profileId) {
|
||||
markAuthProfileGood({
|
||||
store: authStore,
|
||||
provider,
|
||||
profileId: apiKeyInfo.profileId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
payloads: payloads.length ? payloads : undefined,
|
||||
meta: {
|
||||
|
||||
@@ -24,9 +24,15 @@ import type { AnyAgentTool } from "./common.js";
|
||||
const DEFAULT_PROMPT = "Describe the image.";
|
||||
|
||||
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
|
||||
const primary = cfg?.agent?.imageModel?.trim();
|
||||
const fallbacks = cfg?.agent?.imageModelFallbacks ?? [];
|
||||
return Boolean(primary || fallbacks.length > 0);
|
||||
const imageModel = cfg?.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
const primary =
|
||||
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
|
||||
const fallbacks =
|
||||
typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : [];
|
||||
return Boolean(primary?.trim() || fallbacks.length > 0);
|
||||
}
|
||||
|
||||
function pickMaxBytes(
|
||||
@@ -95,15 +101,18 @@ async function runImagePrompt(params: {
|
||||
`Model does not support images: ${provider}/${modelId}`,
|
||||
);
|
||||
}
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
const context = buildImageContext(
|
||||
params.prompt,
|
||||
params.base64,
|
||||
params.mimeType,
|
||||
);
|
||||
const message = (await complete(model, context, {
|
||||
apiKey,
|
||||
apiKey: apiKeyInfo.apiKey,
|
||||
maxTokens: 512,
|
||||
temperature: 0,
|
||||
})) as AssistantMessage;
|
||||
|
||||
Reference in New Issue
Block a user